Skip to content
Merged
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
3 changes: 3 additions & 0 deletions admin-dashboard/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

# testing
/coverage
/playwright-report/
/test-results/
/e2e/.auth/

# next.js
/.next/
Expand Down
4 changes: 1 addition & 3 deletions admin-dashboard/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,4 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
},
},
pages: { signIn: "/login" },
};

export const { handlers, auth, signIn, signOut } = NextAuth(authConfig);
});
19 changes: 19 additions & 0 deletions admin-dashboard/components/dashboard/TelemetryConsentSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

/**
* Telemetry consent toggle for the admin dashboard.
* Stub implementation to support E2E and local dev when telemetry backend is unavailable.
*/
export function TelemetryConsentSettings() {
return (
<section
aria-label="Telemetry consent settings"
className="rounded-3xl border border-border/50 bg-card p-6 shadow-sm"
>
<h2 className="text-base font-semibold text-foreground">Telemetry</h2>
<p className="mt-2 text-sm text-muted-foreground">
Anonymous usage telemetry helps improve Fluid. You can opt out at any time.
</p>
</section>
);
}
47 changes: 47 additions & 0 deletions admin-dashboard/e2e/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { test, expect } from "@playwright/test";

test.describe("Authentication", () => {
test("login page renders email and password fields", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("domcontentloaded");

await expect(page.getByRole("heading", { name: /admin login/i })).toBeVisible({
timeout: 15_000,
});
await expect(page.locator("#email")).toBeVisible();
await expect(page.locator("#password")).toBeVisible();
await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible();
});

test("unauthenticated access to /admin redirects to login", async ({ page }) => {
await page.goto("/admin/dashboard");
await expect(page).toHaveURL(/\/login/, { timeout: 15_000 });
});

test("invalid credentials show error message", async ({ page }) => {
await page.goto("/login");
await page.waitForLoadState("domcontentloaded");

await page.locator("#email").fill("wrong@example.com");
await page.locator("#password").fill("wrong-password");
await page.getByRole("button", { name: /sign in/i }).click();

await expect(page.getByText(/invalid credentials/i)).toBeVisible({ timeout: 15_000 });
});
});

test.describe("Authenticated admin session", () => {
test.use({ storageState: "e2e/.auth/admin.json" });

test.beforeEach(async () => {
const fs = await import("node:fs");
if (!fs.existsSync("e2e/.auth/admin.json")) {
test.skip(true, "Run global setup first (npm run test:e2e)");
}
});

test("authenticated user reaches admin dashboard", async ({ page }) => {
await page.goto("/admin/dashboard");
await expect(page).toHaveURL(/\/admin\/dashboard/, { timeout: 15_000 });
});
});
40 changes: 40 additions & 0 deletions admin-dashboard/e2e/global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import bcrypt from "bcryptjs";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { chromium, type FullConfig } from "@playwright/test";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const AUTH_FILE = path.join(__dirname, ".auth", "admin.json");

export default async function globalSetup(config: FullConfig): Promise<void> {
const email = process.env.ADMIN_EMAIL ?? "e2e-admin@fluid.dev";
const password = process.env.ADMIN_PASSWORD ?? "e2e-test-password";
const authSecret =
process.env.AUTH_SECRET ?? "e2e-test-auth-secret-min-32-chars-long";

process.env.AUTH_SECRET = authSecret;
process.env.ADMIN_EMAIL = email;
process.env.ADMIN_PASSWORD_HASH =
process.env.ADMIN_PASSWORD_HASH ?? bcrypt.hashSync(password, 4);

const baseURL =
config.projects[0]?.use?.baseURL?.toString() ??
process.env.PLAYWRIGHT_BASE_URL ??
"http://127.0.0.1:3001";

const browser = await chromium.launch();
const page = await browser.newPage();

await page.goto(`${baseURL}/login`);
await page.getByPlaceholder(/email address/i).fill(email);
await page.getByPlaceholder(/^password$/i).fill(password);
await page.getByRole("button", { name: /sign in/i }).click();

await page.waitForURL(/\/admin\/dashboard/, { timeout: 60_000 });

fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true });
await page.context().storageState({ path: AUTH_FILE });

await browser.close();
}
37 changes: 37 additions & 0 deletions admin-dashboard/e2e/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test, expect } from "@playwright/test";

