diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 619fed7..0ac0ef9 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -17,8 +17,17 @@ export default defineConfig({ screenshot: "only-on-failure", }, projects: [ - { name: "chromium", use: { ...devices["Desktop Chrome"] } }, - { name: "mobile", use: { ...devices["iPhone 13"] } }, + // ── Desktop browsers ──────────────────────────────────────────────────── + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + { name: "firefox", use: { ...devices["Desktop Firefox"] } }, + { name: "webkit", use: { ...devices["Desktop Safari"] } }, + { + name: "edge", + use: { ...devices["Desktop Edge"], channel: "msedge" }, + }, + // ── Mobile browsers ───────────────────────────────────────────────────── + { name: "mobile-chrome", use: { ...devices["Pixel 5"] } }, + { name: "mobile-safari", use: { ...devices["iPhone 13"] } }, ], webServer: [ { diff --git a/frontend/tests/animation.test.tsx b/frontend/tests/animation.test.tsx new file mode 100644 index 0000000..4239553 --- /dev/null +++ b/frontend/tests/animation.test.tsx @@ -0,0 +1,224 @@ +/** + * Animation and transition tests — Issue #430 + * + * Covers: + * - CoinFlip animation states (idle / flipping / revealed) + * - Modal open/close transition classes + * - LoadingSpinner reduced-motion fallback + * - prefers-reduced-motion media query behaviour + * + * Animation patterns: + * - CoinFlip: CSS `flip` keyframe (1200 ms) applied via `.flipping`; result + * shown via `.showHeads` / `.showTails` transform; reduced-motion skips + * the spin and uses a `fadeReveal` opacity pulse instead. + * - Modal: backdrop + panel fade/scale driven by `.backdropOpen`; reduced- + * motion collapses transition-duration to 0.01 ms and removes scale. + * - LoadingSpinner: `.ring` spins via `spin` keyframe; reduced-motion hides + * the ring and shows a pulsing `.dot` instead. + */ + +import React from "react"; +import { render, screen, act, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { CoinFlip } from "../components/CoinFlip"; +import { LoadingSpinner } from "../components/LoadingSpinner"; +import { Modal } from "../components/Modal"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Simulate prefers-reduced-motion by overriding window.matchMedia. */ +function mockReducedMotion(prefersReduced: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: prefersReduced && query === "(prefers-reduced-motion: reduce)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +} + +// ─── CoinFlip ───────────────────────────────────────────────────────────────── + +describe("CoinFlip — animation states", () => { + it("renders in idle state without flipping class", () => { + const { container } = render(); + const coin = container.querySelector("[class*='coin']"); + expect(coin?.className).not.toMatch(/flipping/); + }); + + it("applies flipping class when state=flipping", () => { + const { container } = render(); + const coin = container.querySelector("[class*='coin']"); + expect(coin?.className).toMatch(/flipping/); + }); + + it("removes flipping class and shows result when state=revealed", () => { + const { container } = render(); + const coin = container.querySelector("[class*='coin']"); + expect(coin?.className).not.toMatch(/flipping/); + expect(coin?.className).toMatch(/showTails/); + }); + + it("shows showHeads class when revealed with heads", () => { + const { container } = render(); + const coin = container.querySelector("[class*='coin']"); + expect(coin?.className).toMatch(/showHeads/); + }); + + it("coin div has onAnimationEnd wired when flipping", () => { + // React synthetic animation events don't fire from raw DOM events in jsdom. + // Verify the coin element exists and the component accepts the callback prop + // without error — the actual invocation is covered by integration/e2e tests. + const onEnd = vi.fn(); + const { container } = render( + + ); + const coin = container.querySelector("[class*='coin']"); + expect(coin).toBeTruthy(); + expect(onEnd).not.toHaveBeenCalled(); // not yet — animation hasn't ended + }); + + it("has aria-live=polite on the scene", () => { + const { container } = render(); + const scene = container.querySelector("[aria-live='polite']"); + expect(scene).toBeTruthy(); + }); + + it("announces result to screen readers when revealed", () => { + render(); + expect(screen.getByText("Result: tails")).toBeInTheDocument(); + }); + + it("does not render sr result text when not revealed", () => { + render(); + expect(screen.queryByText(/Result:/)).toBeNull(); + }); +}); + +// ─── CoinFlip — reduced motion ──────────────────────────────────────────────── + +describe("CoinFlip — reduced-motion", () => { + beforeEach(() => mockReducedMotion(true)); + afterEach(() => vi.restoreAllMocks()); + + it("still applies flipping class (CSS handles the no-spin fallback)", () => { + // The component always adds .flipping; CSS @media reduces the animation. + const { container } = render(); + const coin = container.querySelector("[class*='coin']"); + expect(coin?.className).toMatch(/flipping/); + }); + + it("matchMedia reports prefers-reduced-motion correctly", () => { + expect(window.matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true); + }); +}); + +// ─── LoadingSpinner ─────────────────────────────────────────────────────────── + +describe("LoadingSpinner — animation", () => { + it("renders ring element for standard motion", () => { + const { container } = render(); + expect(container.querySelector("[class*='ring']")).toBeTruthy(); + }); + + it("renders dot element (reduced-motion fallback present in DOM)", () => { + const { container } = render(); + expect(container.querySelector("[class*='dot']")).toBeTruthy(); + }); + + it("renders with correct role and label", () => { + render(); + expect(screen.getByRole("status", { name: "Processing…" })).toBeInTheDocument(); + }); + + it("applies size class for each size variant", () => { + (["small", "medium", "large"] as const).forEach((size) => { + const { container } = render(); + expect(container.querySelector(`[class*='${size}']`)).toBeTruthy(); + }); + }); + + it("wraps in overlay backdrop when mode=overlay", () => { + const { container } = render(); + expect(container.querySelector("[class*='overlayBackdrop']")).toBeTruthy(); + }); +}); + +describe("LoadingSpinner — reduced-motion", () => { + beforeEach(() => mockReducedMotion(true)); + afterEach(() => vi.restoreAllMocks()); + + it("matchMedia signals reduced-motion preference", () => { + expect(window.matchMedia("(prefers-reduced-motion: reduce)").matches).toBe(true); + }); + + it("dot element is present for CSS to activate", () => { + // CSS hides ring and shows dot; both elements must exist in the DOM. + const { container } = render(); + expect(container.querySelector("[class*='dot']")).toBeTruthy(); + expect(container.querySelector("[class*='ring']")).toBeTruthy(); + }); +}); + +// ─── Modal — transitions ────────────────────────────────────────────────────── + +describe("Modal — transition classes", () => { + const Wrapper = ({ open }: { open: boolean }) => ( + + + + + ); + + it("does not render when closed", () => { + render(); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("renders dialog when open", () => { + render(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("applies backdropOpen class when open", async () => { + render(); + // Modal uses a portal; query document.body instead of container. + // CSS modules hash class names, so we verify the backdrop gains a second + // class (the hashed backdropOpen) after the rAF fires. + await waitFor(() => { + const backdrop = document.body.querySelector("[class*='backdrop']"); + expect(backdrop?.classList.length).toBeGreaterThan(1); + }); + }); + + it("dialog has aria-modal=true", () => { + render(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-modal", "true"); + }); + + it("dialog is labelled by titleId", () => { + render(); + expect(screen.getByRole("dialog")).toHaveAttribute("aria-labelledby", "modal-title"); + }); +}); + +describe("Modal — reduced-motion", () => { + beforeEach(() => mockReducedMotion(true)); + afterEach(() => vi.restoreAllMocks()); + + it("still renders and opens correctly under reduced-motion", () => { + render( + +

Hi

+ +
+ ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + const backdrop = document.body.querySelector("[class*='backdrop']"); + expect(backdrop).toBeTruthy(); + }); +}); diff --git a/frontend/tests/e2e/cross-browser.spec.ts b/frontend/tests/e2e/cross-browser.spec.ts new file mode 100644 index 0000000..3bc4467 --- /dev/null +++ b/frontend/tests/e2e/cross-browser.spec.ts @@ -0,0 +1,227 @@ +/** + * Cross-browser compatibility tests — Issue #433 + * + * Runs against all projects defined in playwright.config.ts: + * chromium · firefox · webkit (Safari) · edge · mobile-chrome · mobile-safari + * + * Browser support matrix + * ────────────────────── + * Feature Chrome Firefox Safari Edge Notes + * CSS custom properties ✓ ✓ ✓ ✓ + * CSS 3D transforms ✓ ✓ ✓ ✓ coin flip animation + * prefers-reduced-motion ✓ ✓ ✓ ✓ + * Web Crypto (SHA-256) ✓ ✓ ✓ ✓ commit-reveal + * ES2020 (BigInt, optional ?) ✓ ✓ ✓ ✓ + * CSS :focus-visible ✓ ✓ ✓ ✓ + * dialog / role=dialog ✓ ✓ ✓ ✓ + * Freighter wallet extension ✓ ✓ ✗ ✓ Safari: no extension + * Albedo (web wallet) ✓ ✓ ✓ ✓ popup-based, all browsers + * + * Known limitations + * ───────────────── + * - Safari does not support browser extensions, so Freighter is unavailable. + * The UI must degrade gracefully and offer Albedo as an alternative. + * - Edge uses the Chromium engine; behaviour is identical to Chrome for JS/CSS. + * The Edge project primarily validates channel-specific quirks. + */ + +import { test, expect, type Page, type BrowserContext } from "@playwright/test"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Inject a stub window.freighter so wallet-detection logic can be tested. */ +async function injectFreighterStub(page: Page) { + await page.addInitScript(() => { + (window as any).freighter = { + isConnected: () => Promise.resolve(true), + getPublicKey: () => Promise.resolve("GABC1234TESTPUBLICKEY"), + signTransaction: (_xdr: string) => Promise.resolve({ signedTxXdr: _xdr }), + }; + }); +} + +/** Inject a stub window.albedo for Albedo wallet detection. */ +async function injectAlbedoStub(page: Page) { + await page.addInitScript(() => { + (window as any).albedo = { + publicKey: () => Promise.resolve({ pubkey: "GABC1234TESTPUBLICKEY" }), + }; + }); +} + +// ─── Page load & critical rendering ────────────────────────────────────────── + +test.describe("Page load @cross-browser", () => { + test("landing page loads and renders hero content", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/tossd/i); + // At least one primary CTA or heading must be visible + const hero = page.getByRole("heading", { level: 1 }).or( + page.getByRole("button", { name: /play|start|connect/i }).first() + ); + await expect(hero).toBeVisible(); + }); + + test("no uncaught JS errors on load", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (err) => errors.push(err.message)); + await page.goto("/"); + await page.waitForLoadState("networkidle"); + expect(errors).toHaveLength(0); + }); +}); + +// ─── CSS feature compatibility ──────────────────────────────────────────────── + +test.describe("CSS compatibility @cross-browser", () => { + test("CSS custom properties resolve on body", async ({ page }) => { + await page.goto("/"); + const bgColor = await page.evaluate(() => + getComputedStyle(document.body).getPropertyValue("--color-bg-base").trim() + ); + // Token is defined — non-empty string means custom properties work + expect(bgColor.length).toBeGreaterThan(0); + }); + + test("CSS 3D transforms are supported (coin flip)", async ({ page }) => { + await page.goto("/"); + const supported = await page.evaluate(() => { + const el = document.createElement("div"); + el.style.transform = "rotateY(180deg)"; + return el.style.transform !== ""; + }); + expect(supported).toBe(true); + }); + + test("prefers-reduced-motion media query is parseable", async ({ page }) => { + await page.goto("/"); + const parseable = await page.evaluate(() => + typeof window.matchMedia("(prefers-reduced-motion: reduce)").matches === "boolean" + ); + expect(parseable).toBe(true); + }); + + test(":focus-visible is supported", async ({ page }) => { + await page.goto("/"); + const supported = await page.evaluate(() => { + try { + document.querySelector(":focus-visible"); + return true; + } catch { + return false; + } + }); + expect(supported).toBe(true); + }); +}); + +// ─── JavaScript feature compatibility ──────────────────────────────────────── + +test.describe("JS feature compatibility @cross-browser", () => { + test("Web Crypto SHA-256 is available", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const data = new TextEncoder().encode("test"); + const hash = await crypto.subtle.digest("SHA-256", data); + return new Uint8Array(hash).length; + }); + expect(result).toBe(32); + }); + + test("BigInt is supported", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => { + const n = BigInt("9007199254740993"); + return n.toString(); + }); + expect(result).toBe("9007199254740993"); + }); + + test("optional chaining and nullish coalescing work", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(() => { + const obj: any = null; + return (obj?.value ?? "fallback"); + }); + expect(result).toBe("fallback"); + }); + + test("Promise.allSettled is available", async ({ page }) => { + await page.goto("/"); + const result = await page.evaluate(async () => { + const results = await Promise.allSettled([ + Promise.resolve(1), + Promise.reject(new Error("x")), + ]); + return results.map((r) => r.status); + }); + expect(result).toEqual(["fulfilled", "rejected"]); + }); +}); + +// ─── Wallet provider compatibility ─────────────────────────────────────────── + +test.describe("Wallet provider compatibility @cross-browser", () => { + test("Freighter stub is detected when injected", async ({ page }) => { + await injectFreighterStub(page); + await page.goto("/"); + const detected = await page.evaluate(() => !!(window as any).freighter); + expect(detected).toBe(true); + }); + + test("Albedo stub is detected when injected", async ({ page }) => { + await injectAlbedoStub(page); + await page.goto("/"); + const detected = await page.evaluate(() => !!(window as any).albedo); + expect(detected).toBe(true); + }); + + test("UI renders without wallet extension present", async ({ page }) => { + // No stub injected — simulates Safari or a clean browser profile + await page.goto("/"); + // Page must still render; no crash + await expect(page.locator("body")).toBeVisible(); + // A connect/wallet button or prompt should be present + const walletEntry = page + .getByRole("button", { name: /wallet|connect/i }) + .or(page.getByText(/connect wallet/i)) + .first(); + await expect(walletEntry).toBeVisible(); + }); + + test("wallet modal opens and closes on all browsers", async ({ page }) => { + await page.goto("/"); + const walletBtn = page.getByRole("button", { name: /wallet|connect/i }).first(); + await walletBtn.click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + // Close via Escape + await page.keyboard.press("Escape"); + await expect(dialog).not.toBeVisible(); + }); +}); + +// ─── Accessibility features across browsers ─────────────────────────────────── + +test.describe("Accessibility compatibility @cross-browser", () => { + test("focus trap works inside modal", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: /wallet|connect/i }).first().click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + // Tab through focusable elements — focus must stay inside dialog + await page.keyboard.press("Tab"); + const focusedInDialog = await page.evaluate(() => { + const dialog = document.querySelector("[role='dialog']"); + return dialog?.contains(document.activeElement) ?? false; + }); + expect(focusedInDialog).toBe(true); + }); + + test("aria-live regions are present for game announcements", async ({ page }) => { + await page.goto("/"); + const liveRegions = await page.locator("[aria-live]").count(); + expect(liveRegions).toBeGreaterThan(0); + }); +}); diff --git a/frontend/tests/error-boundary.test.tsx b/frontend/tests/error-boundary.test.tsx new file mode 100644 index 0000000..ce4e6e6 --- /dev/null +++ b/frontend/tests/error-boundary.test.tsx @@ -0,0 +1,230 @@ +/** + * Error boundary tests — Issue #431 + * + * Covers: + * - ErrorBoundary catches render errors and shows default fallback + * - Default fallback UI content and accessibility + * - showDetails prop exposes error.message + * - Custom fallback (ReactNode and render-prop forms) + * - onError logging callback + * - Reset via "Try again" button + * - resetKeys prop triggers automatic reset + * - Nested boundaries: inner catches first, outer untouched + * + * Error handling strategy: + * - ErrorBoundary wraps any subtree that may throw during render. + * - The default fallback gives users two recovery paths: reset (Try again) + * and hard reload. Custom fallbacks can be passed as a ReactNode or a + * render-prop `(error, resetErrorBoundary) => ReactNode`. + * - onError is the integration point for external logging (e.g. Sentry). + * - resetKeys lets parent state changes automatically clear the error state + * without requiring a user action. + * - Nested boundaries isolate failures: an inner boundary catches its own + * subtree's errors and leaves sibling/ancestor trees unaffected. + */ + +import React, { useState } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { ErrorBoundary } from "../components/ErrorBoundary"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Component that throws on first render. */ +function Bomb({ message = "boom" }: { message?: string }) { + throw new Error(message); +} + +/** Component that throws only when `shouldThrow` is true. */ +function ConditionalBomb({ shouldThrow }: { shouldThrow: boolean }) { + if (shouldThrow) throw new Error("conditional boom"); + return

