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: 4 additions & 1 deletion miniapps/forge/.storybook/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { beforeAll } from 'vitest'
import { beforeAll, vi } from 'vitest'
import { setProjectAnnotations } from '@storybook/react-vite'
import * as previewAnnotations from './preview'

// Mock environment variable for Storybook tests
vi.stubEnv('VITE_COT_API_BASE_URL', 'http://mock-api.local')

const annotations = setProjectAnnotations([previewAnnotations])

beforeAll(annotations.beforeAll)
1 change: 1 addition & 0 deletions miniapps/forge/e2e/helpers/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const UI_TEXT = {
connect: {
button: { source: '连接钱包', pattern: /连接钱包|Connect Wallet/i },
loading: { source: '连接中', pattern: /连接中|Connecting/i },
configError: { source: 'Network Error', pattern: /Network Error|Failed to load config/i },
},
swap: {
pay: { source: '支付', pattern: /支付|Pay/i },
Expand Down
106 changes: 106 additions & 0 deletions miniapps/forge/e2e/ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,4 +246,110 @@ test.describe('Forge UI', () => {
await expect(page.locator('button:has-text("BNB")').first()).toBeVisible({ timeout: 5000 })
}
})

// ============ 边界场景测试 ============

test('10 - API failure - config unavailable', async ({ page }) => {
// Override API mock to throw network error
await page.addInitScript(`
const originalFetch = window.fetch
window.fetch = async (url, options) => {
const urlStr = typeof url === 'string' ? url : url.toString()
if (urlStr.includes('/cot/recharge/support') || urlStr.includes('/recharge/support')) {
throw new Error('Network Error')
}
return originalFetch(url, options)
}
`)
await page.addInitScript(mockBioSDK)
await page.goto('/')
await page.waitForLoadState('networkidle')

// Connect button should be visible
const connectBtn = page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first()
await expect(connectBtn).toBeVisible({ timeout: 10000 })

// Error message should be visible (config load failed)
await expect(page.locator(`text=${UI_TEXT.connect.configError.source}`)).toBeVisible({ timeout: 5000 })
})

test('11 - user rejects wallet connection', async ({ page }) => {
// Mock bio SDK that rejects wallet selection
await page.addInitScript(`
window.bio = {
request: async ({ method }) => {
if (method === 'bio_closeSplashScreen') return {}
if (method === 'bio_selectAccount') {
throw new Error('用户取消')
}
return {}
}
}
`)
await page.goto('/')
await page.waitForLoadState('networkidle')

const connectBtn = page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first()
await expect(connectBtn).toBeVisible({ timeout: 10000 })
await connectBtn.click()

// Should show user cancelled error
await expect(page.locator('text=用户取消')).toBeVisible({ timeout: 5000 })
})

test('12 - recharge API failure', async ({ page }) => {
// Mock API that fails on recharge
await page.addInitScript(`
const originalFetch = window.fetch
window.fetch = async (url, options) => {
const urlStr = typeof url === 'string' ? url : url.toString()

if (urlStr.includes('/cot/recharge/support') || urlStr.includes('/recharge/support')) {
return {
ok: true,
json: () => Promise.resolve({
recharge: {
bfmeta: {
BFM: {
enable: true,
logo: '',
supportChain: {
ETH: { enable: true, assetType: 'ETH', depositAddress: '0x1234567890', logo: '' },
},
},
},
},
}),
}
}

// Fail on recharge POST
if (urlStr.includes('/cot/recharge/V2') || urlStr.includes('/recharge/V2')) {
throw new Error('服务器错误')
}

return originalFetch(url, options)
}
`)
await page.addInitScript(mockBioSDK)
await page.goto('/')
await page.waitForLoadState('networkidle')

// Navigate through flow
await page.locator(`button:has-text("${UI_TEXT.connect.button.source}")`).first().click()
await page.waitForSelector('input[type="number"]', { timeout: 10000 })

// Enter amount
await page.fill('input[type="number"]', '0.5')

// Click preview
await page.locator(`button:has-text("${UI_TEXT.swap.preview.source}")`).click()
await expect(page.locator(`button:has-text("${UI_TEXT.confirm.button.source}")`).first()).toBeVisible({ timeout: 5000 })

// Confirm - this should eventually fail
await page.locator(`button:has-text("${UI_TEXT.confirm.button.source}")`).first().click()

// Should show error (may take time due to signing flow)
await expect(page.getByText(/锻造失败|服务器错误/).first()).toBeVisible({ timeout: 15000 })
})
})
4 changes: 4 additions & 0 deletions miniapps/forge/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ export default function App() {
)}

