npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@mechris3/stepwise-automation

v0.2.0

Published

Debugger-style browser automation test runner with live execution control and visual dashboard

Readme

@mechris3/stepwise-automation

Debugger-style browser automation test runner with live execution control, breakpoints, and a visual dashboard.

Features

  • Breakpoints — set breakpoints on any action and pause execution right before it runs
  • Pause / Resume / Step — full debugger-style control over test execution
  • Dual engine support — write tests once, run with Puppeteer or Playwright via an adapter pattern
  • Visual dashboard — web-based UI for selecting journeys, watching actions live, and toggling breakpoints
  • Page Object Model — extend BasePage to encapsulate selectors and interactions per page
  • Journey-first testing — organize tests as end-to-end user journeys
  • Browser auto-discovery — automatically detects Chrome, Brave, Edge, and Chromium on macOS, Windows, and Linux
  • Zero config — works out of the box with sensible defaults; config file is optional
  • Headless CI mode — run all journeys headless with exit codes for CI pipelines
  • Test data lifecycle hooks — configurable hooks for setup/teardown at global and per-journey levels

Installation

Add the package and at least one browser engine to your package.json dependencies:

{
  "dependencies": {
    "@mechris3/stepwise-automation": "^0.1.0",
    "puppeteer": "^22.0.0"
  }
}

Or use Playwright instead (or both):

{
  "dependencies": {
    "@mechris3/stepwise-automation": "^0.1.0"
  },
  "devDependencies": {
    "@playwright/test": "^1.40.0"
  }
}

Important: When using the Playwright engine, @playwright/test must be installed in your consuming project as a dev dependency. The stepwise package resolves Playwright from your project's node_modules at runtime.

Then install:

npm install

Quick Start

  1. Install the package and a browser engine (see above).

  2. Understand BasePage — the base class all page objects extend. It takes a BrowserAdapter in its constructor and exposes convenience methods like click, fill, goto, waitForSelector, etc. You never call the adapter directly; BasePage delegates for you:

// This is what BasePage looks like (provided by the package — you don't write this)
import { BrowserAdapter } from './adapters/browser-adapter.interface';

export class BasePage {
  constructor(protected adapter: BrowserAdapter) {}

  async click(selector: string): Promise<void>                    { await this.adapter.click(selector); }
  async fill(selector: string, value: string): Promise<void>      { await this.adapter.fill(selector, value); }
  async type(selector: string, value: string, options?: { delay?: number }): Promise<void> { await this.adapter.type(selector, value, options); }
  async getText(selector: string): Promise<string>                 { return this.adapter.getText(selector); }
  async waitForSelector(selector: string): Promise<void>           { await this.adapter.waitForSelector(selector); }
  async isVisible(selector: string): Promise<boolean>              { return this.adapter.isVisible(selector); }
  async waitForHidden(selector: string): Promise<void>             { await this.adapter.waitForHidden(selector); }
  async waitForTimeout(ms: number): Promise<void>                  { await this.adapter.waitForTimeout(ms); }
  async clickAndWaitForNavigation(selector: string): Promise<void> { await this.adapter.clickAndWaitForNavigation(selector); }
  async isDisabled(selector: string): Promise<boolean>             { return this.adapter.isDisabled(selector); }
  async getInputValue(selector: string): Promise<string>           { return this.adapter.getInputValue(selector); }
  async countElements(selector: string): Promise<number>           { return this.adapter.countElements(selector); }
  async getAttribute(selector: string, attr: string): Promise<string | null> { return this.adapter.getAttribute(selector, attr); }
  async getCurrentUrl(): Promise<string>                           { return this.adapter.getCurrentUrl(); }
  async goto(url: string): Promise<void>                           { await this.adapter.goto(url); }
  async evaluate<T>(script: () => T): Promise<T>                   { return this.adapter.evaluate(script); }
  async clickAndDownload(selector: string): Promise<DownloadResult> { return this.adapter.clickAndDownload(selector); }
  async clearDownloads(): Promise<void>                              { await this.adapter.clearDownloads(); }
}
  1. Create a page object at page-objects/login.page.ts. Extend BasePage and use this.<method> to interact with the page:
// page-objects/login.page.ts
import { BasePage } from '@mechris3/stepwise-automation';

