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 (
<>
-
-
>
);
- }, [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