test.describe("Settings panel", () => {
test.use({ storageState: "e2e/.auth/admin.json" });

test.beforeEach(async () => {
const fs = await import("node:fs");
if (!fs.existsSync("e2e/.auth/admin.json")) {
test.skip(true, "Run global setup first (npm run test:e2e:setup)");
}
});

test("settings page loads fee and rate limit fields", async ({ page }) => {
await page.goto("/admin/settings");

await expect(page.getByText(/fee configuration/i)).toBeVisible();
await expect(page.getByLabel(/base fee/i)).toBeVisible();
await expect(page.getByLabel(/fee multiplier/i)).toBeVisible();
await expect(page.getByText(/rate & quota limits/i)).toBeVisible();
await expect(page.getByLabel(/^rate limit$/i)).toBeVisible();
});

test("settings form accepts input and shows save button", async ({ page }) => {
await page.goto("/admin/settings");

const baseFeeInput = page.locator("#base_fee");
await baseFeeInput.clear();
await baseFeeInput.fill("150");

await expect(page.getByRole("button", { name: /save & hot-reload/i })).toBeEnabled();
});

test("reset to defaults button is present", async ({ page }) => {
await page.goto("/admin/settings");
await expect(page.getByRole("button", { name: /reset to defaults/i })).toBeVisible();
});
});
61 changes: 61 additions & 0 deletions admin-dashboard/e2e/tables.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { test, expect } from "@playwright/test";

test.describe("Responsive data tables", () => {
test("table preview page renders transactions and signers sections", async ({ page }) => {
await page.goto("/table-preview");
await page.waitForLoadState("domcontentloaded");

await expect(page.getByRole("heading", { name: /responsive data tables/i })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText(/transactions/i).first()).toBeVisible();
await expect(page.getByText(/signers/i).first()).toBeVisible();
});

test("transactions table shows sample rows on desktop", async ({ page }) => {
await page.goto("/table-preview");
await page.waitForLoadState("networkidle");

await expect(page.locator("table").first()).toBeVisible({ timeout: 15_000 });
await expect(page.locator("tbody tr").first()).toBeVisible();
});

test("tables are scrollable on mobile viewports", async ({ page, isMobile }) => {
test.skip(!isMobile, "Mobile-only layout check");

await page.goto("/table-preview");
await expect(page.getByRole("heading", { name: /responsive data tables/i })).toBeVisible({
timeout: 15_000,
});

const main = page.locator("main");
const box = await main.boundingBox();
expect(box).not.toBeNull();
expect(box!.width).toBeLessThanOrEqual(500);
});
});

test.describe("Admin tables (authenticated)", () => {
test.use({ storageState: "e2e/.auth/admin.json" });

test.beforeEach(async () => {
const fs = await import("node:fs");
if (!fs.existsSync("e2e/.auth/admin.json")) {
test.skip(true, "Run global setup first (npm run test:e2e)");
}
});

test("transactions preview page loads", async ({ page }) => {
await page.goto("/transactions-preview");
await expect(page.getByRole("heading", { name: /transaction history table/i })).toBeVisible({
timeout: 15_000,
});
});

test("signers preview page loads keypool management UI", async ({ page }) => {
await page.goto("/signers-preview");
await expect(page.getByRole("heading", { name: /keypool management/i })).toBeVisible({
timeout: 15_000,
});
});
});
1 change: 1 addition & 0 deletions admin-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:unit": "node --test --experimental-test-isolation=none --experimental-strip-types lib/audit-logs-data.test.ts lib/server-log-stream.test.ts lib/portal-links.test.ts lib/theme.test.ts lib/api-key-usage-data.test.ts lib/settings-config.test.ts lib/export-leaderboard.test.ts src/compliance/__tests__/compliance.test.ts src/fees/__tests__/region-congestion-config.test.ts src/fees/__tests__/localized-fee-estimator.test.ts src/i18n/__tests__/i18n.test.ts",
"test:integration": "node --test --experimental-test-isolation=none --experimental-strip-types lib/theme.integration.test.ts src/compliance/__tests__/compliance.integration.test.ts",
"test:e2e": "playwright test",
"test:e2e:chromium": "playwright test --project=chromium",
"docs": "typedoc",
"storybook": "storybook dev -p 6006",
"storybook:build": "storybook build"
Expand Down
61 changes: 61 additions & 0 deletions admin-dashboard/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { defineConfig, devices } from "@playwright/test";
import bcrypt from "bcryptjs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3001";
const e2ePassword = process.env.ADMIN_PASSWORD ?? "e2e-test-password";