export class LoginPage extends BasePage {
  private selectors = {
    username: '[data-testid="username"]',
    password: '[data-testid="password"]',
    submit: '[data-testid="submit"]',
    dashboard: '[data-testid="dashboard"]',
  };

  async login(username: string, password: string): Promise<void> {
    await this.fill(this.selectors.username, username);
    await this.fill(this.selectors.password, password);
    await this.click(this.selectors.submit);
  }

  async waitForDashboard(): Promise<void> {
    await this.waitForSelector(this.selectors.dashboard);
  }
}

No need to write a constructor — BasePage's constructor handles the adapter. Just extend and go.

  1. Create a journey file at journeys/login.journey.ts:
// journeys/login.journey.ts
import type { BrowserAdapter } from '@mechris3/stepwise-automation';
import { LoginPage } from '../page-objects/login.page';

export class LoginJourney {
  constructor(private adapter: BrowserAdapter) {}

  async execute(): Promise<void> {
    const loginPage = new LoginPage(this.adapter);

    await loginPage.goto('/login');
    await loginPage.login('testuser', 'password123');
    await loginPage.waitForDashboard();
  }
}

The runner auto-detects class-based journeys (any exported class with a constructor(adapter) and async execute() method).

  1. Add scripts to your package.json:
{
  "scripts": {
    "test:ui": "stepwise-automation serve",
    "test:run": "stepwise-automation run"
  }
}
  1. Start the dashboard:
npm run test:ui

Open http://localhost:3001 to see the dashboard. Set the Target URL in the settings panel to point at your running application.

That's it — no config file needed. The runner discovers journeys matching ./journeys/**/*.journey.ts by default.

Configuration (Optional)

Create a stepwise.config.ts in your project root to customize behavior:

import { defineConfig } from '@mechris3/stepwise-automation';

export default defineConfig({
  // Glob pattern for journey files (default: './journeys/**/*.journey.ts')
  journeys: './journeys/**/*.journey.ts',

  // Browser engine priority (default: all installed engines)
  adapters: ['puppeteer', 'playwright'],

  // Browser launch configuration
  browser: {
    executablePath: '/path/to/chrome',
    userDataDir: '/path/to/profile',
    profileDir: 'Default',
    defaultViewport: { width: 1280, height: 720 },
    headless: false,
  },

  // Dashboard server port (default: 3001)
  server: { port: 3001 },

  // Test data lifecycle hooks (all optional, paths relative to this config file)
  testData: {
    globalSetup: './helpers/global-setup.ts',
    beforeEach: './helpers/before-each.ts',
    afterEach: './helpers/after-each.ts',
    globalTeardown: './helpers/global-teardown.ts',
  },

  // Redux DevTools — auto-detected from userDataDir if not set
  devtools: { redux: true, reduxPath: '/path/to/redux-devtools' },
});

Configuration Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | journeys | string | './journeys/**/*.journey.ts' | Glob pattern for journey files | | adapters | string[] | All installed | Browser engines to enable: 'puppeteer' and/or 'playwright' | | browser.executablePath | string | Auto-detected | Absolute path to the browser executable | | browser.userDataDir | string | Auto-detected | Path to the browser's user data directory (persistent profile) | | browser.profileDir | string | 'Default' | Profile subdirectory within userDataDir | | browser.defaultViewport | { width, height } | { 1280, 720 } | Browser viewport dimensions | | browser.headless | boolean | false | Run browsers without a visible window | | server.port | number | 3001 | Dashboard server port | | testData.globalSetup | string | — | Module called once before all journeys start | | testData.beforeEach | string | — | Module called before each individual journey | | testData.afterEach | string | — | Module called after each individual journey | | testData.globalTeardown | string | — | Module called once after all journeys complete | | devtools.redux | boolean | false | Enable Redux DevTools extension on launch | | devtools.reduxPath | string | Auto-detected | Path to Redux DevTools browser extension directory |

Writing Page Objects

Page objects encapsulate selectors and interactions for a specific page or component. Extend BasePage to get access to the BrowserAdapter via this.adapter:

// page-objects/profile.page.ts
import { BasePage } from '@mechris3/stepwise-automation';
import type { BrowserAdapter } from '@mechris3/stepwise-automation';

