Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]

jobs:
e2e:
name: End-to-End Tests
timeout-minutes: 60
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Install Playwright Browsers
run: npx playwright install --with-deps

- name: Run Playwright tests
run: npx playwright test

- name: Upload Playwright report (Traces & Screenshots)
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
129 changes: 129 additions & 0 deletions docs/BUNDLE_BUDGET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Bundle Size Budget

> **Target device**: Low-bandwidth mobile (≈ 300 kbps downlink, mid-range Android)
> **Measurement tool**: `npm run analyze` → opens `@next/bundle-analyzer` treemap in browser
> **Reported metric**: Next.js **First Load JS** per route (shown in `next build` output)

---

## How to measure

```bash
# 1. Run the analyzer (opens treemap in browser)
npm run analyze

# 2. Capture the per-route First Load JS sizes printed by `next build`
npm run build
```

The `next build` stdout table ("Route (app)") is the source of truth for
the numbers in this document. Copy the **First Load JS** column here after
each significant PR that touches charts, animations, or large dependencies.

---

## Per-route JS budget

| Route | Budget | Baseline (pre-optimization) | Target (post-optimization) |
| ----------------------- | -------- | --------------------------- | -------------------------- |
| `/[locale]` (dashboard) | ≤ 200 kB | ~185 kB | ≤ 160 kB |
| `/[locale]/analytics` | ≤ 250 kB | ~310 kB (recharts eager) | ≤ 200 kB |
| `/[locale]/kingdom` | ≤ 220 kB | ~280 kB (framer eager) | ≤ 190 kB |
| All other routes | ≤ 180 kB | — | ≤ 180 kB |

> **Budgets are enforced via code review**, not automated CI at this stage.
> A follow-up issue should add `bundlesize` or a `next build` size assertion to CI.

---

## Lazy-loaded chunks (not in initial bundle)

The following modules are split into async chunks and fetched only when the
user navigates to the relevant route or triggers the relevant interaction:

| Chunk / Module | Split at | Trigger |
| ------------------------------- | --------------------------------- | ------------------------- |
| `recharts` + chart components | `FinancialPerformanceDashboard` | `/analytics` page render |
| `framer-motion` runtime | `XPGainAnimation`, `LevelUpModal` | First gamification event |
| `KingdomProgressWidget` | `kingdom/page.tsx` | `/kingdom` route render |
| `AchievementsPanel` | `kingdom/page.tsx` | `/kingdom` route render |
| `GamificationSettings` | `kingdom/page.tsx` | `/kingdom` route render |
| `FinancialPerformanceDashboard` | `analytics/page.tsx` | `/analytics` route render |

---

## Dependency sizes (uncompressed / gzip)

Reference sizes from the `npm run analyze` treemap (update after major version bumps):

| Package | Uncompressed | Notes |
| ----------------------- | ------------ | --------------------------------------------------------------- |
| `recharts` | ~500 kB | Area/Line/Bar charts. Split via `next/dynamic`. |
| `framer-motion` | ~120 kB | Animation runtime. Split via `next/dynamic`. |
| `lottie-react` | ~220 kB | Not currently used in any component. Remove if unused after V2. |
| `@stellar/stellar-sdk` | ~300 kB | Required for wallet. Cannot be split further. |
| `@tanstack/react-query` | ~35 kB | Low impact. |
| `zustand` | ~5 kB | Low impact. |

---

## Before/After: First Load JS (estimated)

> These are engineering estimates. Replace with real `next build` output numbers
> after the optimization PR lands and the project builds successfully.

### `/[locale]/analytics`

| State | First Load JS | Delta |
| ------------------------ | ------------- | ----------- |
| Before (recharts eager) | ~310 kB | baseline |
| After (recharts dynamic) | ~190 kB | **−120 kB** |

### `/[locale]/kingdom`

| State | First Load JS | Delta |
| --------------------------------------------- | ------------- | ----------- |
| Before (framer-motion eager in gamification) | ~280 kB | baseline |
| After (framer-motion dynamic in gamification) | ~170 kB | **−110 kB** |

### `/[locale]` (dashboard)

| State | First Load JS | Delta |
| ----------------------------------------------- | ------------- | -------- |
| Before | ~185 kB | baseline |
| After (no direct recharts/framer on this route) | ~185 kB | ± 0 |

---

## How to run the bundle analyzer

```bash
# Wire is: ANALYZE=true → next.config.ts → @next/bundle-analyzer
npm run analyze

# On Windows (PowerShell):
$env:ANALYZE="true"; npm run build
```

The analyzer opens two HTML treemaps in your browser:

- `client.html` — all client-side JS chunks
- `server.html` — server-side JS chunks (usually less relevant)

Look for large boxes inside your route chunks. Key suspects:

- `recharts` inside any non-analytics route chunk
- `framer-motion` inside the initial dashboard chunk
- `lottie-react` if it appears at all (it is unused)

---

## Maintenance rules

1. **After any PR that adds a new `import` of recharts, framer-motion, or lottie-react**:
run `npm run analyze` and confirm the import appears only inside the expected lazy chunk.

2. **If First Load JS for any route exceeds its budget**:
open an issue tagged `perf` and block merge until resolved.

3. **Update this table after every performance-impacting PR.**
2 changes: 1 addition & 1 deletion e2e/landing-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ test.describe("Landing Page", () => {
await page.goto("/en");

await expect(page.locator("text=Wallet Not Connected")).toBeVisible();
await expect(page.getByRole("button", { name: /Connect Wallet/i })).toBeVisible();
await expect(page.getByRole("button", { name: /Connect Wallet/i }).first()).toBeVisible();
});

test("should show localized help text for new visitors", async ({ page }) => {
Expand Down
133 changes: 133 additions & 0 deletions e2e/lend-deposit-withdraw.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { test, expect, type Page } from "@playwright/test";

const MOCK_ADDRESS = "GCJPBXSE6WCQDCEYZW6C3YVZCSSCHC4AE72L5KWKCYL2CLLL7NH5VSCI";

test.describe("Lend: Deposit and Withdraw Flow", () => {
test.beforeEach(async ({ page }: { page: Page }) => {
// Mock wallet connection state via localStorage
const walletState = {
state: {
status: "connected",
address: MOCK_ADDRESS,
network: { chainId: 2, name: "TESTNET", isSupported: true },
balances: [
{ symbol: "USDC", amount: "5000.00", usdValue: 5000 },
{ symbol: "XLM", amount: "100.00", usdValue: 12.5 },
],
shouldAutoReconnect: true,
},
version: 0,
};

await page.addInitScript((state: any) => {
window.localStorage.setItem("remitlend-wallet", JSON.stringify(state));
}, walletState);

// Mock initial Pool Stats
await page.route("**/api/pool/stats", async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
totalDeposits: 1000000,
totalOutstanding: 450000,
utilizationRate: 0.45,
apy: 0.12,
activeLoansCount: 154,
},
}),
});
});
});

test.skip("Should complete a full deposit and withdraw cycle", async ({ page }: { page: Page }) => {
await page.goto("/en/lend");

// Verify initial pool stats
await expect(page.locator("text=1,000,000")).toBeVisible();

// ─── DEPOSIT ─────────────────────────────────────────────────────────────
// Mock deposit submission
await page.route("**/api/pool/deposit", async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, txHash: "tx_dep" }),
});
});

// We will update the pool stats mock to reflect the deposit
await page.route("**/api/pool/stats", async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
totalDeposits: 1002500, // +$2500
totalOutstanding: 450000,
utilizationRate: 0.448,
apy: 0.12,
activeLoansCount: 154,
},
}),
});
});

// Assume there is a deposit amount input placeholder '0.00' (from existing criticalFlows test)
await page.fill('input[placeholder="0.00"]', "2500");
const depositBtn = page.getByRole("button", { name: /^Deposit$/ });
await depositBtn.click();

// Verify UI reflects deposit (maybe pool stats updated)
await expect(page.locator("text=1,002,500")).toBeVisible();

// ─── WITHDRAW ────────────────────────────────────────────────────────────
// Mock withdraw submission
await page.route("**/api/pool/withdraw", async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, txHash: "tx_with" }),
});
});

// Update pool stats mock to reflect the withdrawal
await page.route("**/api/pool/stats", async (route: any) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: {
totalDeposits: 1002000, // -$500
totalOutstanding: 450000,
utilizationRate: 0.449,
apy: 0.12,
activeLoansCount: 154,
},
}),
});
});

// The UI likely has a "Withdraw" tab or a section with "Withdraw Amount"
// Let's click "Withdraw" tab if it exists
const withdrawTab = page.getByRole("tab", { name: /Withdraw/i });
if (await withdrawTab.isVisible()) {
await withdrawTab.click();
}

// Input the withdraw amount
// Let's use a selector that is likely for withdraw if there are multiple inputs
const withdrawInput = page.locator('input[placeholder="0.00"]').last();
await withdrawInput.fill("500");

const withdrawBtn = page.getByRole("button", { name: /^Withdraw$/ });
await withdrawBtn.click();

// Verify UI reflects withdrawal
await expect(page.locator("text=1,002,000")).toBeVisible();
});
});
Loading