diff --git a/e2e/group-creation.spec.ts b/e2e/group-creation.spec.ts new file mode 100644 index 0000000..7301c6e --- /dev/null +++ b/e2e/group-creation.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Group Creation Form", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/groups/new"); + }); + + test("renders the create group form", async ({ page }) => { + // Check that the form page loads + await expect(page.locator("form, [data-testid='create-group-form']").first()).toBeVisible().catch(() => { + // If no explicit form, check for relevant inputs + return expect(page.locator("input, textarea, select").first()).toBeVisible(); + }); + }); + + test("shows validation error when submitting empty form", async ({ + page, + }) => { + // Find and click the submit button + const submitBtn = page.getByRole("button", { + name: /create|submit|save/i, + }); + if (await submitBtn.isVisible()) { + await submitBtn.click(); + // Check for any validation feedback (HTML5 or custom) + const hasError = + (await page.locator("[class*='error'], [class*='invalid'], :invalid").count()) > 0; + expect(hasError).toBeTruthy(); + } + }); + + test("group name field is present and accepts input", async ({ page }) => { + const nameInput = page + .getByLabel(/group name/i) + .or(page.getByPlaceholder(/group name/i)) + .or(page.locator('input[name="name"]')); + + if (await nameInput.isVisible()) { + await nameInput.fill("Test Savings Group"); + await expect(nameInput).toHaveValue("Test Savings Group"); + } + }); + + test("contribution amount field accepts numeric input", async ({ page }) => { + const amountInput = page + .getByLabel(/contribution/i) + .or(page.getByPlaceholder(/amount/i)) + .or(page.locator('input[name="contributionAmount"], input[type="number"]').first()); + + if (await amountInput.isVisible()) { + await amountInput.fill("100"); + await expect(amountInput).toHaveValue("100"); + } + }); + + test("max members field accepts numeric input", async ({ page }) => { + const membersInput = page + .getByLabel(/max members/i) + .or(page.getByPlaceholder(/members/i)) + .or(page.locator('input[name="maxMembers"]')); + + if (await membersInput.isVisible()) { + await membersInput.fill("5"); + await expect(membersInput).toHaveValue("5"); + } + }); +}); diff --git a/e2e/home.spec.ts b/e2e/home.spec.ts new file mode 100644 index 0000000..0843218 --- /dev/null +++ b/e2e/home.spec.ts @@ -0,0 +1,55 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Landing Page", () => { + test("renders the hero section with correct heading", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("heading", { + name: "Decentralized Group Savings for Everyone", + }) + ).toBeVisible(); + }); + + test("displays the SoroSave brand name in the navbar", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("link", { name: "SoroSave" })).toBeVisible(); + }); + + test("shows Browse Groups and Create a Group CTA buttons", async ({ + page, + }) => { + await page.goto("/"); + await expect( + page.getByRole("link", { name: "Browse Groups" }) + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Create a Group" }) + ).toBeVisible(); + }); + + test("displays the How It Works section with 4 steps", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "How It Works" }) + ).toBeVisible(); + await expect(page.getByText("Create a Group")).toBeVisible(); + await expect(page.getByText("Members Join")).toBeVisible(); + await expect(page.getByText("Contribute Each Cycle")).toBeVisible(); + await expect(page.getByText("Receive the Pot")).toBeVisible(); + }); + + test("displays the Why SoroSave section", async ({ page }) => { + await page.goto("/"); + await expect( + page.getByRole("heading", { name: "Why SoroSave?" }) + ).toBeVisible(); + await expect(page.getByText("Trustless")).toBeVisible(); + await expect(page.getByText("Transparent")).toBeVisible(); + await expect(page.getByText("Low Cost")).toBeVisible(); + }); + + test("footer has GitHub link", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("link", { name: "GitHub" })).toBeVisible(); + }); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..bdc764d --- /dev/null +++ b/e2e/navigation.spec.ts @@ -0,0 +1,38 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Navigation", () => { + test("navigates to groups page via navbar link", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Groups" }).first().click(); + await expect(page).toHaveURL(/.*\/groups/); + }); + + test("navigates to create group page via navbar link", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Create Group" }).first().click(); + await expect(page).toHaveURL(/.*\/groups\/new/); + }); + + test("navigates to groups page from hero CTA", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Browse Groups" }).click(); + await expect(page).toHaveURL(/.*\/groups/); + }); + + test("navigates to create group from hero CTA", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Create a Group" }).click(); + await expect(page).toHaveURL(/.*\/groups\/new/); + }); + + test("SoroSave logo navigates back to home", async ({ page }) => { + await page.goto("/groups"); + await page.getByRole("link", { name: "SoroSave" }).click(); + await expect(page).toHaveURL(/.*\/$/); + }); + + test("page title is correct on home page", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/SoroSave/); + }); +}); diff --git a/e2e/wallet.spec.ts b/e2e/wallet.spec.ts new file mode 100644 index 0000000..091852e --- /dev/null +++ b/e2e/wallet.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "@playwright/test"; + +/** + * Wallet connection tests with mocked Freighter extension. + * These tests mock the @stellar/freighter-api to simulate wallet behavior + * without requiring the actual browser extension. + */ +test.describe("Wallet Connection", () => { + test.beforeEach(async ({ page }) => { + // Mock the Freighter wallet API + await page.addInitScript(() => { + // Mock window.freighter to simulate installed extension + (window as any).freighter = { + isConnected: () => Promise.resolve(true), + getPublicKey: () => + Promise.resolve("GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKZM7CPKKRJOCUAVHKIO1"), + signTransaction: (xdr: string) => Promise.resolve(xdr), + getNetwork: () => Promise.resolve("TESTNET"), + }; + + // Mock the freighter-api module responses + Object.defineProperty(window, "freighterMock", { + value: { + installed: true, + publicKey: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGKZM7CPKKRJOCUAVHKIO1", + }, + }); + }); + }); + + test("Connect Wallet button is visible in navbar", async ({ page }) => { + await page.goto("/"); + const connectBtn = page.getByRole("button", { + name: /connect wallet|connect|freighter/i, + }); + await expect(connectBtn).toBeVisible(); + }); + + test("wallet connect button shows correct initial state", async ({ + page, + }) => { + await page.goto("/"); + // Before connection, should show connect text + const connectBtn = page + .getByRole("button", { name: /connect/i }) + .first(); + if (await connectBtn.isVisible()) { + const text = await connectBtn.textContent(); + expect(text?.toLowerCase()).toContain("connect"); + } + }); + + test("clicking connect wallet triggers connection flow", async ({ page }) => { + await page.goto("/"); + const connectBtn = page.getByRole("button", { + name: /connect wallet/i, + }); + if (await connectBtn.isVisible()) { + await connectBtn.click(); + // After clicking, either shows address or an error about missing extension + await page.waitForTimeout(500); + // The button should have changed or an error should be shown + const pageText = await page.textContent("body"); + expect(pageText).toBeTruthy(); + } + }); +}); diff --git a/package.json b/package.json index 7e788f6..02844df 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report" }, "dependencies": { "@sorosave/sdk": "workspace:*", @@ -16,6 +19,7 @@ "react-dom": "^18.3.0" }, "devDependencies": { + "@playwright/test": "^1.44.0", "@types/node": "^20.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..bd04f9b --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * @see https://playwright.dev/docs/test-configuration + */ +export default defineConfig({ + testDir: "./e2e", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: "html", + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000", + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "firefox", + use: { ...devices["Desktop Firefox"] }, + }, + { + name: "Mobile Chrome", + use: { ...devices["Pixel 5"] }, + }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + }, +});