Skip to content

Multi-Page Workflow

Multi-Page Workflow

This example demonstrates complex, real-world automation scenarios that span multiple pages and involve various interactions.

E-Commerce Checkout Flow

Complete purchase flow from product search to order confirmation:

scripts/ecommerce-checkout.ts
import { Stepwright } from '@korvol/stepwright';
interface CheckoutData {
// Product selection
searchTerm: string;
selectedProduct: {
name: string;
price: number;
quantity: number;
};
// Customer info
customer: {
email: string;
firstName: string;
lastName: string;
};
// Shipping
shipping: {
address: string;
city: string;
state: string;
zip: string;
method: string;
cost: number;
};
// Payment
payment: {
cardNumber: string;
expiry: string;
cvv: string;
nameOnCard: string;
};
// Order result
orderId: string;
orderTotal: number;
estimatedDelivery: string;
}
const script = Stepwright.create<CheckoutData>('E-Commerce Checkout')
.config({
headless: true,
screenshotOnFailure: true,
domOnFailure: true,
defaultTimeout: 30000,
})
.data({
searchTerm: 'wireless headphones',
selectedProduct: { name: '', price: 0, quantity: 2 },
customer: {
email: 'customer@example.com',
firstName: 'John',
lastName: 'Doe',
},
shipping: {
address: '123 Main Street',
city: 'New York',
state: 'NY',
zip: '10001',
method: '',
cost: 0,
},
payment: {
cardNumber: '4111111111111111',
expiry: '12/26',
cvv: '123',
nameOnCard: 'John Doe',
},
orderId: '',
orderTotal: 0,
estimatedDelivery: '',
})
// ========== SEARCH & PRODUCT SELECTION ==========
.checkpoint('Product Search')
.step('Navigate to store', async (ctx) => {
await ctx.page.goto('https://store.example.com');
await ctx.page.waitForSelector('.search-bar');
ctx.log('Store loaded');
})
.step('Search for product', async (ctx) => {
await ctx.page.fill('.search-bar input', ctx.data.searchTerm);
await ctx.page.click('.search-bar button');
await ctx.page.waitForSelector('.search-results');
const resultCount = await ctx.page.locator('.product-card').count();
ctx.log(`Found ${resultCount} products for "${ctx.data.searchTerm}"`);
})
.step('Select first product', async (ctx) => {
const firstProduct = ctx.page.locator('.product-card').first();
// Extract product info before clicking
ctx.data.selectedProduct.name = await firstProduct.locator('.product-name').textContent() || '';
const priceText = await firstProduct.locator('.product-price').textContent() || '0';
ctx.data.selectedProduct.price = parseFloat(priceText.replace(/[^0-9.]/g, ''));
await firstProduct.click();
await ctx.page.waitForSelector('.product-detail');
ctx.log('Selected:', ctx.data.selectedProduct.name);
ctx.log('Price:', ctx.data.selectedProduct.price);
})
.endCheckpoint()
// ========== ADD TO CART ==========
.checkpoint('Add to Cart')
.step('Set quantity', async (ctx) => {
await ctx.page.fill('#quantity', ctx.data.selectedProduct.quantity.toString());
ctx.log('Quantity:', ctx.data.selectedProduct.quantity);
})
.step('Add to cart', async (ctx) => {
await ctx.page.click('#add-to-cart');
await ctx.page.waitForSelector('.cart-notification');
const cartCount = await ctx.page.locator('.cart-count').textContent();
ctx.log('Cart items:', cartCount);
})
.step('Go to cart', async (ctx) => {
await ctx.page.click('.cart-icon');
await ctx.page.waitForURL('**/cart**');
await ctx.page.waitForSelector('.cart-items');
await ctx.screenshot('cart-contents');
})
.step('Verify cart contents', async (ctx) => {
const cartItem = ctx.page.locator('.cart-item').first();
const itemName = await cartItem.locator('.item-name').textContent();
const itemQty = await cartItem.locator('.item-quantity').inputValue();
if (itemName !== ctx.data.selectedProduct.name) {
throw new Error(`Wrong product in cart: ${itemName}`);
}
if (itemQty !== ctx.data.selectedProduct.quantity.toString()) {
throw new Error(`Wrong quantity: ${itemQty}`);
}
ctx.log('Cart verified');
})
.step('Proceed to checkout', async (ctx) => {
await ctx.page.click('#proceed-to-checkout');
await ctx.page.waitForURL('**/checkout**');
})
.endCheckpoint()
// ========== CUSTOMER INFORMATION ==========
.checkpoint('Customer Information')
.step('Fill email', async (ctx) => {
await ctx.page.fill('#email', ctx.data.customer.email);
})
.step('Fill name', async (ctx) => {
await ctx.page.fill('#firstName', ctx.data.customer.firstName);
await ctx.page.fill('#lastName', ctx.data.customer.lastName);
})
.step('Continue to shipping', async (ctx) => {
await ctx.page.click('#continue-to-shipping');
await ctx.page.waitForSelector('#shipping-form');
})
.endCheckpoint()
// ========== SHIPPING ==========
.checkpoint('Shipping', { maxRetries: 2 })
.step('Fill shipping address', async (ctx) => {
await ctx.page.fill('#address', ctx.data.shipping.address);
await ctx.page.fill('#city', ctx.data.shipping.city);
await ctx.page.selectOption('#state', ctx.data.shipping.state);
await ctx.page.fill('#zip', ctx.data.shipping.zip);
// Wait for shipping options to calculate
await ctx.page.waitForSelector('.shipping-options:not(.loading)');
})
.step('Select shipping method', async (ctx) => {
// Get available shipping options
const options = ctx.page.locator('.shipping-option');
const count = await options.count();
ctx.log('Available shipping methods:', count);
// Select standard shipping (or first available)
const standardOption = options.filter({ hasText: 'Standard' }).first();
if (await standardOption.count() > 0) {
await standardOption.click();
ctx.data.shipping.method = 'Standard';
} else {
await options.first().click();
ctx.data.shipping.method = await options.first().locator('.method-name').textContent() || 'Unknown';
}
// Get shipping cost
const costText = await ctx.page.locator('.selected .shipping-cost').textContent() || '0';
ctx.data.shipping.cost = parseFloat(costText.replace(/[^0-9.]/g, ''));
ctx.log('Shipping method:', ctx.data.shipping.method);
ctx.log('Shipping cost:', ctx.data.shipping.cost);
})
.step('Continue to payment', async (ctx) => {
await ctx.page.click('#continue-to-payment');
await ctx.page.waitForSelector('#payment-form');
await ctx.screenshot('payment-form');
})
.endCheckpoint()
// ========== PAYMENT ==========
.checkpoint('Payment', { maxRetries: 2 })
.step('Fill payment details', async (ctx) => {
// Handle iframe if payment is in iframe
const paymentFrame = ctx.page.frameLocator('#payment-iframe');
if (await ctx.page.locator('#payment-iframe').count() > 0) {
// Payment form is in iframe
await paymentFrame.locator('#card-number').fill(ctx.data.payment.cardNumber);
await paymentFrame.locator('#expiry').fill(ctx.data.payment.expiry);
await paymentFrame.locator('#cvv').fill(ctx.data.payment.cvv);
} else {
// Payment form is in main page
await ctx.page.fill('#card-number', ctx.data.payment.cardNumber);
await ctx.page.fill('#expiry', ctx.data.payment.expiry);
await ctx.page.fill('#cvv', ctx.data.payment.cvv);
}
await ctx.page.fill('#name-on-card', ctx.data.payment.nameOnCard);
ctx.log('Payment details entered');
})
.step('Review order', async (ctx) => {
// Calculate expected total
const subtotal = ctx.data.selectedProduct.price * ctx.data.selectedProduct.quantity;
const expectedTotal = subtotal + ctx.data.shipping.cost;
// Get displayed total
const displayedTotal = await ctx.page.locator('.order-total').textContent() || '0';
ctx.data.orderTotal = parseFloat(displayedTotal.replace(/[^0-9.]/g, ''));
ctx.log('Expected total:', expectedTotal);
ctx.log('Displayed total:', ctx.data.orderTotal);
// Verify totals match (with small tolerance for taxes)
if (Math.abs(ctx.data.orderTotal - expectedTotal) > expectedTotal * 0.15) {
throw new Error('Order total mismatch');
}
await ctx.screenshot('order-review');
})
.step('Place order', async (ctx) => {
// Wait for place order button to be enabled
await ctx.page.waitForSelector('#place-order:not([disabled])');
await ctx.page.click('#place-order');
// Wait for confirmation
await ctx.page.waitForURL('**/confirmation**', { timeout: 60000 });
await ctx.page.waitForSelector('.order-confirmation');
ctx.log('Order placed!');
})
.endCheckpoint()
// ========== CONFIRMATION ==========
.checkpoint('Order Confirmation')
.step('Extract order details', async (ctx) => {
ctx.data.orderId = await ctx.page.locator('.order-id').textContent() || '';
ctx.data.estimatedDelivery = await ctx.page.locator('.delivery-date').textContent() || '';
ctx.log('Order ID:', ctx.data.orderId);
ctx.log('Estimated delivery:', ctx.data.estimatedDelivery);
await ctx.screenshot('order-confirmation');
})
.step('Verify confirmation email sent', async (ctx) => {
const emailSent = await ctx.page.locator('.email-confirmation').textContent();
ctx.log('Confirmation email:', emailSent);
if (!emailSent?.includes(ctx.data.customer.email)) {
ctx.log('Warning: Email confirmation not displayed');
}
})
.endCheckpoint();
// Run the script
async function main() {
const result = await script.run();
if (result.success) {
console.log('\n✅ Checkout completed successfully!');
console.log('Order ID:', result.data.orderId);
console.log('Total:', result.data.orderTotal);
console.log('Delivery:', result.data.estimatedDelivery);
} else {
console.error('\n❌ Checkout failed!');
console.error('Failed at:', result.failedStep?.name);
console.error('Error:', result.failedStep?.error.message);
if (result.failedStep?.artifacts.screenshot) {
console.error('Screenshot:', result.failedStep.artifacts.screenshot);
}
}
console.log('\nDuration:', result.duration, 'ms');
}
main().catch(console.error);

