diff --git a/.env.example b/.env.example index 710a2b9..f1ef794 100644 --- a/.env.example +++ b/.env.example @@ -4,4 +4,6 @@ NEXT_PUBLIC_BASE_SEPOLIA= NEXT_PUBLIC_TENDERLY_VNETS_ENABLED= NEXT_PUBLIC_TENDERLY_VNET_ID= NEXT_PUBLIC_TENDERLY_VNET_RPC= -NEXT_PUBLIC_TENDERLY_VNET_EXPLORER= \ No newline at end of file +NEXT_PUBLIC_TENDERLY_VNET_EXPLORER= + +NEXT_PUBLIC_E2E_TEST_ENABLED= \ No newline at end of file diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml new file mode 100644 index 0000000..63c10b4 --- /dev/null +++ b/.github/workflows/e2e-test.yml @@ -0,0 +1,41 @@ +name: Playwright Tests +on: + push: + branches: [ main ] + pull_request: + types: [opened, synchronize] + +env: + NEXT_PUBLIC_BASE_SEPOLIA: ${{ secrets.NEXT_PUBLIC_BASE_SEPOLIA }} + NEXT_PUBLIC_E2E_TEST_ENABLED: "true" + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + cache: 'npm' + - name: Install dependencies + run: npm ci + - name: Set up foundry (includes anvil) + uses: foundry-rs/foundry-toolchain@v1 + - name: Start anvil fork in base sepolia + run: anvil --fork-url "${{ env.NEXT_PUBLIC_BASE_SEPOLIA || 'https://sepolia.base.org' }}" --port 8545 --disable-block-gas-limit & + - name: Warm up anvil + run: npm run warmup:anvil + - name: Build application + run: npm run build:turbo + - name: Install Playwright Browsers + run: npm run playwright:install:chromium # CI only tests in chromium + - name: Run Playwright tests + run: npm run test:e2e + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index c24a835..9b177af 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,10 @@ yarn-error.log* *.tsbuildinfo # idea files -.idea \ No newline at end of file +.idea + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index 34d2584..b2d7614 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,11 @@ To start the production server: npm run start ``` +### End-to-End Tests (Playwright) + +The E2E tests are located in [`/e2e`](./e2e). +For details on how to install, configure, and run them, check the [dedicated README](./e2e/README.md). + ## Deploy your own Vercel instance Deploy your own instance of the application effortlessly through Vercel by simply clicking the button below and following the guided setup process: diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..e40f556 --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,19 @@ +# End-to-End Tests with Playwright + +## Requirements + +- Node.js >= 18 +- Playwright installed: `npm run playwright:install` +- App running: you can chose running it in dev mode (`npm run dev`) or building it first (and running `npm run start`) like it is done in Github actions. + +## Run tests in CI mode + +```bash +nr run test:e2e +``` + +## Run tests in UI mode (recommended for development env) + +```bash +nr run test:e2e:ui +``` diff --git a/e2e/config/__mocks__/allStkTokens.mock.ts b/e2e/config/__mocks__/allStkTokens.mock.ts new file mode 100644 index 0000000..05f5db5 --- /dev/null +++ b/e2e/config/__mocks__/allStkTokens.mock.ts @@ -0,0 +1,443 @@ +import { UMBRELLA_DATA_AGGREGATION_HELPER_ABI } from "@/abis/umbrellaDataAggregationHelper"; +import type { UseReadContractReturnType } from "wagmi"; + +type AllStkTokensData = NonNullable< + UseReadContractReturnType["data"] +>; + +export const allStkTokensMock: AllStkTokensData = [ + [ + { + stakeTokenData: { + token: "0xD15e98f7AD53954fc96A7904738Eb1Ed1f6F0422", + price: 100198410n, + name: "Umbrella Stake Wrapped Aave Base Sepolia USDC V1", + symbol: "stkwaBasSepUSDC.V1", + decimals: 6, + }, + totalAssets: 1001504984320n, + targetLiquidity: 50000000000000n, + isStakeConfigured: true, + rewardsTokenData: [ + { + rewardTokenData: { + token: "0xba50Cd2A20f6DA35D788639E581bca8d0B5d4D5f", + price: 99982437n, + name: "USD Coin", + symbol: "USDC", + decimals: 6, + }, + maxEmissionPerSecond: 160000n, + distributionEnd: 1753788774n, + }, + { + rewardTokenData: { + token: "0x0a215D8ba66387DCA84B284D18c3B4ec3de6E54a", + price: 100043658n, + name: "Tether USD", + symbol: "USDT", + decimals: 6, + }, + maxEmissionPerSecond: 0n, + distributionEnd: 1741892964n, + }, + ], + }, + { + stakeTokenData: { + token: "0x9287a4e4DDb8715862D5c94E6f3108E1E9A83321", + price: 100048523n, + name: "Umbrella Stake Wrapped Aave Base Sepolia USDT V1", + symbol: "stkwaBasSepUSDT.V1", + decimals: 6, + }, + totalAssets: 1017407484390n, + targetLiquidity: 50000000000000n, + isStakeConfigured: true, + rewardsTokenData: [ + { + rewardTokenData: { + token: "0x0a215D8ba66387DCA84B284D18c3B4ec3de6E54a", + price: 100043658n, + name: "Tether USD", + symbol: "USDT", + decimals: 6, + }, + maxEmissionPerSecond: 160000n, + distributionEnd: 1753788774n, + }, + { + rewardTokenData: { + token: "0xba50Cd2A20f6DA35D788639E581bca8d0B5d4D5f", + price: 99982437n, + name: "USD Coin", + symbol: "USDC", + decimals: 6, + }, + maxEmissionPerSecond: 0n, + distributionEnd: 1741892964n, + }, + ], + }, + { + stakeTokenData: { + token: "0xf9a03b111e22180172F5DF879ac3c5d19BA75975", + price: 10432024000000n, + name: "Umbrella Stake Wrapped Aave Base Sepolia WBTC V1", + symbol: "stkwaBasSepWBTC.V1", + decimals: 8, + }, + totalAssets: 201654125n, + targetLiquidity: 15000000000n, + isStakeConfigured: true, + rewardsTokenData: [ + { + rewardTokenData: { + token: "0x54114591963CF60EF3aA63bEfD6eC263D98145a4", + price: 10432024000000n, + name: "Wrapped BTC", + symbol: "WBTC", + decimals: 8, + }, + maxEmissionPerSecond: 0n, + distributionEnd: 1744313198n, + }, + { + rewardTokenData: { + token: "0x47Db195BAf46898302C06c31bCF46c01C64ACcF9", + price: 10432024000000n, + name: "Aave Base Sepolia WBTC", + symbol: "aBasSepWBTC", + decimals: 8, + }, + maxEmissionPerSecond: 10n, + distributionEnd: 1753788774n, + }, + ], + }, + { + stakeTokenData: { + token: "0x36edA587e02e897Db071000ac37606B2767d68aa", + price: 274668821875n, + name: "Umbrella Stake Wrapped Aave Base Sepolia cbETH V1", + symbol: "stkwaBasSepcbETH.V1", + decimals: 18, + }, + totalAssets: 1n, + targetLiquidity: 10000000000000000000000n, + isStakeConfigured: true, + rewardsTokenData: [ + { + rewardTokenData: { + token: "0x73a5bB60b0B0fc35710DDc0ea9c407031E31Bdbb", + price: 249741758265n, + name: "Aave Base Sepolia WETH", + symbol: "aBasSepWETH", + decimals: 18, + }, + maxEmissionPerSecond: 0n, + distributionEnd: 1746532174n, + }, + { + rewardTokenData: { + token: "0x54114591963CF60EF3aA63bEfD6eC263D98145a4", + price: 10432024000000n, + name: "Wrapped BTC", + symbol: "WBTC", + decimals: 8, + }, + maxEmissionPerSecond: 0n, + distributionEnd: 1741892964n, + }, + { + rewardTokenData: { + token: "0xD171b9694f7A2597Ed006D41f7509aaD4B485c4B", + price: 274668821875n, + name: "Coinbase Wrapped Staked ETH ", + symbol: "cbETH", + decimals: 18, + }, + maxEmissionPerSecond: 15855000000000n, + distributionEnd: 1753789430n, + }, + ], + }, + { + stakeTokenData: { + token: "0x16C2230c3B1116Acdf3dE7202893cb5f0fBde6E6", + price: 1377174000n, + name: "Umbrella Stake Wrapped Aave Base Sepolia LINK V1", + symbol: "stkwaBasSepLINK.V1", + decimals: 18, + }, + totalAssets: 1519000000000000000000n, + targetLiquidity: 150000000000000000000000n, + isStakeConfigured: true, + rewardsTokenData: [ + { + rewardTokenData: { + token: "0x810D46F9a9027E28F9B01F75E2bdde839dA61115", + price: 1377174000n, + name: "ChainLink Token", + symbol: "LINK", + decimals: 18, + }, + maxEmissionPerSecond: 237800000000000n, + distributionEnd: 1753788774n, + }, + ], + }, + { + stakeTokenData: { + token: "0xfD055B11563b80648091d0F69704dD647714e134", + price: 249741758265n, + name: "Umbrella Stake Wrapped Ether V1", + symbol: "stkWETH.V1", + decimals: 18, + }, + totalAssets: 177100000000000000n, + targetLiquidity: 25000000000000000000000n, + isStakeConfigured: true, + rewardsTokenData: [ + { + rewardTokenData: { + token: "0x9Fd6d1DBAd7c052e0c43f46df36eEc6a68814B63", + price: 274668821875n, + name: "Aave Base Sepolia cbETH", + symbol: "aBasSepcbETH", + decimals: 18, + }, + maxEmissionPerSecond: 1377865961199n, + distributionEnd: 1755099348n, + }, + { + rewardTokenData: { + token: "0x73a5bB60b0B0fc35710DDc0ea9c407031E31Bdbb", + price: 249741758265n, + name: "Aave Base Sepolia WETH", + symbol: "aBasSepWETH", + decimals: 18, + }, + maxEmissionPerSecond: 0n, + distributionEnd: 1748860284n, + }, + { + rewardTokenData: { + token: "0xD171b9694f7A2597Ed006D41f7509aaD4B485c4B", + price: 274668821875n, + name: "Coinbase Wrapped Staked ETH ", + symbol: "cbETH", + decimals: 18, + }, + maxEmissionPerSecond: 15855000000000n, + distributionEnd: 1753789644n, + }, + { + rewardTokenData: { + token: "0x9F56ff6c3441bb89eBFf1c0bc75D4096C33230d1", + price: 274668821875n, + name: "Wrapped Aave Base Sepolia cbETH", + symbol: "waBasSepcbETH", + decimals: 18, + }, + maxEmissionPerSecond: 688932980599n, + distributionEnd: 1755100110n, + }, + ], + }, + ], + [ + { + stakeToken: "0xD15e98f7AD53954fc96A7904738Eb1Ed1f6F0422", + tokensFromRoute: [ + { + typeOfToken: 3, + tokenData: { + token: "0xf430cb6E2b85f99222fBFA6dFEa18Ff60FA6B32a", + price: 100198410n, + name: "Wrapped Aave Base Sepolia USDC", + symbol: "waBasSepUSDC", + decimals: 6, + }, + }, + { + typeOfToken: 2, + tokenData: { + token: "0x10F1A9D11CDf50041f3f8cB7191CBE2f31750ACC", + price: 99982437n, + name: "Aave Base Sepolia USDC", + symbol: "aBasSepUSDC", + decimals: 6, + }, + }, + { + typeOfToken: 1, + tokenData: { + token: "0xba50Cd2A20f6DA35D788639E581bca8d0B5d4D5f", + price: 99982437n, + name: "USD Coin", + symbol: "USDC", + decimals: 6, + }, + }, + ], + }, + { + stakeToken: "0x9287a4e4DDb8715862D5c94E6f3108E1E9A83321", + tokensFromRoute: [ + { + typeOfToken: 3, + tokenData: { + token: "0xf63dA51069FAe9448747FA425F8Cb84B0149eC0F", + price: 100048523n, + name: "Wrapped Aave Base Sepolia USDT", + symbol: "waBasSepUSDT", + decimals: 6, + }, + }, + { + typeOfToken: 2, + tokenData: { + token: "0xcE3CAae5Ed17A7AafCEEbc897DE843fA6CC0c018", + price: 100043658n, + name: "Aave Base Sepolia USDT", + symbol: "aBasSepUSDT", + decimals: 6, + }, + }, + { + typeOfToken: 1, + tokenData: { + token: "0x0a215D8ba66387DCA84B284D18c3B4ec3de6E54a", + price: 100043658n, + name: "Tether USD", + symbol: "USDT", + decimals: 6, + }, + }, + ], + }, + { + stakeToken: "0xf9a03b111e22180172F5DF879ac3c5d19BA75975", + tokensFromRoute: [ + { + typeOfToken: 3, + tokenData: { + token: "0x5d7538579560F5fdD54F9D264DBe0e697b5BF668", + price: 10432024000000n, + name: "Wrapped Aave Base Sepolia WBTC", + symbol: "waBasSepWBTC", + decimals: 8, + }, + }, + { + typeOfToken: 2, + tokenData: { + token: "0x47Db195BAf46898302C06c31bCF46c01C64ACcF9", + price: 10432024000000n, + name: "Aave Base Sepolia WBTC", + symbol: "aBasSepWBTC", + decimals: 8, + }, + }, + { + typeOfToken: 1, + tokenData: { + token: "0x54114591963CF60EF3aA63bEfD6eC263D98145a4", + price: 10432024000000n, + name: "Wrapped BTC", + symbol: "WBTC", + decimals: 8, + }, + }, + ], + }, + { + stakeToken: "0x36edA587e02e897Db071000ac37606B2767d68aa", + tokensFromRoute: [ + { + typeOfToken: 3, + tokenData: { + token: "0x9F56ff6c3441bb89eBFf1c0bc75D4096C33230d1", + price: 274668821875n, + name: "Wrapped Aave Base Sepolia cbETH", + symbol: "waBasSepcbETH", + decimals: 18, + }, + }, + { + typeOfToken: 2, + tokenData: { + token: "0x9Fd6d1DBAd7c052e0c43f46df36eEc6a68814B63", + price: 274668821875n, + name: "Aave Base Sepolia cbETH", + symbol: "aBasSepcbETH", + decimals: 18, + }, + }, + { + typeOfToken: 1, + tokenData: { + token: "0xD171b9694f7A2597Ed006D41f7509aaD4B485c4B", + price: 274668821875n, + name: "Coinbase Wrapped Staked ETH ", + symbol: "cbETH", + decimals: 18, + }, + }, + ], + }, + { + stakeToken: "0x16C2230c3B1116Acdf3dE7202893cb5f0fBde6E6", + tokensFromRoute: [ + { + typeOfToken: 3, + tokenData: { + token: "0xFb3AB5ABd3dE4703C0F296B1fe6f5E570e1631Ff", + price: 1377174000n, + name: "Wrapped Aave Base Sepolia LINK", + symbol: "waBasSepLINK", + decimals: 18, + }, + }, + { + typeOfToken: 2, + tokenData: { + token: "0x0aD46dE765522399d7b25B438b230A894d72272B", + price: 1377174000n, + name: "Aave Base Sepolia LINK", + symbol: "aBasSepLINK", + decimals: 18, + }, + }, + { + typeOfToken: 1, + tokenData: { + token: "0x810D46F9a9027E28F9B01F75E2bdde839dA61115", + price: 1377174000n, + name: "ChainLink Token", + symbol: "LINK", + decimals: 18, + }, + }, + ], + }, + { + stakeToken: "0xfD055B11563b80648091d0F69704dD647714e134", + tokensFromRoute: [ + { + typeOfToken: 1, + tokenData: { + token: "0x4200000000000000000000000000000000000006", + price: 249741758265n, + name: "Wrapped Ether", + symbol: "WETH", + decimals: 18, + }, + }, + ], + }, + ], + [], + [], +]; diff --git a/e2e/config/createMockConnector.ts b/e2e/config/createMockConnector.ts new file mode 100644 index 0000000..d72d28f --- /dev/null +++ b/e2e/config/createMockConnector.ts @@ -0,0 +1,16 @@ +import { defaultAnvilAccount } from "@e2e/config/e2e.constants"; +import { WalletDetailsParams } from "@rainbow-me/rainbowkit"; +import { CreateConnectorFn, mock } from "wagmi"; + +export function createMockConnector(walletDetails: WalletDetailsParams): CreateConnectorFn { + const mockConnector: CreateConnectorFn = (config) => { + return { + ...mock({ + accounts: [defaultAnvilAccount], + })(config), + rkDetails: walletDetails.rkDetails, + }; + }; + + return mockConnector; +} diff --git a/e2e/config/e2e.constants.ts b/e2e/config/e2e.constants.ts new file mode 100644 index 0000000..9bfeecf --- /dev/null +++ b/e2e/config/e2e.constants.ts @@ -0,0 +1,17 @@ +"use client"; + +import { Address } from "viem"; +import { baseSepolia, Chain } from "wagmi/chains"; + +export const defaultAnvilAccount: Address = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +export const isE2eTestEnabled = process.env.NEXT_PUBLIC_E2E_TEST_ENABLED === "true"; +if (isE2eTestEnabled) console.warn("E2E tests are enabled. This should only be used in a testing environment."); +export const baseSepoliaE2eForkUrl = "http://127.0.0.1:8545"; + +export const baseSepoliaUsingFork: Chain = { + ...baseSepolia, + rpcUrls: { + default: { http: [baseSepoliaE2eForkUrl] }, + }, +}; diff --git a/e2e/config/mockWallet.ts b/e2e/config/mockWallet.ts new file mode 100644 index 0000000..04233c5 --- /dev/null +++ b/e2e/config/mockWallet.ts @@ -0,0 +1,15 @@ +import { createMockConnector } from "@e2e/config/createMockConnector"; +import type { Wallet } from "@rainbow-me/rainbowkit"; + +export const mockWallet = (): Wallet => { + return { + id: "mock", + name: "Mock Wallet", + shortName: "Mock", + installed: true, + iconBackground: "rgba(0, 255, 0, 0.5)", + iconUrl: "images/wallet.svg", + downloadUrls: {}, + createConnector: createMockConnector, + }; +}; diff --git a/e2e/config/warmup-anvil.ts b/e2e/config/warmup-anvil.ts new file mode 100644 index 0000000..e914bf4 --- /dev/null +++ b/e2e/config/warmup-anvil.ts @@ -0,0 +1,80 @@ +import { createPublicClient, http, PublicClient } from "viem"; +import { baseSepolia } from "viem/chains"; +import { UMBRELLA_DATA_AGGREGATION_HELPER_ABI } from "../../src/abis/umbrellaDataAggregationHelper"; +import { baseSepoliaE2eForkUrl, defaultAnvilAccount } from "@e2e/config/e2e.constants"; +import { MARKETS } from "@/constants/markets"; + +const baseSepoliaMarket = MARKETS[1]; +const { umbrellaDataAggregationHelper, umbrellaHelper, oracle } = baseSepoliaMarket; + +const owner = defaultAnvilAccount; + +/** + * Script to warm up Anvil by executing the `getAllAggregatedData` function + * from the `umbrellaDataAggregationHelper` contract. + * This is useful for preloading data into the cache before running tests because it is an expensive operation that would make tests fail due to timeout. + * ⚠️ Notice that anvil fork must be run with --disable-block-gas-limit flag to avoid hitting block gas limits when calling getAllAggregatedData. + */ +async function warmupAnvil() { + console.log("🔥 Starting Anvil warmup..."); + + const client = createPublicClient({ + chain: baseSepolia, + transport: http(baseSepoliaE2eForkUrl), + }); + + try { + console.log("⏳ Waiting for Anvil to be ready..."); + await waitForAnvil(client as PublicClient); + + console.log("🚀 Executing getAllAggregatedData warmup..."); + const startTime = Date.now(); + + const result = await client.readContract({ + address: umbrellaDataAggregationHelper, + abi: UMBRELLA_DATA_AGGREGATION_HELPER_ABI, + functionName: "getAllAggregatedData", + args: [umbrellaHelper, oracle, owner], + }); + + const endTime = Date.now(); + console.log(`✅ Warmup completed in ${endTime - startTime}ms`); + console.log(`📊 Result: ${result[0].length} stake tokens loaded`); + + // Run second time to verify that it is cached + console.log("🔄 Testing cache performance..."); + const cacheStartTime = Date.now(); + + await client.readContract({ + address: umbrellaDataAggregationHelper, + abi: UMBRELLA_DATA_AGGREGATION_HELPER_ABI, + functionName: "getAllAggregatedData", + args: [umbrellaHelper, oracle, owner], + }); + + const cacheEndTime = Date.now(); + console.log(`⚡ Cache hit completed in ${cacheEndTime - cacheStartTime}ms`); + console.log("🎉 Anvil warmup successful!"); + } catch (error) { + console.error("❌ Warmup failed:", error); + process.exit(1); + } +} + +async function waitForAnvil(client: PublicClient, maxRetries = 3) { + for (let i = 0; i < maxRetries; i++) { + try { + await client.getBlockNumber(); + return; + } catch (error) { + if (i === maxRetries - 1) { + throw new Error("Anvil not ready after maximum retries"); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } +} + +if (require.main === module) { + warmupAnvil(); +} diff --git a/e2e/tests/demo.spec.ts b/e2e/tests/demo.spec.ts new file mode 100644 index 0000000..d9a9bed --- /dev/null +++ b/e2e/tests/demo.spec.ts @@ -0,0 +1,60 @@ +import { test, expect, Page } from "@playwright/test"; + +test("Full flow", async ({ page }) => { + await page.goto("http://localhost:3000/"); + + await chooseSepoliaBaseChain(page); + + await connectToMockWallet(page); + + await stakeEth(page); + + await returnToDashboard(page); + + await claimAllRewards(page); + + await returnToDashboard(page); + + await initCooldown(page); + + await page.waitForTimeout(500); +}); + +async function chooseSepoliaBaseChain(page: Page) { + await page.getByRole("combobox").click(); + await page.getByText("Base Sepolia", { exact: true }).click(); +} + +async function connectToMockWallet(page: Page) { + await page.getByRole("button", { name: "Connect Wallet" }).click(); + await page.getByTestId("rk-wallet-option-mock").click(); +} + +async function stakeEth(page: Page) { + // ETH is in the last row in the table + await page.getByRole("link", { name: "Stake" }).last().click(); + await page.getByRole("textbox", { name: "0.000" }).fill("2"); + await page.getByRole("button", { name: "Wrap" }).click(); + await page.getByRole("button", { name: "Approve" }).click(); + await page.getByRole("button", { name: "Stake" }).click(); + + await expect(page.getByRole("heading", { name: "Transaction completed!" })).toBeVisible(); + + // Wait to ensure that claimable rewards are available + await page.waitForTimeout(2000); +} + +async function claimAllRewards(page: Page) { + await page.getByRole("link", { name: "Claim all" }).click(); + await page.getByRole("button", { name: "Claim All" }).click(); + await expect(page.getByRole("heading", { name: "Transaction completed!" })).toBeVisible(); +} + +async function returnToDashboard(page: Page) { + await page.getByRole("link", { name: "Return to dashboard" }).click(); +} + +async function initCooldown(page: Page) { + await page.getByRole("main").getByRole("button", { name: "Cooldown" }).click(); + // TODO: add fork time traveling to test cooldown +} diff --git a/next.config.ts b/next.config.ts index a417286..62c35b9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,6 +1,14 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // FIXME: Delete this setup where TS errors in main are fixed + eslint: { + ignoreDuringBuilds: true, + }, + // FIXME: Delete this setup where TS errors in main are fixed + typescript: { + ignoreBuildErrors: true, + }, webpack(config) { // @ts-expect-error NextConfig doesn't have proper types for webpack config yet const fileLoaderRule = config.module.rules.find((rule) => rule.test?.test?.(".svg")); diff --git a/package-lock.json b/package-lock.json index 998da7a..e15425e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", + "@playwright/test": "^1.52.0", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -67,6 +68,7 @@ "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", + "tsx": "^4.19.4", "typescript": "^5.8.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.4" @@ -4091,6 +4093,21 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz", + "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==", + "devOptional": true, + "dependencies": { + "playwright": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -13597,6 +13614,50 @@ "integrity": "sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==", "license": "MIT" }, + "node_modules/playwright": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz", + "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==", + "devOptional": true, + "dependencies": { + "playwright-core": "1.52.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz", + "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==", + "devOptional": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/pngjs": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", @@ -15744,6 +15805,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.19.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.4.tgz", + "integrity": "sha512-gK5GVzDkJK1SI1zwHf32Mqxf2tSJkNx+eYcNly5+nHvWqXUJYUkWBQtKauoESz3ymezAI++ZwT855x5p5eop+Q==", + "dev": true, + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 4399ba1..108f5b0 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,19 @@ "scripts": { "dev": "next dev --turbopack", "build": "next build", + "build:turbo": "next build --no-lint --turbopack", "start": "next start", "lint": "next lint", "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "prepare": "husky", - "test:ci": "vitest related --run --passWithNoTests" + "test:ci": "vitest related --run --passWithNoTests", + "test:e2e": "NEXT_PUBLIC_E2E_TEST_ENABLED=true npx playwright test", + "test:e2e:ui": "NEXT_PUBLIC_E2E_TEST_ENABLED=true npx playwright test e2e/tests --ui", + "playwright:install": "playwright install --with-deps", + "playwright:install:chromium": "playwright install --with-deps chromium", + "warmup:anvil": "tsx e2e/config/warmup-anvil.ts" }, "lint-staged": { "**/*.{ts,tsx,js,jsx}": [ @@ -63,6 +69,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", + "@playwright/test": "^1.52.0", "@svgr/webpack": "^8.1.0", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", @@ -83,6 +90,7 @@ "postcss": "^8.5.3", "prettier": "^3.5.3", "prettier-plugin-tailwindcss": "^0.6.11", + "tsx": "^4.19.4", "typescript": "^5.8.3", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.1.4" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..46f0fb5 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,40 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./e2e", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + timeout: 30 * 1000, // Default timeout for each test + use: { + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + video: "off", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "npm run start", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 265af17..d3e5486 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import "@rainbow-me/rainbowkit/styles.css"; import type { Metadata } from "next"; import { PropsWithChildren } from "react"; import "./globals.css"; +import { isE2eTestEnabled } from '@e2e/config/e2e.constants'; export const metadata: Metadata = { title: "Umbrella UI", @@ -29,7 +30,9 @@ export default function RootLayout({ children }: Readonly) { return ( + { !isE2eTestEnabled &&