export class ProfilePage extends BasePage {
  private selectors = {
    displayName: '[data-testid="display-name"]',
    email: '[data-testid="email"]',
    saveButton: '[data-testid="save-profile"]',
  };

  constructor(adapter: BrowserAdapter) {
    super(adapter);
  }

  async updateDisplayName(name: string): Promise<void> {
    await this.adapter.waitForSelector(this.selectors.displayName);
    await this.adapter.fill(this.selectors.displayName, name);
  }

  async save(): Promise<void> {
    await this.adapter.click(this.selectors.saveButton);
  }

  async getEmail(): Promise<string> {
    return this.adapter.getText(this.selectors.email);
  }
}

BasePage provides the adapter as this.adapter (protected). It also exposes shorthand methods that delegate to the adapter, so this.click(sel) and this.adapter.click(sel) are equivalent — use whichever you prefer.

BasePage shorthand methods (all delegate to this.adapter):

  • click(selector) — click an element
  • fill(selector, value) — type into an input (sets DOM value, dispatches input/change events)
  • type(selector, value, options?) — type character-by-character using real keyboard events (for third-party inputs like Stripe)
  • getText(selector) — get element text content
  • waitForSelector(selector) — wait for element to appear
  • isVisible(selector) — check element visibility
  • waitForHidden(selector) — wait for element to disappear
  • waitForTimeout(ms) — wait a fixed duration
  • clickAndWaitForNavigation(selector) — click and wait for page load
  • isDisabled(selector) — check if element is disabled
  • getInputValue(selector) — get current input value
  • countElements(selector) — count matching elements
  • getAttribute(selector, attribute) — get an attribute value
  • getCurrentUrl() — get the current page URL
  • goto(url) — navigate to a URL (resolved against Target URL from settings)
  • evaluate(script) — execute JavaScript in the page context
  • clickAndDownload(selector) — click a download trigger and get the file path and suggested filename
  • clearDownloads() — remove all downloaded files from the download directory

For browser-level operations (session management, clipboard, file uploads), use BrowserContextPage:

  • clearSession() — clear all cookies, localStorage, and sessionStorage
  • readClipboard() — read text from the system clipboard
  • uploadFile(selector, filePath) — upload a file to a file input element
  • selectByIndex(selector, index) — select a dropdown option by index
  • selectByValue(selector, value) — select a dropdown option by its value attribute
  • selectByText(selector, text, exact?) — select a dropdown option by visible text (contains match by default, exact match with exact: true)
  • clickAndDownload(selector) — click a download trigger and get the file path and suggested filename
  • clearDownloads() — remove all downloaded files from the download directory

const context = new BrowserContextPage(adapter); await context.clearSession(); await context.uploadFile('#file-input', '/path/to/file.png'); await context.selectByIndex('#dropdown', 2); const clipboard = await context.readClipboard();


All 24 methods above make up the complete `BrowserAdapter` interface. Every method is available on both Puppeteer and Playwright adapters.

### File Downloads

`BasePage` provides `clickAndDownload` and `clearDownloads` as inherited convenience methods. Your page objects use them without referencing the adapter directly:

