diff --git a/frontend/package.json b/frontend/package.json index 3b79383..9aa756e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,9 @@ "start": "next start", "lint": "next lint", "test:e2e": "playwright test", - "test:visual": "playwright test tests/e2e/vrt.spec.ts --project=chromium", - "test:visual:update": "playwright test tests/e2e/vrt.spec.ts --project=chromium --update-snapshots" + "test:e2e:update": "playwright test --update-snapshots", + "test:visual": "playwright test tests/e2e/checkout.visual.spec.ts tests/e2e/create-payment.visual.spec.ts", + "test:visual:update": "playwright test tests/e2e/checkout.visual.spec.ts tests/e2e/create-payment.visual.spec.ts --update-snapshots" }, "dependencies": { "@ducanh2912/next-pwa": "^10.2.9", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index f5d1b49..b87f8fb 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,32 +1,48 @@ import { defineConfig, devices } from "@playwright/test"; +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; + export default defineConfig({ testDir: "./tests/e2e", + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? "github" : "list", + forbidOnly: !!process.env.CI, + snapshotPathTemplate: + "{testDir}/__screenshots__/{testFilePath}/{arg}-{projectName}{ext}", expect: { toHaveScreenshot: { animations: "disabled", caret: "hide", scale: "css", + maxDiffPixelRatio: 0.01, }, }, use: { baseURL: "http://127.0.0.1:3000", locale: "en-US", timezoneId: "UTC", - viewport: { - width: 1440, - height: 1200, - }, + trace: "on-first-retry", }, webServer: { - command: "npm run dev", + command: `${npmCommand} run dev -- --hostname 127.0.0.1 --port 3000`, url: "http://127.0.0.1:3000", reuseExistingServer: true, timeout: 120 * 1000, }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "firefox", use: { ...devices["Desktop Firefox"] } }, - { name: "webkit", use: { ...devices["Desktop Safari"] } }, + { + name: "desktop-chrome", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1440, height: 1100 }, + }, + }, + { + name: "mobile-chrome", + use: { + ...devices["Pixel 7"], + }, + }, ], }); diff --git a/frontend/src/app/(authenticated)/dashboard/create/page.tsx b/frontend/src/app/(authenticated)/dashboard/create/page.tsx index 394b8d9..23c2eee 100644 --- a/frontend/src/app/(authenticated)/dashboard/create/page.tsx +++ b/frontend/src/app/(authenticated)/dashboard/create/page.tsx @@ -12,7 +12,7 @@ export default async function CreatePaymentPage() { const t = await getTranslations("createPaymentPage"); return ( -
+

{t("eyebrow")} @@ -25,7 +25,7 @@ export default async function CreatePaymentPage() {

-
+
diff --git a/frontend/src/app/(authenticated)/layout.tsx b/frontend/src/app/(authenticated)/layout.tsx index 94cc98e..d2fb8a2 100644 --- a/frontend/src/app/(authenticated)/layout.tsx +++ b/frontend/src/app/(authenticated)/layout.tsx @@ -18,15 +18,15 @@ export default function AuthenticatedLayout({ return ( -
+
{/* Sidebar - fixed width for desktop layout offset */} {/* Main Content Area */} -
-
+
+
{/* Header with Breadcrumbs */}
@@ -41,7 +41,7 @@ export default function AuthenticatedLayout({ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.4, ease: "easeOut" }} - className="pb-20 lg:pb-0" + className="min-w-0 pb-20 lg:pb-0" > {children} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 3ab1551..8336ad7 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -4,6 +4,8 @@ :root { color-scheme: light; + --font-sans: "Segoe UI", "Helvetica Neue", Arial, sans-serif; + --font-mono: "Cascadia Code", Consolas, "SFMono-Regular", monospace; } :root.dark { diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index a58ac9c..a4f7cfd 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -8,12 +8,11 @@ import ToastProvider from "@/components/ToastProvider"; import CommandPalette from "@/components/CommandPalette"; import KeyboardShortcuts from "@/components/KeyboardShortcuts"; import { WalletContextProvider } from "@/lib/wallet-context"; +import { Metadata, Viewport } from "next"; const spaceGrotesk = Space_Grotesk({ subsets: ["latin"], variable: "--font-sans", display: "swap" }); const spaceMono = Space_Mono({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-mono", display: "swap" }); -import { Metadata, Viewport } from "next"; - export const metadata: Metadata = { title: "Stellar Payment Dashboard", description: "Accept Stellar payments with simple links and status tracking.", diff --git a/frontend/src/components/CreatePaymentForm.tsx b/frontend/src/components/CreatePaymentForm.tsx index c286fe1..4be88b3 100644 --- a/frontend/src/components/CreatePaymentForm.tsx +++ b/frontend/src/components/CreatePaymentForm.tsx @@ -278,15 +278,19 @@ function SuccessCard({ created, onReset, t }: SuccessCardProps) { export default function CreatePaymentForm() { const t = useTranslations("createPaymentForm"); - const [amount, setAmount] = useState(""); - const [asset, setAsset] = useState<"XLM" | "USDC">("XLM"); - const [recipient, setRecipient] = useState(""); - const [description, setDescription] = useState(""); + const [amount, setAmount] = useLocalStorage("payment_amount", ""); + const [asset, setAsset] = useLocalStorage<"XLM" | "USDC">( + "payment_asset", + "XLM", + ); + const [recipient, setRecipient] = useLocalStorage("payment_recipient", ""); + const [description, setDescription] = useLocalStorage( + "payment_description", + "", + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [created, setCreated] = useState(null); - - const apiKey = useMerchantApiKey(); const hydrated = useMerchantHydrated(); const trustedAddresses = useMerchantTrustedAddresses(); @@ -302,8 +306,6 @@ export default function CreatePaymentForm() { "payment_trusted_address", "", ); - - useHydrateMerchantStore(); // ── Rate-limit countdown ────────────────────────────────── diff --git a/frontend/tests/e2e/checkout-branding.spec.ts b/frontend/tests/e2e/checkout-branding.spec.ts deleted file mode 100644 index 62b31a5..0000000 --- a/frontend/tests/e2e/checkout-branding.spec.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { expect, test } from "@playwright/test"; - -const API_BASE = "http://localhost:4000"; - -test("falls back to default checkout theme when branding is null", async ({ page }) => { - await page.route(`${API_BASE}/api/payment-status/**`, async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - payment: { - id: "f4e8deaa-8a11-47b3-9b27-a95fa38374f4", - amount: 10, - asset: "XLM", - asset_issuer: null, - recipient: "GRECIPIENTADDRESS", - description: "Test payment", - status: "pending", - tx_id: null, - created_at: new Date().toISOString(), - branding_config: null, - }, - }), - }); - }); - - await page.goto("/pay/f4e8deaa-8a11-47b3-9b27-a95fa38374f4"); - await expect(page.getByText("Complete Payment")).toBeVisible(); - - const checkoutPrimary = await page - .locator("main") - .evaluate((el) => getComputedStyle(el).getPropertyValue("--checkout-primary").trim()); - expect(checkoutPrimary).toBe("#5ef2c0"); -}); - -test("renders checkout with custom session branding and matches snapshot", async ({ page }) => { - await page.route(`${API_BASE}/api/payment-status/**`, async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - payment: { - id: "f4e8deaa-8a11-47b3-9b27-a95fa38374f4", - amount: 10, - asset: "XLM", - asset_issuer: null, - recipient: "GRECIPIENTADDRESS", - description: "Styled payment", - status: "pending", - tx_id: null, - created_at: new Date().toISOString(), - branding_config: { - primary_color: "#ff0066", - secondary_color: "#ffd9e8", - background_color: "#1b0b14", - }, - }, - }), - }); - }); - - await page.goto("/pay/f4e8deaa-8a11-47b3-9b27-a95fa38374f4"); - await expect(page.getByText("Complete Payment")).toBeVisible(); - - await expect(page).toHaveScreenshot("checkout-custom-branding.png", { - fullPage: true, - }); -}); diff --git a/frontend/tests/e2e/checkout.visual.spec.ts b/frontend/tests/e2e/checkout.visual.spec.ts new file mode 100644 index 0000000..91ecad8 --- /dev/null +++ b/frontend/tests/e2e/checkout.visual.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; +import { + checkoutPaymentId, + expectNoHorizontalOverflow, + mockCheckoutPayment, + prepareVisualSnapshot, +} from "./helpers/fixtures"; + +test.describe("Checkout Visual Regression", () => { + test.beforeEach(async ({ page }) => { + await mockCheckoutPayment(page); + await page.goto(`/pay/${checkoutPaymentId}`); + await prepareVisualSnapshot(page); + }); + + test("checkout layout remains stable across viewports", async ({ page }) => { + const checkoutMain = page.locator("main"); + await expect(checkoutMain).toBeVisible(); + await expect(page.getByText("Complete Payment")).toBeVisible(); + await expect(page.getByText("Styled payment")).toBeVisible(); + + const noOverflow = await expectNoHorizontalOverflow(page); + expect(noOverflow).toBeTruthy(); + + await expect(checkoutMain).toHaveScreenshot("checkout-page.png"); + }); +}); diff --git a/frontend/tests/e2e/create-payment.visual.spec.ts b/frontend/tests/e2e/create-payment.visual.spec.ts new file mode 100644 index 0000000..491a0bc --- /dev/null +++ b/frontend/tests/e2e/create-payment.visual.spec.ts @@ -0,0 +1,28 @@ +import { expect, test } from "@playwright/test"; +import { + expectNoHorizontalOverflow, + prepareVisualSnapshot, + seedMerchantSession, +} from "./helpers/fixtures"; + +test.describe("Create Payment Visual Regression", () => { + test.beforeEach(async ({ page }) => { + await seedMerchantSession(page); + await page.goto("/dashboard/create"); + await prepareVisualSnapshot(page); + }); + + test("create payment form remains visually stable", async ({ page }) => { + const heading = page.getByRole("heading", { name: "Create Payment Link" }); + const formShell = heading.locator("xpath=ancestor::main[1]"); + + await expect(heading).toBeVisible(); + await expect(formShell).toBeVisible(); + await expect(page.locator("select#trusted-address")).toBeVisible(); + + const noOverflow = await expectNoHorizontalOverflow(page); + expect(noOverflow).toBeTruthy(); + + await expect(formShell).toHaveScreenshot("create-payment-form.png"); + }); +}); diff --git a/frontend/tests/e2e/helpers/fixtures.ts b/frontend/tests/e2e/helpers/fixtures.ts new file mode 100644 index 0000000..cafd94f --- /dev/null +++ b/frontend/tests/e2e/helpers/fixtures.ts @@ -0,0 +1,101 @@ +import type { Page } from "@playwright/test"; + +const API_BASE = "http://localhost:4000"; +const MERCHANT_TOKEN_KEY = "merchant_token"; + +function createMerchantToken() { + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })) + .toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + id: merchantMetadata.id, + email: merchantMetadata.email, + exp: Math.floor(Date.now() / 1000) + 60 * 60, + }), + ).toString("base64url"); + + return `${header}.${payload}.visual-test-signature`; +} + +const merchantMetadata = { + id: "merchant_test_123", + email: "merchant@example.com", + business_name: "Test Merchant", + notification_email: "merchant@example.com", + api_key: "sk_test_123", + webhook_secret: "whsec_test_123", + trusted_addresses: [ + { + id: "addr_1", + label: "Treasury Wallet", + address: "GBZXN7PIRZGNMHGA6XSPU4IQQQ4JVCN6PWPB6T7N7CEJ5JQXBSV5Z5PX", + created_at: "2026-03-27T00:00:00.000Z", + }, + ], + created_at: "2026-03-27T00:00:00.000Z", +}; + +export const checkoutPaymentId = "f4e8deaa-8a11-47b3-9b27-a95fa38374f4"; + +export async function seedMerchantSession(page: Page) { + await stabilizeVisualTestPage(page); + const token = createMerchantToken(); + await page.addInitScript((merchant) => { + localStorage.setItem("theme", "dark"); + localStorage.setItem("merchant_token", merchant.token); + localStorage.setItem("merchant_api_key", merchant.api_key); + localStorage.setItem("merchant_metadata", JSON.stringify(merchant)); + }, { ...merchantMetadata, token }); +} + +export async function mockCheckoutPayment(page: Page, overrides: Record = {}) { + await stabilizeVisualTestPage(page); + await page.route(`${API_BASE}/api/payment-status/**`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + payment: { + id: checkoutPaymentId, + amount: 10, + asset: "XLM", + asset_issuer: null, + recipient: "GRECIPIENTADDRESS", + description: "Styled payment", + status: "pending", + tx_id: null, + created_at: "2026-03-27T00:00:00.000Z", + branding_config: { + primary_color: "#ff0066", + secondary_color: "#ffd9e8", + background_color: "#1b0b14", + }, + ...overrides, + }, + }), + }); + }); +} + +export async function stabilizeVisualTestPage(page: Page) { + await page.emulateMedia({ + colorScheme: "dark", + reducedMotion: "reduce", + }); +} + +export async function prepareVisualSnapshot(page: Page) { + await page.waitForLoadState("networkidle"); + await page.evaluate(async () => { + await document.fonts.ready; + document.documentElement.classList.add("dark"); + document.body.classList.add("dark"); + }); +} + +export async function expectNoHorizontalOverflow(page: Page) { + return page.evaluate(() => { + const root = document.documentElement; + return root.scrollWidth <= root.clientWidth; + }); +} diff --git a/frontend/tests/e2e/vrt.spec.ts b/frontend/tests/e2e/vrt.spec.ts deleted file mode 100644 index b323ed6..0000000 --- a/frontend/tests/e2e/vrt.spec.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { test, expect } from "@playwright/test"; - -test.describe("Visual Regression Tests for Core Components", () => { - test.beforeEach(async ({ page }) => { - // Navigate to the VRT component testing page - await page.goto("/vrt"); - // Ensure the page is fully loaded and hydrated - await page.waitForLoadState("networkidle"); - }); - - test("Buttons should match visual baseline", async ({ page }) => { - const buttonsSection = page.locator("#vrt-buttons"); - await expect(buttonsSection).toBeVisible(); - - // Check match for buttons section which has primary, secondary, loading, disabled - expect(await buttonsSection.screenshot()).toMatchSnapshot("buttons-core.png"); - }); - - test("Copy button shows premium glitch success feedback", async ({ page, browserName }) => { - test.skip(browserName !== "chromium", "Clipboard permission check is only verified in chromium."); - - await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); - - const copyButton = page.getByRole("button", { name: "Copy to clipboard" }); - await expect(copyButton).toBeVisible(); - - await copyButton.click(); - - await expect(page.getByText("Copied!")).toBeVisible(); - await expect(copyButton).toHaveScreenshot("copy-button-success.png", { - animations: "allow", - }); - }); - - test("Inputs should match visual baseline", async ({ page }) => { - const inputsSection = page.locator("#vrt-inputs"); - await expect(inputsSection).toBeVisible(); - - // Fill an input dynamically to capture it - const defaultInput = page.getByTestId("vrt-input-default"); - await defaultInput.fill("test@stellar.org"); - // Unfocus to remove active cursor from screenshot if desired - await page.keyboard.press("Tab"); - - // Check match for inputs section - expect(await inputsSection.screenshot()).toMatchSnapshot("inputs-core.png"); - }); - - test("Modals should match visual baseline", async ({ page }) => { - // Click button to open modal - const openBtn = page.getByTestId("open-modal-btn"); - await openBtn.click(); - - // Check if modal backdrop and content are visible - const modalContent = page.getByTestId("modal-content"); - await expect(modalContent).toBeVisible(); - - // Give it a tiny moment to finish potential css transitions - await page.waitForTimeout(300); - - // Snapshot the entire viewport because it's a modal overlay - expect(await page.screenshot()).toMatchSnapshot("modal-core.png"); - }); -});