safe

; +} + +// Suppress expected console.error noise from React's error boundary logging. +beforeEach(() => { vi.spyOn(console, "error").mockImplementation(() => {}); }); +afterEach(() => { vi.restoreAllMocks(); }); + +// ─── Catching errors ────────────────────────────────────────────────────────── + +describe("ErrorBoundary — catching errors", () => { + it("renders children when no error is thrown", () => { + render(

ok

); + expect(screen.getByText("ok")).toBeInTheDocument(); + }); + + it("catches a render error and shows the default fallback", () => { + render(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + }); + + it("does not render children after an error", () => { + render(

should not appear

); + expect(screen.queryByText("should not appear")).toBeNull(); + }); +}); + +// ─── Default fallback UI ────────────────────────────────────────────────────── + +describe("ErrorBoundary — default fallback UI", () => { + it("renders role=alert for immediate screen-reader announcement", () => { + render(); + expect(screen.getByRole("alert")).toBeInTheDocument(); + }); + + it("renders a 'Try again' button", () => { + render(); + expect(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument(); + }); + + it("renders a 'Reload page' button", () => { + render(); + expect(screen.getByRole("button", { name: "Reload page" })).toBeInTheDocument(); + }); + + it("hides error details by default", () => { + render(); + expect(screen.queryByText("secret")).toBeNull(); + }); + + it("shows error.message when showDetails=true", () => { + render(); + expect(screen.getByText("exposed error")).toBeInTheDocument(); + }); +}); + +// ─── onError logging ────────────────────────────────────────────────────────── + +describe("ErrorBoundary — onError logging", () => { + it("calls onError with the thrown Error instance", () => { + const onError = vi.fn(); + render(); + expect(onError).toHaveBeenCalledOnce(); + const [err] = onError.mock.calls[0]; + expect(err).toBeInstanceOf(Error); + expect(err.message).toBe("log me"); + }); + + it("calls onError with errorInfo containing componentStack", () => { + const onError = vi.fn(); + render(); + const [, errorInfo] = onError.mock.calls[0]; + expect(typeof errorInfo.componentStack).toBe("string"); + }); +}); + +// ─── Custom fallbacks ───────────────────────────────────────────────────────── + +describe("ErrorBoundary — custom fallbacks", () => { + it("renders a ReactNode fallback", () => { + render( + custom fallback

}> + +
+ ); + expect(screen.getByText("custom fallback")).toBeInTheDocument(); + }); + + it("renders a render-prop fallback with error and reset", () => { + const fallback = vi.fn(({ error }: { error: Error; resetErrorBoundary: () => void }) => ( +

render-prop: {error.message}

+ )); + render(); + expect(screen.getByText("render-prop: rp-error")).toBeInTheDocument(); + expect(fallback).toHaveBeenCalled(); + }); +}); + +// ─── Error recovery ─────────────────────────────────────────────────────────── + +describe("ErrorBoundary — error recovery", () => { + it("resets and re-renders children after 'Try again'", () => { + function Recoverable() { + const [boom, setBoom] = useState(true); + return ( + setBoom(false)}> + {boom ? :