```typescript
// page-objects/reports.page.ts
import { BasePage } from '@mechris3/stepwise-automation';
import type { DownloadResult } from '@mechris3/stepwise-automation';
import * as fs from 'fs';

export class ReportsPage extends BasePage {
  private selectors = {
    exportCsvButton: '[data-testid="export-csv"]',
  };

  /** Simple download — returns metadata for the caller to inspect */
  async downloadCsvReport(): Promise<DownloadResult> {
    return this.clickAndDownload(this.selectors.exportCsvButton);
  }

  /** Download + verify filename + return contents for assertions */
  async downloadAndVerifyCsvReport(expectedFilename: string): Promise<string> {
    const result = await this.clickAndDownload(this.selectors.exportCsvButton);

    if (result.suggestedFilename !== expectedFilename) {
      throw new Error(`Expected "${expectedFilename}", got "${result.suggestedFilename}"`);
    }

    return fs.readFileSync(result.filePath, 'utf-8');
  }
}

Use it in a journey:

// journeys/export-report.journey.ts
import type { BrowserAdapter } from '@mechris3/stepwise-automation';
import { ReportsPage } from '../page-objects/reports.page';

export class ExportReportJourney {
  constructor(private adapter: BrowserAdapter) {}

  async execute(): Promise<void> {
    const reportsPage = new ReportsPage(this.adapter);
    await reportsPage.goto('/reports');

    // Simple download — just verify it happened
    const result = await reportsPage.downloadCsvReport();
    console.log(`Downloaded: ${result.suggestedFilename}`);

    // Download + content verification
    const csv = await reportsPage.downloadAndVerifyCsvReport('monthly-report.csv');
    if (!csv.includes('Total Revenue')) {
      throw new Error('CSV missing expected column');
    }

    // Clean up between tests
    await reportsPage.clearDownloads();
  }
}

Downloaded files are stored in a temporary directory and cleaned up by clearDownloads(). The adapter handles browser-specific download mechanics (CDP for Puppeteer, download events for Playwright) — your page objects just call the inherited BasePage methods.

Writing Journeys

Journeys are test files that represent end-to-end user flows. They use page objects to interact with the application.

Naming Convention

Journey files must match the glob pattern (default: ./journeys/**/*.journey.ts). The filename (without .journey.ts) becomes the journey ID. Kebab-case filenames are converted to Title Case for display in the dashboard (e.g. create-profile → "Create Profile").

Class-Based Journeys (Preferred)

// journeys/create-profile.journey.ts
import type { BrowserAdapter } from '@mechris3/stepwise-automation';
import { LoginPage } from '../page-objects/login.page';
import { ProfilePage } from '../page-objects/profile.page';

export class CreateProfileJourney {
  constructor(private adapter: BrowserAdapter) {}

  async execute(): Promise<void> {
    const loginPage = new LoginPage(this.adapter);
    const profilePage = new ProfilePage(this.adapter);

    await loginPage.goto('/login');
    await loginPage.login('testuser', 'password123');

    await profilePage.goto('/profile');
    await profilePage.updateDisplayName('New Name');
    await profilePage.save();
  }
}

Function-Based Journeys

// journeys/login.journey.ts
import type { BrowserAdapter } from '@mechris3/stepwise-automation';
import { LoginPage } from '../page-objects/login.page';

export default async function loginJourney(adapter: BrowserAdapter): Promise<void> {
  const loginPage = new LoginPage(adapter);
  await loginPage.goto('/login');
  await loginPage.login('testuser', 'password123');
}

Both styles are auto-detected. Class-based journeys must have a constructor(adapter) and an async execute() method.

Dashboard UI

Starting the Dashboard

npx stepwise-automation serve

This starts the Express server and opens the visual dashboard at http://localhost:3001 (or your configured port).

Layout

The dashboard has a three-column layout:

  • Journey list — all discovered journeys with checkboxes and status indicators
  • Action log — live stream of actions as they execute, with breakpoint pin icons
  • Console — stdout/stderr output from the test process

Settings Panel

Click the gear icon to open the settings panel. All settings persist to .stepwise/settings.json via the server. Settings are project-scoped and survive browser or domain changes:

  • Target URL — base URL of the application under test
  • Browser Path — path to the browser executable (auto-detected)
  • User Data Dir — path to the browser profile directory (auto-detected)
  • Viewport — content area dimensions with preset dropdown
  • Action Delay — milliseconds between each action (slow mode)
  • DevTools — open Chrome DevTools on launch
  • Keep Browser Open — keep the browser open after a journey completes

Breakpoints

Click the pin icon next to any action in the action log to toggle a breakpoint. Breakpoints are persisted to .stepwise/settings.json via the server API, scoped per journey. They persist across browser sessions.

When a breakpoint is hit during execution, the test pauses before that action runs. The dashboard highlights the paused action and enables the Resume and Step buttons.

Execution Controls

  • Run — start executing selected journeys
  • Stop — terminate the current test run
  • Resume — continue execution from the current position
  • Step — execute exactly one action, then pause again

Button states are managed by a finite state machine with six states: idle, running, paused, stepping, completed, and errored.

Headless CI Execution

Run all journeys headless from the command line:

npx stepwise-automation run

Use the --tool flag to specify the browser engine:

npx stepwise-automation run --tool puppeteer
npx stepwise-automation run --tool playwright

Exit Codes

  • 0 — all journeys passed
  • 1 — one or more journeys failed, globalSetup threw an error, or a configuration error occurred

Journeys execute sequentially. Execution stops on the first failure.

CLI Reference

stepwise-automation <command> [options]

Commands:
  serve                Start the visual dashboard UI server (default)
  run [journeys...]    Run journeys headless (all or specific ones)

Options:
  --config <path>      Path to config file (default: auto-discover stepwise.config.ts)
  --tool <engine>      Browser engine: puppeteer or playwright
  --port <number>      Override the dashboard server port
  --headed             Run with a visible browser window in run mode
  --version            Print version number
  --help               Show help

Examples

# Start the dashboard
npx stepwise-automation serve

# Start on a custom port
npx stepwise-automation serve --port 4000

# Run all journeys headless with Puppeteer
npx stepwise-automation run --tool puppeteer

# Run a specific journey
npx stepwise-automation run login

# Run with a custom config file
npx stepwise-automation run --config ./tests/stepwise.config.ts

# Run headless but show the browser window
npx stepwise-automation run --headed

Browser Auto-Discovery

The runner automatically detects installed Chromium-based browsers by probing common installation paths on each platform. It also resolves the default user data directory for each browser, so the test runner can launch with your existing profile (bookmarks, extensions, etc.).

Supported Browsers

| Browser | macOS | Windows | Linux | |---------|-------|---------|-------| | Google Chrome | ✓ | ✓ | ✓ | | Brave Browser | ✓ | ✓ | ✓ | | Microsoft Edge | ✓ | ✓ | ✓ | | Chromium | ✓ | — | ✓ |

User Data Directories

Setting userDataDir launches the browser with your existing profile, including all installed extensions (e.g. Redux DevTools), saved logins, and bookmarks. This is the recommended approach for local development.

The dashboard auto-detects both the browser executable path and user data directory on startup. You can also set them manually in the settings panel or in your config file.

Common user data directory locations:

| Browser | macOS | Windows | Linux | |---------|-------|---------|-------| | Google Chrome | ~/Library/Application Support/Google/Chrome | %LOCALAPPDATA%\Google\Chrome\User Data | ~/.config/google-chrome | | Brave Browser | ~/Library/Application Support/BraveSoftware/Brave-Browser | %LOCALAPPDATA%\BraveSoftware\Brave-Browser\User Data | ~/.config/BraveSoftware/Brave-Browser | | Microsoft Edge | ~/Library/Application Support/Microsoft Edge | %LOCALAPPDATA%\Microsoft\Edge\User Data | ~/.config/microsoft-edge | | Chromium | ~/Library/Application Support/Chromium | — | ~/.config/chromium |

Note: Without a userDataDir, Puppeteer launches a clean browser instance with no extensions. If you need Redux DevTools in that scenario, set devtools.reduxPath in your config to the extension's path.

If no system browsers are found, the package falls back to Puppeteer's bundled Chromium (if Puppeteer is installed).

Test Data Lifecycle Hooks

When running multiple journeys, you often need to prepare and reset test data at various points. The runner supports four lifecycle hooks via the testData config:

| Hook | When it runs | Runs N times for N journeys | |------|-------------|----------------------------| | globalSetup | Once before all journeys start | 1 | | beforeEach | Before each individual journey | N | | afterEach | After each individual journey | N | | globalTeardown | Once after all journeys complete | 1 |

Step 1: Create the Hook Modules

Create a helpers/ directory (or any name you like) in your test project and add your hook files. Each file must export a default async function, or a named export matching the hook name.

my-app-tests/
  helpers/
    global-setup.ts     ← runs once before all journeys
    before-each.ts      ← runs before each journey
    after-each.ts       ← runs after each journey
    global-teardown.ts  ← runs once after all journeys
  journeys/
    login.journey.ts
    checkout.journey.ts
  package.json

Step 2: Write the Hook Modules

globalSetup — runs once before any journey starts. Use it for one-time setup like starting services or seeding a shared database.

// helpers/global-setup.ts
export default async function globalSetup(): Promise<void> {
  const response = await fetch('http://localhost:3000/api/test-data/seed', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      users: [
        { username: 'testuser', password: 'password123', email: '[email protected]' },
      ],
    }),
  });

  if (!response.ok) {
    throw new Error(`Global setup failed: ${response.status} ${response.statusText}`);
  }

  console.log('Test data seeded successfully');
}

beforeEach — runs before every journey. Use it to ensure each journey starts from a known state.

// helpers/before-each.ts
export default async function beforeEach(): Promise<void> {
  await fetch('http://localhost:3000/api/test-data/reset', { method: 'POST' });
  console.log('Test data reset for next journey');
}

afterEach — runs after every journey (even on failure). Use it to clean up data created by the journey that just ran.

// helpers/after-each.ts
export default async function afterEach(): Promise<void> {
  await fetch('http://localhost:3000/api/sessions/clear', { method: 'POST' });
  console.log('Sessions cleared');
}

globalTeardown — runs once after all journeys complete. Use it for final cleanup like stopping services or removing test databases.

// helpers/global-teardown.ts
export default async function globalTeardown(): Promise<void> {
  await fetch('http://localhost:3000/api/test-data/destroy', { method: 'POST' });
  console.log('Test environment torn down');
}

You can also use a named export instead of default:

export async function beforeEach(): Promise<void> {
  await fetch('http://localhost:3000/api/test-data/reset', { method: 'POST' });
}

The runner tries module.default, then module.<hookName> (matching the hook name), then falls back to the module itself if it's a function.

Step 3: Wire It Up in Config

Point to your hook files in stepwise.config.ts. Paths are resolved relative to the config file's directory.

// stepwise.config.ts
import { defineConfig } from '@mechris3/stepwise-automation';

export default defineConfig({
  testData: {
    globalSetup: './helpers/global-setup.ts',
    beforeEach: './helpers/before-each.ts',
    afterEach: './helpers/after-each.ts',
    globalTeardown: './helpers/global-teardown.ts',
  },
});

Use only the hooks you need — all are optional:

export default defineConfig({
  testData: {
    beforeEach: './helpers/reset-data.ts',
  },
});

Execution Order

For a run with 3 journeys (A, B, C):

globalSetup() → beforeEach() → A → afterEach() → beforeEach() → B → afterEach() → beforeEach() → C → afterEach() → globalTeardown()

Error Handling

Hook error behavior differs between globalSetup and all other hooks:

globalSetup — Abort on Failure

globalSetup failures abort the entire run immediately. The rationale: if the environment isn't ready (database not seeded, service not started, etc.), running journeys would produce meaningless failures.

CLI mode (stepwise-automation run):

❌ globalSetup failed: Connection refused: ECONNREFUSED 127.0.0.1:5432
    at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1595:16)
  • Error printed to stderr with ❌ globalSetup failed: prefix
  • Stack trace printed if the thrown value is an Error with a .stack property
  • No journeys execute
  • globalTeardown is skipped (environment was never set up)
  • Process exits with code 1

Dashboard mode (stepwise-automation serve):

Two WebSocket messages are broadcast in sequence:

{ "type": "error", "message": "Connection refused: ECONNREFUSED 127.0.0.1:5432", "source": "globalSetup" }
{
  "type": "run-end",
  "error": "globalSetup failed: Connection refused: ECONNREFUSED 127.0.0.1:5432",
  "results": [
    { "journey": "login-flow", "status": "skipped" },
    { "journey": "checkout-flow", "status": "skipped" }
  ]
}
  • No journeys execute
  • No beforeEach, afterEach, or globalTeardown hooks execute
  • All scheduled journeys appear as skipped in the results
  • The error field on run-end distinguishes an aborted run from a completed one
  • The source field on the error message lets the UI distinguish hook-level errors from journey-level errors

beforeEach, afterEach, globalTeardown — Log and Continue

All non-globalSetup hooks use a log-and-continue strategy. A hook failure never aborts the run or marks a journey as failed.

CLI output on hook failure:

⚠️  beforeEach failed: fetch failed

Behavior:

  • The error is logged to stderr (CLI) or server console (dashboard) with a ⚠️ <hookName> failed: prefix
  • Execution continues to the next step (journey continues after beforeEach failure, next journey starts after afterEach failure)
  • Journey pass/fail status is determined solely by the journey's own execution
  • Exit code reflects journey outcomes only — a globalTeardown error does not turn a passing run into a failure
  • The run-end broadcast reflects journey results only, with no error field

Why this design? Hook failures in beforeEach/afterEach/globalTeardown are often non-fatal (e.g., a cleanup step that partially fails). Aborting the entire run for a teardown error would be overly aggressive. If your beforeEach is critical, throw from globalSetup instead — or validate preconditions at the start of each journey.

Summary Table

| Hook | On Error | Journeys Run? | Exit Code Affected? | |------|----------|---------------|---------------------| | globalSetup | Abort immediately | No — all skipped | Yes — always 1 | | beforeEach | Log and continue | Yes — current journey proceeds | No | | afterEach | Log and continue | Yes — next journey proceeds | No | | globalTeardown | Log and continue | Already finished | No |

Common Patterns

Direct database reset (beforeEach):

// helpers/before-each.ts
import { Pool } from 'pg';

const pool = new Pool({ connectionString: 'postgresql://localhost/myapp_test' });

export default async function beforeEach(): Promise<void> {
  await pool.query('TRUNCATE users, orders, sessions CASCADE');
}

File-based fixtures (globalSetup):

// helpers/global-setup.ts
import * as fs from 'fs';
import * as path from 'path';

export default async function globalSetup(): Promise<void> {
  const fixtures = JSON.parse(
    fs.readFileSync(path.join(__dirname, 'fixtures.json'), 'utf-8')
  );
  await fetch('http://localhost:3000/api/test-data/load', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(fixtures),
  });
}

Docker compose reset (afterEach):

// helpers/after-each.ts
import { execSync } from 'child_process';

export default async function afterEach(): Promise<void> {
  execSync('docker compose exec -T db psql -U postgres -c "SELECT reset_test_data()"', {
    stdio: 'inherit',
  });
}

Project Structure

Standalone Testing Project

my-app-tests/
  package.json
  stepwise.config.ts          ← optional
  helpers/
    global-setup.ts           ← optional: runs once before all journeys
    before-each.ts            ← optional: runs before each journey
    after-each.ts             ← optional: runs after each journey
    global-teardown.ts        ← optional: runs once after all journeys
  journeys/
    login.journey.ts
    checkout.journey.ts
  page-objects/
    login.page.ts
    checkout.page.ts
{
  "dependencies": {
    "@mechris3/stepwise-automation": "^0.1.0",
    "puppeteer": "^22.0.0"
  },
  "scripts": {
    "test:ui": "stepwise-automation serve",
    "test:run": "stepwise-automation run"
  }
}

Subfolder in an Existing Project

my-app/
  src/
  e2e/
    stepwise.config.ts        ← optional
    helpers/
      before-each.ts
    journeys/
      login.journey.ts
    page-objects/
      login.page.ts

Run from the subfolder:

cd e2e
npx stepwise-automation serve

All relative paths are resolved from the current working directory (or the config file's directory if --config is used).

API Reference

All public exports from @mechris3/stepwise-automation:

Configuration

  • defineConfig(config) — type-safe config helper (returns config unchanged)
  • loadConfig(path?) — load and validate a config file, returns ResolvedConfig
  • detectEngines() — detect installed browser engines
  • StepwiseConfig — config type (all fields optional)
  • ResolvedConfig — resolved config type (journeys always a string)
  • BrowserConfig — browser options type

Adapters

  • BrowserAdapter — unified browser automation interface
  • BaseAdapter — abstract base class with pause/resume/step/breakpoint logic
  • PuppeteerAdapter — Puppeteer implementation of BrowserAdapter
  • PlaywrightAdapter — Playwright implementation of BrowserAdapter
  • DownloadResult — return type for clickAndDownload containing filePath and suggestedFilename

Page Objects

  • BasePage — base page object class with convenience methods for element interaction
  • BrowserContextPage — extended page object for session management, clipboard, file uploads

Utilities

  • waitForCondition(fn, options?) — poll until a condition is true
  • formatTestError(error) — format errors for structured output
  • getErrorSummary(error) — get a one-line error summary
  • withErrorContext(fn, context) — wrap page object methods with error context

IPC

  • writeCommand(command) — write an IPC command to the temp file
  • readCommand() — read and clear the current IPC command
  • clearCommands() — clear any pending IPC commands
  • IpcCommand — IPC command type

Optional

  • findReduxDevToolsExtension() — locate Redux DevTools browser extension

WebSocket Messages (Dashboard Integration)

When building custom dashboard UIs or integrating with the stepwise server, the WebSocket connection at ws://localhost:<port> broadcasts typed JSON messages. Connect and parse event.data as JSON.

Message Types

| Type | Direction | Description | |------|-----------|-------------| | run-start | Server → Client | Batch run has begun | | run-end | Server → Client | Batch run completed, aborted, or stopped | | test-start | Server → Client | A single journey started executing | | test-end | Server → Client | A single journey finished | | log | Server → Client | stdout output from a running journey | | error | Server → Client | stderr output or hook-level error | | journeys | Server → Client | List of discovered journeys |

Message Schemas

interface RunStartMessage {
  type: 'run-start';
  journeys: string[];           // IDs of journeys about to run
  tool: 'puppeteer' | 'playwright';
}

interface RunEndMessage {
  type: 'run-end';
  results: Array<{
    journey: string;
    status: 'passed' | 'failed' | 'skipped';
  }>;
  error?: string;               // Present only when globalSetup aborted the run
}

interface TestStartMessage {
  type: 'test-start';
  journey: string;
  tool: 'puppeteer' | 'playwright';
}

interface TestEndMessage {
  type: 'test-end';
  journey: string;
  tool: 'puppeteer' | 'playwright';
  status: 'passed' | 'failed';
  duration: string;             // e.g. "2.34" (seconds)
}

interface LogMessage {
  type: 'log';
  message: string;
  journey?: string;
  tool?: string;
}

interface ErrorMessage {
  type: 'error';
  message: string;
  source?: string;              // "globalSetup" when the error is from the setup hook
  journey?: string;             // Journey ID when the error is from a journey
  tool?: string;
}

interface JourneysMessage {
  type: 'journeys';
  journeys: Array<{ id: string; name: string }>;
}

Detecting a globalSetup Abort

To distinguish a normal run completion from a globalSetup abort in your UI:

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);

  if (msg.type === 'error' && msg.source === 'globalSetup') {
    // Show a prominent error banner — the run is about to abort
    showSetupError(msg.message);
  }

  if (msg.type === 'run-end' && msg.error) {
    // Run was aborted — all journeys will be 'skipped'
    showAbortedRun(msg.error, msg.results);
  } else if (msg.type === 'run-end') {
    // Normal completion — show pass/fail results
    showResults(msg.results);
  }
};

Troubleshooting

globalSetup fails but I don't see why

The CLI prints the full stack trace to stderr. If you're piping output, make sure stderr is visible:

npx stepwise-automation run 2>&1 | tee output.log

In dashboard mode, check the browser console for the WebSocket error message with source: 'globalSetup'.

Hook module not found

If you see Cannot find module './helpers/global-setup.ts', check that:

  1. The path in stepwise.config.ts is relative to the config file's directory
  2. The file exists and has a valid export (default function or named export matching the hook name)

Hook module doesn't export a function

If your hook file exports an object or class instead of a function, you'll see:

❌ globalSetup failed: module does not export a callable function

Fix: ensure your hook exports a default async function:

// ✅ Correct
export default async function() { /* ... */ }

// ✅ Also correct (named export matching hook name)
export async function globalSetup() { /* ... */ }

// ❌ Wrong — exports an object
export default { setup: () => {} };

Exit code is 1 but all journeys passed

This happens when globalSetup throws. The run aborts before any journeys execute, so you won't see journey results — just the error message. Check stderr for the ❌ globalSetup failed: line.

Dashboard shows all journeys as "skipped"

This means globalSetup failed. The run-end message will have an error field explaining what went wrong. Fix the underlying issue in your globalSetup hook (database not running, API not reachable, etc.).

beforeEach errors but journeys still pass

This is expected behavior. beforeEach errors are logged but don't abort the run or fail journeys. If your beforeEach is critical (e.g., it resets state that the journey depends on), consider moving that logic into globalSetup or adding validation at the start of each journey.

License

MIT