cdpfleet Docs GitHub Dashboard

Cases / #60 · 2026-10-18 · Easy

A multi-step checkout end to end: wait for each step, and check what you typed

Log in, fill a cart, refuse an empty form, type the buyer, check the totals and finish — as three demo users: one normal, one whose pages are slow, one whose inputs are broken. Waiting for each page instead of sleeping handles the slow one; reading the fields back catches the broken one.

Chromium

Run on production on 2026-10-18: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go

The problem

Sign-ups, checkouts and quote forms span several pages. A script that clicks through them with fixed sleeps breaks the first time a page is slow, and one that only checks the last page never notices that a field didn't take what it typed. Here the same checkout runs on Sauce Demo, a public practice shop with demo users built for exactly these problems: standard_user, performance_glitch_user (slow pages) and problem_user (broken inputs). The shop runs entirely in the browser, so there is no server-side order to read back — the checks are on its own pages.

What we used, and why

WhatWhy
page.waitForURL('**/checkout-step-one.html')Each step waits for the page it leads to, however long that takes — never a fixed sleep.
locator('[data-test=shopping-cart-badge]').filter({ hasText: '2' }).waitFor()Waits until the cart really holds both items before going to it.
locator('[data-test=subtotal-label], [data-test=error]').first().waitFor()After submitting a step, waits for whichever comes first: the next step or an error message.
page.inputValue()Reads every field back after typing, before submitting.
[data-test=…] selectorsThe shop marks its elements for tests, so the script doesn't depend on layout or wording.
A fresh context per userEach user starts logged out with an empty cart.

How it works

  1. Log in and wait for the product list.
  2. Add two products, wait for the cart badge to show 2, open the cart and start the checkout.
  3. Step one: submit the empty form and read the error; type first name, last name and postal code; read the three fields back; submit.
  4. Step two: compare the subtotal with the sum of the item prices and the total with subtotal + tax; finish and read the confirmation.
  5. The same for each of the three users, timing every step.

The code

The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:

// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL_US
import { chromium } from 'playwright';

const KEY = process.env.CDPFLEET_API_KEY;
const SITE = 'https://www.saucedemo.com'; // a public demo shop for testing practice
const PASSWORD = 'secret_sauce'; // printed on the shop's login page
// The shop's demo users: a normal one, one whose pages are slow, and one with broken inputs.
const USERS = ['standard_user', 'performance_glitch_user', 'problem_user'];
const BUYER = { firstName: 'Ada', lastName: 'Lovelace', postalCode: '10115' };
const ITEMS = ['sauce-labs-backpack', 'sauce-labs-bike-light'];

const res = await fetch('https://starter.cdpfleet.com/chromium/session', {
  method: 'POST',
  headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ proxy: process.env.PROXY_URL_US, headless: true }),
});
if (!res.ok) throw new Error(`launch: ${res.status} ${await res.text()}`);
const { wsUrl } = await res.json();
const browser = await chromium.connect(wsUrl, { headers: { 'x-api-key': KEY } });
const t = (sel) => `[data-test="${sel}"]`;
const money = (s) => Number(String(s).replace(/[^0-9.]/g, ''));

