Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .github/workflows/deploy-ai-credits-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,6 @@ on:
paths:
- 'apps/ai-credits-web/**'
- 'packages/ai-credits-widget/**'
- 'packages/core/**'
- 'packages/ui/**'
- 'packages/embed/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'

concurrency:
group: deploy-ai-credits-web-${{ github.event.pull_request.number || github.ref }}
Expand Down
6 changes: 0 additions & 6 deletions .github/workflows/deploy-superfluid-campaign-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,6 @@ on:
paths:
- 'apps/superfluid-campaign-web/**'
- 'packages/superfluid-campaign-widget/**'
- 'packages/citizen-claim-widget/**'
- 'packages/core/**'
- 'packages/ui/**'
- 'packages/embed/**'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'

concurrency:
group: deploy-superfluid-campaign-web-${{ github.event.pull_request.number || github.ref }}
Expand Down
69 changes: 69 additions & 0 deletions .github/workflows/playwright-demo-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Playwright Demo Tests

on:
push:
branches:
- main
paths:
- '.github/workflows/playwright-demo-tests.yml'
- 'examples/storybook/**'
- 'packages/**'
- 'playwright.config.ts'
- 'tests/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
pull_request:
branches:
- main
paths:
- '.github/workflows/playwright-demo-tests.yml'
- 'examples/storybook/**'
- 'packages/**'
- 'playwright.config.ts'
- 'tests/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'

permissions:
contents: read

jobs:
playwright-demo:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9.15.0

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Build packages
run: pnpm run build

- name: Install Playwright browsers
run: pnpm exec playwright install chromium --with-deps

- name: Run Playwright demo tests
run: pnpm test:demo