Multi-Step Form Wizard

Handle multi-step form with navigation:

scripts/form-wizard.ts
import { Stepwright } from '@korvol/stepwright';
interface WizardData {
currentStep: number;
totalSteps: number;
// Step 1: Personal
personal: {
firstName: string;
lastName: string;
dob: string;
};
// Step 2: Contact
contact: {
email: string;
phone: string;
address: string;
};
// Step 3: Preferences
preferences: {
newsletter: boolean;
notifications: string[];
};
// Step 4: Review & Submit
submissionId: string;
}
const script = Stepwright.create<WizardData>('Form Wizard')
.config({
headless: true,
})
.data({
currentStep: 1,
totalSteps: 4,
personal: {
firstName: 'Jane',
lastName: 'Smith',
dob: '1990-05-15',
},
contact: {
email: 'jane@example.com',
phone: '+1-555-123-4567',
address: '456 Oak Avenue, Boston, MA 02101',
},
preferences: {
newsletter: true,
notifications: ['email', 'sms'],
},
submissionId: '',
})
.step('Navigate to wizard', async (ctx) => {
await ctx.page.goto('https://example.com/signup-wizard');
await ctx.page.waitForSelector('.wizard-container');
// Verify we're on step 1
const currentStep = await ctx.page.locator('.step.active').getAttribute('data-step');
ctx.data.currentStep = parseInt(currentStep || '1');
ctx.log('Starting at step:', ctx.data.currentStep);
})
// ========== STEP 1: PERSONAL INFO ==========
.checkpoint('Step 1: Personal Information')
.step('Fill personal info', async (ctx) => {
await ctx.page.fill('#firstName', ctx.data.personal.firstName);
await ctx.page.fill('#lastName', ctx.data.personal.lastName);
await ctx.page.fill('#dob', ctx.data.personal.dob);
ctx.log('Personal info filled');
})
.step('Navigate to step 2', async (ctx) => {
await ctx.page.click('#next-step');
// Wait for step transition
await ctx.page.waitForSelector('.step[data-step="2"].active');
ctx.data.currentStep = 2;
ctx.log('Moved to step:', ctx.data.currentStep);
})
.endCheckpoint()
// ========== STEP 2: CONTACT INFO ==========
.checkpoint('Step 2: Contact Information')
.step('Fill contact info', async (ctx) => {
await ctx.page.fill('#email', ctx.data.contact.email);
await ctx.page.fill('#phone', ctx.data.contact.phone);
await ctx.page.fill('#address', ctx.data.contact.address);
})
.step('Navigate to step 3', async (ctx) => {
await ctx.page.click('#next-step');
await ctx.page.waitForSelector('.step[data-step="3"].active');
ctx.data.currentStep = 3;
})
.endCheckpoint()
// ========== STEP 3: PREFERENCES ==========
.checkpoint('Step 3: Preferences')
.step('Set preferences', async (ctx) => {
// Newsletter
if (ctx.data.preferences.newsletter) {
await ctx.page.check('#newsletter');
}
// Notifications
for (const type of ctx.data.preferences.notifications) {
await ctx.page.check(`#notification-${type}`);
}
ctx.log('Preferences set');
})
.step('Navigate to review', async (ctx) => {
await ctx.page.click('#next-step');
await ctx.page.waitForSelector('.step[data-step="4"].active');
ctx.data.currentStep = 4;
})
.endCheckpoint()
// ========== STEP 4: REVIEW & SUBMIT ==========
.checkpoint('Step 4: Review and Submit')
.step('Verify review data', async (ctx) => {
// Check that review shows correct data
const reviewName = await ctx.page.locator('.review-name').textContent();
const expectedName = `${ctx.data.personal.firstName} ${ctx.data.personal.lastName}`;
if (reviewName !== expectedName) {
throw new Error(`Name mismatch: ${reviewName} vs ${expectedName}`);
}
const reviewEmail = await ctx.page.locator('.review-email').textContent();
if (reviewEmail !== ctx.data.contact.email) {
throw new Error(`Email mismatch: ${reviewEmail} vs ${ctx.data.contact.email}`);
}
ctx.log('Review data verified');
await ctx.screenshot('wizard-review');
})
.step('Go back and edit', async (ctx) => {
// Test back navigation
await ctx.page.click('#prev-step');
await ctx.page.waitForSelector('.step[data-step="3"].active');
// Make a change
await ctx.page.uncheck('#notification-sms');
ctx.data.preferences.notifications = ['email'];
// Go forward again
await ctx.page.click('#next-step');
await ctx.page.waitForSelector('.step[data-step="4"].active');
ctx.log('Edited and returned to review');
})
.step('Submit form', async (ctx) => {
await ctx.page.click('#submit-form');
// Wait for success
await ctx.page.waitForSelector('.submission-success');
ctx.data.submissionId = await ctx.page.locator('.submission-id').textContent() || '';
ctx.log('Submission ID:', ctx.data.submissionId);
})
.endCheckpoint();