export default defineConfig({
testDir: "./e2e",
globalSetup: path.join(__dirname, "e2e", "global-setup.ts"),
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chrome",
use: { ...devices["Pixel 5"] },
},
{
name: "mobile-safari",
use: { ...devices["iPhone 13"] },
},
],
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
? undefined
: {
command: "npm run dev",
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
AUTH_SECRET:
process.env.AUTH_SECRET ??
"e2e-test-auth-secret-min-32-chars-long",
ADMIN_EMAIL: process.env.ADMIN_EMAIL ?? "e2e-admin@fluid.dev",
ADMIN_PASSWORD_HASH:
process.env.ADMIN_PASSWORD_HASH ?? bcrypt.hashSync(e2ePassword, 4),
},
},
});
41 changes: 41 additions & 0 deletions docs/congestion-fee-simulator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Congestion Simulator for Dynamic Fee Assertions (#731)

Simulates network congestion by driving `FeeManager` with configurable Horizon `fee_stats` responses and verifies Node/Rust fee-bump parity.

## Usage

```bash
cd server
npm run congestion:simulate
```

## How it works

1. Starts an in-process mock Horizon server with configurable `p70`/`p95` fee percentiles
2. Initializes `FeeManager` and calls `pollOnce()` for each congestion scenario
3. Computes fee-bump amounts via Node (`calculateFeeBumpFee`) and Rust formula mirror (`feeParity.ts`)
4. Reports PASS/FAIL for each scenario

## Scenarios

| Scenario | p70 | p95 | Expected multiplier |
|----------|-----|-----|---------------------|
| low-congestion | 90 | 120 | 1.0 |
| high-congestion | 300 | 800 | 2.0 |
| boundary-at-threshold | 400 | 400 | 2.0 |
| just-below-threshold | 399 | 399 | 1.0 |

Threshold: `max(p70, p95) / baseFee >= 4` → multiplier 2.0

## Unit tests

```bash
cd server
npm test -- feeParity
```

## Parity formula

Both stacks use: `ceil((operationCount + 1) * baseFee * multiplier)`

Node additionally applies: `max(calculated, innerFee + baseFee)` for high inner-fee transactions.
53 changes: 53 additions & 0 deletions docs/cross-browser-e2e-admin-dashboard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Cross-Browser E2E Tests for Admin Dashboard (#722)

Playwright is configured to run E2E tests across Chromium, Firefox, WebKit (Safari), and mobile viewports.

## Browser matrix

| Project | Engine | Device |
|---------|--------|--------|
| chromium | Chrome | Desktop Chrome |
| firefox | Firefox | Desktop Firefox |
| webkit | Safari | Desktop Safari |
| mobile-chrome | Chrome | Pixel 5 |
| mobile-safari | WebKit | iPhone 13 |

## Test suites

| Spec | Coverage |
|------|----------|
| `e2e/auth.spec.ts` | Login page, redirect, invalid credentials, authenticated dashboard |
| `e2e/settings.spec.ts` | Settings form fields, input, save/reset buttons |
| `e2e/tables.spec.ts` | Table preview, transactions/signers preview, mobile layout |
| `e2e/referral-program.spec.ts` | Referral programme (existing) |

## Running tests

```bash
cd admin-dashboard

# Install browsers (first time)
npx playwright install --with-deps chromium firefox webkit

# All browsers
npm run test:e2e

# Chromium only (faster local iteration)
npm run test:e2e:chromium
```

## Configuration

- `playwright.config.ts` — projects, webServer, globalSetup
- `e2e/global-setup.ts` — authenticates admin user and saves `storageState`
- Default base URL: `http://127.0.0.1:3001` (matches `npm run dev`)

## Environment variables

| Variable | Purpose |
|----------|---------|
| `ADMIN_EMAIL` | E2E admin login email |
| `ADMIN_PASSWORD` | E2E admin plain-text password |
| `AUTH_SECRET` | NextAuth JWT secret |
| `PLAYWRIGHT_BASE_URL` | Override target URL |
| `PLAYWRIGHT_SKIP_WEBSERVER` | Skip auto-starting dev server |
Loading
Loading