diff --git a/examples/testapp/package.json b/examples/testapp/package.json index 9e39f0bfe..31968c602 100644 --- a/examples/testapp/package.json +++ b/examples/testapp/package.json @@ -15,6 +15,7 @@ }, "dependencies": { "@base-org/account": "workspace:*", + "@base-org/account-npm": "npm:@base-org/account@latest", "@chakra-ui/icons": "^2.1.1", "@chakra-ui/react": "^2.8.0", "@emotion/react": "^11.11.1", diff --git a/examples/testapp/src/components/Layout.tsx b/examples/testapp/src/components/Layout.tsx index 3fef44b1f..0291d817b 100644 --- a/examples/testapp/src/components/Layout.tsx +++ b/examples/testapp/src/components/Layout.tsx @@ -20,7 +20,7 @@ import { import NextLink from 'next/link'; import React, { useMemo } from 'react'; import { useConfig } from '../context/ConfigContextProvider'; -import { scwUrls, sdkVersions } from '../store/config'; +import { scwUrls } from '../store/config'; import { cleanupSDKLocalStorage } from '../utils/cleanupSDKLocalStorage'; type LayoutProps = { children: React.ReactNode; @@ -38,10 +38,11 @@ const PAGES = [ '/pay-playground', '/subscribe-playground', '/prolink-playground', + '/e2e-test', ]; export function Layout({ children }: LayoutProps) { - const { version, setSDKVersion, scwUrl, setScwUrlAndSave } = useConfig(); + const { scwUrl, setScwUrlAndSave } = useConfig(); const { isOpen, onOpen, onClose } = useDisclosure(); const isSmallScreen = useBreakpointValue({ base: true, xl: false }); @@ -54,24 +55,6 @@ export function Layout({ children }: LayoutProps) { const configs = useMemo(() => { return ( <> - - }> - {`SDK: ${version}`} - - - {sdkVersions.map((v) => ( - : null} - onClick={() => setSDKVersion(v)} - > - {v} - - ))} - - - }> {`Env: ${scwUrl}`} @@ -91,7 +74,7 @@ export function Layout({ children }: LayoutProps) { ); - }, [version, setSDKVersion, scwUrl, setScwUrlAndSave]); + }, [scwUrl, setScwUrlAndSave]); const handleGoToHome = () => { window.location.href = '/'; diff --git a/examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx b/examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx index 8817d1b9b..97e93d13d 100644 --- a/examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx +++ b/examples/testapp/src/components/RpcMethods/RpcMethodCard.tsx @@ -77,7 +77,9 @@ export function RpcMethodCard({ format, method, params, shortcuts }) { )?.data.chain ?? mainnet; if (method.includes('wallet_sign')) { + // biome-ignore lint/suspicious/noExplicitAny: old code, refactor soon const type = data.type || (data.request as any).type; + // biome-ignore lint/suspicious/noExplicitAny: old code, refactor soon const walletSignData = data.data || (data.request as any).data; let result: string | null = null; if (type === '0x01') { diff --git a/examples/testapp/src/components/UserInteractionModal.tsx b/examples/testapp/src/components/UserInteractionModal.tsx new file mode 100644 index 000000000..54da3bb66 --- /dev/null +++ b/examples/testapp/src/components/UserInteractionModal.tsx @@ -0,0 +1,102 @@ +import { + Box, + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + VStack, +} from '@chakra-ui/react'; +import { useEffect, useRef } from 'react'; + +interface UserInteractionModalProps { + isOpen: boolean; + testName: string; + onContinue: () => void; + onCancel: () => void; +} + +export function UserInteractionModal({ + isOpen, + testName, + onContinue, + onCancel, +}: UserInteractionModalProps) { + const continueButtonRef = useRef(null); + + // Focus the continue button when modal opens + useEffect(() => { + if (isOpen) { + setTimeout(() => { + continueButtonRef.current?.focus(); + }, 100); + } + }, [isOpen]); + + // Handle Enter key to continue + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + onContinue(); + } else if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onContinue, onCancel]); + + return ( + + + + User Interaction Required + + + The next test requires user interaction to prevent popup blockers: + + {testName} + + + + [Press Enter to Continue] + + + + Or click "Continue Test" to proceed, or "Cancel Test" to stop the test suite. + + + + + + + + + + ); +} diff --git a/examples/testapp/src/hooks/useUserInteraction.tsx b/examples/testapp/src/hooks/useUserInteraction.tsx new file mode 100644 index 000000000..48acf106a --- /dev/null +++ b/examples/testapp/src/hooks/useUserInteraction.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; + +interface UseUserInteractionReturn { + isModalOpen: boolean; + currentTestName: string; + requestUserInteraction: (testName: string, skipModal?: boolean) => Promise; + handleContinue: () => void; + handleCancel: () => void; +} + +export function useUserInteraction(): UseUserInteractionReturn { + const [isModalOpen, setIsModalOpen] = useState(false); + const [currentTestName, setCurrentTestName] = useState(''); + const [resolver, setResolver] = useState<{ + resolve: () => void; + reject: (error: Error) => void; + } | null>(null); + + const requestUserInteraction = (testName: string, skipModal = false): Promise => { + // If skipModal is true, immediately resolve without showing the modal + if (skipModal) { + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + setCurrentTestName(testName); + setIsModalOpen(true); + setResolver({ resolve, reject }); + }); + }; + + const handleContinue = () => { + setIsModalOpen(false); + resolver?.resolve(); + setResolver(null); + }; + + const handleCancel = () => { + setIsModalOpen(false); + resolver?.reject(new Error('Test cancelled by user')); + setResolver(null); + }; + + return { + isModalOpen, + currentTestName, + requestUserInteraction, + handleContinue, + handleCancel, + }; +} diff --git a/examples/testapp/src/pages/add-sub-account/components/SendCalls.tsx b/examples/testapp/src/pages/add-sub-account/components/SendCalls.tsx index af8d1c2f5..b66eb61ac 100644 --- a/examples/testapp/src/pages/add-sub-account/components/SendCalls.tsx +++ b/examples/testapp/src/pages/add-sub-account/components/SendCalls.tsx @@ -35,7 +35,7 @@ export function SendCalls({ version: '1', capabilities: { paymasterService: { - url: 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + url: 'https://example.paymaster.com', }, }, }, diff --git a/examples/testapp/src/pages/add-sub-account/components/SpendPermissions.tsx b/examples/testapp/src/pages/add-sub-account/components/SpendPermissions.tsx index 780875436..4b7478934 100644 --- a/examples/testapp/src/pages/add-sub-account/components/SpendPermissions.tsx +++ b/examples/testapp/src/pages/add-sub-account/components/SpendPermissions.tsx @@ -78,7 +78,7 @@ export function SpendPermissions({ ], capabilities: { paymasterService: { - url: 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + url: 'https://example.paymaster.com', }, }, }, diff --git a/examples/testapp/src/pages/auto-sub-account/index.page.tsx b/examples/testapp/src/pages/auto-sub-account/index.page.tsx index 0d99e3960..9386c477b 100644 --- a/examples/testapp/src/pages/auto-sub-account/index.page.tsx +++ b/examples/testapp/src/pages/auto-sub-account/index.page.tsx @@ -472,7 +472,7 @@ export default function AutoSubAccount() { ], capabilities: { paymasterService: { - url: 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + url: 'https://example.paymaster.com', }, }, }, @@ -565,7 +565,7 @@ export default function AutoSubAccount() { ], capabilities: { paymasterService: { - url: 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + url: 'https://example.paymaster.com', }, }, }, diff --git a/examples/testapp/src/pages/e2e-test/components/Header.module.css b/examples/testapp/src/pages/e2e-test/components/Header.module.css new file mode 100644 index 000000000..8442113f9 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/components/Header.module.css @@ -0,0 +1,177 @@ +.header { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 2rem 0; + margin-bottom: 2rem; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); +} + +.headerContent { + width: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 0 2rem; + display: flex; + justify-content: space-between; + align-items: center; + gap: 2rem; +} + +.titleSection { + flex: 1; +} + +.title { + margin: 0; + font-size: 2.5rem; + font-weight: 800; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.subtitle { + margin: 0.5rem 0 0; + font-size: 1.125rem; + opacity: 0.9; +} + +.versionSection { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.75rem; +} + +.versionBadge { + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.3); + padding: 0.5rem 1rem; + border-radius: 0.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.versionLabel { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.8; + font-weight: 600; +} + +.versionValue { + font-size: 1.25rem; + font-weight: 700; + font-family: "Courier New", monospace; +} + +.sdkControls { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; +} + +.sourceToggle { + display: flex; + gap: 0.25rem; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 0.25rem; + border-radius: 0.5rem; +} + +.sourceButton { + padding: 0.5rem 1rem; + border: none; + background: transparent; + color: white; + cursor: pointer; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s ease; + white-space: nowrap; +} + +.sourceButton:hover { + background: rgba(255, 255, 255, 0.1); +} + +.sourceButton.active { + background: rgba(255, 255, 255, 0.25); + font-weight: 600; +} + +.versionSelect { + padding: 0.5rem 1rem; + border: 1px solid rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.2); + backdrop-filter: blur(10px); + color: white; + border-radius: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + min-width: 120px; + transition: all 0.2s ease; +} + +.versionSelect:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.4); +} + +.versionSelect option { + background: #764ba2; + color: white; +} + +/* Responsive design */ +@media (max-width: 768px) { + .header { + padding: 1.5rem 0; + } + + .headerContent { + flex-direction: column; + align-items: flex-start; + padding: 0 1rem; + } + + .title { + font-size: 2rem; + } + + .subtitle { + font-size: 1rem; + } + + .versionSection { + align-items: flex-start; + width: 100%; + } + + .versionBadge { + align-self: flex-start; + } +} + +@media (max-width: 480px) { + .title { + font-size: 1.5rem; + } + + .subtitle { + font-size: 0.875rem; + } + + .versionValue { + font-size: 1rem; + } +} diff --git a/examples/testapp/src/pages/e2e-test/components/Header.tsx b/examples/testapp/src/pages/e2e-test/components/Header.tsx new file mode 100644 index 000000000..86cbf8c5a --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/components/Header.tsx @@ -0,0 +1,82 @@ +import styles from './Header.module.css'; + +interface HeaderProps { + sdkVersion?: string; + sdkSource?: 'local' | 'npm'; + onSourceChange?: (source: 'local' | 'npm') => void; + onVersionChange?: (version: string) => void; + availableVersions?: string[]; + npmVersion?: string; + isLoadingSDK?: boolean; + onLoadSDK?: () => void; +} + +export const Header = ({ + sdkVersion = 'Loading...', + sdkSource = 'local', + onSourceChange, + onVersionChange, + availableVersions = ['latest'], + npmVersion = 'latest', + isLoadingSDK = false, + onLoadSDK, +}: HeaderProps) => { + return ( +
+
+
+

๐Ÿงช E2E Test Suite

+

Comprehensive end-to-end tests for the Base Account SDK

+
+
+
+ SDK Version + {sdkVersion} +
+ {onSourceChange && ( +
+
+ + +
+ {sdkSource === 'npm' && onVersionChange && ( + <> + + {onLoadSDK && ( + + )} + + )} +
+ )} +
+
+
+ ); +}; diff --git a/examples/testapp/src/pages/e2e-test/components/index.ts b/examples/testapp/src/pages/e2e-test/components/index.ts new file mode 100644 index 000000000..29429dc97 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/components/index.ts @@ -0,0 +1 @@ +export { Header } from './Header'; diff --git a/examples/testapp/src/pages/e2e-test/hooks/index.ts b/examples/testapp/src/pages/e2e-test/hooks/index.ts new file mode 100644 index 000000000..4ba9c1dfa --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/hooks/index.ts @@ -0,0 +1,17 @@ +/** + * E2E Test Hooks + * + * Centralized exports for all test-related hooks + */ + +export { useTestState } from './useTestState'; +export type { UseTestStateReturn } from './useTestState'; + +export { useSDKState } from './useSDKState'; +export type { UseSDKStateReturn } from './useSDKState'; + +export { useConnectionState } from './useConnectionState'; +export type { UseConnectionStateReturn } from './useConnectionState'; + +export { useTestRunner } from './useTestRunner'; +export type { UseTestRunnerReturn, UseTestRunnerOptions } from './useTestRunner'; diff --git a/examples/testapp/src/pages/e2e-test/hooks/testResultHandlers.ts b/examples/testapp/src/pages/e2e-test/hooks/testResultHandlers.ts new file mode 100644 index 000000000..24b9f6849 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/hooks/testResultHandlers.ts @@ -0,0 +1,374 @@ +/** + * Test Result Handlers Configuration + * + * Centralized mapping of test names to their result processing logic. + * Each handler is responsible for: + * - Extracting relevant data from test results + * - Updating refs (e.g., paymentId, subscriptionId) + * - Updating test status with meaningful details + * - Updating connection state when needed + */ + +import type { MutableRefObject } from 'react'; +import type { UseConnectionStateReturn } from './useConnectionState'; +import type { UseTestStateReturn } from './useTestState'; + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Context passed to each test result handler + */ +export interface TestResultHandlerContext { + testCategory: string; + testName: string; + // biome-ignore lint/suspicious/noExplicitAny: Result type varies, runtime checks in handlers + result: any; + testState: UseTestStateReturn; + connectionState: UseConnectionStateReturn; + paymentIdRef: MutableRefObject; + subscriptionIdRef: MutableRefObject; + permissionHashRef: MutableRefObject; + subAccountAddressRef: MutableRefObject; +} + +/** + * Handler function for processing test results + */ +export type TestResultHandler = (ctx: TestResultHandlerContext) => void; + +// ============================================================================ +// Handler Configuration +// ============================================================================ + +/** + * Centralized configuration for handling test results. + * Each test name maps to a handler that processes the result and updates state/refs/details. + */ +export const TEST_RESULT_HANDLERS: Record = { + // Payment features + 'pay() function': (ctx) => { + if (ctx.result.id) { + ctx.paymentIdRef.current = ctx.result.id; + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Payment ID: ${ctx.result.id}` + ); + } + }, + + // Subscription features + 'subscribe() function': (ctx) => { + if (ctx.result.id) { + ctx.subscriptionIdRef.current = ctx.result.id; + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Subscription ID: ${ctx.result.id}` + ); + } + }, + 'base.subscription.getStatus()': (ctx) => { + if (ctx.result.details) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + ctx.result.details + ); + } + }, + 'prepareCharge() with amount': (ctx) => { + if (Array.isArray(ctx.result)) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Generated ${ctx.result.length} call(s)` + ); + } + }, + 'prepareCharge() max-remaining-charge': (ctx) => { + if (Array.isArray(ctx.result)) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Generated ${ctx.result.length} call(s)` + ); + } + }, + + // Sub-account features + wallet_addSubAccount: (ctx) => { + if (ctx.result.address) { + ctx.subAccountAddressRef.current = ctx.result.address; + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Address: ${ctx.result.address}` + ); + } + }, + wallet_getSubAccounts: (ctx) => { + const result = ctx.result as { subAccounts?: Array<{ address: string }>; addresses?: string[] }; + if (result.subAccounts) { + const addresses = result.addresses || result.subAccounts.map((sa) => sa.address); + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + addresses.join(', ') + ); + } + }, + 'wallet_sendCalls (sub-account)': (ctx) => { + // Handle both direct string result and object with txHash property + const hash = typeof ctx.result === 'string' ? ctx.result : ctx.result?.txHash; + if (hash) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Tx: ${hash}` + ); + } + }, + 'personal_sign (sub-account)': (ctx) => { + if (ctx.result.isValid !== undefined) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Verified: ${ctx.result.isValid}` + ); + } + }, + + // Spend permission features + 'spendPermission.requestSpendPermission()': (ctx) => { + if (ctx.result.permissionHash) { + ctx.permissionHashRef.current = ctx.result.permissionHash; + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Hash: ${ctx.result.permissionHash}` + ); + } + }, + 'spendPermission.getPermissionStatus()': (ctx) => { + if (ctx.result.remainingSpend) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Remaining: ${ctx.result.remainingSpend}` + ); + } + }, + 'spendPermission.fetchPermission()': (ctx) => { + if (ctx.result.permissionHash) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Hash: ${ctx.result.permissionHash}` + ); + } + }, + 'spendPermission.fetchPermissions()': (ctx) => { + if (Array.isArray(ctx.result)) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Found ${ctx.result.length} permission(s)` + ); + } + }, + 'spendPermission.prepareSpendCallData()': (ctx) => { + if (Array.isArray(ctx.result)) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Generated ${ctx.result.length} call(s)` + ); + } + }, + 'spendPermission.prepareRevokeCallData()': (ctx) => { + if (ctx.result.to) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `To: ${ctx.result.to}` + ); + } + }, + + // Wallet connection + 'Connect wallet': (ctx) => { + if (Array.isArray(ctx.result) && ctx.result.length > 0) { + ctx.connectionState.setCurrentAccount(ctx.result[0]); + ctx.connectionState.setAllAccounts(ctx.result); + ctx.connectionState.setConnected(true); + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Connected: ${ctx.result[0]}` + ); + } + }, + 'Get accounts': (ctx) => { + if (Array.isArray(ctx.result)) { + if (ctx.result.length > 0 && !ctx.connectionState.connected) { + ctx.connectionState.setCurrentAccount(ctx.result[0]); + ctx.connectionState.setAllAccounts(ctx.result); + ctx.connectionState.setConnected(true); + } else if (ctx.result.length > 0) { + // Update accounts even if already connected + ctx.connectionState.setAllAccounts(ctx.result); + } + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + ctx.result.join(', ') + ); + } + }, + 'Get chain ID': (ctx) => { + if (typeof ctx.result === 'number') { + ctx.connectionState.setChainId(ctx.result); + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Chain ID: ${ctx.result}` + ); + } + }, + 'Sign message (personal_sign)': (ctx) => { + if (typeof ctx.result === 'string') { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Sig: ${ctx.result}` + ); + } + }, + + // Sign & Send + eth_signTypedData_v4: (ctx) => { + if (typeof ctx.result === 'string') { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Sig: ${ctx.result}` + ); + } + }, + wallet_sendCalls: (ctx) => { + let hash: string | undefined; + if (typeof ctx.result === 'string') { + hash = ctx.result; + } else if (typeof ctx.result === 'object' && ctx.result !== null && 'id' in ctx.result) { + hash = ctx.result.id; + } + if (hash) { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Hash: ${hash}` + ); + } + }, + + // Prolink features + 'encodeProlink()': (ctx) => { + if (typeof ctx.result === 'string') { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `Encoded: ${ctx.result}` + ); + } + }, + 'createProlinkUrl()': (ctx) => { + if (typeof ctx.result === 'string') { + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `URL: ${ctx.result}` + ); + } + }, +}; + +// ============================================================================ +// Processing Function +// ============================================================================ + +/** + * Process test result using the configured handler. + * If no handler exists for the test name, this is a no-op. + * Errors in handlers are caught and logged to prevent test suite crashes. + */ +export function processTestResult(ctx: TestResultHandlerContext): void { + const handler = TEST_RESULT_HANDLERS[ctx.testName]; + if (handler) { + try { + handler(ctx); + } catch (error) { + console.error(`[Test Result Handler] Error processing result for "${ctx.testName}":`, error); + // Update test status to reflect handler error, but keep the test as passed + // since the actual test execution succeeded + const currentDetails = ctx.testState.testCategories + .find((cat) => cat.name === ctx.testCategory) + ?.tests.find((t) => t.name === ctx.testName)?.details; + ctx.testState.updateTestStatus( + ctx.testCategory, + ctx.testName, + 'passed', + undefined, + `${currentDetails || 'Success'} (Note: Result handler error - check console)` + ); + } + } +} diff --git a/examples/testapp/src/pages/e2e-test/hooks/useConnectionState.ts b/examples/testapp/src/pages/e2e-test/hooks/useConnectionState.ts new file mode 100644 index 000000000..250cb0eea --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/hooks/useConnectionState.ts @@ -0,0 +1,99 @@ +/** + * Hook for managing wallet connection state + * + * Consolidates connection status, current account, and chain ID tracking. + * Provides helper functions for ensuring connection is established. + */ + +import { useCallback, useState } from 'react'; +import type { EIP1193Provider } from 'viem'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface UseConnectionStateReturn { + // State + connected: boolean; + currentAccount: string | null; + allAccounts: string[]; + chainId: number | null; + + // Actions + setConnected: (connected: boolean) => void; + setCurrentAccount: (account: string | null) => void; + setAllAccounts: (accounts: string[]) => void; + setChainId: (chainId: number | null) => void; + + // Helpers + updateConnectionFromProvider: (provider: EIP1193Provider) => Promise; +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useConnectionState(): UseConnectionStateReturn { + const [connected, setConnected] = useState(false); + const [currentAccount, setCurrentAccount] = useState(null); + const [allAccounts, setAllAccounts] = useState([]); + const [chainId, setChainId] = useState(null); + + /** + * Update connection state from provider + * Queries provider for current account and chain ID + */ + const updateConnectionFromProvider = useCallback(async (provider: EIP1193Provider) => { + if (!provider) { + return; + } + + try { + // Get accounts + const accounts = (await provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + if (accounts && accounts.length > 0) { + setCurrentAccount(accounts[0]); + setAllAccounts(accounts); + setConnected(true); + } else { + setCurrentAccount(null); + setAllAccounts([]); + setConnected(false); + } + + // Get chain ID + const chainIdHex = (await provider.request({ + method: 'eth_chainId', + params: [], + })) as string; + const chainIdNum = Number.parseInt(chainIdHex, 16); + setChainId(chainIdNum); + } catch (_error) { + // Failed to update connection from provider - reset state + setCurrentAccount(null); + setAllAccounts([]); + setConnected(false); + } + }, []); + + return { + // State + connected, + currentAccount, + allAccounts, + chainId, + + // Actions + setConnected, + setCurrentAccount, + setAllAccounts, + setChainId, + + // Helpers + updateConnectionFromProvider, + }; +} diff --git a/examples/testapp/src/pages/e2e-test/hooks/useSDKState.ts b/examples/testapp/src/pages/e2e-test/hooks/useSDKState.ts new file mode 100644 index 000000000..56f9f2fb0 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/hooks/useSDKState.ts @@ -0,0 +1,125 @@ +/** + * Hook for managing SDK loading and state + * + * Consolidates SDK source selection, loading, version management, + * and SDK instance state into a single hook. + */ + +import { useCallback, useRef, useState } from 'react'; +import { SDK_CONFIG } from '../../../utils/e2e-test-config'; +import { type LoadedSDK, type SDKSource, loadSDK } from '../../../utils/sdkLoader'; +import type { BaseAccountSDK } from '../types'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface UseSDKStateReturn { + // State + sdkSource: SDKSource; + loadedSDK: LoadedSDK | null; + sdk: BaseAccountSDK | null; + // biome-ignore lint/suspicious/noExplicitAny: EIP1193Provider type varies + provider: any | null; // EIP1193Provider type + isLoadingSDK: boolean; + sdkLoadError: string | null; + + // Actions + setSdkSource: (source: SDKSource) => void; + loadAndInitializeSDK: (config?: { + appName?: string; + appLogoUrl?: string; + appChainIds?: number[]; + walletUrl?: string; + }) => Promise; + setSdk: (sdk: BaseAccountSDK | null) => void; + // biome-ignore lint/suspicious/noExplicitAny: EIP1193Provider type varies + setProvider: (provider: any | null) => void; +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useSDKState(): UseSDKStateReturn { + const [sdkSource, setSdkSource] = useState('local'); + const [loadedSDK, setLoadedSDK] = useState(null); + const [sdk, setSdk] = useState(null); + // biome-ignore lint/suspicious/noExplicitAny: EIP1193Provider type varies + const [provider, setProvider] = useState(null); + const [isLoadingSDK, setIsLoadingSDK] = useState(false); + const [sdkLoadError, setSdkLoadError] = useState(null); + + // Track SDK load version to handle race conditions when rapidly switching sources + const loadVersionRef = useRef(0); + + const loadAndInitializeSDK = useCallback( + async (config?: { + appName?: string; + appLogoUrl?: string; + appChainIds?: number[]; + walletUrl?: string; + }) => { + // Increment load version to invalidate any in-flight SDK loads + const currentLoadVersion = ++loadVersionRef.current; + + setIsLoadingSDK(true); + setSdkLoadError(null); + + try { + const loaded = await loadSDK({ source: sdkSource }); + + // Check if this load is still valid (user hasn't switched sources) + if (currentLoadVersion !== loadVersionRef.current) { + // Ignoring stale SDK load - user has switched sources + return; + } + + setLoadedSDK(loaded); + + // Initialize SDK instance with provided or default config + const sdkInstance = loaded.createBaseAccountSDK({ + appName: config?.appName || SDK_CONFIG.APP_NAME, + appLogoUrl: config?.appLogoUrl || SDK_CONFIG.APP_LOGO_URL, + appChainIds: config?.appChainIds || [...SDK_CONFIG.DEFAULT_CHAIN_IDS], + preference: { + walletUrl: config?.walletUrl, + }, + }); + + setSdk(sdkInstance); + const providerInstance = sdkInstance.getProvider(); + setProvider(providerInstance); + } catch (error) { + // Only update error state if this load is still current + if (currentLoadVersion === loadVersionRef.current) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setSdkLoadError(errorMessage); + throw error; // Re-throw so caller can handle + } + } finally { + // Only update loading state if this load is still current + if (currentLoadVersion === loadVersionRef.current) { + setIsLoadingSDK(false); + } + } + }, + [sdkSource] + ); + + return { + // State + sdkSource, + loadedSDK, + sdk, + provider, + isLoadingSDK, + sdkLoadError, + + // Actions + setSdkSource, + loadAndInitializeSDK, + setSdk, + setProvider, + }; +} diff --git a/examples/testapp/src/pages/e2e-test/hooks/useTestRunner.ts b/examples/testapp/src/pages/e2e-test/hooks/useTestRunner.ts new file mode 100644 index 000000000..93ed67fee --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/hooks/useTestRunner.ts @@ -0,0 +1,561 @@ +/** + * Hook for running E2E tests + * + * Encapsulates the test execution orchestration logic for both individual sections + * and the full test suite. Uses the test registry to execute tests in sequence. + */ + +import { useToast } from '@chakra-ui/react'; +import { useCallback, useRef, type MutableRefObject } from 'react'; +import { TEST_DELAYS } from '../../../utils/e2e-test-config/test-config'; +import { + TEST_INDICES, + categoryRequiresConnection, + getTestByIndex, + getTestsByCategory, + type TestFn, +} from '../tests'; +import type { TestContext, TestHandlers } from '../types'; +import { processTestResult } from './testResultHandlers'; +import type { UseConnectionStateReturn } from './useConnectionState'; +import type { UseTestStateReturn } from './useTestState'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface UseTestRunnerOptions { + // State management + testState: UseTestStateReturn; + connectionState: UseConnectionStateReturn; + + // SDK state + // biome-ignore lint/suspicious/noExplicitAny: LoadedSDK type varies between local and npm versions + loadedSDK: any; + // biome-ignore lint/suspicious/noExplicitAny: EIP1193Provider type from viem + provider: any; + + // User interaction + requestUserInteraction: (testName: string, skipModal?: boolean) => Promise; + + // Test data refs + paymentIdRef: MutableRefObject; + subscriptionIdRef: MutableRefObject; + permissionHashRef: MutableRefObject; + subAccountAddressRef: MutableRefObject; + + // Configuration + walletUrl?: string; +} + +export interface UseTestRunnerReturn { + runAllTests: () => Promise; + runTestSection: (sectionName: string) => Promise; + runSCWReleaseTests: () => Promise; +} + +// ============================================================================ +// Hook +// ============================================================================ + +export function useTestRunner(options: UseTestRunnerOptions): UseTestRunnerReturn { + const { + testState, + connectionState, + loadedSDK, + provider, + requestUserInteraction, + paymentIdRef, + subscriptionIdRef, + permissionHashRef, + subAccountAddressRef, + walletUrl, + } = options; + + const toast = useToast(); + + // Track whether we've shown the first modal for the current test run + // The first modal should be skipped since the user's button click provides the gesture + const hasShownFirstModalRef = useRef(false); + + /** + * Build test context from current state + */ + const buildTestContext = useCallback((): TestContext => { + // Skip the first modal since the user's button click provides the gesture + // After that, show modals for subsequent user interactions + const skipModal = !hasShownFirstModalRef.current; + + return { + provider, + loadedSDK, + connected: connectionState.connected, + currentAccount: connectionState.currentAccount, + chainId: connectionState.chainId, + paymentId: paymentIdRef.current, + subscriptionId: subscriptionIdRef.current, + permissionHash: permissionHashRef.current, + subAccountAddress: subAccountAddressRef.current, + skipModal, + walletUrl, + }; + }, [ + provider, + loadedSDK, + connectionState.connected, + connectionState.currentAccount, + connectionState.chainId, + paymentIdRef, + subscriptionIdRef, + permissionHashRef, + subAccountAddressRef, + walletUrl, + ]); + + /** + * Execute a single test function and capture return values to update refs + */ + const executeTest = useCallback( + async (testFn: TestFn): Promise => { + const context = buildTestContext(); + + // Track which test ran by wrapping updateTestStatus + let testCategory = ''; + let testName = ''; + let _requiresUserInteraction = false; + + const handlers: TestHandlers = { + updateTestStatus: (category, name, status, error, details, duration) => { + testCategory = category; + testName = name; + testState.updateTestStatus(category, name, status, error, details, duration); + }, + requestUserInteraction: async (testName: string, skipModal?: boolean) => { + _requiresUserInteraction = true; + await requestUserInteraction(testName, skipModal); + // After the first modal opportunity (whether shown or skipped), + // mark that we've passed it so subsequent modals will be shown + hasShownFirstModalRef.current = true; + }, + }; + + try { + const result = await testFn(handlers, context); + + // Process test result using centralized handler + if (result) { + processTestResult({ + testCategory, + testName, + result, + testState, + connectionState, + paymentIdRef, + subscriptionIdRef, + permissionHashRef, + subAccountAddressRef, + }); + } + } catch (error) { + // Test functions handle their own errors, but we catch here to prevent + // uncaught promise rejections. If error is 'Test cancelled by user', + // it will be re-thrown by the test function to stop execution. + if (error instanceof Error && error.message === 'Test cancelled by user') { + throw error; + } + // Other errors are already logged by the test function + } + }, + [ + buildTestContext, + paymentIdRef, + subscriptionIdRef, + subAccountAddressRef, + permissionHashRef, + testState, + connectionState, + requestUserInteraction, + ] + ); + + /** + * Ensure wallet connection for tests that require it + */ + const ensureConnectionForTests = useCallback(async (): Promise => { + if (!provider) { + throw new Error('Provider not available'); + } + + // Check if already connected + const accounts = await provider.request({ + method: 'eth_accounts', + params: [], + }); + + if (accounts && accounts.length > 0) { + connectionState.setCurrentAccount(accounts[0]); + connectionState.setAllAccounts(accounts); + connectionState.setConnected(true); + return; + } + + // Not connected - run wallet connection tests to establish connection + const walletTests = getTestsByCategory('Wallet Connection'); + for (const testFn of walletTests) { + await executeTest(testFn); + await delay(TEST_DELAYS.BETWEEN_TESTS); + } + + // Verify connection was established + const accountsAfter = await provider.request({ + method: 'eth_accounts', + params: [], + }); + + if (!accountsAfter || accountsAfter.length === 0) { + throw new Error( + 'Failed to establish wallet connection after running connection tests. Please ensure wallet is available and user approves connection.' + ); + } + + // Update connection state with verified connection + connectionState.setCurrentAccount(accountsAfter[0]); + connectionState.setAllAccounts(accountsAfter); + connectionState.setConnected(true); + }, [provider, connectionState, executeTest]); + + /** + * Run a specific test section + */ + const runTestSection = useCallback( + async (sectionName: string): Promise => { + testState.setRunningSectionName(sectionName); + testState.resetCategory(sectionName); + + // Reset modal tracking for this test run + // The first modal will be skipped since the button click provides the gesture + hasShownFirstModalRef.current = false; + + try { + // Check if section requires connection + if (categoryRequiresConnection(sectionName)) { + await ensureConnectionForTests(); + await delay(TEST_DELAYS.BETWEEN_TESTS); + } + + // Get tests for this section + const tests = getTestsByCategory(sectionName); + + if (tests.length === 0) { + return; + } + + // Execute tests in sequence + for (let i = 0; i < tests.length; i++) { + await executeTest(tests[i]); + + // Add delay between tests (except after last test) + if (i < tests.length - 1) { + await delay(TEST_DELAYS.BETWEEN_TESTS); + } + } + + toast({ + title: 'Section Complete', + description: `${sectionName} tests finished`, + status: 'success', + duration: TEST_DELAYS.TOAST_SUCCESS_DURATION, + isClosable: true, + }); + } catch (error) { + if (error instanceof Error && error.message === 'Test cancelled by user') { + toast({ + title: 'Tests Cancelled', + description: `${sectionName} tests were cancelled`, + status: 'warning', + duration: TEST_DELAYS.TOAST_WARNING_DURATION, + isClosable: true, + }); + } + } finally { + testState.setRunningSectionName(null); + } + }, + [testState, toast, ensureConnectionForTests, executeTest] + ); + + /** + * Helper to run all tests in a category + */ + const runTestCategory = useCallback( + async (categoryName: string): Promise => { + const tests = getTestsByCategory(categoryName); + + for (let i = 0; i < tests.length; i++) { + await executeTest(tests[i]); + + // Add delay between tests (except after last test) + if (i < tests.length - 1) { + await delay(TEST_DELAYS.BETWEEN_TESTS); + } + } + }, + [executeTest] + ); + + /** + * Run all tests in the complete test suite + */ + const runAllTests = useCallback(async (): Promise => { + testState.startTests(); + testState.resetAllCategories(); + + // Reset modal tracking for this test run + // The first modal will be skipped since the button click provides the gesture + hasShownFirstModalRef.current = false; + + try { + // Execute tests following the optimized sequence from the original implementation + // This sequence is designed to minimize flakiness and ensure proper test dependencies + + // 1. SDK Initialization (must be first) + await runTestCategory('SDK Initialization & Exports'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + + // 2. Establish wallet connection + await runTestCategory('Wallet Connection'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + + // 3. Run connection-dependent tests BEFORE pay/subscribe + // These need a stable connection state + + // Sign & Send tests + await runTestCategory('Sign & Send'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + + // Spend Permission tests + await runTestCategory('Spend Permissions'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + + // 4. Sub-Account tests (run BEFORE pay/subscribe to avoid state conflicts) + await runTestCategory('Sub-Account Features'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + + // 5. Payment & Subscription tests (run AFTER sub-account tests) + await runTestCategory('Payment Features'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + + await runTestCategory('Subscription Features'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + + // 6. Standalone tests (don't require connection) + await runTestCategory('Prolink Features'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + + await runTestCategory('Provider Events'); + await delay(TEST_DELAYS.BETWEEN_TESTS); + } catch (error) { + if (error instanceof Error && error.message === 'Test cancelled by user') { + toast({ + title: 'Tests Cancelled', + description: 'Test suite was cancelled by user', + status: 'warning', + duration: TEST_DELAYS.TOAST_WARNING_DURATION, + isClosable: true, + }); + } + } finally { + testState.stopTests(); + + // Show completion toast (if not cancelled) + const passed = testState.testCategories.reduce( + (acc, cat) => acc + cat.tests.filter((t) => t.status === 'passed').length, + 0 + ); + const failed = testState.testCategories.reduce( + (acc, cat) => acc + cat.tests.filter((t) => t.status === 'failed').length, + 0 + ); + + if (passed > 0 || failed > 0) { + toast({ + title: 'Tests Complete', + description: `${passed} passed, ${failed} failed`, + status: failed > 0 ? 'warning' : 'success', + duration: TEST_DELAYS.TOAST_INFO_DURATION, + isClosable: true, + }); + } + } + }, [testState, toast, runTestCategory]); + + /** + * Run only tests that make external requests (require user interaction) + * This is useful for SCW Release testing + * + * The following tests are included (tests that open popups/windows and receive responses): + * 1. Wallet Connection: + * - testConnectWallet (opens popup) + * - testGetAccounts + * - testGetChainId + * - testSignMessage (opens popup) + * 2. Sign & Send: + * - testSignTypedData (opens popup) + * - testWalletSendCalls (opens popup) + * 3. Spend Permissions: + * - testRequestSpendPermission (opens popup) + * - testGetPermissionStatus + * - testFetchPermission + * - testFetchPermissions + * 4. Sub-Account Features: + * - testCreateSubAccount (opens popup) + * - testGetSubAccounts + * - testSendCallsFromSubAccount (opens popup) + * 5. Payment Features: + * - testPay (opens popup) + * - testGetPaymentStatus + * 6. Subscription Features: + * - testSubscribe (opens popup) + * - testGetSubscriptionStatus + * + * Tests excluded (no external requests or not relevant for SCW Release): + * - SDK Initialization & Exports (SDK already instantiated on page load) + * - wallet_prepareCalls (no popup) + * - personal_sign (sub-account) (no popup) + * - prepareSpendCallData (no popup) + * - prepareRevokeCallData (no popup) + * - prepareCharge variants (no popup) + * - Prolink Features (all tests - no external requests) + * - Provider Events (all tests - no external requests) + */ + const runSCWReleaseTests = useCallback(async (): Promise => { + testState.startTests(); + testState.resetAllCategories(); + + // Reset modal tracking for this test run + hasShownFirstModalRef.current = false; + + try { + // Execute tests that make external requests following the optimized sequence + // Note: SDK Initialization tests are skipped - SDK is already loaded on page load + + // Helper to safely execute a test by index + const executeTestByIndex = async (categoryName: string, index: number) => { + const test = getTestByIndex(categoryName, index); + if (test) { + await executeTest(test); + await delay(TEST_DELAYS.BETWEEN_TESTS); + } else { + console.warn(`[SCW Release Tests] Test not found: ${categoryName}[${index}]`); + } + }; + + // 1. Establish wallet connection - testConnectWallet requires user interaction + await executeTestByIndex('Wallet Connection', TEST_INDICES.WALLET_CONNECTION.CONNECT_WALLET); + + // Get remaining wallet connection tests (testGetAccounts, testGetChainId don't need user interaction) + await executeTestByIndex('Wallet Connection', TEST_INDICES.WALLET_CONNECTION.GET_ACCOUNTS); + await executeTestByIndex('Wallet Connection', TEST_INDICES.WALLET_CONNECTION.GET_CHAIN_ID); + + // testSignMessage requires user interaction + await executeTestByIndex('Wallet Connection', TEST_INDICES.WALLET_CONNECTION.SIGN_MESSAGE); + + // 2. Sign & Send tests - testSignTypedData and testWalletSendCalls require user interaction + await executeTestByIndex('Sign & Send', TEST_INDICES.SIGN_AND_SEND.SIGN_TYPED_DATA); + await executeTestByIndex('Sign & Send', TEST_INDICES.SIGN_AND_SEND.WALLET_SEND_CALLS); + // testWalletPrepareCalls doesn't require user interaction - skip + + // 3. Spend Permission tests - testRequestSpendPermission requires user interaction + await executeTestByIndex( + 'Spend Permissions', + TEST_INDICES.SPEND_PERMISSIONS.REQUEST_SPEND_PERMISSION + ); + // testGetPermissionStatus, testFetchPermission don't require user interaction - skip + // testFetchPermissions doesn't require user interaction + await executeTestByIndex( + 'Spend Permissions', + TEST_INDICES.SPEND_PERMISSIONS.FETCH_PERMISSIONS + ); + // testPrepareSpendCallData and testPrepareRevokeCallData don't require user interaction - skip + + // 4. Sub-Account tests - testCreateSubAccount and testSendCallsFromSubAccount require user interaction + await executeTestByIndex( + 'Sub-Account Features', + TEST_INDICES.SUB_ACCOUNT_FEATURES.CREATE_SUB_ACCOUNT + ); + await executeTestByIndex( + 'Sub-Account Features', + TEST_INDICES.SUB_ACCOUNT_FEATURES.GET_SUB_ACCOUNTS + ); + // testSignWithSubAccount doesn't require user interaction - skip + await executeTestByIndex( + 'Sub-Account Features', + TEST_INDICES.SUB_ACCOUNT_FEATURES.SEND_CALLS_FROM_SUB_ACCOUNT + ); + + // 5. Payment tests - testPay requires user interaction + await executeTestByIndex('Payment Features', TEST_INDICES.PAYMENT_FEATURES.PAY); + await executeTestByIndex( + 'Payment Features', + TEST_INDICES.PAYMENT_FEATURES.GET_PAYMENT_STATUS + ); + + // 6. Subscription tests - testSubscribe requires user interaction + await executeTestByIndex( + 'Subscription Features', + TEST_INDICES.SUBSCRIPTION_FEATURES.SUBSCRIBE + ); + // testGetSubscriptionStatus doesn't require user interaction - skip + // testPrepareCharge doesn't require user interaction - skip + } catch (error) { + if (error instanceof Error && error.message === 'Test cancelled by user') { + toast({ + title: 'Tests Cancelled', + description: 'SCW Release test suite was cancelled by user', + status: 'warning', + duration: TEST_DELAYS.TOAST_WARNING_DURATION, + isClosable: true, + }); + } + } finally { + testState.stopTests(); + + // Show completion toast + const passed = testState.testCategories.reduce( + (acc, cat) => acc + cat.tests.filter((t) => t.status === 'passed').length, + 0 + ); + const failed = testState.testCategories.reduce( + (acc, cat) => acc + cat.tests.filter((t) => t.status === 'failed').length, + 0 + ); + + if (passed > 0 || failed > 0) { + toast({ + title: 'SCW Release Tests Complete', + description: `${passed} passed, ${failed} failed`, + status: failed > 0 ? 'warning' : 'success', + duration: TEST_DELAYS.TOAST_INFO_DURATION, + isClosable: true, + }); + } + } + }, [testState, toast, executeTest]); + + return { + runAllTests, + runTestSection, + runSCWReleaseTests, + }; +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Delay helper for test sequencing + */ +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/examples/testapp/src/pages/e2e-test/hooks/useTestState.ts b/examples/testapp/src/pages/e2e-test/hooks/useTestState.ts new file mode 100644 index 000000000..3c7c5bd04 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/hooks/useTestState.ts @@ -0,0 +1,268 @@ +/** + * Hook for managing test execution state + * + * Consolidates test categories, test results, and running section tracking + * into a single cohesive state manager using reducer pattern. + */ + +import { useCallback, useReducer } from 'react'; +import { TEST_CATEGORIES } from '../../../utils/e2e-test-config'; +import type { TestCategory, TestResults, TestStatus } from '../types'; + +// ============================================================================ +// Types +// ============================================================================ + +interface TestState { + categories: TestCategory[]; + results: TestResults; + runningSectionName: string | null; + isRunningTests: boolean; +} + +type TestAction = + | { + type: 'UPDATE_TEST_STATUS'; + payload: { + category: string; + testName: string; + status: TestStatus; + error?: string; + details?: string; + duration?: number; + }; + } + | { type: 'RESET_CATEGORY'; payload: string } + | { type: 'RESET_ALL_CATEGORIES' } + | { type: 'START_TESTS' } + | { type: 'STOP_TESTS' } + | { type: 'SET_RUNNING_SECTION'; payload: string | null } + | { type: 'TOGGLE_CATEGORY_EXPANDED'; payload: string }; + +// ============================================================================ +// Initial State +// ============================================================================ + +const initialCategories: TestCategory[] = TEST_CATEGORIES.map((name) => ({ + name, + tests: [], + expanded: true, +})); + +const initialState: TestState = { + categories: initialCategories, + results: { + total: 0, + passed: 0, + failed: 0, + skipped: 0, + }, + runningSectionName: null, + isRunningTests: false, +}; + +// ============================================================================ +// Reducer +// ============================================================================ + +function testStateReducer(state: TestState, action: TestAction): TestState { + switch (action.type) { + case 'UPDATE_TEST_STATUS': { + const { category, testName, status, error, details, duration } = action.payload; + + const updatedCategories = state.categories.map((cat) => { + if (cat.name === category) { + const existingTestIndex = cat.tests.findIndex((t) => t.name === testName); + + if (existingTestIndex >= 0) { + // Update existing test + const updatedTests = [...cat.tests]; + updatedTests[existingTestIndex] = { + name: testName, + status, + error, + details, + duration, + }; + return { ...cat, tests: updatedTests }; + } + + // Add new test + return { + ...cat, + tests: [...cat.tests, { name: testName, status, error, details, duration }], + }; + } + return cat; + }); + + // Update totals if test is finalized + let updatedResults = state.results; + if (status === 'passed' || status === 'failed' || status === 'skipped') { + // Check if this is a new final status (not an update) + const oldCategory = state.categories.find((c) => c.name === category); + const oldTest = oldCategory?.tests.find((t) => t.name === testName); + const wasNotFinal = + !oldTest || oldTest.status === 'pending' || oldTest.status === 'running'; + + if (wasNotFinal) { + updatedResults = { + total: state.results.total + 1, + passed: state.results.passed + (status === 'passed' ? 1 : 0), + failed: state.results.failed + (status === 'failed' ? 1 : 0), + skipped: state.results.skipped + (status === 'skipped' ? 1 : 0), + }; + } + } + + return { + ...state, + categories: updatedCategories, + results: updatedResults, + }; + } + + case 'RESET_CATEGORY': { + const updatedCategories = state.categories.map((cat) => + cat.name === action.payload ? { ...cat, tests: [] } : cat + ); + return { + ...state, + categories: updatedCategories, + }; + } + + case 'RESET_ALL_CATEGORIES': + return { + ...state, + categories: state.categories.map((cat) => ({ ...cat, tests: [] })), + results: { + total: 0, + passed: 0, + failed: 0, + skipped: 0, + }, + }; + + case 'START_TESTS': + return { + ...state, + isRunningTests: true, + }; + + case 'STOP_TESTS': + return { + ...state, + isRunningTests: false, + }; + + case 'SET_RUNNING_SECTION': + return { + ...state, + runningSectionName: action.payload, + }; + + case 'TOGGLE_CATEGORY_EXPANDED': { + const updatedCategories = state.categories.map((cat) => + cat.name === action.payload ? { ...cat, expanded: !cat.expanded } : cat + ); + return { + ...state, + categories: updatedCategories, + }; + } + + default: + return state; + } +} + +// ============================================================================ +// Hook +// ============================================================================ + +export interface UseTestStateReturn { + // State + testCategories: TestCategory[]; + testResults: TestResults; + runningSectionName: string | null; + isRunningTests: boolean; + + // Actions + updateTestStatus: ( + category: string, + testName: string, + status: TestStatus, + error?: string, + details?: string, + duration?: number + ) => void; + resetCategory: (categoryName: string) => void; + resetAllCategories: () => void; + startTests: () => void; + stopTests: () => void; + setRunningSectionName: (name: string | null) => void; + toggleCategoryExpanded: (categoryName: string) => void; +} + +export function useTestState(): UseTestStateReturn { + const [state, dispatch] = useReducer(testStateReducer, initialState); + + const updateTestStatus = useCallback( + ( + category: string, + testName: string, + status: TestStatus, + error?: string, + details?: string, + duration?: number + ) => { + dispatch({ + type: 'UPDATE_TEST_STATUS', + payload: { category, testName, status, error, details, duration }, + }); + }, + [] + ); + + const resetCategory = useCallback((categoryName: string) => { + dispatch({ type: 'RESET_CATEGORY', payload: categoryName }); + }, []); + + const resetAllCategories = useCallback(() => { + dispatch({ type: 'RESET_ALL_CATEGORIES' }); + }, []); + + const startTests = useCallback(() => { + dispatch({ type: 'START_TESTS' }); + }, []); + + const stopTests = useCallback(() => { + dispatch({ type: 'STOP_TESTS' }); + }, []); + + const setRunningSectionName = useCallback((name: string | null) => { + dispatch({ type: 'SET_RUNNING_SECTION', payload: name }); + }, []); + + const toggleCategoryExpanded = useCallback((categoryName: string) => { + dispatch({ type: 'TOGGLE_CATEGORY_EXPANDED', payload: categoryName }); + }, []); + + return { + // State + testCategories: state.categories, + testResults: state.results, + runningSectionName: state.runningSectionName, + isRunningTests: state.isRunningTests, + + // Actions + updateTestStatus, + resetCategory, + resetAllCategories, + startTests, + stopTests, + setRunningSectionName, + toggleCategoryExpanded, + }; +} diff --git a/examples/testapp/src/pages/e2e-test/index.page.tsx b/examples/testapp/src/pages/e2e-test/index.page.tsx new file mode 100644 index 000000000..d1b71d785 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/index.page.tsx @@ -0,0 +1,552 @@ +import { CopyIcon } from '@chakra-ui/icons'; +import { + Badge, + Box, + Button, + Card, + CardBody, + CardHeader, + Code, + Container, + Flex, + Heading, + Link, + Radio, + RadioGroup, + Stack, + Stat, + StatGroup, + StatLabel, + StatNumber, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, + Text, + Tooltip, + VStack, + useToast, +} from '@chakra-ui/react'; +import { useEffect, useRef } from 'react'; +import { WIDTH_2XL } from '../../components/Layout'; +import { UserInteractionModal } from '../../components/UserInteractionModal'; +import { useConfig } from '../../context/ConfigContextProvider'; +import { useUserInteraction } from '../../hooks/useUserInteraction'; +import type { SDKSource } from '../../utils/sdkLoader'; + +// Import refactored modules +import { TEST_DELAYS, UI_COLORS } from '../../utils/e2e-test-config'; +import { useConnectionState } from './hooks/useConnectionState'; +import { useSDKState } from './hooks/useSDKState'; +import { useTestRunner } from './hooks/useTestRunner'; +import { useTestState } from './hooks/useTestState'; +import { formatTestResults, getStatusColor, getStatusIcon } from './utils/format-results'; + +export default function E2ETestPage() { + const toast = useToast(); + const { scwUrl } = useConfig(); + const { isModalOpen, currentTestName, requestUserInteraction, handleContinue, handleCancel } = + useUserInteraction(); + + // Test data refs (use refs instead of state to avoid async state update issues) + const paymentIdRef = useRef(null); + const subscriptionIdRef = useRef(null); + const permissionHashRef = useRef(null); + const subAccountAddressRef = useRef(null); + + // State management hooks + const testState = useTestState(); + const { testCategories, runningSectionName, isRunningTests } = testState; + + const { sdkSource, loadedSDK, provider, isLoadingSDK, setSdkSource, loadAndInitializeSDK } = + useSDKState(); + + const connectionState = useConnectionState(); + const { connected, currentAccount, allAccounts, chainId } = connectionState; + + // Test runner hook - handles all test execution logic + const { runAllTests, runTestSection, runSCWReleaseTests } = useTestRunner({ + testState, + connectionState, + loadedSDK, + provider, + requestUserInteraction, + paymentIdRef, + subscriptionIdRef, + permissionHashRef, + subAccountAddressRef, + walletUrl: scwUrl, + }); + + // Copy functions for test results + const copyTestResults = async () => { + const resultsText = formatTestResults(testCategories, { + format: 'full', + sdkInfo: { + version: loadedSDK?.VERSION || 'Not Loaded', + source: sdkSource === 'npm' ? 'NPM Latest' : 'Local Workspace', + }, + }); + + try { + await navigator.clipboard.writeText(resultsText); + toast({ + title: 'Copied!', + description: 'Test results copied to clipboard', + status: 'success', + duration: TEST_DELAYS.TOAST_SUCCESS_DURATION, + isClosable: true, + }); + } catch (_error) { + toast({ + title: 'Copy Failed', + description: 'Failed to copy to clipboard', + status: 'error', + duration: TEST_DELAYS.TOAST_ERROR_DURATION, + isClosable: true, + }); + } + }; + + const copyAbbreviatedResults = async () => { + const resultsText = formatTestResults(testCategories, { + format: 'abbreviated', + sdkInfo: { + version: loadedSDK?.VERSION || 'Not Loaded', + source: sdkSource === 'npm' ? 'NPM Latest' : 'Local Workspace', + }, + }); + + try { + await navigator.clipboard.writeText(resultsText); + toast({ + title: 'Copied!', + description: 'Abbreviated results copied to clipboard', + status: 'success', + duration: TEST_DELAYS.TOAST_SUCCESS_DURATION, + isClosable: true, + }); + } catch (_error) { + toast({ + title: 'Copy Failed', + description: 'Failed to copy to clipboard', + status: 'error', + duration: TEST_DELAYS.TOAST_ERROR_DURATION, + isClosable: true, + }); + } + }; + + const copySectionResults = async (categoryName: string) => { + const resultsText = formatTestResults(testCategories, { + format: 'section', + categoryName, + sdkInfo: { + version: loadedSDK?.VERSION || 'Not Loaded', + source: sdkSource === 'npm' ? 'NPM Latest' : 'Local Workspace', + }, + }); + + try { + await navigator.clipboard.writeText(resultsText); + toast({ + title: 'Copied!', + description: `${categoryName} results copied to clipboard`, + status: 'success', + duration: TEST_DELAYS.TOAST_SUCCESS_DURATION, + isClosable: true, + }); + } catch (_error) { + toast({ + title: 'Copy Failed', + description: 'Failed to copy to clipboard', + status: 'error', + duration: TEST_DELAYS.TOAST_ERROR_DURATION, + isClosable: true, + }); + } + }; + + // Initialize SDK on mount with local version + // biome-ignore lint/correctness/useExhaustiveDependencies: loadAndInitializeSDK should only run on mount + useEffect(() => { + loadAndInitializeSDK({ walletUrl: scwUrl }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Reload SDK when scwUrl changes + // biome-ignore lint/correctness/useExhaustiveDependencies: loadAndInitializeSDK is stable, loadedSDK check is intentional + useEffect(() => { + if (loadedSDK) { + loadAndInitializeSDK({ walletUrl: scwUrl }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scwUrl]); + + // Helper for source change + const handleSourceChange = (source: SDKSource) => { + setSdkSource(source); + }; + + return ( + <> + + + {/* SDK Source Controls */} + + + + + SDK Configuration + + Choose which SDK version to test + + + + handleSourceChange(value as SDKSource)} + size="md" + isDisabled={isLoadingSDK} + > + + + Local + + + NPM Latest + + + + + {isLoadingSDK && ( + + Loading... + + )} + + + v{loadedSDK?.VERSION || 'Not Loaded'} + + + + + + + {/* Connection Status */} + + + Wallet Connection Status + + + + + + + {connected ? 'Connected' : 'Not Connected'} + + {connected && Active} + + + {connected && currentAccount && ( + + + + Connected Account{allAccounts.length > 1 ? 's' : ''} + + + {allAccounts.map((account, index) => ( + + {account} + + ))} + + + + + Active Network Chain ID + + + + {chainId || 'Unknown'} + + + + + )} + + {!connected && ( + + + No wallet connected. Run the "Connect wallet" test to establish a connection. + + + )} + + + + + {/* Test Controls */} + + + + + Test Controls + + Run all tests or individual test categories + + + + + + + + + + + {/* Test Results Summary */} + + + + Test Results + + + + + + + + + + + + + + Total Tests + + {testCategories.reduce((acc, cat) => acc + cat.tests.length, 0)} + + + + Passed + + {testCategories.reduce( + (acc, cat) => acc + cat.tests.filter((t) => t.status === 'passed').length, + 0 + )} + + + + Failed + + {testCategories.reduce( + (acc, cat) => acc + cat.tests.filter((t) => t.status === 'failed').length, + 0 + )} + + + + Skipped + + {testCategories.reduce( + (acc, cat) => acc + cat.tests.filter((t) => t.status === 'skipped').length, + 0 + )} + + + + + + + {/* Test Categories */} + + + Test Categories + + + + {/* Test Categories Tab */} + + + {testCategories.map((category) => ( + + + + + {category.name} + + + + {category.tests.length} test{category.tests.length !== 1 ? 's' : ''} + + + + + + + + + + {category.tests.length === 0 ? ( + + No tests run yet + + ) : ( + + {category.tests.map((test) => ( + + + + {getStatusIcon(test.status)} + {test.name} + + {test.duration && ( + {test.duration}ms + )} + + {test.details && ( + + {test.details} + + )} + {test.error && ( + + + Error: {test.error} + + + )} + + ))} + + )} + + + ))} + + + + + + {/* Documentation Link */} + + + + ๐Ÿ“š For more information, visit the + + Base Account Documentation + + + + + + + + ); +} diff --git a/examples/testapp/src/pages/e2e-test/tests/index.ts b/examples/testapp/src/pages/e2e-test/tests/index.ts new file mode 100644 index 000000000..27e9d7106 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/index.ts @@ -0,0 +1,243 @@ +/** + * Test Registry + * + * Central registry of all E2E tests organized by category. + * This provides a structured way to access and run tests. + */ + +import type { TestContext, TestHandlers } from '../types'; + +import { testGetPaymentStatus, testPay } from './payment-features'; +import { testProlinkEncodeDecode } from './prolink-features'; +import { testProviderEvents } from './provider-events'; +// Import all test functions +import { testSDKInitialization } from './sdk-initialization'; +import { testSignTypedData, testWalletPrepareCalls, testWalletSendCalls } from './sign-and-send'; +import { + testFetchPermission, + testFetchPermissions, + testGetPermissionStatus, + testPrepareRevokeCallData, + testPrepareSpendCallData, + testRequestSpendPermission, +} from './spend-permissions'; +import { + testCreateSubAccount, + testGetSubAccounts, + testSendCallsFromSubAccount, + testSignWithSubAccount, +} from './sub-account-features'; +import { + testGetSubscriptionStatus, + testPrepareCharge, + testSubscribe, +} from './subscription-features'; +import { + testConnectWallet, + testGetAccounts, + testGetChainId, + testSignMessage, +} from './wallet-connection'; + +/** + * Test function type + */ +export type TestFn = (handlers: TestHandlers, context: TestContext) => Promise; + +/** + * Test category definition + */ +export interface TestCategoryDefinition { + name: string; + tests: TestFn[]; + requiresConnection?: boolean; // Category-level requirement +} + +/** + * Complete test registry organized by category + */ +export const testRegistry: TestCategoryDefinition[] = [ + { + name: 'SDK Initialization & Exports', + tests: [testSDKInitialization], + requiresConnection: false, + }, + { + name: 'Wallet Connection', + tests: [testConnectWallet, testGetAccounts, testGetChainId, testSignMessage], + requiresConnection: false, // Connection is established during these tests + }, + { + name: 'Payment Features', + tests: [testPay, testGetPaymentStatus], + requiresConnection: false, // pay() doesn't require explicit connection + }, + { + name: 'Subscription Features', + tests: [testSubscribe, testGetSubscriptionStatus, testPrepareCharge], + requiresConnection: false, // subscribe() doesn't require explicit connection + }, + { + name: 'Prolink Features', + tests: [testProlinkEncodeDecode], + requiresConnection: false, + }, + { + name: 'Spend Permissions', + tests: [ + testRequestSpendPermission, + testGetPermissionStatus, + testFetchPermission, + testFetchPermissions, + testPrepareSpendCallData, + testPrepareRevokeCallData, + ], + requiresConnection: true, + }, + { + name: 'Sub-Account Features', + tests: [ + testCreateSubAccount, + testGetSubAccounts, + testSignWithSubAccount, + testSendCallsFromSubAccount, + ], + requiresConnection: true, + }, + { + name: 'Sign & Send', + tests: [testSignTypedData, testWalletSendCalls, testWalletPrepareCalls], + requiresConnection: true, + }, + { + name: 'Provider Events', + tests: [testProviderEvents], + requiresConnection: false, + }, +]; + +/** + * Get all test functions in a flat array + */ +export function getAllTests(): TestFn[] { + return testRegistry.flatMap((category) => category.tests); +} + +/** + * Get tests for a specific category by name + */ +export function getTestsByCategory(categoryName: string): TestFn[] { + const category = testRegistry.find((cat) => cat.name === categoryName); + return category?.tests || []; +} + +/** + * Get all category names + */ +export function getCategoryNames(): string[] { + return testRegistry.map((cat) => cat.name); +} + +/** + * Check if a category requires connection + */ +export function categoryRequiresConnection(categoryName: string): boolean { + const category = testRegistry.find((cat) => cat.name === categoryName); + return category?.requiresConnection || false; +} + +/** + * Get a specific test by category and index with validation + * This is safer than directly accessing array indices + * + * @param categoryName - Name of the category + * @param index - Index of the test in the category + * @returns The test function, or undefined if not found + * + * @example + * ```typescript + * const connectTest = getTestByIndex('Wallet Connection', 0); // testConnectWallet + * ``` + */ +export function getTestByIndex(categoryName: string, index: number): TestFn | undefined { + const tests = getTestsByCategory(categoryName); + if (index < 0 || index >= tests.length) { + console.warn( + `[Test Registry] Test index ${index} out of bounds for category "${categoryName}" (has ${tests.length} tests)` + ); + return undefined; + } + return tests[index]; +} + +/** + * Test index constants for runSCWReleaseTests + * These constants make the test indices more explicit and easier to maintain + */ +export const TEST_INDICES = { + WALLET_CONNECTION: { + CONNECT_WALLET: 0, + GET_ACCOUNTS: 1, + GET_CHAIN_ID: 2, + SIGN_MESSAGE: 3, + }, + SIGN_AND_SEND: { + SIGN_TYPED_DATA: 0, + WALLET_SEND_CALLS: 1, + }, + SPEND_PERMISSIONS: { + REQUEST_SPEND_PERMISSION: 0, + FETCH_PERMISSIONS: 3, + }, + SUB_ACCOUNT_FEATURES: { + CREATE_SUB_ACCOUNT: 0, + GET_SUB_ACCOUNTS: 1, + SEND_CALLS_FROM_SUB_ACCOUNT: 3, + }, + PAYMENT_FEATURES: { + PAY: 0, + GET_PAYMENT_STATUS: 1, + }, + SUBSCRIPTION_FEATURES: { + SUBSCRIBE: 0, + }, +} as const; + +// Export all test functions for direct use +export { testSDKInitialization } from './sdk-initialization'; +export { + testConnectWallet, + testGetAccounts, + testGetChainId, + testSignMessage, +} from './wallet-connection'; +export { + testPay, + testGetPaymentStatus, +} from './payment-features'; +export { + testSubscribe, + testGetSubscriptionStatus, + testPrepareCharge, +} from './subscription-features'; +export { + testRequestSpendPermission, + testGetPermissionStatus, + testFetchPermission, + testFetchPermissions, + testPrepareSpendCallData, + testPrepareRevokeCallData, +} from './spend-permissions'; +export { + testCreateSubAccount, + testGetSubAccounts, + testSignWithSubAccount, + testSendCallsFromSubAccount, +} from './sub-account-features'; +export { + testSignTypedData, + testWalletSendCalls, + testWalletPrepareCalls, +} from './sign-and-send'; +export { testProlinkEncodeDecode } from './prolink-features'; +export { testProviderEvents } from './provider-events'; diff --git a/examples/testapp/src/pages/e2e-test/tests/payment-features.ts b/examples/testapp/src/pages/e2e-test/tests/payment-features.ts new file mode 100644 index 000000000..e0a9d854a --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/payment-features.ts @@ -0,0 +1,86 @@ +/** + * Payment Features Tests + * + * Tests for one-time payment functionality via base.pay() and status checking. + */ + +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test creating a payment with base.pay() + */ +export async function testPay( + handlers: TestHandlers, + context: TestContext +): Promise<{ id: string } | undefined> { + return runTest( + { + category: 'Payment Features', + name: 'pay() function', + requiresSDK: true, + requiresUserInteraction: true, + }, + async (ctx) => { + const result = await ctx.loadedSDK.base.pay({ + amount: '0.01', + to: '0x0000000000000000000000000000000000000001', + testnet: true, + walletUrl: ctx.walletUrl, + }); + + return result; + }, + handlers, + context + ); +} + +/** + * Test checking payment status with getPaymentStatus() + */ +export async function testGetPaymentStatus( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check if payment ID is available + if (!context.paymentId) { + handlers.updateTestStatus( + 'Payment Features', + 'getPaymentStatus()', + 'skipped', + 'No payment ID available' + ); + return undefined; + } + + return runTest( + { + category: 'Payment Features', + name: 'getPaymentStatus()', + requiresSDK: true, + }, + async (ctx) => { + const status = await ctx.loadedSDK.getPaymentStatus({ + id: ctx.paymentId!, + testnet: true, + maxRetries: 10, // Retry up to 10 times + retryDelayMs: 500, // 500ms between retries = ~5 seconds total + }); + + const details = [ + `Status: ${status.status}`, + status.amount ? `Amount: ${status.amount} USDC` : null, + status.recipient ? `Recipient: ${status.recipient}` : null, + status.sender ? `Sender: ${status.sender}` : null, + status.reason ? `Reason: ${status.reason}` : null, + ] + .filter(Boolean) + .join(', '); + + return { status, details }; + }, + handlers, + context + ); +} diff --git a/examples/testapp/src/pages/e2e-test/tests/prolink-features.ts b/examples/testapp/src/pages/e2e-test/tests/prolink-features.ts new file mode 100644 index 000000000..0c4f18bcc --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/prolink-features.ts @@ -0,0 +1,123 @@ +/** + * Prolink Features Tests + * + * Tests for Prolink encoding, decoding, and URL generation functionality. + */ + +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test Prolink encoding, decoding, and URL creation + */ +export async function testProlinkEncodeDecode( + handlers: TestHandlers, + context: TestContext +): Promise { + const category = 'Prolink Features'; + + // Check if Prolink functions are available + if ( + !context.loadedSDK.encodeProlink || + !context.loadedSDK.decodeProlink || + !context.loadedSDK.createProlinkUrl + ) { + handlers.updateTestStatus(category, 'encodeProlink()', 'skipped', 'Prolink API not available'); + handlers.updateTestStatus(category, 'decodeProlink()', 'skipped', 'Prolink API not available'); + handlers.updateTestStatus( + category, + 'createProlinkUrl()', + 'skipped', + 'Prolink API not available' + ); + return; + } + + // Test encoding + const encoded = await runTest( + { + category, + name: 'encodeProlink()', + requiresSDK: true, + }, + async (ctx) => { + const testRequest = { + method: 'wallet_sendCalls', + params: [ + { + version: '1', + from: '0x0000000000000000000000000000000000000001', + calls: [ + { + to: '0x0000000000000000000000000000000000000002', + data: '0x', + value: '0x0', + }, + ], + chainId: '0x2105', + }, + ], + }; + + const encoded = await ctx.loadedSDK.encodeProlink!(testRequest); + + const details = `Length: ${encoded.length} chars, Method: ${testRequest.method}`; + + return { encoded, details }; + }, + handlers, + context + ); + + if (!encoded) { + return; // Encoding failed, skip remaining tests + } + + // Extract the encoded string from the result + const encodedString = + typeof encoded === 'object' && 'encoded' in encoded ? encoded.encoded : encoded; + + // Test decoding + await runTest( + { + category, + name: 'decodeProlink()', + requiresSDK: true, + }, + async (ctx) => { + const decoded = await ctx.loadedSDK.decodeProlink!(encodedString); + + if (decoded.method === 'wallet_sendCalls') { + const details = `Method: ${decoded.method}, ChainId: ${decoded.chainId || 'N/A'}`; + + return { decoded, details }; + } + + throw new Error('Decoded method mismatch'); + }, + handlers, + context + ); + + // Test URL creation + await runTest( + { + category, + name: 'createProlinkUrl()', + requiresSDK: true, + }, + async (ctx) => { + const url = ctx.loadedSDK.createProlinkUrl!(encodedString); + + if (url.startsWith('https://base.app/base-pay')) { + const details = `URL: ${url.substring(0, 50)}..., Params: ${new URL(url).searchParams.size}`; + + return { url, details }; + } + + throw new Error(`Invalid URL format: ${url}`); + }, + handlers, + context + ); +} diff --git a/examples/testapp/src/pages/e2e-test/tests/provider-events.ts b/examples/testapp/src/pages/e2e-test/tests/provider-events.ts new file mode 100644 index 000000000..d24c72c60 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/provider-events.ts @@ -0,0 +1,95 @@ +/** + * Provider Events Tests + * + * Tests for provider event listeners (accountsChanged, chainChanged, disconnect). + */ + +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test all provider event listeners + */ +export async function testProviderEvents( + handlers: TestHandlers, + context: TestContext +): Promise { + const category = 'Provider Events'; + + if (!context.provider) { + handlers.updateTestStatus( + category, + 'accountsChanged listener', + 'skipped', + 'Provider not available' + ); + handlers.updateTestStatus( + category, + 'chainChanged listener', + 'skipped', + 'Provider not available' + ); + handlers.updateTestStatus(category, 'disconnect listener', 'skipped', 'Provider not available'); + return; + } + + // Test accountsChanged listener + await runTest( + { + category, + name: 'accountsChanged listener', + requiresProvider: true, + }, + async (ctx) => { + let _accountsChangedFired = false; + const accountsChangedHandler = () => { + _accountsChangedFired = true; + }; + + ctx.provider.on('accountsChanged', accountsChangedHandler); + + // Clean up listener + ctx.provider.removeListener('accountsChanged', accountsChangedHandler); + + return true; + }, + handlers, + context + ); + + // Test chainChanged listener + await runTest( + { + category, + name: 'chainChanged listener', + requiresProvider: true, + }, + async (ctx) => { + const chainChangedHandler = () => {}; + ctx.provider.on('chainChanged', chainChangedHandler); + ctx.provider.removeListener('chainChanged', chainChangedHandler); + + return true; + }, + handlers, + context + ); + + // Test disconnect listener + await runTest( + { + category, + name: 'disconnect listener', + requiresProvider: true, + }, + async (ctx) => { + const disconnectHandler = () => {}; + ctx.provider.on('disconnect', disconnectHandler); + ctx.provider.removeListener('disconnect', disconnectHandler); + + return true; + }, + handlers, + context + ); +} diff --git a/examples/testapp/src/pages/e2e-test/tests/sdk-initialization.ts b/examples/testapp/src/pages/e2e-test/tests/sdk-initialization.ts new file mode 100644 index 000000000..0363036b1 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/sdk-initialization.ts @@ -0,0 +1,107 @@ +/** + * SDK Initialization & Exports Tests + * + * Tests that verify the SDK can be properly initialized and all expected + * functions are exported. + */ + +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test SDK initialization and verify all core exports are available + */ +export async function testSDKInitialization( + handlers: TestHandlers, + context: TestContext +): Promise { + const category = 'SDK Initialization & Exports'; + + // Test SDK can be initialized + await runTest( + { + category, + name: 'SDK can be initialized', + requiresSDK: true, + }, + async (ctx) => { + const sdkInstance = ctx.loadedSDK.createBaseAccountSDK({ + appName: 'E2E Test Suite', + appLogoUrl: undefined, + appChainIds: [84532], // Base Sepolia + }); + + // Update provider in context (this is a side effect but necessary for subsequent tests) + const provider = sdkInstance.getProvider(); + + return { sdkInstance, provider }; + }, + handlers, + context + ); + + // Test core exports (these don't need runTest wrapper as they're synchronous checks) + const coreExports = [ + { name: 'createBaseAccountSDK', value: context.loadedSDK.createBaseAccountSDK }, + { name: 'base.pay', value: context.loadedSDK.base?.pay }, + { name: 'base.subscribe', value: context.loadedSDK.base?.subscribe }, + { name: 'base.subscription.getStatus', value: context.loadedSDK.base?.subscription?.getStatus }, + { + name: 'base.subscription.prepareCharge', + value: context.loadedSDK.base?.subscription?.prepareCharge, + }, + { name: 'getPaymentStatus', value: context.loadedSDK.getPaymentStatus }, + { name: 'TOKENS', value: context.loadedSDK.TOKENS }, + { name: 'CHAIN_IDS', value: context.loadedSDK.CHAIN_IDS }, + { name: 'VERSION', value: context.loadedSDK.VERSION }, + ]; + + for (const exp of coreExports) { + handlers.updateTestStatus(category, `${exp.name} is exported`, 'running'); + if (exp.value !== undefined && exp.value !== null) { + handlers.updateTestStatus(category, `${exp.name} is exported`, 'passed'); + } else { + handlers.updateTestStatus( + category, + `${exp.name} is exported`, + 'failed', + `${exp.name} is undefined` + ); + } + } + + // Test optional exports (only available in local SDK, not npm CDN) + const optionalExports = [ + { name: 'encodeProlink', value: context.loadedSDK.encodeProlink }, + { name: 'decodeProlink', value: context.loadedSDK.decodeProlink }, + { name: 'createProlinkUrl', value: context.loadedSDK.createProlinkUrl }, + { + name: 'spendPermission.requestSpendPermission', + value: context.loadedSDK.spendPermission?.requestSpendPermission, + }, + { + name: 'spendPermission.fetchPermissions', + value: context.loadedSDK.spendPermission?.fetchPermissions, + }, + ]; + + for (const exp of optionalExports) { + handlers.updateTestStatus(category, `${exp.name} is exported`, 'running'); + if (exp.value !== undefined && exp.value !== null) { + handlers.updateTestStatus( + category, + `${exp.name} is exported`, + 'passed', + undefined, + 'Available' + ); + } else { + handlers.updateTestStatus( + category, + `${exp.name} is exported`, + 'skipped', + 'Not available (local SDK only)' + ); + } + } +} diff --git a/examples/testapp/src/pages/e2e-test/tests/sign-and-send.ts b/examples/testapp/src/pages/e2e-test/tests/sign-and-send.ts new file mode 100644 index 000000000..6ad3511e6 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/sign-and-send.ts @@ -0,0 +1,175 @@ +/** + * Sign & Send Tests + * + * Tests for signing typed data and sending transactions/calls. + */ + +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test signing typed data with eth_signTypedData_v4 + */ +export async function testSignTypedData( + handlers: TestHandlers, + context: TestContext +): Promise { + return runTest( + { + category: 'Sign & Send', + name: 'eth_signTypedData_v4', + requiresProvider: true, + requiresConnection: true, + requiresUserInteraction: true, + }, + async (ctx) => { + // Get current account and chain ID + const accounts = (await ctx.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + const account = accounts[0]; + + const chainIdHex = (await ctx.provider.request({ + method: 'eth_chainId', + params: [], + })) as string; + const chainIdNum = Number.parseInt(chainIdHex, 16); + + const typedData = { + domain: { + name: 'E2E Test', + version: '1', + chainId: chainIdNum, + }, + types: { + TestMessage: [{ name: 'message', type: 'string' }], + }, + primaryType: 'TestMessage', + message: { + message: 'Hello from E2E tests!', + }, + }; + + const signature = (await ctx.provider.request({ + method: 'eth_signTypedData_v4', + params: [account, JSON.stringify(typedData)], + })) as string; + + return signature; + }, + handlers, + context + ); +} + +/** + * Test sending calls with wallet_sendCalls + */ +export async function testWalletSendCalls( + handlers: TestHandlers, + context: TestContext +): Promise { + return runTest( + { + category: 'Sign & Send', + name: 'wallet_sendCalls', + requiresProvider: true, + requiresConnection: true, + requiresUserInteraction: true, + }, + async (ctx) => { + // Get current account and chain ID + const accounts = (await ctx.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + const account = accounts[0]; + + const chainIdHex = (await ctx.provider.request({ + method: 'eth_chainId', + params: [], + })) as string; + const chainIdNum = Number.parseInt(chainIdHex, 16); + + const result = await ctx.provider.request({ + method: 'wallet_sendCalls', + params: [ + { + version: '2.0.0', + from: account, + chainId: `0x${chainIdNum.toString(16)}`, + calls: [ + { + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + }, + ], + }, + ], + }); + + return result; + }, + handlers, + context + ); +} + +/** + * Test preparing calls with wallet_prepareCalls + */ +export async function testWalletPrepareCalls( + handlers: TestHandlers, + context: TestContext +): Promise { + return runTest( + { + category: 'Sign & Send', + name: 'wallet_prepareCalls', + requiresProvider: true, + requiresConnection: true, + requiresUserInteraction: false, // wallet_prepareCalls doesn't open a popup + }, + async (ctx) => { + // Get current account and chain ID + const accounts = (await ctx.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + const account = accounts[0]; + + const chainIdHex = (await ctx.provider.request({ + method: 'eth_chainId', + params: [], + })) as string; + const chainIdNum = Number.parseInt(chainIdHex, 16); + + const result = await ctx.provider.request({ + method: 'wallet_prepareCalls', + params: [ + { + version: '2.0.0', + from: account, + chainId: `0x${chainIdNum.toString(16)}`, + calls: [ + { + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + }, + ], + }, + ], + }); + + return result; + }, + handlers, + context + ); +} diff --git a/examples/testapp/src/pages/e2e-test/tests/spend-permissions.ts b/examples/testapp/src/pages/e2e-test/tests/spend-permissions.ts new file mode 100644 index 000000000..dbcb02f72 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/spend-permissions.ts @@ -0,0 +1,338 @@ +/** + * Spend Permission Tests + * + * Tests for spend permission functionality including requesting permissions, + * fetching permissions, and preparing spend/revoke call data. + */ + +import { parseUnits } from 'viem'; +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test requesting a spend permission + */ +export async function testRequestSpendPermission( + handlers: TestHandlers, + context: TestContext +): Promise<{ permissionHash: string } | undefined> { + // Check if spendPermission API is available (only works with local SDK, not npm CDN) + if (!context.loadedSDK.spendPermission?.requestSpendPermission) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.requestSpendPermission()', + 'skipped', + 'Spend permission API not available (only works with local SDK)' + ); + return undefined; + } + + return runTest( + { + category: 'Spend Permissions', + name: 'spendPermission.requestSpendPermission()', + requiresProvider: true, + requiresSDK: true, + requiresConnection: true, + requiresUserInteraction: true, + }, + async (ctx) => { + const accounts = (await ctx.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + const account = accounts[0]; + + // Check if TOKENS are available + if (!ctx.loadedSDK.TOKENS?.USDC?.addresses?.baseSepolia) { + throw new Error('TOKENS.USDC not available'); + } + + const permission = await ctx.loadedSDK.spendPermission.requestSpendPermission({ + provider: ctx.provider, + account, + spender: '0x0000000000000000000000000000000000000001', + token: ctx.loadedSDK.TOKENS.USDC.addresses.baseSepolia, + chainId: 84532, + allowance: parseUnits('100', 6), + periodInDays: 30, + }); + + return permission; + }, + handlers, + context + ); +} + +/** + * Test getting permission status + */ +export async function testGetPermissionStatus( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check prerequisites + if (!context.permissionHash) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.getPermissionStatus()', + 'skipped', + 'No permission hash available' + ); + return undefined; + } + + if ( + !context.loadedSDK.spendPermission?.getPermissionStatus || + !context.loadedSDK.spendPermission?.fetchPermission + ) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.getPermissionStatus()', + 'skipped', + 'Spend permission API not available' + ); + return undefined; + } + + return runTest( + { + category: 'Spend Permissions', + name: 'spendPermission.getPermissionStatus()', + requiresSDK: true, + }, + async (ctx) => { + // First fetch the full permission object (which includes chainId) + const permission = await ctx.loadedSDK.spendPermission!.fetchPermission({ + permissionHash: ctx.permissionHash!, + }); + + if (!permission) { + throw new Error('Permission not found'); + } + + // Now get the status using the full permission object + const status = await ctx.loadedSDK.spendPermission!.getPermissionStatus(permission); + + return status; + }, + handlers, + context + ); +} + +/** + * Test fetching a single permission + */ +export async function testFetchPermission( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check prerequisites + if (!context.permissionHash) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.fetchPermission()', + 'skipped', + 'No permission hash available' + ); + return undefined; + } + + if (!context.loadedSDK.spendPermission?.fetchPermission) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.fetchPermission()', + 'skipped', + 'Spend permission API not available' + ); + return undefined; + } + + return runTest( + { + category: 'Spend Permissions', + name: 'spendPermission.fetchPermission()', + requiresSDK: true, + }, + async (ctx) => { + const permission = await ctx.loadedSDK.spendPermission!.fetchPermission({ + permissionHash: ctx.permissionHash!, + }); + + if (permission) { + return permission; + } + + throw new Error('Permission not found'); + }, + handlers, + context + ); +} + +/** + * Test fetching all permissions for an account + */ +export async function testFetchPermissions( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check if spendPermission API is available + if (!context.loadedSDK.spendPermission?.fetchPermissions) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.fetchPermissions()', + 'skipped', + 'Spend permission API not available' + ); + return []; + } + + return ( + runTest( + { + category: 'Spend Permissions', + name: 'spendPermission.fetchPermissions()', + requiresProvider: true, + requiresSDK: true, + requiresConnection: true, + }, + async (ctx) => { + const accounts = (await ctx.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + const account = accounts[0]; + + // fetchPermissions requires a spender parameter - use the same one we used in requestSpendPermission + const permissions = await ctx.loadedSDK.spendPermission!.fetchPermissions({ + provider: ctx.provider, + account, + spender: '0x0000000000000000000000000000000000000001', + chainId: 84532, + }); + + return permissions; + }, + handlers, + context + ) || [] + ); +} + +/** + * Test preparing spend call data + */ +export async function testPrepareSpendCallData( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check prerequisites + if (!context.permissionHash) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.prepareSpendCallData()', + 'skipped', + 'No permission hash available' + ); + return undefined; + } + + if ( + !context.loadedSDK.spendPermission?.prepareSpendCallData || + !context.loadedSDK.spendPermission?.fetchPermission + ) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.prepareSpendCallData()', + 'skipped', + 'Spend permission API not available' + ); + return undefined; + } + + return runTest( + { + category: 'Spend Permissions', + name: 'spendPermission.prepareSpendCallData()', + requiresSDK: true, + }, + async (ctx) => { + const permission = await ctx.loadedSDK.spendPermission!.fetchPermission({ + permissionHash: ctx.permissionHash!, + }); + + if (!permission) { + throw new Error('Permission not found'); + } + + const callData = await ctx.loadedSDK.spendPermission!.prepareSpendCallData( + permission, + parseUnits('10', 6) + ); + + return callData; + }, + handlers, + context + ); +} + +/** + * Test preparing revoke call data + */ +export async function testPrepareRevokeCallData( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check prerequisites + if (!context.permissionHash) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.prepareRevokeCallData()', + 'skipped', + 'No permission hash available' + ); + return undefined; + } + + if ( + !context.loadedSDK.spendPermission?.prepareRevokeCallData || + !context.loadedSDK.spendPermission?.fetchPermission + ) { + handlers.updateTestStatus( + 'Spend Permissions', + 'spendPermission.prepareRevokeCallData()', + 'skipped', + 'Spend permission API not available' + ); + return undefined; + } + + return runTest( + { + category: 'Spend Permissions', + name: 'spendPermission.prepareRevokeCallData()', + requiresSDK: true, + }, + async (ctx) => { + const permission = await ctx.loadedSDK.spendPermission!.fetchPermission({ + permissionHash: ctx.permissionHash!, + }); + + if (!permission) { + throw new Error('Permission not found'); + } + + const callData = await ctx.loadedSDK.spendPermission!.prepareRevokeCallData(permission); + + return callData; + }, + handlers, + context + ); +} diff --git a/examples/testapp/src/pages/e2e-test/tests/sub-account-features.ts b/examples/testapp/src/pages/e2e-test/tests/sub-account-features.ts new file mode 100644 index 000000000..e6a6211d1 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/sub-account-features.ts @@ -0,0 +1,264 @@ +/** + * Sub-Account Features Tests + * + * Tests for sub-account creation, management, and operations including + * creating sub-accounts, retrieving them, and performing operations with them. + */ + +import { http, createPublicClient, toHex } from 'viem'; +import { baseSepolia } from 'viem/chains'; +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test creating a sub-account with wallet_addSubAccount + */ +export async function testCreateSubAccount( + handlers: TestHandlers, + context: TestContext +): Promise<{ address: string } | undefined> { + // Check if getCryptoKeyAccount is available (local SDK only) + if (!context.loadedSDK.getCryptoKeyAccount) { + handlers.updateTestStatus( + 'Sub-Account Features', + 'wallet_addSubAccount', + 'skipped', + 'getCryptoKeyAccount not available (local SDK only)' + ); + return undefined; + } + + return runTest( + { + category: 'Sub-Account Features', + name: 'wallet_addSubAccount', + requiresProvider: true, + requiresSDK: true, + requiresUserInteraction: true, + }, + async (ctx) => { + // Get or create a signer using getCryptoKeyAccount + const { account } = await ctx.loadedSDK.getCryptoKeyAccount!(); + + if (!account) { + throw new Error('Could not get owner account from getCryptoKeyAccount'); + } + + const accountType = account.type as string; + + // Switch to Base Sepolia + await ctx.provider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: '0x14a34' }], // 84532 in hex + }); + + // Prepare keys + const keys = + accountType === 'webAuthn' + ? [{ type: 'webauthn-p256', publicKey: account.publicKey }] + : [{ type: 'address', publicKey: account.address }]; + + // Create sub-account with keys + const response = (await ctx.provider.request({ + method: 'wallet_addSubAccount', + params: [ + { + version: '1', + account: { + type: 'create', + keys, + }, + }, + ], + })) as { address: string }; + + if (!response || !response.address) { + throw new Error('wallet_addSubAccount returned invalid response (no address)'); + } + + return response; + }, + handlers, + context + ); +} + +/** + * Test retrieving sub-accounts with wallet_getSubAccounts + */ +export async function testGetSubAccounts( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check if sub-account address is available + if (!context.subAccountAddress) { + handlers.updateTestStatus( + 'Sub-Account Features', + 'wallet_getSubAccounts', + 'skipped', + 'No sub-account available' + ); + return undefined; + } + + return runTest( + { + category: 'Sub-Account Features', + name: 'wallet_getSubAccounts', + requiresProvider: true, + }, + async (ctx) => { + const accounts = (await ctx.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + if (!accounts || accounts.length < 2) { + throw new Error('No sub-account found in accounts list'); + } + + const response = (await ctx.provider.request({ + method: 'wallet_getSubAccounts', + params: [ + { + account: accounts[1], + domain: window.location.origin, + }, + ], + })) as { subAccounts: Array<{ address: string; factory: string; factoryData: string }> }; + + const subAccounts = response.subAccounts || []; + const addresses = subAccounts.map((sa) => sa.address); + + return { ...response, addresses }; + }, + handlers, + context + ); +} + +/** + * Test signing with a sub-account using personal_sign + */ +export async function testSignWithSubAccount( + handlers: TestHandlers, + context: TestContext +): Promise<{ signature: string; isValid: boolean } | undefined> { + // Check if sub-account address is available + if (!context.subAccountAddress) { + handlers.updateTestStatus( + 'Sub-Account Features', + 'personal_sign (sub-account)', + 'skipped', + 'No sub-account available' + ); + return undefined; + } + + return runTest( + { + category: 'Sub-Account Features', + name: 'personal_sign (sub-account)', + requiresProvider: true, + requiresUserInteraction: false, + }, + async (ctx) => { + const message = 'Hello from sub-account!'; + const signature = (await ctx.provider.request({ + method: 'personal_sign', + params: [toHex(message), ctx.subAccountAddress!], + })) as string; + + // Verify signature + const publicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), + }); + + const isValid = await publicClient.verifyMessage({ + address: ctx.subAccountAddress! as `0x${string}`, + message, + signature: signature as `0x${string}`, + }); + + if (!isValid) { + throw new Error('Signature verification failed'); + } + + return { signature, isValid }; + }, + handlers, + context + ); +} + +/** + * Test sending calls from a sub-account with wallet_sendCalls + */ +export async function testSendCallsFromSubAccount( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check if sub-account address is available + if (!context.subAccountAddress) { + handlers.updateTestStatus( + 'Sub-Account Features', + 'wallet_sendCalls (sub-account)', + 'skipped', + 'No sub-account available' + ); + return undefined; + } + + return runTest( + { + category: 'Sub-Account Features', + name: 'wallet_sendCalls (sub-account)', + requiresProvider: true, + requiresUserInteraction: true, + }, + async (ctx) => { + const result = (await ctx.provider.request({ + method: 'wallet_sendCalls', + params: [ + { + version: '1.0', + chainId: '0x14a34', // Base Sepolia + from: ctx.subAccountAddress!, + calls: [ + { + to: '0x000000000000000000000000000000000000dead', + data: '0x', + value: '0x0', + }, + ], + capabilities: { + paymasterService: { + url: 'https://example.paymaster.com', + }, + }, + }, + ], + })) as string; + + // Validate the result + if (!result) { + throw new Error('wallet_sendCalls returned empty response'); + } + + // Check if the result is an error message instead of a transaction hash + if (typeof result === 'string' && result.toLowerCase().includes('error')) { + throw new Error(result); + } + + // Validate transaction hash format (should start with 0x) + if (typeof result === 'string' && !result.startsWith('0x')) { + throw new Error(`Invalid transaction hash format: ${result}`); + } + + return { txHash: result }; + }, + handlers, + context + ); +} diff --git a/examples/testapp/src/pages/e2e-test/tests/subscription-features.ts b/examples/testapp/src/pages/e2e-test/tests/subscription-features.ts new file mode 100644 index 000000000..3b5bf6b0c --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/subscription-features.ts @@ -0,0 +1,151 @@ +/** + * Subscription Features Tests + * + * Tests for recurring payment functionality via base.subscribe() and + * related subscription management methods. + */ + +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test creating a subscription with base.subscribe() + */ +export async function testSubscribe( + handlers: TestHandlers, + context: TestContext +): Promise<{ id: string } | undefined> { + return runTest( + { + category: 'Subscription Features', + name: 'subscribe() function', + requiresSDK: true, + requiresUserInteraction: true, + }, + async (ctx) => { + const result = await ctx.loadedSDK.base.subscribe({ + recurringCharge: '9.99', + subscriptionOwner: '0x0000000000000000000000000000000000000001', + periodInDays: 30, + requireBalance: false, + testnet: true, + walletUrl: ctx.walletUrl, + }); + + return result; + }, + handlers, + context + ); +} + +/** + * Test checking subscription status with base.subscription.getStatus() + */ +export async function testGetSubscriptionStatus( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check if subscription ID is available + if (!context.subscriptionId) { + handlers.updateTestStatus( + 'Subscription Features', + 'base.subscription.getStatus()', + 'skipped', + 'No subscription ID available' + ); + return undefined; + } + + return runTest( + { + category: 'Subscription Features', + name: 'base.subscription.getStatus()', + requiresSDK: true, + }, + async (ctx) => { + const status = await ctx.loadedSDK.base.subscription.getStatus({ + id: ctx.subscriptionId!, + testnet: true, + }); + + const details = [ + `Active: ${status.isSubscribed}`, + `Recurring: $${status.recurringCharge}`, + status.remainingChargeInPeriod ? `Remaining: $${status.remainingChargeInPeriod}` : null, + status.periodInDays ? `Period: ${status.periodInDays} days` : null, + ] + .filter(Boolean) + .join(', '); + + return { status, details }; + }, + handlers, + context + ); +} + +/** + * Test preparing charge data with base.subscription.prepareCharge() + */ +export async function testPrepareCharge( + handlers: TestHandlers, + context: TestContext +): Promise { + // Check if subscription ID is available + if (!context.subscriptionId) { + handlers.updateTestStatus( + 'Subscription Features', + 'prepareCharge() with amount', + 'skipped', + 'No subscription ID available' + ); + handlers.updateTestStatus( + 'Subscription Features', + 'prepareCharge() max-remaining-charge', + 'skipped', + 'No subscription ID available' + ); + return; + } + + // Test with specific amount + await runTest( + { + category: 'Subscription Features', + name: 'prepareCharge() with amount', + requiresSDK: true, + }, + async (ctx) => { + const chargeCalls = await ctx.loadedSDK.base.subscription.prepareCharge({ + id: ctx.subscriptionId!, + amount: '1.00', + testnet: true, + }); + + return chargeCalls; + }, + handlers, + context + ); + + // Test with max-remaining-charge + await runTest( + { + category: 'Subscription Features', + name: 'prepareCharge() max-remaining-charge', + requiresSDK: true, + }, + async (ctx) => { + const maxChargeCalls = await ctx.loadedSDK.base.subscription.prepareCharge({ + id: ctx.subscriptionId!, + amount: 'max-remaining-charge', + testnet: true, + }); + + return maxChargeCalls; + }, + handlers, + context + ); +} diff --git a/examples/testapp/src/pages/e2e-test/tests/wallet-connection.ts b/examples/testapp/src/pages/e2e-test/tests/wallet-connection.ts new file mode 100644 index 000000000..e1d2e105b --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/tests/wallet-connection.ts @@ -0,0 +1,123 @@ +import type { TestContext, TestHandlers } from '../types'; +import { runTest } from '../utils/test-helpers'; + +/** + * Test wallet connection via eth_requestAccounts + */ +export async function testConnectWallet( + handlers: TestHandlers, + context: TestContext +): Promise { + return runTest( + { + category: 'Wallet Connection', + name: 'Connect wallet', + requiresProvider: true, + requiresUserInteraction: true, + }, + async (ctx) => { + const accounts = (await ctx.provider.request({ + method: 'eth_requestAccounts', + params: [], + })) as string[]; + + if (accounts && accounts.length > 0) { + return accounts; + } + + throw new Error('No accounts returned'); + }, + handlers, + context + ); +} + +/** + * Test retrieving accounts via eth_accounts + */ +export async function testGetAccounts( + handlers: TestHandlers, + context: TestContext +): Promise { + return runTest( + { + category: 'Wallet Connection', + name: 'Get accounts', + requiresProvider: true, + }, + async (ctx) => { + const accounts = (await ctx.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + return accounts; + }, + handlers, + context + ); +} + +/** + * Test retrieving chain ID via eth_chainId + */ +export async function testGetChainId( + handlers: TestHandlers, + context: TestContext +): Promise { + return runTest( + { + category: 'Wallet Connection', + name: 'Get chain ID', + requiresProvider: true, + }, + async (ctx) => { + const chainIdHex = (await ctx.provider.request({ + method: 'eth_chainId', + params: [], + })) as string; + + const chainIdNum = Number.parseInt(chainIdHex, 16); + + return chainIdNum; + }, + handlers, + context + ); +} + +/** + * Test signing a message with personal_sign + */ +export async function testSignMessage( + handlers: TestHandlers, + context: TestContext +): Promise { + return runTest( + { + category: 'Wallet Connection', + name: 'Sign message (personal_sign)', + requiresProvider: true, + requiresConnection: true, + requiresUserInteraction: true, + }, + async (ctx) => { + const accounts = (await ctx.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + const account = accounts[0]; + const message = 'Hello from Base Account SDK E2E Test!'; + + const signature = (await ctx.provider.request({ + method: 'personal_sign', + params: [message, account], + })) as string; + + return signature; + }, + handlers, + context + ); +} diff --git a/examples/testapp/src/pages/e2e-test/types.ts b/examples/testapp/src/pages/e2e-test/types.ts new file mode 100644 index 000000000..922a19a9e --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/types.ts @@ -0,0 +1,288 @@ +/** + * Type definitions for E2E Test Suite + */ + +import type { EIP1193Provider } from 'viem'; + +// ============================================================================ +// Test Status & Results Types +// ============================================================================ + +export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'; + +export interface TestResult { + name: string; + status: TestStatus; + error?: string; + details?: string; + duration?: number; +} + +export interface TestCategory { + name: string; + tests: TestResult[]; + expanded: boolean; +} + +export interface TestResults { + total: number; + passed: number; + failed: number; + skipped: number; +} + +// ============================================================================ +// SDK Types +// ============================================================================ + +export interface BaseAccountSDK { + getProvider: () => EIP1193Provider; +} + +export interface PayOptions { + amount: string; + to: string; + testnet?: boolean; + token?: string; +} + +export interface PayResult { + id: string; + status?: string; +} + +export interface SubscribeOptions { + recurringCharge: string; + subscriptionOwner: string; + periodInDays: number; + testnet?: boolean; + token?: string; +} + +export interface SubscribeResult { + id: string; +} + +export interface PaymentStatus { + status: string; + [key: string]: unknown; +} + +export interface SubscriptionStatus { + isSubscribed: boolean; + recurringCharge: string; + remainingChargeInPeriod?: string; + periodInDays?: number; + nextPeriodStart?: Date; + [key: string]: unknown; +} + +export interface PrepareChargeOptions { + id: string; + amount: string | 'max-remaining-charge'; + testnet?: boolean; +} + +export interface GetPaymentStatusOptions { + id: string; + testnet?: boolean; + maxRetries?: number; + retryDelayMs?: number; +} + +export interface GetSubscriptionStatusOptions { + id: string; + testnet?: boolean; +} + +export interface Call { + to: string; + data: string; + value?: string; +} + +export interface SpendPermission { + account: string; + spender: string; + token: string; + allowance: bigint; + period: number; + start: number; + end: number; + salt: bigint; + extraData: string; + chainId: number; + permissionHash?: string; +} + +export interface RequestSpendPermissionOptions { + provider: EIP1193Provider; + account: string; + spender: string; + token: string; + chainId: number; + allowance: bigint; + periodInDays: number; +} + +export interface RequestSpendPermissionResult { + permissionHash: string; + permission: SpendPermission; +} + +export interface PermissionStatus { + remainingSpend: string; + [key: string]: unknown; +} + +export interface FetchPermissionsOptions { + provider: EIP1193Provider; + account: string; + spender: string; + chainId: number; +} + +// Using a more flexible approach for LoadedSDK to match actual SDK exports +// We use 'any' strategically here because the SDK has complex types that vary +// between local and npm versions. Tests will validate actual behavior. +export interface LoadedSDK { + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + base: any; // Actual type varies, includes pay, subscribe, subscription methods + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + createBaseAccountSDK: (config: SDKConfig) => any; // Returns SDK instance with getProvider + createProlinkUrl?: (encoded: string) => string; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + decodeProlink?: (encoded: string) => Promise; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + encodeProlink?: (request: any) => Promise; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + getCryptoKeyAccount?: () => Promise<{ account: any }>; // Only available in local SDK + VERSION: string; + CHAIN_IDS: Record; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + TOKENS: Record; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + getPaymentStatus: (options: any) => Promise; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + getSubscriptionStatus?: (options: any) => Promise; + spendPermission?: { + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + fetchPermission: (options: { permissionHash: string }) => Promise; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + fetchPermissions: (options: any) => Promise; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + getHash?: (permission: any) => Promise; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + getPermissionStatus: (permission: any) => Promise; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + prepareRevokeCallData: (permission: any) => Promise; + prepareSpendCallData: ( + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + permission: any, + amount: bigint | string, + recipient?: string + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + ) => Promise; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + requestSpendPermission: (options: any) => Promise; + }; +} + +export interface SDKConfig { + appName: string; + appLogoUrl?: string; + appChainIds: number[]; + preference?: { + walletUrl?: string; + // biome-ignore lint/suspicious/noExplicitAny: SDK types vary between local and npm versions + attribution?: any; + telemetry?: boolean; + }; +} + +export interface CryptoKeyAccount { + address: string; + publicKey?: string; + type?: string; +} + +// ============================================================================ +// SDK Loader Types +// ============================================================================ + +export type SDKSource = 'local' | 'npm'; + +export interface SDKLoaderConfig { + source: SDKSource; +} + +// ============================================================================ +// Test Context & Handler Types +// ============================================================================ + +export interface TestContext { + provider: EIP1193Provider; + loadedSDK: LoadedSDK; + connected: boolean; + currentAccount: string | null; + chainId: number | null; + // Shared test data + paymentId: string | null; + subscriptionId: string | null; + permissionHash: string | null; + subAccountAddress: string | null; + // Configuration + skipModal: boolean; + walletUrl?: string; +} + +export interface TestHandlers { + updateTestStatus: ( + category: string, + testName: string, + status: TestStatus, + error?: string, + details?: string, + duration?: number + ) => void; + requestUserInteraction?: (testName: string, skipModal?: boolean) => Promise; +} + +export interface TestConfig { + category: string; + name: string; + requiresProvider?: boolean; + requiresSDK?: boolean; + requiresConnection?: boolean; + requiresUserInteraction?: boolean; +} + +export interface TestFunction { + (context: TestContext): Promise; +} + +// ============================================================================ +// Format Results Types +// ============================================================================ + +export type ResultFormat = 'full' | 'abbreviated' | 'section'; + +export interface FormatOptions { + format: ResultFormat; + categoryName?: string; // For section format + sdkInfo: { + version: string; + source: string; + }; +} + +// ============================================================================ +// Header Props Types +// ============================================================================ + +export interface HeaderProps { + sdkVersion: string; + sdkSource: SDKSource; + onSourceChange: (source: SDKSource) => void; + isLoadingSDK?: boolean; +} diff --git a/examples/testapp/src/pages/e2e-test/utils/format-results.ts b/examples/testapp/src/pages/e2e-test/utils/format-results.ts new file mode 100644 index 000000000..4790372b2 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/utils/format-results.ts @@ -0,0 +1,318 @@ +/** + * Utilities for formatting and copying test results + */ + +import type { FormatOptions, TestCategory, TestResult, TestStatus } from '../types'; + +// ============================================================================ +// Status Utilities +// ============================================================================ + +/** + * Get the emoji icon for a test status + */ +export function getStatusIcon(status: TestStatus): string { + switch (status) { + case 'passed': + return 'โœ…'; + case 'failed': + return 'โŒ'; + case 'running': + return 'โณ'; + case 'skipped': + return 'โŠ˜'; + default: + return 'โธ'; + } +} + +/** + * Get the Chakra UI color for a test status + */ +export function getStatusColor(status: TestStatus): string { + switch (status) { + case 'passed': + return 'green.500'; + case 'failed': + return 'red.500'; + case 'running': + return 'blue.500'; + case 'skipped': + return 'gray.500'; + default: + return 'gray.400'; + } +} + +// ============================================================================ +// Result Formatting Functions +// ============================================================================ + +/** + * Calculate test statistics from categories + */ +function calculateStats(categories: TestCategory[]): { + total: number; + passed: number; + failed: number; + skipped: number; +} { + return categories.reduce( + (acc, cat) => ({ + total: acc.total + cat.tests.length, + passed: acc.passed + cat.tests.filter((t) => t.status === 'passed').length, + failed: acc.failed + cat.tests.filter((t) => t.status === 'failed').length, + skipped: acc.skipped + cat.tests.filter((t) => t.status === 'skipped').length, + }), + { total: 0, passed: 0, failed: 0, skipped: 0 } + ); +} + +/** + * Format header section with SDK info and timestamp + */ +function formatHeader( + title: string, + sdkInfo: { version: string; source: string }, + stats?: { total: number; passed: number; failed: number; skipped: number } +): string { + let header = `=== ${title} ===\n\n`; + header += `SDK Version: ${sdkInfo.version}\n`; + header += `SDK Source: ${sdkInfo.source}\n`; + header += `Timestamp: ${new Date().toISOString()}\n\n`; + + if (stats) { + header += 'Summary:\n'; + header += ` Total: ${stats.total}\n`; + header += ` Passed: ${stats.passed}\n`; + header += ` Failed: ${stats.failed}\n`; + header += ` Skipped: ${stats.skipped}\n\n`; + } + + return header; +} + +/** + * Format a single test result with details + */ +function formatTestResult(test: TestResult): string { + const statusSymbol = getStatusIcon(test.status); + let result = `${statusSymbol} ${test.name}\n`; + result += ` Status: ${test.status.toUpperCase()}\n`; + + if (test.duration) { + result += ` Duration: ${test.duration}ms\n`; + } + + if (test.details) { + result += ` Details: ${test.details}\n`; + } + + if (test.error) { + result += ` ERROR: ${test.error}\n`; + } + + result += '\n'; + return result; +} + +/** + * Format detailed results for all categories or a specific category + */ +function formatDetailedResults(categories: TestCategory[], includeFailureSummary = true): string { + let result = ''; + + // Format each category + categories.forEach((category) => { + if (category.tests.length > 0) { + result += `\n${category.name}\n`; + result += `${'='.repeat(category.name.length)}\n\n`; + + category.tests.forEach((test) => { + result += formatTestResult(test); + }); + } + }); + + // Add failed tests summary if requested + if (includeFailureSummary) { + const failedCategories = categories.filter((cat) => + cat.tests.some((t) => t.status === 'failed') + ); + + if (failedCategories.length > 0) { + result += '\n=== Failed Tests Summary ===\n\n'; + + failedCategories.forEach((category) => { + const failedTests = category.tests.filter((t) => t.status === 'failed'); + if (failedTests.length > 0) { + result += `${category.name}:\n`; + failedTests.forEach((test) => { + result += ` โŒ ${test.name}\n`; + result += ` Reason: ${test.error || 'Unknown error'}\n`; + if (test.details) { + result += ` Details: ${test.details}\n`; + } + }); + result += '\n'; + } + }); + } + } + + return result; +} + +/** + * Format abbreviated results (passed/failed only, with collapsing) + */ +function formatAbbreviatedResults(categories: TestCategory[]): string { + let result = ''; + + categories.forEach((category) => { + // Filter out skipped tests - only show passed and failed + const relevantTests = category.tests.filter( + (t) => t.status === 'passed' || t.status === 'failed' + ); + + if (relevantTests.length > 0) { + // Special handling for SDK Initialization & Exports - collapse exports + if (category.name === 'SDK Initialization & Exports') { + const initTest = relevantTests.find((t) => t.name === 'SDK can be initialized'); + const exportTests = relevantTests.filter((t) => t.name.includes('is exported')); + const otherTests = relevantTests.filter((t) => t !== initTest && !exportTests.includes(t)); + + // Show SDK initialization test (commented out to skip in abbreviated) + // if (initTest) { + // const icon = initTest.status === 'passed' ? ':check:' : ':failure_icon:'; + // result += `${icon} ${initTest.name}\n`; + // } + + // Collapse export tests + if (exportTests.length > 0) { + const anyExportsFailed = exportTests.some((t) => t.status === 'failed'); + + if (anyExportsFailed) { + // Show which exports failed + exportTests.forEach((test) => { + if (test.status === 'failed') { + result += `:failure_icon: ${test.name}\n`; + } + }); + } + // Skip showing "all exports passed" in abbreviated results + } + + // Show any other tests + otherTests.forEach((test) => { + const icon = test.status === 'passed' ? ':check:' : ':failure_icon:'; + result += `${icon} ${test.name}\n`; + }); + } else if (category.name === 'Provider Events') { + // Collapse provider events listeners + const listenerTests = relevantTests.filter((t) => t.name.includes('listener')); + + if (listenerTests.length > 0) { + const anyListenersFailed = listenerTests.some((t) => t.status === 'failed'); + + if (anyListenersFailed) { + // Show which listeners failed + listenerTests.forEach((test) => { + if (test.status === 'failed') { + result += `:failure_icon: ${test.name}\n`; + } + }); + } + // Skip showing "all listeners passed" in abbreviated results + } + } else { + // For other categories, show all tests individually + relevantTests.forEach((test) => { + const icon = test.status === 'passed' ? ':check:' : ':failure_icon:'; + result += `${icon} ${test.name}\n`; + }); + } + } + }); + + return result; +} + +/** + * Main function to format test results based on options + */ +export function formatTestResults(categories: TestCategory[], options: FormatOptions): string { + const { format, categoryName, sdkInfo } = options; + + // Filter categories if section format + const targetCategories = + format === 'section' && categoryName + ? categories.filter((cat) => cat.name === categoryName) + : categories; + + // Calculate stats + const stats = calculateStats(targetCategories); + + // Format based on type + let result = ''; + + if (format === 'abbreviated') { + // No header for abbreviated format + result = formatAbbreviatedResults(targetCategories); + } else if (format === 'section') { + // Section format + const title = categoryName ? `${categoryName} Test Results` : 'E2E Test Results'; + result = formatHeader(title, sdkInfo, stats); + + // For section, show category name again + if (categoryName && targetCategories.length > 0) { + result += `${categoryName}\n`; + result += `${'='.repeat(categoryName.length)}\n\n`; + } + + // Format tests + targetCategories.forEach((category) => { + category.tests.forEach((test) => { + result += formatTestResult(test); + }); + }); + + // Add failed tests summary for section + const failedTests = targetCategories.flatMap((cat) => + cat.tests.filter((t) => t.status === 'failed') + ); + + if (failedTests.length > 0) { + result += '\n=== Failed Tests ===\n\n'; + failedTests.forEach((test) => { + result += ` โŒ ${test.name}\n`; + result += ` Reason: ${test.error || 'Unknown error'}\n`; + if (test.details) { + result += ` Details: ${test.details}\n`; + } + result += '\n'; + }); + } + } else { + // Full format + result = formatHeader('E2E Test Results', sdkInfo, stats); + result += formatDetailedResults(targetCategories, true); + } + + return result; +} + +/** + * Helper to check if there are any test results + */ +export function hasTestResults(categories: TestCategory[]): boolean { + return categories.some((cat) => cat.tests.length > 0); +} + +/** + * Helper to check if a specific category has results + */ +export function categoryHasResults(categories: TestCategory[], categoryName: string): boolean { + const category = categories.find((cat) => cat.name === categoryName); + return category ? category.tests.length > 0 : false; +} diff --git a/examples/testapp/src/pages/e2e-test/utils/index.ts b/examples/testapp/src/pages/e2e-test/utils/index.ts new file mode 100644 index 000000000..086f953d9 --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/utils/index.ts @@ -0,0 +1,13 @@ +/** + * Utility exports for E2E test suite + */ + +export { + runTest, + runTestSequence, + createTestWrapper, + updateTestDetails, + TestCancelledError, + isTestCancelled, + formatTestError, +} from './test-helpers'; diff --git a/examples/testapp/src/pages/e2e-test/utils/test-helpers.ts b/examples/testapp/src/pages/e2e-test/utils/test-helpers.ts new file mode 100644 index 000000000..2a3ea723c --- /dev/null +++ b/examples/testapp/src/pages/e2e-test/utils/test-helpers.ts @@ -0,0 +1,291 @@ +/** + * Test helper utilities for E2E test suite + * + * This module provides the core `runTest` function that wraps test execution + * with consistent error handling, status updates, and logging. + */ + +import type { TestConfig, TestContext, TestFunction, TestHandlers, TestStatus } from '../types'; + +// ============================================================================ +// Constants +// ============================================================================ + +/** + * Default timeout for test execution (30 seconds) + */ +const DEFAULT_TEST_TIMEOUT_MS = 30000; + +// ============================================================================ +// Error Classes +// ============================================================================ + +/** + * Custom error class for test cancellation + */ +export class TestCancelledError extends Error { + constructor() { + super('Test cancelled by user'); + this.name = 'TestCancelledError'; + } +} + +/** + * Custom error class for test timeout + */ +export class TestTimeoutError extends Error { + constructor(testName: string, timeoutMs: number) { + super(`Test "${testName}" timed out after ${timeoutMs}ms`); + this.name = 'TestTimeoutError'; + } +} + +/** + * Check if an error is a test cancellation + */ +export function isTestCancelled(error: unknown): boolean { + return ( + error instanceof TestCancelledError || + (error instanceof Error && error.message === 'Test cancelled by user') + ); +} + +/** + * Format an error for display + */ +export function formatTestError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + try { + return JSON.stringify(error, null, 2); + } catch { + return String(error); + } +} + +/** + * Wrap a promise with a timeout + * + * @param promise - The promise to wrap + * @param timeoutMs - Timeout in milliseconds + * @param testName - Name of the test (for error message) + * @returns Promise that rejects if timeout is reached + */ +async function withTimeout( + promise: Promise, + timeoutMs: number, + testName: string +): Promise { + let timeoutId: NodeJS.Timeout; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new TestTimeoutError(testName, timeoutMs)); + }, timeoutMs); + }); + + try { + const result = await Promise.race([promise, timeoutPromise]); + clearTimeout(timeoutId); + return result; + } catch (error) { + clearTimeout(timeoutId); + throw error; + } +} + +/** + * Validate test prerequisites + * + * Note: Connection status is NOT checked here because it's checked later + * with a live provider query in the main runTest function. This ensures + * we always use the most up-to-date connection status. + */ +function validatePrerequisites(config: TestConfig, context: TestContext): string | null { + const { requiresProvider, requiresSDK } = config; + + if (requiresProvider && !context.provider) { + return 'Provider not available'; + } + + if (requiresSDK && !context.loadedSDK) { + return 'SDK not loaded'; + } + + // Connection check is done later with getCurrentAccount() for accuracy + return null; +} + +/** + * Get current account from provider + */ +async function getCurrentAccount(context: TestContext): Promise { + if (!context.provider) { + return null; + } + + try { + const accounts = (await context.provider.request({ + method: 'eth_accounts', + params: [], + })) as string[]; + + return accounts && accounts.length > 0 ? accounts[0] : null; + } catch { + return null; + } +} + +/** + * Core test runner that wraps test execution with consistent error handling, + * status updates, and logging. + * + * This function eliminates ~500 lines of duplicated try-catch-logging-status code + * across all test functions. + * + * @param config - Test configuration (name, category, requirements) + * @param testFn - The actual test function to execute + * @param handlers - Handlers for status updates and logging + * @param context - Test context (provider, SDK, connection state, etc.) + * @returns The test result or undefined if skipped/failed + * + * @example + * ```typescript + * await runTest( + * { + * category: 'Wallet Connection', + * name: 'Sign message', + * requiresConnection: true, + * requiresUserInteraction: true, + * }, + * async (ctx) => { + * const accounts = await ctx.provider.request({ method: 'eth_accounts', params: [] }); + * const message = 'Hello from E2E test!'; + * return await ctx.provider.request({ + * method: 'personal_sign', + * params: [message, accounts[0]], + * }); + * }, + * handlers, + * context + * ); + * ``` + */ +export async function runTest( + config: TestConfig, + testFn: TestFunction, + handlers: TestHandlers, + context: TestContext +): Promise { + const { category, name, requiresUserInteraction } = config; + const { updateTestStatus, requestUserInteraction } = handlers; + + try { + // Mark test as running + updateTestStatus(category, name, 'running'); + + // Validate prerequisites + const prerequisiteError = validatePrerequisites(config, context); + if (prerequisiteError) { + updateTestStatus(category, name, 'skipped', prerequisiteError); + return undefined; + } + + // Check connection status for connection-required tests + if (config.requiresConnection) { + const account = await getCurrentAccount(context); + if (!account) { + updateTestStatus(category, name, 'skipped', 'Not connected'); + return undefined; + } + } + + // Request user interaction if needed + if (requiresUserInteraction && requestUserInteraction) { + await requestUserInteraction(name, context.skipModal); + } + + // Execute the test with timeout protection + const startTime = Date.now(); + const result = await withTimeout(testFn(context), DEFAULT_TEST_TIMEOUT_MS, name); + const duration = Date.now() - startTime; + + // Mark test as passed + updateTestStatus(category, name, 'passed', undefined, undefined, duration); + + return result; + } catch (error) { + // Handle test timeout + if (error instanceof TestTimeoutError) { + updateTestStatus(category, name, 'failed', error.message); + return undefined; + } + + // Handle test cancellation + if (isTestCancelled(error)) { + updateTestStatus(category, name, 'skipped', 'Cancelled by user'); + throw error; // Re-throw to stop test suite + } + + // Handle other errors + const errorMessage = formatTestError(error); + updateTestStatus(category, name, 'failed', errorMessage); + + return undefined; + } +} + +/** + * Run multiple tests in sequence with delays between them + * + * @param tests - Array of test functions to run + * @param delayMs - Delay in milliseconds between tests (default: 500) + */ +export async function runTestSequence( + tests: Array<() => Promise>, + delayMs = 500 +): Promise { + for (let i = 0; i < tests.length; i++) { + await tests[i](); + + // Add delay between tests (but not after the last one) + if (i < tests.length - 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } +} + +/** + * Create a test function wrapper that provides simplified test context + * + * This is useful for tests that need to be called multiple times or + * need access to shared test data (like paymentId, subscriptionId, etc.) + */ +export function createTestWrapper( + config: TestConfig, + handlers: TestHandlers, + context: TestContext +) { + return async (testFn: TestFunction): Promise => { + return runTest(config, testFn, handlers, context); + }; +} + +/** + * Helper to update test result details after a test has completed + * + * Useful for adding additional information after the test finishes + */ +export function updateTestDetails( + handlers: TestHandlers, + category: string, + name: string, + status: TestStatus, + details: string +): void { + handlers.updateTestStatus(category, name, status, undefined, details); +} diff --git a/examples/testapp/src/pages/import-sub-account/components/AddGlobalOwner.tsx b/examples/testapp/src/pages/import-sub-account/components/AddGlobalOwner.tsx index e0cf78d72..406dc551b 100644 --- a/examples/testapp/src/pages/import-sub-account/components/AddGlobalOwner.tsx +++ b/examples/testapp/src/pages/import-sub-account/components/AddGlobalOwner.tsx @@ -39,16 +39,12 @@ export function AddGlobalOwner({ transport: http(), }); const paymasterClient = createPaymasterClient({ - transport: http( - 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O' - ), + transport: http('https://example.paymaster.com'), }); const bundlerClient = createBundlerClient({ account: subAccount, client: client as Client, - transport: http( - 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O' - ), + transport: http('https://example.paymaster.com'), paymaster: paymasterClient, }); // @ts-ignore diff --git a/examples/testapp/src/pages/import-sub-account/components/DeploySubAccount.tsx b/examples/testapp/src/pages/import-sub-account/components/DeploySubAccount.tsx index 321366751..9d9687b98 100644 --- a/examples/testapp/src/pages/import-sub-account/components/DeploySubAccount.tsx +++ b/examples/testapp/src/pages/import-sub-account/components/DeploySubAccount.tsx @@ -30,16 +30,12 @@ export function DeploySubAccount({ transport: http(), }); const paymasterClient = createPaymasterClient({ - transport: http( - 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O' - ), + transport: http('https://example.paymaster.com'), }); const bundlerClient = createBundlerClient({ account: subAccount, client: client as Client, - transport: http( - 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O' - ), + transport: http('https://example.paymaster.com'), paymaster: paymasterClient, }); diff --git a/examples/testapp/src/pages/import-sub-account/components/SendCalls.tsx b/examples/testapp/src/pages/import-sub-account/components/SendCalls.tsx index 00cf4ee2d..8995989f4 100644 --- a/examples/testapp/src/pages/import-sub-account/components/SendCalls.tsx +++ b/examples/testapp/src/pages/import-sub-account/components/SendCalls.tsx @@ -29,7 +29,7 @@ export function SendCalls({ version: '1', capabilities: { paymasterService: { - url: 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + url: 'https://example.paymaster.com', }, }, }, diff --git a/examples/testapp/src/utils/e2e-test-config/index.ts b/examples/testapp/src/utils/e2e-test-config/index.ts new file mode 100644 index 000000000..a7c27ff89 --- /dev/null +++ b/examples/testapp/src/utils/e2e-test-config/index.ts @@ -0,0 +1,5 @@ +/** + * Configuration exports + */ + +export * from './test-config'; diff --git a/examples/testapp/src/utils/e2e-test-config/test-config.ts b/examples/testapp/src/utils/e2e-test-config/test-config.ts new file mode 100644 index 000000000..c7ae11985 --- /dev/null +++ b/examples/testapp/src/utils/e2e-test-config/test-config.ts @@ -0,0 +1,291 @@ +/** + * Centralized test configuration and constants + * + * This file consolidates all hardcoded values, test addresses, chain configurations, + * and other constants used throughout the E2E test suite. + */ + +// ============================================================================ +// Test Addresses +// ============================================================================ + +export const TEST_ADDRESSES = { + /** + * Zero address - used for various tests + */ + ZERO: '0x0000000000000000000000000000000000000000' as const, + + /** + * Burn address - used for transaction tests + */ + BURN: '0x000000000000000000000000000000000000dead' as const, + + /** + * Generic test recipient address + */ + TEST_RECIPIENT: '0x0000000000000000000000000000000000000001' as const, + + /** + * Alternative test address + */ + TEST_RECIPIENT_2: '0x0000000000000000000000000000000000000002' as const, +} as const; + +// ============================================================================ +// Token Configuration +// ============================================================================ + +export const TOKENS = { + USDC: { + decimals: 6, + testAmount: '100', // Amount in token units + smallTestAmount: '10', // Smaller amount for testing + }, + ETH: { + decimals: 18, + testAmount: '0.01', // Amount in ETH + }, +} as const; + +// ============================================================================ +// Test Timing Configuration +// ============================================================================ + +export const TEST_DELAYS = { + /** + * Delay between individual tests in a sequence (milliseconds) + */ + BETWEEN_TESTS: 500, + + /** + * Delay for payment status polling (milliseconds) + */ + PAYMENT_STATUS_POLLING: 500, + + /** + * Maximum number of retries for payment status checks + */ + PAYMENT_STATUS_MAX_RETRIES: 10, + + /** + * Toast notification durations (milliseconds) + */ + TOAST_SUCCESS_DURATION: 2000, + TOAST_ERROR_DURATION: 3000, + TOAST_WARNING_DURATION: 3000, + TOAST_INFO_DURATION: 5000, +} as const; + +// ============================================================================ +// SDK Configuration +// ============================================================================ + +export const SDK_CONFIG = { + /** + * Default app name for SDK initialization + */ + APP_NAME: 'E2E Test Suite', + + /** + * Default chain IDs for SDK initialization (Base Sepolia) + */ + DEFAULT_CHAIN_IDS: [84532], + + /** + * App logo URL (optional) + */ + APP_LOGO_URL: undefined, +} as const; + +// ============================================================================ +// Test Messages +// ============================================================================ + +export const TEST_MESSAGES = { + /** + * Personal sign test message + */ + PERSONAL_SIGN: 'Hello from Base Account SDK E2E Test!', + + /** + * Sub-account sign test message + */ + SUB_ACCOUNT_SIGN: 'Hello from sub-account!', + + /** + * Typed data test message + */ + TYPED_DATA_MESSAGE: 'Hello from E2E tests!', +} as const; + +// ============================================================================ +// Payment & Subscription Configuration +// ============================================================================ + +export const PAYMENT_CONFIG = { + /** + * Test payment amount + */ + AMOUNT: '0.01', + + /** + * Test subscription recurring charge + */ + SUBSCRIPTION_RECURRING_CHARGE: '9.99', + + /** + * Test subscription specific charge + */ + SUBSCRIPTION_CHARGE_AMOUNT: '1.00', + + /** + * Subscription period in days + */ + SUBSCRIPTION_PERIOD_DAYS: 30, +} as const; + +// ============================================================================ +// Spend Permission Configuration +// ============================================================================ + +export const SPEND_PERMISSION_CONFIG = { + /** + * Test allowance amount (in USDC base units - 6 decimals) + */ + ALLOWANCE: '100', + + /** + * Smaller spend amount for testing (in USDC base units) + */ + SPEND_AMOUNT: '10', + + /** + * Permission period in days + */ + PERIOD_DAYS: 30, +} as const; + +// ============================================================================ +// Wallet Send Calls Configuration +// ============================================================================ + +export const WALLET_SEND_CALLS_CONFIG = { + /** + * Version for wallet_sendCalls + */ + VERSION: '2.0.0', + + /** + * Version for wallet_addSubAccount + */ + SUB_ACCOUNT_VERSION: '1', + + /** + * Simple test call (no value transfer) + */ + SIMPLE_TEST_CALL: { + to: TEST_ADDRESSES.TEST_RECIPIENT, + data: '0x', + value: '0x0', + }, + + /** + * Burn address call for sub-account tests + */ + BURN_ADDRESS_CALL: { + to: TEST_ADDRESSES.BURN, + data: '0x', + value: '0x0', + }, +} as const; + +// ============================================================================ +// Typed Data Configuration +// ============================================================================ + +export const TYPED_DATA_CONFIG = { + /** + * Test typed data domain + */ + DOMAIN: { + name: 'E2E Test', + version: '1', + }, + + /** + * Test typed data types + */ + TYPES: { + TestMessage: [{ name: 'message', type: 'string' }], + }, + + /** + * Primary type + */ + PRIMARY_TYPE: 'TestMessage', +} as const; + +// ============================================================================ +// Test Categories +// ============================================================================ + +export const TEST_CATEGORIES = [ + 'SDK Initialization & Exports', + 'Wallet Connection', + 'Payment Features', + 'Subscription Features', + 'Prolink Features', + 'Spend Permissions', + 'Sub-Account Features', + 'Sign & Send', + 'Provider Events', +] as const; + +export type TestCategoryName = (typeof TEST_CATEGORIES)[number]; + +// ============================================================================ +// Playground Pages Configuration +// ============================================================================ + +export const PLAYGROUND_PAGES = [ + { path: '/', name: 'SDK Playground' }, + { path: '/add-sub-account', name: 'Add Sub-Account' }, + { path: '/import-sub-account', name: 'Import Sub-Account' }, + { path: '/auto-sub-account', name: 'Auto Sub-Account' }, + { path: '/spend-permission', name: 'Spend Permission' }, + { path: '/payment', name: 'Payment' }, + { path: '/pay-playground', name: 'Pay Playground' }, + { path: '/subscribe-playground', name: 'Subscribe Playground' }, + { path: '/prolink-playground', name: 'Prolink Playground' }, +] as const; + +// ============================================================================ +// UI Theme Colors (for Chakra UI) +// ============================================================================ + +export const UI_COLORS = { + STATUS: { + PASSED: 'green.500', + FAILED: 'red.500', + RUNNING: 'blue.500', + SKIPPED: 'gray.500', + PENDING: 'gray.400', + CONNECTED: 'green.500', + DISCONNECTED: 'gray.400', + }, + THEME: { + PRIMARY: 'purple.500', + SECONDARY: 'gray.800', + }, +} as const; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Format chain ID as hex string + */ +export function toHexChainId(chainId: number): `0x${string}` { + return `0x${chainId.toString(16)}` as `0x${string}`; +} diff --git a/examples/testapp/src/utils/sdkLoader.ts b/examples/testapp/src/utils/sdkLoader.ts new file mode 100644 index 000000000..b8361900e --- /dev/null +++ b/examples/testapp/src/utils/sdkLoader.ts @@ -0,0 +1,82 @@ +/** + * Utility to dynamically load SDK from npm or use local workspace version + */ + +import type { LoadedSDK, SDKLoaderConfig, SDKSource } from '../pages/e2e-test/types'; + +// Re-export types for backward compatibility +export type { LoadedSDK, SDKLoaderConfig, SDKSource }; + +/** + * Load SDK from npm package (published version) + */ +async function loadFromNpm(): Promise { + // Dynamic import of npm package (installed as @base-org/account-npm alias) + const mainModule = await import('@base-org/account-npm'); + const spendPermissionModule = await import('@base-org/account-npm/spend-permission'); + + return { + base: mainModule.base, + createBaseAccountSDK: mainModule.createBaseAccountSDK, + createProlinkUrl: mainModule.createProlinkUrl, + decodeProlink: mainModule.decodeProlink, + encodeProlink: mainModule.encodeProlink, + getCryptoKeyAccount: mainModule.getCryptoKeyAccount, // May or may not be available + VERSION: mainModule.VERSION, + CHAIN_IDS: mainModule.CHAIN_IDS, + TOKENS: mainModule.TOKENS, + getPaymentStatus: mainModule.getPaymentStatus, + getSubscriptionStatus: mainModule.getSubscriptionStatus, + spendPermission: { + fetchPermission: spendPermissionModule.fetchPermission, + fetchPermissions: spendPermissionModule.fetchPermissions, + getHash: spendPermissionModule.getHash, + getPermissionStatus: spendPermissionModule.getPermissionStatus, + prepareRevokeCallData: spendPermissionModule.prepareRevokeCallData, + prepareSpendCallData: spendPermissionModule.prepareSpendCallData, + requestSpendPermission: spendPermissionModule.requestSpendPermission, + }, + } as unknown as LoadedSDK; +} + +/** + * Load SDK from local workspace (development version) + */ +async function loadFromLocal(): Promise { + // Dynamic import of local workspace package + const mainModule = await import('@base-org/account'); + const spendPermissionModule = await import('@base-org/account/spend-permission'); + + return { + base: mainModule.base, + createBaseAccountSDK: mainModule.createBaseAccountSDK, + createProlinkUrl: mainModule.createProlinkUrl, + decodeProlink: mainModule.decodeProlink, + encodeProlink: mainModule.encodeProlink, + getCryptoKeyAccount: mainModule.getCryptoKeyAccount, + VERSION: mainModule.VERSION, + CHAIN_IDS: mainModule.CHAIN_IDS, + TOKENS: mainModule.TOKENS, + getPaymentStatus: mainModule.getPaymentStatus, + getSubscriptionStatus: mainModule.getSubscriptionStatus, + spendPermission: { + fetchPermission: spendPermissionModule.fetchPermission, + fetchPermissions: spendPermissionModule.fetchPermissions, + getHash: spendPermissionModule.getHash, + getPermissionStatus: spendPermissionModule.getPermissionStatus, + prepareRevokeCallData: spendPermissionModule.prepareRevokeCallData, + prepareSpendCallData: spendPermissionModule.prepareSpendCallData, + requestSpendPermission: spendPermissionModule.requestSpendPermission, + }, + } as unknown as LoadedSDK; +} + +/** + * Main SDK loader function + */ +export async function loadSDK(config: SDKLoaderConfig): Promise { + if (config.source === 'npm') { + return loadFromNpm(); + } + return loadFromLocal(); +} diff --git a/packages/account-sdk/src/interface/payment/getPaymentStatus.test.ts b/packages/account-sdk/src/interface/payment/getPaymentStatus.test.ts index dae63bbac..e3df373af 100644 --- a/packages/account-sdk/src/interface/payment/getPaymentStatus.test.ts +++ b/packages/account-sdk/src/interface/payment/getPaymentStatus.test.ts @@ -67,7 +67,7 @@ describe('getPaymentStatus', () => { }); expect(fetch).toHaveBeenCalledWith( - 'https://api.developer.coinbase.com/rpc/v1/base/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + 'https://example.paymaster.com', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -212,10 +212,7 @@ describe('getPaymentStatus', () => { testnet: true, }); - expect(fetch).toHaveBeenCalledWith( - 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', - expect.any(Object) - ); + expect(fetch).toHaveBeenCalledWith('https://example.paymaster.com', expect.any(Object)); }); it('should parse user-friendly failure reasons', async () => { @@ -619,10 +616,7 @@ describe('getPaymentStatus', () => { ); // Verify default bundler URL was NOT used - expect(fetch).not.toHaveBeenCalledWith( - 'https://api.developer.coinbase.com/rpc/v1/base/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', - expect.anything() - ); + expect(fetch).not.toHaveBeenCalledWith('https://example.paymaster.com', expect.anything()); }); it('should use custom bundler URL for both receipt and pending checks', async () => { @@ -700,10 +694,7 @@ describe('getPaymentStatus', () => { }); // Verify default testnet bundler URL was used - expect(fetch).toHaveBeenCalledWith( - 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', - expect.anything() - ); + expect(fetch).toHaveBeenCalledWith('https://example.paymaster.com', expect.anything()); }); }); }); diff --git a/packages/account-sdk/src/interface/payment/getPaymentStatus.ts b/packages/account-sdk/src/interface/payment/getPaymentStatus.ts index c2bc100be..bf2009ace 100644 --- a/packages/account-sdk/src/interface/payment/getPaymentStatus.ts +++ b/packages/account-sdk/src/interface/payment/getPaymentStatus.ts @@ -59,10 +59,7 @@ export async function getPaymentStatus(options: PaymentStatusOptions): Promise

{ ], capabilities: { paymasterService: { - url: 'https://api.developer.coinbase.com/rpc/v1/base-sepolia/S-fOd2n2Oi4fl4e1Crm83XeDXZ7tkg8O', + url: 'https://example.paymaster.com', }, }, }); diff --git a/packages/account-sdk/src/interface/payment/subscribe.ts b/packages/account-sdk/src/interface/payment/subscribe.ts index 68e1032c8..0d9a1e80b 100644 --- a/packages/account-sdk/src/interface/payment/subscribe.ts +++ b/packages/account-sdk/src/interface/payment/subscribe.ts @@ -95,7 +95,10 @@ export async function subscribe(options: SubscriptionOptions): Promise