Data Pipeline Workflow

Extract, process, and export data:

scripts/data-pipeline.ts
import { Stepwright } from '@korvol/stepwright';
import fs from 'fs';
interface DataRecord {
id: string;
name: string;
value: number;
category: string;
}
interface PipelineData {
sourceUrl: string;
rawRecords: DataRecord[];
processedRecords: DataRecord[];
summary: {
totalRecords: number;
totalValue: number;
byCategory: Record<string, number>;
};
exportPath: string;
}
const script = Stepwright.create<PipelineData>('Data Pipeline')
.config({
headless: true,
})
.data({
sourceUrl: 'https://example.com/data-source',
rawRecords: [],
processedRecords: [],
summary: {
totalRecords: 0,
totalValue: 0,
byCategory: {},
},
exportPath: './output/data-export.json',
})
// ========== EXTRACTION ==========
.checkpoint('Data Extraction')
.step('Navigate to data source', async (ctx) => {
await ctx.page.goto(ctx.data.sourceUrl);
await ctx.page.waitForSelector('.data-table');
ctx.log('Data source loaded');
})
.step('Extract all records', async (ctx) => {
let hasMore = true;
while (hasMore) {
// Extract current page
const rows = ctx.page.locator('.data-row');
const count = await rows.count();
for (let i = 0; i < count; i++) {
const row = rows.nth(i);
const record: DataRecord = {
id: await row.locator('.col-id').textContent() || '',
name: await row.locator('.col-name').textContent() || '',
value: parseFloat(await row.locator('.col-value').textContent() || '0'),
category: await row.locator('.col-category').textContent() || '',
};
ctx.data.rawRecords.push(record);
}
// Check for next page
const nextBtn = ctx.page.locator('.pagination-next:not([disabled])');
if (await nextBtn.count() > 0) {
await nextBtn.click();
await ctx.page.waitForLoadState('networkidle');
} else {
hasMore = false;
}
}
ctx.log('Raw records extracted:', ctx.data.rawRecords.length);
})
.endCheckpoint()
// ========== PROCESSING ==========
.checkpoint('Data Processing')
.step('Filter and clean records', async (ctx) => {
ctx.data.processedRecords = ctx.data.rawRecords
// Remove invalid records
.filter((r) => r.id && r.name && r.value > 0)
// Normalize names
.map((r) => ({
...r,
name: r.name.trim().toLowerCase(),
category: r.category.trim().toUpperCase(),
}))
// Remove duplicates by ID
.filter((r, i, arr) => arr.findIndex((x) => x.id === r.id) === i);
ctx.log('Processed records:', ctx.data.processedRecords.length);
ctx.log('Removed:', ctx.data.rawRecords.length - ctx.data.processedRecords.length);
})
.step('Calculate summary', async (ctx) => {
ctx.data.summary.totalRecords = ctx.data.processedRecords.length;
ctx.data.summary.totalValue = ctx.data.processedRecords.reduce((sum, r) => sum + r.value, 0);
// Group by category
for (const record of ctx.data.processedRecords) {
ctx.data.summary.byCategory[record.category] =
(ctx.data.summary.byCategory[record.category] || 0) + record.value;
}
ctx.log('Total value:', ctx.data.summary.totalValue);
ctx.log('Categories:', Object.keys(ctx.data.summary.byCategory).length);
})
.endCheckpoint()
// ========== EXPORT ==========
.checkpoint('Data Export')
.step('Export to JSON', async (ctx) => {
const exportData = {
extractedAt: new Date().toISOString(),
source: ctx.data.sourceUrl,
summary: ctx.data.summary,
records: ctx.data.processedRecords,
};
// Ensure output directory exists
const dir = ctx.data.exportPath.substring(0, ctx.data.exportPath.lastIndexOf('/'));
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(ctx.data.exportPath, JSON.stringify(exportData, null, 2));
ctx.log('Exported to:', ctx.data.exportPath);
})
.step('Generate report', async (ctx) => {
const report = `
# Data Pipeline Report
Generated: ${new Date().toISOString()}
## Summary
- Total Records: ${ctx.data.summary.totalRecords}
- Total Value: $${ctx.data.summary.totalValue.toLocaleString()}
## By Category
${Object.entries(ctx.data.summary.byCategory)
.map(([cat, val]) => `- ${cat}: $${val.toLocaleString()}`)
.join('\n')}
`.trim();
fs.writeFileSync('./output/report.md', report);
ctx.log('Report generated');
})
.endCheckpoint();

