diff --git a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx index 12520e8..f1a00bd 100644 --- a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx +++ b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx @@ -20,7 +20,7 @@ function StreamingWidgetStoryShell({ dataTestId: string }) { return ( - + ) @@ -45,7 +45,7 @@ function InjectedWalletStory() { if (!usableProvider) { return ( - + No injected wallet found Install/enable MetaMask (or another EIP-1193 wallet) in this browser, then refresh @@ -77,7 +77,7 @@ function CustodialLocalFixtureStory() { ) } catch (error: unknown) { return ( - + Custodial fixture not configured {error instanceof Error @@ -123,3 +123,195 @@ export const CustodialLocalFixture: Story = { export const NoWallet: Story = { render: () => , } + +// --------------------------------------------------------------------------- +// Wrong-chain story — provider reports an unsupported chain (Ethereum mainnet) +// --------------------------------------------------------------------------- +function WrongChainStory() { + const mockProvider = { + on: () => {}, + removeListener: () => {}, + request: async ({ method }: { method: string }) => { + if (method === 'eth_accounts' || method === 'eth_requestAccounts') { + return ['0x1234567890123456789012345678901234567890'] + } + if (method === 'eth_chainId') return '0x1' // Ethereum mainnet (unsupported) + if (method === 'net_version') return '1' + return null + }, + } + + return ( + + ) +} + +/** + * Wallet connected but on an unsupported chain (Ethereum mainnet). + * Shows the "Unsupported network" prompt with chain-switch buttons. + */ +export const WrongChain: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Loading state story — blocks all RPC/subgraph calls to keep widget loading +// --------------------------------------------------------------------------- +function LoadingStateStory() { + // Use custodial provider but block RPC in a separate story via Playwright routing + try { + const provider = createCustodialEip1193Provider() + return ( + + ) + } catch { + return ( + + Custodial fixture not configured + + ) + } +} + +/** + * Widget in loading state — RPC/subgraph calls are routed to hang in Playwright tests. + * Shows loading spinners across all tabs. + */ +export const LoadingState: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Error state story — blocks all RPC calls to force error state +// --------------------------------------------------------------------------- +function ErrorStateStory() { + // Same as loading; Playwright routes handle the actual error forcing + try { + const provider = createCustodialEip1193Provider() + return ( + + ) + } catch { + return ( + + Custodial fixture not configured + + ) + } +} + +/** + * Widget in error state — RPC calls are aborted in Playwright tests. + * Shows error messages and retry buttons. + */ +export const ErrorState: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Pool claim story — custodial fixture on Celo showing pool memberships +// with claimable amounts and claim action. Playwright routes can mock +// claimable balances for deterministic screenshots. +// --------------------------------------------------------------------------- +function PoolClaimStory() { + try { + const provider = createCustodialEip1193Provider() + return ( + + ) + } catch { + return ( + + Custodial fixture not configured + + ) + } +} + +/** + * Pool claim scenario — shows GDA pool memberships with claimable amounts + * and the Claim action button. Navigate to the Pools tab to view. + */ +export const PoolClaim: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Base SUP reserve story — mock provider on Base chain (8453) to show +// the SUP reserve balance section. +// --------------------------------------------------------------------------- +function BaseSupReserveStory() { + const mockProvider = { + on: () => {}, + removeListener: () => {}, + request: async ({ method }: { method: string }) => { + if (method === 'eth_accounts' || method === 'eth_requestAccounts') { + return ['0x1234567890123456789012345678901234567890'] + } + if (method === 'eth_chainId') return '0x2105' // Base mainnet (8453) + if (method === 'net_version') return '8453' + return null + }, + } + + return ( + + ) +} + +/** + * Base chain SUP reserve — wallet connected on Base shows the SUP Reserve + * (Staked) section with reserve balance. Navigate to the Balances tab. + */ +export const BaseSupReserve: Story = { + render: () => , +} + +// --------------------------------------------------------------------------- +// Base SUP balance story — mock provider on Base chain showing Super Token +// balance for SUP on Base. +// --------------------------------------------------------------------------- +function BaseSupBalanceStory() { + const mockProvider = { + on: () => {}, + removeListener: () => {}, + request: async ({ method }: { method: string }) => { + if (method === 'eth_accounts' || method === 'eth_requestAccounts') { + return ['0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'] + } + if (method === 'eth_chainId') return '0x2105' // Base mainnet (8453) + if (method === 'net_version') return '8453' + return null + }, + } + + return ( + + ) +} + +/** + * Base chain SUP balance — wallet connected on Base shows the Super Token + * balance for SUP. Navigate to the Balances tab. + */ +export const BaseSupBalance: Story = { + render: () => , +} diff --git a/packages/streaming-widget/src/StreamingWidget.tsx b/packages/streaming-widget/src/StreamingWidget.tsx index f837009..1f5d237 100644 --- a/packages/streaming-widget/src/StreamingWidget.tsx +++ b/packages/streaming-widget/src/StreamingWidget.tsx @@ -452,16 +452,24 @@ function PoolCard({ pool, connectStatus, connectError, + claimStatus, + claimError, onConnect, onDisconnect, + onClaim, }: { pool: PoolMembershipItem connectStatus: WriteStatus connectError: string | null + claimStatus: WriteStatus + claimError: string | null onConnect: (poolAddress: Address) => void onDisconnect: (poolAddress: Address) => void + onClaim: (poolAddress: Address) => void }) { - const isPending = connectStatus === 'pending' + const isConnectPending = connectStatus === 'pending' + const isClaimPending = claimStatus === 'pending' + const hasClaimable = pool.claimableAmount > 0n return ( @@ -479,23 +487,49 @@ function PoolCard({ {formatUnits(pool.totalAmountClaimed, 18)} + {hasClaimable && ( + + + Claimable + + {formatUnits(pool.claimableAmount, 18)} + + )} + {connectError && ( {connectError} )} + {claimError && ( + + {claimError} + + )} - + {pool.isConnected ? ( - ) : ( - )} + {pool.isConnected && hasClaimable && ( + <> + + + + )} ) @@ -548,8 +582,11 @@ function PoolsTab({ pool={pool} connectStatus={state.poolConnectStatus[pool.poolId] ?? 'idle'} connectError={state.poolConnectError[pool.poolId] ?? null} + claimStatus={state.poolClaimStatus[pool.poolId] ?? 'idle'} + claimError={state.poolClaimError[pool.poolId] ?? null} onConnect={actions.connectToPool} onDisconnect={actions.disconnectFromPool} + onClaim={actions.claimFromPool} /> ))} diff --git a/packages/streaming-widget/src/adapter.ts b/packages/streaming-widget/src/adapter.ts index 313ce59..a970983 100644 --- a/packages/streaming-widget/src/adapter.ts +++ b/packages/streaming-widget/src/adapter.ts @@ -5,7 +5,6 @@ import { createWalletClient, custom, formatUnits, - http, parseUnits, type Chain, } from 'viem' @@ -30,6 +29,30 @@ import type { WriteStatus, } from './widgetRuntimeContract' +// --------------------------------------------------------------------------- +// Minimal GDA Pool ABI for claimAll and getClaimableNow +// --------------------------------------------------------------------------- +const GDA_POOL_ABI = [ + { + name: 'claimAll', + type: 'function', + stateMutability: 'nonpayable', + inputs: [ + { name: 'pool', type: 'address' }, + { name: 'memberAddress', type: 'address' }, + { name: 'userData', type: 'bytes' }, + ], + outputs: [], + }, + { + name: 'getClaimableNow', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'member', type: 'address' }], + outputs: [{ name: 'claimable', type: 'uint256' }], + }, +] as const + // --------------------------------------------------------------------------- // Chain descriptors for Superfluid-supported chains (Celo and Base) // --------------------------------------------------------------------------- @@ -65,27 +88,62 @@ const DEFAULT_FORM_STATE: SetStreamFormState = { function humanReadableError(err: unknown): string { console.error('[StreamingWidget]', err) - if (!(err instanceof Error)) return 'Something went wrong. Please try again.' + if (!(err instanceof Error)) { + console.error('[StreamingWidget] non-Error thrown:', typeof err, err) + return 'Something went wrong. Please try again.' + } const msg = err.message + // Network-level failures if ( msg.includes('Failed to fetch') || msg.includes('fetch failed') || msg.includes('NetworkError') || - msg.includes('net::ERR_') + msg.includes('net::ERR_') || + msg.includes('ECONNREFUSED') || + msg.includes('ECONNRESET') || + msg.includes('ETIMEDOUT') ) { return 'Unable to reach the network. Check your connection and try again.' } + // Timeout if (msg.includes('timeout') || msg.includes('Timeout') || msg.includes('timed out')) { return 'The request timed out. Please try again.' } - if (msg.includes('User rejected') || msg.includes('user rejected') || msg.includes('4001')) { + // User rejected transaction + if ( + msg.includes('User rejected') || + msg.includes('user rejected') || + msg.includes('4001') || + msg.includes('ACTION_REJECTED') + ) { return 'Transaction cancelled by wallet.' } + // Insufficient funds + if (msg.includes('insufficient funds') || msg.includes('InsufficientFunds')) { + return 'Insufficient funds to complete this transaction.' + } + + // Contract revert — extract reason if available + if (msg.includes('reverted') || msg.includes('revert')) { + const reasonMatch = msg.match(/reason:\s*(.+?)(?:\n|$)/) + if (reasonMatch) { + const reason = reasonMatch[1].replace(/[^\x20-\x7E]/g, '').trim().slice(0, 80) + if (reason) return `Transaction failed: ${reason}` + } + return 'Transaction was reverted. Please try again.' + } + + // Unsupported chain + if (msg.includes('unsupported chain') || msg.includes('Unsupported chain')) { + return 'This network is not supported. Please switch to a supported chain.' + } + + // Token not available if (msg.includes('Token address not available')) { return 'This token is not available on the current chain.' } @@ -123,12 +181,13 @@ function toStreamListItem(stream: StreamInfo, address: Address): StreamListItem // --------------------------------------------------------------------------- // Derive PoolMembershipItem from the SDK GDAPool // --------------------------------------------------------------------------- -function toPoolMembershipItem(pool: GDAPool): PoolMembershipItem { +function toPoolMembershipItem(pool: GDAPool, claimableAmount = 0n): PoolMembershipItem { return { poolId: pool.id, poolToken: pool.token, totalUnits: pool.totalUnits, totalAmountClaimed: pool.totalAmountClaimed, + claimableAmount, isConnected: pool.isConnected ?? false, } } @@ -204,6 +263,18 @@ export function useStreamingAdapter({ const [poolConnectStatus, setPoolConnectStatus] = useState>({}) const [poolConnectError, setPoolConnectError] = useState>({}) + // --- pool claim state keyed by pool address --- + const [poolClaimStatus, setPoolClaimStatus] = useState>({}) + const [poolClaimError, setPoolClaimError] = useState>({}) + // Guard against state updates after unmount + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + // Chain validity const isWrongChain = !!chainId && !isSupportedChain(chainId) @@ -217,9 +288,8 @@ export function useStreamingAdapter({ if (!chain) return null const transport = custom(provider as Parameters[0]) - const publicClient = createPublicClient({ chain, transport: http(chain.rpcUrls.default.http[0]) }) + const publicClient = createPublicClient({ chain, transport }) const walletClient = createWalletClient({ chain, transport }) - return { publicClient, walletClient } }, [provider, chainId]) @@ -256,7 +326,6 @@ export function useStreamingAdapter({ // --------------------------------------------------------------------------- const fetchStreams = useCallback(async () => { if (!streamingSDK || !address) return - setStreamsLoading(true) setStreamsError(null) try { @@ -264,47 +333,68 @@ export function useStreamingAdapter({ account: address as Address, direction: 'all', }) + if (!mountedRef.current) return setStreams(result.map((s) => toStreamListItem(s, address as Address))) } catch (err) { + if (!mountedRef.current) return setStreamsError(humanReadableError(err)) } finally { - setStreamsLoading(false) + if (mountedRef.current) setStreamsLoading(false) } }, [streamingSDK, address]) // --------------------------------------------------------------------------- - // Fetch pool memberships + // Fetch pool memberships with claimable amounts // --------------------------------------------------------------------------- const fetchPools = useCallback(async () => { - if (!gdaSDK || !address) return - + if (!gdaSDK || !address || !viemClients) return setPoolsLoading(true) setPoolsError(null) try { const result = await gdaSDK.getDistributionPools(address as Address) - setPools(result.map(toPoolMembershipItem)) + if (!mountedRef.current) return + + // Fetch claimable amounts for each pool in parallel + const poolsWithClaimable = await Promise.all( + result.map(async (pool) => { + try { + const claimable = await viemClients.publicClient.readContract({ + address: pool.id, + abi: GDA_POOL_ABI, + functionName: 'getClaimableNow', + args: [address as Address], + }) + return toPoolMembershipItem(pool, claimable) + } catch { + return toPoolMembershipItem(pool, 0n) + } + }), + ) + setPools(poolsWithClaimable) } catch (err) { + if (!mountedRef.current) return setPoolsError(humanReadableError(err)) } finally { - setPoolsLoading(false) + if (mountedRef.current) setPoolsLoading(false) } - }, [gdaSDK, address]) + }, [gdaSDK, address, viemClients]) // --------------------------------------------------------------------------- // Fetch Super Token balance // --------------------------------------------------------------------------- const fetchBalance = useCallback(async () => { if (!streamingSDK || !address) return - setBalanceLoading(true) setBalanceError(null) try { const rawBalance = await streamingSDK.getSuperTokenBalance(address as Address) + if (!mountedRef.current) return setSuperTokenBalance(formatUnits(rawBalance, 18)) } catch (err) { + if (!mountedRef.current) return setBalanceError(humanReadableError(err)) } finally { - setBalanceLoading(false) + if (mountedRef.current) setBalanceLoading(false) } }, [streamingSDK, address]) @@ -313,20 +403,21 @@ export function useStreamingAdapter({ // --------------------------------------------------------------------------- const fetchSupReserve = useCallback(async () => { if (!subgraphClient || !address || chainId !== SupportedChains.BASE) { - setSupReserveBalance(null) + if (mountedRef.current) setSupReserveBalance(null) return } - setSupReserveLoading(true) setSupReserveError(null) try { const lockers = await subgraphClient.querySUPReserves(address as Address) const total = lockers.reduce((sum, l) => sum + l.stakedBalance, 0n) + if (!mountedRef.current) return setSupReserveBalance(formatUnits(total, 18)) } catch (err) { + if (!mountedRef.current) return setSupReserveError(humanReadableError(err)) } finally { - setSupReserveLoading(false) + if (mountedRef.current) setSupReserveLoading(false) } }, [subgraphClient, address, chainId]) @@ -417,7 +508,14 @@ export function useStreamingAdapter({ setPoolConnectError((prev) => ({ ...prev, [poolAddress]: null })) try { - await gdaSDK.connectToPool({ poolAddress }) + await gdaSDK.connectToPool({ + poolAddress, + onHash: () => { + if (mountedRef.current) { + setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'success' })) + } + }, + }) setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'success' })) void fetchPools() } catch (err) { @@ -439,7 +537,14 @@ export function useStreamingAdapter({ setPoolConnectError((prev) => ({ ...prev, [poolAddress]: null })) try { - await gdaSDK.disconnectFromPool({ poolAddress }) + await gdaSDK.disconnectFromPool({ + poolAddress, + onHash: () => { + if (mountedRef.current) { + setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'success' })) + } + }, + }) setPoolConnectStatus((prev) => ({ ...prev, [poolAddress]: 'success' })) void fetchPools() } catch (err) { @@ -453,6 +558,39 @@ export function useStreamingAdapter({ [gdaSDK, fetchPools], ) + // --------------------------------------------------------------------------- + // Claim from pool — uses viem writeContract directly since GdaSDK lacks this + // --------------------------------------------------------------------------- + const claimFromPool = useCallback( + async (poolAddress: Address) => { + if (!viemClients || !address) return + + setPoolClaimStatus((prev) => ({ ...prev, [poolAddress]: 'pending' })) + setPoolClaimError((prev) => ({ ...prev, [poolAddress]: null })) + + try { + await viemClients.walletClient.writeContract({ + account: address as Address, + address: poolAddress, + abi: GDA_POOL_ABI, + functionName: 'claimAll', + args: [poolAddress, address as Address, '0x'], + }) + if (!mountedRef.current) return + setPoolClaimStatus((prev) => ({ ...prev, [poolAddress]: 'success' })) + void fetchPools() + } catch (err) { + if (!mountedRef.current) return + setPoolClaimStatus((prev) => ({ ...prev, [poolAddress]: 'error' })) + setPoolClaimError((prev) => ({ + ...prev, + [poolAddress]: humanReadableError(err), + })) + } + }, + [viemClients, address, fetchPools], + ) + // --------------------------------------------------------------------------- // Chain switch via EIP-1193 // --------------------------------------------------------------------------- @@ -495,6 +633,8 @@ export function useStreamingAdapter({ setStreamTxHash, poolConnectStatus, poolConnectError, + poolClaimStatus, + poolClaimError, }), [ isConnected, @@ -519,6 +659,8 @@ export function useStreamingAdapter({ setStreamTxHash, poolConnectStatus, poolConnectError, + poolClaimStatus, + poolClaimError, ], ) @@ -534,6 +676,7 @@ export function useStreamingAdapter({ resetSetStream, connectToPool, disconnectFromPool, + claimFromPool, }), [ connect, @@ -546,6 +689,7 @@ export function useStreamingAdapter({ resetSetStream, connectToPool, disconnectFromPool, + claimFromPool, ], ) diff --git a/packages/streaming-widget/src/widgetRuntimeContract.ts b/packages/streaming-widget/src/widgetRuntimeContract.ts index 9f1900c..a98699f 100644 --- a/packages/streaming-widget/src/widgetRuntimeContract.ts +++ b/packages/streaming-widget/src/widgetRuntimeContract.ts @@ -63,6 +63,8 @@ export interface PoolMembershipItem { poolToken: Address totalUnits: bigint totalAmountClaimed: bigint + /** Amount currently claimable by this member (wei) */ + claimableAmount: bigint /** Whether this account has actively connected to the pool distribution */ isConnected: boolean } @@ -121,6 +123,10 @@ export interface StreamingWidgetAdapterState { /** Pool connect/disconnect write status keyed by pool address */ poolConnectStatus: Record poolConnectError: Record + + /** Pool claim write status keyed by pool address */ + poolClaimStatus: Record + poolClaimError: Record } // --------------------------------------------------------------------------- @@ -144,6 +150,8 @@ export interface StreamingWidgetAdapterActions { connectToPool: (poolAddress: Address) => Promise /** Disconnect wallet from a GDA pool */ disconnectFromPool: (poolAddress: Address) => Promise + /** Claim all available tokens from a GDA pool */ + claimFromPool: (poolAddress: Address) => Promise } export interface StreamingWidgetAdapterResult { diff --git a/tests/widgets/streaming-widget/states.spec.ts b/tests/widgets/streaming-widget/states.spec.ts index ea576b5..0c71808 100644 --- a/tests/widgets/streaming-widget/states.spec.ts +++ b/tests/widgets/streaming-widget/states.spec.ts @@ -8,6 +8,11 @@ * Story URLs: * /iframe.html?id=widgets-streamingwidget--no-wallet&viewMode=story * /iframe.html?id=widgets-streamingwidget--custodial-local-fixture&viewMode=story + * /iframe.html?id=widgets-streamingwidget--wrong-chain&viewMode=story + * /iframe.html?id=widgets-streamingwidget--error-state&viewMode=story + * /iframe.html?id=widgets-streamingwidget--pool-claim&viewMode=story + * /iframe.html?id=widgets-streamingwidget--base-sup-reserve&viewMode=story + * /iframe.html?id=widgets-streamingwidget--base-sup-balance&viewMode=story * * Browser flags (set globally in playwright.config.ts): * --disable-web-security : allows viem fetch calls from localhost to external HTTPS RPC @@ -29,12 +34,42 @@ const NO_WALLET_STORY_URL = const CUSTODIAL_STORY_URL = '/iframe.html?id=widgets-streamingwidget--custodial-local-fixture&viewMode=story' +const WRONG_CHAIN_STORY_URL = + '/iframe.html?id=widgets-streamingwidget--wrong-chain&viewMode=story' + +const ERROR_STATE_STORY_URL = + '/iframe.html?id=widgets-streamingwidget--error-state&viewMode=story' + +const POOL_CLAIM_STORY_URL = + '/iframe.html?id=widgets-streamingwidget--pool-claim&viewMode=story' + +const BASE_SUP_RESERVE_STORY_URL = + '/iframe.html?id=widgets-streamingwidget--base-sup-reserve&viewMode=story' + +const BASE_SUP_BALANCE_STORY_URL = + '/iframe.html?id=widgets-streamingwidget--base-sup-balance&viewMode=story' + /** Navigate directly to the story iframe (bypasses Storybook shell for speed). */ async function gotoStory(page: Page, url: string): Promise { await page.goto(url) await page.waitForLoadState('domcontentloaded') } +/** Poll the page until all of the given text patterns appear in the body. */ +async function waitForAllText( + page: Page, + patterns: string[], + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const text = await page.evaluate(() => document.body.innerText) + if (patterns.every((p) => text.includes(p))) return true + await page.waitForTimeout(500) + } + return false +} + /** Poll the page until any of the given text patterns appears in the body. */ async function waitForText( page: Page, @@ -61,7 +96,8 @@ test('StreamingWidget shows connect-wallet prompt when no provider is given', as expect(matched, 'Expected connect-wallet prompt').toBeTruthy() const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toMatch(/Connect Wallet|not connected/i) + expect(bodyText).toMatch(/Connect Wallet/i) + expect(bodyText).toMatch(/not connected/i) await page.screenshot({ path: 'tests/widgets/streaming-widget/test-results/sw-01-no-wallet.png', @@ -73,13 +109,8 @@ test('StreamingWidget shows connect-wallet prompt when no provider is given', as test('StreamingWidget renders Streams, Pools, Balances tabs', async ({ page }) => { await gotoStory(page, NO_WALLET_STORY_URL) - const matched = await waitForText(page, ['Streams', 'Pools', 'Balances'], 20_000) - expect(matched, 'Tab bar must render').toBeTruthy() - - const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toContain('Streams') - expect(bodyText).toContain('Pools') - expect(bodyText).toContain('Balances') + const allTabsVisible = await waitForAllText(page, ['Streams', 'Pools', 'Balances'], 20_000) + expect(allTabsVisible, 'All three tabs must render').toBeTruthy() await page.screenshot({ path: 'tests/widgets/streaming-widget/test-results/sw-02-tabs-visible.png', @@ -87,41 +118,28 @@ test('StreamingWidget renders Streams, Pools, Balances tabs', async ({ page }) = }) }) -// ─── wrong-chain state ──────────────────────────────────────────────────────── -test('StreamingWidget shows wrong-chain prompt when wallet is on unsupported chain', async ({ +// ─── wrong-chain state (dedicated story) ────────────────────────────────────── +test('StreamingWidget wrong-chain story — shows unsupported network prompt', async ({ page, }) => { - // Inject a minimal EIP-1193 provider that reports an unsupported chain (chain 1 = Ethereum mainnet) - await gotoStory(page, NO_WALLET_STORY_URL) - - // Evaluate a mock provider in the page context to simulate wrong chain - await page.evaluate(() => { - const mockProvider = { - on: () => {}, - removeListener: () => {}, - request: async ({ method }: { method: string }) => { - if (method === 'eth_accounts' || method === 'eth_requestAccounts') - return ['0x1234567890123456789012345678901234567890'] - if (method === 'eth_chainId') return '0x1' // Ethereum mainnet (unsupported) - if (method === 'net_version') return '1' - return null - }, - } - ;(window as unknown as Record).__mockProvider = mockProvider - }) + await gotoStory(page, WRONG_CHAIN_STORY_URL) - // The story renders with no provider so we can't easily inject — just verify tab bar renders - const matched = await waitForText(page, ['Streams', 'Pools', 'Balances'], 15_000) - expect(matched).toBeTruthy() + // Should show the wrong-chain prompt with chain switch options + const allVisible = await waitForAllText( + page, + ['Unsupported network', 'Switch to Celo', 'Switch to Base'], + 20_000, + ) + expect(allVisible, 'Expected wrong-chain prompt with chain-switch buttons').toBeTruthy() await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-03-no-wallet-tabs.png', + path: 'tests/widgets/streaming-widget/test-results/sw-03-wrong-chain.png', fullPage: true, }) }) -// ─── custodial: loading + empty states ──────────────────────────────────────── -test('StreamingWidget custodial fixture — Streams tab shows loading then empty state', async ({ +// ─── custodial: loading state ───────────────────────────────────────────────── +test('StreamingWidget custodial fixture — Streams tab shows loading spinner', async ({ page, browserName, }) => { @@ -142,9 +160,13 @@ test('StreamingWidget custodial fixture — Streams tab shows loading then empty await gotoStory(page, CUSTODIAL_STORY_URL) // Tab bar should render first - const tabsVisible = await waitForText(page, ['Streams', 'Pools', 'Balances'], 30_000) + const tabsVisible = await waitForAllText(page, ['Streams', 'Pools', 'Balances'], 30_000) expect(tabsVisible).toBeTruthy() + // Loading spinner text should appear + const loadingVisible = await waitForText(page, ['Loading'], 10_000) + expect(loadingVisible, 'Expected loading spinner text').toBeTruthy() + await page.screenshot({ path: 'tests/widgets/streaming-widget/test-results/sw-04-loading.png', fullPage: true, @@ -169,8 +191,12 @@ test('StreamingWidget custodial fixture — shows error state when RPC is blocke await gotoStory(page, CUSTODIAL_STORY_URL) // After RPCs abort, adapter should surface error state with Retry button - const matched = await waitForText(page, ['Retry', 'error', 'reach'], 25_000) - expect(matched, 'Expected error state after RPC abort').toBeTruthy() + const allVisible = await waitForAllText( + page, + ['Retry', 'Unable to reach'], + 25_000, + ) + expect(allVisible, 'Expected error state with Retry button after RPC abort').toBeTruthy() await page.screenshot({ path: 'tests/widgets/streaming-widget/test-results/sw-05-error.png', @@ -195,7 +221,7 @@ test('StreamingWidget custodial fixture — clicking Pools tab changes view', as await gotoStory(page, CUSTODIAL_STORY_URL) // Wait for tab bar to render - await waitForText(page, ['Streams', 'Pools', 'Balances'], 20_000) + await waitForAllText(page, ['Streams', 'Pools', 'Balances'], 20_000) // Click the Pools tab const poolsTab = page.getByText('Pools').first() @@ -209,9 +235,15 @@ test('StreamingWidget custodial fixture — clicking Pools tab changes view', as fullPage: true, }) - // Verify the tab content changed (either loading, error, or empty state for pools) + // Verify Pools tab content is visible — either loading, error, or empty state const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toContain('Pools') + // Should show pool-related content: loading message, error, or empty state + const hasPoolsContent = + bodyText.includes('Loading pool') || + bodyText.includes('No GDA pool') || + bodyText.includes('Retry') || + bodyText.includes('Something went wrong') + expect(hasPoolsContent, 'Pools tab should show loading/empty/error state').toBeTruthy() }) // ─── custodial: balances tab navigation ────────────────────────────────────── @@ -230,7 +262,7 @@ test('StreamingWidget custodial fixture — clicking Balances tab shows balance await gotoStory(page, CUSTODIAL_STORY_URL) - await waitForText(page, ['Streams', 'Pools', 'Balances'], 20_000) + await waitForAllText(page, ['Streams', 'Pools', 'Balances'], 20_000) // Click Balances tab const balancesTab = page.getByText('Balances').first() @@ -246,7 +278,9 @@ test('StreamingWidget custodial fixture — clicking Balances tab shows balance // Balances tab shows Super Token Balance section and the SUP reserve disabled notice for non-Base const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toMatch(/Balance|Refresh|Super Token/i) + expect(bodyText).toMatch(/Super Token Balance/i) + // On Celo, SUP reserve should show disabled state + expect(bodyText).toMatch(/only available on Base|SUP Reserve/i) }) // ─── custodial: create-stream form toggle ──────────────────────────────────── @@ -273,13 +307,201 @@ test('StreamingWidget custodial fixture — New Stream button toggles form', asy await newStreamBtn.click() // Form should appear with recipient and amount fields - await waitForText(page, ['Recipient address', 'Amount', 'Set Stream'], 5_000) + const formVisible = await waitForAllText( + page, + ['Create Stream', 'Recipient address', 'Amount', 'Set Stream'], + 5_000, + ) + expect(formVisible, 'Create stream form should render with all fields').toBeTruthy() + + await page.screenshot({ + path: 'tests/widgets/streaming-widget/test-results/sw-08-create-stream-form.png', + fullPage: true, + }) +}) + +// ─── error state via RPC abort (dedicated story) ───────────────────────────── +test('StreamingWidget error state — shows error with retry after RPC failure', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', + ) + // Block all RPC and subgraph endpoints to force error state + await page.route('https://forno.celo.org/**', (route) => route.abort()) + await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) + await page.route('https://gateway-arbitrum.network.thegraph.com/**', (route) => route.abort()) + + await gotoStory(page, ERROR_STATE_STORY_URL) + + // After RPCs abort, adapter should surface error state with Retry button + const allVisible = await waitForAllText( + page, + ['Retry', 'Unable to reach'], + 25_000, + ) + expect(allVisible, 'Expected error state with Retry button after RPC abort').toBeTruthy() + + await page.screenshot({ + path: 'tests/widgets/streaming-widget/test-results/sw-09-error-state.png', + fullPage: true, + }) +}) + +// ─── pool connect/disconnect state ────────────────────────────────────────── +test('StreamingWidget custodial fixture — Pools tab shows connect/disconnect and claim actions', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', + ) + + // Block external calls for determinism + await page.route('https://forno.celo.org/**', (route) => route.abort()) + await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) + + await gotoStory(page, POOL_CLAIM_STORY_URL) + + // Wait for tab bar to render + await waitForAllText(page, ['Streams', 'Pools', 'Balances'], 20_000) + + // Click the Pools tab + const poolsTab = page.getByText('Pools').first() + await expect(poolsTab).toBeVisible() + await poolsTab.click() + + await page.waitForTimeout(500) + + await page.screenshot({ + path: 'tests/widgets/streaming-widget/test-results/sw-10-pools-connect-disconnect.png', + fullPage: true, + }) + + // Pools tab should show pool-related content const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toMatch(/Recipient|Amount|Set Stream/i) + const hasPoolsContent = + bodyText.includes('Loading pool') || + bodyText.includes('No GDA pool') || + bodyText.includes('Connect') || + bodyText.includes('Disconnect') || + bodyText.includes('Claim') || + bodyText.includes('Claimable') || + bodyText.includes('Retry') + expect(hasPoolsContent, 'Pools tab should show pool content with connect/disconnect/claim').toBeTruthy() +}) + +// ─── SUP reserve visibility by chain (non-Base) ───────────────────────────── +test('StreamingWidget custodial fixture — SUP reserve disabled on non-Base chain', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', + ) + + // Block external calls + await page.route('https://forno.celo.org/**', (route) => route.abort()) + await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) + + await gotoStory(page, CUSTODIAL_STORY_URL) + + await waitForAllText(page, ['Streams', 'Pools', 'Balances'], 20_000) + + // Click Balances tab + const balancesTab = page.getByText('Balances').first() + await expect(balancesTab).toBeVisible() + await balancesTab.click() + + await page.waitForTimeout(500) + + // On Celo (custodial fixture default), SUP reserve should show disabled state + const bodyText = await page.evaluate(() => document.body.innerText) + expect(bodyText).toMatch(/Super Token Balance/i) + + // SUP reserve must mention that it is only available on Base + expect(bodyText).toMatch(/only available on Base/i) await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-08-create-stream-form.png', + path: 'tests/widgets/streaming-widget/test-results/sw-11-sup-reserve-non-base.png', + fullPage: true, + }) +}) + +// ─── Base SUP reserve ─────────────────────────────────────────────────────── +test('StreamingWidget Base story — SUP reserve section visible on Base chain', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', + ) + + // Block RPC calls so we can see the loading/structure without real data + await page.route('https://mainnet.base.org/**', (route) => route.abort()) + await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) + + await gotoStory(page, BASE_SUP_RESERVE_STORY_URL) + + // Wait for tab bar + await waitForAllText(page, ['Streams', 'Pools', 'Balances'], 20_000) + + // Click Balances tab + const balancesTab = page.getByText('Balances').first() + await expect(balancesTab).toBeVisible() + await balancesTab.click() + + await page.waitForTimeout(500) + + // On Base, SUP Reserve section should be visible (not the disabled message) + const bodyText = await page.evaluate(() => document.body.innerText) + expect(bodyText).toMatch(/SUP Reserve|Staked/i) + // Should NOT show the "only available on Base" disabled message + expect(bodyText).not.toMatch(/only available on Base/i) + + await page.screenshot({ + path: 'tests/widgets/streaming-widget/test-results/sw-12-base-sup-reserve.png', + fullPage: true, + }) +}) + +// ─── Base SUP balance ─────────────────────────────────────────────────────── +test('StreamingWidget Base story — Super Token balance shows SUP on Base', async ({ + page, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', + ) + + // Block RPC calls + await page.route('https://mainnet.base.org/**', (route) => route.abort()) + await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) + + await gotoStory(page, BASE_SUP_BALANCE_STORY_URL) + + // Wait for tab bar + await waitForAllText(page, ['Streams', 'Pools', 'Balances'], 20_000) + + // Click Balances tab + const balancesTab = page.getByText('Balances').first() + await expect(balancesTab).toBeVisible() + await balancesTab.click() + + await page.waitForTimeout(500) + + const bodyText = await page.evaluate(() => document.body.innerText) + expect(bodyText).toMatch(/Super Token Balance/i) + + await page.screenshot({ + path: 'tests/widgets/streaming-widget/test-results/sw-13-base-sup-balance.png', fullPage: true, }) })