fix(e2e): sync E2E specs with current codebase (#2220)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YellowSnnowmann
2026-05-19 22:46:56 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Steven Enamakel
parent 6ace4abf3b
commit 9ec2ae765f
25 changed files with 862 additions and 415 deletions
+47
View File
@@ -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[@]}"
+147 -18
View File
@@ -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
+29 -6
View File
@@ -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
+10 -11
View File
@@ -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<void> {
const opened = await browser.execute(() => {
const buttons = Array.from(document.querySelectorAll<HTMLButtonElement>('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<void> {
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, <name> 👋' 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() {
@@ -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();
});
});
@@ -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');
+33 -26
View File
@@ -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<boolean> {
/** Click the action button (Pause | Resume | Remove | …) inside the morning_briefing row. */
async function clickActionForJob(jobName: string, action: string): Promise<boolean> {
return Boolean(
await browser.execute(
(id: string, actionName: string) => {
const btn = document.querySelector<HTMLButtonElement>(
`[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<HTMLButtonElement>('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<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const current = await browser.execute((id: string) => {
const btn = document.querySelector<HTMLButtonElement>(
`[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<HTMLButtonElement>('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<void> {
}
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.
@@ -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();
});
});
@@ -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;
+22 -102
View File
@@ -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}"`);
+6 -10
View File
@@ -54,11 +54,9 @@ async function waitForCoreSidecar(timeout = 30_000): Promise<void> {
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<void> {
}
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 () => {
@@ -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);
});
});
@@ -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);
});
});
@@ -72,8 +72,7 @@ async function defaultMessagingChannelFromStore(): Promise<string | null> {
}
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);
@@ -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:<port>/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.
});
});
+5 -5
View File
@@ -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);
});
});
@@ -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();
});
});
+3 -7
View File
@@ -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();
+14 -31
View File
@@ -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<T = unknown>(
): Promise<TauriResult<T>> {
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<T = unknown>(
}
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);
});
});
+68 -44
View File
@@ -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
+105 -40
View File
@@ -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);
});
});
@@ -24,8 +24,7 @@ async function openWebhooksDebugPanel(): Promise<void> {
}
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 () => {
@@ -64,8 +64,7 @@ function unwrapRpcValue<T = unknown>(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);
});
+3 -7
View File
@@ -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();
+9 -2
View File
@@ -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<PathBuf, String> {
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());