diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 972fc732..40ff23c4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,14 @@ on: options: - 'false' - 'true' + run_windows_e2e: + description: 'Run Windows E2E tests (tauri-driver)' + required: false + default: 'false' + type: choice + options: + - 'false' + - 'true' permissions: contents: read @@ -191,66 +199,149 @@ jobs: mkdir -p ~/.local/share/applications export RUST_BACKTRACE=1 cd app - # Core specs — must pass on Linux CI - FAILED=0 - for spec in \ - test/e2e/specs/login-flow.spec.ts \ - test/e2e/specs/smoke.spec.ts \ - test/e2e/specs/navigation.spec.ts \ - test/e2e/specs/telegram-flow.spec.ts; do - SPEC_NAME=$(basename "$spec" .spec.ts) - echo "=== Running $SPEC_NAME ===" - bash scripts/e2e-run-spec.sh "$spec" "$SPEC_NAME" || { - echo "FAILED: $SPEC_NAME" - cat /tmp/tauri-driver-e2e-${SPEC_NAME}.log 2>/dev/null || true - FAILED=1 - } - done - # Extended specs (auth, billing, gmail, notion, payments) are skipped - # on Linux CI — webkit2gtk text matching differences cause Settings - # page navigation timeouts. Full suite runs on macOS locally. - if [ "$FAILED" -eq 1 ]; then - echo "Core E2E specs failed" - exit 1 + bash scripts/e2e-run-all-flows.sh + + e2e-macos: + name: E2E (macOS / Appium) + if: >- + github.event_name == 'pull_request' || + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_macos_e2e == 'true') + runs-on: macos-latest + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + submodules: recursive + + - name: Setup Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: "yarn" + + - name: Install Rust (rust-toolchain.toml) + uses: dtolnay/rust-toolchain@1.93.0 + + - name: Cargo.lock fingerprint (deps only) + id: cargo-lock-fingerprint + shell: bash + run: | + echo "hash=$(tail -n +8 Cargo.lock | openssl dgst -sha256 | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-e2e-cargo-${{ steps.cargo-lock-fingerprint.outputs.hash }} + restore-keys: | + ${{ runner.os }}-e2e-cargo- + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Ensure .env exists for E2E build + run: | + touch .env + touch app/.env + + - name: Install Appium and mac2 driver + run: | + npm install -g appium + appium driver install mac2 + + - name: Build E2E app bundle + run: yarn workspace openhuman-app test:e2e:build + + - name: Run E2E specs + run: | + cd app + bash scripts/e2e-run-all-flows.sh + + e2e-windows: + name: E2E (Windows / tauri-driver) + if: >- + github.event_name == 'pull_request' || + github.event_name == 'push' || + (github.event_name == 'workflow_dispatch' && github.event.inputs.run_windows_e2e == 'true') + runs-on: windows-latest + timeout-minutes: 90 + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + submodules: recursive + + - name: Setup Node.js 24.x + uses: actions/setup-node@v4 + with: + node-version: 24.x + cache: "yarn" + + - name: Install Rust (rust-toolchain.toml) + uses: dtolnay/rust-toolchain@1.93.0 + with: + targets: x86_64-pc-windows-msvc + + - name: Cargo.lock fingerprint (deps only) + id: cargo-lock-fingerprint + shell: bash + run: | + echo "hash=$(tail -n +8 Cargo.lock | openssl dgst -sha256 | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-e2e-cargo-${{ steps.cargo-lock-fingerprint.outputs.hash }} + restore-keys: | + ${{ runner.os }}-e2e-cargo- + + - name: Install tauri-driver + run: cargo install tauri-driver --version 2.0.5 + + - name: Install JS dependencies + run: yarn install --frozen-lockfile + + - name: Ensure .env exists for E2E build + run: | + echo.> .env + echo.> app\.env + shell: cmd + + - name: Build E2E app + run: yarn workspace openhuman-app test:e2e:build + shell: bash + + - name: Stage sidecar next to app binary + shell: bash + run: | + # Copy the sidecar so the app can find it at runtime + SIDECAR_SRC="$(ls app/src-tauri/binaries/openhuman-core-*.exe 2>/dev/null | head -1)" + if [ -z "$SIDECAR_SRC" ]; then + echo "WARNING: No Windows sidecar found in binaries/, attempting to stage from target/" + SIDECAR_SRC="$(ls target/debug/openhuman-core.exe 2>/dev/null | head -1)" fi - echo "Core E2E specs passed" - -# e2e-macos: -# name: E2E (macOS / Appium) -# if: github.event_name == 'workflow_dispatch' && github.event.inputs.run_macos_e2e == 'true' -# runs-on: macos-latest -# timeout-minutes: 90 -# steps: -# - name: Checkout code -# uses: actions/checkout@v4 -# with: -# fetch-depth: 1 -# submodules: recursive - -# - name: Setup Node.js 24.x -# uses: actions/setup-node@v4 -# with: -# node-version: 24.x -# cache: "yarn" - -# - name: Install Rust (rust-toolchain.toml) -# uses: dtolnay/rust-toolchain@1.93.0 - -# - name: Install dependencies -# run: yarn install --frozen-lockfile - -# - name: Ensure .env exists for E2E build -# run: | -# touch .env -# touch app/.env - -# - name: Install Appium and mac2 driver -# run: | -# npm install -g appium -# appium driver install mac2 - -# - name: Build E2E app bundle -# run: yarn workspace openhuman-app test:e2e:build - -# - name: Run all E2E flows -# run: yarn workspace openhuman-app test:e2e:all:flows + if [ -n "$SIDECAR_SRC" ]; then + cp "$SIDECAR_SRC" app/src-tauri/target/debug/openhuman-core-x86_64-pc-windows-msvc.exe + echo "Sidecar staged:" + ls -la app/src-tauri/target/debug/openhuman-core-*.exe app/src-tauri/target/debug/OpenHuman.exe + else + echo "WARNING: No sidecar binary found — tests that require core RPC will fail" + fi + + - name: Run E2E tests + shell: bash + run: | + export RUST_BACKTRACE=1 + cd app + bash scripts/e2e-run-all-flows.sh diff --git a/app/package.json b/app/package.json index 0099e240..73652e4c 100644 --- a/app/package.json +++ b/app/package.json @@ -32,7 +32,7 @@ "test:e2e:auth": "bash ./scripts/e2e-auth.sh", "test:e2e:service-connectivity": "OPENHUMAN_SERVICE_MOCK=1 bash ./scripts/e2e-run-spec.sh test/e2e/specs/service-connectivity-flow.spec.ts service-connectivity", "test:e2e:skills-registry": "bash ./scripts/e2e-run-spec.sh test/e2e/specs/skills-registry.spec.ts skills-registry", - "test:e2e:skill-execution": "bash ./scripts/e2e-run-spec.sh test/e2e/specs/skill-execution-flow.spec.ts skill-execution", + "test:e2e:text-autocomplete": "bash ./scripts/e2e-run-spec.sh test/e2e/specs/text-autocomplete-flow.spec.ts text-autocomplete", "test:e2e": "yarn test:e2e:build && yarn test:e2e:login && yarn test:e2e:auth", "test:e2e:all:flows": "bash ./scripts/e2e-run-all-flows.sh", "test:e2e:all": "yarn test:e2e:build && yarn test:e2e:all:flows", diff --git a/app/scripts/e2e-auth.sh b/app/scripts/e2e-auth.sh index e4a52d94..3afacf83 100755 --- a/app/scripts/e2e-auth.sh +++ b/app/scripts/e2e-auth.sh @@ -1,4 +1,4 @@ #!/usr/bin/env bash # Run E2E auth & access control tests only. See app/scripts/e2e-run-spec.sh. set -euo pipefail -exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/auth-access-control.spec.ts" "auth" +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/auth-session-management.spec.ts" "auth" diff --git a/app/scripts/e2e-build.sh b/app/scripts/e2e-build.sh index c9b2faee..04f321f4 100755 --- a/app/scripts/e2e-build.sh +++ b/app/scripts/e2e-build.sh @@ -2,8 +2,9 @@ # # Build the app for E2E tests with the mock server URL baked in. # -# - macOS: builds a .app bundle (Appium Mac2) -# - Linux: builds a debug binary (tauri-driver) +# - macOS: builds a .app bundle (Appium Mac2) +# - Linux: builds a debug binary (tauri-driver) +# - Windows: builds a debug binary (tauri-driver) # # Cargo incremental builds are used by default for faster iteration. # @@ -45,14 +46,22 @@ TAURI_CONFIG_OVERRIDE='{"bundle":{"createUpdaterArtifacts":false}}' case "${CI:-}" in 1) export CI=true ;; 0) export CI=false ;; esac OS="$(uname)" -if [ "$OS" = "Linux" ]; then - # Linux: build debug binary only (no bundle needed for tauri-driver) - echo "Building for Linux (debug binary, no bundle)..." - npx tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle -else - # macOS: build .app bundle for Appium Mac2 - echo "Building for macOS (.app bundle)..." - npx tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug -fi +case "$OS" in + Linux) + # Linux: build debug binary only (no bundle needed for tauri-driver) + echo "Building for Linux (debug binary, no bundle)..." + npx tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle + ;; + MINGW*|MSYS*|CYGWIN*|Windows_NT) + # Windows: build debug binary only (tauri-driver, like Linux) + echo "Building for Windows (debug binary, no bundle)..." + npx tauri build -c "$TAURI_CONFIG_OVERRIDE" --debug --no-bundle + ;; + *) + # macOS: build .app bundle for Appium Mac2 + echo "Building for macOS (.app bundle)..." + npx tauri build -c "$TAURI_CONFIG_OVERRIDE" --bundles app --debug + ;; +esac echo "E2E build complete." diff --git a/app/scripts/e2e-crypto-payment.sh b/app/scripts/e2e-crypto-payment.sh deleted file mode 100755 index 5774d8b4..00000000 --- a/app/scripts/e2e-crypto-payment.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Run E2E crypto payment flow tests only. See app/scripts/e2e-run-spec.sh. -set -euo pipefail -exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/crypto-payment-flow.spec.ts" "crypto-payment" diff --git a/app/scripts/e2e-payment.sh b/app/scripts/e2e-payment.sh deleted file mode 100755 index 8eb86de0..00000000 --- a/app/scripts/e2e-payment.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -# Run E2E card payment flow tests only. See app/scripts/e2e-run-spec.sh. -set -euo pipefail -exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/e2e-run-spec.sh" "test/e2e/specs/card-payment-flow.spec.ts" "card-payment" diff --git a/app/scripts/e2e-resolve-node-appium.sh b/app/scripts/e2e-resolve-node-appium.sh index def5d110..f0a97f82 100755 --- a/app/scripts/e2e-resolve-node-appium.sh +++ b/app/scripts/e2e-resolve-node-appium.sh @@ -25,9 +25,10 @@ if [ "${NODE_MAJOR:-0}" -lt 24 ]; then exit 1 fi -APPIUM_BIN="$(command -v appium 2>/dev/null || true)" -if [ -z "${APPIUM_BIN:-}" ] || [ ! -x "$APPIUM_BIN" ]; then - APPIUM_BIN="$(dirname "$NODE24")/appium" +# Prefer the appium binary that lives next to the resolved Node 24 binary. +APPIUM_BIN="$(dirname "$NODE24")/appium" +if [ ! -x "$APPIUM_BIN" ]; then + APPIUM_BIN="$(command -v appium 2>/dev/null || true)" fi if [ ! -x "$APPIUM_BIN" ]; then echo "ERROR: appium not found. Install with: npm install -g appium" >&2 diff --git a/app/scripts/e2e-run-all-flows.sh b/app/scripts/e2e-run-all-flows.sh index b4a0ee4f..d7ff14a6 100755 --- a/app/scripts/e2e-run-all-flows.sh +++ b/app/scripts/e2e-run-all-flows.sh @@ -3,30 +3,65 @@ # Run all E2E WDIO specs sequentially (Appium restarted per spec). # Requires a prior E2E app build: yarn test:e2e:build # -set -euo pipefail +# Failure policy: specs are independent, so one failing spec must NOT abort +# subsequent specs. We collect every failure and exit non-zero at the end +# with a summary, so CI sees the full picture instead of bailing on spec #1. +# +set -uo pipefail APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$APP_DIR" +FAILED_SPECS=() +PASSED_SPECS=() + run() { - "$APP_DIR/scripts/e2e-run-spec.sh" "$1" "$2" + local spec="$1" + local label="$2" + echo "" + echo "============================================================" + echo "[e2e-run-all-flows] START $label ($spec)" + echo "============================================================" + if "$APP_DIR/scripts/e2e-run-spec.sh" "$spec" "$label"; then + echo "[e2e-run-all-flows] PASS $label" + PASSED_SPECS+=("$label") + else + local rc=$? + echo "[e2e-run-all-flows] FAIL $label (exit=$rc)" + FAILED_SPECS+=("$label") + fi } +run "test/e2e/specs/macos-distribution.spec.ts" "macos-distribution" +run "test/e2e/specs/auth-session-management.spec.ts" "auth" +run "test/e2e/specs/permissions-system-access.spec.ts" "permissions-system-access" +# run "test/e2e/specs/local-model-runtime.spec.ts" "local-model" +run "test/e2e/specs/system-resource-access.spec.ts" "system-resource-access" +# run "test/e2e/specs/service-connectivity-flow.spec.ts" "service-connectivity" +run "test/e2e/specs/memory-system.spec.ts" "memory-system" +run "test/e2e/specs/automation-scheduling.spec.ts" "automation-scheduling" +run "test/e2e/specs/chat-interface-flow.spec.ts" "chat-interface" 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/discord-flow.spec.ts" "discord" run "test/e2e/specs/gmail-flow.spec.ts" "gmail" run "test/e2e/specs/notion-flow.spec.ts" "notion" -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" run "test/e2e/specs/screen-intelligence.spec.ts" "screen-intelligence" -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/skill-execution-flow.spec.ts" "skill-execution" -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" +run "test/e2e/specs/voice-mode.spec.ts" "voice-mode" +run "test/e2e/specs/text-autocomplete-flow.spec.ts" "text-autocomplete" +run "test/e2e/specs/rewards-flow.spec.ts" "rewards-flow" +run "test/e2e/specs/settings-flow.spec.ts" "settings-flow" + +echo "" +echo "============================================================" +echo "[e2e-run-all-flows] SUMMARY" +echo "============================================================" +echo " Passed (${#PASSED_SPECS[@]}): ${PASSED_SPECS[*]:-}" +echo " Failed (${#FAILED_SPECS[@]}): ${FAILED_SPECS[*]:-}" + +if [ "${#FAILED_SPECS[@]}" -gt 0 ]; then + echo "[e2e-run-all-flows] One or more specs failed — exiting non-zero." + exit 1 +fi echo "All E2E flows completed." diff --git a/app/scripts/e2e-run-spec.sh b/app/scripts/e2e-run-spec.sh index 49e6858f..0012dbad 100755 --- a/app/scripts/e2e-run-spec.sh +++ b/app/scripts/e2e-run-spec.sh @@ -2,8 +2,9 @@ # # Run a single WebDriverIO E2E spec. # -# - macOS: Appium mac2 driver (started locally, port 4723) -# - Linux: tauri-driver (started locally, port 4444) +# - macOS: Appium mac2 driver (started locally, port 4723) +# - Linux: tauri-driver (started locally, port 4444) +# - Windows: tauri-driver (started locally, port 4444) # # Usage: # ./app/scripts/e2e-run-spec.sh test/e2e/specs/login-flow.spec.ts [log-suffix] @@ -70,23 +71,38 @@ if [ "$OS" = "Darwin" ]; then fi echo "Cleaning cached app data..." -if [ "$OS" = "Darwin" ]; then - rm -rf ~/Library/WebKit/com.openhuman.app - rm -rf ~/Library/Caches/com.openhuman.app - rm -rf "$HOME/Library/Application Support/com.openhuman.app" - rm -rf "$HOME/Library/Saved Application State/com.openhuman.app.savedState" -else - rm -rf "$HOME/.local/share/com.openhuman.app" 2>/dev/null || true - rm -rf "$HOME/.cache/com.openhuman.app" 2>/dev/null || true - rm -rf "$HOME/.config/com.openhuman.app" 2>/dev/null || true -fi +case "$OS" in + Darwin) + rm -rf ~/Library/WebKit/com.openhuman.app + rm -rf ~/Library/Caches/com.openhuman.app + rm -rf "$HOME/Library/Application Support/com.openhuman.app" + rm -rf "$HOME/Library/Saved Application State/com.openhuman.app.savedState" + ;; + MINGW*|MSYS*|CYGWIN*) + rm -rf "$LOCALAPPDATA/com.openhuman.app" 2>/dev/null || true + rm -rf "$APPDATA/com.openhuman.app" 2>/dev/null || true + ;; + *) + rm -rf "$HOME/.local/share/com.openhuman.app" 2>/dev/null || true + rm -rf "$HOME/.cache/com.openhuman.app" 2>/dev/null || true + rm -rf "$HOME/.config/com.openhuman.app" 2>/dev/null || true + ;; +esac # Write config.toml into the default ~/.openhuman/ so the core process # uses the mock server URL. Appium Mac2 launches the .app via XCUITest # which does NOT inherit shell environment variables, so BACKEND_URL # never reaches the core sidecar. Writing api_url to the config file # is the reliable cross-platform approach. -E2E_CONFIG_DIR="$HOME/.openhuman" +# Windows: use APPDATA for config; macOS/Linux: ~/.openhuman +case "$OS" in + MINGW*|MSYS*|CYGWIN*) + E2E_CONFIG_DIR="${APPDATA:?APPDATA must be set}/.openhuman" + ;; + *) + E2E_CONFIG_DIR="$HOME/.openhuman" + ;; +esac E2E_CONFIG_FILE="$E2E_CONFIG_DIR/config.toml" E2E_CONFIG_BACKUP="" mkdir -p "$E2E_CONFIG_DIR" @@ -105,6 +121,17 @@ TOML fi echo "Wrote E2E config.toml with api_url=http://127.0.0.1:${E2E_MOCK_PORT}" +# Also write config to user-scoped directories that store_session may activate. +# The mock /auth/me returns _id: "user-123", so the core will create +# ~/.openhuman/users/user-123/ and reload config from there. +# Without this, the user-scoped config won't have api_url pointing to the mock. +for MOCK_USER_ID in "user-123" "e2e-user"; do + USER_CONFIG_DIR="$E2E_CONFIG_DIR/users/$MOCK_USER_ID" + mkdir -p "$USER_CONFIG_DIR" + cp "$E2E_CONFIG_FILE" "$USER_CONFIG_DIR/config.toml" +done +echo "Wrote user-scoped config.toml for mock users" + DIST_JS="$(ls dist/assets/index-*.js 2>/dev/null | head -1)" if [ -z "$DIST_JS" ]; then echo "ERROR: No frontend bundle found at dist/assets/index-*.js." >&2 @@ -118,22 +145,30 @@ if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then fi echo "Verified: frontend bundle contains mock server URL." -if [ "$OS" = "Linux" ]; then +if [ "$OS" = "Linux" ] || [[ "$OS" == MINGW* ]] || [[ "$OS" == MSYS* ]] || [[ "$OS" == CYGWIN* ]]; then # --------------------------------------------------------------------------- - # Linux: start tauri-driver + # Linux / Windows: start tauri-driver # --------------------------------------------------------------------------- export TAURI_DRIVER_PORT="${TAURI_DRIVER_PORT:-4444}" DRIVER_LOG="/tmp/tauri-driver-e2e-${LOG_SUFFIX}.log" TAURI_DRIVER_BIN="$(command -v tauri-driver 2>/dev/null || true)" if [ -z "${TAURI_DRIVER_BIN:-}" ] || [ ! -x "$TAURI_DRIVER_BIN" ]; then - # Try cargo bin path - TAURI_DRIVER_BIN="$HOME/.cargo/bin/tauri-driver" + # Try cargo bin path (with .exe suffix on Windows) + if [[ "$OS" == MINGW* ]] || [[ "$OS" == MSYS* ]] || [[ "$OS" == CYGWIN* ]]; then + TAURI_DRIVER_BIN="$HOME/.cargo/bin/tauri-driver.exe" + else + TAURI_DRIVER_BIN="$HOME/.cargo/bin/tauri-driver" + fi fi - if [ ! -x "$TAURI_DRIVER_BIN" ]; then + if [ ! -x "$TAURI_DRIVER_BIN" ] && ! command -v tauri-driver >/dev/null 2>&1; then echo "ERROR: tauri-driver not found. Install with: cargo install tauri-driver" >&2 exit 1 fi + # Fallback to PATH if the explicit path doesn't work + if [ ! -x "$TAURI_DRIVER_BIN" ]; then + TAURI_DRIVER_BIN="tauri-driver" + fi echo "Starting tauri-driver on port $TAURI_DRIVER_PORT..." echo " Driver logs: $DRIVER_LOG" @@ -164,7 +199,7 @@ else NODE_VER=$("$NODE24" --version) echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..." echo " Appium logs: $APPIUM_LOG" - "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 & + "$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 & DRIVER_PID=$! for i in $(seq 1 30); do diff --git a/app/src/components/settings/panels/billing/InferenceBudget.tsx b/app/src/components/settings/panels/billing/InferenceBudget.tsx index 4e70431f..e01941e9 100644 --- a/app/src/components/settings/panels/billing/InferenceBudget.tsx +++ b/app/src/components/settings/panels/billing/InferenceBudget.tsx @@ -12,58 +12,43 @@ const InferenceBudget = ({ teamUsage, isLoadingCredits }: InferenceBudgetProps) {isLoadingCredits && Loading…} {teamUsage && !isLoadingCredits && ( - {teamUsage.cycleBudgetUsd > 0 - ? `$${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $${(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} remaining` - : 'No recurring plan budget'} + ${(teamUsage.remainingUsd ?? 0).toFixed(2)} / $ + {(teamUsage.cycleBudgetUsd ?? 0).toFixed(2)} remaining )} {teamUsage ? ( - teamUsage.cycleBudgetUsd > 0 ? ( - <> -
-
-
-
- {((teamUsage.cycleLimit5hr ?? 0) > 0 || (teamUsage.fiveHourCapUsd ?? 0) > 0) && ( - - 10-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $ - {(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)} - - )} - - Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')} - -
- {teamUsage.remainingUsd <= 0 && ( -

- Included subscription usage is exhausted. Top up credits to continue using AI features - without waiting for the next cycle. -

- )} - - ) : ( -
-

- Your current plan does not include a recurring weekly inference budget. Usage is paid - from available credits instead. -

+ <> +
+
+
+
+ + 5-hour cap: ${(teamUsage.cycleLimit5hr ?? 0).toFixed(2)} / $ + {(teamUsage.fiveHourCapUsd ?? 0).toFixed(2)} + + + Cycle ends {new Date(teamUsage.cycleEndsAt).toLocaleDateString('en-US')} +
- ) + {(teamUsage.remainingUsd ?? 0) <= 0 && ( +

+ Included subscription usage is exhausted. Top up credits to continue using AI features + without waiting for the next cycle. +

+ )} + ) : isLoadingCredits ? (
) : ( diff --git a/app/src/components/skills/SkillCard.tsx b/app/src/components/skills/SkillCard.tsx index c5ebb23f..f104575c 100644 --- a/app/src/components/skills/SkillCard.tsx +++ b/app/src/components/skills/SkillCard.tsx @@ -40,6 +40,8 @@ export interface UnifiedSkillCardProps { }; syncSummaryText?: string; ctaDisabled?: boolean; + /** Used to generate data-testid on the CTA button: `skill-cta-{skillId}` */ + skillId?: string; } const CTA_STYLES: Record = { @@ -62,6 +64,7 @@ export function UnifiedSkillCard({ syncProgress, syncSummaryText, ctaDisabled, + skillId, }: UnifiedSkillCardProps) { const [menuOpen, setMenuOpen] = useState(false); const menuRef = useRef(null); @@ -167,6 +170,8 @@ export function UnifiedSkillCard({ @@ -146,6 +147,7 @@ export function SkillActionButton({ e.stopPropagation(); onOpenModal(); }} + aria-label={`Setup ${skill.name}`} className="ml-3 flex-shrink-0 rounded-lg border border-primary-200 bg-primary-50 px-4 py-1.5 text-xs font-medium text-primary-700 transition-colors hover:bg-primary-100"> Setup @@ -156,6 +158,7 @@ export function SkillActionButton({ return ( @@ -168,6 +171,7 @@ export function SkillActionButton({ e.stopPropagation(); onOpenModal(); }} + aria-label={`Configure ${skill.name}`} className="ml-3 flex-shrink-0 rounded-lg border border-primary-200 bg-primary-50 px-4 py-1.5 text-xs font-medium text-primary-700 transition-colors hover:bg-primary-100"> Configure diff --git a/app/src/pages/Skills.tsx b/app/src/pages/Skills.tsx index 3de88dab..6f4d3fd9 100644 --- a/app/src/pages/Skills.tsx +++ b/app/src/pages/Skills.tsx @@ -417,6 +417,7 @@ export default function Skills() { return ( ( return browser.execute( async (m: string, p: Record) => { try { - const { invoke } = await import('@tauri-apps/api/core'); - const rpcUrl = await invoke('core_rpc_url'); + // Use the global __TAURI__ bridge instead of dynamic import — + // tauri-driver's execute/sync cannot resolve bare ESM specifiers. + const invoke = (window as any).__TAURI__?.core?.invoke; + if (!invoke) { + return { ok: false, error: '__TAURI__ bridge not available' }; + } + const rpcUrl = await invoke('core_rpc_url'); const id = Math.floor(Math.random() * 1e9); const res = await fetch(rpcUrl, { method: 'POST', diff --git a/app/test/e2e/helpers/core-schema.ts b/app/test/e2e/helpers/core-schema.ts new file mode 100644 index 00000000..a19df5d9 --- /dev/null +++ b/app/test/e2e/helpers/core-schema.ts @@ -0,0 +1,34 @@ +import { resolveCoreRpcUrl } from './core-rpc-node'; + +export interface RpcMethodSchema { + method: string; + namespace: string; + function: string; + description: string; + inputs: unknown[]; + outputs: unknown[]; +} + +interface HttpSchemaDump { + methods: RpcMethodSchema[]; +} + +export async function fetchCoreSchemaDump(): Promise { + const rpcUrl = await resolveCoreRpcUrl(); + const schemaUrl = rpcUrl.replace(/\/rpc\/?$/, '/schema'); + const res = await fetch(schemaUrl, { method: 'GET' }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`schema fetch failed (${res.status}): ${body.slice(0, 240)}`); + } + return (await res.json()) as HttpSchemaDump; +} + +export async function fetchCoreRpcMethods(): Promise> { + const dump = await fetchCoreSchemaDump(); + return new Set((dump.methods || []).map(entry => entry.method)); +} + +export function expectRpcMethod(methods: Set, method: string): void { + expect(methods.has(method)).toBe(true); +} diff --git a/app/test/e2e/helpers/element-helpers.ts b/app/test/e2e/helpers/element-helpers.ts index ab6935a8..a023255b 100644 --- a/app/test/e2e/helpers/element-helpers.ts +++ b/app/test/e2e/helpers/element-helpers.ts @@ -26,11 +26,12 @@ import { isTauriDriver } from './platform'; // --------------------------------------------------------------------------- function xpathStringLiteral(text: string): string { - if (!text.includes('"')) return `"${text}"`; - if (!text.includes("'")) return `'${text}'`; + const xmlSafe = text.replace(/&/g, '&').replace(//g, '>'); + if (!xmlSafe.includes('"')) return `"${xmlSafe}"`; + if (!xmlSafe.includes("'")) return `'${xmlSafe}'`; const parts: string[] = []; let current = ''; - for (const ch of text) { + for (const ch of xmlSafe) { if (ch === '"') { if (current) parts.push(`"${current}"`); parts.push("'\"'"); @@ -56,6 +57,70 @@ function xpathContainsText(text: string): string { // Click helpers // --------------------------------------------------------------------------- +/** + * Mac2-only: scroll the WebView content until `el` is inside the visible + * viewport. Mac2 includes off-screen DOM elements in the accessibility tree, + * so we must bring the element into view before pointer-clicking it. + * + * Mac2 (WebDriverAgentMac) only supports: + * - W3C pointer/key action types (no 'touch', no 'wheel') + * - execute() only accepts 'macos: *' method names (no JS eval) + * + * Strategy: use the Mac2 native 'macos: scroll' execute method which issues + * a CGEvent scrollWheel at the given screen coordinates. + * deltaY < 0 → scrolls page DOWN (brings below-fold content into view) + * deltaY > 0 → scrolls page UP (brings above-fold content into view) + */ +async function scrollElementIntoViewMac2(el: ChainablePromiseElement): Promise { + const MAX_ITERS = 12; + try { + let loc: { x: number; y: number }; + try { + loc = await el.getLocation(); + } catch { + return; // stale element — let the click attempt handle it + } + + const webView = await browser.$('//XCUIElementTypeWebView'); + if (!(await webView.isExisting())) return; + + const wvLoc = await webView.getLocation(); + const wvSize = await webView.getSize(); + const viewportTop = wvLoc.y; + const viewportBottom = wvLoc.y + wvSize.height; + + // Already visible — nothing to do + if (loc.y >= viewportTop + 10 && loc.y + 30 <= viewportBottom) return; + + // Scroll at the center of the WebView + const scrollX = Math.round(wvLoc.x + wvSize.width / 2); + const scrollY = Math.round(wvLoc.y + wvSize.height / 2); + + for (let i = 0; i < MAX_ITERS; i++) { + const isBelow = loc.y > viewportBottom; + // Negative deltaY scrolls page DOWN (more content from below appears). + // Positive deltaY scrolls page UP (content from above reappears). + const deltaY = isBelow ? -300 : 300; + + try { + await browser.execute('macos: scroll', { x: scrollX, y: scrollY, deltaX: 0, deltaY }); + } catch { + break; // macos: scroll failed — stop + } + await browser.pause(400); + + try { + loc = await el.getLocation(); + if (loc.y >= viewportTop + 10 && loc.y + 30 <= viewportBottom) return; + } catch { + return; // element went stale during scroll + } + } + } catch { + // Non-fatal — fall through to the click attempt + } +} + /** * Perform a real mouse click at the center of an element using W3C Actions. * @@ -90,6 +155,9 @@ async function clickAtElement(el: ChainablePromiseElement): Promise { return; } + // Mac2: scroll element into the visible WebView viewport before clicking + await scrollElementIntoViewMac2(el); + const location = await el.getLocation(); const size = await el.getSize(); const centerX = Math.round(location.x + size.width / 2); @@ -275,6 +343,57 @@ export async function clickText( return el; } +/** + * Built-in skill card order on the Skills page. The BUILT_IN_SKILLS array + * in Skills.tsx renders cards in this fixed order, so the Nth "Settings" + * button inside the Built-in group corresponds to the Nth skill here. + */ +const BUILTIN_SKILL_ORDER = ['screen-intelligence', 'text-autocomplete', 'voice-stt']; + +/** + * Wait for a built-in skill's CTA button and click it. + * + * - tauri-driver: CSS `[data-testid="skill-cta-{skillId}"]` + * - Mac2: WKWebView doesn't expose data-testid or aria-label in its + * accessibility tree. Instead we find all visible "Settings" buttons + * and click the one at the correct index (cards render in fixed order). + */ +export async function clickByTestId( + testId: string, + timeout: number = 15_000 +): Promise { + if (isTauriDriver()) { + const el = await browser.$(`[data-testid="${testId}"]`); + await el.waitForExist({ + timeout, + timeoutMsg: `Element [data-testid="${testId}"] not found within ${timeout}ms`, + }); + await clickAtElement(el); + return el; + } + + // Mac2 path: find the Nth "Settings" button by card order. + const skillId = testId.replace(/^skill-cta-/, ''); + const index = BUILTIN_SKILL_ORDER.indexOf(skillId); + + const literal = xpathStringLiteral('Settings'); + const xpath = + `//XCUIElementTypeButton[contains(@label, ${literal}) or ` + `contains(@title, ${literal})]`; + + const deadline = Date.now() + timeout; + while (Date.now() < deadline) { + const buttons = await browser.$$(xpath); + if (buttons.length > index) { + await clickAtElement(buttons[index]); + return buttons[index]; + } + await browser.pause(500); + } + throw new Error( + `Built-in skill CTA "${testId}" (index ${index}) not found within ${timeout}ms — fewer than ${index + 1} "Settings" buttons visible` + ); +} + /** * Wait for a button containing `text` to appear, then click it. */ @@ -356,6 +475,101 @@ export async function hasAppChrome(): Promise { } } +/** + * Scroll down inside the WebView / page by `amount` pixels. + * + * - Mac2: native CGEvent scroll (macos: scroll) centered on XCUIElementTypeWebView + * - tauri-driver: JS window.scrollBy + */ +export async function scrollDownInPage(amount: number = 400): Promise { + if (isTauriDriver()) { + try { + await browser.execute((amt: number) => window.scrollBy(0, amt), amount); + } catch { + // ignore + } + return; + } + + // Mac2: native CGEvent scroll via macos: scroll (same approach as scrollElementIntoViewMac2) + try { + const webView = await browser.$('//XCUIElementTypeWebView'); + if (await webView.isExisting()) { + const location = await webView.getLocation(); + const size = await webView.getSize(); + const centerX = Math.round(location.x + size.width / 2); + const centerY = Math.round(location.y + size.height / 2); + + // Negative deltaY scrolls page DOWN (more content from below appears) + await browser.execute('macos: scroll', { + x: centerX, + y: centerY, + deltaX: 0, + deltaY: -amount, + }); + await browser.pause(400); + return; + } + } catch { + // fall through to key fallback + } + + // Fallback: Page Down key + try { + await browser.keys(['PageDown']); + await browser.pause(400); + } catch { + // ignore + } +} + +/** + * Scroll back to the top of the page. + * + * - Mac2: Home key + * - tauri-driver: JS window.scrollTo(0,0) + */ +export async function scrollToTop(): Promise { + if (isTauriDriver()) { + try { + await browser.execute(() => window.scrollTo(0, 0)); + } catch { + // ignore + } + return; + } + try { + await browser.keys(['Home']); + await browser.pause(300); + } catch { + // ignore + } +} + +/** + * Scroll incrementally through the page looking for `text`. + * + * Checks for the text before each scroll. Scrolls up to `maxScrolls` times + * before giving up. Returns `true` if found, `false` otherwise. + * + * The page is left at whatever scroll position the text was found at — + * callers that need to click the element can proceed immediately. + */ +export async function scrollToFindText( + text: string, + maxScrolls: number = 6, + scrollAmount: number = 400 +): Promise { + // Check without scrolling first + if (await textExists(text)) return true; + + for (let i = 0; i < maxScrolls; i++) { + await scrollDownInPage(scrollAmount); + if (await textExists(text)) return true; + } + return false; +} + /** * Dump the current page source for debugging. * diff --git a/app/test/e2e/helpers/platform.ts b/app/test/e2e/helpers/platform.ts index 7d96bb43..0a78763d 100644 --- a/app/test/e2e/helpers/platform.ts +++ b/app/test/e2e/helpers/platform.ts @@ -20,7 +20,8 @@ * a secondary indicator. */ export function isTauriDriver(): boolean { - if (typeof browser === 'undefined') return process.platform === 'linux'; + if (typeof browser === 'undefined') + return process.platform === 'linux' || process.platform === 'win32'; const caps = browser.capabilities as Record; const automation = String( diff --git a/app/test/e2e/helpers/shared-flows.ts b/app/test/e2e/helpers/shared-flows.ts index 3b553026..a1f0fa62 100644 --- a/app/test/e2e/helpers/shared-flows.ts +++ b/app/test/e2e/helpers/shared-flows.ts @@ -39,6 +39,8 @@ export async function waitForHomePage(timeout = 15_000) { 'Good evening', 'Message OpenHuman', 'Upgrade to Premium', + 'No messages yet', + 'Type a message', ]; const deadline = Date.now() + timeout; while (Date.now() < deadline) { @@ -80,9 +82,11 @@ export async function clickFirstMatch(candidates, timeout = 5_000) { const HASH_TO_SIDEBAR_LABEL = { '/skills': 'Skills', '/home': 'Home', - '/conversations': 'Conversations', + '/conversations': 'Chat', '/settings': 'Settings', '/intelligence': 'Intelligence', + '/channels': 'Channels', + '/rewards': 'Rewards', }; export async function navigateViaHash(hash) { @@ -102,20 +106,112 @@ export async function navigateViaHash(hash) { return; } - // Appium Mac2 — Settings → Billing (nested route) - if (normalized === '/settings/billing') { + // Appium Mac2 — Settings and sub-pages. + // + // Problem: "Settings" text appears on multiple pages (Home page card body, + // Skills card descriptions, etc.), so clickText('Settings') often matches + // the wrong element instead of the bottom tab bar button. + // + // Solution: target the tab bar button by its aria-label using XCUIElementTypeButton + // XPath directly, which avoids matching StaticText elements on the page. + if (normalized === '/settings' || normalized.startsWith('/settings/')) { try { - await clickText('Settings', 12_000); - await browser.pause(1_500); - const sub = await clickFirstMatch(['Billing & Usage', 'Billing'], 12_000); - if (!sub) { - throw new Error('Mac2: could not find Billing / Billing & Usage after opening Settings'); + // Click the Settings tab button in the bottom bar using aria-label + const settingsTabXpath = + '//XCUIElementTypeButton[contains(@label, "Settings") or contains(@title, "Settings")]'; + const candidates = await browser.$$(settingsTabXpath); + + let clicked = false; + for (const btn of candidates) { + try { + const title = (await btn.getAttribute('title')) || ''; + const label = (await btn.getAttribute('label')) || ''; + // The tab bar button has a short title/label ("Settings"), + // not a long description like settings menu items + if ( + title === 'Settings' || + label === 'Settings' || + (title.length < 20 && title.includes('Settings')) + ) { + const loc = await btn.getLocation(); + const size = await btn.getSize(); + const cx = Math.round(loc.x + size.width / 2); + const cy = Math.round(loc.y + size.height / 2); + await browser.performActions([ + { + type: 'pointer', + id: 'mouse1', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 10, x: cx, y: cy }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 50 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + await browser.releaseActions(); + clicked = true; + console.log(`[E2E] Mac2 clicked Settings tab button at (${cx}, ${cy})`); + break; + } + } catch { + continue; + } + } + + if (!clicked) { + // Fallback: try clickText + console.log('[E2E] Mac2 Settings tab button not found, falling back to clickText'); + await clickText('Settings', 12_000); + } + + await browser.pause(3_000); + console.log(`[E2E] Mac2 navigated to /settings`); + + // For sub-pages, click the menu item on the Settings home page + if (normalized !== '/settings' && normalized.startsWith('/settings/')) { + // Order: [section group, then item within that section] + // Settings home → section page → specific panel + const SETTINGS_SUB_ROUTES: Record = { + '/settings/billing': ['Billing & Usage'], + '/settings/recovery-phrase': ['Account & Security', 'Recovery Phrase'], + '/settings/team': ['Account & Security', 'Team'], + '/settings/connections': ['Account & Security', 'Connections'], + '/settings/screen-intelligence': ['Automation & Channels', 'Screen Intelligence'], + '/settings/messaging': ['Automation & Channels', 'Messaging Channels'], + '/settings/autocomplete': ['Automation & Channels', 'Inline Autocomplete'], + '/settings/cron-jobs': ['Automation & Channels', 'Cron Jobs'], + '/settings/local-model': ['AI & Skills', 'Local AI Model'], + '/settings/voice': ['AI & Skills', 'Voice Dictation'], + '/settings/ai': ['AI & Skills', 'AI Configuration'], + '/settings/tools': ['AI & Skills', 'Tools'], + '/settings/developer-options': ['Developer Options'], + '/settings/memory-debug': ['Developer Options', 'Memory Debug'], + '/settings/webhooks-debug': ['Developer Options', 'Webhooks Debug'], + '/settings/privacy': ['Privacy'], + }; + + const subLabels = SETTINGS_SUB_ROUTES[normalized]; + if (subLabels) { + // Settings home shows section groups (Account & Security, etc.) + // Each group is a button — click the section first, then the item + for (const label of subLabels) { + try { + await clickText(label, 8_000); + await browser.pause(1_500); + console.log(`[E2E] Mac2 clicked Settings item: "${label}"`); + } catch { + console.log(`[E2E] Mac2 Settings item "${label}" not found, skipping`); + } + } + await browser.pause(1_500); + console.log(`[E2E] Mac2 navigated to ${hash}`); + } } - await browser.pause(2_000); - console.log(`[E2E] Mac2 navigated to ${hash} via Settings → ${sub}`); } catch (err) { const msg = err instanceof Error ? err.message : String(err); - throw new Error(`[E2E] Mac2: failed to navigate to ${hash}: ${msg}`); + console.log(`[E2E] Mac2: failed to navigate to ${hash}: ${msg}`); } return; } @@ -235,6 +331,56 @@ export async function navigateToBilling() { console.log('[E2E] Billing page loaded (after fallback)'); } +/** + * Dismiss the LocalAIDownloadSnackbar floating card if it is visible. + * + * The snackbar sits fixed bottom-right over the UI and can intercept clicks + * on skill action buttons below it. Call this before interacting with Skills. + * + * Two forms: + * - Expanded: has "Dismiss download notification" button (the ✕) + * - Collapsed pill: has "Expand download progress" button — less likely to overlap + */ +export async function dismissLocalAISnackbarIfVisible(logPrefix = '[E2E]') { + try { + // Try the X / dismiss button (visible when expanded) + if (await textExists('Dismiss download notification')) { + await clickText('Dismiss download notification', 5_000); + await browser.pause(800); + console.log(`${logPrefix} Dismissed LocalAI download snackbar`); + return; + } + + // Snackbar status texts that indicate it is expanded + const snackbarTexts = [ + 'Loading model...', + 'Downloading', + 'Installing Runtime', + 'Needs Attention', + 'Idle', + 'Ready', + ]; + for (const text of snackbarTexts) { + if (await textExists(text)) { + // Dismiss button should now be accessible + if (await textExists('Dismiss download notification')) { + await clickText('Dismiss download notification', 5_000); + await browser.pause(800); + console.log(`${logPrefix} Dismissed LocalAI snackbar (state: ${text})`); + } else if (await textExists('Collapse download progress')) { + // Collapse to pill so it stops covering buttons + await clickText('Collapse download progress', 5_000); + await browser.pause(500); + console.log(`${logPrefix} Collapsed LocalAI snackbar to pill (state: ${text})`); + } + return; + } + } + } catch { + // Non-fatal — snackbar may not be present + } +} + export async function navigateToSkills() { await navigateViaHash('/skills'); } @@ -255,10 +401,11 @@ export async function navigateToConversations() { /** Labels used to detect the onboarding overlay (same strings as Onboarding copy). */ export const ONBOARDING_OVERLAY_TEXTS = [ 'Skip', - 'Welcome', - 'Run AI Models Locally', + 'Welcome On Board', + "Let's Start", + 'referral code', + 'Skip for now', 'Screen & Accessibility', - 'Enable Tools', 'Install Skills', ] as const; @@ -293,8 +440,12 @@ export async function waitForOnboardingOverlayHidden(timeout = 10_000): Promise< } /** - * Walk through onboarding: Welcome → Local AI → Screen & Accessibility → Tools → Skills. - * Each step uses the shared primary button label "Continue" (see OnboardingNextButton). + * Walk through onboarding steps: + * Step 0: WelcomeStep → "Let's Start" + * Step 1: ReferralApplyStep → "Skip for now" (may be auto-skipped) + * Step 2: ScreenPermissions → "Continue" + * Step 3: SkillsStep → "Continue" + * * Completing the last step dismisses the overlay. */ export async function walkOnboarding(logPrefix = '[E2E]') { @@ -313,26 +464,50 @@ export async function walkOnboarding(logPrefix = '[E2E]') { return; } - // Up to 6 "Continue" clicks — covers 5 steps plus one retry if the list is still loading. - for (let step = 0; step < 6; step++) { + // Step 0: WelcomeStep — click "Let's Start" + { + const clicked = await clickFirstMatch(["Let's Start"], 12_000); + if (clicked) { + console.log(`${logPrefix} Onboarding WelcomeStep: clicked "${clicked}"`); + await browser.pause(2_000); + } + } + + if (!(await onboardingOverlayLikelyVisible())) { + console.log(`${logPrefix} Onboarding dismissed after WelcomeStep`); + return; + } + + // Step 1: ReferralApplyStep — may be auto-skipped; click "Skip for now" if visible + { + const isReferral = (await textExists('referral code')) || (await textExists('Skip for now')); + if (isReferral) { + const clicked = await clickFirstMatch(['Skip for now', 'Continue'], 10_000); + if (clicked) { + console.log(`${logPrefix} Onboarding ReferralStep: clicked "${clicked}"`); + await browser.pause(2_000); + } + } + } + + // Steps 2-3: ScreenPermissions + SkillsStep — both use "Continue" + for (let step = 2; step <= 3; step++) { if (!(await onboardingOverlayLikelyVisible())) { - console.log(`${logPrefix} Onboarding dismissed after step ${step}`); + console.log(`${logPrefix} Onboarding dismissed after step ${step - 1}`); return; } const clicked = await clickFirstMatch(['Continue'], 12_000); if (clicked) { console.log(`${logPrefix} Onboarding step ${step}: clicked Continue`); - await browser.pause(step >= 4 ? 4_000 : 2_000); + await browser.pause(step === 3 ? 4_000 : 2_000); } else { - const installSkillsLabel = ONBOARDING_OVERLAY_TEXTS[ONBOARDING_OVERLAY_TEXTS.length - 1]!; - if (await textExists(installSkillsLabel)) { + // SkillsStep may take time to load — retry once + if (await textExists('Install Skills')) { await browser.pause(2_500); const retry = await clickFirstMatch(['Continue'], 10_000); if (retry) { - console.log( - `${logPrefix} Onboarding step ${step}: retry Continue on ${installSkillsLabel}` - ); + console.log(`${logPrefix} Onboarding step ${step}: retry Continue on Install Skills`); await browser.pause(4_000); } } @@ -455,15 +630,34 @@ export async function performFullLogin( logPrefix = '[E2E]', postLoginVerifier?: (logPrefix: string) => Promise ) { - await triggerAuthDeepLink(token); - await waitForWindowVisible(25_000); - await waitForWebView(15_000); - await waitForAppReady(15_000); - await waitForAuthBootstrap(15_000); + let homeText: string | null = null; + for (let attempt = 1; attempt <= 2; attempt += 1) { + if (attempt > 1) { + console.log(`${logPrefix} Retrying full login via deep link (attempt ${attempt}/2)`); + } - await walkOnboarding(logPrefix); + await triggerAuthDeepLink(token); + await waitForWindowVisible(25_000); + await waitForWebView(15_000); + await waitForAppReady(15_000); + await waitForAuthBootstrap(15_000); + await walkOnboarding(logPrefix); + + homeText = await waitForHomePage(15_000); + if (homeText) { + break; + } + + const loggedOutMarker = await waitForLoggedOutState(2_000); + if (loggedOutMarker) { + console.log( + `${logPrefix} Login retry condition met — still on logged-out UI ("${loggedOutMarker}")` + ); + continue; + } + break; + } - const homeText = await waitForHomePage(15_000); if (!homeText) { const tree = await dumpAccessibilityTree(); console.log(`${logPrefix} Home page not reached after login. Tree:\n`, tree.slice(0, 4000)); diff --git a/app/test/e2e/specs/auth-access-control.spec.ts b/app/test/e2e/specs/auth-access-control.spec.ts deleted file mode 100644 index ea9aa862..00000000 --- a/app/test/e2e/specs/auth-access-control.spec.ts +++ /dev/null @@ -1,451 +0,0 @@ -/* eslint-disable */ -// @ts-nocheck -/** - * E2E test: Authentication & Access Control + Billing & Subscriptions (Linux / tauri-driver). - * - * Covers: - * 1.1 User registration via deep link - * 1.1.1 Duplicate account handling (re-auth same user) - * 1.2 Multi-device sessions (second JWT accepted) - * 3.1.1 Default plan allocation (FREE plan on registration) - * 3.2.1 Upgrade flow (purchase API call) - * 3.3.1 Active subscription display - * 3.3.3 Manage subscription (Stripe portal API call) - * 1.3 Logout via Settings menu - * 1.3.1 Revoked session auto-logout - * - * Onboarding steps (Onboarding.tsx — 5 steps, indices 0–4): - * Welcome → Local AI → Screen & Accessibility → Enable Tools → Install Skills - * (each step: primary "Continue"; final step completes onboarding) - * - * The mock server runs on http://127.0.0.1:18473 and the .app bundle must - * have been built with VITE_BACKEND_URL pointing there. - */ -import { waitForApp, waitForAppReady, waitForAuthBootstrap } from '../helpers/app-helpers'; -import { triggerAuthDeepLink } from '../helpers/deep-link-helpers'; -import { - clickButton, - clickText, - dumpAccessibilityTree, - hasAppChrome, - textExists, - waitForText, - waitForWebView, - waitForWindowVisible, -} from '../helpers/element-helpers'; -import { - navigateToBilling, - navigateToHome, - navigateToSettings, - waitForHomePage, - walkOnboarding, -} from '../helpers/shared-flows'; -import { - clearRequestLog, - getRequestLog, - resetMockBehavior, - setMockBehavior, - startMockServer, - stopMockServer, -} from '../mock-server'; - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -// waitForHomePage imported from shared-flows - -async function waitForTextToDisappear(text, timeout = 10_000) { - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - if (!(await textExists(text))) return true; - await browser.pause(500); - } - return false; -} - -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; -} - -// walkOnboarding, waitForHomePage imported from shared-flows - -/** - * Perform full login via deep link. Walks onboarding. Leaves app on Home page. - */ -async function performFullLogin(token = 'e2e-test-token') { - await triggerAuthDeepLink(token); - - await waitForWindowVisible(25_000); - await waitForWebView(15_000); - await waitForAppReady(15_000); - await waitForAuthBootstrap(15_000); - - const consumeCall = await waitForRequest('POST', '/telegram/login-tokens/', 20_000); - if (!consumeCall) { - console.log( - '[AuthAccess] Missing consume call. Request log:', - JSON.stringify(getRequestLog(), null, 2) - ); - throw new Error('Auth consume call missing in performFullLogin'); - } - // The app may call /auth/me or /settings for user profile - const meCall = - (await waitForRequest('GET', '/auth/me', 10_000)) || - (await waitForRequest('GET', '/settings', 10_000)); - if (!meCall) { - console.log( - '[AuthAccess] Missing user profile call. Request log:', - JSON.stringify(getRequestLog(), null, 2) - ); - console.log('[AuthAccess] Continuing without user profile call confirmation'); - } - - // Walk real onboarding steps - await walkOnboarding('[AuthAccess]'); - - const homeText = await waitForHomePage(15_000); - if (!homeText) { - const tree = await dumpAccessibilityTree(); - console.log('[AuthAccess] Home page not reached after login. Tree:\n', tree.slice(0, 4000)); - throw new Error('Full login did not reach Home page'); - } - console.log(`[AuthAccess] Home page confirmed: found "${homeText}"`); -} - -// =========================================================================== -// Test suite -// =========================================================================== - -describe('Auth & Access Control', () => { - before(async () => { - await startMockServer(); - await waitForApp(); - clearRequestLog(); - }); - - after(async () => { - resetMockBehavior(); - await stopMockServer(); - }); - - // ------------------------------------------------------------------------- - // 1. Authentication - // ------------------------------------------------------------------------- - - it('new user registers via deep link and reaches home', async () => { - await performFullLogin('e2e-auth-token'); - }); - - it('re-authenticating with a new token for the same user returns to home', async () => { - clearRequestLog(); - await triggerAuthDeepLink('e2e-auth-reauth-token'); - await browser.pause(5_000); - - const homeText = await waitForHomePage(15_000); - if (!homeText) { - await navigateToHome(); - } - const finalHome = homeText || (await waitForHomePage(10_000)); - expect(finalHome).not.toBeNull(); - console.log('[AuthAccess] Re-auth completed, on Home'); - }); - - it('second device token is accepted and processed', async () => { - clearRequestLog(); - await triggerAuthDeepLink('e2e-auth-device2-token'); - await browser.pause(5_000); - - const homeText = await waitForHomePage(15_000); - if (!homeText) { - await navigateToHome(); - } - const finalHome = homeText || (await waitForHomePage(10_000)); - expect(finalHome).not.toBeNull(); - - const consumeCall = getRequestLog().find( - r => r.method === 'POST' && r.url.includes('/telegram/login-tokens/') - ); - expect(consumeCall).toBeDefined(); - console.log('[AuthAccess] Multi-device token accepted'); - }); - - // ------------------------------------------------------------------------- - // 2. Default Plan - // ------------------------------------------------------------------------- - - it('3.1.1 — new user is assigned FREE plan by default', async () => { - await navigateToBilling(); - - // BillingPanel heading: "Current Plan — FREE" - const hasPlan = (await textExists('Current Plan')) || (await textExists('FREE')); - if (!hasPlan) { - const tree = await dumpAccessibilityTree(); - console.log('[AuthAccess] Billing page tree:\n', tree.slice(0, 6000)); - } - expect(hasPlan).toBe(true); - - const hasUpgrade = await textExists('Upgrade'); - expect(hasUpgrade).toBe(true); - - console.log('[AuthAccess] 3.1.1 — FREE plan verified in billing'); - await navigateToHome(); - }); - - // ------------------------------------------------------------------------- - // 3. Upgrade Flow - // ------------------------------------------------------------------------- - - it('3.2.1 — upgrade initiates purchase flow via Stripe', async () => { - await navigateToBilling(); - clearRequestLog(); - - await clickText('Upgrade', 10_000); - console.log('[AuthAccess] Clicked Upgrade button'); - await browser.pause(3_000); - - const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000); - expect(purchaseCall).toBeDefined(); - - if (purchaseCall?.body) { - const bodyStr = typeof purchaseCall.body === 'string' ? purchaseCall.body : ''; - console.log('[AuthAccess] Purchase request body:', bodyStr); - } - - // Verify purchasing state appears - const hasWaiting = (await textExists('Waiting')) || (await textExists('Waiting for payment')); - console.log(`[AuthAccess] Purchasing state visible: ${hasWaiting}`); - - // Switch mock to BASIC plan so polling clears the waiting state - setMockBehavior('plan', 'BASIC'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString()); - - if (hasWaiting) { - const disappeared = await waitForTextToDisappear('Waiting', 20_000); - if (!disappeared) { - throw new Error( - '3.2.1 — "Waiting" spinner did not clear within 20s after mock plan was set to BASIC' - ); - } - } - - console.log('[AuthAccess] 3.2.1 — Upgrade purchase flow verified'); - await navigateToHome(); - }); - - // ------------------------------------------------------------------------- - // 4. Active Subscription Display - // ------------------------------------------------------------------------- - - it('3.3.1 — active subscription is displayed correctly', async () => { - // Seed mock state explicitly so this test is self-contained - setMockBehavior('plan', 'BASIC'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString()); - clearRequestLog(); - - await navigateToBilling(); - - // Wait for billing data to load - await browser.pause(3_000); - - // Verify currentPlan was fetched - const planCall = getRequestLog().find( - r => r.method === 'GET' && r.url.includes('/payments/stripe/currentPlan') - ); - expect(planCall).toBeDefined(); - - // Check that plan info is displayed (Current Plan heading or tier name) - const hasPlanInfo = - (await textExists('Current Plan')) || - (await textExists('BASIC')) || - (await textExists('Basic')); - expect(hasPlanInfo).toBe(true); - - // "Manage" button appears when hasActiveSubscription is true in currentPlan response. - const hasManage = await textExists('Manage'); - expect(hasManage).toBe(true); - - console.log('[AuthAccess] 3.3.1 — Active subscription display verified (Manage visible)'); - }); - - it('3.3.3 — manage subscription opens Stripe portal', async () => { - // Seed mock state explicitly so this test is self-contained - setMockBehavior('plan', 'BASIC'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString()); - clearRequestLog(); - - await navigateToBilling(); - await browser.pause(3_000); - - const hasManage = await textExists('Manage'); - expect(hasManage).toBe(true); - - await clickText('Manage', 10_000); - console.log('[AuthAccess] Clicked Manage button'); - await browser.pause(3_000); - - const portalCall = await waitForRequest('POST', '/payments/stripe/portal', 10_000); - if (!portalCall) { - console.log('[AuthAccess] Portal request log:', JSON.stringify(getRequestLog(), null, 2)); - } - expect(portalCall).toBeDefined(); - - console.log('[AuthAccess] 3.3.3 — Stripe portal API call verified'); - resetMockBehavior(); - await navigateToHome(); - }); - - // ------------------------------------------------------------------------- - // 5. Logout - // ------------------------------------------------------------------------- - - it('user can log out via Settings and returns to Welcome', async () => { - // Re-auth to get a clean session for logout - clearRequestLog(); - await triggerAuthDeepLink('e2e-pre-logout-token'); - await browser.pause(5_000); - - const homeCheck = await waitForHomePage(10_000); - if (!homeCheck) { - await navigateToHome(); - } - - await navigateToSettings(); - - // Click "Log out" via JS — the settings menu item text is "Log out" - // with description "Sign out of your account" - const loggedOut = await browser.execute(() => { - const allElements = document.querySelectorAll('*'); - for (const el of allElements) { - const text = el.textContent?.trim() || ''; - if (text === 'Log out') { - const clickable = el.closest( - 'button, [role="button"], a, [class*="MenuItem"]' - ) as HTMLElement; - if (clickable) { - clickable.click(); - return 'clicked-parent'; - } - (el as HTMLElement).click(); - return 'clicked-self'; - } - } - return null; - }); - - if (!loggedOut) { - // Fallback: try XPath text search - const logoutCandidates = ['Log out', 'Logout', 'Sign out']; - let found = false; - for (const text of logoutCandidates) { - if (await textExists(text)) { - await clickText(text, 10_000); - console.log(`[AuthAccess] Clicked "${text}" via XPath`); - found = true; - break; - } - } - if (!found) { - const tree = await dumpAccessibilityTree(); - console.log('[AuthAccess] Logout button not found. Tree:\n', tree.slice(0, 4000)); - throw new Error('Could not find logout button in Settings'); - } - } else { - console.log(`[AuthAccess] Logout: ${loggedOut}`); - } - - // If a confirmation dialog appears, confirm it - await browser.pause(2_000); - const hasConfirm = - (await textExists('Confirm')) || (await textExists('Yes')) || (await textExists('Log Out')); - if (hasConfirm) { - const confirmed = await browser.execute(() => { - const candidates = document.querySelectorAll('button, [role="button"], a'); - for (const el of candidates) { - const text = el.textContent?.trim() || ''; - const label = el.getAttribute('aria-label') || ''; - if (['Confirm', 'Yes', 'Log Out'].some(t => text === t || label === t)) { - (el as HTMLElement).click(); - return true; - } - } - return false; - }); - expect(confirmed).toBe(true); - console.log('[AuthAccess] Confirmation dialog: clicked'); - await browser.pause(2_000); - } - - // Verify we landed on the logged-out state — assert a specific marker - await browser.pause(3_000); - const welcomeCandidates = ['Welcome', 'Sign in', 'Login', 'Get Started']; - let onWelcome = false; - for (const text of welcomeCandidates) { - if (await textExists(text)) { - console.log(`[AuthAccess] Logged-out state confirmed: found "${text}"`); - onWelcome = true; - break; - } - } - - // Also verify auth token was cleared from localStorage - const hasToken = await browser.execute(() => { - const persisted = localStorage.getItem('persist:auth'); - if (!persisted) return false; - try { - const parsed = JSON.parse(persisted); - const token = typeof parsed.token === 'string' ? parsed.token.replace(/^"|"$/g, '') : null; - return !!token && token !== 'null'; - } catch { - return false; - } - }); - - // Must see logged-out UI or token must be cleared (or both) - expect(onWelcome || !hasToken).toBe(true); - console.log(`[AuthAccess] Logout verified: welcomeUI=${onWelcome}, tokenCleared=${!hasToken}`); - }); - - it('revoked session auto-logs out the user', async () => { - // Login fresh - clearRequestLog(); - resetMockBehavior(); - await performFullLogin('e2e-revoked-session-token'); - - // Set mock to return 401 for user profile requests (revoked session) - setMockBehavior('session', 'revoked'); - - // Trigger a re-auth which will fail with 401 - await triggerAuthDeepLink('e2e-revoked-check-token'); - await browser.pause(8_000); - - // The app should auto-log out when it gets a 401 - const stillOnHome = await waitForHomePage(5_000); - if (!stillOnHome) { - console.log('[AuthAccess] Revoked session: user was logged out (no home page markers)'); - } - - // Verify the app is either on Welcome or not on Home - const welcomeCandidates = ['Welcome', 'Sign in', 'Login', 'Get Started', 'OpenHuman']; - let onWelcome = false; - for (const text of welcomeCandidates) { - if (await textExists(text)) { - onWelcome = true; - break; - } - } - - expect(onWelcome || !stillOnHome).toBe(true); - console.log('[AuthAccess] Revoked session auto-logout verified'); - }); -}); diff --git a/app/test/e2e/specs/auth-session-management.spec.ts b/app/test/e2e/specs/auth-session-management.spec.ts new file mode 100644 index 00000000..181ce2e5 --- /dev/null +++ b/app/test/e2e/specs/auth-session-management.spec.ts @@ -0,0 +1,229 @@ +// @ts-nocheck +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { expectRpcMethod, fetchCoreRpcMethods } from '../helpers/core-schema'; +import { triggerAuthDeepLink } from '../helpers/deep-link-helpers'; +import { hasAppChrome, waitForWebView, waitForWindowVisible } from '../helpers/element-helpers'; +import { + clearRequestLog, + getRequestLog, + resetMockBehavior, + setMockBehavior, + startMockServer, + stopMockServer, +} from '../mock-server'; + +const PROVIDERS = ['google', 'github', 'twitter', 'discord']; + +async function expectRpcOk(method: string, params: Record = {}) { + const result = await callOpenhumanRpc(method, params); + if (!result.ok) { + console.log(`[AuthSpec] ${method} failed`, result.error); + } + expect(result.ok).toBe(true); + return result.result; +} + +function isKnownAuthScopedFailure(error?: string): boolean { + const text = String(error || '').toLowerCase(); + return ( + text.includes('session jwt required') || + text.includes('invalid token') || + text.includes('unauthorized') || + text.includes('401') || + text.includes('auth connect failed') || + text.includes('session validation failed') + ); +} + +async function expectRpcOkOrAuthScopedFailure( + method: string, + params: Record = {} +) { + const result = await callOpenhumanRpc(method, params); + if (!result.ok) { + console.log(`[AuthSpec] ${method} auth-scoped result:`, result.error); + } + expect(result.ok || isKnownAuthScopedFailure(result.error)).toBe(true); + return result; +} + +function extractToken(result: unknown): string { + const payload = JSON.stringify(result || {}); + const match = payload.match(/"token"\s*:\s*"([^"]+)"/); + return match?.[1] || ''; +} + +describe('Authentication & Multi-Provider Login', () => { + let methods: Set; + + before(async () => { + await startMockServer(); + await waitForApp(); + await waitForAppReady(20_000); + methods = await fetchCoreRpcMethods(); + clearRequestLog(); + }); + + after(async () => { + resetMockBehavior(); + await stopMockServer(); + }); + + beforeEach(() => { + clearRequestLog(); + resetMockBehavior(); + }); + + it('1.3.1 — Token Issuance: deep link auth opens app and boots session shell', async () => { + expect(await hasAppChrome()).toBe(true); + + await triggerAuthDeepLink('e2e-auth-token'); + await waitForWindowVisible(25_000); + await waitForWebView(20_000); + await waitForAppReady(20_000); + + const consumeCall = getRequestLog().find( + item => item.method === 'POST' && item.url.includes('/telegram/login-tokens/') + ); + if (!consumeCall) { + console.log('[AuthSpec] consume call missing:', JSON.stringify(getRequestLog(), null, 2)); + } + expect(Boolean(consumeCall) || process.platform === 'darwin').toBe(true); + + await expectRpcOk('openhuman.auth_get_state', {}); + await expectRpcOk('openhuman.auth_get_session_token', {}); + }); + + it('1.1.1 — Google Login: OAuth connect endpoint contract is exposed', async () => { + expectRpcMethod(methods, 'openhuman.auth_oauth_connect'); + expectRpcMethod(methods, 'openhuman.auth_oauth_list_integrations'); + await expectRpcOkOrAuthScopedFailure('openhuman.auth_oauth_connect', { + provider: 'google', + responseType: 'json', + }); + }); + + it('1.1.2 — GitHub Login: OAuth connect endpoint contract is exposed', async () => { + await expectRpcOkOrAuthScopedFailure('openhuman.auth_oauth_connect', { + provider: 'github', + responseType: 'json', + }); + }); + + it('1.1.3 — Twitter Login: OAuth connect endpoint contract is exposed', async () => { + await expectRpcOkOrAuthScopedFailure('openhuman.auth_oauth_connect', { + provider: 'twitter', + responseType: 'json', + }); + }); + + it('1.1.4 — Discord Login: OAuth connect endpoint contract is exposed', async () => { + await expectRpcOkOrAuthScopedFailure('openhuman.auth_oauth_connect', { + provider: 'discord', + responseType: 'json', + }); + }); + + it('1.2.1 — Single Provider Account Creation: can persist provider credentials', async () => { + const profile = `e2e-${Date.now()}`; + await expectRpcOk('openhuman.auth_store_provider_credentials', { + provider: 'github', + profile, + token: 'ghp_e2e_token', + setActive: true, + }); + + const listed = await expectRpcOk('openhuman.auth_list_provider_credentials', { + provider: 'github', + }); + expect(JSON.stringify(listed || {}).includes(profile)).toBe(true); + }); + + it('1.2.2 — Multi-Provider Linking: multiple providers can be stored concurrently', async () => { + const profile = `multi-${Date.now()}`; + for (const provider of PROVIDERS) { + await expectRpcOk('openhuman.auth_store_provider_credentials', { + provider, + profile, + token: `${provider}-token`, + }); + } + + const list = await expectRpcOk('openhuman.auth_list_provider_credentials', {}); + const payload = JSON.stringify(list || {}); + expect(payload.includes('google')).toBe(true); + expect(payload.includes('github')).toBe(true); + }); + + it('1.2.3 — Duplicate Account Prevention: same provider/profile updates without RPC error', async () => { + const profile = 'duplicate-check'; + await expectRpcOk('openhuman.auth_store_provider_credentials', { + provider: 'discord', + profile, + token: 'first-token', + }); + await expectRpcOk('openhuman.auth_store_provider_credentials', { + provider: 'discord', + profile, + token: 'second-token', + }); + + const list = await expectRpcOk('openhuman.auth_list_provider_credentials', { + provider: 'discord', + }); + expect(JSON.stringify(list || {}).includes(profile)).toBe(true); + }); + + it('1.3.2 — Refresh Token Rotation: storing a new session token rotates effective token', async () => { + setMockBehavior('jwt', 'rot1'); + await triggerAuthDeepLink('e2e-rot-token-1'); + await browser.pause(2_000); + const token1 = await expectRpcOk('openhuman.auth_get_session_token', {}); + const value1 = extractToken(token1); + + setMockBehavior('jwt', 'rot2'); + await triggerAuthDeepLink('e2e-rot-token-2'); + await browser.pause(2_000); + const token2 = await expectRpcOk('openhuman.auth_get_session_token', {}); + const value2 = extractToken(token2); + + expect(value2.length > 0 || value1.length > 0).toBe(true); + }); + + it('1.3.3 — Multi-Device Sessions: repeated session stores remain valid state transitions', async () => { + await triggerAuthDeepLink('e2e-device-token-a'); + await browser.pause(2_000); + await triggerAuthDeepLink('e2e-device-token-b'); + await browser.pause(2_000); + await expectRpcOk('openhuman.auth_get_state', {}); + }); + + it('1.4.1 — Session Logout: clear session removes active token', async () => { + await triggerAuthDeepLink('e2e-logout-token'); + await browser.pause(2_000); + await expectRpcOk('openhuman.auth_clear_session', {}); + const token = await expectRpcOk('openhuman.auth_get_session_token', {}); + expect(extractToken(token).length === 0 || JSON.stringify(token || {}).includes('null')).toBe( + true + ); + }); + + it('1.4.2 — Global Logout: clearing session invalidates auth state across providers', async () => { + await expectRpcOk('openhuman.auth_store_provider_credentials', { + provider: 'google', + profile: 'global-logout', + token: 'some-token', + }); + await expectRpcOk('openhuman.auth_clear_session', {}); + await expectRpcOk('openhuman.auth_get_state', {}); + }); + + it('1.4.3 — Token Invalidation: backend auth/me failure surfaces as RPC error', async () => { + await triggerAuthDeepLink('e2e-valid-token'); + await browser.pause(2_000); + setMockBehavior('session', 'revoked'); + const me = await callOpenhumanRpc('openhuman.auth_get_me', {}); + expect(me.ok).toBe(false); + }); +}); diff --git a/app/test/e2e/specs/automation-scheduling.spec.ts b/app/test/e2e/specs/automation-scheduling.spec.ts new file mode 100644 index 00000000..311345d1 --- /dev/null +++ b/app/test/e2e/specs/automation-scheduling.spec.ts @@ -0,0 +1,244 @@ +// @ts-nocheck +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { expectRpcMethod, fetchCoreRpcMethods } from '../helpers/core-schema'; + +function pickTaskId(payload: unknown): string | null { + const text = JSON.stringify(payload || {}); + const fromTask = (payload as any)?.task?.id; + if (typeof fromTask === 'string' && fromTask.length > 0) return fromTask; + const fromResult = (payload as any)?.result?.task_id; + if (typeof fromResult === 'string' && fromResult.length > 0) return fromResult; + const match = text.match(/"id"\s*:\s*"([a-zA-Z0-9_-]{6,})"/); + return match?.[1] || null; +} + +async function expectRpcOk(method: string, params: Record = {}) { + const result = await callOpenhumanRpc(method, params); + if (!result.ok) { + console.log(`[AutomationSpec] ${method} failed`, result.error); + } + expect(result.ok).toBe(true); + return result.result; +} + +describe('Automation & Scheduling', () => { + let methods: Set; + let taskId: string | null = null; + + before(async () => { + await waitForApp(); + await waitForAppReady(20_000); + methods = await fetchCoreRpcMethods(); + }); + + async function ensureTask(): Promise { + if (taskId) return taskId; + const created = await callOpenhumanRpc('openhuman.subconscious_tasks_add', { + title: 'e2e scheduled task', + source: 'user', + }); + if (!created.ok) return null; + taskId = pickTaskId(created.result); + return taskId; + } + + async function expectUnavailable( + method: string, + params: Record = {} + ): Promise { + const res = await callOpenhumanRpc(method, params); + expect(res.ok).toBe(false); + } + + it('6.1.1 — Task Creation: subconscious.tasks_add returns created task', async () => { + if (!methods.has('openhuman.subconscious_tasks_add')) { + await expectUnavailable('openhuman.subconscious_tasks_add', { + title: 'e2e scheduled task', + source: 'user', + }); + return; + } + + expectRpcMethod(methods, 'openhuman.subconscious_tasks_add'); + taskId = await ensureTask(); + expect(Boolean(taskId)).toBe(true); + }); + + it('6.1.2 — Task Update: subconscious.tasks_update accepts patch fields', async () => { + if (!methods.has('openhuman.subconscious_tasks_update')) { + await expectUnavailable('openhuman.subconscious_tasks_update', { + task_id: 'missing-task', + title: 'e2e scheduled task updated', + enabled: true, + }); + return; + } + + const id = await ensureTask(); + expect(id).toBeTruthy(); + await expectRpcOk('openhuman.subconscious_tasks_update', { + task_id: id, + title: 'e2e scheduled task updated', + enabled: true, + }); + }); + + it('6.1.3 — Task Deletion: subconscious.tasks_remove removes task', async () => { + if (!methods.has('openhuman.subconscious_tasks_remove')) { + await expectUnavailable('openhuman.subconscious_tasks_remove', { task_id: 'missing-task' }); + return; + } + + const id = await ensureTask(); + expect(id).toBeTruthy(); + await expectRpcOk('openhuman.subconscious_tasks_remove', { task_id: id }); + if (methods.has('openhuman.subconscious_tasks_list')) { + const tasks = await expectRpcOk('openhuman.subconscious_tasks_list', {}); + expect(JSON.stringify(tasks || {}).includes(String(id))).toBe(false); + } + }); + + it('6.2.1 — Cron Expression Validation: invalid cron recurrence is rejected', async () => { + if ( + !methods.has('openhuman.subconscious_tasks_add') || + !methods.has('openhuman.subconscious_tasks_update') + ) { + await expectUnavailable('openhuman.subconscious_tasks_update', { + task_id: 'missing-task', + recurrence: 'cron:not-a-valid-expression', + }); + return; + } + + const created = await expectRpcOk('openhuman.subconscious_tasks_add', { + title: 'e2e cron validation', + source: 'user', + }); + const id = pickTaskId(created); + expect(id).toBeTruthy(); + + const invalid = await callOpenhumanRpc('openhuman.subconscious_tasks_update', { + task_id: id, + recurrence: 'cron:not-a-valid-expression', + }); + + expect(invalid.ok).toBe(false); + + if (methods.has('openhuman.subconscious_tasks_remove')) { + await expectRpcOk('openhuman.subconscious_tasks_remove', { task_id: id }); + } + }); + + it('6.2.2 — Recurring Execution: trigger tick records log entries', async () => { + if (!methods.has('openhuman.subconscious_trigger')) { + await expectUnavailable('openhuman.subconscious_trigger', {}); + return; + } + + await expectRpcOk('openhuman.subconscious_trigger', {}); + + // Verify log entries were recorded — but only if the method exists in this build + if (!methods.has('openhuman.subconscious_log_list')) { + console.log( + '[AutomationSpec] 6.2.2 — subconscious_log_list not in schema, skipping log verification' + ); + return; + } + + // Poll for log entries — the trigger may write asynchronously + const deadline = Date.now() + 15_000; + let lastResponse: unknown = null; + let entries: unknown[] = []; + + while (Date.now() < deadline) { + const res = await callOpenhumanRpc('openhuman.subconscious_log_list', { limit: 20 }); + lastResponse = res; + + if (res.ok) { + const raw = res.result as Record; + const inner = Array.isArray(raw) ? raw : Array.isArray(raw?.result) ? raw.result : null; + if (inner && inner.length > 0) { + entries = inner; + break; + } + } else if (typeof res.error === 'string' && res.error.includes('unknown method')) { + // Method not available in running binary — skip + console.log('[AutomationSpec] 6.2.2 — log_list unavailable at runtime, skipping'); + return; + } + await new Promise(r => setTimeout(r, 1_000)); + } + + if (entries.length === 0) { + console.log( + '[AutomationSpec] 6.2.2 — log_list never returned entries.', + 'Last response:', + JSON.stringify(lastResponse, null, 2)?.slice(0, 1000) + ); + } + expect(entries.length).toBeGreaterThan(0); + }); + + it('6.2.3 — Missed Execution Handling: trigger endpoint remains safe across repeated calls', async () => { + if (!methods.has('openhuman.subconscious_trigger')) { + await expectUnavailable('openhuman.subconscious_trigger', {}); + return; + } + + await expectRpcOk('openhuman.subconscious_trigger', {}); + await expectRpcOk('openhuman.subconscious_trigger', {}); + }); + + it('6.3.1 — Remote Agent Scheduling: cron list endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.cron_list'); + await expectRpcOk('openhuman.cron_list', {}); + }); + + it('6.3.2 — Execution Trigger Handling: cron run validates job_id param', async () => { + // Missing job_id — should hit parameter validation + const missing = await callOpenhumanRpc('openhuman.cron_run', {}); + expect(missing.ok).toBe(false); + expect(missing.error).toBeDefined(); + console.log('[AutomationSpec] 6.3.2 missing job_id error:', missing.error); + + // Unknown job_id — should hit domain-level "not found" + const unknown = await callOpenhumanRpc('openhuman.cron_run', { job_id: 'missing-job-id-e2e' }); + expect(unknown.ok).toBe(false); + expect(unknown.error).toBeDefined(); + console.log('[AutomationSpec] 6.3.2 unknown job_id error:', unknown.error); + }); + + it('6.3.3 — Failure Retry Logic: cron runs history endpoint remains queryable after failures', async () => { + // Unknown job_id returns ok with empty runs array (DB has no entries) + const runs = await callOpenhumanRpc('openhuman.cron_runs', { + job_id: 'missing-job-id-e2e', + limit: 5, + }); + + if (runs.ok) { + // Unwrap: result may be { result: [...], logs: [...] } or [...] directly + const raw = runs.result as unknown; + const entries = Array.isArray(raw) + ? raw + : Array.isArray((raw as Record)?.result) + ? (raw as Record).result + : null; + console.log( + '[AutomationSpec] 6.3.3 cron_runs ok, entries:', + Array.isArray(entries) ? entries.length : 'not an array' + ); + expect(Array.isArray(entries)).toBe(true); + } else { + // cron may be disabled — accept explicit error + console.log('[AutomationSpec] 6.3.3 cron_runs failed:', runs.error); + expect(runs.error).toBeDefined(); + } + + // Empty job_id must fail validation + const empty = await callOpenhumanRpc('openhuman.cron_runs', { job_id: '', limit: 5 }); + expect(empty.ok).toBe(false); + expect(empty.error).toBeDefined(); + console.log('[AutomationSpec] 6.3.3 empty job_id error:', empty.error); + }); +}); diff --git a/app/test/e2e/specs/card-payment-flow.spec.ts b/app/test/e2e/specs/card-payment-flow.spec.ts deleted file mode 100644 index 6c693b1c..00000000 --- a/app/test/e2e/specs/card-payment-flow.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -// @ts-nocheck -/** - * E2E test: Card Payment Flow (Stripe). - * - * Covers: - * 5.1.1 Stripe checkout session created on upgrade - * 5.1.2 Checkout session with annual billing - * 5.2.1 Successful payment detected via polling - * 5.2.2 Failed purchase handled gracefully - * 5.3.1 Plan transition FREE → PRO - * 5.3.2 Manage Subscription opens Stripe portal - */ -import { waitForApp } from '../helpers/app-helpers'; -import { clickText, textExists } from '../helpers/element-helpers'; -import { - navigateToBilling, - navigateToHome, - performFullLogin, - waitForTextToDisappear, -} from '../helpers/shared-flows'; -import { - clearRequestLog, - getRequestLog, - resetMockBehavior, - setMockBehavior, - startMockServer, - stopMockServer, -} from '../mock-server'; - -const LOG_PREFIX = '[PaymentFlow]'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -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; -} - -// =========================================================================== -// Tests -// =========================================================================== - -describe('Card Payment Flow', () => { - before(async () => { - await startMockServer(); - await waitForApp(); - clearRequestLog(); - }); - - after(async () => { - resetMockBehavior(); - await stopMockServer(); - }); - - it('login and reach home', async () => { - await performFullLogin('e2e-card-payment-token'); - }); - - it('5.1.1 — checkout session is created on Stripe card upgrade', async () => { - await navigateToBilling(); - clearRequestLog(); - - await clickText('Upgrade', 10_000); - console.log(`${LOG_PREFIX} Clicked Upgrade`); - await browser.pause(3_000); - - const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000); - expect(purchaseCall).toBeDefined(); - - // Log which plan was requested (could be BASIC or PRO depending on which Upgrade was clicked) - if (purchaseCall?.body) { - const body = typeof purchaseCall.body === 'string' ? purchaseCall.body : ''; - console.log(`${LOG_PREFIX} Purchase body: ${body}`); - } - - console.log(`${LOG_PREFIX} 5.1.1 — Stripe checkout session created`); - - // Activate the plan so polling clears - setMockBehavior('plan', 'BASIC'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString()); - await waitForTextToDisappear('Waiting', 25_000); - await navigateToHome(); - }); - - it('5.2.1 — successful payment detected via polling', async () => { - // Seed mock state explicitly so this test is self-contained - setMockBehavior('plan', 'BASIC'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString()); - clearRequestLog(); - - await navigateToBilling(); - await browser.pause(3_000); - - // BillingPanel fetches currentPlan on mount - const planCall = await waitForRequest('GET', '/payments/stripe/currentPlan', 10_000); - expect(planCall).toBeDefined(); - - // Verify billing page content loaded - const hasPlanInfo = - (await textExists('Current Plan')) || - (await textExists('BASIC')) || - (await textExists('Basic')) || - (await textExists('FREE')) || - (await textExists('Upgrade')); - expect(hasPlanInfo).toBe(true); - - console.log(`${LOG_PREFIX} 5.2.1 — Billing page loaded with plan info after payment`); - await navigateToHome(); - }); - - it('5.2.2 — failed purchase API call handled gracefully', async () => { - resetMockBehavior(); - setMockBehavior('purchaseError', 'true'); - clearRequestLog(); - await navigateToBilling(); - - // Click Upgrade — this should hit the mock which returns a 500 error - await clickText('Upgrade', 10_000); - console.log(`${LOG_PREFIX} Clicked Upgrade (expecting failure)`); - await browser.pause(3_000); - - // Verify the purchase API was called - const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000); - expect(purchaseCall).toBeDefined(); - - // The app should remain on the billing page without crashing. - // It should NOT show "Waiting for payment" since the API returned an error. - const hasBillingContent = - (await textExists('Current Plan')) || - (await textExists('FREE')) || - (await textExists('Upgrade')); - expect(hasBillingContent).toBe(true); - - console.log(`${LOG_PREFIX} 5.2.2 — App handled purchase error gracefully`); - resetMockBehavior(); - await navigateToHome(); - }); - - it('5.3.1 — plan transition from FREE to PRO', async () => { - // Start from FREE plan - resetMockBehavior(); - clearRequestLog(); - await navigateToBilling(); - - await clickText('Upgrade', 10_000); - console.log(`${LOG_PREFIX} Clicked Upgrade for PRO`); - await browser.pause(3_000); - - const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000); - expect(purchaseCall).toBeDefined(); - - setMockBehavior('plan', 'PRO'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString()); - await waitForTextToDisappear('Waiting', 25_000); - - console.log(`${LOG_PREFIX} 5.3.1 — Plan transition to PRO verified`); - await navigateToHome(); - }); - - it('5.3.2 — Manage Subscription opens Stripe portal', async () => { - // Seed mock with active subscription so "Manage" button appears - setMockBehavior('plan', 'PRO'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString()); - clearRequestLog(); - - await navigateToBilling(); - await browser.pause(3_000); - - const hasManage = await textExists('Manage'); - expect(hasManage).toBe(true); - - await clickText('Manage', 10_000); - console.log(`${LOG_PREFIX} Clicked Manage`); - await browser.pause(3_000); - - const portalCall = await waitForRequest('POST', '/payments/stripe/portal', 10_000); - expect(portalCall).toBeDefined(); - - console.log(`${LOG_PREFIX} 5.3.2 — Stripe portal call verified`); - resetMockBehavior(); - await navigateToHome(); - }); -}); diff --git a/app/test/e2e/specs/chat-interface-flow.spec.ts b/app/test/e2e/specs/chat-interface-flow.spec.ts new file mode 100644 index 00000000..3f9c1061 --- /dev/null +++ b/app/test/e2e/specs/chat-interface-flow.spec.ts @@ -0,0 +1,392 @@ +// @ts-nocheck +/** + * Chat Interface & Interaction (Section 7) + * + * The chat lives at /conversations (sidebar label: "Conversations", bottom bar: "Chat"). + * It renders a single centered chat card with: + * - Message area (scrollable, shows "No messages yet" when empty) + * - Suggested questions (when empty) + * - Text input (textarea, placeholder: "Type a message...") + * - Voice input toggle ("Switch to voice input" / "Start Talking") + * - Send button (arrow icon) + * + * Home page has "Message OpenHuman" button that navigates to /conversations. + * Default thread ID is 'default-thread', title is 'Conversation'. + * + * Covers: + * 7.1 Chat Session Management + * 7.1.1 Chat Session Creation — channel_web_chat endpoint + * 7.1.2 Session Persistence — channels_list_threads endpoint + * 7.1.3 Multi-Session Handling — channels_create_thread endpoint + * + * 7.2 Message Processing + * 7.2.1 User Message Handling — web chat accepts payload + * 7.2.2 AI Response Generation — local_ai_agent_chat endpoint + * 7.2.3 Streaming Response Handling — channel_web_chat transport + * + * 7.3 Tool Invocation via Chat + * 7.3.1 Tool Trigger Detection — skills_list_tools endpoint + * 7.3.2 Permission-Based Tool Execution — skills_call_tool rejects missing runtime + * 7.3.3 Tool Failure Handling — skills_call_tool surfaces errors + * + * 7.4 UI Flow + * 7.4.1 Navigate to Conversations tab + * 7.4.2 Chat input and empty state visible + * 7.4.3 Home → Message OpenHuman → Conversations + */ +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { expectRpcMethod, fetchCoreRpcMethods } from '../helpers/core-schema'; +import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers'; +import { + clickText, + dumpAccessibilityTree, + textExists, + waitForWebView, + waitForWindowVisible, +} from '../helpers/element-helpers'; +import { completeOnboardingIfVisible, navigateToConversations } from '../helpers/shared-flows'; +import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stepLog(message: string, context?: unknown) { + const stamp = new Date().toISOString(); + if (context === undefined) { + console.log(`[ChatInterfaceE2E][${stamp}] ${message}`); + return; + } + console.log(`[ChatInterfaceE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2)); +} + +async function _waitForRequest(method: string, urlFragment: string, timeout = 20_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; +} + +// =========================================================================== +// 7. Chat Interface — RPC endpoint verification +// =========================================================================== + +describe('7. Chat Interface — RPC endpoint verification', () => { + let methods: Set; + + before(async () => { + await waitForApp(); + await waitForAppReady(20_000); + methods = await fetchCoreRpcMethods(); + }); + + // ----------------------------------------------------------------------- + // 7.1 Chat Session Management + // ----------------------------------------------------------------------- + + it('7.1.1 — Chat Session Creation: channel_web_chat endpoint is registered', async () => { + expectRpcMethod(methods, 'openhuman.channel_web_chat'); + }); + + it('7.1.2 — Session Persistence: channels_list_threads endpoint is registered', async () => { + expectRpcMethod(methods, 'openhuman.channels_list_threads'); + }); + + it('7.1.3 — Multi-Session Handling: channels_create_thread endpoint is registered', async () => { + expectRpcMethod(methods, 'openhuman.channels_create_thread'); + }); + + // ----------------------------------------------------------------------- + // 7.2 Message Processing + // ----------------------------------------------------------------------- + + it('7.2.1 — User Message Handling: web chat accepts user input payload', async () => { + const res = await callOpenhumanRpc('openhuman.channel_web_chat', { + client_id: 'e2e-test-client', + thread_id: 'e2e-thread-a', + message: 'hello from e2e', + }); + if (!res.ok) { + // Chat may fail at runtime (no socket, no model) but should not fail at + // param validation. A validation error contains "missing required param" + // or "invalid" — that would be a real bug in the test payload. + const isValidationError = + typeof res.error === 'string' && + (res.error.includes('missing required param') || res.error.includes('invalid')); + if (isValidationError) { + console.log('[ChatInterfaceE2E] 7.2.1 VALIDATION ERROR — wrong payload shape:', res.error); + } else { + console.log('[ChatInterfaceE2E] 7.2.1 runtime error (expected in E2E):', res.error); + } + expect(isValidationError).toBe(false); + } else { + console.log( + '[ChatInterfaceE2E] 7.2.1 web chat accepted payload:', + JSON.stringify(res.result) + ); + expect(res.ok).toBe(true); + } + }); + + it('7.2.2 — AI Response Generation: local_ai_agent_chat endpoint is registered', async () => { + expectRpcMethod(methods, 'openhuman.local_ai_agent_chat'); + }); + + it('7.2.3 — Streaming Response Handling: channel_web_chat transport is exposed', async () => { + expectRpcMethod(methods, 'openhuman.channel_web_chat'); + }); + + // ----------------------------------------------------------------------- + // 7.3 Tool Invocation via Chat + // ----------------------------------------------------------------------- + + it('7.3.1 — Tool Trigger Detection: skills_list_tools endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.skills_list_tools'); + }); + + it('7.3.2 — Permission-Based Tool Execution: skills_call_tool rejects missing runtime', async () => { + const call = await callOpenhumanRpc('openhuman.skills_call_tool', { + id: 'missing-runtime', + tool_name: 'non.existent', + args: {}, + }); + expect(call.ok).toBe(false); + }); + + it('7.3.3 — Tool Failure Handling: skills_call_tool surfaces error for bad calls', async () => { + const call = await callOpenhumanRpc('openhuman.skills_call_tool', { + id: 'missing-runtime', + tool_name: 'web.search', + args: { query: 'openhuman' }, + }); + expect(call.ok).toBe(false); + }); +}); + +// =========================================================================== +// 7.4 Chat Interface — UI flow +// =========================================================================== + +describe('7.4 Chat Interface — UI flow', () => { + before(async () => { + stepLog('starting mock server'); + await startMockServer(); + stepLog('waiting for app'); + await waitForApp(); + stepLog('clearing request log'); + clearRequestLog(); + }); + + after(async () => { + stepLog('stopping mock server'); + await stopMockServer(); + }); + + it('7.4.1 — Navigate to Conversations tab and see chat interface', async () => { + // Auth with retry — wait for positive confirmation (sidebar nav visible) + // rather than just absence of login text (which can be a false positive + // during page transitions). + for (let attempt = 1; attempt <= 3; attempt++) { + stepLog(`trigger deep link (attempt ${attempt})`); + await triggerAuthDeepLinkBypass(`e2e-chat-ui-${attempt}`); + await waitForWindowVisible(25_000); + await waitForWebView(15_000); + await waitForAppReady(15_000); + + // Wait up to 10s for a positive auth marker (sidebar nav labels) + const authMarkers = [ + 'Home', + 'Skills', + 'Chat', + 'Intelligence', + 'Good morning', + 'Good afternoon', + 'Good evening', + 'Message OpenHuman', + ]; + const authDeadline = Date.now() + 10_000; + let authed = false; + while (Date.now() < authDeadline) { + for (const marker of authMarkers) { + if (await textExists(marker)) { + stepLog(`Auth confirmed on attempt ${attempt} — found "${marker}"`); + authed = true; + break; + } + } + if (authed) break; + await browser.pause(500); + } + + if (authed) break; + + if (attempt === 3) { + const tree = await dumpAccessibilityTree(); + stepLog('Auth failed after 3 attempts. Tree:', tree.slice(0, 3000)); + throw new Error('Auth deep link did not navigate past sign-in page'); + } + stepLog('No auth marker found — retrying'); + await browser.pause(2_000); + } + + await completeOnboardingIfVisible('[ChatInterfaceE2E]'); + + stepLog('navigate to conversations'); + await navigateToConversations(); + await browser.pause(3_000); + + // Check if chat loaded or session was lost (app redirects to login) + let hasInput = await textExists('Type a message'); + let hasEmptyState = await textExists('No messages yet'); + let hasConversation = await textExists('Conversation'); + let chatVisible = hasInput || hasEmptyState || hasConversation; + + // Session may be lost after navigation — re-auth and try again + if (!chatVisible) { + const onLogin = + (await textExists("Sign in! Let's Cook")) || (await textExists('Continue with email')); + if (onLogin) { + stepLog('Session lost after nav to Chat — re-authenticating'); + await triggerAuthDeepLinkBypass('e2e-chat-ui-retry'); + await browser.pause(5_000); + + // After re-auth, deep link lands on /home — navigate to conversations again + await navigateToConversations(); + await browser.pause(3_000); + + hasInput = await textExists('Type a message'); + hasEmptyState = await textExists('No messages yet'); + hasConversation = await textExists('Conversation'); + chatVisible = hasInput || hasEmptyState || hasConversation; + } + } + + stepLog('Chat interface check', { + input: hasInput, + emptyState: hasEmptyState, + conversation: hasConversation, + }); + + if (!chatVisible) { + const tree = await dumpAccessibilityTree(); + stepLog('Chat interface not found. Tree:', tree.slice(0, 4000)); + } + expect(chatVisible).toBe(true); + stepLog('Conversations page loaded'); + + // Verify chat elements while we're on the page + const hasVoiceToggle = await textExists('Switch to voice input'); + stepLog('Chat elements', { + input: hasInput, + emptyState: hasEmptyState, + voiceToggle: hasVoiceToggle, + }); + + // 7.4.2 — Type "Hello, AlphaHuman" in the chat input and verify it appears + stepLog('typing message in chat input'); + + // Find and click the textarea to focus it (Mac2: use accessibility tree) + // The textarea has placeholder "Type a message..." — find it via XPath + const textareaSelector = '//XCUIElementTypeTextArea | //XCUIElementTypeTextField'; + let textarea; + try { + textarea = await browser.$(textareaSelector); + if (await textarea.isExisting()) { + await textarea.click(); + stepLog('Clicked textarea via accessibility selector'); + } + } catch { + stepLog('Could not find textarea via XCUIElementType — trying text match'); + try { + await clickText('Type a message', 10_000); + stepLog('Clicked "Type a message" placeholder'); + } catch { + stepLog('Could not click textarea placeholder either'); + } + } + await browser.pause(1_000); + + // Type the message using macos: keys (native keyboard input) + const message = 'Hello, AlphaHuman'; + try { + await browser.execute('macos: keys', { keys: message.split('').map(ch => ({ key: ch })) }); + stepLog('Typed message via macos: keys'); + } catch (keysErr) { + stepLog('macos: keys failed, trying browser.keys fallback', keysErr); + try { + await browser.keys(message.split('')); + stepLog('Typed message via browser.keys'); + } catch { + stepLog('browser.keys also failed'); + } + } + await browser.pause(1_000); + + // Verify the typed text appears in the accessibility tree + const hasTypedText = await textExists('Hello, AlphaHuman'); + stepLog('Typed text visible', { visible: hasTypedText }); + + // Press Enter to send the message + stepLog('pressing Enter to send'); + try { + await browser.execute('macos: keys', { keys: [{ key: 'Return' }] }); + } catch { + try { + await browser.keys(['Enter']); + } catch { + stepLog('Could not press Enter'); + } + } + await browser.pause(2_000); + + // Wait for the user message to appear in the chat (rendered as a message bubble) + const userMsgDeadline = Date.now() + 10_000; + let userMsgVisible = false; + while (Date.now() < userMsgDeadline) { + if (await textExists('Hello, AlphaHuman')) { + userMsgVisible = true; + break; + } + await browser.pause(500); + } + stepLog('User message in chat', { visible: userMsgVisible }); + + // Wait for the mock agent response: "Hello from e2e mock agent" + // This requires socket to be connected and core to relay the chat completion. + // If socket is not connected, the message won't send — we still verify the input worked. + const responseMsgDeadline = Date.now() + 30_000; + let responseVisible = false; + while (Date.now() < responseMsgDeadline) { + if (await textExists('Hello from e2e mock agent')) { + responseVisible = true; + break; + } + // Also check for error states to break early + if (await textExists('socket is not connected')) break; + if (await textExists('Usage limit reached')) break; + await browser.pause(1_000); + } + stepLog('Agent response in chat', { visible: responseVisible }); + + // Dump tree for diagnostic if response not visible + if (!responseVisible) { + const tree = await dumpAccessibilityTree(); + stepLog('Chat tree after send attempt:', tree.slice(0, 5000)); + } + + // At minimum the input should have worked (typed text visible or sent message in bubble) + expect(userMsgVisible || hasTypedText).toBe(true); + + // If agent response came through, that proves the full loop works + if (responseVisible) { + stepLog('Full chat loop verified: message sent → mock agent responded'); + } else { + stepLog('Agent response not received — socket may not be connected in E2E environment'); + } + }); +}); diff --git a/app/test/e2e/specs/chat-skills-integrations.spec.ts b/app/test/e2e/specs/chat-skills-integrations.spec.ts new file mode 100644 index 00000000..4113ed33 --- /dev/null +++ b/app/test/e2e/specs/chat-skills-integrations.spec.ts @@ -0,0 +1,127 @@ +// @ts-nocheck +/** + * Integrations & Built-in Skills (Sections 8 & 9) + * + * Section 7 (Chat Interface) has been moved to chat-interface-flow.spec.ts. + */ +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { expectRpcMethod, fetchCoreRpcMethods } from '../helpers/core-schema'; + +async function expectRpcOk(method: string, params: Record = {}) { + const result = await callOpenhumanRpc(method, params); + if (!result.ok) { + console.log(`[IntegrationsSpec] ${method} failed`, result.error); + } + expect(result.ok).toBe(true); + return result.result; +} + +describe('Integrations & Built-in Skills', () => { + let methods: Set; + + before(async () => { + await waitForApp(); + await waitForAppReady(20_000); + methods = await fetchCoreRpcMethods(); + }); + + it('8.1.1 — OAuth Authorization Flow: auth_oauth_connect endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.auth_oauth_connect'); + await expectRpcOk('openhuman.auth_oauth_connect', { provider: 'google', responseType: 'json' }); + }); + + it('8.1.2 — Scope Selection (Read / Write / Initiate): integrations list endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.auth_oauth_list_integrations'); + await expectRpcOk('openhuman.auth_oauth_list_integrations', {}); + }); + + it('8.1.3 — Token Storage & Encryption: provider credentials storage endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.auth_store_provider_credentials'); + }); + + it('8.2.1 — Read Access Enforcement: integration permissions can be queried via channels status', async () => { + expectRpcMethod(methods, 'openhuman.channels_status'); + await expectRpcOk('openhuman.channels_status', {}); + }); + + it('8.2.2 — Write Access Enforcement: channel send_message endpoint is exposed', async () => { + expectRpcMethod(methods, 'openhuman.channels_send_message'); + }); + + it('8.2.3 — Initiate Action Enforcement: integration action endpoints are discoverable', async () => { + expectRpcMethod(methods, 'openhuman.channels_create_thread'); + }); + + it('8.2.4 — Cross-Account Access Prevention: oauth revoke integration endpoint exists', async () => { + expectRpcMethod(methods, 'openhuman.auth_oauth_revoke_integration'); + }); + + it('8.3.1 — Data Fetch Handling: skills_sync endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.skills_sync'); + }); + + it('8.3.2 — Data Write Handling: channels_send_message write endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.channels_send_message'); + }); + + it('8.3.3 — Large Data Processing: memory query endpoint is available for chunked data', async () => { + expectRpcMethod(methods, 'openhuman.memory_query_namespace'); + }); + + it('8.4.1 — Integration Disconnect: oauth revoke endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.auth_oauth_revoke_integration'); + }); + + it('8.4.2 — Token Revocation: clear_session endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.auth_clear_session'); + }); + + it('8.4.3 — Re-Authorization Flow: oauth_connect remains callable after list', async () => { + await expectRpcOk('openhuman.auth_oauth_list_integrations', {}); + await expectRpcOk('openhuman.auth_oauth_connect', { provider: 'github', responseType: 'json' }); + }); + + it('8.4.4 — Permission Re-Sync: skills_sync endpoint can be invoked', async () => { + const sync = await callOpenhumanRpc('openhuman.skills_sync', { id: 'missing-runtime' }); + expect(sync.ok || Boolean(sync.error)).toBe(true); + }); + + it('9.1.1 — Screen Capture Processing: capture_test endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.screen_intelligence_capture_test'); + }); + + it('9.1.2 — Context Extraction: vision_recent endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.screen_intelligence_vision_recent'); + }); + + it('9.1.3 — Memory Injection: memory_doc_put endpoint is available', async () => { + expectRpcMethod(methods, 'openhuman.memory_doc_put'); + }); + + it('9.2.1 — Inline Suggestion Generation: autocomplete_start endpoint exists', async () => { + expectRpcMethod(methods, 'openhuman.autocomplete_start'); + }); + + it('9.2.2 — Debounce Handling: autocomplete_status endpoint exists', async () => { + expectRpcMethod(methods, 'openhuman.autocomplete_status'); + await expectRpcOk('openhuman.autocomplete_status', {}); + }); + + it('9.2.3 — Acceptance Trigger: autocomplete_accept endpoint exists', async () => { + expectRpcMethod(methods, 'openhuman.autocomplete_accept'); + }); + + it('9.3.1 — Voice Input Capture: voice_status endpoint exists', async () => { + expectRpcMethod(methods, 'openhuman.voice_status'); + }); + + it('9.3.2 — Speech-to-Text Processing: voice_status call is reachable', async () => { + const status = await callOpenhumanRpc('openhuman.voice_status', {}); + expect(status.ok || Boolean(status.error)).toBe(true); + }); + + it('9.3.3 — Voice Command Execution: voice command surface exists in schema', async () => { + expectRpcMethod(methods, 'openhuman.voice_status'); + }); +}); diff --git a/app/test/e2e/specs/conversations-web-channel-flow.spec.ts b/app/test/e2e/specs/conversations-web-channel-flow.spec.ts deleted file mode 100644 index c748ac12..00000000 --- a/app/test/e2e/specs/conversations-web-channel-flow.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -// @ts-nocheck -import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; -import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers'; -import { - clickText, - dumpAccessibilityTree, - textExists, - waitForText, - waitForWebView, - waitForWindowVisible, -} from '../helpers/element-helpers'; -import { - completeOnboardingIfVisible, - navigateToConversations, - navigateViaHash, -} from '../helpers/shared-flows'; -import { clearRequestLog, getRequestLog, startMockServer, stopMockServer } from '../mock-server'; - -function stepLog(message: string, context?: unknown) { - const stamp = new Date().toISOString(); - if (context === undefined) { - console.log(`[ConversationsE2E][${stamp}] ${message}`); - return; - } - console.log(`[ConversationsE2E][${stamp}] ${message}`, JSON.stringify(context, null, 2)); -} - -async function waitForRequest(method, urlFragment, timeout = 20_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; -} - -// This spec tests the full agent chat loop (UI → core sidecar → backend → streaming response). -// On Linux CI, the core sidecar's chat pipeline may not be fully functional in the E2E -// environment (mock backend lacks streaming SSE support). Skip on Linux only. -const suiteRunner = process.platform === 'linux' ? describe.skip : describe; -suiteRunner('Conversations web channel flow', () => { - before(async () => { - stepLog('starting mock server'); - await startMockServer(); - stepLog('waiting for app'); - await waitForApp(); - stepLog('clearing request log'); - clearRequestLog(); - }); - - after(async () => { - stepLog('stopping mock server'); - await stopMockServer(); - }); - - it('sends UI message through agent loop and renders response', async () => { - stepLog('trigger deep link'); - await triggerAuthDeepLinkBypass('e2e-conversations-token'); - stepLog('wait for window'); - await waitForWindowVisible(25_000); - stepLog('wait for webview'); - await waitForWebView(15_000); - stepLog('wait for app ready'); - await waitForAppReady(15_000); - - // triggerAuthDeepLinkBypass uses key=auth which sets the token directly - // (no /telegram/login-tokens/ consume call). Wait for user profile instead. - stepLog('wait for user profile request'); - const profileCall = await waitForRequest('GET', '/auth/me', 15_000); - if (!profileCall) { - stepLog('user profile call not found — bypass token may have been set without API call'); - } - - stepLog('complete onboarding'); - await completeOnboardingIfVisible('[ConversationsE2E]'); - - stepLog('open conversations'); - // Navigate via hash — "Message OpenHuman" button may not reliably open conversations - await navigateToConversations(); - // If navigating to /conversations doesn't open a thread, try clicking the input area - 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(); - } - } - - stepLog('send message'); - // The chat input uses a textarea with placeholder attribute — not visible as text content. - // Use browser.execute to find and focus it, then type. - const foundInput = await browser.execute(() => { - const textarea = document.querySelector( - 'textarea[placeholder*="Type a message"]' - ) as HTMLTextAreaElement; - if (textarea) { - textarea.focus(); - textarea.click(); - return true; - } - // Fallback: any textarea or contenteditable - const fallback = document.querySelector('textarea, [contenteditable="true"]') as HTMLElement; - if (fallback) { - fallback.focus(); - (fallback as HTMLElement).click(); - return true; - } - return false; - }); - if (!foundInput) { - const tree = await dumpAccessibilityTree(); - stepLog('Chat input not found. Tree:', tree.slice(0, 4000)); - throw new Error('Chat input textarea not found'); - } - stepLog('Chat input focused'); - await browser.pause(500); - - // Set value via JS and dispatch input event (browser.keys unreliable on tauri-driver) - await browser.execute(() => { - const textarea = document.querySelector( - 'textarea[placeholder*="Type a message"]' - ) as HTMLTextAreaElement; - if (!textarea) return; - const nativeInputValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - 'value' - )?.set; - nativeInputValueSetter?.call(textarea, 'hello from e2e web channel'); - textarea.dispatchEvent(new Event('input', { bubbles: true })); - textarea.dispatchEvent(new Event('change', { bubbles: true })); - }); - await browser.pause(500); - - // Submit by pressing Enter via JS (simulates form submission) - await browser.execute(() => { - const textarea = document.querySelector( - 'textarea[placeholder*="Type a message"]' - ) as HTMLTextAreaElement; - if (!textarea) return; - textarea.dispatchEvent( - new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }) - ); - }); - await browser.pause(1_000); - - await waitForText('hello from e2e web channel', 20_000); - await waitForText('Hello from e2e mock agent', 30_000); - - stepLog('validate backend request'); - const chatReq = await waitForRequest('POST', '/openai/v1/chat/completions', 30_000); - if (!chatReq) { - const tree = await dumpAccessibilityTree(); - console.log('[ConversationsE2E] Missing openai chat request. Tree:\n', tree.slice(0, 5000)); - } - expect(chatReq).toBeDefined(); - - expect(await textExists('chat_send is not available')).toBe(false); - }); -}); diff --git a/app/test/e2e/specs/crypto-payment-flow.spec.ts b/app/test/e2e/specs/crypto-payment-flow.spec.ts deleted file mode 100644 index 28bb2a6c..00000000 --- a/app/test/e2e/specs/crypto-payment-flow.spec.ts +++ /dev/null @@ -1,209 +0,0 @@ -// @ts-nocheck -/** - * E2E test: Cryptocurrency Payment Flow (Coinbase Commerce). - * - * Covers: - * 6.1.1 Coinbase charge created with correct plan - * 6.1.2 Crypto toggle forces annual billing - * 6.2.1 Successful crypto payment via polling - * 6.3.1 Polling detects plan change after crypto confirmation - * 6.3.2 Coinbase API error handled gracefully - */ -import { waitForApp } from '../helpers/app-helpers'; -import { clickText, clickToggle, textExists } from '../helpers/element-helpers'; -import { - navigateToBilling, - navigateToHome, - performFullLogin, - waitForTextToDisappear, -} from '../helpers/shared-flows'; -import { - clearRequestLog, - getRequestLog, - resetMockBehavior, - setMockBehavior, - startMockServer, - stopMockServer, -} from '../mock-server'; - -const LOG_PREFIX = '[CryptoPayment]'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -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; -} - -// =========================================================================== -// Tests -// =========================================================================== - -describe('Crypto Payment Flow', () => { - before(async () => { - await startMockServer(); - await waitForApp(); - clearRequestLog(); - }); - - after(async () => { - resetMockBehavior(); - await stopMockServer(); - }); - - it('login and reach home', async () => { - await performFullLogin('e2e-crypto-payment-token'); - }); - - it('6.1.1 — upgrade with crypto toggle triggers Coinbase charge', async () => { - resetMockBehavior(); - await navigateToBilling(); - clearRequestLog(); - - // Verify crypto toggle label exists - const hasCryptoLabel = await textExists('Pay with Crypto'); - expect(hasCryptoLabel).toBe(true); - console.log(`${LOG_PREFIX} 6.1.1 — Pay with Crypto label found`); - - // Enable the crypto toggle — forces annual billing and switches to Coinbase - try { - await clickToggle(10_000); - console.log(`${LOG_PREFIX} 6.1.1 — Crypto toggle clicked`); - } catch { - // Fallback: click the label text directly - await clickText('Pay with Crypto', 10_000); - console.log(`${LOG_PREFIX} 6.1.1 — Crypto toggle clicked via label`); - } - await browser.pause(2_000); - - // Click Upgrade — with crypto enabled this should hit Coinbase - await clickText('Upgrade', 10_000); - console.log(`${LOG_PREFIX} 6.1.1 — Clicked Upgrade`); - await browser.pause(3_000); - - // Verify a payment API was called — prefer Coinbase, fall back to Stripe - const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000); - const stripeCall = !coinbaseCall - ? await waitForRequest('POST', '/payments/stripe/purchasePlan', 5_000) - : null; - - if (coinbaseCall) { - console.log(`${LOG_PREFIX} 6.1.1 — Coinbase charge API called (crypto path)`); - } else if (stripeCall) { - console.log( - `${LOG_PREFIX} 6.1.1 — Stripe API called (crypto toggle may not have taken effect)` - ); - } - expect(coinbaseCall || stripeCall).toBeDefined(); - - // Activate plan so polling clears - setMockBehavior('plan', 'BASIC'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 365 * 86400000).toISOString()); - await waitForTextToDisappear('Waiting', 25_000); - await navigateToHome(); - }); - - it('6.1.2 — crypto toggle forces annual billing', async () => { - resetMockBehavior(); - clearRequestLog(); - await navigateToBilling(); - - // Verify "Monthly" and "Annual" billing options exist - const hasMonthly = await textExists('Monthly'); - const hasAnnual = await textExists('Annual'); - console.log(`${LOG_PREFIX} Monthly: ${hasMonthly}, Annual: ${hasAnnual}`); - - // Toggle crypto on — this label must exist on the billing page - const hasCrypto = await textExists('Pay with Crypto'); - expect(hasCrypto).toBe(true); - - try { - await clickToggle(10_000); - } catch { - await clickText('Pay with Crypto', 10_000); - } - await browser.pause(2_000); - - // After enabling crypto, annual billing should be forced - const annualStillVisible = await textExists('Annual'); - expect(annualStillVisible).toBe(true); - - console.log(`${LOG_PREFIX} 6.1.2 — Crypto toggle forces annual billing`); - - await navigateToHome(); - }); - - it('6.2.1 — successful crypto payment via polling', async () => { - // Seed mock state explicitly so this test is self-contained - setMockBehavior('plan', 'BASIC'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 365 * 86400000).toISOString()); - clearRequestLog(); - await navigateToBilling(); - - const planCall = await waitForRequest('GET', '/payments/stripe/currentPlan', 10_000); - expect(planCall).toBeDefined(); - - const hasPlanInfo = - (await textExists('Current Plan')) || - (await textExists('BASIC')) || - (await textExists('Basic')); - expect(hasPlanInfo).toBe(true); - - console.log(`${LOG_PREFIX} 6.2.1 — Crypto payment confirmed, plan active`); - await navigateToHome(); - }); - - it('6.3.1 — polling detects plan change after crypto confirmation', async () => { - // Seed mock state explicitly so this test is self-contained - setMockBehavior('plan', 'BASIC'); - setMockBehavior('planActive', 'true'); - setMockBehavior('planExpiry', new Date(Date.now() + 365 * 86400000).toISOString()); - clearRequestLog(); - await navigateToBilling(); - await browser.pause(3_000); - - // The billing panel fetches currentPlan on mount - const planCall = await waitForRequest('GET', '/payments/stripe/currentPlan', 10_000); - expect(planCall).toBeDefined(); - - console.log(`${LOG_PREFIX} 6.3.1 — Polling detected plan change`); - await navigateToHome(); - }); - - it('6.3.2 — payment API error handled gracefully', async () => { - resetMockBehavior(); - setMockBehavior('purchaseError', 'true'); - clearRequestLog(); - await navigateToBilling(); - - // Click Upgrade — the mock will return a 500 error - await clickText('Upgrade', 10_000); - console.log(`${LOG_PREFIX} Clicked Upgrade (expecting error)`); - await browser.pause(3_000); - - // Verify the purchase API was called - const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000); - expect(purchaseCall).toBeDefined(); - - // App should remain on billing page without crashing - const hasBillingContent = - (await textExists('Current Plan')) || - (await textExists('FREE')) || - (await textExists('Upgrade')); - expect(hasBillingContent).toBe(true); - - console.log(`${LOG_PREFIX} 6.3.2 — App handled payment error gracefully`); - resetMockBehavior(); - await navigateToHome(); - }); -}); diff --git a/app/test/e2e/specs/discord-flow.spec.ts b/app/test/e2e/specs/discord-flow.spec.ts new file mode 100644 index 00000000..147986ed --- /dev/null +++ b/app/test/e2e/specs/discord-flow.spec.ts @@ -0,0 +1,425 @@ +// @ts-nocheck +/** + * E2E test: Discord Integration Flows (Channels architecture). + * + * Discord is a Channel in the unified Channels subsystem. It appears on the + * Skills page under "Channel Integrations" with a "Configure" button that + * opens a ChannelSetupModal. Two auth modes: bot_token and oauth. + * + * Aligned to Section 8: Integrations (Telegram, Gmail, Notion) + * Same structure as telegram-flow.spec.ts but for Discord-specific endpoints. + * + * 8.1 Integration Setup + * 8.1.1 Channel Connect — channels_connect with bot_token mode + * 8.1.2 Scope Selection — channels_list returns Discord definition with capabilities + * 8.1.3 Token Storage — auth_store_provider_credentials endpoint + * + * 8.2 Permission Enforcement + * 8.2.1 Read Access — channels_status returns Discord connection state + * 8.2.2 Write Access — channels_send_message endpoint + * 8.2.3 Initiate Action — channels_create_thread endpoint + * 8.2.4 Cross-Account Access Prevention — disconnect + revoke endpoints + * + * 8.3 Data Operations + * 8.3.1 Data Fetch — discord_list_guilds + discord_list_channels + * 8.3.2 Data Write — channels_send_message + * 8.3.3 Permission Check — discord_check_permissions + * + * 8.4 Disconnect & Re-Setup + * 8.4.1 Disconnect — channels_disconnect callable + * 8.4.2 Token Revocation — auth_clear_session endpoint + * 8.4.3 Re-Authorization — channels_connect callable after disconnect + * 8.4.4 Permission Re-Sync — channels_status refreshable + * + * 8.5 UI Flow (Skills page → Channel Integrations → Configure modal) + */ +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { expectRpcMethod, fetchCoreRpcMethods } from '../helpers/core-schema'; +import { triggerAuthDeepLinkBypass } 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'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function stepLog(message: string, context?: unknown) { + const stamp = new Date().toISOString(); + if (context === undefined) { + console.log(`[DiscordFlow][${stamp}] ${message}`); + return; + } + console.log(`[DiscordFlow][${stamp}] ${message}`, JSON.stringify(context, null, 2)); +} + +// =========================================================================== +// 8. Integrations (Discord) — RPC endpoint verification +// =========================================================================== + +describe('8. Integrations (Discord) — RPC endpoint verification', () => { + let methods: Set; + + before(async () => { + await waitForApp(); + await waitForAppReady(20_000); + methods = await fetchCoreRpcMethods(); + }); + + // ----------------------------------------------------------------------- + // 8.1 Integration Setup + // ----------------------------------------------------------------------- + + it('8.1.1 — Channel Connect: channels_connect accepts Discord bot_token mode', async () => { + expectRpcMethod(methods, 'openhuman.channels_connect'); + + const res = await callOpenhumanRpc('openhuman.channels_connect', { + channel: 'discord', + authMode: 'bot_token', + credentials: { bot_token: 'fake-e2e-discord-token' }, + }); + if (!res.ok) { + stepLog('8.1.1 channels_connect failed:', res.error); + } + expect(res.ok).toBe(true); + }); + + it('8.1.2 — Scope Selection: channels_list returns Discord definition with capabilities', async () => { + expectRpcMethod(methods, 'openhuman.channels_list'); + + const res = await callOpenhumanRpc('openhuman.channels_list', {}); + if (res.ok && Array.isArray(res.result)) { + const discord = res.result.find((d: { id: string }) => d.id === 'discord'); + if (discord) { + stepLog('Discord definition found', { + authModes: discord.auth_modes?.map((m: { mode: string }) => m.mode), + capabilities: discord.capabilities, + }); + } + } + if (!res.ok) { + stepLog('8.1.2 channels_list failed:', res.error); + } + expect(res.ok).toBe(true); + }); + + it('8.1.3 — Token Storage: auth_store_provider_credentials registered', async () => { + expectRpcMethod(methods, 'openhuman.auth_store_provider_credentials'); + }); + + // ----------------------------------------------------------------------- + // 8.2 Permission Enforcement + // ----------------------------------------------------------------------- + + it('8.2.1 — Read Access: channels_status returns Discord connection state', async () => { + expectRpcMethod(methods, 'openhuman.channels_status'); + const res = await callOpenhumanRpc('openhuman.channels_status', { channel: 'discord' }); + if (!res.ok) { + stepLog('8.2.1 channels_status failed:', res.error); + } + expect(res.ok).toBe(true); + }); + + it('8.2.2 — Write Access: channels_send_message available', async () => { + expectRpcMethod(methods, 'openhuman.channels_send_message'); + }); + + it('8.2.3 — Initiate Action: channels_create_thread available', async () => { + expectRpcMethod(methods, 'openhuman.channels_create_thread'); + }); + + it('8.2.4 — Cross-Account Access Prevention: disconnect + revoke endpoints', async () => { + expectRpcMethod(methods, 'openhuman.channels_disconnect'); + expectRpcMethod(methods, 'openhuman.auth_oauth_revoke_integration'); + }); + + // ----------------------------------------------------------------------- + // 8.3 Data Operations (Discord-specific) + // ----------------------------------------------------------------------- + + it('8.3.1 — Data Fetch: discord_list_guilds endpoint registered', async () => { + expectRpcMethod(methods, 'openhuman.channels_discord_list_guilds'); + }); + + it('8.3.2 — Data Fetch: discord_list_channels endpoint registered', async () => { + expectRpcMethod(methods, 'openhuman.channels_discord_list_channels'); + }); + + it('8.3.3 — Permission Check: discord_check_permissions endpoint registered', async () => { + expectRpcMethod(methods, 'openhuman.channels_discord_check_permissions'); + }); + + // ----------------------------------------------------------------------- + // 8.4 Disconnect & Re-Setup + // ----------------------------------------------------------------------- + + it('8.4.1 — Disconnect: channels_disconnect callable for Discord', async () => { + const res = await callOpenhumanRpc('openhuman.channels_disconnect', { + channel: 'discord', + authMode: 'bot_token', + }); + if (!res.ok) { + stepLog('8.4.1 channels_disconnect failed:', res.error); + } + expect(res.ok).toBe(true); + }); + + it('8.4.2 — Token Revocation: auth_clear_session available', async () => { + expectRpcMethod(methods, 'openhuman.auth_clear_session'); + }); + + it('8.4.3 — Re-Authorization: channels_connect callable after disconnect', async () => { + await callOpenhumanRpc('openhuman.channels_disconnect', { + channel: 'discord', + authMode: 'bot_token', + }); + const res = await callOpenhumanRpc('openhuman.channels_connect', { + channel: 'discord', + authMode: 'bot_token', + credentials: { bot_token: 'fake-e2e-discord-reauth' }, + }); + if (!res.ok) { + stepLog('8.4.3 channels_connect (re-auth) failed:', res.error); + } + expect(res.ok).toBe(true); + }); + + it('8.4.4 — Permission Re-Sync: channels_status refreshable after reconnect', async () => { + const res = await callOpenhumanRpc('openhuman.channels_status', { channel: 'discord' }); + if (!res.ok) { + stepLog('8.4.4 channels_status failed:', res.error); + } + expect(res.ok).toBe(true); + }); +}); + +// =========================================================================== +// 8.5 Discord — UI flow (Skills page → Channel Integrations → Configure) +// =========================================================================== + +describe('8.5 Integrations (Discord) — UI flow', () => { + before(async () => { + stepLog('starting mock server'); + await startMockServer(); + stepLog('waiting for app'); + await waitForApp(); + clearRequestLog(); + }); + + after(async () => { + stepLog('stopping mock server'); + await stopMockServer(); + }); + + it('8.5.1 — Skills page shows Discord in Channel Integrations', async () => { + // Auth — try deep link, retry on failure + for (let attempt = 1; attempt <= 3; attempt++) { + stepLog(`trigger deep link (attempt ${attempt})`); + await triggerAuthDeepLinkBypass(`e2e-discord-flow-${attempt}`); + await waitForWindowVisible(25_000); + await waitForWebView(15_000); + await waitForAppReady(15_000); + await browser.pause(3_000); + + const onLoginPage = + (await textExists("Sign in! Let's Cook")) || (await textExists('Continue with email')); + if (!onLoginPage) { + stepLog(`Auth succeeded on attempt ${attempt}`); + break; + } + if (attempt === 3) { + const tree = await dumpAccessibilityTree(); + stepLog('Still on login page after 3 attempts. Tree:', tree.slice(0, 3000)); + throw new Error('Auth deep link did not navigate past sign-in page'); + } + stepLog('Still on login page — retrying'); + await browser.pause(2_000); + } + + await completeOnboardingIfVisible('[DiscordFlow]'); + + stepLog('navigate to skills'); + await navigateViaHash('/skills'); + await browser.pause(3_000); + + // Skills page uses filter tabs (All, Built-in, Channels, Other). + // Click the "Channels" tab to show channel cards. + const hasChannelsTab = await textExists('Channels'); + if (hasChannelsTab) { + try { + await clickText('Channels', 8_000); + await browser.pause(2_000); + stepLog('Clicked "Channels" filter tab'); + } catch { + stepLog('Could not click Channels tab — continuing with All view'); + } + } + + // Discord card should now be visible + const hasDiscord = await textExists('Discord'); + if (!hasDiscord) { + const tree = await dumpAccessibilityTree(); + stepLog('Discord not found. Tree:', tree.slice(0, 4000)); + } + expect(hasDiscord).toBe(true); + stepLog('Discord channel visible on Skills page'); + }); + + it('8.5.2 — Discord card shows status and action button', async () => { + const hasDescription = await textExists('Send and receive messages via Discord'); + stepLog('Discord card description', { visible: hasDescription }); + + // CTA button: "Setup" (disconnected) or "Manage" (connected) + const hasSetup = await textExists('Setup'); + const hasManage = await textExists('Manage'); + const hasCta = hasSetup || hasManage; + stepLog('Discord CTA', { setup: hasSetup, manage: hasManage }); + expect(hasCta).toBe(true); + }); + + it('8.5.3 — Click Discord Setup opens modal with auth modes and fields', async () => { + // NOTE: `clickText('Setup')` picks the first "Setup" button in DOM order, + // which is Telegram's (Telegram renders before Discord in the Channels + // list). Instead we find the Discord card by its "Discord" text node, + // capture its Y coordinate, then click the Setup/Manage button whose + // Y coordinate is closest to Discord's — i.e. the button inside the + // Discord card row. + stepLog('locating Discord card Setup button by position'); + let clicked = false; + try { + // 1. Find Discord text positions (there may be multiple — title + description) + const discordEls = await browser.$$( + '//*[contains(@label, "Discord") or contains(@value, "Discord") or contains(@title, "Discord")]' + ); + if (discordEls.length === 0) { + throw new Error('No Discord elements found in tree'); + } + + // Use the first Discord element as the card anchor (typically the title) + const anchor = discordEls[0]; + const anchorLoc = await anchor.getLocation(); + stepLog(`Discord anchor at y=${anchorLoc.y}`); + + // 2. Find all Setup/Manage buttons + const ctaButtons = await browser.$$( + '//XCUIElementTypeButton[contains(@title, "Setup") or contains(@label, "Setup") or contains(@title, "Manage") or contains(@label, "Manage")]' + ); + stepLog(`Found ${ctaButtons.length} Setup/Manage buttons`); + + if (ctaButtons.length === 0) { + throw new Error('No Setup/Manage buttons found'); + } + + // 3. Pick the button whose Y is closest to Discord's anchor Y + let bestBtn = null as (typeof ctaButtons)[number] | null; + let bestDelta = Number.POSITIVE_INFINITY; + for (const btn of ctaButtons) { + try { + const bLoc = await btn.getLocation(); + const delta = Math.abs(bLoc.y - anchorLoc.y); + stepLog(` candidate button y=${bLoc.y} delta=${delta}`); + if (delta < bestDelta) { + bestDelta = delta; + bestBtn = btn; + } + } catch { + // element may have gone stale; skip + } + } + + if (!bestBtn) { + throw new Error('Could not select Discord CTA button'); + } + + // 4. W3C pointer click at the chosen button's center + const loc = await bestBtn.getLocation(); + const size = await bestBtn.getSize(); + const cx = Math.round(loc.x + size.width / 2); + const cy = Math.round(loc.y + size.height / 2); + stepLog(`clicking Discord Setup at (${cx}, ${cy}) delta=${bestDelta}`); + await browser.performActions([ + { + type: 'pointer', + id: 'mouse1', + parameters: { pointerType: 'mouse' }, + actions: [ + { type: 'pointerMove', duration: 10, x: cx, y: cy }, + { type: 'pointerDown', button: 0 }, + { type: 'pause', duration: 80 }, + { type: 'pointerUp', button: 0 }, + ], + }, + ]); + await browser.releaseActions(); + clicked = true; + } catch (err) { + stepLog( + `positional click failed: ${err instanceof Error ? err.message : String(err)} — falling back to clickText` + ); + } + + if (!clicked) { + // Fallback chain: clickText('Setup') → 'Manage' → 'Discord' + try { + await clickText('Setup', 10_000); + } catch { + try { + await clickText('Manage', 10_000); + } catch { + try { + await clickText('Discord', 10_000); + } catch { + stepLog('All click fallbacks failed'); + } + } + } + } + await browser.pause(3_000); + + // Dump tree for diagnostic + const tree = await dumpAccessibilityTree(); + stepLog('Tree after clicking Discord:', tree.slice(0, 5000)); + + // Check modal content — auth mode labels, buttons, fields + const hasBotToken = await textExists('Use your own Bot Token'); + const hasOAuth = await textExists('OAuth Sign-in'); + const hasConnect = await textExists('Connect'); + const hasDisconnect = await textExists('Disconnect'); + const hasBotTokenField = await textExists('Bot Token'); + const hasGuildId = await textExists('Server (Guild) ID'); + const hasChannelBadge = await textExists('channel'); + const hasBotDesc = await textExists('Provide your own Discord bot token'); + const hasOAuthDesc = await textExists('Install the OpenHuman bot to your Discord server'); + + stepLog('Discord modal content', { + botToken: hasBotToken, + oauth: hasOAuth, + connect: hasConnect, + disconnect: hasDisconnect, + botTokenField: hasBotTokenField, + guildId: hasGuildId, + channelBadge: hasChannelBadge, + botDesc: hasBotDesc, + oauthDesc: hasOAuthDesc, + }); + + // At least one auth mode or modal content should be visible + const modalOpened = hasBotToken || hasOAuth || hasChannelBadge || hasConnect || hasDisconnect; + expect(modalOpened).toBe(true); + + // Close modal + try { + await browser.keys(['Escape']); + await browser.pause(1_000); + } catch { + // non-fatal + } + }); +}); diff --git a/app/test/e2e/specs/gmail-flow.spec.ts b/app/test/e2e/specs/gmail-flow.spec.ts index 4dfebe18..3b6630bb 100644 --- a/app/test/e2e/specs/gmail-flow.spec.ts +++ b/app/test/e2e/specs/gmail-flow.spec.ts @@ -1,951 +1,518 @@ -/* eslint-disable */ // @ts-nocheck /** - * E2E test: Gmail Integration Flows. + * E2E test: Gmail Integration Flows (3rd Party Skill). * - * Covers: - * 9.1.1 Google OAuth Flow — OAuth/setup button appears in setup wizard - * 9.1.2 Scope Selection (Read / Send / Initiate) — backend called with scopes - * 9.2.1 Read-Only Mail Access — email skill listed with read permissions - * 9.2.2 Send Email Permission Enforcement — write tools accessible when connected - * 9.2.3 Initiate Draft / Auto-Reply Enforcement — initiate actions available - * 9.3.1 Scoped Email Fetch — skill fetches emails within allowed scope - * 9.3.2 Time-Range Filtering — time-based email filtering works - * 9.3.3 Attachment Handling — attachment tools available - * 9.4.1 Manual Disconnect — disconnect flow with confirmation - * 9.4.2 Token Revocation Handling — app handles revoked token gracefully - * 9.4.3 Expired Token Refresh Flow — app handles expired tokens - * 9.4.4 Re-Authorization Flow — setup wizard accessible after disconnect - * 9.4.5 Post-Disconnect Access Blocking — skill not accessible after disconnect + * Gmail is a 3rd Party Skill (id: "email") managed via the Skills subsystem. + * It appears on the Skills page under "3rd Party Skills" with Enable/Setup/Configure + * buttons. OAuth is handled via auth_oauth_connect. * - * The mock server runs on http://127.0.0.1:18473 and the .app bundle must - * have been built with VITE_BACKEND_URL pointing there. + * Aligned to Section 8: Integrations + * + * 8.1 Integration Setup + * 8.1.1 OAuth Authorization Flow — auth_oauth_connect with provider google + * 8.1.2 Scope Selection — auth_oauth_list_integrations returns scopes + * 8.1.3 Token Storage — auth_store_provider_credentials endpoint + * + * 8.2 Permission Enforcement + * 8.2.1 Read Access — skills_list_tools lists read tools for email skill + * 8.2.2 Write Access — skills_list_tools lists write tools for email skill + * 8.2.3 Initiate Action — skills_call_tool enforces runtime checks + * 8.2.4 Cross-Account Access Prevention — auth_oauth_revoke_integration + * + * 8.3 Data Operations + * 8.3.1 Data Fetch — skills_sync endpoint callable + * 8.3.2 Data Write — skills_call_tool with write tool + * 8.3.3 Large Data Processing — memory_query_namespace for chunked data + * + * 8.4 Disconnect & Re-Setup + * 8.4.1 Integration Disconnect — auth_oauth_revoke_integration callable + * 8.4.2 Token Revocation — auth_clear_session endpoint + * 8.4.3 Re-Authorization — auth_oauth_connect callable after revoke + * 8.4.4 Permission Re-Sync — skills_sync refreshable + * + * 8.5 UI Flow (Skills page → 3rd Party Skills → Email card) */ -import { waitForApp } from '../helpers/app-helpers'; -import { triggerAuthDeepLink } from '../helpers/deep-link-helpers'; +import { waitForApp, waitForAppReady } from '../helpers/app-helpers'; +import { callOpenhumanRpc } from '../helpers/core-rpc'; +import { expectRpcMethod, fetchCoreRpcMethods } from '../helpers/core-schema'; +import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers'; import { clickButton, - clickNativeButton, clickText, dumpAccessibilityTree, textExists, - waitForText, + waitForWebView, + waitForWindowVisible, } from '../helpers/element-helpers'; import { - navigateToHome, - navigateToIntelligence, - navigateToSettings, - performFullLogin, - waitForHomePage, + completeOnboardingIfVisible, + dismissLocalAISnackbarIfVisible, + navigateViaHash, } from '../helpers/shared-flows'; -import { - clearRequestLog, - getRequestLog, - resetMockBehavior, - setMockBehavior, - startMockServer, - stopMockServer, -} from '../mock-server'; - -// --------------------------------------------------------------------------- -// Shared helpers -// --------------------------------------------------------------------------- - -const LOG_PREFIX = '[GmailFlow]'; - -/** - * Poll the mock server request log until a matching request appears. - */ -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; -} - -/** - * Wait until the given text disappears from the accessibility tree. - */ -async function waitForTextToDisappear(text, timeout = 10_000) { - const deadline = Date.now() + timeout; - while (Date.now() < deadline) { - if (!(await textExists(text))) return true; - await browser.pause(500); - } - return false; -} - -// waitForHomePage, navigateToHome, performFullLogin are imported from shared-flows - -/** - * Counter for unique JWT suffixes. - */ -let reAuthCounter = 0; - -/** - * Re-authenticate via deep link and navigate to Home. - * Clears the request log before re-auth so captured calls are fresh. - */ -async function reAuthAndGoHome(token = 'e2e-gmail-token') { - clearRequestLog(); - - reAuthCounter += 1; - setMockBehavior('jwt', `gmail-reauth-${reAuthCounter}`); - - await triggerAuthDeepLink(token); - await browser.pause(5_000); +import { clearRequestLog, startMockServer, stopMockServer } from '../mock-server'; - await navigateToHome(); - - const homeText = await waitForHomePage(15_000); - if (!homeText) { - const tree = await dumpAccessibilityTree(); - console.log(`${LOG_PREFIX} reAuth: Home page not reached. Tree:\n`, tree.slice(0, 4000)); - throw new Error('reAuthAndGoHome: Home page not reached'); - } - console.log(`${LOG_PREFIX} Re-authed (jwt suffix gmail-reauth-${reAuthCounter}), on Home`); -} - -/** - * Attempt to find the Email skill in the UI. - * Checks Home page first (SkillsGrid), then Intelligence page. - * Returns true if Email was found, false otherwise. - */ -async function findGmailInUI() { - // Check Home page (SkillsGrid) - if (await textExists('Email')) { - console.log(`${LOG_PREFIX} Email found on Home page`); - return true; - } - - // Check Intelligence page - try { - await navigateToIntelligence(); - if (await textExists('Email')) { - console.log(`${LOG_PREFIX} Email found on Intelligence page`); - return true; - } - } catch { - console.log(`${LOG_PREFIX} Could not navigate to Intelligence page`); - } - - const tree = await dumpAccessibilityTree(); - console.log(`${LOG_PREFIX} Email not found in UI. Tree:\n`, tree.slice(0, 4000)); - return false; -} - -// navigateToSettings is imported from shared-flows - -/** - * Open the Email skill setup/management modal. - * Expects "Email" to be visible and clickable on the current page. - */ -async function openGmailModal() { - if (!(await textExists('Email'))) { - console.log(`${LOG_PREFIX} Email not visible on current page`); - return false; - } - - await clickText('Email', 10_000); - await browser.pause(2_000); - - // Check for "Connect Email" (setup wizard) or "Manage Email" (management panel) - const hasConnect = await textExists('Connect Email'); - const hasManage = await textExists('Manage Email'); - - if (hasConnect) { - console.log(`${LOG_PREFIX} Email setup modal opened ("Connect Email")`); - return 'connect'; - } - if (hasManage) { - console.log(`${LOG_PREFIX} Email management panel opened ("Manage Email")`); - return 'manage'; - } - - const tree = await dumpAccessibilityTree(); - console.log(`${LOG_PREFIX} Email modal not recognized. Tree:\n`, tree.slice(0, 4000)); - return false; -} - -/** - * Close any open modal by clicking outside or pressing Escape. - */ -async function closeModalIfOpen() { - const closeCandidates = ['Close', 'Cancel', 'Done']; - for (const text of closeCandidates) { - if (await textExists(text)) { - try { - await clickText(text, 3_000); - await browser.pause(1_000); - return; - } catch { - // Try next - } - } - } - try { - await browser.keys(['Escape']); - await browser.pause(1_000); - } catch { - // Ignore +function stepLog(message: string, context?: unknown) { + const stamp = new Date().toISOString(); + if (context === undefined) { + console.log(`[GmailFlow][${stamp}] ${message}`); + return; } + console.log(`[GmailFlow][${stamp}] ${message}`, JSON.stringify(context, null, 2)); } // =========================================================================== -// Test suite +// 8. Integrations (Gmail/Email) — RPC endpoint verification // =========================================================================== -describe('Gmail Integration Flows', () => { +describe('8. Integrations (Gmail) — RPC endpoint verification', () => { + let methods: Set; + before(async () => { - await startMockServer(); await waitForApp(); - clearRequestLog(); + await waitForAppReady(20_000); + methods = await fetchCoreRpcMethods(); + }); - // Full login + onboarding — lands on Home - await performFullLogin('e2e-gmail-flow-token'); + // ----------------------------------------------------------------------- + // 8.1 Integration Setup + // ----------------------------------------------------------------------- - // Ensure we're on Home - await navigateToHome(); + it('8.1.1 — OAuth Authorization Flow: auth_oauth_connect with google provider', async () => { + expectRpcMethod(methods, 'openhuman.auth_oauth_connect'); + const res = await callOpenhumanRpc('openhuman.auth_oauth_connect', { + provider: 'google', + responseType: 'json', + }); + if (!res.ok) { + // Without a backend session the RPC fails with an auth/request error — + // accept any defined error as proof the endpoint is reachable. + stepLog(`8.1.1 auth_oauth_connect failed (expected without session): ${res.error}`); + expect(res.error).toBeDefined(); + } }); - after(async function () { - this.timeout(30_000); - resetMockBehavior(); - try { - await stopMockServer(); - } catch (err) { - console.log(`${LOG_PREFIX} stopMockServer error (non-fatal):`, err); + it('8.1.2 — Scope Selection: auth_oauth_list_integrations returns integration list', async () => { + expectRpcMethod(methods, 'openhuman.auth_oauth_list_integrations'); + const res = await callOpenhumanRpc('openhuman.auth_oauth_list_integrations', {}); + if (!res.ok) { + stepLog(`8.1.2 auth_oauth_list_integrations failed (expected without session): ${res.error}`); + expect(res.error).toBeDefined(); } }); - // ------------------------------------------------------------------------- - // 9.1 Google OAuth Flow & Setup - // ------------------------------------------------------------------------- - - describe('9.1 Google OAuth Flow & Setup', () => { - it('9.1.1 — Google OAuth Flow: OAuth/setup button appears in setup wizard', async () => { - resetMockBehavior(); - await navigateToHome(); - - // Find Email in the UI (SkillsGrid or Intelligence page) - const emailVisible = await findGmailInUI(); - - if (!emailVisible) { - console.log( - `${LOG_PREFIX} 9.1.1: Email skill not discovered by V8 runtime. ` + - `Checking Settings connections fallback.` - ); - await navigateToHome(); - await navigateToSettings(); - } - - // Try to open the Email modal - const modalState = await openGmailModal(); - - if (!modalState) { - console.log( - `${LOG_PREFIX} 9.1.1: Email modal not opened — skill not discovered in environment. ` + - `Verifying OAuth endpoint is configured in mock server.` - ); - // Verify the mock endpoint would respond correctly - clearRequestLog(); - await navigateToHome(); - return; - } - - if (modalState === 'connect') { - // Setup wizard is open — verify setup UI elements - // The email skill uses IMAP/SMTP credential setup (setup.required: true, label: "Connect Email") - const hasSetupText = - (await textExists('Connect Email')) || - (await textExists('Email')) || - (await textExists('IMAP')) || - (await textExists('email')); - expect(hasSetupText).toBe(true); - console.log(`${LOG_PREFIX} 9.1.1: Setup wizard showing email connection UI`); - - // Verify Cancel button is present - const hasCancel = await textExists('Cancel'); - expect(hasCancel).toBe(true); - console.log(`${LOG_PREFIX} 9.1.1: Cancel button present in setup wizard`); - } else if (modalState === 'manage') { - // Already connected — setup flow previously completed - console.log( - `${LOG_PREFIX} 9.1.1: Email already connected (management panel). ` + - `Setup flow was already completed.` - ); - } - - await closeModalIfOpen(); - await navigateToHome(); - console.log(`${LOG_PREFIX} 9.1.1 PASSED`); - }); - - it('9.1.2 — Scope Selection (Read / Send / Initiate): backend called with scopes', async () => { - resetMockBehavior(); - setMockBehavior('gmailScope', 'read'); - await reAuthAndGoHome('e2e-gmail-scope-token'); - - const emailVisible = await findGmailInUI(); - if (!emailVisible) { - console.log( - `${LOG_PREFIX} 9.1.2: Email skill not discovered. ` + - `Mock OAuth endpoint configured — test passes as environment-dependent.` - ); - await navigateToHome(); - return; - } - - // Open Email modal - const modalState = await openGmailModal(); - - if (modalState === 'connect') { - clearRequestLog(); - - // Click setup button to trigger OAuth/credential setup - const setupButtonTexts = ['Connect Email', 'Sign in', 'Connect']; - let clicked = false; - for (const text of setupButtonTexts) { - if (await textExists(text)) { - await clickText(text, 10_000); - clicked = true; - console.log(`${LOG_PREFIX} 9.1.2: Clicked "${text}"`); - break; - } - } - - if (clicked) { - await browser.pause(3_000); - - // Verify the OAuth connect request was made - const oauthRequest = await waitForRequest('GET', '/auth/google/connect', 5_000); - if (oauthRequest) { - console.log(`${LOG_PREFIX} 9.1.2: OAuth connect request made: ${oauthRequest.url}`); - } else { - console.log( - `${LOG_PREFIX} 9.1.2: No OAuth connect request detected — ` + - `skill may use credential-based setup without hitting mock OAuth endpoint.` - ); - } - - // After clicking, wizard should show next step or waiting state - const hasWaiting = - (await textExists('Waiting for')) || - (await textExists('authorization')) || - (await textExists('IMAP')) || - (await textExists('Server')); - if (hasWaiting) { - console.log(`${LOG_PREFIX} 9.1.2: Setup wizard advanced to next step`); - } - } - } else if (modalState === 'manage') { - console.log( - `${LOG_PREFIX} 9.1.2: Email already connected — ` + - `scope selection happened during initial setup.` - ); - } - - await closeModalIfOpen(); - await navigateToHome(); - console.log(`${LOG_PREFIX} 9.1.2 PASSED`); - }); + it('8.1.3 — Token Storage: auth_store_provider_credentials registered', async () => { + expectRpcMethod(methods, 'openhuman.auth_store_provider_credentials'); }); - // ------------------------------------------------------------------------- - // 9.2 Permission Enforcement - // ------------------------------------------------------------------------- - - describe('9.2 Permission Enforcement', () => { - it('9.2.1 — Read-Only Mail Access: email skill listed with read permissions', async () => { - resetMockBehavior(); - setMockBehavior('gmailPermission', 'read'); - await reAuthAndGoHome('e2e-gmail-read-token'); + // ----------------------------------------------------------------------- + // 8.2 Permission Enforcement + // ----------------------------------------------------------------------- - // Navigate to Intelligence page to see skills list - try { - await navigateToIntelligence(); - await browser.pause(3_000); - console.log(`${LOG_PREFIX} 9.2.1: Navigated to Intelligence page`); - } catch { - console.log(`${LOG_PREFIX} 9.2.1: Intelligence nav not found — checking Home for skills`); - await navigateToHome(); - } + it('8.2.1 — Read Access: skills_list_tools endpoint registered for email skill', async () => { + expectRpcMethod(methods, 'openhuman.skills_list_tools'); + }); - const emailInUI = await textExists('Email'); - - if (emailInUI) { - console.log(`${LOG_PREFIX} 9.2.1: Email found — read access available`); - expect(emailInUI).toBe(true); - } else { - console.log(`${LOG_PREFIX} 9.2.1: Email not visible. ` + `Checking Home page as fallback.`); - await navigateToHome(); - const emailOnHome = await textExists('Email'); - if (emailOnHome) { - console.log(`${LOG_PREFIX} 9.2.1: Email found on Home — read access available`); - expect(emailOnHome).toBe(true); - } else { - console.log( - `${LOG_PREFIX} 9.2.1: Email skill not discovered in current environment. ` + - `Passing — skill discovery is V8 runtime-dependent.` - ); - } - } + it('8.2.2 — Write Access: skills_call_tool endpoint registered', async () => { + expectRpcMethod(methods, 'openhuman.skills_call_tool'); + }); - await navigateToHome(); - console.log(`${LOG_PREFIX} 9.2.1 PASSED`); + it('8.2.3 — Initiate Action: skills_call_tool rejects missing runtime', async () => { + const res = await callOpenhumanRpc('openhuman.skills_call_tool', { + id: 'email', + tool_name: 'send_email', + args: {}, }); + // Should fail since runtime is not started — proves endpoint is reachable + expect(res.ok).toBe(false); + }); - it('9.2.2 — Send Email Permission Enforcement: write tools accessible when connected', async () => { - resetMockBehavior(); - setMockBehavior('gmailPermission', 'write'); - setMockBehavior('gmailSetupComplete', 'true'); - await reAuthAndGoHome('e2e-gmail-write-token'); - - const emailVisible = await findGmailInUI(); - - if (!emailVisible) { - console.log( - `${LOG_PREFIX} 9.2.2: Email skill not in UI — ` + - `Mock configured with write permissions.` - ); - await navigateToHome(); - return; - } - - // If Email is visible and setup complete, write tools (send-email, create-draft, - // reply-to-email, etc.) should be accessible through the skill runtime. - const modalState = await openGmailModal(); - if (modalState === 'manage') { - console.log(`${LOG_PREFIX} 9.2.2: Email management panel open — write tools accessible`); - - // Look for Sync Now button (indicates connected + full access) - const hasSyncNow = await textExists('Sync Now'); - if (hasSyncNow) { - console.log(`${LOG_PREFIX} 9.2.2: "Sync Now" button present — full write access`); - } + it('8.2.4 — Cross-Account Access Prevention: auth_oauth_revoke_integration registered', async () => { + expectRpcMethod(methods, 'openhuman.auth_oauth_revoke_integration'); + }); - // Look for options section (configurable when connected with write access) - const hasOptions = await textExists('Options'); - if (hasOptions) { - console.log(`${LOG_PREFIX} 9.2.2: Options section present — skill fully active`); - } - } else if (modalState === 'connect') { - console.log( - `${LOG_PREFIX} 9.2.2: Email showing setup wizard — ` + - `write access requires completing setup first.` - ); - } + // ----------------------------------------------------------------------- + // 8.3 Data Operations + // ----------------------------------------------------------------------- + + it('8.3.1 — Data Fetch: skills_sync endpoint callable', async () => { + expectRpcMethod(methods, 'openhuman.skills_sync'); + const res = await callOpenhumanRpc('openhuman.skills_sync', { id: 'email' }); + if (!res.ok) { + // Skill may not be running — expected runtime error, not a validation bug + stepLog(`8.3.1 skills_sync failed: ${res.error}`); + expect(res.error).toBeDefined(); + } + }); - await closeModalIfOpen(); - await navigateToHome(); - console.log(`${LOG_PREFIX} 9.2.2 PASSED`); + it('8.3.2 — Data Write: skills_call_tool rejects write to non-running skill', async () => { + const res = await callOpenhumanRpc('openhuman.skills_call_tool', { + id: 'email', + tool_name: 'create_draft', + args: { subject: 'test', body: 'e2e' }, }); + expect(res.ok).toBe(false); + }); - it('9.2.3 — Initiate Draft / Auto-Reply Enforcement: initiate actions available', async () => { - resetMockBehavior(); - setMockBehavior('gmailPermission', 'admin'); - setMockBehavior('gmailSetupComplete', 'true'); - await reAuthAndGoHome('e2e-gmail-initiate-token'); - - const emailVisible = await findGmailInUI(); - - if (!emailVisible) { - console.log( - `${LOG_PREFIX} 9.2.3: Email skill not in UI. ` + - `Verifying mock tools endpoint is configured.` - ); - await navigateToHome(); - return; - } + it('8.3.3 — Large Data Processing: memory_query_namespace available', async () => { + expectRpcMethod(methods, 'openhuman.memory_query_namespace'); + }); - // Open management panel — if connected, tools like create-draft, auto-reply are available - const modalState = await openGmailModal(); - if (modalState === 'manage') { - console.log( - `${LOG_PREFIX} 9.2.3: Email management panel open — ` + - `create-draft, auto-reply tools available through runtime.` - ); - - // The 35 Email tools include send-email, create-draft, reply-to-email, etc. - // These are exposed through skillManager.callTool() — not directly in the UI - // but are available to AI through the MCP system. - - // Verify the skill is in a connected state (action buttons visible) - const hasRestart = await textExists('Restart'); - const hasDisconnect = await textExists('Disconnect'); - if (hasRestart || hasDisconnect) { - console.log( - `${LOG_PREFIX} 9.2.3: Skill action buttons present — ` + - `tool access (including initiate) is active.` - ); - expect(hasRestart || hasDisconnect).toBe(true); - } - } else if (modalState === 'connect') { - console.log( - `${LOG_PREFIX} 9.2.3: Email showing setup wizard — ` + - `initiate actions require completing setup first.` - ); - } + // ----------------------------------------------------------------------- + // 8.4 Disconnect & Re-Setup + // ----------------------------------------------------------------------- - await closeModalIfOpen(); - await navigateToHome(); - console.log(`${LOG_PREFIX} 9.2.3 PASSED`); + it('8.4.1 — Integration Disconnect: auth_oauth_revoke_integration callable', async () => { + const res = await callOpenhumanRpc('openhuman.auth_oauth_revoke_integration', { + integrationId: 'email-e2e-test', }); + if (!res.ok) { + // No integration exists to revoke — expected, endpoint is reachable + stepLog(`8.4.1 revoke_integration failed: ${res.error}`); + expect(res.error).toBeDefined(); + } }); - // ------------------------------------------------------------------------- - // 9.3 Email Processing - // ------------------------------------------------------------------------- - - describe('9.3 Email Processing', () => { - it('9.3.1 — Scoped Email Fetch: skill fetches emails within allowed scope', async () => { - resetMockBehavior(); - setMockBehavior('gmailPermission', 'read'); - setMockBehavior('gmailSetupComplete', 'true'); - await reAuthAndGoHome('e2e-gmail-fetch-token'); - - // Verify app is stable with email fetch capabilities - const homeMarker = await waitForHomePage(10_000); - expect(homeMarker).toBeTruthy(); - console.log(`${LOG_PREFIX} 9.3.1: Home page accessible: "${homeMarker}"`); - - const emailVisible = await findGmailInUI(); - if (emailVisible) { - const modalState = await openGmailModal(); - if (modalState === 'manage') { - console.log( - `${LOG_PREFIX} 9.3.1: Email management panel open — ` + - `scoped fetch tools (list-emails, search-emails, get-email) available.` - ); - - // Verify the skill shows connected status - const hasConnected = (await textExists('Connected')) || (await textExists('Online')); - if (hasConnected) { - console.log(`${LOG_PREFIX} 9.3.1: Email skill is connected — fetch scope active`); - } - } - await closeModalIfOpen(); - } else { - console.log( - `${LOG_PREFIX} 9.3.1: Email skill not in UI — ` + `email fetch is environment-dependent.` - ); - } - - // Verify the mock email fetch endpoint is reachable - clearRequestLog(); - await navigateToHome(); - - // Check if any email-related requests were made during re-auth - const allRequests = getRequestLog(); - const emailRequests = allRequests.filter(r => r.url.includes('/gmail/')); - console.log(`${LOG_PREFIX} 9.3.1: Email-related requests: ${emailRequests.length}`); + it('8.4.2 — Token Revocation: auth_clear_session available', async () => { + expectRpcMethod(methods, 'openhuman.auth_clear_session'); + }); - console.log(`${LOG_PREFIX} 9.3.1 PASSED`); + it('8.4.3 — Re-Authorization: auth_oauth_connect callable after revoke', async () => { + await callOpenhumanRpc('openhuman.auth_oauth_revoke_integration', { + integrationId: 'email-e2e-reauth', }); - - it('9.3.2 — Time-Range Filtering: time-based email filtering works', async () => { - resetMockBehavior(); - setMockBehavior('gmailPermission', 'read'); - setMockBehavior('gmailSetupComplete', 'true'); - await reAuthAndGoHome('e2e-gmail-timerange-token'); - - // Verify app stability with time-range filtering configured - const homeMarker = await waitForHomePage(10_000); - expect(homeMarker).toBeTruthy(); - console.log( - `${LOG_PREFIX} 9.3.2: App stable with time-range filtering mock: "${homeMarker}"` - ); - - const emailVisible = await findGmailInUI(); - if (emailVisible) { - const modalState = await openGmailModal(); - if (modalState === 'manage') { - console.log( - `${LOG_PREFIX} 9.3.2: Email management panel open — ` + - `time-range filtering available through search-emails tool.` - ); - - // The email skill's search-emails tool accepts date range parameters - // Verify options section is present (may include filtering preferences) - const hasOptions = await textExists('Options'); - if (hasOptions) { - console.log(`${LOG_PREFIX} 9.3.2: Options section present for filter configuration`); - } - } - await closeModalIfOpen(); - } else { - console.log( - `${LOG_PREFIX} 9.3.2: Email skill not in UI — ` + - `time-range filtering is environment-dependent.` - ); - } - - await navigateToHome(); - console.log(`${LOG_PREFIX} 9.3.2 PASSED`); + const res = await callOpenhumanRpc('openhuman.auth_oauth_connect', { + provider: 'google', + responseType: 'json', }); + if (!res.ok) { + stepLog(`8.4.3 auth_oauth_connect (re-auth) failed (expected without session): ${res.error}`); + expect(res.error).toBeDefined(); + } + }); - it('9.3.3 — Attachment Handling: attachment tools available', async () => { - resetMockBehavior(); - setMockBehavior('gmailPermission', 'write'); - setMockBehavior('gmailSetupComplete', 'true'); - await reAuthAndGoHome('e2e-gmail-attachment-token'); - - const emailVisible = await findGmailInUI(); - - if (!emailVisible) { - console.log( - `${LOG_PREFIX} 9.3.3: Email skill not in UI. ` + - `Attachment handling is environment-dependent.` - ); - await navigateToHome(); - return; - } + it('8.4.4 — Permission Re-Sync: skills_sync callable after reconnect', async () => { + const res = await callOpenhumanRpc('openhuman.skills_sync', { id: 'email' }); + if (!res.ok) { + stepLog(`8.4.4 skills_sync failed: ${res.error}`); + expect(res.error).toBeDefined(); + } + }); - const modalState = await openGmailModal(); - if (modalState === 'manage') { - console.log( - `${LOG_PREFIX} 9.3.3: Email management panel open — ` + - `attachment tools (get-attachments, download-attachment) available through runtime.` - ); - - // Verify skill is in active state with full tool access - const hasRestart = await textExists('Restart'); - const hasDisconnect = await textExists('Disconnect'); - if (hasRestart || hasDisconnect) { - console.log( - `${LOG_PREFIX} 9.3.3: Skill action buttons present — attachment tools active.` - ); - } - } else if (modalState === 'connect') { - console.log( - `${LOG_PREFIX} 9.3.3: Email showing setup wizard — ` + - `attachment tools require completing setup first.` - ); - } + // Additional skill endpoints + it('skills_start endpoint registered', async () => { + expectRpcMethod(methods, 'openhuman.skills_start'); + }); - await closeModalIfOpen(); - await navigateToHome(); - console.log(`${LOG_PREFIX} 9.3.3 PASSED`); - }); + it('skills_stop endpoint registered', async () => { + expectRpcMethod(methods, 'openhuman.skills_stop'); }); - // ------------------------------------------------------------------------- - // 9.4 Disconnect & Re-Run Setup - // ------------------------------------------------------------------------- + it('skills_discover endpoint registered', async () => { + expectRpcMethod(methods, 'openhuman.skills_discover'); + }); - describe('9.4 Disconnect & Re-Run Setup', () => { - it('9.4.1 — Manual Disconnect: disconnect flow with confirmation', async () => { - resetMockBehavior(); - await reAuthAndGoHome('e2e-gmail-disconnect-token'); + it('skills_status endpoint registered', async () => { + expectRpcMethod(methods, 'openhuman.skills_status'); + }); +}); - const emailVisible = await findGmailInUI(); - if (!emailVisible) { - console.log(`${LOG_PREFIX} 9.4.1: Email skill not discovered. Checking Settings.`); - await navigateToHome(); - await navigateToSettings(); - } +// =========================================================================== +// 8.5 Gmail — UI flow (Skills page → 3rd Party Skills → Email card) +// =========================================================================== - await browser.pause(1_000); +describe('8.5 Integrations (Gmail) — UI flow', () => { + before(async () => { + stepLog('starting mock server'); + await startMockServer(); + stepLog('waiting for app'); + await waitForApp(); + clearRequestLog(); + }); - // Open the Email modal - const modalState = await openGmailModal(); + after(async () => { + stepLog('stopping mock server'); + await stopMockServer(); + }); - if (!modalState) { - console.log( - `${LOG_PREFIX} 9.4.1: Email modal not opened — ` + - `skill not discovered in current environment.` - ); - await navigateToHome(); + /** + * Ensure the Skills page "Other" filter tab is active so only 3rd-party + * skills (Gmail, Notion, …) are rendered. The filter lives in React + * component state and can revert to "All" between `it()` blocks, which + * pushes Gmail/Notion far below the fold under Built-in and Channels. + * + * The category filter is rendered as `