Search

Sign in to launch Copilot/Codex from the palette.

CHECKOUT REALITY: PLAYWRIGHT + GATEPROOF

UI shows "Order Confirmed!" Did Stripe actually charge? Verify both.

Pattern 2026.01.28

Problem: Your checkout flow shows "Order Confirmed!" Playwright sees the success message. But did Stripe actually process the payment? Did the order record get created? UI tests verify what users see, not what happened.

Solution: I use Playwright for DOM verification. gateproof for backend reality checks. They run together.

  THE GAP
  =======

  Playwright says:              Reality says:
  ✓ "Order Confirmed" visible   ? PaymentIntent created?
  ✓ Order ID displayed          ? Payment succeeded?
  ✓ Cart cleared                ? Order record persisted?

  SOLUTION
  ========

  Gate 1: products-render       Playwright → product cards visible
  Gate 2: cart-ui-updates       Playwright → cart count changes
  Gate 3: checkout-creates-intent  HTTP observe → stripe:payment_intent.created
  Gate 4: payment-confirms      HTTP observe → stripe:payment_intent.succeeded
  Gate 5: confirmation-page     Playwright → order ID visible
  Gate 6: full-flow-verified    HTTP observe → order:created matches UI

  Both pass → no gap.
        

Playwright Isn't Wrong

Playwright is excellent at what it does: verify the DOM. When you need to check that buttons render, forms submit, and success messages appear, Playwright is the right tool.

But Playwright tests what the UI claims. Not what actually happened. A checkout page can show "Payment Successful!" while the Stripe API call silently failed. A confirmation page can display an order ID that was never persisted.

Playwright is part of the verification toolkit. It's not the whole toolkit. Gateproof adds the backend reality layer.

The Checkout Flow

A minimal Stripe checkout: products page, cart, payment with test keys, confirmation. 10 gates verify both layers.

checkout-flow/
├── prd.ts                     # 10 stories with dependencies
├── server.ts                  # Bun + Stripe server
├── gates/
│   ├── server-responds.gate.ts
│   ├── products-api.gate.ts
│   ├── cart-api.gate.ts
│   ├── products-render.gate.ts    # Playwright
│   ├── cart-ui-updates.gate.ts    # Playwright
│   ├── checkout-creates-intent.gate.ts
│   ├── payment-confirms.gate.ts
│   ├── order-created.gate.ts
│   ├── confirmation-page.gate.ts  # Playwright
│   └── full-flow-verified.gate.ts # Both
└── scripts/
    └── *.ts                   # Action scripts

Three Playwright gates for DOM. Six HTTP observation gates for backend. One combined gate proves the full flow works.

The PRD

prd.ts .. dependencies ensure backend works before testing UI

import { definePrd } from "gateproof/prd";

export const prd = definePrd({
  stories: [
    // === BACKEND FOUNDATION ===
    {
      id: "server-responds",
      title: "Server responds on port 3000",
      gateFile: "./gates/server-responds.gate.ts",
    },
    {
      id: "products-api",
      title: "Products API returns available products",
      gateFile: "./gates/products-api.gate.ts",
      dependsOn: ["server-responds"],
    },
    {
      id: "cart-api",
      title: "Cart API accepts add/view operations",
      gateFile: "./gates/cart-api.gate.ts",
      dependsOn: ["products-api"],
    },

    // === UI VERIFICATION (PLAYWRIGHT) ===
    {
      id: "products-render",
      title: "Products page renders product cards in browser",
      gateFile: "./gates/products-render.gate.ts",
      dependsOn: ["products-api"],
    },
    {
      id: "cart-ui-updates",
      title: "Adding to cart updates the cart UI",
      gateFile: "./gates/cart-ui-updates.gate.ts",
      dependsOn: ["cart-api", "products-render"],
    },

    // === STRIPE INTEGRATION ===
    {
      id: "checkout-creates-intent",
      title: "Checkout creates Stripe PaymentIntent",
      gateFile: "./gates/checkout-creates-intent.gate.ts",
      dependsOn: ["cart-api"],
    },
    {
      id: "payment-confirms",
      title: "Payment confirms with test card",
      gateFile: "./gates/payment-confirms.gate.ts",
      dependsOn: ["checkout-creates-intent"],
    },

    // === THE GAP: UI vs REALITY ===
    {
      id: "order-created",
      title: "Order record created after payment",
      gateFile: "./gates/order-created.gate.ts",
      dependsOn: ["payment-confirms"],
    },
    {
      id: "confirmation-page",
      title: "Confirmation page shows order ID",
      gateFile: "./gates/confirmation-page.gate.ts",
      dependsOn: ["order-created"],
    },

    // === THE PROOF ===
    {
      id: "full-flow-verified",
      title: "Full checkout: UI worked AND backend reality matches",
      gateFile: "./gates/full-flow-verified.gate.ts",
      dependsOn: ["confirmation-page"],
    },
  ] as const,
});