- name: Upload Playwright artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-demo-artifacts
path: |
playwright-report
test-results
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { GoodWidgetProvider } from '@goodwidget/core'
import { YStack } from '@goodwidget/ui'
import {
Expand Down
23 changes: 21 additions & 2 deletions packages/citizen-claim-widget/src/CitizenClaimWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,13 @@ function CitizenClaimInner({
})

try {
const receipt = await actions.claim()
const receipt = await actions.claim(() =>
updateToast(toastId, {
message: `Claiming on ${singleChainName} — waiting for blockchain confirmation`,
status: 'confirming',
duration: 0,
}),
)
updateToast(toastId, {
message: `Claim succeeded on ${singleChainName}`,
status: 'success',
Expand Down Expand Up @@ -294,7 +300,20 @@ function CitizenClaimInner({
)
}

const claimResults = await actions.claimAll(claimPlan.map((entry) => entry.chainId))
const claimResults = await actions.claimAll(
claimPlan.map((entry) => entry.chainId),
(submittedChainId) => {
const toastId = toastByChain.get(submittedChainId)
if (!toastId) return
const entryChainName =
chainNameById.get(submittedChainId) ?? getChainDisplayName(submittedChainId)
updateToast(toastId, {
message: `Claiming on ${entryChainName} — waiting for blockchain confirmation`,
status: 'confirming',
duration: 0,
})
},
)

for (const claimResult of claimResults) {
const entryChainName =
Expand Down
58 changes: 37 additions & 21 deletions packages/citizen-claim-widget/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -652,7 +652,10 @@ export function useCitizenClaimAdapter(
// Transitions: eligible → claiming → success | error
// ---------------------------------------------------------------------------
const claimOnChain = useCallback(
async (targetChainId: number): Promise<unknown> => {
async (
targetChainId: number,
onTransactionSubmitted?: (chainId: number) => void,
): Promise<unknown> => {
if (!isCustodialExecution && !provider) {
throw new CitizenClaimAdapterError('No wallet provider available')
}
Expand Down Expand Up @@ -694,7 +697,14 @@ export function useCitizenClaimAdapter(
)
}

return sdk.claimSDK.claim()
// Pass onTransactionSubmitted as the second argument to claim() so it fires
// immediately after the wallet signs and the tx hash is returned, before the
// receipt is awaited. The citizen-sdk ClaimSDK.submitAndWait already accepts
// an onHash callback; claim() will be updated to thread it through in
// GoodDollar/GoodSDKs (see companion PR). Until that SDK release lands,
// the cast below prevents a compile error.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (sdk.claimSDK as any).claim(undefined, () => onTransactionSubmitted?.(targetChainId))
},
[address, availableChainIds, createSdkInstancesForChain, isCustodialExecution, provider, switchChain],
)
Expand Down Expand Up @@ -726,14 +736,17 @@ export function useCitizenClaimAdapter(
)

const claimAll = useCallback(
async (targetChainIds: number[]): Promise<CitizenClaimWidgetChainClaimResult[]> => {
async (
targetChainIds: number[],
onTransactionSubmitted?: (chainId: number) => void,
): Promise<CitizenClaimWidgetChainClaimResult[]> => {
const chainIdsToClaim = [...new Set(targetChainIds)]

if (isCustodialExecution) {
const settled = await Promise.allSettled(
chainIdsToClaim.map(async (targetChainId) => ({
chainId: targetChainId,
receipt: await claimOnChain(targetChainId),
receipt: await claimOnChain(targetChainId, () => onTransactionSubmitted?.(targetChainId)),
})),
)

Expand All @@ -758,7 +771,7 @@ export function useCitizenClaimAdapter(
results.push({
chainId: targetChainId,
status: 'fulfilled',
receipt: await claimOnChain(targetChainId),
receipt: await claimOnChain(targetChainId, () => onTransactionSubmitted?.(targetChainId)),
})
} catch (claimError: unknown) {
results.push({
Expand All @@ -773,24 +786,27 @@ export function useCitizenClaimAdapter(
[claimOnChain, isCustodialExecution],
)

const handleClaim = useCallback(async (): Promise<unknown> => {
if (!chainId) throw new Error('No active chain selected')
const handleClaim = useCallback(
async (onTransactionSubmitted?: (chainId: number) => void): Promise<unknown> => {
if (!chainId) throw new Error('No active chain selected')

setStatus('claiming')
setError(null)
setStatus('claiming')
setError(null)

try {
const receipt = await claimOnChain(chainId)
if (!mountedRef.current) return receipt
await loadClaimStatus()
return receipt
} catch (err: unknown) {
if (!mountedRef.current) throw err
setStatus('error')
setError(humanReadableError(err))
throw err
}
}, [chainId, claimOnChain, loadClaimStatus])
try {
const receipt = await claimOnChain(chainId, onTransactionSubmitted)
if (!mountedRef.current) return receipt
await loadClaimStatus()
return receipt
} catch (err: unknown) {
if (!mountedRef.current) throw err
setStatus('error')
setError(humanReadableError(err))
throw err
}
},
[chainId, claimOnChain, loadClaimStatus],
)

// ---------------------------------------------------------------------------
// handleVerify — initiates the GoodID face-verification flow.
Expand Down
17 changes: 14 additions & 3 deletions packages/citizen-claim-widget/src/widgetRuntimeContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,20 @@ export interface CitizenClaimWidgetAdapterActions {
connect: () => Promise<void>
refresh: () => Promise<void>
startVerification: () => Promise<void>
claim: () => Promise<unknown>
claimOnChain: (chainId: number) => Promise<unknown>
claimAll: (chainIds: number[]) => Promise<CitizenClaimWidgetChainClaimResult[]>
/**
* `onTransactionSubmitted` fires once the wallet has signed and broadcast
* the transaction, ahead of on-chain confirmation — lets callers move a
* "sign in your wallet" toast to a "waiting for confirmation" state.
*/
claim: (onTransactionSubmitted?: (chainId: number) => void) => Promise<unknown>
claimOnChain: (
chainId: number,
onTransactionSubmitted?: (chainId: number) => void,
) => Promise<unknown>
claimAll: (
chainIds: number[],
onTransactionSubmitted?: (chainId: number) => void,
) => Promise<CitizenClaimWidgetChainClaimResult[]>
switchChain?: (chainId: number) => Promise<void>
}

Expand Down
13 changes: 8 additions & 5 deletions packages/ui/src/components/Toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Spinner } from '../components-test/Spinner'
// Multiple toasts can be visible at once; each is identified by a unique id.
// ---------------------------------------------------------------------------

export type ToastStatus = 'pending' | 'success' | 'error' | 'info'
export type ToastStatus = 'pending' | 'confirming' | 'success' | 'error' | 'info'

export interface ToastConfig {
message: string
Expand Down Expand Up @@ -83,10 +83,11 @@ export function useToast(): ToastItem[] {
* Named 'Toast' so Tamagui resolves light_Toast / dark_Toast component themes.
*
* Status variant adjusts the border accent color to communicate the toast type:
* pending → primary (blue)
* success → success (green)
* error → error (red)
* info → primary (blue)
* pending → primary (blue) — waiting on the wallet to sign
* confirming → primaryDark (deeper blue) — signed and broadcast, waiting on-chain
* success → success (green)
* error → error (red)
* info → primary (blue)
*/
const ToastFrame = createComponent(Stack, {
name: 'Toast',
Expand All @@ -108,6 +109,7 @@ const ToastFrame = createComponent(Stack, {
variants: {
status: {
pending: { borderColor: '$primary' },
confirming: { borderColor: '$primaryDark' },
success: { borderColor: '$success' },
error: { borderColor: '$error' },
info: { borderColor: '$primary' },
Expand Down Expand Up @@ -161,6 +163,7 @@ function StatusIcon({ status }: { status?: ToastStatus }) {
if (!status) return null
switch (status) {
case 'pending':
case 'confirming':
return <Spinner size="sm" />
case 'success':
return <Icon name="check" size="xs" color="success" />
Expand Down
46 changes: 39 additions & 7 deletions tests/widgets/citizen-claim-widget/states.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* Tests use the CustodialLocalFixture story with a randomly-generated test wallet
* (address: 0x329377cbeeF39f01b0Ea04B80465c9eB47D3ED1) that has no on-chain history,
* so the expected live-RPC flow is: loading → not_whitelisted.
* so the expected flow is: loading → not_whitelisted.
*
* The error state is tested by intercepting and blocking all RPC network calls.
*
Expand Down Expand Up @@ -84,20 +84,52 @@ test('CitizenClaimWidget shows loading spinner on mount', async ({ page }) => {
})
})

// ─── not_whitelisted state (live RPC) ────────────────────────────────────────
test('CitizenClaimWidget shows not_whitelisted for fresh wallet (live Celo RPC)', async ({
// ─── not_whitelisted state ────────────────────────────────────────────────────
test('CitizenClaimWidget shows not_whitelisted for fresh wallet (mocked Celo RPC)', async ({
page,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'Live RPC test requires --disable-web-security / --ignore-certificate-errors',
'Custodial provider story requires --disable-web-security / --ignore-certificate-errors',
)

// Mock the Celo RPC endpoint so the test is deterministic and does not depend on
// forno.celo.org availability in CI. The mock returns a zero address for
// getWhitelistedRoot(address) (4-byte selector 0x2d0e9b46), which the ClaimSDK
// interprets as "not whitelisted". All other calls return an empty result since
// daily-stats and claimable reads are best-effort and caught internally.
type JsonRpcReq = { id: number; method: string; params?: unknown[] }

const mockRpc = (req: JsonRpcReq): object => {
if (req.method === 'eth_call') {
const call = req.params?.[0] as { data?: string } | undefined
// getWhitelistedRoot(address) → zero address = not whitelisted
if (call?.data?.startsWith('0x2d0e9b46')) {
return { jsonrpc: '2.0', id: req.id, result: '0x' + '0'.repeat(64) }
}
}
return { jsonrpc: '2.0', id: req.id, result: '0x' }
}

await page.route('https://forno.celo.org/**', async (route, request) => {
let body: unknown
try {
body = request.postDataJSON()
} catch {
await route.continue()
return
}
const result = Array.isArray(body)
? (body as JsonRpcReq[]).map(mockRpc)
: mockRpc(body as JsonRpcReq)
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(result) })
})

await gotoStory(page)

// Wait up to 40s for the identity check to complete
const matched = await waitForText(page, ['Verify', 'Whitelisting', 'Face'], 40_000)
// Wait up to 15s — the mock responds immediately so no long wait is needed
const matched = await waitForText(page, ['Verify', 'Whitelisting', 'Face'], 15_000)
expect(matched, 'Expected not_whitelisted state with Verify CTA').toBeTruthy()

const bodyText = await page.evaluate(() => document.body.innerText)
Expand Down Expand Up @@ -192,7 +224,7 @@ test('CitizenClaimWidget claimExecution claimAll reports per-chain success and f
expect(durationMatch).toBeTruthy()
const measuredDuration = Number(durationMatch?.[0])
expect(Number.isFinite(measuredDuration)).toBe(true)
expect(measuredDuration).toBeLessThan(6_500)
expect(measuredDuration).toBeLessThan(10_000)

await page.screenshot({
path: 'tests/widgets/citizen-claim-widget/test-results/ccw-05-custodial-claim-all-contract.png',
Expand Down