@mechris3/stepwise-automation
v0.2.0
Published
Debugger-style browser automation test runner with live execution control and visual dashboard
Maintainers
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
BasePageto 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/testmust be installed in your consuming project as a dev dependency. The stepwise package resolves Playwright from your project'snode_modulesat runtime.
Then install:
npm installQuick Start
Install the package and a browser engine (see above).
Understand
BasePage— the base class all page objects extend. It takes aBrowserAdapterin its constructor and exposes convenience methods likeclick,fill,goto,waitForSelector, etc. You never call the adapter directly;BasePagedelegates 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(); }
}- Create a page object at
page-objects/login.page.ts. ExtendBasePageand usethis.<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.
- 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).
- Add scripts to your
package.json:
{
"scripts": {
"test:ui": "stepwise-automation serve",
"test:run": "stepwise-automation run"
}
}- Start the dashboard:
npm run test:uiOpen 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 elementfill(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 contentwaitForSelector(selector)— wait for element to appearisVisible(selector)— check element visibilitywaitForHidden(selector)— wait for element to disappearwaitForTimeout(ms)— wait a fixed durationclickAndWaitForNavigation(selector)— click and wait for page loadisDisabled(selector)— check if element is disabledgetInputValue(selector)— get current input valuecountElements(selector)— count matching elementsgetAttribute(selector, attribute)— get an attribute valuegetCurrentUrl()— get the current page URLgoto(url)— navigate to a URL (resolved against Target URL from settings)evaluate(script)— execute JavaScript in the page contextclickAndDownload(selector)— click a download trigger and get the file path and suggested filenameclearDownloads()— 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 sessionStoragereadClipboard()— read text from the system clipboarduploadFile(selector, filePath)— upload a file to a file input elementselectByIndex(selector, index)— select a dropdown option by indexselectByValue(selector, value)— select a dropdown option by itsvalueattributeselectByText(selector, text, exact?)— select a dropdown option by visible text (contains match by default, exact match withexact: true)clickAndDownload(selector)— click a download trigger and get the file path and suggested filenameclearDownloads()— 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 serveThis 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 runUse the --tool flag to specify the browser engine:
npx stepwise-automation run --tool puppeteer
npx stepwise-automation run --tool playwrightExit Codes
0— all journeys passed1— one or more journeys failed,globalSetupthrew 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 helpExamples
# 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 --headedBrowser 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, setdevtools.reduxPathin 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.jsonStep 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
Errorwith a.stackproperty - No journeys execute
globalTeardownis 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, orglobalTeardownhooks execute - All scheduled journeys appear as
skippedin the results - The
errorfield onrun-enddistinguishes an aborted run from a completed one - The
sourcefield 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 failedBehavior:
- 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
beforeEachfailure, next journey starts afterafterEachfailure) - Journey pass/fail status is determined solely by the journey's own execution
- Exit code reflects journey outcomes only — a
globalTeardownerror does not turn a passing run into a failure - The
run-endbroadcast reflects journey results only, with noerrorfield
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.tsRun from the subfolder:
cd e2e
npx stepwise-automation serveAll 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, returnsResolvedConfigdetectEngines()— detect installed browser enginesStepwiseConfig— config type (all fields optional)ResolvedConfig— resolved config type (journeysalways a string)BrowserConfig— browser options type
Adapters
BrowserAdapter— unified browser automation interfaceBaseAdapter— abstract base class with pause/resume/step/breakpoint logicPuppeteerAdapter— Puppeteer implementation ofBrowserAdapterPlaywrightAdapter— Playwright implementation ofBrowserAdapterDownloadResult— return type forclickAndDownloadcontainingfilePathandsuggestedFilename
Page Objects
BasePage— base page object class with convenience methods for element interactionBrowserContextPage— extended page object for session management, clipboard, file uploads
Utilities
waitForCondition(fn, options?)— poll until a condition is trueformatTestError(error)— format errors for structured outputgetErrorSummary(error)— get a one-line error summarywithErrorContext(fn, context)— wrap page object methods with error context
IPC
writeCommand(command)— write an IPC command to the temp filereadCommand()— read and clear the current IPC commandclearCommands()— clear any pending IPC commandsIpcCommand— 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.logIn 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:
- The path in
stepwise.config.tsis relative to the config file's directory - 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 functionFix: 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