Monitoring Dashboard Workflow

Automated monitoring and alerting:

scripts/monitoring-workflow.ts
import { Stepwright } from '@korvol/stepwright';
interface HealthCheck {
name: string;
url: string;
status: 'healthy' | 'degraded' | 'down';
responseTime: number;
lastChecked: string;
}
interface MonitoringData {
services: HealthCheck[];
alerts: string[];
overallStatus: 'healthy' | 'degraded' | 'critical';
}
const script = Stepwright.create<MonitoringData>('Service Monitoring')
.config({
headless: true,
defaultTimeout: 10000,
})
.data({
services: [],
alerts: [],
overallStatus: 'healthy',
})
.step('Check API health', async (ctx) => {
const startTime = Date.now();
try {
const response = await ctx.page.request.get('https://api.example.com/health');
const responseTime = Date.now() - startTime;
ctx.data.services.push({
name: 'API',
url: 'https://api.example.com/health',
status: response.status() === 200 ? 'healthy' : 'degraded',
responseTime,
lastChecked: new Date().toISOString(),
});
if (responseTime > 5000) {
ctx.data.alerts.push('API response time > 5s');
}
} catch (error) {
ctx.data.services.push({
name: 'API',
url: 'https://api.example.com/health',
status: 'down',
responseTime: -1,
lastChecked: new Date().toISOString(),
});
ctx.data.alerts.push('API is down');
}
})
.step('Check web app health', async (ctx) => {
const startTime = Date.now();
try {
await ctx.page.goto('https://app.example.com');
await ctx.page.waitForSelector('#app-loaded');
const responseTime = Date.now() - startTime;
ctx.data.services.push({
name: 'Web App',
url: 'https://app.example.com',
status: responseTime < 3000 ? 'healthy' : 'degraded',
responseTime,
lastChecked: new Date().toISOString(),
});
} catch (error) {
ctx.data.services.push({
name: 'Web App',
url: 'https://app.example.com',
status: 'down',
responseTime: -1,
lastChecked: new Date().toISOString(),
});
ctx.data.alerts.push('Web App is down');
}
})
.step('Check authentication flow', async (ctx) => {
try {
await ctx.page.goto('https://app.example.com/login');
await ctx.page.fill('#email', 'monitor@example.com');
await ctx.page.fill('#password', process.env.MONITOR_PASSWORD!);
await ctx.page.click('#login');
await ctx.page.waitForURL('**/dashboard**', { timeout: 10000 });
ctx.data.services.push({
name: 'Auth Flow',
url: 'https://app.example.com/login',
status: 'healthy',
responseTime: 0,
lastChecked: new Date().toISOString(),
});
} catch (error) {
ctx.data.services.push({
name: 'Auth Flow',
url: 'https://app.example.com/login',
status: 'down',
responseTime: -1,
lastChecked: new Date().toISOString(),
});
ctx.data.alerts.push('Authentication flow broken');
}
})
.step('Determine overall status', async (ctx) => {
const downCount = ctx.data.services.filter((s) => s.status === 'down').length;
const degradedCount = ctx.data.services.filter((s) => s.status === 'degraded').length;
if (downCount > 0) {
ctx.data.overallStatus = 'critical';
} else if (degradedCount > 0) {
ctx.data.overallStatus = 'degraded';
} else {
ctx.data.overallStatus = 'healthy';
}
ctx.log('Overall status:', ctx.data.overallStatus);
ctx.log('Alerts:', ctx.data.alerts.length);
})
.step('Send alerts if needed', async (ctx) => {
if (ctx.data.alerts.length > 0) {
// Send to monitoring endpoint
await ctx.page.request.post('https://alerts.example.com/webhook', {
data: {
status: ctx.data.overallStatus,
alerts: ctx.data.alerts,
services: ctx.data.services,
},
});
ctx.log('Alerts sent:', ctx.data.alerts);
}
});

Running the Examples

Terminal window
# E-commerce checkout
npx tsx scripts/ecommerce-checkout.ts
# With visible browser for debugging
npx stepwright run scripts/form-wizard.ts --headed
# Data pipeline with output
npx tsx scripts/data-pipeline.ts && cat output/report.md

Key Takeaways

  1. Use checkpoints to group related steps and define retry boundaries
  2. Store state in data for verification and debugging
  3. Take screenshots at key points in the workflow
  4. Handle errors gracefully with appropriate retry strategies
  5. Extract and validate data throughout the flow
  6. Use type-safe data for complex workflows

Next Steps