feat: add E2E tests for card payment processing flow (#124)

* feat: add E2E tests for auth access control and billing/subscription flows

Add comprehensive E2E test coverage for authentication, access control,
and billing/subscription lifecycle using Appium mac2 + WebDriverIO.

New test cases (11 total):
- 1.1 User registration via deep link
- 1.1.1 Duplicate account handling
- 1.2 Multi-device sessions
- 3.1.1 Default FREE plan allocation
- 3.2.1 Upgrade flow (Stripe purchase API + polling)
- 3.2.2 Downgrade flow verification
- 3.3.1 Active subscription display
- 3.3.2 Renewal date handling
- 3.3.3 Cancellation via Stripe portal
- 1.3 Logout via Settings
- 1.3.1 Revoked session auto-logout

Mock server additions:
- Dynamic team data with subscription controlled by mockBehavior
- /payments/stripe/currentPlan, purchasePlan, portal routes
- /payments/coinbase/charge route

Split e2e.sh into per-flow scripts (e2e-login.sh, e2e-auth.sh) to
prevent Redux Persist state leaking between specs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix Prettier formatting for E2E test files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review findings for E2E auth spec

- clickFirstCandidate: check retry result, return null with tree dump on failure
- navigateToHome: throw on failure instead of silently continuing
- 3.2.2: assert exactly 1 Upgrade element (PRO only) and verify PRO visible

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for card payment processing flow

Add 8 new E2E tests covering checkout sessions, payment confirmation
via polling, duplicate prevention, and billing events for Stripe card
and Coinbase crypto payment flows.

New files:
- test/e2e/specs/card-payment-flow.spec.ts (8 test cases)
- scripts/e2e-payment.sh (standalone runner)

Modified:
- mock-server.ts: add purchaseError/coinbaseError behavior toggles
- package.json: add test:e2e:payment script and chain into test:e2e

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add assertions to waitForTextToDisappear calls and guard purchaseError cleanup

Ensure all waitForTextToDisappear results are asserted with expect().toBe(true)
instead of silently discarding the return value. Wrap 5.2.2 test body in
try/finally so the purchaseError mock flag is always reset even if assertions throw.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix Prettier formatting in card payment spec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix pre-existing Prettier formatting issues

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for cryptocurrency payment processing flow

Covers invoice creation (Coinbase charge), confirmation handling
(success, underpayment, overpayment), and payment status updates
(polling, API errors, expired charges). Extends mock server with
crypto payment status endpoint and adds test runner script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for Telegram integration flows

Covers sections 7.1-7.5: account linking, permission levels, command
processing, webhook handling, and disconnect/re-setup flows (13 tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for Notion integration flows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add E2E tests for Gmail integration flows

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
CodeGhost21
2026-02-21 10:57:12 +04:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6356e3bdc4
commit 29f542a3d7
15 changed files with 5806 additions and 23 deletions
+6 -1
View File
@@ -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 .",
+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"
+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);
+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