<Button
data-testid="connect-button"
size="lg"
className="w-full max-w-xs h-12"
onClick={handleConnect}
Expand Down Expand Up @@ -282,6 +283,7 @@ export default function App() {
<ChevronDown className="size-4 text-muted-foreground" />
</Button>
<Input
data-testid="amount-input"
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
Expand Down Expand Up @@ -344,6 +346,7 @@ export default function App() {

<div className="mt-auto pt-4">
<Button
data-testid="preview-button"
className="w-full h-12"
onClick={handlePreview}
disabled={!amount || parseFloat(amount) <= 0}
Expand Down Expand Up @@ -420,6 +423,7 @@ export default function App() {

<div className="mt-auto pt-4">
<Button
data-testid="confirm-button"
className="w-full h-12"
onClick={handleConfirm}
disabled={loading}
Expand Down
8 changes: 6 additions & 2 deletions miniapps/forge/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* API Client
*/

import { API_BASE_URL } from './config'
import { getApiBaseUrlSafe, ApiConfigError } from './config'

export { ApiConfigError }

export class ApiError extends Error {
constructor(
Expand All @@ -22,7 +24,9 @@ interface RequestOptions extends RequestInit {
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
const { params, ...init } = options

let url = `${API_BASE_URL}${endpoint}`
// Get base URL - will throw ApiConfigError if not configured
const baseUrl = getApiBaseUrlSafe()
let url = `${baseUrl}${endpoint}`

if (params) {
const searchParams = new URLSearchParams()
Expand Down
37 changes: 27 additions & 10 deletions miniapps/forge/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,24 +5,41 @@
* The COT Recharge API host has not been confirmed - do not use a hardcoded default.
*/

/** Configuration error for missing API base URL */
export class ApiConfigError extends Error {
constructor() {
super(
'[Forge API] VITE_COT_API_BASE_URL is not configured. ' +
'Please set this environment variable to the COT Recharge API base URL.'
)
this.name = 'ApiConfigError'
}
}

/** API Base URL - must be configured via environment variable */
function getApiBaseUrl(): string {
const url = import.meta.env.VITE_COT_API_BASE_URL
if (!url) {
// Fail-fast in development to catch missing configuration early
if (import.meta.env.DEV) {
console.error(
'[Forge API] VITE_COT_API_BASE_URL is not configured. ' +
'Please set this environment variable to the COT Recharge API base URL.'
)
}
// Return empty string - API calls will fail with clear error
return ''
// Log error for visibility in dev tools
console.error(new ApiConfigError().message)
// Throw to fail fast - prevents silent failures with relative paths
throw new ApiConfigError()
}
return url
}

export const API_BASE_URL = getApiBaseUrl()
// Lazy initialization to allow error handling at app level
let _apiBaseUrl: string | null = null

export function getApiBaseUrlSafe(): string {
if (_apiBaseUrl === null) {
_apiBaseUrl = getApiBaseUrl()
}
return _apiBaseUrl
}

// For backwards compatibility - will throw if not configured
export const API_BASE_URL = import.meta.env.VITE_COT_API_BASE_URL || ''

/** API Endpoints */
export const API_ENDPOINTS = {
Expand Down
1 change: 1 addition & 0 deletions miniapps/teleport/e2e/helpers/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const UI_TEXT = {
connect: {
button: /启动传送门|Start Teleport/i,
loading: /连接中|加载配置中|Connecting|Loading/i,
configError: /加载配置失败|载入配置失败|Failed to load configuration/i,
},
asset: {
select: /选择资产|Select Asset/i,
Expand Down
120 changes: 120 additions & 0 deletions miniapps/teleport/e2e/ui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,4 +305,124 @@ test.describe('Teleport UI', () => {

await expect(page).toHaveScreenshot('08-error-no-sdk.png')
})

// ============ 边界场景测试 ============

test('09 - API failure - asset list unavailable', async ({ page }) => {
// Override API mock to return error for asset list
await page.addInitScript(`
const originalFetch = window.fetch
window.fetch = async (url, options) => {
const urlStr = typeof url === 'string' ? url : url.toString()
if (urlStr.includes('/transmit/assetTypeList')) {
throw new Error('Network Error')
}
return originalFetch(url, options)
}
`)
await page.addInitScript(mockBioSDK)
await page.goto('/')
await page.waitForLoadState('networkidle')

// Connect button should be visible
const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button })
await expect(connectBtn).toBeVisible({ timeout: 10000 })

// Config error message should be visible (API failed to load)
await expect(page.getByText(UI_TEXT.connect.configError)).toBeVisible({ timeout: 5000 })
})

test('10 - user rejects wallet selection', async ({ page }) => {
// Mock bio SDK that rejects wallet selection
await page.addInitScript(`
window.bio = {
request: async ({ method }) => {
if (method === 'bio_closeSplashScreen') return
if (method === 'bio_selectAccount') {
throw new Error('用户取消')
}
return {}
},
on: () => {},
off: () => {},
isConnected: () => true,
}
`)
await page.goto('/')
await page.waitForLoadState('networkidle')

const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button })
await expect(connectBtn).toBeVisible({ timeout: 10000 })
await connectBtn.click()

// Should show user cancelled error
await expect(page.getByText('用户取消')).toBeVisible({ timeout: 5000 })
})

test('11 - transmit API failure', async ({ page }) => {
// Mock API that fails on transmit
await page.addInitScript(`
const originalFetch = window.fetch
window.fetch = async (url, options) => {
const urlStr = typeof url === 'string' ? url : url.toString()

if (urlStr.includes('/transmit/assetTypeList')) {
return {
ok: true,
json: () => Promise.resolve({
transmitSupport: {
ETH: {
ETH: {
enable: true,
isAirdrop: false,
assetType: 'ETH',
recipientAddress: '0x1234567890abcdef1234567890abcdef12345678',
targetChain: 'BFMCHAIN',
targetAsset: 'BFM',
ratio: { numerator: 1, denominator: 1 },
transmitDate: { startDate: '2020-01-01', endDate: '2030-12-31' },
},
},
},
}),
}
}

// Fail on transmit POST
if (urlStr.includes('/transmit') && options?.method === 'POST') {
throw new Error('服务器错误')
}

return originalFetch(url, options)
}
`)
await page.addInitScript(mockBioSDK)
await page.goto('/')
await page.waitForLoadState('networkidle')

// Navigate through flow
const connectBtn = page.getByRole('button', { name: UI_TEXT.connect.button })
await expect(connectBtn).toBeVisible({ timeout: 10000 })
await connectBtn.click()

// Select asset
await expect(page.getByText(UI_TEXT.asset.select)).toBeVisible({ timeout: 10000 })
await page.getByText('ETH → BFMCHAIN').first().click()

// Fill amount
await expect(page.locator('input[type="number"]')).toBeVisible({ timeout: 10000 })
await page.fill('input[type="number"]', '100')
await page.getByRole('button', { name: UI_TEXT.amount.next }).click()

// Select target
await expect(page.getByRole('button', { name: UI_TEXT.target.button })).toBeVisible({ timeout: 5000 })
await page.getByRole('button', { name: UI_TEXT.target.button }).click()

// Confirm - this should fail
await expect(page.getByRole('button', { name: UI_TEXT.confirm.button })).toBeVisible({ timeout: 5000 })
await page.getByRole('button', { name: UI_TEXT.confirm.button }).click()

// Should show error (use first() to handle multiple matching elements)
await expect(page.getByText(/传送失败|服务器错误/).first()).toBeVisible({ timeout: 10000 })
})
})
Loading