From 9ec2ae765f1c50242551beed4856bef4305b3498 Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Wed, 20 May 2026 11:16:56 +0530 Subject: [PATCH] fix(e2e): sync E2E specs with current codebase (#2220) Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Steven Enamakel --- app/scripts/e2e-merge-affected.sh | 47 +++++ app/scripts/e2e-run-all-flows.sh | 165 ++++++++++++++++-- app/scripts/e2e-run-session.sh | 35 +++- app/test/e2e/helpers/shared-flows.ts | 21 ++- app/test/e2e/specs/card-payment-flow.spec.ts | 81 +++++++++ .../conversations-web-channel-flow.spec.ts | 17 +- app/test/e2e/specs/cron-jobs-flow.spec.ts | 59 ++++--- .../e2e/specs/crypto-payment-flow.spec.ts | 63 +++++++ .../e2e/specs/local-model-runtime.spec.ts | 4 +- app/test/e2e/specs/login-flow.spec.ts | 124 +++---------- app/test/e2e/specs/notifications.spec.ts | 16 +- .../settings-channels-permissions.spec.ts | 21 +-- .../specs/settings-data-management.spec.ts | 17 +- .../settings-feature-preferences.spec.ts | 24 +-- .../e2e/specs/skill-execution-flow.spec.ts | 157 +++++++++++++++++ app/test/e2e/specs/skill-multi-round.spec.ts | 10 +- .../e2e/specs/skill-socket-reconnect.spec.ts | 9 +- app/test/e2e/specs/slack-flow.spec.ts | 10 +- app/test/e2e/specs/tauri-commands.spec.ts | 45 ++--- app/test/e2e/specs/telegram-flow.spec.ts | 112 +++++++----- app/test/e2e/specs/voice-mode.spec.ts | 145 ++++++++++----- .../e2e/specs/webhooks-ingress-flow.spec.ts | 64 +++---- .../e2e/specs/webhooks-tunnel-flow.spec.ts | 10 +- app/test/e2e/specs/whatsapp-flow.spec.ts | 10 +- src/openhuman/test_support/introspect.rs | 11 +- 25 files changed, 862 insertions(+), 415 deletions(-) create mode 100755 app/scripts/e2e-merge-affected.sh create mode 100644 app/test/e2e/specs/card-payment-flow.spec.ts create mode 100644 app/test/e2e/specs/crypto-payment-flow.spec.ts create mode 100644 app/test/e2e/specs/skill-execution-flow.spec.ts diff --git a/app/scripts/e2e-merge-affected.sh b/app/scripts/e2e-merge-affected.sh new file mode 100755 index 000000000..79f26b6ba --- /dev/null +++ b/app/scripts/e2e-merge-affected.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -uo pipefail +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$APP_DIR" || exit 1 + +_names=() +_results=() +run() { + local spec="$1" label="${2:-$1}" + _names+=("$label") + if timeout 600 "$APP_DIR/scripts/e2e-run-spec.sh" "$spec" "$label"; then + _results+=(0) + else + _results+=(1) + fi +} + +run "test/e2e/specs/card-payment-flow.spec.ts" "card-payment" +run "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment" +run "test/e2e/specs/skill-execution-flow.spec.ts" "skill-execution" +run "test/e2e/specs/cron-jobs-flow.spec.ts" "cron-jobs" +run "test/e2e/specs/notifications.spec.ts" "notifications" +run "test/e2e/specs/settings-channels-permissions.spec.ts" "settings-channels" +run "test/e2e/specs/settings-data-management.spec.ts" "settings-data" +run "test/e2e/specs/settings-feature-preferences.spec.ts" "settings-features" +run "test/e2e/specs/skill-multi-round.spec.ts" "skill-multi-round" +run "test/e2e/specs/skill-socket-reconnect.spec.ts" "skill-socket-reconnect" +run "test/e2e/specs/slack-flow.spec.ts" "slack" +run "test/e2e/specs/tauri-commands.spec.ts" "tauri-commands" +run "test/e2e/specs/telegram-flow.spec.ts" "telegram" +run "test/e2e/specs/voice-mode.spec.ts" "voice-mode" +run "test/e2e/specs/webhooks-ingress-flow.spec.ts" "webhooks-ingress" +run "test/e2e/specs/webhooks-tunnel-flow.spec.ts" "webhooks-tunnel" +run "test/e2e/specs/whatsapp-flow.spec.ts" "whatsapp" + +echo "" +echo "========= MERGE-AFFECTED SPEC SUMMARY =========" +pass=0; fail=0 +for i in "${!_names[@]}"; do + if [[ "${_results[$i]}" -eq 0 ]]; then + printf " PASS %s\n" "${_names[$i]}"; (( pass++ )) || true + else + printf " FAIL %s\n" "${_names[$i]}"; (( fail++ )) || true + fi +done +echo "------------------------------------------------" +printf " Passed: %d Failed: %d Total: %d\n" "$pass" "$fail" "${#_names[@]}" diff --git a/app/scripts/e2e-run-all-flows.sh b/app/scripts/e2e-run-all-flows.sh index 241ebff23..fb6afd3fc 100755 --- a/app/scripts/e2e-run-all-flows.sh +++ b/app/scripts/e2e-run-all-flows.sh @@ -3,28 +3,157 @@ # Run all E2E WDIO specs sequentially (Appium restarted per spec). # Requires a prior E2E app build: pnpm --filter openhuman-app test:e2e:build # -set -euo pipefail +# Each spec runs to completion regardless of prior failures; a pass/fail +# summary is printed at the end and the script exits non-zero if any spec +# failed. (Previously `set -e` caused the first failure to abort the run +# and made the terminal appear to crash.) +# +set -uo pipefail APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$APP_DIR" +cd "$APP_DIR" || { echo "FATAL: could not cd to $APP_DIR" >&2; exit 1; } + +# Parallel arrays: names + exit codes collected during the run. +_spec_names=() +_spec_results=() run() { - "$APP_DIR/scripts/e2e-run-spec.sh" "$1" "$2" + local spec="$1" + local label="${2:-$1}" + _spec_names+=("$label") + if "$APP_DIR/scripts/e2e-run-spec.sh" "$spec" "$label"; then + _spec_results+=(0) + else + _spec_results+=(1) + fi } -run "test/e2e/specs/login-flow.spec.ts" "login" -run "test/e2e/specs/auth-access-control.spec.ts" "auth" -run "test/e2e/specs/telegram-flow.spec.ts" "telegram" -run "test/e2e/specs/gmail-flow.spec.ts" "gmail" -run "test/e2e/specs/card-payment-flow.spec.ts" "card-payment" -run "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment" -run "test/e2e/specs/conversations-web-channel-flow.spec.ts" "conversations" -run "test/e2e/specs/local-model-runtime.spec.ts" "local-model" -OPENHUMAN_SERVICE_MOCK=1 run "test/e2e/specs/service-connectivity-flow.spec.ts" "service-connectivity" -run "test/e2e/specs/skills-registry.spec.ts" "skills-registry" -run "test/e2e/specs/cron-jobs-flow.spec.ts" "cron-jobs" -run "test/e2e/specs/navigation.spec.ts" "navigation" -run "test/e2e/specs/smoke.spec.ts" "smoke" -run "test/e2e/specs/tauri-commands.spec.ts" "tauri-commands" +# Print summary and exit with the appropriate code. +finish() { + local pass=0 fail=0 + echo "" + echo "══════════════════════════════════════════════" + echo " E2E run summary ($(uname -s))" + echo "══════════════════════════════════════════════" + for i in "${!_spec_names[@]}"; do + if [[ "${_spec_results[$i]}" -eq 0 ]]; then + printf " ✓ %s\n" "${_spec_names[$i]}" + (( pass++ )) || true + else + printf " ✗ %s\n" "${_spec_names[$i]}" + (( fail++ )) || true + fi + done + echo "──────────────────────────────────────────────" + printf " Passed: %d Failed: %d Total: %d\n" "$pass" "$fail" "${#_spec_names[@]}" + echo "══════════════════════════════════════════════" + if [[ $fail -gt 0 ]]; then + exit 1 + fi +} +trap finish EXIT -echo "All E2E flows completed." +# --------------------------------------------------------------------------- +# Auth & onboarding +# --------------------------------------------------------------------------- +run "test/e2e/specs/smoke.spec.ts" "smoke" +run "test/e2e/specs/login-flow.spec.ts" "login" +run "test/e2e/specs/auth-access-control.spec.ts" "auth" +run "test/e2e/specs/logout-relogin-onboarding.spec.ts" "logout-relogin" +run "test/e2e/specs/onboarding-modes.spec.ts" "onboarding-modes" +run "test/e2e/specs/runtime-picker-login.spec.ts" "runtime-picker-login" + +# --------------------------------------------------------------------------- +# Navigation & core UI +# --------------------------------------------------------------------------- +run "test/e2e/specs/navigation.spec.ts" "navigation" +run "test/e2e/specs/command-palette.spec.ts" "command-palette" +run "test/e2e/specs/channels-smoke.spec.ts" "channels-smoke" +run "test/e2e/specs/insights-dashboard.spec.ts" "insights-dashboard" + +# --------------------------------------------------------------------------- +# Chat & agent harness +# --------------------------------------------------------------------------- +run "test/e2e/specs/chat-harness-send-stream.spec.ts" "chat-send-stream" +run "test/e2e/specs/chat-harness-cancel.spec.ts" "chat-cancel" +run "test/e2e/specs/chat-harness-scroll-render.spec.ts" "chat-scroll-render" +run "test/e2e/specs/chat-harness-subagent.spec.ts" "chat-subagent" +run "test/e2e/specs/chat-harness-wallet-flow.spec.ts" "chat-wallet" +run "test/e2e/specs/agent-review.spec.ts" "agent-review" +run "test/e2e/specs/mega-flow.spec.ts" "mega-flow" + +# --------------------------------------------------------------------------- +# Skills +# --------------------------------------------------------------------------- +run "test/e2e/specs/skills-registry.spec.ts" "skills-registry" +run "test/e2e/specs/skill-execution-flow.spec.ts" "skill-execution" +run "test/e2e/specs/skill-lifecycle.spec.ts" "skill-lifecycle" +run "test/e2e/specs/skill-multi-round.spec.ts" "skill-multi-round" +run "test/e2e/specs/skill-oauth.spec.ts" "skill-oauth" +run "test/e2e/specs/skill-socket-reconnect.spec.ts" "skill-socket-reconnect" + +# --------------------------------------------------------------------------- +# Notifications, memory, cron +# --------------------------------------------------------------------------- +run "test/e2e/specs/notifications.spec.ts" "notifications" +run "test/e2e/specs/memory-roundtrip.spec.ts" "memory-roundtrip" +run "test/e2e/specs/cron-jobs-flow.spec.ts" "cron-jobs" +run "test/e2e/specs/autocomplete-flow.spec.ts" "autocomplete" + +# --------------------------------------------------------------------------- +# Webhooks & tools +# --------------------------------------------------------------------------- +run "test/e2e/specs/webhooks-ingress-flow.spec.ts" "webhooks-ingress" +run "test/e2e/specs/webhooks-tunnel-flow.spec.ts" "webhooks-tunnel" +run "test/e2e/specs/tool-browser-flow.spec.ts" "tool-browser" +run "test/e2e/specs/tool-filesystem-flow.spec.ts" "tool-filesystem" +run "test/e2e/specs/tool-shell-git-flow.spec.ts" "tool-shell-git" + +# --------------------------------------------------------------------------- +# Provider flows +# --------------------------------------------------------------------------- +run "test/e2e/specs/telegram-flow.spec.ts" "telegram" +run "test/e2e/specs/gmail-flow.spec.ts" "gmail" +run "test/e2e/specs/slack-flow.spec.ts" "slack" +run "test/e2e/specs/whatsapp-flow.spec.ts" "whatsapp" +run "test/e2e/specs/conversations-web-channel-flow.spec.ts" "conversations" +run "test/e2e/specs/composio-triggers-flow.spec.ts" "composio-triggers" + +# --------------------------------------------------------------------------- +# Payments & rewards +# --------------------------------------------------------------------------- +run "test/e2e/specs/card-payment-flow.spec.ts" "card-payment" +run "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment" +run "test/e2e/specs/rewards-unlock-flow.spec.ts" "rewards-unlock" +run "test/e2e/specs/rewards-progression-persistence.spec.ts" "rewards-progression" + +# --------------------------------------------------------------------------- +# Settings panels +# --------------------------------------------------------------------------- +run "test/e2e/specs/settings-channels-permissions.spec.ts" "settings-channels" +run "test/e2e/specs/settings-data-management.spec.ts" "settings-data" +run "test/e2e/specs/settings-dev-options.spec.ts" "settings-dev" +run "test/e2e/specs/settings-ai-skills.spec.ts" "settings-ai-skills" +run "test/e2e/specs/settings-account-preferences.spec.ts" "settings-account" +run "test/e2e/specs/settings-advanced-config.spec.ts" "settings-advanced" +run "test/e2e/specs/settings-feature-preferences.spec.ts" "settings-features" + +# --------------------------------------------------------------------------- +# AI, voice & screen +# --------------------------------------------------------------------------- +run "test/e2e/specs/local-model-runtime.spec.ts" "local-model" +run "test/e2e/specs/voice-mode.spec.ts" "voice-mode" +run "test/e2e/specs/audio-toolkit-flow.spec.ts" "audio-toolkit" + +# --------------------------------------------------------------------------- +# System / Tauri +# --------------------------------------------------------------------------- +run "test/e2e/specs/tauri-commands.spec.ts" "tauri-commands" +OPENHUMAN_SERVICE_MOCK=1 \ + run "test/e2e/specs/service-connectivity-flow.spec.ts" "service-connectivity" + +# linux-cef-deb-runtime.spec.ts is Linux-only (tests /usr/bin path resolution +# for .deb package installs) — skipped on macOS/Windows. +if [[ "$(uname -s)" == "Linux" ]]; then + run "test/e2e/specs/linux-cef-deb-runtime.spec.ts" "linux-cef-deb-runtime" +fi diff --git a/app/scripts/e2e-run-session.sh b/app/scripts/e2e-run-session.sh index 7df1efe43..0644c01e1 100755 --- a/app/scripts/e2e-run-session.sh +++ b/app/scripts/e2e-run-session.sh @@ -177,13 +177,36 @@ mkdir -p "$E2E_CONFIG_DIR" if [ -f "$E2E_CONFIG_FILE" ]; then E2E_CONFIG_BACKUP="$E2E_CONFIG_FILE.e2e-backup.$$" cp "$E2E_CONFIG_FILE" "$E2E_CONFIG_BACKUP" - sed -i.bak '/^api_url[[:space:]]*=/d' "$E2E_CONFIG_FILE" && rm -f "$E2E_CONFIG_FILE.bak" - EXISTING_CONTENT="$(cat "$E2E_CONFIG_FILE")" - printf 'api_url = "http://127.0.0.1:%s"\n%s\n' "${E2E_MOCK_PORT}" "$EXISTING_CONTENT" > "$E2E_CONFIG_FILE" -else - printf 'api_url = "http://127.0.0.1:%s"\n' "${E2E_MOCK_PORT}" > "$E2E_CONFIG_FILE" fi -echo "[runner] Wrote E2E config.toml with api_url=http://127.0.0.1:${E2E_MOCK_PORT}" + +# Write a complete E2E config that routes ALL LLM inference through the mock +# server via OpenAiCompatibleProvider (supports_streaming=true). +# +# WHY pre-populate cloud_providers here: +# The unify_ai_provider_settings migration runs on first startup. If +# cloud_providers is empty it seeds an OpenHuman entry and sets primary_cloud +# to that entry — which routes all inference to OpenHumanBackendProvider +# (supports_streaming=false, always returns non-streaming responses, so the +# mock server never receives /openai/v1/chat/completions). +# +# By pre-populating [[cloud_providers]] with a "none" auth mock entry and +# setting primary_cloud to its id, the migration sees !is_empty() and skips +# seeding entirely. provider_for_role() resolves unset workloads via +# primary_cloud → slug "e2e" (non-openhuman) → returns "e2e:" → +# make_cloud_provider_by_slug → auth_style=none → OpenAiCompatibleProvider +# → supports_streaming=true → streams to mock at /openai/v1/chat/completions. +cat > "$E2E_CONFIG_FILE" << TOMLEOF +api_url = "http://127.0.0.1:${E2E_MOCK_PORT}" +primary_cloud = "p_e2e_mock" + +[[cloud_providers]] +id = "p_e2e_mock" +slug = "e2e" +label = "E2E Mock" +endpoint = "http://127.0.0.1:${E2E_MOCK_PORT}/openai/v1" +auth_style = "none" +TOMLEOF +echo "[runner] Wrote E2E config.toml routing inference to mock at http://127.0.0.1:${E2E_MOCK_PORT}" DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)" if [ -z "$DIST_JS" ]; then diff --git a/app/test/e2e/helpers/shared-flows.ts b/app/test/e2e/helpers/shared-flows.ts index 9ff3c9f9e..6db917189 100644 --- a/app/test/e2e/helpers/shared-flows.ts +++ b/app/test/e2e/helpers/shared-flows.ts @@ -25,8 +25,8 @@ import { supportsExecuteScript } from './platform'; /** * Open the "Add Account" modal on /accounts. * - * The "Add Account" affordance is a button whose only labelled descendants are - * an SVG plus a tooltip span with `pointer-events: none`. None of the shared + * The "Add app" affordance is a button whose only labelled descendants are an + * SVG plus a tooltip span with `pointer-events: none`. None of the shared * `clickButton`/`clickText` helpers can target it cleanly because the * accessible name lives only on `aria-label`, so this helper reaches for the * explicit selector. Tracking a follow-up `clickByAriaLabel` helper. @@ -34,7 +34,7 @@ import { supportsExecuteScript } from './platform'; export async function openAddAccountModal(): Promise { const opened = await browser.execute(() => { const buttons = Array.from(document.querySelectorAll('button')); - // The aria-label is sourced from i18n key 'accounts.addAccount' = 'Add Account'. + // aria-label is t('accounts.addAccount') = 'Add Account' const addBtn = buttons.find(b => b.getAttribute('aria-label') === 'Add Account'); if (addBtn) { addBtn.click(); @@ -43,7 +43,7 @@ export async function openAddAccountModal(): Promise { return false; }); if (!opened) { - throw new Error('Could not locate Add Account button on /accounts'); + throw new Error('Could not locate Add Account button on /chat'); } await waitForText('Add account', 5_000); } @@ -63,11 +63,10 @@ export async function waitForRequest(log, method, urlFragment, timeout = 15_000) } export async function waitForHomePage(timeout = 15_000) { - // Home page renders a CTA button with t('home.askAssistant') = 'Ask your assistant anything...'. - // Also accept the shorter variant and the navigation bar label as fallbacks. - // The old strings ('Good morning', 'Message OpenHuman', 'Upgrade to Premium') - // are no longer rendered on the home page. - const candidates = ['Ask your assistant anything', 'Ask your assistant']; + // Home page (Home.tsx) renders t('home.askAssistant') = 'Ask your assistant anything...' + // as a stable CTA button. The animated typewriter heading ('Welcome, 👋' etc.) + // and old strings ('Good morning', 'Message OpenHuman', 'Upgrade to Premium') are gone. + const candidates = ['Ask your assistant anything', 'Your device is connected']; const deadline = Date.now() + timeout; while (Date.now() < deadline) { for (const text of candidates) { @@ -108,7 +107,7 @@ export async function clickFirstMatch(candidates, timeout = 5_000) { const HASH_TO_SIDEBAR_LABEL = { '/skills': 'Skills', '/home': 'Home', - '/conversations': 'Conversations', + '/chat': 'Chat', '/notifications': 'Alerts', '/settings': 'Settings', '/settings/intelligence': 'Intelligence', @@ -280,7 +279,7 @@ export async function navigateToIntelligence() { } export async function navigateToConversations() { - await navigateViaHash('/conversations'); + await navigateViaHash('/chat'); } export async function navigateToNotifications() { diff --git a/app/test/e2e/specs/card-payment-flow.spec.ts b/app/test/e2e/specs/card-payment-flow.spec.ts new file mode 100644 index 000000000..6b7e5739e --- /dev/null +++ b/app/test/e2e/specs/card-payment-flow.spec.ts @@ -0,0 +1,81 @@ +// @ts-nocheck +/** + * E2E test: Billing panel (card/Stripe path). + * + * The in-app Stripe checkout UI was removed; billing now lives on the web + * dashboard. These tests verify the billing redirect panel that replaced it: + * + * 5.1 Billing panel renders the "moved to web" redirect page + * 5.2 "Open billing dashboard" button is present + * 5.3 Back-to-settings navigation works after visiting billing + */ +import { waitForApp } from '../helpers/app-helpers'; +import { textExists, waitForText } from '../helpers/element-helpers'; +import { + navigateToBilling, + navigateToHome, + navigateToSettings, + performFullLogin, +} from '../helpers/shared-flows'; +import { clearRequestLog, startMockServer, stopMockServer } from '../mock-server'; + +const LOG_PREFIX = '[PaymentFlow]'; + +describe('Card Payment Flow', () => { + before(async () => { + await startMockServer(); + await waitForApp(); + clearRequestLog(); + }); + + after(async () => { + await stopMockServer(); + }); + + it('login and reach home', async () => { + await performFullLogin('e2e-card-payment-token'); + }); + + it('5.1 — billing panel shows "moved to web" redirect page', async () => { + await navigateToBilling(); + // BillingPanel.tsx renders t('settings.billing.movedToWeb') = 'Billing moved to the web' + await waitForText('Billing moved to the web', 10_000); + console.log(`${LOG_PREFIX} 5.1 — billing redirect panel loaded`); + }); + + it('5.2 — "Open billing dashboard" button is present', async () => { + // Should still be on billing panel from previous test; navigate again to be safe. + await navigateToBilling(); + // t('settings.billing.openDashboard') = 'Open billing dashboard' + const hasButton = await textExists('Open billing dashboard'); + expect(hasButton).toBe(true); + console.log(`${LOG_PREFIX} 5.2 — "Open billing dashboard" button present`); + }); + + it('5.3 — back-to-settings navigation works', async () => { + await navigateToBilling(); + await waitForText('Billing moved to the web', 10_000); + + // t('settings.billing.backToSettings') = 'Back to settings' + const hasBack = await textExists('Back to settings'); + if (hasBack) { + const { clickText } = await import('../helpers/element-helpers'); + await clickText('Back to settings', 5_000); + await browser.pause(1_500); + // Should be back on a settings page + const onSettings = + (await textExists('Settings')) || + (await textExists('Account')) || + (await textExists('Data')); + expect(onSettings).toBe(true); + console.log(`${LOG_PREFIX} 5.3 — back-to-settings navigation works`); + } else { + // Fallback: use PageBackButton's generic back arrow + await navigateToSettings(); + const onSettings = await textExists('Settings'); + expect(onSettings).toBe(true); + console.log(`${LOG_PREFIX} 5.3 — navigated back to settings via fallback`); + } + await navigateToHome(); + }); +}); diff --git a/app/test/e2e/specs/conversations-web-channel-flow.spec.ts b/app/test/e2e/specs/conversations-web-channel-flow.spec.ts index 940193dbd..a820de79c 100644 --- a/app/test/e2e/specs/conversations-web-channel-flow.spec.ts +++ b/app/test/e2e/specs/conversations-web-channel-flow.spec.ts @@ -2,7 +2,6 @@ import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers'; import { - clickText, dumpAccessibilityTree, textExists, waitForText, @@ -79,20 +78,14 @@ suiteRunner('Conversations web channel flow', () => { await completeOnboardingIfVisible('[ConversationsE2E]'); stepLog('open conversations'); - // Navigate via hash — "Message OpenHuman" button may not reliably open conversations + // Navigate via hash to /chat (the unified agent + web channel page). + // 'Message OpenHuman' button was removed from Home in a redesign — navigate directly. await navigateToConversations(); - // If navigating to /conversations doesn't open a thread, try clicking the input area + // If navigating to /chat doesn't show threads, retry via direct hash. const hasInput = await textExists('Type a message...'); if (!hasInput) { - // Try the home page "Message OpenHuman" button as fallback - await navigateViaHash('/home'); - try { - await waitForText('Message OpenHuman', 10_000); - await clickText('Message OpenHuman', 10_000); - } catch { - stepLog('Message OpenHuman button not found, staying on conversations'); - await navigateToConversations(); - } + await navigateViaHash('/chat'); + await browser.pause(2_000); } stepLog('send message'); diff --git a/app/test/e2e/specs/cron-jobs-flow.spec.ts b/app/test/e2e/specs/cron-jobs-flow.spec.ts index 5465dc59d..f5fdead46 100644 --- a/app/test/e2e/specs/cron-jobs-flow.spec.ts +++ b/app/test/e2e/specs/cron-jobs-flow.spec.ts @@ -57,41 +57,50 @@ async function waitForAnyText(candidates: string[], timeoutMs = 10_000): Promise return null; } -/** Click the action button (Pause | Resume | Remove | …) inside the seeded cron row. */ -async function clickActionForJob( - jobId: string, - action: 'toggle' | 'run' | 'view-runs' | 'remove' -): Promise { +/** Click the action button (Pause | Resume | Remove | …) inside the morning_briefing row. */ +async function clickActionForJob(jobName: string, action: string): Promise { return Boolean( await browser.execute( - (id: string, actionName: string) => { - const btn = document.querySelector( - `[data-testid="cron-job-${actionName}-${id}"]` - ); + (name: string, label: string) => { + const rows = Array.from(document.querySelectorAll('div')) + .filter(div => /text-sm font-semibold text-stone-900/.test(div.className)) + .filter(div => (div.textContent ?? '').trim() === name); + if (rows.length === 0) return false; + // Walk up to the panel row container (sibling-of-sibling structure in CoreJobList). + const container = rows[0]?.closest('div.p-4'); + if (!container) return false; + const buttons = Array.from(container.querySelectorAll('button')); + const btn = buttons.find(b => (b.textContent ?? '').trim() === label); if (!btn) return false; btn.click(); return true; }, - jobId, + jobName, action ) ); } -/** Poll for the in-row toggle action button label to settle (e.g. "Pause" → "Resume"). */ +/** Poll for the in-row action button label to settle (e.g. "Pause" → "Resume"). */ async function waitForRowActionLabel( - jobId: string, + jobName: string, expected: string, timeoutMs = 10_000 ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { - const current = await browser.execute((id: string) => { - const btn = document.querySelector( - `[data-testid="cron-job-toggle-${id}"]` + const current = await browser.execute((name: string) => { + const rows = Array.from(document.querySelectorAll('div')) + .filter(div => /text-sm font-semibold text-stone-900/.test(div.className)) + .filter(div => (div.textContent ?? '').trim() === name); + const container = rows[0]?.closest('div.p-4'); + if (!container) return null; + const labels = Array.from(container.querySelectorAll('button')).map(b => + (b.textContent ?? '').trim() ); - return (btn?.textContent ?? '').trim() || null; - }, jobId); + // We care about the toggle button (first one in the row). + return labels[0] ?? null; + }, jobName); if (current === expected) return true; await browser.pause(400); } @@ -112,8 +121,7 @@ async function openCronJobsPanel(): Promise { } describe('Cron jobs settings panel (real UI flow)', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); @@ -124,11 +132,10 @@ describe('Cron jobs settings panel (real UI flow)', () => { }); it('completing onboarding lands the user on the home screen', async () => { - // The home page renders the CTA button with t('home.askAssistant'). - // Legacy text like 'Message OpenHuman', 'Good morning', 'Good afternoon', - // 'Good evening', and 'Upgrade to Premium' no longer appear on the home page. + // Home.tsx renders t('home.askAssistant') = 'Ask your assistant anything...' as the stable + // CTA button. Old strings ('Good morning', 'Message OpenHuman', etc.) are no longer rendered. const home = await waitForAnyText( - ['Ask your assistant anything', 'Ask your assistant'], + ['Ask your assistant anything', 'Your device is connected'], 15_000 ); expect(home).toBeTruthy(); @@ -151,7 +158,7 @@ describe('Cron jobs settings panel (real UI flow)', () => { const startLabel = await waitForRowActionLabel(MORNING_BRIEFING, 'Pause', 5_000); expect(startLabel).toBe(true); - const clicked = await clickActionForJob(MORNING_BRIEFING, 'toggle'); + const clicked = await clickActionForJob(MORNING_BRIEFING, 'Pause'); expect(clicked).toBe(true); const flipped = await waitForRowActionLabel(MORNING_BRIEFING, 'Resume', 10_000); @@ -165,14 +172,14 @@ describe('Cron jobs settings panel (real UI flow)', () => { expect(stillResumed).toBe(true); // Restore so the next test starts from the enabled state. - const restored = await clickActionForJob(MORNING_BRIEFING, 'toggle'); + const restored = await clickActionForJob(MORNING_BRIEFING, 'Resume'); expect(restored).toBe(true); const back = await waitForRowActionLabel(MORNING_BRIEFING, 'Pause', 10_000); expect(back).toBe(true); }); it('clicking Remove deletes the job from both the UI and the sidecar', async () => { - const clicked = await clickActionForJob(MORNING_BRIEFING, 'remove'); + const clicked = await clickActionForJob(MORNING_BRIEFING, 'Remove'); expect(clicked).toBe(true); // UI assertion first — the row should disappear and the empty state appear. diff --git a/app/test/e2e/specs/crypto-payment-flow.spec.ts b/app/test/e2e/specs/crypto-payment-flow.spec.ts new file mode 100644 index 000000000..12c308628 --- /dev/null +++ b/app/test/e2e/specs/crypto-payment-flow.spec.ts @@ -0,0 +1,63 @@ +// @ts-nocheck +/** + * E2E test: Billing panel (crypto/Coinbase path). + * + * The in-app Coinbase Commerce checkout UI was removed alongside Stripe; + * billing now lives on the web dashboard. These tests verify the billing + * redirect panel shown at /settings/billing: + * + * 6.1 Billing panel renders the "moved to web" redirect page + * 6.2 "Open billing dashboard" button is present + * 6.3 Opening browser is indicated while the redirect fires + */ +import { waitForApp } from '../helpers/app-helpers'; +import { textExists, waitForText } from '../helpers/element-helpers'; +import { navigateToBilling, navigateToHome, performFullLogin } from '../helpers/shared-flows'; +import { clearRequestLog, startMockServer, stopMockServer } from '../mock-server'; + +const LOG_PREFIX = '[CryptoPayment]'; + +describe('Crypto Payment Flow', () => { + before(async () => { + await startMockServer(); + await waitForApp(); + clearRequestLog(); + }); + + after(async () => { + await stopMockServer(); + }); + + it('login and reach home', async () => { + await performFullLogin('e2e-crypto-payment-token'); + }); + + it('6.1 — billing panel shows "moved to web" redirect page', async () => { + await navigateToBilling(); + await waitForText('Billing moved to the web', 10_000); + console.log(`${LOG_PREFIX} 6.1 — billing redirect panel loaded`); + }); + + it('6.2 — "Open billing dashboard" button is present', async () => { + await navigateToBilling(); + const hasButton = await textExists('Open billing dashboard'); + expect(hasButton).toBe(true); + console.log(`${LOG_PREFIX} 6.2 — "Open billing dashboard" button present`); + }); + + it('6.3 — opening-browser status message is shown on mount', async () => { + await navigateToBilling(); + await waitForText('Billing moved to the web', 10_000); + // BillingPanel triggers openUrl on mount; while it is in-flight it shows + // t('settings.billing.openingBrowser') = 'Opening your browser...' + // After the promise resolves it transitions to 'idle' / 'error'. + // Either the opening message or the idle fallback must be visible. + const hasOpeningMsg = + (await textExists('Opening your browser')) || + (await textExists('If your browser did not open')) || + (await textExists('Open billing dashboard')); + expect(hasOpeningMsg).toBe(true); + console.log(`${LOG_PREFIX} 6.3 — browser-open status message present`); + await navigateToHome(); + }); +}); diff --git a/app/test/e2e/specs/local-model-runtime.spec.ts b/app/test/e2e/specs/local-model-runtime.spec.ts index c3c1f5ec1..8b0663964 100644 --- a/app/test/e2e/specs/local-model-runtime.spec.ts +++ b/app/test/e2e/specs/local-model-runtime.spec.ts @@ -24,9 +24,11 @@ async function waitForRequest(method, urlFragment, timeout = 15_000) { } async function waitForHome(timeout = 20_000) { + // Home.tsx renders t('home.askAssistant') = 'Ask your assistant anything...' as stable CTA. const deadline = Date.now() + timeout; while (Date.now() < deadline) { - if (await textExists('Message OpenHuman')) return true; + if (await textExists('Ask your assistant anything')) return true; + if (await textExists('Your device is connected')) return true; await browser.pause(700); } return false; diff --git a/app/test/e2e/specs/login-flow.spec.ts b/app/test/e2e/specs/login-flow.spec.ts index cee637861..1843592e5 100644 --- a/app/test/e2e/specs/login-flow.spec.ts +++ b/app/test/e2e/specs/login-flow.spec.ts @@ -9,11 +9,10 @@ * 3. App receives JWT, dispatches to Redux authSlice * 4. UserProvider calls GET /auth/me (mock server) * - * Phase 2 — Onboarding steps (3 steps in Onboarding.tsx): - * Step 0: WelcomeStep — "Continue" + * Phase 2 — Onboarding steps (driven via data-testid="onboarding-next-button"): + * Step 0: WelcomeStep — "Get Started" * Step 1: SkillsStep — "Continue" or "Skip for Now" - * Step 2: ContextGatheringStep — user-driven gate: "Start when ready" / "Continue" / - * "Skip for now" (skipped entirely if no sources connected) + * Step 2: ContextGatheringStep — user-driven gate (skipped if no sources connected) * * Phase 3 — Completion verification: * - App calls POST /settings/onboarding-complete (from SkillsStep) @@ -32,7 +31,6 @@ import { waitForApp, waitForAppReady, waitForAuthBootstrap } from '../helpers/app-helpers'; import { buildBypassJwt, triggerAuthDeepLink, triggerDeepLink } from '../helpers/deep-link-helpers'; import { - clickText, dumpAccessibilityTree, hasAppChrome, textExists, @@ -40,6 +38,7 @@ import { waitForWindowVisible, } from '../helpers/element-helpers'; import { resetApp } from '../helpers/reset-app'; +import { waitForHomePage, walkOnboarding } from '../helpers/shared-flows'; import { clearRequestLog, getRequestLog, @@ -78,20 +77,6 @@ async function waitForAnyText(candidates, timeout = 15_000) { return null; } -/** - * Click the first matching text from a list of candidates. - * Returns the clicked text or null if none found. - */ -async function clickFirstMatch(candidates, timeout = 5_000) { - for (const text of candidates) { - if (await textExists(text)) { - await clickText(text, timeout); - return text; - } - } - return null; -} - /** * Verify Redux auth state via browser.execute (tauri-driver only). */ @@ -120,8 +105,7 @@ async function getReduxAuthState() { let hadOnboardingWalkthrough = false; describe('Login flow — complete with mock data (Linux)', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); resetMockBehavior(); setMockBehavior('composioConnections', '[]'); @@ -144,14 +128,12 @@ describe('Login flow — complete with mock data (Linux)', () => { // Phase 1: Deep link authentication // ----------------------------------------------------------------------- - it('app process is running and has a window handle', async function () { - this.timeout(90_000); + it('app process is running and has a window handle', async () => { const hasChrome = await hasAppChrome(); expect(hasChrome).toBe(true); }); - it('deep link triggers login and shows the app window', async function () { - this.timeout(90_000); + it('deep link triggers login and shows the app window', async () => { await triggerAuthDeepLink('e2e-test-token'); await waitForWindowVisible(25_000); @@ -244,68 +226,20 @@ describe('Login flow — complete with mock data (Linux)', () => { expect(foundOnboarding || foundHome).toBeTruthy(); }); - it('walk through onboarding steps (if overlay is visible)', async function () { - this.timeout(60_000); - // Check if we're on the WelcomeStep or any onboarding step - const onboardingVisible = - (await textExists("Hi. I'm OpenHuman.")) || - (await textExists("Let's Start")) || - (await textExists('Connect your Gmail')) || - (await textExists('Skip')) || - (await textExists('Continue')) || - (await textExists('Finish Setup')); + it('walk through onboarding steps (if overlay is visible)', async () => { + // Use the shared walkOnboarding helper which drives via + // data-testid="onboarding-next-button" — resilient to CTA label changes. + const beforeHash = await browser.execute(() => window.location.hash); + const alreadyOnHome = String(beforeHash).includes('/home'); - if (!onboardingVisible) { - console.log('[LoginFlow] Onboarding overlay not visible — skipping step walkthrough'); + if (alreadyOnHome) { + console.log('[LoginFlow] Already on /home — skipping onboarding walk'); hadOnboardingWalkthrough = false; return; } hadOnboardingWalkthrough = true; - - // Step 0: WelcomeStep — click the current CTA. - if ((await textExists("Hi. I'm OpenHuman.")) || (await textExists("Let's Start"))) { - const clicked = await clickFirstMatch(["Let's Start", 'Continue'], 10_000); - console.log(`[LoginFlow] WelcomeStep: clicked "${clicked}"`); - await browser.pause(2_000); - } - - // Step 1: SkillsStep — click "Skip for Now" (no skills connected in E2E) - { - const skillsVisible = - (await textExists('Connect your Gmail')) || (await textExists('Connect Gmail')); - if (skillsVisible) { - const clicked = await clickFirstMatch(['Skip for Now', 'Continue'], 10_000); - if (clicked) { - console.log(`[LoginFlow] SkillsStep: clicked "${clicked}"`); - await browser.pause(3_000); - } - } - } - - // Step 2: ContextGatheringStep — current builds auto-start the profile - // pipeline and can land on either the progress state or the recoverable - // "Almost there!" fallback. In both cases "Continue to chat" is the final - // action that persists onboarding_completed and navigates to Home. - { - const contextVisible = - (await textExists('Building your profile')) || - (await textExists('Almost there')) || - (await textExists('Continue to chat')) || - (await textExists('Getting to know you')) || - (await textExists('Reading your connected accounts')) || - (await textExists('Context Ready')); - if (contextVisible) { - const clicked = await clickFirstMatch( - ['Continue to chat', 'Skip for now', 'Continue', 'Start when ready'], - 10_000 - ); - if (clicked) { - console.log(`[LoginFlow] ContextGatheringStep: clicked "${clicked}"`); - await browser.pause(3_000); - } - } - } + await walkOnboarding('[LoginFlow]'); }); // ----------------------------------------------------------------------- @@ -342,16 +276,7 @@ describe('Login flow — complete with mock data (Linux)', () => { }); it('app navigated to Home page after onboarding', async () => { - const nameCandidates = [ - 'Ask your assistant anything', - 'Ask your assistant', - 'Test', - 'Good morning', - 'Good afternoon', - 'Good evening', - ]; - - const foundText = await waitForAnyText(nameCandidates, 15_000); + const foundText = await waitForHomePage(20_000); if (foundText) { console.log(`[LoginFlow] Home page confirmed: found "${foundText}"`); @@ -421,7 +346,7 @@ describe('Login flow — complete with mock data (Linux)', () => { // Trigger bypass deep link (key=auth skips token consume) await triggerDeepLink(`openhuman://auth?token=${encodeURIComponent(bypassJwt)}&key=auth`); - await browser.pause(5_000); + await browser.pause(3_000); // Assert NO consume call was made (bypass skips it) const consumeCall = getRequestLog().find( @@ -430,16 +355,11 @@ describe('Login flow — complete with mock data (Linux)', () => { expect(consumeCall).toBeUndefined(); console.log('[LoginFlow] Bypass auth: no consume call (correct — token set directly)'); - // Assert the app navigated to home (post-login UI marker) - const homeCandidates = [ - 'Ask your assistant anything', - 'Ask your assistant', - 'Good morning', - 'Good afternoon', - 'Good evening', - 'Home', - ]; - const foundHome = await waitForAnyText(homeCandidates, 15_000); + // Clearing localStorage resets onboarding state — walk through it so the + // app reaches /home before we assert the home-page markers. + await walkOnboarding('[LoginFlow][bypass]'); + + const foundHome = await waitForHomePage(20_000); expect(foundHome).not.toBeNull(); console.log(`[LoginFlow] Bypass auth: home reached with "${foundHome}"`); diff --git a/app/test/e2e/specs/notifications.spec.ts b/app/test/e2e/specs/notifications.spec.ts index 7575eea30..c5eeb1b33 100644 --- a/app/test/e2e/specs/notifications.spec.ts +++ b/app/test/e2e/specs/notifications.spec.ts @@ -54,11 +54,9 @@ async function waitForCoreSidecar(timeout = 30_000): Promise { let lastErr: unknown; await browser.waitUntil( async () => { - // Use openhuman.about_app_list as a lightweight ping — it's a - // read-only catalog fetch that succeeds as soon as the core is up. - const result = await callOpenhumanRpc('openhuman.about_app_list', {}); + const result = await callOpenhumanRpc('core.ping', {}); if (result.ok) { - stepLog('core sidecar ready', { result: result.result }); + stepLog('core ready (ping ok)', { result: result.result }); return true; } lastErr = result.error; @@ -73,8 +71,7 @@ async function waitForCoreSidecar(timeout = 30_000): Promise { } describe('Notifications', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); @@ -204,10 +201,9 @@ describe('Notifications', () => { } expect(sectionVisible).toBe(true); - // The heading text: t('alerts.title') = 'Alerts'. - // The empty state: t('alerts.empty') = 'No alerts yet'. - await waitForText('Alerts', 8_000); - await waitForText('No alerts yet', 8_000); + // The heading text should also be present. + await waitForText('System Events', 8_000); + await waitForText('All caught up', 8_000); }); it('native notification permission command returns a valid state', async () => { diff --git a/app/test/e2e/specs/settings-channels-permissions.spec.ts b/app/test/e2e/specs/settings-channels-permissions.spec.ts index c5e39d7fc..86056601a 100644 --- a/app/test/e2e/specs/settings-channels-permissions.spec.ts +++ b/app/test/e2e/specs/settings-channels-permissions.spec.ts @@ -19,8 +19,7 @@ import { startMockServer, stopMockServer } from '../mock-server'; const USER_ID = 'e2e-settings-channels'; describe('Settings - Channels & Permissions', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); @@ -30,8 +29,7 @@ describe('Settings - Channels & Permissions', () => { await stopMockServer(); }); - it('allows switching default messaging channel (13.2.1)', async function () { - this.timeout(90_000); + it('allows switching default messaging channel (13.2.1)', async () => { await navigateViaHash('/settings/messaging'); await waitForText('Default Messaging Channel', 15_000); @@ -39,21 +37,18 @@ describe('Settings - Channels & Permissions', () => { expect(await textExists('Discord')).toBe(true); await clickText('Discord'); - // After clicking Discord, the route summary shows either an active route - // or "No active route" (when no Discord account is connected in E2E). - // We assert that the Active route label is rendered either way. - await browser.pause(1_000); - expect((await textExists('Active route')) || (await textExists('No active route'))).toBe(true); + // The active-route line always renders regardless of connection state. + await waitForText('Active route', 5_000); }); - it('renders privacy settings and analytics toggle (13.2.2)', async function () { - this.timeout(90_000); + it('renders privacy settings and analytics toggle (13.2.2)', async () => { await navigateViaHash('/settings/privacy'); - // Privacy panel shows 'Privacy & Security' as the panel title and - // 'Share Anonymized Usage Data' as the analytics toggle label. await waitForText('Privacy', 15_000); + // PrivacyPanel renders "Anonymized Analytics" section header (not "Data Sharing") await waitForText('Anonymized Analytics', 15_000); expect(await textExists('Share Anonymized Usage Data')).toBe(true); + // Capability list section is "What leaves your computer" (not "Permission Metadata") + await waitForText('What leaves your computer', 5_000); }); }); diff --git a/app/test/e2e/specs/settings-data-management.spec.ts b/app/test/e2e/specs/settings-data-management.spec.ts index 9d8227d94..b1dea96b6 100644 --- a/app/test/e2e/specs/settings-data-management.spec.ts +++ b/app/test/e2e/specs/settings-data-management.spec.ts @@ -20,8 +20,7 @@ import { startMockServer, stopMockServer } from '../mock-server'; const USER_ID = 'e2e-settings-data-mgmt'; describe('Settings - Data Management', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); @@ -31,8 +30,7 @@ describe('Settings - Data Management', () => { await stopMockServer(); }); - it('shows Clear App Data confirmation dialog and handles Cancel (13.5.1)', async function () { - this.timeout(90_000); + it('shows Clear App Data confirmation dialog and handles Cancel (13.5.1)', async () => { await navigateViaHash('/settings'); await waitForText('Clear App Data', 15_000); @@ -46,8 +44,7 @@ describe('Settings - Data Management', () => { expect(await textExists('Clear App Data')).toBe(true); }); - it('performs Full State Reset (13.5.3)', async function () { - this.timeout(90_000); + it('performs Full State Reset (13.5.3)', async () => { await navigateViaHash('/settings'); await waitForText('Clear App Data', 15_000); @@ -57,9 +54,9 @@ describe('Settings - Data Management', () => { await clickText('Clear App Data'); // After reset the app reloads to the Welcome screen. - // The Welcome page shows "Welcome to OpenHuman" and OAuth provider buttons - // but no literal "Sign in" text — assert on the welcome title instead. - await waitForText('Welcome to OpenHuman', 25_000); - expect(await textExists('Welcome to OpenHuman')).toBe(true); + // Welcome page renders t('welcome.title') = 'Welcome to OpenHuman' + await waitForText('Welcome', 25_000); + // Welcome page shows runtime selector, not a "Sign in" text link. + expect(await textExists('Select a Runtime')).toBe(true); }); }); diff --git a/app/test/e2e/specs/settings-feature-preferences.spec.ts b/app/test/e2e/specs/settings-feature-preferences.spec.ts index 2fc4f535d..cec300041 100644 --- a/app/test/e2e/specs/settings-feature-preferences.spec.ts +++ b/app/test/e2e/specs/settings-feature-preferences.spec.ts @@ -72,8 +72,7 @@ async function defaultMessagingChannelFromStore(): Promise { } describe('Settings - Feature Preferences', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); @@ -83,20 +82,19 @@ describe('Settings - Feature Preferences', () => { await stopMockServer(); }); - it('renders the features settings section route', async function () { - this.timeout(90_000); + it('renders the features settings section route', async () => { await navigateViaHash('/settings/features'); await waitForText('Features', 15_000); - // i18n uses sentence-case: 'Screen awareness', 'Messaging channels', etc. + // Settings uses t('pages.settings.features.screenAwareness') = 'Screen awareness' await waitForText('Screen awareness', 15_000); + // Settings uses t('pages.settings.features.messagingChannels') = 'Messaging channels' await waitForText('Messaging channels', 15_000); await waitForText('Notifications', 15_000); await waitForText('Tools', 15_000); }); - it('persists the default messaging channel through redux state', async function () { - this.timeout(90_000); + it('persists the default messaging channel through redux state', async () => { await navigateViaHash('/settings/messaging'); await waitForText('Default Messaging Channel', 15_000); @@ -108,8 +106,7 @@ describe('Settings - Feature Preferences', () => { }); }); - it('persists tools preferences to the core app-state snapshot', async function () { - this.timeout(90_000); + it('persists tools preferences to the core app-state snapshot', async () => { const before = await callOpenhumanRpc('openhuman.app_state_snapshot', {}); expect(before.ok).toBe(true); const enabledBefore = before.result?.result?.localState?.onboardingTasks?.enabledTools ?? []; @@ -131,8 +128,7 @@ describe('Settings - Feature Preferences', () => { ); }); - it('persists notifications DND and category preferences', async function () { - this.timeout(90_000); + it('persists notifications DND and category preferences', async () => { await navigateViaHash('/settings/notifications'); await waitForText('Do Not Disturb', 15_000); @@ -147,8 +143,7 @@ describe('Settings - Feature Preferences', () => { expect(await switchState('Toggle Messages notifications')).toBe('false'); }); - it('persists mascot color selection', async function () { - this.timeout(60_000); + it('persists mascot color selection', async () => { await navigateViaHash('/settings/mascot'); await waitForText('Color', 15_000); @@ -159,8 +154,7 @@ describe('Settings - Feature Preferences', () => { expect(await mascotColorChecked('burgundy')).toBe('true'); }); - it('persists the custom mascot voice override on the voice panel', async function () { - this.timeout(90_000); + it('persists the custom mascot voice override on the voice panel', async () => { await navigateViaHash('/settings/voice'); await waitForText('Mascot Voice', 20_000); diff --git a/app/test/e2e/specs/skill-execution-flow.spec.ts b/app/test/e2e/specs/skill-execution-flow.spec.ts new file mode 100644 index 000000000..9ae96af47 --- /dev/null +++ b/app/test/e2e/specs/skill-execution-flow.spec.ts @@ -0,0 +1,157 @@ +// @ts-nocheck +/** + * Skill execution end-to-end (UI shell + core JSON-RPC runtime). + * + * Mirrors the Rust integration test + * `json_rpc_skills_runtime_start_tools_call_stop` in + * `tests/json_rpc_e2e.rs` — but goes through the same HTTP path the + * desktop UI uses (`callOpenhumanRpc` → `http://127.0.0.1:/rpc`). + * + * RPC result shapes: + * - skills_start → SkillSnapshot ({ status, skill_id, … }) + * - skills_call_tool → ToolResult ({ content[] }) + * - skills_stop → { success, skill_id } + * - skills_set_setup_complete → ok / err + * - skills_status → { setup_complete, … } + * + * Issue #68 (model → agent → tool → conversation) is environment- and + * LLM-dependent; that's tracked separately. This spec validates the + * skill runtime + RPC + Skills shell deterministically. + */ +import { waitForApp } from '../helpers/app-helpers'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { dumpAccessibilityTree, textExists } from '../helpers/element-helpers'; +import { resetApp } from '../helpers/reset-app'; +import { navigateToSkills } from '../helpers/shared-flows'; +import { + E2E_RUNTIME_SKILL_ID, + removeSeededEchoSkill, + seedMinimalEchoSkill, +} from '../helpers/skill-e2e-runtime'; +import { getRequestLog, startMockServer, stopMockServer } from '../mock-server'; + +const USER_ID = 'e2e-skill-execution'; + +describe('Skill execution (UI + core RPC)', () => { + before(async () => { + await seedMinimalEchoSkill(); + await startMockServer(); + await waitForApp(); + await resetApp(USER_ID); + }); + + after(async () => { + await stopMockServer(); + await removeSeededEchoSkill(); + }); + + it('lands the user on a logged-in shell', async () => { + // Home.tsx renders t('home.askAssistant') as the stable CTA button. + // 'Good morning' / 'Message OpenHuman' / 'Upgrade to Premium' are no longer rendered. + const atHome = + (await textExists('Ask your assistant anything')) || + (await textExists('Your device is connected')); + expect(atHome).toBe(true); + }); + + it('core.ping responds over the same JSON-RPC URL the UI uses', async () => { + const ping = await callOpenhumanRpc('core.ping', {}); + expect(ping.ok).toBe(true); + }); + + // RC-7 PRODUCT GAP: The QuickJS/rquickjs skill execution runtime was removed + // (see CLAUDE.md — "Skills runtime removed"). The six RPC methods below no + // longer exist in the Rust registry: + // openhuman.skills_start / skills_list_tools / skills_call_tool / + // skills_stop / skills_set_setup_complete / skills_status + // + // Calling them returns a JSON-RPC "method not found" error, so these tests + // always fail rather than verifying any real behaviour. They are skipped + // here so the suite doesn't silently misreport status. Restore + un-skip + // when a replacement skill-execution runtime is shipped. + it.skip('(RC-7 — skills runtime removed) start → list_tools → call_tool → stop', async () => { + const start = await callOpenhumanRpc('openhuman.skills_start', { + skill_id: E2E_RUNTIME_SKILL_ID, + }); + if (!start.ok) { + console.error('[SkillExecutionE2E] skills_start failed', start, getRequestLog()); + } + expect(start.ok).toBe(true); + const status = start.result?.status; + expect(status === 'running' || status === 'initializing').toBe(true); + + await browser.pause(800); + + const tools = await callOpenhumanRpc('openhuman.skills_list_tools', { + skill_id: E2E_RUNTIME_SKILL_ID, + }); + expect(tools.ok).toBe(true); + const toolNames = (tools.result?.tools || []).map((t: { name?: string }) => t.name); + expect(toolNames.includes('echo')).toBe(true); + + const call = await callOpenhumanRpc('openhuman.skills_call_tool', { + skill_id: E2E_RUNTIME_SKILL_ID, + tool_name: 'echo', + arguments: { message: 'hello from e2e skill execution' }, + }); + expect(call.ok).toBe(true); + const content = call.result?.content || []; + expect( + content.some( + (c: { text?: string }) => + typeof c?.text === 'string' && c.text.includes('hello from e2e skill execution') + ) + ).toBe(true); + + const stop = await callOpenhumanRpc('openhuman.skills_stop', { + skill_id: E2E_RUNTIME_SKILL_ID, + }); + expect(stop.ok).toBe(true); + expect(stop.result?.success === true).toBe(true); + }); + + it.skip('(RC-7 — skills runtime removed) setup_complete via skills_set_setup_complete', async () => { + try { + const set = await callOpenhumanRpc('openhuman.skills_set_setup_complete', { + skill_id: E2E_RUNTIME_SKILL_ID, + complete: true, + }); + expect(set.ok).toBe(true); + + const st = await callOpenhumanRpc('openhuman.skills_status', { + skill_id: E2E_RUNTIME_SKILL_ID, + }); + expect(st.ok).toBe(true); + expect(st.result?.setup_complete === true).toBe(true); + } finally { + await callOpenhumanRpc('openhuman.skills_set_setup_complete', { + skill_id: E2E_RUNTIME_SKILL_ID, + complete: false, + }); + } + }); + + it('Skills UI surface shows installed tools', async () => { + await navigateToSkills(); + await browser.pause(2_000); + + const hash = await browser.execute(() => window.location.hash); + expect(String(hash)).toContain('/skills'); + + const visible = + (await textExists('Skills')) || + (await textExists('Install')) || + (await textExists('Available')) || + (await textExists('Telegram')) || + (await textExists('Notion')); + if (!visible) { + await dumpAccessibilityTree(); + console.error('[SkillExecutionE2E] request log:', getRequestLog()); + } + expect(visible).toBe(true); + }); + + it.skip('(future) agent chat issues model tool_calls to echo — needs LLM + mock tool_calls', async () => { + // Tracked under #68: drive chat with a prompt that forces tool use and assert echo in thread. + }); +}); diff --git a/app/test/e2e/specs/skill-multi-round.spec.ts b/app/test/e2e/specs/skill-multi-round.spec.ts index 66ff96093..9466126d9 100644 --- a/app/test/e2e/specs/skill-multi-round.spec.ts +++ b/app/test/e2e/specs/skill-multi-round.spec.ts @@ -17,8 +17,7 @@ import { startMockServer, stopMockServer } from '../mock-server'; const USER_ID = 'e2e-skill-multi-round'; describe('Multi-round tool conversation smoke', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); @@ -35,10 +34,11 @@ describe('Multi-round tool conversation smoke', () => { const hash = await browser.execute(() => window.location.hash); expect(String(hash)).toContain('/chat'); + // /chat page renders 'Threads' (t('chat.threads')) as a stable sidebar heading. const ok = - (await textExists('Type a message...')) || - (await textExists('Ask the agent anything...')) || - (await textExists('Conversation')); + (await textExists('Threads')) || + (await textExists('New')) || + (await textExists('Type a message')); expect(ok).toBe(true); }); }); diff --git a/app/test/e2e/specs/skill-socket-reconnect.spec.ts b/app/test/e2e/specs/skill-socket-reconnect.spec.ts index 862422c5a..041243536 100644 --- a/app/test/e2e/specs/skill-socket-reconnect.spec.ts +++ b/app/test/e2e/specs/skill-socket-reconnect.spec.ts @@ -15,8 +15,7 @@ import { startMockServer, stopMockServer } from '../mock-server'; const USER_ID = 'e2e-skill-socket-reconnect'; describe('Socket reconnect skill sync smoke', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); @@ -33,10 +32,8 @@ describe('Socket reconnect skill sync smoke', () => { home = await waitForHomePage(15_000); } - const ok = - home || - (await textExists('Ask your assistant anything')) || - (await textExists('Ask your assistant')); + // waitForHomePage already checks current Home.tsx text ('Ask your assistant anything' etc.) + const ok = home || (await textExists('Ask your assistant anything')); expect(ok).toBeTruthy(); }); }); diff --git a/app/test/e2e/specs/slack-flow.spec.ts b/app/test/e2e/specs/slack-flow.spec.ts index 3f10df7b1..a34646baa 100644 --- a/app/test/e2e/specs/slack-flow.spec.ts +++ b/app/test/e2e/specs/slack-flow.spec.ts @@ -40,9 +40,6 @@ function stepLog(message: string, context?: unknown): void { describe('Slack account integration smoke', () => { before(async function beforeSuite() { - // Auth + onboarding can take longer than the default 30s per-hook budget. - this.timeout(90_000); - if (!supportsExecuteScript()) { stepLog('Skipping suite on Mac2 — Accounts rail not mapped for Appium'); this.skip(); @@ -65,9 +62,8 @@ describe('Slack account integration smoke', () => { await stopMockServer(); }); - it('shows Slack as an addable provider in the Add Account modal', async function () { - this.timeout(90_000); - stepLog('navigating to /chat'); + it('shows Slack as an addable provider in the Add Account modal', async () => { + stepLog('navigating to /accounts'); await navigateViaHash('/chat'); await waitForText('Add Account', 15_000); @@ -81,7 +77,7 @@ describe('Slack account integration smoke', () => { it('selecting Slack closes the modal and registers an account on the rail', async () => { // Set up route + modal independently so this case is runnable in isolation. - stepLog('navigating to /chat (independent setup)'); + stepLog('navigating to /accounts (independent setup)'); await navigateViaHash('/chat'); await waitForText('Add Account', 15_000); await openAddAccountModal(); diff --git a/app/test/e2e/specs/tauri-commands.spec.ts b/app/test/e2e/specs/tauri-commands.spec.ts index 7340d4feb..b3c84c25b 100644 --- a/app/test/e2e/specs/tauri-commands.spec.ts +++ b/app/test/e2e/specs/tauri-commands.spec.ts @@ -17,14 +17,12 @@ * * The Tauri commands are invoked via `window.__TAURI_INTERNALS__.invoke` * inside `browser.executeAsync(...)` so the call lives inside the WebView, - * the same way the React app reaches the shell at runtime via the - * `@tauri-apps/api/core` `invoke()` helper. - * - * Note: under the CEF runtime `window.__TAURI__` (the public namespace) is - * NOT populated. The underlying IPC bridge lives in - * `window.__TAURI_INTERNALS__`, which `@tauri-apps/api/core` uses - * internally. The first test therefore probes `__TAURI_INTERNALS__.invoke` - * rather than `__TAURI__.core.invoke`. + * the same way the React app reaches the shell at runtime. + * `window.__TAURI_INTERNALS__` is the low-level IPC channel set up by the + * Rust side; it is available on all platforms including the custom CEF + * runtime, whereas `window.__TAURI__` (the higher-level JS namespace) is + * only injected when the `@tauri-apps/api` init script runs and is not + * present in the CEF harness. */ import { waitForApp } from '../helpers/app-helpers'; import { callOpenhumanRpc } from '../helpers/core-rpc'; @@ -44,18 +42,12 @@ async function invokeTauri( ): Promise> { return (await browser.executeAsync( (command, payload, done) => { - // Under the CEF runtime, Tauri exposes the IPC bridge through - // `__TAURI_INTERNALS__` (not the public `__TAURI__` namespace which - // CEF does not populate). `@tauri-apps/api/core`'s `invoke()` helper - // reads `__TAURI_INTERNALS__.invoke` internally — so this matches the - // exact transport path the product code uses. - const internals = (window as any).__TAURI_INTERNALS__; - if (typeof internals?.invoke !== 'function') { + const invoke = (window as any).__TAURI_INTERNALS__?.invoke; + if (typeof invoke !== 'function') { done({ __error: 'window.__TAURI_INTERNALS__.invoke not available' }); return; } - internals - .invoke(command, payload) + invoke(command, payload) .then((result: unknown) => done({ __ok: result })) .catch((err: unknown) => done({ __error: err instanceof Error ? err.message : String(err) }) @@ -67,8 +59,7 @@ async function invokeTauri( } describe('Tauri commands', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await waitForApp(); await resetApp(USER_ID); }); @@ -83,12 +74,7 @@ describe('Tauri commands', () => { expect(screenshot.length).toBeGreaterThan(100); }); - it('exposes __TAURI_INTERNALS__.invoke to the renderer (CEF IPC bridge)', async () => { - // Under the CEF runtime `window.__TAURI__` (the public Tauri JS API - // namespace) is not populated. The underlying IPC bridge used by - // `@tauri-apps/api/core`'s `invoke()` lives in - // `window.__TAURI_INTERNALS__.invoke`. Asserting on `__TAURI_INTERNALS__` - // matches what the product code actually calls at runtime. + it('exposes window.__TAURI_INTERNALS__.invoke to the renderer', async () => { const present = await browser.execute( () => typeof (window as any).__TAURI_INTERNALS__?.invoke === 'function' ); @@ -111,13 +97,10 @@ describe('Tauri commands', () => { }); it('round-trips an RPC through the relay (openhuman.about_app_list)', async () => { - const res = await callOpenhumanRpc('openhuman.about_app_list', {}); + const res = await callOpenhumanRpc<{ capabilities: unknown[] }>('openhuman.about_app_list', {}); expect(res.ok).toBe(true); if (!res.ok) return; - // RpcOutcome with single_log returns {result: Capability[], logs: [string]}. - // The outer `result` field (from JSON-RPC) holds that envelope. - const capabilities = (res.result as { result?: unknown[] })?.result ?? res.result; - expect(Array.isArray(capabilities)).toBe(true); - expect((capabilities as unknown[]).length).toBeGreaterThan(0); + expect(Array.isArray(res.result.capabilities)).toBe(true); + expect(res.result.capabilities.length).toBeGreaterThan(0); }); }); diff --git a/app/test/e2e/specs/telegram-flow.spec.ts b/app/test/e2e/specs/telegram-flow.spec.ts index c1242d969..04818d2ca 100644 --- a/app/test/e2e/specs/telegram-flow.spec.ts +++ b/app/test/e2e/specs/telegram-flow.spec.ts @@ -4,13 +4,13 @@ * E2E test: Telegram Integration Flows. * * Covers: - * 7.1.1 /start Command Handling — "Message OpenHuman" button entry point + * 7.1.1 /start Command Handling — "Ask your assistant anything" button entry point * 7.1.2 Telegram ID Mapping — Telegram skill appears in SkillsGrid with status * 7.1.3 Duplicate TG Account Prevention — setup returns duplicate error * 7.2.1 Read Access — Telegram skill listed in Intelligence page * 7.2.2 Write Access — Telegram skill present with write-capable tools - * 7.2.3 Initiate Action Enforcement — "Message OpenHuman" accessible for auth users - * 7.3.1 Valid Command — "Message OpenHuman" button is clickable + * 7.2.3 Initiate Action Enforcement — "Ask your assistant anything" accessible for auth users + * 7.3.1 Valid Command — "Ask your assistant anything" button is clickable * 7.3.2 Invalid Command — skill status reflects error state * 7.3.3 Unauthorized Action — unauthorized status shown when mock returns 403 * 7.4.1 Telegram Webhook — app makes expected webhook configuration call @@ -268,28 +268,26 @@ describe.skip('Telegram Integration Flows', () => { // ------------------------------------------------------------------------- describe('7.1 Account Linking', () => { - it('7.1.1 — /start Command Handling: home screen CTA exists on Home', async () => { + it('7.1.1 — /start Command Handling: "Ask your assistant anything" button exists on Home', async () => { // Ensure we're on Home await navigateToHome(); - // Verify the home page CTA renders — t('home.askAssistant') = 'Ask your assistant anything...'. - // The old 'Message OpenHuman' button was retired when the Home page was redesigned. - const hasButton = - (await textExists('Ask your assistant anything')) || - (await textExists('Ask your assistant')); + // Verify "Ask your assistant anything" button is present — this is the /start entry point + const hasButton = await textExists('Ask your assistant anything'); if (!hasButton) { const tree = await dumpAccessibilityTree(); console.log(`${LOG_PREFIX} 7.1.1: Home page tree:\n`, tree.slice(0, 6000)); } expect(hasButton).toBe(true); - console.log(`${LOG_PREFIX} 7.1.1: Home page CTA found`); + console.log(`${LOG_PREFIX} 7.1.1: "Ask your assistant anything" button found on Home page`); // Verify Telegram skill or related content is somewhere in the app + // (Telegram drives the "Ask your assistant anything" integration) const hasTelegram = await findTelegramInUI(); if (!hasTelegram) { console.log( `${LOG_PREFIX} 7.1.1: Telegram skill not visible in UI — V8 runtime may not ` + - `have discovered it.` + `have discovered it. The "Ask your assistant anything" button still confirms /start entry point.` ); } @@ -541,34 +539,38 @@ describe.skip('Telegram Integration Flows', () => { return; } - // Telegram is visible — verify the home screen CTA is present - // (the home CTA is the entry point for agent interactions) + // Telegram is visible — verify the "Ask your assistant anything" button exists + // (the bot interaction button requires write access to Telegram) await navigateToHome(); - const hasMessageButton = - (await textExists('Ask your assistant anything')) || - (await textExists('Ask your assistant')); + const hasMessageButton = await textExists('Ask your assistant anything'); expect(hasMessageButton).toBe(true); - console.log(`${LOG_PREFIX} 7.2.2: Home CTA present — write-capable tools accessible`); + console.log( + `${LOG_PREFIX} 7.2.2: "Ask your assistant anything" button present — write-capable tools accessible` + ); console.log(`${LOG_PREFIX} 7.2.2 PASSED`); }); - it('7.2.3 — Initiate Action Enforcement: home screen CTA accessible for auth users', async () => { + it('7.2.3 — Initiate Action Enforcement: "Ask your assistant anything" accessible for auth users', async () => { resetMockBehavior(); await reAuthAndGoHome('e2e-telegram-initiate-token'); // Ensure we're on Home await navigateToHome(); - // Verify the home page CTA button exists — t('home.askAssistant'). - // The old 'Message OpenHuman' button was retired in the Home page redesign. - const hasButton = - (await textExists('Ask your assistant anything')) || - (await textExists('Ask your assistant')); + // Verify the "Ask your assistant anything" button exists and is clickable + const hasButton = await textExists('Ask your assistant anything'); expect(hasButton).toBe(true); - console.log(`${LOG_PREFIX} 7.2.3: Home page CTA is present for auth user`); + console.log( + `${LOG_PREFIX} 7.2.3: "Ask your assistant anything" button is present for auth user` + ); - console.log(`${LOG_PREFIX} 7.2.3: Home CTA is accessible for authenticated user`); + // The button should be interactable — it's the entry point for initiating Telegram actions + const buttonEl = await waitForText('Ask your assistant anything', 10_000); + const isExisting = await buttonEl.isExisting(); + expect(isExisting).toBe(true); + + console.log(`${LOG_PREFIX} 7.2.3: "Message OpenHuman" is accessible for authenticated user`); console.log(`${LOG_PREFIX} 7.2.3 PASSED`); }); }); @@ -578,31 +580,53 @@ describe.skip('Telegram Integration Flows', () => { // ------------------------------------------------------------------------- describe('7.3 Command Processing', () => { - it('7.3.1 — Valid Command: home screen CTA is clickable and navigates to chat', async () => { + it('7.3.1 — Valid Command: "Ask your assistant anything" button is clickable', async () => { resetMockBehavior(); await reAuthAndGoHome('e2e-telegram-cmd-valid-token'); await navigateToHome(); - // Verify the home page CTA button exists — t('home.askAssistant'). - // The old 'Message OpenHuman' button was retired in the Home page redesign. - const hasButton = - (await textExists('Ask your assistant anything')) || - (await textExists('Ask your assistant')); + // Verify the button exists + const hasButton = await textExists('Ask your assistant anything'); expect(hasButton).toBe(true); clearRequestLog(); - // Click the home CTA — this navigates to /chat in the current app - const ctaEl = await waitForText('Ask your assistant', 10_000); - if (ctaEl) { - await ctaEl.click(); - await browser.pause(2_000); - } - console.log(`${LOG_PREFIX} 7.3.1: Clicked home CTA button`); + // Click "Message OpenHuman" — this triggers the Telegram bot interaction + // In production, this opens the Telegram bot URL + // In testing, we verify the button is clickable without errors + const el = await waitForText('Ask your assistant anything', 10_000); + const loc = await el.getLocation(); + const sz = await el.getSize(); + const centerX = Math.round(loc.x + sz.width / 2); + const centerY = Math.round(loc.y + sz.height / 2); - // After clicking, either on /chat or still on /home — both are valid - const hash = await browser.execute(() => window.location.hash); - console.log(`${LOG_PREFIX} 7.3.1: After click — hash: ${hash}`); + await browser.performActions([ + { + type: 'pointer', + id: 'mouse1', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 10, x: centerX, y: centerY }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 50 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + await browser.releaseActions(); + console.log(`${LOG_PREFIX} 7.3.1: Clicked "Ask your assistant anything" button`); + await browser.pause(2_000); + + // After clicking, the button should remain on the page (it opens an external URL) + // or navigate away — either is valid behavior + const stillHasButton = await textExists('Ask your assistant anything'); + const isOnHome = await waitForHomePage(5_000); + // The button click either opens external URL (button still there) or navigates + // Both outcomes are valid — just ensure no crash occurred + console.log( + `${LOG_PREFIX} 7.3.1: After click — button still visible: ${stillHasButton}, ` + + `home detected: ${!!isOnHome}` + ); // Navigate back to Home for cleanup await navigateToHome(); @@ -656,11 +680,11 @@ describe.skip('Telegram Integration Flows', () => { expect(homeMarker).toBeTruthy(); console.log(`${LOG_PREFIX} 7.3.3: Home page accessible with unauthorized mock`); - // Verify "Message OpenHuman" button may still be present + // Verify "Ask your assistant anything" button may still be present // (UI should degrade gracefully — not crash) - const hasButton = await textExists('Message OpenHuman'); + const hasButton = await textExists('Ask your assistant anything'); console.log( - `${LOG_PREFIX} 7.3.3: "Message OpenHuman" button present despite unauthorized mock: ${hasButton}` + `${LOG_PREFIX} 7.3.3: "Ask your assistant anything" button present despite unauthorized mock: ${hasButton}` ); // Check Telegram status in skills grid diff --git a/app/test/e2e/specs/voice-mode.spec.ts b/app/test/e2e/specs/voice-mode.spec.ts index 262f298d4..7ffe1be52 100644 --- a/app/test/e2e/specs/voice-mode.spec.ts +++ b/app/test/e2e/specs/voice-mode.spec.ts @@ -1,37 +1,46 @@ // @ts-nocheck /** - * E2E test: Voice mode integration — smoke + * E2E test: Voice mode integration * * Covers: - * - Authenticating and reaching the home screen - * - Navigating to the chat surface (/chat) - * - Verifying the text input area renders (default mode) - * - Verifying the Voice Settings panel is reachable under Settings - * - * NOTE: The explicit voice-mode toggle UI (Input/Reply toggle group with - * "Text" and "Voice" buttons) was removed in the /chat refactor (see - * internal PR #717, "voice input mic hidden"). The voice mode flow now - * lives in the cloud-mic composer (MicComposer) on the /human page. - * This spec covers the reachable parts of the voice surface. + * - Navigating to conversations page + * - Switching to voice input mode + * - Voice status check fires and displays availability message + * - Voice input/reply mode toggle buttons render + * - Voice recording button renders in voice mode + * - Switching back to text mode restores text input * * The mock server runs on http://127.0.0.1:18473 */ import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; import { triggerAuthDeepLink } from '../helpers/deep-link-helpers'; import { + clickText, dumpAccessibilityTree, textExists, waitForWebView, waitForWindowVisible, } from '../helpers/element-helpers'; -import { completeOnboardingIfVisible, navigateViaHash } from '../helpers/shared-flows'; -import { clearRequestLog, startMockServer, stopMockServer } from '../mock-server'; +import { completeOnboardingIfVisible } from '../helpers/shared-flows'; +import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server'; + +async function waitForRequest(method, urlFragment, timeout = 15_000) { + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const log = getRequestLog(); + const match = log.find(r => r.method === method && r.url.includes(urlFragment)); + if (match) return match; + await browser.pause(500); + } + return undefined; +} async function waitForHome(timeout = 20_000) { - // After auth + onboarding the app lands on /home. + // Home.tsx renders t('home.askAssistant') = 'Ask your assistant anything...' as stable CTA. const deadline = Date.now() + timeout; while (Date.now() < deadline) { if (await textExists('Ask your assistant anything')) return true; + if (await textExists('Your device is connected')) return true; await browser.pause(700); } return false; @@ -48,7 +57,11 @@ async function waitForAnyText(candidates, timeout = 20_000) { return null; } -describe('Voice mode integration', () => { +// #717: The Input/Text/Voice toggle buttons were removed from the regular chat +// composer. Voice mode now exists only in the mascot tab (composer='mic-cloud' +// → MicComposer). These tests targeted the removed toggle UI and will always +// fail until rewritten against the mascot voice path. +describe.skip('Voice mode integration', () => { before(async () => { await startMockServer(); await waitForApp(); @@ -59,15 +72,16 @@ describe('Voice mode integration', () => { await stopMockServer(); }); - it('authenticates and reaches home, then confirms chat surface is reachable', async function () { - this.timeout(90_000); - - // --- Authenticate and reach home --- + it('can switch to voice input mode, see status message, and switch back to text', async () => { + // --- Authenticate and reach conversations --- await triggerAuthDeepLink('e2e-voice-token'); await waitForWindowVisible(25_000); await waitForWebView(15_000); await waitForAppReady(15_000); + const consume = await waitForRequest('POST', '/telegram/login-tokens/'); + expect(consume).toBeDefined(); + await completeOnboardingIfVisible('[VoiceModeE2E]'); const onHome = await waitForHome(20_000); @@ -77,34 +91,85 @@ describe('Voice mode integration', () => { } expect(onHome).toBe(true); - // --- Navigate to chat and verify text input area --- - await navigateViaHash('/chat'); + // --- Verify we see the text input area (default mode) --- + // Chat input placeholder is t('chat.typeMessage') = 'Type a message...' + const hasTextInput = await waitForAnyText(['Type a message', 'Threads', 'New'], 10_000); + expect(hasTextInput).not.toBeNull(); + + // --- Verify voice toggle buttons are visible --- + // The Input toggle group should show "Text" and "Voice" buttons + const hasInputLabel = await textExists('Input'); + expect(hasInputLabel).toBe(true); + + // --- Switch to voice input mode --- + // There are two "Voice" buttons (Input toggle and Reply toggle). + // We click the first one which is the Input mode toggle. + await clickText('Voice', 10_000); await browser.pause(2_000); - const hash = await browser.execute(() => window.location.hash); - expect(String(hash)).toContain('/chat'); + // --- Voice status check should fire --- + // Since whisper-cli is not installed in the E2E environment, + // we expect the unavailability message or the ready message. + const voiceStatusMessage = await waitForAnyText( + [ + 'Speech-to-text unavailable', + 'whisper-cli binary', + 'STT model not found', + 'Ready', + 'Start Talking', + 'Could not check voice availability', + ], + 15_000 + ); - const hasTextInput = await waitForAnyText( - ['Type a message...', 'Ask the agent anything...', 'Type a message', 'Ask the agent'], + if (!voiceStatusMessage) { + const tree = await dumpAccessibilityTree(); + console.log('[VoiceModeE2E] No voice status message seen. Tree:\n', tree.slice(0, 5000)); + } + expect(voiceStatusMessage).not.toBeNull(); + + // --- Verify the voice recording button or unavailability message is visible --- + const hasVoiceButton = await waitForAnyText( + ['Start Talking', 'Transcribing', 'Stop & Send'], 10_000 ); - expect(hasTextInput).not.toBeNull(); + if (!hasVoiceButton) { + const hasStatus = await textExists('Speech-to-text unavailable'); + expect(hasStatus).toBe(true); + } + + // --- Switch back to text mode --- + // Click the "Text" button in the Input toggle group + await clickText('Text', 10_000); + await browser.pause(1_500); + + // --- Verify text input is restored --- + const textRestored = await waitForAnyText(['Type a message', 'Threads', 'New'], 10_000); + expect(textRestored).not.toBeNull(); }); - it('Voice Settings panel is reachable under Settings', async function () { - this.timeout(90_000); - - await navigateViaHash('/settings/voice'); - await browser.pause(2_000); - - const hash = await browser.execute(() => window.location.hash); - expect(String(hash)).toContain('/settings/voice'); - - // Voice settings panel should show "Mascot Voice" or "Voice" heading. - const voiceSettingsVisible = await waitForAnyText( - ['Mascot Voice', 'Voice Dictation', 'Voice Settings', 'Voice'], - 10_000 + it('shows reply mode toggle with text and voice options', async () => { + // Ensure conversations page is loaded (re-authenticate if state was lost). + const onConversations = await waitForAnyText( + ['Type a message', 'Reply', 'Threads', 'New'], + 5_000 ); - expect(voiceSettingsVisible).not.toBeNull(); + if (!onConversations) { + await triggerAuthDeepLink('e2e-voice-token'); + await waitForWindowVisible(25_000); + await waitForWebView(15_000); + await waitForAppReady(15_000); + await completeOnboardingIfVisible('[VoiceModeE2E]'); + await waitForHome(20_000); + } + + // The Reply toggle should be visible on the conversations page + const hasReplyLabel = await textExists('Reply'); + expect(hasReplyLabel).toBe(true); + + // Verify both reply mode options exist + // (There are multiple "Text" and "Voice" buttons — Input + Reply groups) + const hasText = await textExists('Text'); + expect(hasText).toBe(true); }); }); diff --git a/app/test/e2e/specs/webhooks-ingress-flow.spec.ts b/app/test/e2e/specs/webhooks-ingress-flow.spec.ts index cf8d631b5..9a84a339e 100644 --- a/app/test/e2e/specs/webhooks-ingress-flow.spec.ts +++ b/app/test/e2e/specs/webhooks-ingress-flow.spec.ts @@ -24,8 +24,7 @@ async function openWebhooksDebugPanel(): Promise { } describe('Webhooks ingress surface (stub-level)', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await waitForApp(); await resetApp(USER_ID); @@ -37,24 +36,19 @@ describe('Webhooks ingress surface (stub-level)', () => { }); it('reaches the app shell after onboarding', async () => { - // Home page renders a CTA button with this text (t('home.askAssistant')). - // The old anchors ('Message OpenHuman', 'Good morning', 'Upgrade to - // Premium') no longer appear on the home page. + // Home.tsx: t('home.askAssistant') is the stable home page CTA button text. const atHome = - (await textExists('Ask your assistant anything')) || (await textExists('Ask your assistant')); + (await textExists('Ask your assistant anything')) || + (await textExists('Your device is connected')); expect(atHome).toBe(true); }); it('exposes the stub webhook RPC surface with stable result and log shapes', async () => { const tunnelUuid = 'e2e-webhooks-ingress-tunnel'; - // list_registrations gracefully returns [] when router is not yet initialized - // (which is the case in E2E where no real socket connection is made). const registrations = await callOpenhumanRpc('openhuman.webhooks_list_registrations', {}); expect(registrations.ok).toBe(true); expect(registrations.result?.result?.registrations).toEqual([]); - // Log message contains "returned 0" (either "registration(s)" suffix or - // "registration(s) (router not initialized)" — both are valid). expect(registrations.result?.logs?.[0]).toContain('webhooks.list_registrations returned 0'); const logs = await callOpenhumanRpc('openhuman.webhooks_list_logs', { limit: 5 }); @@ -62,46 +56,30 @@ describe('Webhooks ingress surface (stub-level)', () => { expect(logs.result?.result?.logs).toEqual([]); expect(logs.result?.logs?.[0]).toContain('webhooks.list_logs returned 0'); - // register_echo requires the socket/webhook router to be initialized. - // In E2E the backend socket is mocked so the router may not be available. - // We verify the RPC endpoint is reachable but do not hard-assert success. const register = await callOpenhumanRpc('openhuman.webhooks_register_echo', { tunnel_uuid: tunnelUuid, tunnel_name: 'E2E Tunnel', backend_tunnel_id: 'backend-e2e-webhooks-ingress', }); - stepLog('register_echo result', { ok: register.ok, result: register.result }); - // The RPC must be reachable (not a network error) — ok=true means router is up, - // ok=false means router not initialized (acceptable in E2E). - expect(typeof register.ok).toBe('boolean'); + expect(register.ok).toBe(true); + expect(register.result?.result?.registrations).toEqual([]); + expect(register.result?.logs?.[0]).toContain( + `webhooks.register_echo registered tunnel ${tunnelUuid}` + ); - if (register.ok) { - // Router was available — verify the result shape and full round-trip. - expect(Array.isArray(register.result?.result?.registrations)).toBe(true); - expect(register.result?.logs?.[0]).toContain( - `webhooks.register_echo registered tunnel ${tunnelUuid}` - ); + const clear = await callOpenhumanRpc('openhuman.webhooks_clear_logs', {}); + expect(clear.ok).toBe(true); + expect(clear.result?.result?.cleared).toBe(0); + expect(clear.result?.logs?.[0]).toContain('webhooks.clear_logs removed 0'); - const clear = await callOpenhumanRpc('openhuman.webhooks_clear_logs', {}); - expect(clear.ok).toBe(true); - expect(clear.result?.result?.cleared).toBeGreaterThanOrEqual(0); - expect(clear.result?.logs?.[0]).toContain('webhooks.clear_logs removed 0'); - - const unregister = await callOpenhumanRpc('openhuman.webhooks_unregister_echo', { - tunnel_uuid: tunnelUuid, - }); - expect(unregister.ok).toBe(true); - expect(unregister.result?.result?.registrations).toEqual([]); - expect(unregister.result?.logs?.[0]).toContain( - `webhooks.unregister_echo removed tunnel ${tunnelUuid}` - ); - } else { - // Router not initialized — verify list/clear still work gracefully. - const clear = await callOpenhumanRpc('openhuman.webhooks_clear_logs', {}); - expect(clear.ok).toBe(true); - expect(clear.result?.result?.cleared).toBe(0); - expect(clear.result?.logs?.[0]).toContain('webhooks.clear_logs removed 0'); - } + const unregister = await callOpenhumanRpc('openhuman.webhooks_unregister_echo', { + tunnel_uuid: tunnelUuid, + }); + expect(unregister.ok).toBe(true); + expect(unregister.result?.result?.registrations).toEqual([]); + expect(unregister.result?.logs?.[0]).toContain( + `webhooks.unregister_echo removed tunnel ${tunnelUuid}` + ); }); it('renders the webhooks debug panel empty states', async () => { diff --git a/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts b/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts index a2ad0f19a..7d6a18b13 100644 --- a/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts +++ b/app/test/e2e/specs/webhooks-tunnel-flow.spec.ts @@ -64,8 +64,7 @@ function unwrapRpcValue(raw: unknown): T | undefined { } describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => { - before(async function beforeSuite() { - this.timeout(90_000); + before(async () => { await startMockServer(); await resetMockBehavior(); await waitForApp(); @@ -82,11 +81,10 @@ describe('Webhook tunnel CRUD (UI + core RPC + mock backend)', () => { }); it('reached the logged-in shell after onboarding', async () => { - // Home page renders a CTA button with this text (t('home.askAssistant')). - // The old anchors ('Message OpenHuman', 'Good morning', 'Upgrade to - // Premium') no longer appear on the home page. + // Home.tsx: t('home.askAssistant') is the stable home page CTA button text. const atHome = - (await textExists('Ask your assistant anything')) || (await textExists('Ask your assistant')); + (await textExists('Ask your assistant anything')) || + (await textExists('Your device is connected')); expect(atHome).toBe(true); }); diff --git a/app/test/e2e/specs/whatsapp-flow.spec.ts b/app/test/e2e/specs/whatsapp-flow.spec.ts index 7b88ce644..d9423d2be 100644 --- a/app/test/e2e/specs/whatsapp-flow.spec.ts +++ b/app/test/e2e/specs/whatsapp-flow.spec.ts @@ -45,9 +45,6 @@ function stepLog(message: string, context?: unknown): void { describe('WhatsApp account integration smoke', () => { before(async function beforeSuite() { - // Auth + onboarding can take longer than the default 30s per-hook budget. - this.timeout(90_000); - if (!supportsExecuteScript()) { stepLog('Skipping suite on Mac2 — Accounts rail not mapped for Appium'); this.skip(); @@ -70,9 +67,8 @@ describe('WhatsApp account integration smoke', () => { await stopMockServer(); }); - it('shows WhatsApp Web as an addable provider in the Add Account modal', async function () { - this.timeout(90_000); - stepLog('navigating to /chat'); + it('shows WhatsApp Web as an addable provider in the Add Account modal', async () => { + stepLog('navigating to /accounts'); await navigateViaHash('/chat'); await waitForText('Add Account', 15_000); @@ -89,7 +85,7 @@ describe('WhatsApp account integration smoke', () => { it('selecting WhatsApp Web closes the modal and registers an account on the rail', async () => { // Set up route + modal independently so this case is runnable in isolation. - stepLog('navigating to /chat (independent setup)'); + stepLog('navigating to /accounts (independent setup)'); await navigateViaHash('/chat'); await waitForText('Add Account', 15_000); await openAddAccountModal(); diff --git a/src/openhuman/test_support/introspect.rs b/src/openhuman/test_support/introspect.rs index 272095974..4bc75daa7 100644 --- a/src/openhuman/test_support/introspect.rs +++ b/src/openhuman/test_support/introspect.rs @@ -29,11 +29,18 @@ const LIST_MAX_DEPTH: u32 = 6; /// Reject any relative path containing a `..` component or that resolves /// outside the workspace root. Returns the joined absolute path on success. fn resolve_workspace_relative(workspace: &Path, rel: &str) -> Result { - let trimmed = rel.trim_start_matches('/'); - let candidate = workspace.join(trimmed); + // Canonicalize the workspace root first so both sides of the prefix check + // share the same symlink-resolved base. On macOS `/var` is a symlink to + // `/private/var`; if we join `rel` onto the original (unresolved) workspace + // and the candidate file doesn't exist yet, `canonicalize()` falls back to + // the unresolved path — which then fails `starts_with(canonical_root)` + // because the root was resolved through the symlink. Joining onto + // `canonical_root` ensures the fallback path already shares the prefix. let canonical_root = workspace .canonicalize() .unwrap_or_else(|_| workspace.to_path_buf()); + let trimmed = rel.trim_start_matches('/'); + let candidate = canonical_root.join(trimmed); let canonical_candidate = candidate .canonicalize() .unwrap_or_else(|_| candidate.clone());