// The checkout as one user, in a fresh context: every step waits for the page it leads to
// (waitForURL), never for a fixed time.
async function checkout(user) {
  const context = await browser.newContext();
  const page = await context.newPage();
  const steps = {};
  let mark = Date.now();
  const step = (name) => { steps[name] = Date.now() - mark; mark = Date.now(); };
  const out = { user, empty_form_error: null, typed_back: null, step_one_error: null, item_sum: null, subtotal: null, tax: null, total: null, totals_add_up: null, completed: null };
  try {
    await page.goto(SITE);
    await page.fill(t('username'), user);
    await page.fill(t('password'), PASSWORD);
    await page.click(t('login-button'));
    await page.waitForURL('**/inventory.html');
    step('login');

    for (const item of ITEMS) await page.click(t(`add-to-cart-${item}`));
    await page.locator(t('shopping-cart-badge')).filter({ hasText: String(ITEMS.length) }).waitFor();
    await page.click(t('shopping-cart-link'));
    await page.waitForURL('**/cart.html');
    await page.click(t('checkout'));
    await page.waitForURL('**/checkout-step-one.html');
    step('cart');

    // Step one: an empty form is refused; then type the buyer and read the fields back.
    await page.click(t('continue'));
    out.empty_form_error = await page.locator(t('error')).innerText();
    await page.fill(t('firstName'), BUYER.firstName);
    await page.fill(t('lastName'), BUYER.lastName);
    await page.fill(t('postalCode'), BUYER.postalCode);
    out.typed_back = { firstName: await page.inputValue(t('firstName')), lastName: await page.inputValue(t('lastName')), postalCode: await page.inputValue(t('postalCode')) };
    await page.click(t('continue'));
    // Either step two loads or the form shows an error: wait for whichever comes first.
    await page.locator(`${t('subtotal-label')}, ${t('error')}`).first().waitFor();
    if (!page.url().includes('checkout-step-two')) {
      out.step_one_error = await page.locator(t('error')).innerText();
      step('step_one');
      return { ...out, steps_ms: steps };
    }
    step('step_one');

    // Step two: the overview's totals against the item prices.
    const prices = await page.locator(t('inventory-item-price')).allInnerTexts();
    out.item_sum = Math.round(prices.reduce((a, p) => a + money(p), 0) * 100) / 100;
    out.subtotal = money(await page.locator(t('subtotal-label')).innerText());
    out.tax = money(await page.locator(t('tax-label')).innerText());
    out.total = money(await page.locator(t('total-label')).innerText());
    out.totals_add_up = out.subtotal === out.item_sum && Math.abs(out.subtotal + out.tax - out.total) < 0.005;
    await page.click(t('finish'));
    await page.waitForURL('**/checkout-complete.html');
    out.completed = await page.locator(t('complete-header')).innerText();
    step('step_two_and_finish');
    return { ...out, steps_ms: steps };
  } finally {
    await context.close();
  }
}

try {
  const runs = [];
  for (const user of USERS) runs.push(await checkout(user));
  console.log(JSON.stringify(runs, null, 2));
} finally {
  await browser.close();
}

What we got

UserLogin (ms)Cart (ms)Fields read backResultTotals
standard_user2674535Ada / Lovelace / 10115Thank you for your order!39.98 + 3.2 = 43.18 ✓
performance_glitch_user8004292Ada / Lovelace / 10115Thank you for your order!39.98 + 3.2 = 43.18 ✓
problem_user1960280Lovelace / ∅ / 10115Error: Last Name is required—

From the Node.js run on 2026-10-18. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.

Raw output (Node.js)
[
  {
    "user": "standard_user",
    "empty_form_error": "Error: First Name is required",
    "typed_back": {
      "firstName": "Ada",
      "lastName": "Lovelace",
      "postalCode": "10115"
    },
    "step_one_error": null,
    "item_sum": 39.98,
    "subtotal": 39.98,
    "tax": 3.2,
    "total": 43.18,
    "totals_add_up": true,
    "completed": "Thank you for your order!",
    "steps_ms": {
      "login": 2674,
      "cart": 535,
      "step_one": 330,
      "step_two_and_finish": 175
    }
  },
  {
    "user": "performance_glitch_user",
    "empty_form_error": "Error: First Name is required",
    "typed_back": {
      "firstName": "Ada",
      "lastName": "Lovelace",
      "postalCode": "10115"
    },
    "step_one_error": null,
    "item_sum": 39.98,
    "subtotal": 39.98,
    "tax": 3.2,
    "total": 43.18,
    "totals_add_up": true,
    "completed": "Thank you for your order!",
    "steps_ms": {
      "login": 8004,
      "cart": 292,
      "step_one": 180,
      "step_two_and_finish": 162
    }
  },
  {
    "user": "problem_user",
    "empty_form_error": "Error: First Name is required",
    "typed_back": {
      "firstName": "Lovelace",
      "lastName": "",
      "postalCode": "10115"
    },
    "step_one_error": "Error: Last Name is required",
    "item_sum": null,
    "subtotal": null,
    "tax": null,
    "total": null,
    "totals_add_up": null,
    "completed": null,
    "steps_ms": {
      "login": 1960,
      "cart": 280,
      "step_one": 157
    }
  }
]

Takeaways