From 2777c1e9a90bb8d66ad7ac474a560ab1c0651a8f Mon Sep 17 00:00:00 2001 From: Musa Khalid <112591148+Mkalbani@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:37:32 +0000 Subject: [PATCH 1/2] feat: add validation scripts and enhance CI workflow for Web3 integration --- .env.example | 6 + .github/workflows/ci.yml | 33 +++++- package.json | 5 +- scripts/validate-ui.js | 131 ++++++++++++++++++++++ scripts/validate-web3.js | 104 ++++++++++++++++++ src/providers/WalletProvider.tsx | 171 +++++++++++++++++++++++++++++ src/utils/web3/envValidation.ts | 97 ++++++++++++++++ src/utils/web3/index.ts | 15 +++ src/utils/web3/walletValidation.ts | 56 ++++++++++ 9 files changed, 616 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 scripts/validate-ui.js create mode 100644 scripts/validate-web3.js create mode 100644 src/providers/WalletProvider.tsx create mode 100644 src/utils/web3/envValidation.ts create mode 100644 src/utils/web3/index.ts create mode 100644 src/utils/web3/walletValidation.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d99d929 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Starknet Configuration +NEXT_PUBLIC_STARKNET_NETWORK=goerli-alpha +# NEXT_PUBLIC_STARKNET_RPC_URL=https://your-rpc-endpoint.com + +# Optional: For production deployments +# NEXT_PUBLIC_STARKNET_NETWORK=mainnet-alpha diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53eec40..9e3050f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,9 +41,29 @@ jobs: - name: Run Lint run: npm run lint + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Validate UI (icons, responsive) + run: npm run validate:ui + + - name: Validate Web3 (wallet, env) + run: npm run validate:web3 + build: runs-on: ubuntu-latest - needs: [type-check, lint] + needs: [type-check, lint, validate] steps: - uses: actions/checkout@v4 @@ -58,6 +78,17 @@ jobs: - name: Run Build run: npm run build + env: + NEXT_PUBLIC_STARKNET_NETWORK: goerli-alpha + + - name: Check for build errors + run: | + if [ -f .next/build-manifest.json ]; then + echo "āœ… Build completed successfully" + else + echo "āŒ Build failed - no manifest found" + exit 1 + fi test: runs-on: ubuntu-latest diff --git a/package.json b/package.json index 44f94c8..5fecab7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", "test:watch": "vitest --watch", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "validate:ui": "node scripts/validate-ui.js", + "validate:web3": "node scripts/validate-web3.js", + "validate": "npm run validate:ui && npm run validate:web3" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/scripts/validate-ui.js b/scripts/validate-ui.js new file mode 100644 index 0000000..71d9038 --- /dev/null +++ b/scripts/validate-ui.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +/** + * UI Validation Script + * Checks for consistent icon usage and responsive Tailwind classes + * Exit code 0 = pass, 1 = fail + */ + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +const SRC_DIR = path.join(__dirname, '../src'); +const COMPONENT_DIRS = ['components', 'app', 'pages']; + +// Disallowed icon libraries (should use lucide-react) +const DISALLOWED_ICONS = [ + { pattern: /from ['"]@heroicons\/react/g, name: '@heroicons/react' }, + { pattern: /from ['"]@fortawesome/g, name: '@fortawesome' }, + { pattern: /from ['"]react-feather/g, name: 'react-feather' }, +]; + +// Required responsive breakpoints for key layout patterns +const RESPONSIVE_PATTERNS = [ + { pattern: /\bflex\b/, shouldHave: ['sm:', 'md:', 'lg:'], context: 'flex layouts' }, + { pattern: /\bgrid\b/, shouldHave: ['sm:', 'md:', 'lg:'], context: 'grid layouts' }, +]; + +let errors = []; +let warnings = []; + +function getAllFiles(dir, extensions = ['.tsx', '.jsx', '.ts', '.js']) { + let files = []; + + if (!fs.existsSync(dir)) return files; + + const items = fs.readdirSync(dir); + + for (const item of items) { + const fullPath = path.join(dir, item); + const stat = fs.statSync(fullPath); + + if (stat.isDirectory() && !item.startsWith('.') && item !== 'node_modules') { + files = files.concat(getAllFiles(fullPath, extensions)); + } else if (stat.isFile() && extensions.some(ext => item.endsWith(ext))) { + files.push(fullPath); + } + } + + return files; +} + +function checkIconUsage(content, filePath) { + for (const { pattern, name } of DISALLOWED_ICONS) { + if (pattern.test(content)) { + errors.push(`[ICON] ${filePath}: Uses ${name} - should use lucide-react`); + } + } + + // Check for react-icons usage (warning, not error) + if (/from ['"]react-icons/g.test(content)) { + warnings.push(`[ICON] ${filePath}: Uses react-icons - prefer lucide-react for consistency`); + } +} + +function checkResponsiveTailwind(content, filePath) { + // Only check component files that have className + if (!content.includes('className')) return; + + // Check for common layout patterns without responsive variants + const lines = content.split('\n'); + + lines.forEach((line, index) => { + // Check for grid/flex without any responsive classes + if (/className=["'][^"']*\b(grid|flex)\b[^"']*["']/.test(line)) { + const hasResponsive = /\b(sm|md|lg|xl|2xl):/.test(line); + if (!hasResponsive && line.includes('grid-cols-') && !line.includes('grid-cols-1')) { + warnings.push(`[RESPONSIVE] ${filePath}:${index + 1}: Grid layout may need responsive classes`); + } + } + }); +} + +function checkForConsoleStatements(content, filePath) { + // Allow console.warn and console.error, flag console.log + const matches = content.match(/console\.log\(/g); + if (matches && matches.length > 0) { + warnings.push(`[CONSOLE] ${filePath}: Contains ${matches.length} console.log statement(s)`); + } +} + +function validateFiles() { + console.log('šŸ” Running UI validation checks...\n'); + + for (const dir of COMPONENT_DIRS) { + const fullDir = path.join(SRC_DIR, dir); + const files = getAllFiles(fullDir); + + for (const file of files) { + const content = fs.readFileSync(file, 'utf-8'); + const relativePath = path.relative(process.cwd(), file); + + checkIconUsage(content, relativePath); + checkResponsiveTailwind(content, relativePath); + checkForConsoleStatements(content, relativePath); + } + } +} + +function printResults() { + if (warnings.length > 0) { + console.log('āš ļø Warnings:\n'); + warnings.forEach(w => console.log(` ${w}`)); + console.log(''); + } + + if (errors.length > 0) { + console.log('āŒ Errors:\n'); + errors.forEach(e => console.log(` ${e}`)); + console.log(''); + console.log(`\nāŒ UI validation failed with ${errors.length} error(s)`); + process.exit(1); + } + + console.log(`āœ… UI validation passed (${warnings.length} warning(s))`); + process.exit(0); +} + +// Run validation +validateFiles(); +printResults(); diff --git a/scripts/validate-web3.js b/scripts/validate-web3.js new file mode 100644 index 0000000..9e71b40 --- /dev/null +++ b/scripts/validate-web3.js @@ -0,0 +1,104 @@ +#!/usr/bin/env node + +/** + * Web3 Validation Script + * Validates wallet provider setup and environment configuration + * Exit code 0 = pass, 1 = fail + */ + +const fs = require('fs'); +const path = require('path'); + +const SRC_DIR = path.join(__dirname, '../src'); + +let errors = []; +let warnings = []; + +function checkWalletProviderExists() { + const providerPath = path.join(SRC_DIR, 'providers/WalletProvider.tsx'); + + if (!fs.existsSync(providerPath)) { + errors.push('[WEB3] WalletProvider.tsx not found in src/providers/'); + return false; + } + + const content = fs.readFileSync(providerPath, 'utf-8'); + + // Check for error handling + if (!content.includes('try') || !content.includes('catch')) { + errors.push('[WEB3] WalletProvider should have try-catch error handling'); + } + + // Check for SSR safety + if (!content.includes("typeof window")) { + warnings.push('[WEB3] WalletProvider should check for SSR (typeof window)'); + } + + // Check for graceful fallback + if (!content.includes('useContext') || !content.includes('null')) { + warnings.push('[WEB3] useWallet hook should handle missing provider gracefully'); + } + + return true; +} + +function checkWeb3Utils() { + const utilsPath = path.join(SRC_DIR, 'utils/web3/index.ts'); + + if (!fs.existsSync(utilsPath)) { + errors.push('[WEB3] utils/web3/index.ts not found'); + return false; + } + + const envValidationPath = path.join(SRC_DIR, 'utils/web3/envValidation.ts'); + if (!fs.existsSync(envValidationPath)) { + errors.push('[WEB3] utils/web3/envValidation.ts not found'); + return false; + } + + const content = fs.readFileSync(envValidationPath, 'utf-8'); + + // Check for network validation + if (!content.includes('NEXT_PUBLIC_STARKNET')) { + warnings.push('[WEB3] Environment validation should check NEXT_PUBLIC_STARKNET_* vars'); + } + + return true; +} + +function checkEnvExample() { + const envExamplePath = path.join(__dirname, '../.env.example'); + const envLocalPath = path.join(__dirname, '../.env.local.example'); + + if (!fs.existsSync(envExamplePath) && !fs.existsSync(envLocalPath)) { + warnings.push('[WEB3] Consider adding .env.example with NEXT_PUBLIC_STARKNET_* variables'); + } +} + +function printResults() { + console.log('šŸ” Running Web3 validation checks...\n'); + + checkWalletProviderExists(); + checkWeb3Utils(); + checkEnvExample(); + + if (warnings.length > 0) { + console.log('āš ļø Warnings:\n'); + warnings.forEach(w => console.log(` ${w}`)); + console.log(''); + } + + if (errors.length > 0) { + console.log('āŒ Errors:\n'); + errors.forEach(e => console.log(` ${e}`)); + console.log(''); + console.log(`\nāŒ Web3 validation failed with ${errors.length} error(s)`); + process.exit(1); + } + + console.log(`āœ… Web3 validation passed (${warnings.length} warning(s))`); + process.exit(0); +} + +// Run validation +printResults(); diff --git a/src/providers/WalletProvider.tsx b/src/providers/WalletProvider.tsx new file mode 100644 index 0000000..68bedc0 --- /dev/null +++ b/src/providers/WalletProvider.tsx @@ -0,0 +1,171 @@ +'use client'; + +import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react'; + +// Environment validation for wallet config +const validateWalletEnv = () => { + const warnings: string[] = []; + + if (!process.env.NEXT_PUBLIC_STARKNET_NETWORK) { + warnings.push('NEXT_PUBLIC_STARKNET_NETWORK not set, defaulting to testnet'); + } + + if (process.env.NODE_ENV === 'development' && warnings.length > 0) { + console.warn('[WalletProvider] Environment warnings:', warnings); + } + + return { + network: process.env.NEXT_PUBLIC_STARKNET_NETWORK || 'goerli-alpha', + isValid: true, // Non-blocking - app works without wallet + }; +}; + +export interface WalletState { + address: string | null; + isConnected: boolean; + isConnecting: boolean; + error: string | null; + network: string; +} + +export interface WalletContextType extends WalletState { + connect: () => Promise; + disconnect: () => Promise; + clearError: () => void; +} + +const initialState: WalletState = { + address: null, + isConnected: false, + isConnecting: false, + error: null, + network: 'goerli-alpha', +}; + +const WalletContext = createContext(null); + +interface WalletProviderProps { + children: ReactNode; +} + +export function WalletProvider({ children }: WalletProviderProps) { + const [state, setState] = useState(() => ({ + ...initialState, + network: validateWalletEnv().network, + })); + + // Safe wallet connection with error boundary + const connect = useCallback(async () => { + setState(prev => ({ ...prev, isConnecting: true, error: null })); + + try { + // Check if wallet extension is available + if (typeof window === 'undefined') { + throw new Error('Window not available'); + } + + // Starknet wallet detection + const starknet = (window as Window & { starknet?: { + enable: () => Promise; + selectedAddress?: string; + isConnected?: boolean; + }}).starknet; + + if (!starknet) { + throw new Error('No Starknet wallet detected. Please install ArgentX or Braavos.'); + } + + const accounts = await starknet.enable(); + const address = accounts[0] || starknet.selectedAddress; + + if (!address) { + throw new Error('No account available'); + } + + setState(prev => ({ + ...prev, + address, + isConnected: true, + isConnecting: false, + error: null, + })); + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to connect wallet'; + setState(prev => ({ + ...prev, + isConnecting: false, + error: message, + })); + // Don't rethrow - graceful degradation + console.error('[WalletProvider] Connection failed:', message); + } + }, []); + + const disconnect = useCallback(async () => { + setState(prev => ({ + ...prev, + address: null, + isConnected: false, + error: null, + })); + }, []); + + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })); + }, []); + + // Auto-reconnect on mount if previously connected + useEffect(() => { + const wasConnected = typeof window !== 'undefined' && + localStorage.getItem('wallet_connected') === 'true'; + + if (wasConnected) { + connect().catch(() => { + // Silent fail on auto-reconnect + localStorage.removeItem('wallet_connected'); + }); + } + }, [connect]); + + // Persist connection state + useEffect(() => { + if (typeof window !== 'undefined') { + if (state.isConnected) { + localStorage.setItem('wallet_connected', 'true'); + } else { + localStorage.removeItem('wallet_connected'); + } + } + }, [state.isConnected]); + + const value: WalletContextType = { + ...state, + connect, + disconnect, + clearError, + }; + + return ( + + {children} + + ); +} + +export function useWallet(): WalletContextType { + const context = useContext(WalletContext); + + if (!context) { + // Return safe fallback instead of throwing - prevents build breaks + return { + ...initialState, + connect: async () => { console.warn('WalletProvider not found'); }, + disconnect: async () => {}, + clearError: () => {}, + }; + } + + return context; +} + +export default WalletProvider; diff --git a/src/utils/web3/envValidation.ts b/src/utils/web3/envValidation.ts new file mode 100644 index 0000000..ce40e07 --- /dev/null +++ b/src/utils/web3/envValidation.ts @@ -0,0 +1,97 @@ +/** + * Web3 Environment Validation + * Validates required environment variables for Starknet integration + */ + +export interface EnvValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; + config: Web3Config; +} + +export interface Web3Config { + network: string; + rpcUrl: string | null; + explorerUrl: string; +} + +const NETWORKS = { + 'mainnet-alpha': { + rpcUrl: 'https://starknet-mainnet.public.blastapi.io', + explorerUrl: 'https://starkscan.co', + }, + 'goerli-alpha': { + rpcUrl: 'https://starknet-testnet.public.blastapi.io', + explorerUrl: 'https://testnet.starkscan.co', + }, + 'sepolia-alpha': { + rpcUrl: 'https://starknet-sepolia.public.blastapi.io', + explorerUrl: 'https://sepolia.starkscan.co', + }, +} as const; + +type NetworkType = keyof typeof NETWORKS; + +/** + * Validates web3-related environment variables + * Returns warnings instead of throwing to prevent build breaks + */ +export function validateWeb3Env(): EnvValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + const network = process.env.NEXT_PUBLIC_STARKNET_NETWORK || 'goerli-alpha'; + const customRpcUrl = process.env.NEXT_PUBLIC_STARKNET_RPC_URL; + + // Validate network + if (!Object.keys(NETWORKS).includes(network)) { + warnings.push(`Unknown network "${network}", defaulting to goerli-alpha`); + } + + // Check for custom RPC in production + if (process.env.NODE_ENV === 'production' && !customRpcUrl) { + warnings.push('Consider setting NEXT_PUBLIC_STARKNET_RPC_URL for production'); + } + + const networkConfig = NETWORKS[network as NetworkType] || NETWORKS['goerli-alpha']; + + return { + isValid: errors.length === 0, + errors, + warnings, + config: { + network, + rpcUrl: customRpcUrl || networkConfig.rpcUrl, + explorerUrl: networkConfig.explorerUrl, + }, + }; +} + +/** + * Get explorer URL for a transaction or address + */ +export function getExplorerUrl(hashOrAddress: string, type: 'tx' | 'contract' | 'address' = 'tx'): string { + const { config } = validateWeb3Env(); + const path = type === 'tx' ? 'tx' : type === 'contract' ? 'contract' : 'contract'; + return `${config.explorerUrl}/${path}/${hashOrAddress}`; +} + +/** + * Format wallet address for display + */ +export function formatAddress(address: string | null): string { + if (!address) return ''; + if (address.length < 10) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +} + +/** + * Validate Starknet address format + */ +export function isValidStarknetAddress(address: string): boolean { + if (!address) return false; + // Starknet addresses are 66 chars (0x + 64 hex chars) or shorter + const cleanAddress = address.toLowerCase(); + return /^0x[a-f0-9]{1,64}$/i.test(cleanAddress); +} diff --git a/src/utils/web3/index.ts b/src/utils/web3/index.ts new file mode 100644 index 0000000..40a1fab --- /dev/null +++ b/src/utils/web3/index.ts @@ -0,0 +1,15 @@ +/** + * Web3 Utilities + * Barrel export for web3-related utilities + */ + +export { + validateWeb3Env, + getExplorerUrl, + formatAddress, + isValidStarknetAddress, + type EnvValidationResult, + type Web3Config, +} from './envValidation'; + +export { validateWalletInteraction, type WalletInteractionResult } from './walletValidation'; diff --git a/src/utils/web3/walletValidation.ts b/src/utils/web3/walletValidation.ts new file mode 100644 index 0000000..6db0337 --- /dev/null +++ b/src/utils/web3/walletValidation.ts @@ -0,0 +1,56 @@ +/** + * Wallet Interaction Validation + * Safe checks for wallet operations that won't break builds + */ + +export interface WalletInteractionResult { + canInteract: boolean; + reason: string | null; +} + +/** + * Check if wallet interactions are possible in current environment + * Returns safe result - never throws + */ +export function validateWalletInteraction(): WalletInteractionResult { + // SSR check + if (typeof window === 'undefined') { + return { + canInteract: false, + reason: 'Server-side rendering - wallet interactions disabled', + }; + } + + // Check for Starknet wallet + const hasStarknet = !!(window as Window & { starknet?: unknown }).starknet; + + if (!hasStarknet) { + return { + canInteract: false, + reason: 'No Starknet wallet extension detected', + }; + } + + return { + canInteract: true, + reason: null, + }; +} + +/** + * Safe wrapper for wallet operations + * Catches errors and returns structured result + */ +export async function safeWalletCall( + operation: () => Promise, + fallback: T +): Promise<{ success: boolean; data: T; error: string | null }> { + try { + const data = await operation(); + return { success: true, data, error: null }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown wallet error'; + console.error('[safeWalletCall]', message); + return { success: false, data: fallback, error: message }; + } +} From b3fc2c24db8207330d27442f1dab75a762d92743 Mon Sep 17 00:00:00 2001 From: Musa Khalid <112591148+Mkalbani@users.noreply.github.com> Date: Wed, 25 Feb 2026 10:50:33 +0000 Subject: [PATCH 2/2] fix: rename setup.ts to setup.tsx for JSX parsing --- src/testing/{setup.ts => setup.tsx} | 1 + 1 file changed, 1 insertion(+) rename src/testing/{setup.ts => setup.tsx} (98%) diff --git a/src/testing/setup.ts b/src/testing/setup.tsx similarity index 98% rename from src/testing/setup.ts rename to src/testing/setup.tsx index bc108c8..dbe3b40 100644 --- a/src/testing/setup.ts +++ b/src/testing/setup.tsx @@ -1,4 +1,5 @@ import '@testing-library/jest-dom' +import type React from 'react' import { cleanup } from '@testing-library/react' import { afterEach, vi, beforeAll } from 'vitest'