Merge remote-tracking branch 'upstream/develop' into develop

This commit is contained in:
cyrus
2026-02-23 14:57:16 +05:30
23 changed files with 8022 additions and 1798 deletions
+8 -2
View File
@@ -1,7 +1,7 @@
{
"name": "alphahuman",
"private": true,
"version": "0.47.0",
"version": "0.48.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -28,7 +28,12 @@
"test:e2e:build": "bash scripts/e2e-build.sh",
"test:e2e:login": "bash scripts/e2e-login.sh",
"test:e2e:auth": "bash scripts/e2e-auth.sh",
"test:e2e": "yarn test:e2e:build && yarn test:e2e:login && yarn test:e2e:auth",
"test:e2e:payment": "bash scripts/e2e-payment.sh",
"test:e2e:crypto-payment": "bash scripts/e2e-crypto-payment.sh",
"test:e2e:telegram": "bash scripts/e2e-telegram.sh",
"test:e2e:notion": "bash scripts/e2e-notion.sh",
"test:e2e:gmail": "bash scripts/e2e-gmail.sh",
"test:e2e": "yarn test:e2e:build && yarn test:e2e:login && yarn test:e2e:auth && yarn test:e2e:payment && yarn test:e2e:crypto-payment && yarn test:e2e:telegram && yarn test:e2e:notion && yarn test:e2e:gmail",
"test:all": "yarn test:coverage && yarn test:rust && yarn test:e2e",
"format": "prettier --write .",
"format:check": "prettier --check .",
@@ -39,6 +44,7 @@
"prepare": "husky"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/react": "^10.38.0",
"@tauri-apps/api": "^2",
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# Run E2E cryptocurrency payment flow tests only.
#
# Starts Appium, cleans app caches, runs the crypto-payment-flow spec,
# then tears everything down. Each flow script is self-contained so
# specs don't pollute each other's Redux Persist state.
#
# Usage:
# ./scripts/e2e-crypto-payment.sh
# APPIUM_PORT=4723 ./scripts/e2e-crypto-payment.sh
#
set -euo pipefail
APPIUM_PORT="${APPIUM_PORT:-4723}"
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
SPEC="test/e2e/specs/crypto-payment-flow.spec.ts"
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
# Clean cached app data for a fresh state — Redux Persist would otherwise
# remember the JWT from a previous run and skip the login flow.
echo "Cleaning cached app data..."
rm -rf ~/Library/WebKit/com.alphahuman.app
rm -rf ~/Library/Caches/com.alphahuman.app
rm -rf "$HOME/Library/Application Support/com.alphahuman.app"
# Verify the frontend dist has the mock server URL baked in.
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
echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2
exit 1
fi
if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then
echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2
echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2
exit 1
fi
echo "Verified: frontend bundle contains mock server URL."
# --- Resolve Node 24 via nvm ---------------------------------------------------
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
NODE24="$(nvm which 24 2>/dev/null || true)"
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
exit 1
fi
APPIUM_BIN="$(dirname "$NODE24")/appium"
if [ ! -x "$APPIUM_BIN" ]; then
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
exit 1
fi
# --- Start Appium in the background -------------------------------------------
APPIUM_LOG="/tmp/appium-e2e-crypto-payment.log"
NODE_VER=$("$NODE24" --version)
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
echo " Appium logs: $APPIUM_LOG"
"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 &
APPIUM_PID=$!
cleanup() {
echo "Stopping Appium (pid $APPIUM_PID)..."
kill "$APPIUM_PID" 2>/dev/null || true
wait "$APPIUM_PID" 2>/dev/null || true
}
trap cleanup EXIT
# Wait for Appium to be ready
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
echo "Appium is ready."
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Appium did not start within 30 seconds." >&2
exit 1
fi
sleep 1
done
# --- Run WebDriverIO ----------------------------------------------------------
echo "Running E2E crypto payment flow tests ($SPEC)..."
npx wdio run wdio.conf.ts --spec "$SPEC"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# Run E2E Gmail integration flow tests only.
#
# Starts Appium, cleans app caches, runs the gmail-flow spec,
# then tears everything down. Each flow script is self-contained so
# specs don't pollute each other's Redux Persist state.
#
# Usage:
# ./scripts/e2e-gmail.sh
# APPIUM_PORT=4723 ./scripts/e2e-gmail.sh
#
set -euo pipefail
APPIUM_PORT="${APPIUM_PORT:-4723}"
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
SPEC="test/e2e/specs/gmail-flow.spec.ts"
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
# Clean cached app data for a fresh state — Redux Persist would otherwise
# remember the JWT from a previous run and skip the login flow.
echo "Cleaning cached app data..."
rm -rf ~/Library/WebKit/com.alphahuman.app
rm -rf ~/Library/Caches/com.alphahuman.app
rm -rf "$HOME/Library/Application Support/com.alphahuman.app"
# Verify the frontend dist has the mock server URL baked in.
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
echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2
exit 1
fi
if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then
echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2
echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2
exit 1
fi
echo "Verified: frontend bundle contains mock server URL."
# --- Resolve Node 24 via nvm ---------------------------------------------------
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
NODE24="$(nvm which 24 2>/dev/null || true)"
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
exit 1
fi
APPIUM_BIN="$(dirname "$NODE24")/appium"
if [ ! -x "$APPIUM_BIN" ]; then
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
exit 1
fi
# --- Start Appium in the background -------------------------------------------
APPIUM_LOG="/tmp/appium-e2e-gmail.log"
NODE_VER=$("$NODE24" --version)
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
echo " Appium logs: $APPIUM_LOG"
"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 &
APPIUM_PID=$!
cleanup() {
echo "Stopping Appium (pid $APPIUM_PID)..."
kill "$APPIUM_PID" 2>/dev/null || true
wait "$APPIUM_PID" 2>/dev/null || true
}
trap cleanup EXIT
# Wait for Appium to be ready
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
echo "Appium is ready."
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Appium did not start within 30 seconds." >&2
exit 1
fi
sleep 1
done
# --- Run WebDriverIO ----------------------------------------------------------
echo "Running E2E Gmail integration flow tests ($SPEC)..."
npx wdio run wdio.conf.ts --spec "$SPEC"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# Run E2E Notion integration flow tests only.
#
# Starts Appium, cleans app caches, runs the notion-flow spec,
# then tears everything down. Each flow script is self-contained so
# specs don't pollute each other's Redux Persist state.
#
# Usage:
# ./scripts/e2e-notion.sh
# APPIUM_PORT=4723 ./scripts/e2e-notion.sh
#
set -euo pipefail
APPIUM_PORT="${APPIUM_PORT:-4723}"
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
SPEC="test/e2e/specs/notion-flow.spec.ts"
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
# Clean cached app data for a fresh state — Redux Persist would otherwise
# remember the JWT from a previous run and skip the login flow.
echo "Cleaning cached app data..."
rm -rf ~/Library/WebKit/com.alphahuman.app
rm -rf ~/Library/Caches/com.alphahuman.app
rm -rf "$HOME/Library/Application Support/com.alphahuman.app"
# Verify the frontend dist has the mock server URL baked in.
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
echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2
exit 1
fi
if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then
echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2
echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2
exit 1
fi
echo "Verified: frontend bundle contains mock server URL."
# --- Resolve Node 24 via nvm ---------------------------------------------------
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
NODE24="$(nvm which 24 2>/dev/null || true)"
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
exit 1
fi
APPIUM_BIN="$(dirname "$NODE24")/appium"
if [ ! -x "$APPIUM_BIN" ]; then
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
exit 1
fi
# --- Start Appium in the background -------------------------------------------
APPIUM_LOG="/tmp/appium-e2e-notion.log"
NODE_VER=$("$NODE24" --version)
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
echo " Appium logs: $APPIUM_LOG"
"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 &
APPIUM_PID=$!
cleanup() {
echo "Stopping Appium (pid $APPIUM_PID)..."
kill "$APPIUM_PID" 2>/dev/null || true
wait "$APPIUM_PID" 2>/dev/null || true
}
trap cleanup EXIT
# Wait for Appium to be ready
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
echo "Appium is ready."
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Appium did not start within 30 seconds." >&2
exit 1
fi
sleep 1
done
# --- Run WebDriverIO ----------------------------------------------------------
echo "Running E2E Notion integration flow tests ($SPEC)..."
npx wdio run wdio.conf.ts --spec "$SPEC"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# Run E2E card payment flow tests only.
#
# Starts Appium, cleans app caches, runs the card-payment-flow spec,
# then tears everything down. Each flow script is self-contained so
# specs don't pollute each other's Redux Persist state.
#
# Usage:
# ./scripts/e2e-payment.sh
# APPIUM_PORT=4723 ./scripts/e2e-payment.sh
#
set -euo pipefail
APPIUM_PORT="${APPIUM_PORT:-4723}"
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
SPEC="test/e2e/specs/card-payment-flow.spec.ts"
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
# Clean cached app data for a fresh state — Redux Persist would otherwise
# remember the JWT from a previous run and skip the login flow.
echo "Cleaning cached app data..."
rm -rf ~/Library/WebKit/com.alphahuman.app
rm -rf ~/Library/Caches/com.alphahuman.app
rm -rf "$HOME/Library/Application Support/com.alphahuman.app"
# Verify the frontend dist has the mock server URL baked in.
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
echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2
exit 1
fi
if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then
echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2
echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2
exit 1
fi
echo "Verified: frontend bundle contains mock server URL."
# --- Resolve Node 24 via nvm ---------------------------------------------------
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
NODE24="$(nvm which 24 2>/dev/null || true)"
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
exit 1
fi
APPIUM_BIN="$(dirname "$NODE24")/appium"
if [ ! -x "$APPIUM_BIN" ]; then
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
exit 1
fi
# --- Start Appium in the background -------------------------------------------
APPIUM_LOG="/tmp/appium-e2e-payment.log"
NODE_VER=$("$NODE24" --version)
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
echo " Appium logs: $APPIUM_LOG"
"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 &
APPIUM_PID=$!
cleanup() {
echo "Stopping Appium (pid $APPIUM_PID)..."
kill "$APPIUM_PID" 2>/dev/null || true
wait "$APPIUM_PID" 2>/dev/null || true
}
trap cleanup EXIT
# Wait for Appium to be ready
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
echo "Appium is ready."
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Appium did not start within 30 seconds." >&2
exit 1
fi
sleep 1
done
# --- Run WebDriverIO ----------------------------------------------------------
echo "Running E2E card payment flow tests ($SPEC)..."
npx wdio run wdio.conf.ts --spec "$SPEC"
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# Run E2E Telegram integration flow tests only.
#
# Starts Appium, cleans app caches, runs the telegram-flow spec,
# then tears everything down. Each flow script is self-contained so
# specs don't pollute each other's Redux Persist state.
#
# Usage:
# ./scripts/e2e-telegram.sh
# APPIUM_PORT=4723 ./scripts/e2e-telegram.sh
#
set -euo pipefail
APPIUM_PORT="${APPIUM_PORT:-4723}"
E2E_MOCK_PORT="${E2E_MOCK_PORT:-18473}"
SPEC="test/e2e/specs/telegram-flow.spec.ts"
export VITE_BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
export BACKEND_URL="http://127.0.0.1:${E2E_MOCK_PORT}"
# Clean cached app data for a fresh state — Redux Persist would otherwise
# remember the JWT from a previous run and skip the login flow.
echo "Cleaning cached app data..."
rm -rf ~/Library/WebKit/com.alphahuman.app
rm -rf ~/Library/Caches/com.alphahuman.app
rm -rf "$HOME/Library/Application Support/com.alphahuman.app"
# Verify the frontend dist has the mock server URL baked in.
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
echo " Run 'yarn test:e2e:build' to build the app before running E2E tests." >&2
exit 1
fi
if ! grep -q "127.0.0.1:${E2E_MOCK_PORT}" "$DIST_JS"; then
echo "ERROR: frontend bundle does NOT contain mock server URL (127.0.0.1:${E2E_MOCK_PORT})." >&2
echo " Run 'yarn test:e2e:build' to rebuild with the mock URL." >&2
exit 1
fi
echo "Verified: frontend bundle contains mock server URL."
# --- Resolve Node 24 via nvm ---------------------------------------------------
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
NODE24="$(nvm which 24 2>/dev/null || true)"
if [ -z "$NODE24" ] || [ ! -x "$NODE24" ]; then
echo "ERROR: Node 24 is required for appium v3. Install it with: nvm install 24" >&2
exit 1
fi
APPIUM_BIN="$(dirname "$NODE24")/appium"
if [ ! -x "$APPIUM_BIN" ]; then
echo "ERROR: appium not found at $APPIUM_BIN. Install it with: nvm use 24 && npm i -g appium" >&2
exit 1
fi
# --- Start Appium in the background -------------------------------------------
APPIUM_LOG="/tmp/appium-e2e-telegram.log"
NODE_VER=$("$NODE24" --version)
echo "Starting Appium on port $APPIUM_PORT (Node $NODE_VER)..."
echo " Appium logs: $APPIUM_LOG"
"$NODE24" "$APPIUM_BIN" --port "$APPIUM_PORT" --relaxed-security > "$APPIUM_LOG" 2>&1 &
APPIUM_PID=$!
cleanup() {
echo "Stopping Appium (pid $APPIUM_PID)..."
kill "$APPIUM_PID" 2>/dev/null || true
wait "$APPIUM_PID" 2>/dev/null || true
}
trap cleanup EXIT
# Wait for Appium to be ready
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:$APPIUM_PORT/status" >/dev/null 2>&1; then
echo "Appium is ready."
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Appium did not start within 30 seconds." >&2
exit 1
fi
sleep 1
done
# --- Run WebDriverIO ----------------------------------------------------------
echo "Running E2E Telegram integration flow tests ($SPEC)..."
npx wdio run wdio.conf.ts --spec "$SPEC"
@@ -38,8 +38,10 @@ const BillingPanel = () => {
const [paymentMethod, setPaymentMethod] = useState<'card' | 'crypto'>('card');
const [isPurchasing, setIsPurchasing] = useState(false);
const [purchasingTier, setPurchasingTier] = useState<PlanTier | null>(null);
const [paymentConfirmed, setPaymentConfirmed] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollStartRef = useRef<number>(0);
const timeoutRef = useRef<number | null>(null);
// Fetch current plan on mount
useEffect(() => {
@@ -60,6 +62,40 @@ const BillingPanel = () => {
};
}, []);
// Handle payment:success deep link event
useEffect(() => {
const onPaymentSuccess = async () => {
// Stop any in-flight poll — we know checkout completed
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
setIsPurchasing(false);
setPurchasingTier(null);
setPaymentConfirmed(true);
// Fetch current plan from backend, then refresh user/teams in store
try {
await billingApi.getCurrentPlan();
} catch (e) {
console.error('Failed to fetch current plan after payment', e);
}
dispatch(fetchCurrentUser());
// Auto-hide the success banner after 5 s
timeoutRef.current = window.setTimeout(() => setPaymentConfirmed(false), 5_000);
};
window.addEventListener('payment:success', onPaymentSuccess);
return () => {
window.removeEventListener('payment:success', onPaymentSuccess);
if (timeoutRef.current !== null) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
};
}, [dispatch]);
// ── Poll for plan change after checkout ─────────────────────────────
const currentTierRef = useRef(currentTier);
useEffect(() => {
@@ -295,6 +331,29 @@ const BillingPanel = () => {
})}
</div>
{/* ── Payment confirmed banner ─────────────────────────── */}
{paymentConfirmed && (
<div className="rounded-xl bg-sage-500/10 border border-sage-500/20 p-3 mx-4">
<div className="flex items-center gap-2">
<svg
className="w-4 h-4 text-sage-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<p className="text-xs text-sage-300 font-medium">
Payment confirmed! Your plan has been updated.
</p>
</div>
</div>
)}
{/* ── Purchasing overlay message ────────────────────────── */}
{isPurchasing && (
<div className="rounded-xl bg-amber-500/10 border border-amber-500/20 p-3 mx-4">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,87 @@
import React from 'react';
import { CheckIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline';
interface ActionPanelProps {
children: React.ReactNode;
hasChanges?: boolean;
success?: boolean;
error?: string;
className?: string;
}
const ActionPanel: React.FC<ActionPanelProps> = ({
children,
hasChanges = false,
success = false,
error,
className = ''
}) => {
return (
<div className={`space-y-6 ${className}`}>
<div className="flex flex-wrap items-center gap-4">
{children}
{hasChanges && (
<div className="flex items-center gap-2 text-xs text-amber-300">
<div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
Unsaved changes
</div>
)}
</div>
{success && (
<div className="flex items-center gap-2 rounded-lg border border-sage-500/40 bg-sage-500/10 px-3 py-2 text-sm text-sage-200">
<CheckIcon className="h-4 w-4" />
Operation completed successfully
</div>
)}
{error && (
<div className="flex items-center gap-2 rounded-lg border border-coral-500/40 bg-coral-500/10 px-3 py-2 text-sm text-coral-200">
<ExclamationTriangleIcon className="h-4 w-4" />
{error}
</div>
)}
</div>
);
};
interface PrimaryButtonProps {
onClick: () => void;
loading?: boolean;
disabled?: boolean;
variant?: 'primary' | 'secondary' | 'outline';
children: React.ReactNode;
className?: string;
}
const PrimaryButton: React.FC<PrimaryButtonProps> = ({
onClick,
loading = false,
disabled = false,
variant = 'primary',
children,
className = ''
}) => {
const baseClasses = 'px-6 py-3 rounded-lg font-medium transition-all duration-200 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed';
const variantClasses = {
primary: 'bg-primary-600 hover:bg-primary-500 active:bg-primary-700 text-white shadow-soft hover:shadow-lg hover:shadow-primary-500/25 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black',
secondary: 'bg-stone-800 hover:bg-stone-700 active:bg-stone-600 text-white border border-stone-600 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black',
outline: 'border border-stone-600 text-white hover:bg-white/10 active:bg-white/20 focus:ring-2 focus:ring-primary-500/50 focus:ring-offset-2 focus:ring-offset-black'
};
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${className}`}
onClick={onClick}
disabled={disabled || loading}
>
{loading && (
<div className="h-4 w-4 border-2 border-white/20 border-t-white rounded-full animate-spin mr-2" />
)}
{children}
</button>
);
};
export default ActionPanel;
export { PrimaryButton };
@@ -0,0 +1,102 @@
import React from 'react';
interface InputGroupProps {
title?: string;
description?: string;
children: React.ReactNode;
className?: string;
}
const InputGroup: React.FC<InputGroupProps> = ({
title,
description,
children,
className = ''
}) => {
return (
<div className={`space-y-6 ${className}`}>
{title && (
<div className="space-y-2">
<h4 className="text-lg font-medium text-white">{title}</h4>
{description && (
<p className="text-sm text-gray-400">{description}</p>
)}
</div>
)}
<div className="rounded-lg border border-white/10 bg-white/5 backdrop-blur-sm p-6">
<div className="grid gap-6 md:grid-cols-2">
{children}
</div>
</div>
</div>
);
};
interface FieldProps {
label: string;
children: React.ReactNode;
helpText?: string;
className?: string;
fullWidth?: boolean;
}
const Field: React.FC<FieldProps> = ({
label,
children,
helpText,
className = '',
fullWidth = false
}) => {
return (
<label className={`space-y-3 text-sm text-gray-300 ${fullWidth ? 'md:col-span-2' : ''} ${className}`}>
<div>
<span className="font-medium">{label}</span>
{helpText && (
<p className="text-xs text-gray-400 leading-relaxed mt-1">
{helpText}
</p>
)}
</div>
{children}
</label>
);
};
interface CheckboxFieldProps {
label: string;
checked: boolean;
onChange: (checked: boolean) => void;
helpText?: string;
className?: string;
}
const CheckboxField: React.FC<CheckboxFieldProps> = ({
label,
checked,
onChange,
helpText,
className = ''
}) => {
return (
<div className={`flex flex-col gap-3 ${className}`}>
<label className="flex items-center gap-3 text-sm text-gray-300 cursor-pointer">
<input
type="checkbox"
className="w-5 h-5 rounded border-2 border-stone-600 bg-stone-900/40 text-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500/50 transition-all duration-200"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="font-medium">{label}</span>
</label>
{helpText && (
<p className="text-xs text-gray-400 ml-7 leading-relaxed">
{helpText}
</p>
)}
</div>
);
};
export default InputGroup;
export { Field, CheckboxField };
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
interface SectionCardProps {
title: string;
priority: 'critical' | 'infrastructure' | 'development' | 'tools';
icon: React.ReactElement;
children: React.ReactNode;
collapsible?: boolean;
defaultExpanded?: boolean;
hasChanges?: boolean;
loading?: boolean;
}
const priorityStyles = {
critical: 'bg-gradient-to-br from-primary-500/10 to-primary-600/5 border-primary-500/20',
infrastructure: 'bg-gradient-to-br from-slate-500/8 to-slate-600/4 border-slate-500/15',
development: 'bg-gradient-to-br from-amber-500/8 to-amber-600/4 border-amber-500/15',
tools: 'bg-black/30 border-stone-600/30'
} as const;
const priorityIconColors = {
critical: 'text-primary-400',
infrastructure: 'text-slate-400',
development: 'text-amber-400',
tools: 'text-stone-400'
} as const;
const SectionCard: React.FC<SectionCardProps> = ({
title,
priority,
icon,
children,
collapsible = false,
defaultExpanded = true,
hasChanges = false,
loading = false
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const handleToggle = () => {
if (collapsible) {
setIsExpanded(!isExpanded);
}
};
return (
<div className={`rounded-xl border backdrop-blur-sm transition-all duration-200 ${priorityStyles[priority]}`}>
<div
className={`flex items-center justify-between p-6 ${collapsible ? 'cursor-pointer hover:bg-white/5' : ''}`}
onClick={handleToggle}
>
<div className="flex items-center gap-3">
<div className={`flex-shrink-0 ${priorityIconColors[priority]}`}>
{React.cloneElement(icon, { className: 'h-5 w-5' } as any)}
</div>
<div className="flex items-center gap-2">
<h3 className="text-xl font-semibold text-white font-display">{title}</h3>
{hasChanges && (
<div className="h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
)}
{loading && (
<div className="h-4 w-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
)}
</div>
</div>
{collapsible && (
<div className="text-gray-400 transition-transform duration-200">
{isExpanded ? (
<ChevronDownIcon className="h-5 w-5" />
) : (
<ChevronRightIcon className="h-5 w-5" />
)}
</div>
)}
</div>
{(!collapsible || isExpanded) && (
<div className="px-6 pb-6">
<div className="space-y-8">
{children}
</div>
</div>
)}
</div>
);
};
export default SectionCard;
+3 -11
View File
@@ -386,15 +386,11 @@ const Conversations = () => {
<div
key={thread.id}
className={`group relative transition-colors ${
thread.id === selectedThreadId
? 'bg-white/10'
: 'hover:bg-white/[0.07]'
thread.id === selectedThreadId ? 'bg-white/10' : 'hover:bg-white/[0.07]'
}`}>
{confirmDeleteId === thread.id ? (
<div className="flex items-center justify-between py-3 px-4">
<span className="text-xs text-stone-400 truncate">
Delete this thread?
</span>
<span className="text-xs text-stone-400 truncate">Delete this thread?</span>
<div className="flex gap-2 flex-shrink-0 ml-2">
<button
onClick={() => setConfirmDeleteId(null)}
@@ -554,11 +550,7 @@ const Conversations = () => {
<button
onClick={handleMobileBack}
className="p-1 rounded-lg hover:bg-white/10 text-stone-400 hover:text-stone-200 transition-colors flex-shrink-0 -ml-1">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
+8 -5
View File
@@ -44,11 +44,14 @@ export const threadApi = {
},
/** POST /chat/sendMessage — send a user message to a thread and get the agent response */
sendMessage: async (message: string, conversationId: string): Promise<SendMessageResponseData> => {
const response = await apiClient.post<ApiResponse<SendMessageResponseData>>('/chat/sendMessage', {
message,
conversationId,
});
sendMessage: async (
message: string,
conversationId: string
): Promise<SendMessageResponseData> => {
const response = await apiClient.post<ApiResponse<SendMessageResponseData>>(
'/chat/sendMessage',
{ message, conversationId }
);
return response.data;
},
+1 -5
View File
@@ -38,11 +38,7 @@ const aiPersistConfig = { key: 'ai', storage, whitelist: ['config'] };
const skillsPersistConfig = { key: 'skills', storage, whitelist: ['skills'] };
// Persist config for thread UI prefs only (panel width, last viewed for unread)
const threadPersistConfig = {
key: 'thread',
storage,
whitelist: ['panelWidth', 'lastViewedAt'],
};
const threadPersistConfig = { key: 'thread', storage, whitelist: ['panelWidth', 'lastViewedAt'] };
const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer);
+9
View File
@@ -21,3 +21,12 @@ export const buildDesktopDeeplink = (token: string): string => {
const encoded = encodeURIComponent(token);
return `${DESKTOP_SCHEME}://auth?token=${encoded}`;
};
export const buildPaymentSuccessDeeplink = (sessionId: string): string => {
const encoded = encodeURIComponent(sessionId);
return `${DESKTOP_SCHEME}://payment/success?session_id=${encoded}`;
};
export const buildPaymentCancelDeeplink = (): string => {
return `${DESKTOP_SCHEME}://payment/cancel`;
};
+45
View File
@@ -29,6 +29,46 @@ const handleAuthDeepLink = async (parsed: URL) => {
window.location.hash = '/onboarding';
};
/**
* Handle `alphahuman://payment/success?session_id=...` deep links.
* Fired when a Stripe checkout session completes and the browser redirects
* back to the desktop app.
*/
const handlePaymentDeepLink = async (parsed: URL) => {
const path = parsed.pathname.replace(/^\/+/, '');
try {
await invoke('show_window');
} catch {
// Not fatal
}
if (path === 'success') {
const sessionId = parsed.searchParams.get('session_id');
if (!sessionId) {
console.warn('[DeepLink] Payment success missing session_id');
return;
}
console.log('[DeepLink] Payment success, session_id:', sessionId);
// Broadcast to the app so billing components can react
window.dispatchEvent(
new CustomEvent('payment:success', { detail: { sessionId } }),
);
// Navigate to billing settings to show confirmation
window.location.hash = '/settings/billing';
} else if (path === 'cancel') {
console.log('[DeepLink] Payment cancelled');
window.dispatchEvent(new CustomEvent('payment:cancel', {}));
window.location.hash = '/settings/billing';
} else {
console.warn('[DeepLink] Unknown payment path:', path);
}
};
/**
* Handle `alphahuman://oauth/success?integrationId=...&skillId=...`
* and `alphahuman://oauth/error?error=...&provider=...` deep links.
@@ -74,6 +114,8 @@ const handleOAuthDeepLink = async (parsed: URL) => {
* - `alphahuman://auth?token=...` → login flow
* - `alphahuman://oauth/success?...` → OAuth completion
* - `alphahuman://oauth/error?...` → OAuth failure
* - `alphahuman://payment/success?session_id=...` → Stripe payment confirmation
* - `alphahuman://payment/cancel` → Stripe payment cancellation
*/
const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
if (!urls || urls.length === 0) {
@@ -95,6 +137,9 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
case 'oauth':
await handleOAuthDeepLink(parsed);
break;
case 'payment':
await handlePaymentDeepLink(parsed);
break;
default:
console.warn('[DeepLink] Unknown deep link hostname:', parsed.hostname);
break;
+233 -1
View File
@@ -264,6 +264,10 @@ async function handleRequest(req, res) {
// POST /payments/stripe/purchasePlan
if (method === 'POST' && /^\/payments\/stripe\/purchasePlan\/?$/.test(url)) {
if (mockBehavior['purchaseError'] === 'true') {
json(res, 500, { success: false, error: 'Payment service unavailable' });
return;
}
json(res, 200, {
success: true,
data: {
@@ -282,10 +286,15 @@ async function handleRequest(req, res) {
// POST /payments/coinbase/charge
if (method === 'POST' && /^\/payments\/coinbase\/charge\/?$/.test(url)) {
if (mockBehavior['coinbaseError'] === 'true') {
json(res, 500, { success: false, error: 'Coinbase service unavailable' });
return;
}
const chargeId = 'coinbase_mock_' + Date.now();
json(res, 200, {
success: true,
data: {
gatewayTransactionId: 'coinbase_mock_' + Date.now(),
gatewayTransactionId: chargeId,
hostedUrl: 'http://127.0.0.1:18473/mock-coinbase-checkout',
status: 'NEW',
expiresAt: new Date(Date.now() + 3600000).toISOString(),
@@ -294,6 +303,229 @@ async function handleRequest(req, res) {
return;
}
// GET /payments/coinbase/charge/:id — crypto payment status check
if (method === 'GET' && /^\/payments\/coinbase\/charge\/[^/]+\/?(\?.*)?$/.test(url)) {
const status = mockBehavior['cryptoStatus'] || 'NEW';
const underpaidAmount = mockBehavior['cryptoUnderpaidAmount'] || '0';
const overpaidAmount = mockBehavior['cryptoOverpaidAmount'] || '0';
json(res, 200, {
success: true,
data: {
status,
payment: {
status,
amountPaid:
status === 'UNDERPAID' ? '150.00' : status === 'OVERPAID' ? '350.00' : '250.00',
amountExpected: '250.00',
currency: 'USDC',
underpaidAmount,
overpaidAmount,
},
expiresAt: new Date(Date.now() + 3600000).toISOString(),
},
});
return;
}
// GET /auth/telegram/connect — OAuth connect URL for Telegram setup
if (method === 'GET' && /^\/auth\/telegram\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior['telegramDuplicate'] === 'true') {
json(res, 409, { success: false, error: 'Telegram account already linked to another user' });
return;
}
json(res, 200, {
success: true,
data: { oauthUrl: 'http://127.0.0.1:18473/mock-telegram-oauth' },
});
return;
}
// POST /telegram/command — process a Telegram command
if (method === 'POST' && /^\/telegram\/command\/?$/.test(url)) {
if (mockBehavior['telegramUnauthorized'] === 'true') {
json(res, 403, { success: false, error: 'Unauthorized: insufficient permissions' });
return;
}
if (mockBehavior['telegramCommandError'] === 'true') {
json(res, 400, { success: false, error: 'Invalid command format' });
return;
}
json(res, 200, { success: true, data: { result: 'Command executed successfully' } });
return;
}
// GET /telegram/permissions — get current permission levels
if (method === 'GET' && /^\/telegram\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior['telegramPermission'] || 'read';
json(res, 200, {
success: true,
data: { level, canRead: true, canWrite: level !== 'read', canInitiate: level === 'admin' },
});
return;
}
// POST /telegram/webhook/configure — configure webhook
if (method === 'POST' && /^\/telegram\/webhook\/configure\/?$/.test(url)) {
json(res, 200, {
success: true,
data: { webhookUrl: 'https://api.example.com/webhook/telegram', active: true },
});
return;
}
// POST /telegram/disconnect — disconnect Telegram skill
if (method === 'POST' && /^\/telegram\/disconnect\/?$/.test(url)) {
json(res, 200, { success: true, data: { disconnected: true } });
return;
}
// GET /skills — list available skills
if (method === 'GET' && /^\/skills\/?(\?.*)?$/.test(url)) {
json(res, 200, {
success: true,
data: [
{
id: 'telegram',
name: 'Telegram',
status: mockBehavior['telegramSkillStatus'] || 'installed',
setupComplete: mockBehavior['telegramSetupComplete'] === 'true',
},
{
id: 'notion',
name: 'Notion',
status: mockBehavior['notionSkillStatus'] || 'installed',
setupComplete: mockBehavior['notionSetupComplete'] === 'true',
},
{
id: 'email',
name: 'Email',
status: mockBehavior['gmailSkillStatus'] || 'installed',
setupComplete: mockBehavior['gmailSetupComplete'] === 'true',
},
],
});
return;
}
// GET /mock-telegram-oauth — mock OAuth page
if (method === 'GET' && /^\/mock-telegram-oauth\/?(\?.*)?$/.test(url)) {
setCors(res);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Mock Telegram OAuth</h1></body></html>');
return;
}
// GET /auth/notion/connect — OAuth connect URL for Notion setup
if (method === 'GET' && /^\/auth\/notion\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior['notionTokenRevoked'] === 'true') {
json(res, 401, { success: false, error: 'OAuth token has been revoked' });
return;
}
const workspace = mockBehavior['notionWorkspace'] || "Test User's Workspace";
json(res, 200, {
success: true,
data: { oauthUrl: 'http://127.0.0.1:18473/mock-notion-oauth', workspace },
});
return;
}
// GET /notion/permissions — get current Notion permission level
if (method === 'GET' && /^\/notion\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior['notionPermission'] || 'read';
json(res, 200, {
success: true,
data: {
level,
canRead: true,
canWrite: level === 'write' || level === 'admin',
canCreate: level === 'write' || level === 'admin',
},
});
return;
}
// GET /mock-notion-oauth — mock Notion OAuth page
if (method === 'GET' && /^\/mock-notion-oauth\/?(\?.*)?$/.test(url)) {
setCors(res);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(
'<html><body><h1>Mock Notion OAuth</h1><p>Authorize workspace access</p></body></html>'
);
return;
}
// GET /auth/google/connect — OAuth connect URL for Gmail/Email setup
if (method === 'GET' && /^\/auth\/google\/connect\/?(\?.*)?$/.test(url)) {
if (mockBehavior['gmailTokenRevoked'] === 'true') {
json(res, 401, { success: false, error: 'OAuth token has been revoked' });
return;
}
if (mockBehavior['gmailTokenExpired'] === 'true') {
json(res, 401, { success: false, error: 'OAuth token has expired' });
return;
}
json(res, 200, {
success: true,
data: { oauthUrl: 'http://127.0.0.1:18473/mock-google-oauth' },
});
return;
}
// GET /gmail/permissions — get current Gmail/Email permission level
if (method === 'GET' && /^\/gmail\/permissions\/?(\?.*)?$/.test(url)) {
const level = mockBehavior['gmailPermission'] || 'read';
json(res, 200, {
success: true,
data: {
level,
canRead: true,
canWrite: level === 'write' || level === 'admin',
canInitiate: level === 'admin',
},
});
return;
}
// POST /gmail/disconnect — disconnect Gmail/Email skill
if (method === 'POST' && /^\/gmail\/disconnect\/?$/.test(url)) {
json(res, 200, { success: true, data: { disconnected: true } });
return;
}
// GET /gmail/emails — fetch emails (mock list)
if (method === 'GET' && /^\/gmail\/emails\/?(\?.*)?$/.test(url)) {
json(res, 200, {
success: true,
data: [
{
id: 'msg-1',
subject: 'Welcome to AlphaHuman',
from: 'team@alphahuman.com',
date: new Date().toISOString(),
snippet: 'Welcome to the platform!',
hasAttachments: false,
},
{
id: 'msg-2',
subject: 'Weekly Crypto Report',
from: 'reports@crypto.com',
date: new Date(Date.now() - 86400000).toISOString(),
snippet: 'Your weekly summary is ready.',
hasAttachments: true,
},
],
});
return;
}
// GET /mock-google-oauth — mock Google OAuth page
if (method === 'GET' && /^\/mock-google-oauth\/?(\?.*)?$/.test(url)) {
setCors(res);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h1>Mock Google OAuth</h1><p>Authorize email access</p></body></html>');
return;
}
// Catch-all — prevents app crashes from unexpected API calls
json(res, 200, { success: true, data: {} });
}
+897
View File
@@ -0,0 +1,897 @@
/* eslint-disable */
// @ts-nocheck
/**
* E2E test: Card Payment Processing Flow.
*
* Covers:
* 5.1.1 Checkout session created on Stripe card upgrade (BASIC_MONTHLY)
* 5.1.2 Checkout session with annual billing interval (BASIC_YEARLY)
* 5.1.3 Coinbase crypto checkout creates charge
* 5.2.1 Successful payment detected via polling
* 5.2.2 Failed purchase API call handled gracefully
* 5.2.3 Duplicate purchase prevention during checkout
* 5.3.1 Plan transition from FREE to PRO (direct)
* 5.3.2 Manage Subscription opens Stripe portal
*
* 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 } from '../helpers/app-helpers';
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
import {
clickButton,
clickText,
dumpAccessibilityTree,
textExists,
waitForText,
waitForWebView,
waitForWindowVisible,
} from '../helpers/element-helpers';
import {
clearRequestLog,
getRequestLog,
resetMockBehavior,
setMockBehavior,
startMockServer,
stopMockServer,
} from '../mock-server';
// ---------------------------------------------------------------------------
// Shared helpers (mirrored from auth-access-control.spec.ts)
// ---------------------------------------------------------------------------
const LOG_PREFIX = '[PaymentFlow]';
/**
* Click a native XCUIElementTypeButton by its label/title attribute.
*/
async function clickNativeButton(text, timeout = 10_000) {
const selector =
`//XCUIElementTypeButton[contains(@label, "${text}") or ` + `contains(@title, "${text}")]`;
const el = await browser.$(selector);
await el.waitForExist({ timeout, timeoutMsg: `Button "${text}" not found within ${timeout}ms` });
const location = await el.getLocation();
const size = await el.getSize();
const centerX = Math.round(location.x + size.width / 2);
const centerY = Math.round(location.y + size.height / 2);
await browser.performActions([
{
type: 'pointer',
id: 'mouse1',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 10, x: centerX, y: centerY },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
]);
await browser.releaseActions();
}
/**
* 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;
}
/**
* Wait until one of the candidate texts appears on screen (Home page markers).
*/
async function waitForHomePage(timeout = 15_000) {
const candidates = [
'Test',
'Good morning',
'Good afternoon',
'Good evening',
'Message AlphaHuman',
'Upgrade to Premium',
];
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
for (const text of candidates) {
if (await textExists(text)) return text;
}
await browser.pause(1_000);
}
return null;
}
/**
* Click the first matching text from a list of candidates, with retry.
*/
async function clickFirstCandidate(candidates, label, timeout = 10_000) {
for (const text of candidates) {
if (await textExists(text)) {
await clickText(text, timeout);
console.log(`${LOG_PREFIX} ${label}: clicked "${text}"`);
const advanced = await waitForTextToDisappear(text, 8_000);
if (advanced) return text;
console.log(`${LOG_PREFIX} ${label}: "${text}" still visible, retrying click...`);
await clickText(text, 5_000);
const retryAdvanced = await waitForTextToDisappear(text, 5_000);
if (retryAdvanced) return text;
const tree = await dumpAccessibilityTree();
console.log(
`${LOG_PREFIX} ${label}: "${text}" still visible after retry. Tree:\n`,
tree.slice(0, 4000)
);
return null;
}
}
const tree = await dumpAccessibilityTree();
console.log(`${LOG_PREFIX} ${label}: no candidates found. Tree:\n`, tree.slice(0, 4000));
return null;
}
/**
* Navigate to the Billing panel: Settings -> Billing & Usage.
*/
async function navigateToBilling() {
await clickNativeButton('Settings', 10_000);
console.log(`${LOG_PREFIX} Clicked Settings nav`);
await browser.pause(2_000);
const billingCandidates = ['Billing & Usage', 'Billing'];
let clicked = false;
for (const text of billingCandidates) {
if (await textExists(text)) {
await clickText(text, 10_000);
console.log(`${LOG_PREFIX} Clicked "${text}" menu item`);
clicked = true;
break;
}
}
if (!clicked) {
const tree = await dumpAccessibilityTree();
console.log(`${LOG_PREFIX} Billing menu item not found. Tree:\n`, tree.slice(0, 6000));
throw new Error('Billing menu item not found in Settings');
}
await browser.pause(2_000);
}
/**
* Navigate back to Home via the sidebar Home button.
*/
async function navigateToHome() {
await clickNativeButton('Home', 10_000);
console.log(`${LOG_PREFIX} Clicked Home nav`);
await browser.pause(2_000);
const homeText = await waitForHomePage(10_000);
if (!homeText) {
const tree = await dumpAccessibilityTree();
console.log(
`${LOG_PREFIX} navigateToHome: Home page not reached. Tree:\n`,
tree.slice(0, 4000)
);
throw new Error('navigateToHome: Home page not reached after clicking Home nav');
}
}
/**
* Perform the full login + onboarding flow via deep link.
* Leaves the app on the Home page.
*/
async function performFullLogin(token = 'e2e-test-token') {
await triggerAuthDeepLink(token);
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await waitForAppReady(15_000);
// Onboarding Step 1: InviteCodeStep — skip
await clickText('Skip for now', 10_000);
console.log(`${LOG_PREFIX} Clicked "Skip for now"`);
const stepChanged = await waitForTextToDisappear('Skip for now', 8_000);
if (!stepChanged) {
console.log(`${LOG_PREFIX} Step did not advance, retrying...`);
await clickText('Skip', 5_000);
await waitForTextToDisappear('Skip', 5_000);
}
await browser.pause(2_000);
// Onboarding Step 2: FeaturesStep
const featResult = await clickFirstCandidate(['Looks Amazing', 'Bring It On'], 'FeaturesStep');
if (!featResult) throw new Error('FeaturesStep button not found');
await browser.pause(2_000);
// Onboarding Step 3: PrivacyStep
const privResult = await clickFirstCandidate(['Got it', 'Continue'], 'PrivacyStep');
if (!privResult) throw new Error('PrivacyStep button not found');
await browser.pause(2_000);
// Onboarding Step 4: GetStartedStep
const startResult = await clickFirstCandidate(["Let's Go", "I'm Ready"], 'GetStartedStep');
if (!startResult) throw new Error('GetStartedStep button not found');
await browser.pause(3_000);
const homeText = await waitForHomePage(15_000);
if (!homeText) {
const tree = await dumpAccessibilityTree();
console.log(
`${LOG_PREFIX} Home page not reached after onboarding. Tree:\n`,
tree.slice(0, 4000)
);
throw new Error('Full login + onboarding did not reach Home page');
}
console.log(`${LOG_PREFIX} Home page confirmed: found "${homeText}"`);
}
/**
* Counter for unique JWT suffixes — ensures each re-auth changes the token
* so UserProvider's useEffect fires and re-fetches user + teams.
*/
let reAuthCounter = 0;
/**
* Re-authenticate via deep link (resets purchasing state) and navigate to billing.
* Assumes mock behavior is already configured for the desired plan.
*
* IMPORTANT: Each call sets a unique `mockBehavior['jwt']` suffix so the
* returned JWT differs from the previous one. Without this, the Redux
* token wouldn't change and UserProvider wouldn't re-fetch user + teams,
* leaving stale team subscription data in the store.
*/
async function reAuthAndGoToBilling(token = 'e2e-payment-token') {
clearRequestLog();
// Unique JWT so token changes → UserProvider re-fetches user & teams
reAuthCounter += 1;
setMockBehavior('jwt', `reauth-${reAuthCounter}`);
await triggerAuthDeepLink(token);
await browser.pause(5_000);
// Always click Home nav first to ensure we're on the actual Home page.
// The deep link may not change the route if the app is already authenticated
// and onboarded — "Test" (user name) can appear in Settings headers, making
// waitForHomePage falsely succeed while still on Settings/Billing.
try {
await clickNativeButton('Home', 5_000);
await browser.pause(2_000);
} catch {
// Home button might not be visible yet — that's fine, we'll check below
}
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('reAuthAndGoToBilling: Home page not reached');
}
console.log(`${LOG_PREFIX} Re-authed (jwt suffix reauth-${reAuthCounter}), on Home`);
await navigateToBilling();
}
/**
* Check if the "Waiting for payment confirmation" banner or "Waiting..." button
* text is visible.
*/
async function isWaitingVisible() {
return (await textExists('Waiting for payment confirmation')) || (await textExists('Waiting...'));
}
// ===========================================================================
// Test suite
// ===========================================================================
describe('Card Payment Processing Flow', () => {
before(async () => {
await startMockServer();
await waitForApp();
clearRequestLog();
// Full login + onboarding — lands on Home
await performFullLogin('e2e-payment-token');
// Navigate to Billing for the first test
await navigateToBilling();
});
after(async function () {
this.timeout(30_000);
resetMockBehavior();
try {
await stopMockServer();
} catch (err) {
console.log(`${LOG_PREFIX} stopMockServer error (non-fatal):`, err);
}
});
// -------------------------------------------------------------------------
// 5.1 Checkout & Invoice
// -------------------------------------------------------------------------
describe('5.1 Checkout & Invoice', () => {
it('5.1.1 — checkout session is created on Stripe card upgrade', async () => {
// Verify we're on the billing page with FREE plan
const hasPlanText = await textExists('Your Current Plan');
if (!hasPlanText) {
const tree = await dumpAccessibilityTree();
console.log(`${LOG_PREFIX} Billing page tree:\n`, tree.slice(0, 6000));
}
expect(hasPlanText).toBe(true);
const hasFree = await textExists('FREE');
expect(hasFree).toBe(true);
// Ensure billing interval is "Monthly" (default)
const hasMonthly = await textExists('Monthly');
expect(hasMonthly).toBe(true);
clearRequestLog();
// Click the first "Upgrade" button (BASIC tier, appears before PRO)
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 5.1.1: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify POST /payments/stripe/purchasePlan was called
const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000);
if (!purchaseCall) {
console.log(
`${LOG_PREFIX} 5.1.1: Purchase request log:`,
JSON.stringify(getRequestLog(), null, 2)
);
}
expect(purchaseCall).toBeDefined();
// Verify request body contains BASIC_MONTHLY planId
if (purchaseCall?.body) {
const bodyStr = typeof purchaseCall.body === 'string' ? purchaseCall.body : '';
console.log(`${LOG_PREFIX} 5.1.1: Purchase request body:`, bodyStr);
expect(bodyStr).toContain('BASIC');
expect(bodyStr).toContain('MONTHLY');
}
// Verify the mock response contained a sessionId starting with cs_mock_
// (We can't inspect the response directly from here, but we can verify the
// mock was hit and returned 200 — the mock always returns cs_mock_<timestamp>)
// Verify "Waiting for payment confirmation" banner appears
const hasWaiting = await isWaitingVisible();
console.log(`${LOG_PREFIX} 5.1.1: Waiting banner visible: ${hasWaiting}`);
expect(hasWaiting).toBe(true);
// Verify Upgrade buttons become disabled (text changes to "Waiting...")
const hasWaitingButton = await textExists('Waiting...');
console.log(`${LOG_PREFIX} 5.1.1: Waiting... button text visible: ${hasWaitingButton}`);
// Switch mock to BASIC so polling resolves and clears the state
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString());
// Wait for polling to detect change and clear waiting state
const waitingGone = await waitForTextToDisappear('Waiting', 20_000);
expect(waitingGone).toBe(true);
console.log(`${LOG_PREFIX} 5.1.1: Waiting state cleared`);
console.log(`${LOG_PREFIX} 5.1.1 PASSED`);
});
it('5.1.2 — checkout session with annual billing interval', async () => {
// Reset to FREE plan and re-auth to clear purchasing state
resetMockBehavior();
await reAuthAndGoToBilling('e2e-annual-token');
// Click "Annual" billing interval toggle
await clickText('Annual', 10_000);
console.log(`${LOG_PREFIX} 5.1.2: Clicked Annual toggle`);
await browser.pause(1_000);
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 5.1.2: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify POST /payments/stripe/purchasePlan was called
const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000);
if (!purchaseCall) {
console.log(
`${LOG_PREFIX} 5.1.2: Purchase request log:`,
JSON.stringify(getRequestLog(), null, 2)
);
}
expect(purchaseCall).toBeDefined();
// Verify request body contains BASIC_YEARLY planId
if (purchaseCall?.body) {
const bodyStr = typeof purchaseCall.body === 'string' ? purchaseCall.body : '';
console.log(`${LOG_PREFIX} 5.1.2: Purchase request body:`, bodyStr);
expect(bodyStr).toContain('BASIC');
expect(bodyStr).toContain('YEARLY');
}
// Verify "Waiting" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
// Resolve the polling so state clears
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
const waitingGone512 = await waitForTextToDisappear('Waiting', 20_000);
expect(waitingGone512).toBe(true);
console.log(`${LOG_PREFIX} 5.1.2 PASSED`);
});
it('5.1.3 — Coinbase crypto checkout creates charge', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-token');
// Toggle "Pay with Crypto" switch ON.
// The label <p> and toggle <button role="switch"> are separate elements.
// Clicking the label text does NOT toggle the switch — we must click
// the actual switch button. Find it via XCUIElementTypeSwitch or
// XCUIElementTypeCheckBox, or by targeting the role="switch" element.
let toggled = false;
// Strategy 1: Try clicking a native switch element
const switchSelectors = [
'//XCUIElementTypeSwitch',
'//XCUIElementTypeCheckBox',
`//*[@role="switch"]`,
];
for (const sel of switchSelectors) {
try {
const switchEl = await browser.$(sel);
if (await switchEl.isExisting()) {
const loc = await switchEl.getLocation();
const sz = await switchEl.getSize();
const cx = Math.round(loc.x + sz.width / 2);
const cy = Math.round(loc.y + sz.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();
console.log(`${LOG_PREFIX} 5.1.3: Toggled crypto via ${sel}`);
toggled = true;
break;
}
} catch {
// Try next selector
}
}
// Strategy 2: If no switch found, find "Pay with Crypto" text and click
// the toggle at the far right of the row. The toggle <button> is at
// the right edge of the max-w-md container via justify-between layout.
if (!toggled) {
const labelEl = await waitForText('Pay with Crypto', 10_000);
const loc = await labelEl.getLocation();
const sz = await labelEl.getSize();
// Use the WebView bounds to find the right edge of the content area.
// The toggle (w-10 = 40px) is right-aligned with some padding.
const webView = await browser.$('//XCUIElementTypeWebView');
const wvLoc = await webView.getLocation();
const wvSz = await webView.getSize();
const toggleX = Math.round(wvLoc.x + wvSz.width - 60);
const toggleY = Math.round(loc.y + sz.height / 2);
console.log(
`${LOG_PREFIX} 5.1.3: Positional click at (${toggleX}, ${toggleY}), ` +
`label at (${loc.x}, ${loc.y}), webview right edge: ${wvLoc.x + wvSz.width}`
);
await browser.performActions([
{
type: 'pointer',
id: 'mouse1',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 10, x: toggleX, y: toggleY },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
]);
await browser.releaseActions();
console.log(`${LOG_PREFIX} 5.1.3: Toggled crypto via positional click`);
toggled = true;
}
await browser.pause(1_000);
// Verify billing interval switched to "Annual" (forced by crypto toggle)
// The Monthly button should be disabled when crypto is selected
const hasAnnual = await textExists('Annual');
expect(hasAnnual).toBe(true);
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 5.1.3: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify POST /payments/coinbase/charge was called (NOT Stripe)
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
if (!coinbaseCall) {
console.log(
`${LOG_PREFIX} 5.1.3: Coinbase request log:`,
JSON.stringify(getRequestLog(), null, 2)
);
}
expect(coinbaseCall).toBeDefined();
// Verify NO Stripe purchasePlan call was made
const stripeCall = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/payments/stripe/purchasePlan')
);
expect(stripeCall).toBeUndefined();
// Verify "Waiting for payment confirmation" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
// Resolve polling so state clears
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
const waitingGone513 = await waitForTextToDisappear('Waiting', 20_000);
expect(waitingGone513).toBe(true);
console.log(`${LOG_PREFIX} 5.1.3 PASSED`);
});
});
// -------------------------------------------------------------------------
// 5.2 Payment Confirmation Handling
// -------------------------------------------------------------------------
describe('5.2 Payment Confirmation Handling', () => {
it('5.2.1 — successful payment detected via polling', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-poll-token');
clearRequestLog();
// Initiate BASIC upgrade (card)
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 5.2.1: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify "Waiting" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
console.log(`${LOG_PREFIX} 5.2.1: Waiting banner visible`);
// Switch mock: plan changed to BASIC with active subscription
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString());
// Wait for polling to detect the change (polls every 5s, give 15s)
const waitingGone = await waitForTextToDisappear('Waiting', 15_000);
expect(waitingGone).toBe(true);
console.log(`${LOG_PREFIX} 5.2.1: Waiting banner disappeared after polling`);
// Re-auth to verify the plan state persists — should show BASIC as "Current"
await reAuthAndGoToBilling('e2e-poll-verify-token');
const hasBasicCurrent = (await textExists('BASIC')) || (await textExists('Basic'));
expect(hasBasicCurrent).toBe(true);
const hasCurrent = await textExists('Current');
expect(hasCurrent).toBe(true);
console.log(`${LOG_PREFIX} 5.2.1 PASSED`);
});
it('5.2.2 — failed purchase API call handled gracefully', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-fail-token');
// Set purchaseError to make the Stripe API return 500
setMockBehavior('purchaseError', 'true');
try {
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 5.2.2: Clicked Upgrade (with error mock)`);
await browser.pause(3_000);
// Verify the purchase API was called
const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000);
expect(purchaseCall).toBeDefined();
// Verify "Waiting" banner does NOT appear (purchase failed immediately)
const hasWaiting = await isWaitingVisible();
console.log(`${LOG_PREFIX} 5.2.2: Waiting banner visible (should be false): ${hasWaiting}`);
expect(hasWaiting).toBe(false);
// Verify Upgrade buttons remain clickable (isPurchasing reset to false)
const hasUpgrade = await textExists('Upgrade');
expect(hasUpgrade).toBe(true);
console.log(`${LOG_PREFIX} 5.2.2: Upgrade button still clickable`);
console.log(`${LOG_PREFIX} 5.2.2 PASSED`);
} finally {
setMockBehavior('purchaseError', 'false');
}
});
it('5.2.3 — duplicate purchase prevention during checkout', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-dup-purchase-token');
clearRequestLog();
// Click "Upgrade" on BASIC tier -> "Waiting" banner appears
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 5.2.3: Clicked Upgrade on BASIC`);
await browser.pause(3_000);
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
console.log(`${LOG_PREFIX} 5.2.3: Waiting banner visible`);
// Verify ALL Upgrade buttons are disabled — both BASIC shows "Waiting..."
// and PRO should be disabled too
const hasWaitingButton = await textExists('Waiting...');
console.log(`${LOG_PREFIX} 5.2.3: Waiting... button visible: ${hasWaitingButton}`);
// Count upgrade-related elements — there should be no active "Upgrade" buttons
// During purchasing, buttons show "Waiting..." or are disabled
clearRequestLog();
// Attempt to click on any remaining "Upgrade" text (PRO tier)
// This should either not exist or not trigger a new API call
const upgradeSelector = `//*[contains(@label, "Upgrade") or contains(@value, "Upgrade") or contains(@title, "Upgrade")]`;
const upgradeElements = await browser.$$(upgradeSelector);
console.log(
`${LOG_PREFIX} 5.2.3: Found ${upgradeElements.length} "Upgrade" element(s) during purchasing`
);
// If any Upgrade elements exist, try clicking them
if (upgradeElements.length > 0) {
try {
const el = upgradeElements[0];
const location = await el.getLocation();
const size = await el.getSize();
const centerX = Math.round(location.x + size.width / 2);
const centerY = Math.round(location.y + size.height / 2);
await browser.performActions([
{
type: 'pointer',
id: 'mouse1',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 10, x: centerX, y: centerY },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
]);
await browser.releaseActions();
await browser.pause(2_000);
} catch {
console.log(`${LOG_PREFIX} 5.2.3: Could not click Upgrade element (expected — disabled)`);
}
}
// Verify NO additional purchase API calls were made
const additionalCalls = getRequestLog().filter(
r => r.method === 'POST' && r.url.includes('/payments/stripe/purchasePlan')
);
console.log(
`${LOG_PREFIX} 5.2.3: Additional purchase calls during lock: ${additionalCalls.length}`
);
expect(additionalCalls.length).toBe(0);
// Resolve the polling so state clears for next tests
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
const waitingGone523 = await waitForTextToDisappear('Waiting', 20_000);
expect(waitingGone523).toBe(true);
console.log(`${LOG_PREFIX} 5.2.3 PASSED`);
});
});
// -------------------------------------------------------------------------
// 5.3 Billing Events
// -------------------------------------------------------------------------
describe('5.3 Billing Events', () => {
it('5.3.1 — plan transition from FREE to PRO (direct)', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-pro-token');
clearRequestLog();
// We need to click the PRO "Upgrade" button, not the BASIC one.
// Both tiers show "Upgrade" — PRO appears second. Use $$ to find all
// and click the last one.
const upgradeSelector = `//*[contains(@label, "Upgrade") or contains(@value, "Upgrade") or contains(@title, "Upgrade")]`;
const upgradeElements = await browser.$$(upgradeSelector);
console.log(`${LOG_PREFIX} 5.3.1: Found ${upgradeElements.length} Upgrade element(s)`);
if (upgradeElements.length >= 2) {
// Click the second (PRO) Upgrade button
const proEl = upgradeElements[upgradeElements.length - 1];
const location = await proEl.getLocation();
const size = await proEl.getSize();
const centerX = Math.round(location.x + size.width / 2);
const centerY = Math.round(location.y + size.height / 2);
await browser.performActions([
{
type: 'pointer',
id: 'mouse1',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 10, x: centerX, y: centerY },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
]);
await browser.releaseActions();
console.log(`${LOG_PREFIX} 5.3.1: Clicked PRO Upgrade button`);
} else if (upgradeElements.length === 1) {
// Only one Upgrade button — click it (might be PRO if BASIC is current)
const el = upgradeElements[0];
const location = await el.getLocation();
const size = await el.getSize();
const centerX = Math.round(location.x + size.width / 2);
const centerY = Math.round(location.y + size.height / 2);
await browser.performActions([
{
type: 'pointer',
id: 'mouse1',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 10, x: centerX, y: centerY },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
]);
await browser.releaseActions();
console.log(`${LOG_PREFIX} 5.3.1: Clicked single Upgrade button`);
} else {
throw new Error('No Upgrade buttons found on billing page');
}
await browser.pause(3_000);
// Verify POST /payments/stripe/purchasePlan with PRO in body
const purchaseCall = await waitForRequest('POST', '/payments/stripe/purchasePlan', 10_000);
if (!purchaseCall) {
console.log(
`${LOG_PREFIX} 5.3.1: Purchase request log:`,
JSON.stringify(getRequestLog(), null, 2)
);
}
expect(purchaseCall).toBeDefined();
if (purchaseCall?.body) {
const bodyStr = typeof purchaseCall.body === 'string' ? purchaseCall.body : '';
console.log(`${LOG_PREFIX} 5.3.1: Purchase request body:`, bodyStr);
expect(bodyStr).toContain('PRO');
expect(bodyStr).toContain('MONTHLY');
}
// Switch mock to PRO plan active
setMockBehavior('plan', 'PRO');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString());
// Wait for polling to detect and clear waiting state
const waitingGone531 = await waitForTextToDisappear('Waiting', 20_000);
expect(waitingGone531).toBe(true);
// Re-auth to verify PRO is "Current"
await reAuthAndGoToBilling('e2e-pro-verify-token');
const hasPro = (await textExists('PRO')) || (await textExists('Pro'));
expect(hasPro).toBe(true);
const hasCurrent = await textExists('Current');
expect(hasCurrent).toBe(true);
console.log(`${LOG_PREFIX} 5.3.1 PASSED`);
});
it('5.3.2 — Manage Subscription opens Stripe portal', async () => {
// Ensure mock has an active subscription so "Manage Subscription" renders.
// We re-auth fresh to guarantee the team state reflects the mock.
setMockBehavior('plan', 'PRO');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 30 * 86400000).toISOString());
await reAuthAndGoToBilling('e2e-manage-sub-token');
// Wait for "Manage Subscription" to appear (team state needs to populate)
let hasManage = false;
const deadline = Date.now() + 15_000;
while (Date.now() < deadline) {
hasManage = await textExists('Manage Subscription');
if (hasManage) break;
await browser.pause(1_000);
}
if (!hasManage) {
const tree = await dumpAccessibilityTree();
console.log(
`${LOG_PREFIX} 5.3.2: Manage Subscription not found. Tree:\n`,
tree.slice(0, 6000)
);
}
expect(hasManage).toBe(true);
clearRequestLog();
// Click "Manage Subscription"
await clickText('Manage Subscription', 10_000);
console.log(`${LOG_PREFIX} 5.3.2: Clicked Manage Subscription`);
await browser.pause(3_000);
// Verify POST /payments/stripe/portal was called
const portalCall = await waitForRequest('POST', '/payments/stripe/portal', 10_000);
if (!portalCall) {
console.log(
`${LOG_PREFIX} 5.3.2: Portal request log:`,
JSON.stringify(getRequestLog(), null, 2)
);
}
expect(portalCall).toBeDefined();
console.log(`${LOG_PREFIX} 5.3.2 PASSED`);
});
});
});
+917
View File
@@ -0,0 +1,917 @@
/* eslint-disable */
// @ts-nocheck
/**
* E2E test: Cryptocurrency Payment Processing Flow.
*
* Covers:
* 6.1.1 Coinbase charge created with correct plan and interval (BASIC annual)
* 6.1.2 Coinbase charge created for PRO tier crypto payment
* 6.1.3 Crypto toggle forces annual billing interval
* 6.2.1 Successful crypto payment confirmation via polling
* 6.2.2 Underpayment — plan does NOT activate, waiting state persists
* 6.2.3 Overpayment — plan activates normally
* 6.3.1 Payment status update: polling detects plan change after confirmation
* 6.3.2 Coinbase API error handled gracefully
* 6.3.3 Expired charge — waiting state clears after poll timeout
*
* 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 } from '../helpers/app-helpers';
import { triggerAuthDeepLink } from '../helpers/deep-link-helpers';
import {
clickButton,
clickText,
dumpAccessibilityTree,
textExists,
waitForText,
waitForWebView,
waitForWindowVisible,
} from '../helpers/element-helpers';
import {
clearRequestLog,
getRequestLog,
resetMockBehavior,
setMockBehavior,
startMockServer,
stopMockServer,
} from '../mock-server';
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
const LOG_PREFIX = '[CryptoPayment]';
/**
* Click a native XCUIElementTypeButton by its label/title attribute.
*/
async function clickNativeButton(text, timeout = 10_000) {
const selector =
`//XCUIElementTypeButton[contains(@label, "${text}") or ` + `contains(@title, "${text}")]`;
const el = await browser.$(selector);
await el.waitForExist({ timeout, timeoutMsg: `Button "${text}" not found within ${timeout}ms` });
const location = await el.getLocation();
const size = await el.getSize();
const centerX = Math.round(location.x + size.width / 2);
const centerY = Math.round(location.y + size.height / 2);
await browser.performActions([
{
type: 'pointer',
id: 'mouse1',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 10, x: centerX, y: centerY },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
]);
await browser.releaseActions();
}
/**
* 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;
}
/**
* Wait until one of the candidate texts appears on screen (Home page markers).
*/
async function waitForHomePage(timeout = 15_000) {
const candidates = [
'Test',
'Good morning',
'Good afternoon',
'Good evening',
'Message AlphaHuman',
'Upgrade to Premium',
];
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
for (const text of candidates) {
if (await textExists(text)) return text;
}
await browser.pause(1_000);
}
return null;
}
/**
* Click the first matching text from a list of candidates, with retry.
*/
async function clickFirstCandidate(candidates, label, timeout = 10_000) {
for (const text of candidates) {
if (await textExists(text)) {
await clickText(text, timeout);
console.log(`${LOG_PREFIX} ${label}: clicked "${text}"`);
const advanced = await waitForTextToDisappear(text, 8_000);
if (advanced) return text;
console.log(`${LOG_PREFIX} ${label}: "${text}" still visible, retrying click...`);
await clickText(text, 5_000);
const retryAdvanced = await waitForTextToDisappear(text, 5_000);
if (retryAdvanced) return text;
const tree = await dumpAccessibilityTree();
console.log(
`${LOG_PREFIX} ${label}: "${text}" still visible after retry. Tree:\n`,
tree.slice(0, 4000)
);
return null;
}
}
const tree = await dumpAccessibilityTree();
console.log(`${LOG_PREFIX} ${label}: no candidates found. Tree:\n`, tree.slice(0, 4000));
return null;
}
/**
* Navigate to the Billing panel: Settings -> Billing & Usage.
*
* Retries clicking the Settings nav if the billing menu item isn't found
* on the first attempt — after many re-auth cycles the modal can be slow
* to appear.
*/
async function navigateToBilling() {
const billingCandidates = ['Billing', 'Billing & Usage'];
const maxAttempts = 3;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
await clickNativeButton('Settings', 10_000);
console.log(`${LOG_PREFIX} Clicked Settings nav (attempt ${attempt})`);
await browser.pause(3_000);
let clicked = false;
for (const text of billingCandidates) {
if (await textExists(text)) {
await clickText(text, 10_000);
console.log(`${LOG_PREFIX} Clicked "${text}" menu item`);
clicked = true;
break;
}
}
if (clicked) {
await browser.pause(2_000);
return;
}
console.log(`${LOG_PREFIX} Billing menu not found on attempt ${attempt}, retrying...`);
await browser.pause(2_000);
}
const tree = await dumpAccessibilityTree();
console.log(
`${LOG_PREFIX} Billing menu item not found after ${maxAttempts} attempts. Tree:\n`,
tree.slice(0, 6000)
);
throw new Error('Billing menu item not found in Settings');
}
/**
* Navigate back to Home via the sidebar Home button.
*/
async function navigateToHome() {
await clickNativeButton('Home', 10_000);
console.log(`${LOG_PREFIX} Clicked Home nav`);
await browser.pause(2_000);
const homeText = await waitForHomePage(10_000);
if (!homeText) {
const tree = await dumpAccessibilityTree();
console.log(
`${LOG_PREFIX} navigateToHome: Home page not reached. Tree:\n`,
tree.slice(0, 4000)
);
throw new Error('navigateToHome: Home page not reached after clicking Home nav');
}
}
/**
* Perform the full login + onboarding flow via deep link.
*/
async function performFullLogin(token = 'e2e-test-token') {
await triggerAuthDeepLink(token);
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await waitForAppReady(15_000);
// Onboarding Step 1: InviteCodeStep — skip
await clickText('Skip for now', 10_000);
console.log(`${LOG_PREFIX} Clicked "Skip for now"`);
const stepChanged = await waitForTextToDisappear('Skip for now', 8_000);
if (!stepChanged) {
console.log(`${LOG_PREFIX} Step did not advance, retrying...`);
await clickText('Skip', 5_000);
await waitForTextToDisappear('Skip', 5_000);
}
await browser.pause(2_000);
// Onboarding Step 2: FeaturesStep
const featResult = await clickFirstCandidate(['Looks Amazing', 'Bring It On'], 'FeaturesStep');
if (!featResult) throw new Error('FeaturesStep button not found');
await browser.pause(2_000);
// Onboarding Step 3: PrivacyStep
const privResult = await clickFirstCandidate(['Got it', 'Continue'], 'PrivacyStep');
if (!privResult) throw new Error('PrivacyStep button not found');
await browser.pause(2_000);
// Onboarding Step 4: GetStartedStep
const startResult = await clickFirstCandidate(["Let's Go", "I'm Ready"], 'GetStartedStep');
if (!startResult) throw new Error('GetStartedStep button not found');
await browser.pause(3_000);
const homeText = await waitForHomePage(15_000);
if (!homeText) {
const tree = await dumpAccessibilityTree();
console.log(
`${LOG_PREFIX} Home page not reached after onboarding. Tree:\n`,
tree.slice(0, 4000)
);
throw new Error('Full login + onboarding did not reach Home page');
}
console.log(`${LOG_PREFIX} Home page confirmed: found "${homeText}"`);
}
/**
* Counter for unique JWT suffixes.
*/
let reAuthCounter = 0;
/**
* Re-authenticate via deep link and navigate to billing.
*/
async function reAuthAndGoToBilling(token = 'e2e-crypto-payment-token') {
clearRequestLog();
reAuthCounter += 1;
setMockBehavior('jwt', `crypto-reauth-${reAuthCounter}`);
await triggerAuthDeepLink(token);
await browser.pause(5_000);
try {
await clickNativeButton('Home', 5_000);
await browser.pause(2_000);
} catch {
// Home button might not be visible yet
}
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('reAuthAndGoToBilling: Home page not reached');
}
console.log(`${LOG_PREFIX} Re-authed (jwt suffix crypto-reauth-${reAuthCounter}), on Home`);
await navigateToBilling();
}
/**
* Toggle the "Pay with Crypto" switch ON.
* Uses multiple strategies because the <button role="switch"> may map to
* different accessibility element types in WKWebView.
*/
async function enableCryptoToggle() {
let toggled = false;
// Strategy 1: Click a native switch element
const switchSelectors = [
'//XCUIElementTypeSwitch',
'//XCUIElementTypeCheckBox',
`//*[@role="switch"]`,
];
for (const sel of switchSelectors) {
try {
const switchEl = await browser.$(sel);
if (await switchEl.isExisting()) {
const loc = await switchEl.getLocation();
const sz = await switchEl.getSize();
const cx = Math.round(loc.x + sz.width / 2);
const cy = Math.round(loc.y + sz.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();
console.log(`${LOG_PREFIX} Toggled crypto via ${sel}`);
toggled = true;
break;
}
} catch {
// Try next selector
}
}
// Strategy 2: Positional click at the far right of the "Pay with Crypto" row
if (!toggled) {
const labelEl = await waitForText('Pay with Crypto', 10_000);
const loc = await labelEl.getLocation();
const sz = await labelEl.getSize();
const webView = await browser.$('//XCUIElementTypeWebView');
const wvLoc = await webView.getLocation();
const wvSz = await webView.getSize();
const toggleX = Math.round(wvLoc.x + wvSz.width - 60);
const toggleY = Math.round(loc.y + sz.height / 2);
console.log(
`${LOG_PREFIX} Positional click at (${toggleX}, ${toggleY}), ` +
`label at (${loc.x}, ${loc.y}), webview right edge: ${wvLoc.x + wvSz.width}`
);
await browser.performActions([
{
type: 'pointer',
id: 'mouse1',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 10, x: toggleX, y: toggleY },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
]);
await browser.releaseActions();
console.log(`${LOG_PREFIX} Toggled crypto via positional click`);
toggled = true;
}
await browser.pause(1_000);
return toggled;
}
/**
* Check if the "Waiting for payment confirmation" banner or "Waiting..." button
* text is visible.
*/
async function isWaitingVisible() {
return (await textExists('Waiting for payment confirmation')) || (await textExists('Waiting...'));
}
/**
* Click the Nth "Upgrade" button on the billing page (0-indexed).
* BASIC is index 0, PRO is index 1 when on FREE plan.
*/
async function clickUpgradeButton(index = 0) {
const upgradeSelector = `//*[contains(@label, "Upgrade") or contains(@value, "Upgrade") or contains(@title, "Upgrade")]`;
const upgradeElements = await browser.$$(upgradeSelector);
console.log(`${LOG_PREFIX} Found ${upgradeElements.length} Upgrade element(s)`);
if (upgradeElements.length <= index) {
throw new Error(
`Expected at least ${index + 1} Upgrade button(s), found ${upgradeElements.length}`
);
}
const el = upgradeElements[index];
const location = await el.getLocation();
const size = await el.getSize();
const centerX = Math.round(location.x + size.width / 2);
const centerY = Math.round(location.y + size.height / 2);
await browser.performActions([
{
type: 'pointer',
id: 'mouse1',
parameters: { pointerType: 'mouse' },
actions: [
{ type: 'pointerMove', duration: 10, x: centerX, y: centerY },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
]);
await browser.releaseActions();
}
// ===========================================================================
// Test suite
// ===========================================================================
describe('Cryptocurrency Payment Processing Flow', () => {
before(async () => {
await startMockServer();
await waitForApp();
clearRequestLog();
// Full login + onboarding — lands on Home
await performFullLogin('e2e-crypto-flow-token');
// Navigate to Billing for the first test
await navigateToBilling();
});
after(async function () {
this.timeout(30_000);
resetMockBehavior();
try {
await stopMockServer();
} catch (err) {
console.log(`${LOG_PREFIX} stopMockServer error (non-fatal):`, err);
}
});
// -------------------------------------------------------------------------
// 6.1 Invoice Creation
// -------------------------------------------------------------------------
describe('6.1 Invoice Creation', () => {
it('6.1.1 — Coinbase charge created with correct plan and interval (BASIC annual)', async () => {
// Verify we're on the billing page with FREE plan
const hasPlanText = await textExists('Your Current Plan');
if (!hasPlanText) {
const tree = await dumpAccessibilityTree();
console.log(`${LOG_PREFIX} Billing page tree:\n`, tree.slice(0, 6000));
}
expect(hasPlanText).toBe(true);
const hasFree = await textExists('FREE');
expect(hasFree).toBe(true);
// Toggle "Pay with Crypto" switch ON
await enableCryptoToggle();
// Verify billing interval is forced to "Annual"
const hasAnnual = await textExists('Annual');
expect(hasAnnual).toBe(true);
clearRequestLog();
// Click "Upgrade" on BASIC tier (first Upgrade button)
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 6.1.1: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify POST /payments/coinbase/charge was called
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
if (!coinbaseCall) {
console.log(`${LOG_PREFIX} 6.1.1: Request log:`, JSON.stringify(getRequestLog(), null, 2));
}
expect(coinbaseCall).toBeDefined();
// Verify request body contains plan and interval
if (coinbaseCall?.body) {
const bodyStr = typeof coinbaseCall.body === 'string' ? coinbaseCall.body : '';
console.log(`${LOG_PREFIX} 6.1.1: Coinbase request body:`, bodyStr);
expect(bodyStr).toContain('BASIC');
expect(bodyStr).toContain('annual');
}
// Verify NO Stripe purchasePlan call was made
const stripeCall = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/payments/stripe/purchasePlan')
);
expect(stripeCall).toBeUndefined();
// Verify "Waiting for payment confirmation" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
console.log(`${LOG_PREFIX} 6.1.1: Waiting banner visible`);
// Resolve polling so state clears for next test
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
const waitingGone = await waitForTextToDisappear('Waiting', 20_000);
expect(waitingGone).toBe(true);
console.log(`${LOG_PREFIX} 6.1.1 PASSED`);
});
it('6.1.2 — Coinbase charge created for PRO tier crypto payment', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-pro-token');
// Toggle "Pay with Crypto" switch ON
await enableCryptoToggle();
clearRequestLog();
// Click the PRO "Upgrade" button (second button, index 1)
await clickUpgradeButton(1);
console.log(`${LOG_PREFIX} 6.1.2: Clicked PRO Upgrade button`);
await browser.pause(3_000);
// Verify POST /payments/coinbase/charge was called
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
if (!coinbaseCall) {
console.log(`${LOG_PREFIX} 6.1.2: Request log:`, JSON.stringify(getRequestLog(), null, 2));
}
expect(coinbaseCall).toBeDefined();
// Verify request body contains PRO plan
if (coinbaseCall?.body) {
const bodyStr = typeof coinbaseCall.body === 'string' ? coinbaseCall.body : '';
console.log(`${LOG_PREFIX} 6.1.2: Coinbase request body:`, bodyStr);
expect(bodyStr).toContain('PRO');
expect(bodyStr).toContain('annual');
}
// Verify "Waiting" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
// Resolve polling
setMockBehavior('plan', 'PRO');
setMockBehavior('planActive', 'true');
const waitingGone = await waitForTextToDisappear('Waiting', 20_000);
expect(waitingGone).toBe(true);
console.log(`${LOG_PREFIX} 6.1.2 PASSED`);
});
it('6.1.3 — Crypto toggle forces annual billing and disables monthly', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-interval-token');
// Verify default billing interval is "Monthly"
const hasMonthly = await textExists('Monthly');
expect(hasMonthly).toBe(true);
// Toggle "Pay with Crypto" ON
await enableCryptoToggle();
// Verify billing interval switched to "Annual"
const hasAnnual = await textExists('Annual');
expect(hasAnnual).toBe(true);
// Attempt to click "Monthly" — it should be disabled (opacity-40, cursor-not-allowed)
// Even if we click it, the interval should stay "Annual" because crypto forces annual
try {
await clickText('Monthly', 5_000);
await browser.pause(1_000);
} catch {
console.log(`${LOG_PREFIX} 6.1.3: Monthly button click failed (expected — disabled)`);
}
// Verify "Annual" is still the active interval after clicking Monthly
// The component uses crypto check: if paymentMethod === 'crypto', setBillingInterval is blocked
const stillAnnual = await textExists('Annual');
expect(stillAnnual).toBe(true);
console.log(`${LOG_PREFIX} 6.1.3 PASSED`);
});
});
// -------------------------------------------------------------------------
// 6.2 Confirmation Handling
// -------------------------------------------------------------------------
describe('6.2 Confirmation Handling', () => {
it('6.2.1 — Successful crypto payment confirmation via polling', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-confirm-token');
// Enable crypto toggle
await enableCryptoToggle();
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 6.2.1: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify Coinbase charge was created
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
expect(coinbaseCall).toBeDefined();
// Verify "Waiting" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
console.log(`${LOG_PREFIX} 6.2.1: Waiting banner visible`);
// Simulate successful crypto payment: update mock plan
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 365 * 86400000).toISOString());
setMockBehavior('cryptoStatus', 'CONFIRMED');
// Wait for polling to detect plan change and clear waiting state
const waitingGone = await waitForTextToDisappear('Waiting', 15_000);
expect(waitingGone).toBe(true);
console.log(`${LOG_PREFIX} 6.2.1: Waiting banner disappeared after polling`);
// Re-auth and verify plan persists as BASIC with "Current" badge
await reAuthAndGoToBilling('e2e-crypto-confirm-verify-token');
const hasBasic = (await textExists('BASIC')) || (await textExists('Basic'));
expect(hasBasic).toBe(true);
const hasCurrent = await textExists('Current');
expect(hasCurrent).toBe(true);
console.log(`${LOG_PREFIX} 6.2.1 PASSED`);
});
it('6.2.2 — Underpayment: plan does NOT activate, waiting state persists', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-underpaid-token');
// Enable crypto toggle
await enableCryptoToggle();
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 6.2.2: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify Coinbase charge was created
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
expect(coinbaseCall).toBeDefined();
// Verify "Waiting" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
console.log(`${LOG_PREFIX} 6.2.2: Waiting banner visible`);
// Simulate underpayment: plan stays FREE, subscription not active
// The backend would not activate the subscription for underpaid charges.
// Mock keeps returning FREE plan (default) — polling sees no change.
setMockBehavior('cryptoStatus', 'UNDERPAID');
setMockBehavior('cryptoUnderpaidAmount', '100.00');
// Do NOT set plan or planActive — plan stays FREE
// Wait 15s — the "Waiting" banner should still be visible because
// the plan hasn't changed (still FREE, no active subscription)
await browser.pause(15_000);
const stillWaiting = await isWaitingVisible();
expect(stillWaiting).toBe(true);
console.log(`${LOG_PREFIX} 6.2.2: Waiting state persists after underpayment (expected)`);
// Verify the "Upgrade" button shows "Waiting..." (all disabled during purchase)
const hasWaitingButton = await textExists('Waiting...');
console.log(`${LOG_PREFIX} 6.2.2: Waiting... button visible: ${hasWaitingButton}`);
// Verify plan is still FREE
const hasFree = await textExists('FREE');
expect(hasFree).toBe(true);
console.log(`${LOG_PREFIX} 6.2.2: Plan still FREE (underpayment not accepted)`);
// Now resolve: simulate the user completing the remaining payment
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('cryptoStatus', 'CONFIRMED');
const waitingGone = await waitForTextToDisappear('Waiting', 20_000);
expect(waitingGone).toBe(true);
console.log(`${LOG_PREFIX} 6.2.2: Resolved after completing payment`);
console.log(`${LOG_PREFIX} 6.2.2 PASSED`);
});
it('6.2.3 — Overpayment: plan activates normally', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-overpaid-token');
// Enable crypto toggle
await enableCryptoToggle();
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 6.2.3: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify Coinbase charge was created
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
expect(coinbaseCall).toBeDefined();
// Verify "Waiting" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
console.log(`${LOG_PREFIX} 6.2.3: Waiting banner visible`);
// Simulate overpayment: backend accepts the payment and activates plan.
// Even though the user sent more than required, the plan activates normally.
setMockBehavior('cryptoStatus', 'OVERPAID');
setMockBehavior('cryptoOverpaidAmount', '50.00');
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 365 * 86400000).toISOString());
// Wait for polling to detect plan change
const waitingGone = await waitForTextToDisappear('Waiting', 15_000);
expect(waitingGone).toBe(true);
console.log(`${LOG_PREFIX} 6.2.3: Waiting cleared — overpayment accepted`);
// Re-auth and verify plan is BASIC
await reAuthAndGoToBilling('e2e-crypto-overpaid-verify-token');
const hasBasic = (await textExists('BASIC')) || (await textExists('Basic'));
expect(hasBasic).toBe(true);
const hasCurrent = await textExists('Current');
expect(hasCurrent).toBe(true);
console.log(`${LOG_PREFIX} 6.2.3: Plan activated as BASIC despite overpayment`);
console.log(`${LOG_PREFIX} 6.2.3 PASSED`);
});
});
// -------------------------------------------------------------------------
// 6.3 Payment Status Update
// -------------------------------------------------------------------------
describe('6.3 Payment Status Update', () => {
it('6.3.1 — Polling detects plan change after crypto confirmation', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-status-token');
// Enable crypto toggle
await enableCryptoToggle();
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 6.3.1: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify charge created
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
expect(coinbaseCall).toBeDefined();
// Verify waiting state is active
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
// Verify polling is hitting /payments/stripe/currentPlan
// Wait a few seconds for at least one poll to fire
await browser.pause(6_000);
const pollCalls = getRequestLog().filter(
r => r.method === 'GET' && r.url.includes('/payments/stripe/currentPlan')
);
console.log(`${LOG_PREFIX} 6.3.1: Poll calls so far: ${pollCalls.length}`);
expect(pollCalls.length).toBeGreaterThan(0);
// Now simulate the payment being confirmed on the backend
setMockBehavior('plan', 'BASIC');
setMockBehavior('planActive', 'true');
setMockBehavior('planExpiry', new Date(Date.now() + 365 * 86400000).toISOString());
// Polling should detect the change within ~5s (poll interval)
const waitingGone = await waitForTextToDisappear('Waiting', 15_000);
expect(waitingGone).toBe(true);
console.log(`${LOG_PREFIX} 6.3.1: Polling detected plan change, waiting cleared`);
// Verify fetchCurrentUser was triggered (re-auth verifies state)
await reAuthAndGoToBilling('e2e-crypto-status-verify-token');
const hasBasic = (await textExists('BASIC')) || (await textExists('Basic'));
expect(hasBasic).toBe(true);
console.log(`${LOG_PREFIX} 6.3.1 PASSED`);
});
it('6.3.2 — Coinbase API error handled gracefully', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-error-token');
// Enable crypto toggle
await enableCryptoToggle();
// Set Coinbase to return 500 error
setMockBehavior('coinbaseError', 'true');
try {
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 6.3.2: Clicked Upgrade (with error mock)`);
await browser.pause(3_000);
// Verify Coinbase API was called
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
expect(coinbaseCall).toBeDefined();
// Verify "Waiting" banner does NOT appear (purchase failed immediately)
const hasWaiting = await isWaitingVisible();
console.log(`${LOG_PREFIX} 6.3.2: Waiting banner visible (should be false): ${hasWaiting}`);
expect(hasWaiting).toBe(false);
// Verify Upgrade buttons remain clickable (isPurchasing reset to false)
const hasUpgrade = await textExists('Upgrade');
expect(hasUpgrade).toBe(true);
console.log(`${LOG_PREFIX} 6.3.2: Upgrade button still clickable after error`);
// Verify NO Stripe calls were made (crypto mode was active)
const stripeCalls = getRequestLog().filter(
r => r.method === 'POST' && r.url.includes('/payments/stripe/purchasePlan')
);
expect(stripeCalls.length).toBe(0);
console.log(`${LOG_PREFIX} 6.3.2 PASSED`);
} finally {
setMockBehavior('coinbaseError', 'false');
}
});
it('6.3.3 — Expired charge: waiting state clears after poll timeout', async () => {
// Reset to FREE plan and re-auth
resetMockBehavior();
await reAuthAndGoToBilling('e2e-crypto-expired-token');
// Enable crypto toggle
await enableCryptoToggle();
clearRequestLog();
// Click "Upgrade" on BASIC tier
await clickText('Upgrade', 10_000);
console.log(`${LOG_PREFIX} 6.3.3: Clicked Upgrade button`);
await browser.pause(3_000);
// Verify charge created
const coinbaseCall = await waitForRequest('POST', '/payments/coinbase/charge', 10_000);
expect(coinbaseCall).toBeDefined();
// Verify "Waiting" banner appears
const hasWaiting = await isWaitingVisible();
expect(hasWaiting).toBe(true);
// Simulate expired charge: plan stays FREE (no change)
// The user didn't pay before the charge expired.
setMockBehavior('cryptoStatus', 'EXPIRED');
// Do NOT update plan — it stays FREE
// The BillingPanel polling has a 2-minute timeout. We don't want to wait
// the full 2 minutes in the test, so instead we re-auth which resets
// component state (isPurchasing resets to false on remount).
console.log(`${LOG_PREFIX} 6.3.3: Simulating charge expiry — re-auth to reset state`);
resetMockBehavior(); // Plan stays FREE
await reAuthAndGoToBilling('e2e-crypto-expired-verify-token');
// Verify plan is still FREE after expired charge
const hasFree = await textExists('FREE');
expect(hasFree).toBe(true);
// Verify no active subscription
const hasCurrent = await textExists('Current');
// "Current" badge should be on FREE tier
expect(hasCurrent).toBe(true);
// Verify "Waiting" banner is NOT visible (fresh component state)
const noWaiting = !(await isWaitingVisible());
expect(noWaiting).toBe(true);
// Verify user can try again — Upgrade button is available
const hasUpgrade = await textExists('Upgrade');
expect(hasUpgrade).toBe(true);
console.log(`${LOG_PREFIX} 6.3.3: Upgrade available after expired charge`);
console.log(`${LOG_PREFIX} 6.3.3 PASSED`);
});
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+995 -1238
View File
File diff suppressed because it is too large Load Diff