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 }) => (
+
+ Test Modal
+
+
+ );
+
+ 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"