if (import.meta.main) {
  const { runPrd } = await import("gateproof/prd");
  const result = await runPrd(prd);

  if (!result.success) {
    console.error(`\n❌ Failed at: ${result.failedStory?.id}`);
    process.exit(1);
  }

  console.log("\n✅ All gates passed.");
  console.log("   UI worked. Backend reality confirmed. No gap.\n");
}

Note the dependency structure: API gates run before UI gates. products-render depends on products-api. The final full-flow-verified gate runs after everything else passes.

Playwright Gate

Playwright gates import chromium directly and return a GateResult. Launch browser, navigate, assert DOM state, close browser.

gates/products-render.gate.ts .. verify product cards render

import { chromium } from "playwright";
import type { GateResult } from "gateproof";

export async function run(): Promise<GateResult> {
  const startTime = Date.now();
  const logs: any[] = [];

  let browser;
  try {
    browser = await chromium.launch({ headless: true });
    const page = await browser.newPage();

    await page.goto("http://localhost:3000");
    logs.push({ action: "navigated", url: "http://localhost:3000" });

    // Wait for product cards to render
    const productCards = await page.locator("[data-testid='product-card']").count();

    if (productCards === 0) {
      return {
        status: "failed",
        durationMs: Date.now() - startTime,
        logs,
        evidence: { requestIds: [], stagesSeen: [], actionsSeen: [], errorTags: ["no-products"] },
        error: new Error("No product cards found in DOM"),
      };
    }

    logs.push({ action: "found-products", count: productCards });

    // Verify product has required elements
    const firstProduct = page.locator("[data-testid='product-card']").first();
    const hasName = await firstProduct.locator("[data-testid='product-name']").count();
    const hasPrice = await firstProduct.locator("[data-testid='product-price']").count();
    const hasButton = await firstProduct.locator("[data-testid='add-to-cart']").count();

    if (!hasName || !hasPrice || !hasButton) {
      return {
        status: "failed",
        durationMs: Date.now() - startTime,
        logs,
        evidence: { requestIds: [], stagesSeen: [], actionsSeen: [], errorTags: ["missing-elements"] },
        error: new Error("Product card missing required elements"),
      };
    }

    return {
      status: "success",
      durationMs: Date.now() - startTime,
      logs,
      evidence: {
        requestIds: [],
        stagesSeen: ["browser"],
        actionsSeen: ["navigate", "verify-products"],
        errorTags: [],
      },
    };
  } finally {
    await browser?.close();
  }
}

Playwright handles the DOM assertions. The gate structure captures logs and timing. No gateproof magic here .. just standard Playwright with a gate wrapper.

Backend Reality Gate

Backend gates use createHttpObserveResource to poll an events endpoint. The server logs every significant action. Gates assert those logs exist.

gates/payment-confirms.gate.ts .. verify Stripe payment succeeded

import { Gate, Act, Assert } from "gateproof";
import { createHttpObserveResource } from "gateproof";

export async function run() {
  const observe = createHttpObserveResource({
    url: "http://localhost:3000/api/events",
    pollInterval: 500,
  });

  return await Gate.run({
    name: "payment-confirms",
    observe,
    act: [
      Act.exec("bun run scripts/confirm-payment.ts"),
      Act.wait(2000),
    ],
    assert: [
      Assert.noErrors(),
      Assert.custom("payment-succeeded", (logs) => {
        return logs.some((log) => {
          const body = (log.data as any)?.body;
          if (!body?.events) return false;
          return body.events.some(
            (e: any) => e.type === "stripe:payment_intent.succeeded"
          );
        });
      }),
    ],
    stop: { idleMs: 3000, maxMs: 15000 },
  });
}

The key assertion: stripe:payment_intent.succeeded exists in the event log. Not "the UI says success" .. the actual Stripe confirmation was logged.

The Full Flow Gate

The final gate combines both: Playwright verifies the confirmation page shows the order ID, HTTP observation verifies the order was actually created in the backend.

gates/full-flow-verified.gate.ts .. both layers verified

import { chromium } from "playwright";
import type { GateResult } from "gateproof";