recovered

} +
+ ); + } + render(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(screen.getByText("recovered")).toBeInTheDocument(); + }); + + it("calls onReset when reset is triggered", () => { + const onReset = vi.fn(); + function Wrapper() { + const [boom, setBoom] = useState(true); + return ( + { onReset(); setBoom(false); }}> + {boom ? :

ok

} +
+ ); + } + render(); + fireEvent.click(screen.getByRole("button", { name: "Try again" })); + expect(onReset).toHaveBeenCalledOnce(); + }); + + it("resets automatically when resetKeys change", () => { + function KeyedWrapper() { + const [key, setKey] = useState(0); + const [boom, setBoom] = useState(true); + return ( + <> + + + {boom ? :

auto-recovered

} +
+ + ); + } + render(); + expect(screen.getByText("Something went wrong")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "fix" })); + expect(screen.getByText("auto-recovered")).toBeInTheDocument(); + }); +}); + +// ─── Nested boundaries ──────────────────────────────────────────────────────── + +describe("ErrorBoundary — nested boundaries", () => { + it("inner boundary catches its own error, outer children unaffected", () => { + render( + outer fallback

}> +

outer content

+ inner fallback

}> + +
+
+ ); + expect(screen.getByText("inner fallback")).toBeInTheDocument(); + expect(screen.getByText("outer content")).toBeInTheDocument(); + expect(screen.queryByText("outer fallback")).toBeNull(); + }); + + it("outer boundary catches when inner has no boundary", () => { + render( + outer caught

}> + +
+ ); + expect(screen.getByText("outer caught")).toBeInTheDocument(); + }); + + it("sibling boundaries are independent", () => { + render( +
+ left fallback

}>
+ right ok

}>

right content

+
+ ); + expect(screen.getByText("left fallback")).toBeInTheDocument(); + expect(screen.getByText("right content")).toBeInTheDocument(); + expect(screen.queryByText("right ok")).toBeNull(); + }); +}); diff --git a/frontend/tests/integration/README.md b/frontend/tests/integration/README.md new file mode 100644 index 0000000..3d185f1 --- /dev/null +++ b/frontend/tests/integration/README.md @@ -0,0 +1,60 @@ +# Testnet Integration Tests + +Tests in this directory run against a **real deployed Tossd contract** on Stellar testnet. They are skipped automatically in CI when the required environment variables are absent. + +## Prerequisites + +| Tool | Install | +|------|---------| +| Rust + `wasm32` target | `rustup target add wasm32-unknown-unknown` | +| stellar CLI | `cargo install --locked stellar-cli --features opt` | +| Funded testnet account | `stellar keys generate --network testnet mykey && stellar keys fund mykey --network testnet` | + +## Deploy the contract + +```bash +# From the repo root +IDENTITY=mykey ./scripts/deploy-testnet.sh +``` + +The script prints the deployed `CONTRACT_ID`. Copy it for the next step. + +## Run the integration tests + +```bash +export TESTNET_CONTRACT_ID=C... # from deploy step above +export TESTNET_SECRET_KEY=S... # funded testnet keypair secret +# optional — defaults to https://soroban-testnet.stellar.org +export TESTNET_RPC_URL=https://soroban-testnet.stellar.org + +npx vitest run frontend/tests/integration/testnet.integration.test.ts +``` + +## Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `TESTNET_CONTRACT_ID` | Yes | Deployed contract address (`C...`) | +| `TESTNET_SECRET_KEY` | Yes | Funded testnet keypair secret (`S...`) | +| `TESTNET_RPC_URL` | No | Soroban RPC endpoint (default: `https://soroban-testnet.stellar.org`) | + +When either required variable is absent the entire suite is skipped via `describe.skip` — no test failures, no noise in CI. + +## What is tested + +| # | Test | Description | +|---|------|-------------| +| 1 | Connectivity | Account is funded and reachable on testnet | +| 2 | `start_game` | Submits a transaction, returns a 64-char hex tx hash | +| 3 | `reveal` | Full start → reveal flow; outcome is `win` or `loss` | +| 4 | `cash_out` | Payout after a win exceeds the original wager | +| 5 | `continue_streak` | Extends a winning streak, returns a tx hash | +| 6 | Network errors | Bad RPC URL surfaces a clear thrown error | +| 7 | Invalid commitment | Wrong secret is rejected by the contract | + +## Notes + +- Each test creates its own game to stay independent. +- `cash_out` and `continue_streak` retry up to 5 times to land a win; if all attempts lose they log a warning and pass (non-deterministic outcome is expected). +- Timeouts are generous (30–120 s) to account for testnet ledger time (~5 s/ledger). +- Never use mainnet credentials here. The `TESTNET_SECRET_KEY` is only ever used against `testnet` network passphrase. diff --git a/frontend/tests/integration/testnet-adapter.ts b/frontend/tests/integration/testnet-adapter.ts new file mode 100644 index 0000000..aa93bd0 --- /dev/null +++ b/frontend/tests/integration/testnet-adapter.ts @@ -0,0 +1,106 @@ +/** + * Minimal ContractAdapter implementation that calls the deployed Tossd contract + * on Stellar testnet via stellar-sdk. + * + * Used exclusively by testnet integration tests. Not imported by the app. + */ + +import { + Contract, + Keypair, + Networks, + rpc as SorobanRpc, + TransactionBuilder, + xdr, + nativeToScVal, + scValToNative, + BASE_FEE, +} from "@stellar/stellar-sdk"; +import type { + ContractAdapter, + StartGameInput, + RevealInput, + CashOutInput, + ContinueInput, +} from "../../hooks/contract"; + +const RPC_URL = process.env.TESTNET_RPC_URL ?? "https://soroban-testnet.stellar.org"; +const CONTRACT_ID = process.env.TESTNET_CONTRACT_ID!; +const SECRET_KEY = process.env.TESTNET_SECRET_KEY!; + +const server = new SorobanRpc.Server(RPC_URL, { allowHttp: false }); + +async function invoke(method: string, args: xdr.ScVal[]): Promise { + const keypair = Keypair.fromSecret(SECRET_KEY); + const account = await server.getAccount(keypair.publicKey()); + const contract = new Contract(CONTRACT_ID); + + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: Networks.TESTNET, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); + + const simResult = await server.simulateTransaction(tx); + if (SorobanRpc.Api.isSimulationError(simResult)) { + throw new Error(`Simulation failed: ${simResult.error}`); + } + + const assembled = SorobanRpc.assembleTransaction(tx, simResult).build(); + assembled.sign(keypair); + + const sendResult = await server.sendTransaction(assembled); + if (sendResult.status === "ERROR") { + throw new Error(`Send failed: ${JSON.stringify(sendResult.errorResult)}`); + } + + // Poll for confirmation (testnet ~5 s per ledger) + let getResult: SorobanRpc.Api.GetTransactionResponse; + do { + await new Promise((r) => setTimeout(r, 1000)); + getResult = await server.getTransaction(sendResult.hash); + } while (getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND); + + if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + throw new Error(`Transaction failed: ${getResult.status}`); + } + + return getResult.returnValue ?? xdr.ScVal.scvVoid(); +} + +export function createTestnetAdapter(): ContractAdapter { + return { + async startGame({ wagerStroops, side, commitmentHash }: StartGameInput) { + const result = await invoke("start_game", [ + nativeToScVal(wagerStroops, { type: "i128" }), + nativeToScVal(side === "heads" ? 0 : 1, { type: "u32" }), + nativeToScVal(Buffer.from(commitmentHash.replace("0x", ""), "hex"), { type: "bytes" }), + ]); + const native = scValToNative(result) as { tx_hash: string }; + return { txHash: native.tx_hash }; + }, + + async reveal({ gameId, secret }: RevealInput) { + const result = await invoke("reveal", [ + nativeToScVal(gameId), + nativeToScVal(Buffer.from(secret, "hex"), { type: "bytes" }), + ]); + const native = scValToNative(result) as { tx_hash: string; outcome: number }; + return { txHash: native.tx_hash, outcome: native.outcome === 0 ? "win" : "loss" }; + }, + + async cashOut({ gameId }: CashOutInput) { + const result = await invoke("cash_out", [nativeToScVal(gameId)]); + const native = scValToNative(result) as { tx_hash: string; payout_stroops: number }; + return { txHash: native.tx_hash, payoutStroops: native.payout_stroops }; + }, + + async continueGame({ gameId }: ContinueInput) { + const result = await invoke("continue_streak", [nativeToScVal(gameId)]); + const native = scValToNative(result) as { tx_hash: string }; + return { txHash: native.tx_hash }; + }, + }; +} diff --git a/frontend/tests/integration/testnet.integration.test.ts b/frontend/tests/integration/testnet.integration.test.ts new file mode 100644 index 0000000..7ecc708 --- /dev/null +++ b/frontend/tests/integration/testnet.integration.test.ts @@ -0,0 +1,168 @@ +/** + * Testnet integration tests — Issue #432 + * + * These tests run against a real deployed Tossd contract on Stellar testnet. + * They are SKIPPED automatically when the required environment variables are + * not set, so CI stays green without testnet credentials. + * + * Required env vars (see tests/integration/README.md): + * TESTNET_CONTRACT_ID — deployed contract address (C...) + * TESTNET_SECRET_KEY — funded testnet keypair (S...) + * TESTNET_RPC_URL — optional, defaults to soroban-testnet.stellar.org + * + * Run manually: + * TESTNET_CONTRACT_ID=C... TESTNET_SECRET_KEY=S... \ + * npx vitest run tests/integration/testnet.integration.test.ts + * + * Test coverage: + * 1. Account is funded and reachable on testnet + * 2. start_game submits a transaction and returns a tx hash + * 3. reveal resolves the outcome (win or loss) + * 4. cash_out pays out after a win + * 5. continue_streak extends a winning streak + * 6. Network error handling — bad RPC URL surfaces a clear error + * 7. Invalid commitment is rejected by the contract (error code 12) + */ + +import { describe, it, expect, beforeAll } from "vitest"; +import crypto from "crypto"; +import { Keypair, rpc as SorobanRpc } from "@stellar/stellar-sdk"; +import { createTestnetAdapter } from "./testnet-adapter"; + +// ─── Guard: skip entire suite when env vars are absent ──────────────────────── + +const CONTRACT_ID = process.env.TESTNET_CONTRACT_ID; +const SECRET_KEY = process.env.TESTNET_SECRET_KEY; +const RPC_URL = process.env.TESTNET_RPC_URL ?? "https://soroban-testnet.stellar.org"; + +const runTestnet = CONTRACT_ID && SECRET_KEY; + +// Vitest's `describe.skipIf` skips the whole suite cleanly. +const suite = runTestnet ? describe : describe.skip; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Generate a random 32-byte secret and its SHA-256 commitment. */ +function makeCommitment(): { secret: string; commitmentHash: string } { + const secret = crypto.randomBytes(32).toString("hex"); + const commitmentHash = crypto.createHash("sha256").update(Buffer.from(secret, "hex")).digest("hex"); + return { secret, commitmentHash }; +} + +const WAGER_STROOPS = 10_000_000; // 1 XLM + +// ─── Suite ──────────────────────────────────────────────────────────────────── + +suite("Testnet integration — full game flows", () => { + let adapter: ReturnType; + + beforeAll(() => { + adapter = createTestnetAdapter(); + }); + + // 1. Connectivity + it("testnet account is funded and reachable", async () => { + const server = new SorobanRpc.Server(RPC_URL, { allowHttp: false }); + const keypair = Keypair.fromSecret(SECRET_KEY!); + const account = await server.getAccount(keypair.publicKey()); + expect(account.accountId()).toBe(keypair.publicKey()); + }, 15_000); + + // 2. start_game + it("start_game returns a transaction hash", async () => { + const { secret, commitmentHash } = makeCommitment(); + const result = await adapter.startGame({ + wagerStroops: WAGER_STROOPS, + side: "heads", + commitmentHash, + }); + expect(result.txHash).toMatch(/^[0-9a-f]{64}$/i); + // Store for downstream tests via closure — each test is independent here + // because testnet state is real; downstream tests create their own games. + void secret; // used in reveal test below + }, 30_000); + + // 3. reveal — full start → reveal flow + it("reveal returns outcome win or loss", async () => { + const { secret, commitmentHash } = makeCommitment(); + const { txHash: startTx } = await adapter.startGame({ + wagerStroops: WAGER_STROOPS, + side: "heads", + commitmentHash, + }); + expect(startTx).toBeTruthy(); + + // gameId is the player's public key on this contract + const gameId = Keypair.fromSecret(SECRET_KEY!).publicKey(); + const { txHash, outcome } = await adapter.reveal({ gameId, secret }); + + expect(txHash).toMatch(/^[0-9a-f]{64}$/i); + expect(["win", "loss"]).toContain(outcome); + }, 60_000); + + // 4. cash_out after a win + it("cash_out returns a payout after winning", async () => { + // We need a win — keep trying until we get one (max 5 attempts). + let won = false; + let gameId = ""; + for (let i = 0; i < 5 && !won; i++) { + const { secret, commitmentHash } = makeCommitment(); + await adapter.startGame({ wagerStroops: WAGER_STROOPS, side: "heads", commitmentHash }); + gameId = Keypair.fromSecret(SECRET_KEY!).publicKey(); + const { outcome } = await adapter.reveal({ gameId, secret }); + won = outcome === "win"; + } + + if (!won) { + console.warn("cash_out test: did not win in 5 attempts — skipping payout assertion"); + return; + } + + const { txHash, payoutStroops } = await adapter.cashOut({ gameId }); + expect(txHash).toMatch(/^[0-9a-f]{64}$/i); + expect(payoutStroops).toBeGreaterThan(WAGER_STROOPS); // payout > wager (multiplier ≥ 1) + }, 120_000); + + // 5. continue_streak after a win + it("continue_streak extends a winning streak", async () => { + let won = false; + let gameId = ""; + for (let i = 0; i < 5 && !won; i++) { + const { secret, commitmentHash } = makeCommitment(); + await adapter.startGame({ wagerStroops: WAGER_STROOPS, side: "heads", commitmentHash }); + gameId = Keypair.fromSecret(SECRET_KEY!).publicKey(); + const { outcome } = await adapter.reveal({ gameId, secret }); + won = outcome === "win"; + } + + if (!won) { + console.warn("continue_streak test: did not win in 5 attempts — skipping"); + return; + } + + const { txHash } = await adapter.continueGame({ gameId }); + expect(txHash).toMatch(/^[0-9a-f]{64}$/i); + }, 120_000); + + // 6. Network error handling + it("surfaces a clear error when RPC is unreachable", async () => { + // Override env temporarily by constructing a bad-URL adapter inline + const badServer = new SorobanRpc.Server("https://invalid.testnet.example.invalid", { + allowHttp: false, + }); + await expect( + badServer.getAccount(Keypair.fromSecret(SECRET_KEY!).publicKey()) + ).rejects.toThrow(); + }, 15_000); + + // 7. Invalid commitment rejected + it("reveal with wrong secret is rejected by the contract", async () => { + const { commitmentHash } = makeCommitment(); + await adapter.startGame({ wagerStroops: WAGER_STROOPS, side: "heads", commitmentHash }); + + const gameId = Keypair.fromSecret(SECRET_KEY!).publicKey(); + const wrongSecret = crypto.randomBytes(32).toString("hex"); // does not match commitment + + await expect(adapter.reveal({ gameId, secret: wrongSecret })).rejects.toThrow(); + }, 60_000); +}); diff --git a/scripts/deploy-testnet.sh b/scripts/deploy-testnet.sh new file mode 100755 index 0000000..bf30069 --- /dev/null +++ b/scripts/deploy-testnet.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# scripts/deploy-testnet.sh +# +# Builds the Tossd Soroban contract and deploys it to Stellar testnet. +# Outputs the deployed CONTRACT_ID for use in integration tests. +# +# Prerequisites: +# - Rust + wasm32-unknown-unknown target (rustup target add wasm32-unknown-unknown) +# - stellar CLI (cargo install --locked stellar-cli --features opt) +# - A funded testnet keypair in ~/.config/stellar/identity/.toml +# or passed via TESTNET_SECRET_KEY env var +# +# Usage: +# IDENTITY=mykey ./scripts/deploy-testnet.sh +# # or +# TESTNET_SECRET_KEY=S... ./scripts/deploy-testnet.sh + +set -euo pipefail + +IDENTITY="${IDENTITY:-tossd-deployer}" +NETWORK="testnet" +MANIFEST="contract/Cargo.toml" +WASM_PATH="contract/target/wasm32-unknown-unknown/release/tossd_contract.wasm" + +# ── 1. Build ────────────────────────────────────────────────────────────────── +echo "==> Building contract (release)…" +cargo build --manifest-path "$MANIFEST" \ + --target wasm32-unknown-unknown \ + --release + +# ── 2. Optimise (optional but recommended) ──────────────────────────────────── +if command -v stellar &>/dev/null; then + echo "==> Optimising WASM…" + stellar contract optimize --wasm "$WASM_PATH" || true +fi + +# ── 3. Deploy ───────────────────────────────────────────────────────────────── +echo "==> Deploying to $NETWORK…" + +if [[ -n "${TESTNET_SECRET_KEY:-}" ]]; then + # CI path: use secret key directly + CONTRACT_ID=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source-account "$TESTNET_SECRET_KEY" \ + --network "$NETWORK") +else + # Local path: use named identity + CONTRACT_ID=$(stellar contract deploy \ + --wasm "$WASM_PATH" \ + --source-account "$IDENTITY" \ + --network "$NETWORK") +fi + +echo "" +echo "✅ Contract deployed: $CONTRACT_ID" +echo "" +echo "Export for integration tests:" +echo " export TESTNET_CONTRACT_ID=$CONTRACT_ID" +echo " export TESTNET_SECRET_KEY=" +echo "" +echo "Run integration tests:" +echo " TESTNET_CONTRACT_ID=$CONTRACT_ID TESTNET_SECRET_KEY=\$TESTNET_SECRET_KEY \\" +echo " npx vitest run frontend/tests/integration/testnet.integration.test.ts"