export async function run(): Promise<GateResult> {
  const startTime = Date.now();
  const logs: any[] = [];

  let browser;
  try {
    browser = await chromium.launch({ headless: true });
    const page = await browser.newPage();

    // Clear previous state
    await fetch("http://localhost:3000/api/reset", { method: "POST" });

    // 1. Navigate to products
    await page.goto("http://localhost:3000");
    await page.waitForSelector("[data-testid='product-card']");

    // 2. Add to cart
    await page.locator("[data-testid='add-to-cart']").first().click();
    await page.waitForTimeout(300);

    // 3. Go to checkout
    await page.locator("[data-testid='checkout-button']").click();
    await page.waitForSelector("[data-testid='checkout-page']");

    // 4. Confirm payment
    await page.locator("[data-testid='pay-now']").click();
    await page.waitForSelector("[data-testid='success-message']", { timeout: 10000 });

    // 5. Verify confirmation page
    const orderId = await page.locator("[data-testid='order-id']").textContent();
    logs.push({ action: "dom-verified", orderId });

    // 6. Verify backend reality matches
    const eventsRes = await fetch("http://localhost:3000/api/events");
    const eventsData = await eventsRes.json();

    const requiredEvents = [
      "stripe:payment_intent.created",
      "stripe:payment_intent.succeeded",
      "order:created",
    ];

    const foundEvents = requiredEvents.filter((type) =>
      eventsData.events?.some((e: any) => e.type === type)
    );

    if (foundEvents.length !== requiredEvents.length) {
      const missing = requiredEvents.filter((t) => !foundEvents.includes(t));
      return {
        status: "failed",
        durationMs: Date.now() - startTime,
        logs,
        evidence: { requestIds: [], stagesSeen: [], actionsSeen: [], errorTags: missing },
        error: new Error(`Missing backend events: ${missing.join(", ")}`),
      };
    }

    logs.push({ action: "backend-verified", events: foundEvents });

    return {
      status: "success",
      durationMs: Date.now() - startTime,
      logs,
      evidence: {
        requestIds: [],
        stagesSeen: ["browser", "backend"],
        actionsSeen: ["full-checkout-flow"],
        errorTags: [],
      },
    };
  } finally {
    await browser?.close();
  }
}

Two independent verification paths. Playwright confirms the DOM shows "Order Confirmed!" with an order ID. HTTP observation confirms the order:created event exists with a matching ID. Both must pass.

The Server

The server logs every significant action to an in-memory event array. This is the "observability backend" .. simple for demos, replaceable with Cloudflare Analytics Engine or any log aggregator in production.

server.ts .. event logging pattern (excerpt)

let events: { type: string; data: any; timestamp: string }[] = [];

function logEvent(type: string, data: any = {}) {
  events.push({ type, data, timestamp: new Date().toISOString() });
}

// When creating PaymentIntent:
const paymentIntent = await stripe.paymentIntents.create({
  amount: total,
  currency: "usd",
  automatic_payment_methods: { enabled: true, allow_redirects: "never" },
});
logEvent("stripe:payment_intent.created", {
  paymentIntentId: paymentIntent.id,
  amount: total
});

// When payment succeeds:
if (paymentIntent.status === "succeeded") {
  logEvent("stripe:payment_intent.succeeded", {
    paymentIntentId: paymentIntent.id
  });

  const orderId = `order_${Date.now()}`;
  orders.push({ id: orderId, items: [...cart], total, paymentIntentId: paymentIntent.id });
  logEvent("order:created", { orderId, total, items: cart.length });
}

// Events endpoint for observation:
if (path === "/api/events") {
  return Response.json({ events });
}

Every Stripe call logs its result. /api/events exposes the log. Gates poll it. Reality becomes observable.

Run It

$ bun run server.ts &
$ bun run prd.ts

--- server-responds: Server responds on port 3000
✓ success

--- products-api: Products API returns available products
✓ success

--- cart-api: Cart API accepts add/view operations
✓ success

--- products-render: Products page renders product cards in browser
✓ success (Playwright)

--- cart-ui-updates: Adding to cart updates the cart UI
✓ success (Playwright)

--- checkout-creates-intent: Checkout creates Stripe PaymentIntent
✓ success (stripe:payment_intent.created logged)

--- payment-confirms: Payment confirms with test card
✓ success (stripe:payment_intent.succeeded logged)

--- order-created: Order record created after payment
✓ success (order:created logged)

--- confirmation-page: Confirmation page shows order ID
✓ success (Playwright)

--- full-flow-verified: Full checkout verified
✓ success (DOM + backend match)

All gates passed.
UI worked. Backend reality confirmed. No gap.

The Pattern

1. Log everything significant. Every Stripe call, every order creation, every state change. Logs are your observability backend.

2. Playwright for DOM. Use it for what it's good at: verifying what users see. Product cards render. Cart updates. Success messages appear.

3. HTTP observation for reality. Poll your event log. Assert the events exist. payment_intent.succeeded proves the payment happened, not just that the UI claimed it did.

4. Combine in final gate. The last gate checks both layers. DOM shows order ID. Backend has matching order record. The gap between "looks right" and "is right" closes.

When to Use This

  • Payment flows where UI can claim success while backend fails
  • Order systems where confirmation pages might show stale/fake data
  • Any flow where external APIs (Stripe, Twilio, etc.) must actually succeed
  • E2E tests that need to catch the "UI lies" class of bugs

When Not to Use This

  • Pure UI flows .. if there's no backend state to verify, Playwright alone is enough
  • Unit testing .. this is E2E verification, not function-level testing
  • When you can't log .. no event log means no observation; use mocks instead

The Project

Full working example with Stripe test keys: github.com/acoyfellow/checkout-flow

Clone, add your Stripe test keys to .env, run bun install && bun run server.ts, then bun run prd.ts.

Playwright verifies what users see. Gateproof verifies what actually happened. Run both.

UI can lie. Logs don't. Close the gap.