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

@sixpack-dev/playwright-flows

v0.5.12

Published

Reusable Playwright flows for test data setup, standalone execution, and generator-style automation with discovery and local CLI support.

Readme

Playwright Flows

Many teams already have reliable end-to-end Playwright journeys for things like signing up users, approving entities, or reaching a prerequisite application state.

The problem is that the same journey is often needed somewhere else too: to create test data, seed intermediate state, or run setup steps outside the test runner. Without a shared flow abstraction, teams usually end up maintaining the same business journey twice.

@sixpack-dev/playwright-flows solves that. It lets you extract a Playwright journey once and reuse it as a flow both in tests and outside them, while also making that flow discoverable, inspectable, and executable locally.

Each flow accepts structured input and returns structured output, so the same maintained code can serve two purposes:

  • writing and maintaining Playwright test flows
  • reusing those same flows to prepare state, create data, or drive setup steps

This is especially useful when the most reliable way to create test data is through the same UI journey that your end-to-end tests already use.

Installation

npm install @sixpack-dev/playwright-flows playwright

@sixpack-dev/playwright-flows uses your project's Playwright installation.

If your repo currently depends only on @playwright/test, also add playwright. This package resolves the Playwright runtime from the playwright module, even in a normal Playwright Test project.

Supported Playwright peer range: >=1.53.2 <2

Start Here

Start with one existing Playwright test, extract the business journey into a reusable flow, then prove it still works in both the test runner and the CLI.

As a regular Playwright test:

import { test, expect } from '@playwright/test'

test('creates a user', async ({ page }) => {
	await page.goto('/signup')
	await page.fill('#email', '[email protected]')
	await page.click('button[type="submit"]')

	await expect(page.locator('#user-id')).not.toHaveText('')
})

The same interaction extracted into a reusable flow:

import { expect } from '@playwright/test'

export const createUser = async ({ page }, input: { email: string }) => {
	await page.goto('/signup')
	await page.fill('#email', input.email)
	await page.click('button[type="submit"]')

	await expect(page.locator('#user-id')).not.toHaveText('')

	return {
		userId: (await page.locator('#user-id').textContent()) ?? '',
	}
}

The test after extraction:

import { test } from '@playwright/test'

import { createUser } from './flows/create-user'

test('creates a user', async ({ page }) => {
	await createUser({ page }, { email: '[email protected]' })
})

Creating a user from the CLI:

npx sixpack-playwright generate createUser --input '{"email":"[email protected]"}'

Use --input or --input-file as the default way to execute flows from the CLI. That path is the most reliable because it passes the full input object directly.

For flat input objects, you can also provide repeatable --set flags and the CLI will coerce values based on the flow input type.

npx sixpack-playwright generate createUser --set [email protected]

When generate validates input, it only requires non-optional properties from the caller-facing flow contract:

  • if a flow input has both required and optional properties, only the required ones must be provided
  • optional properties may be omitted from --set, --input, or --input-file
  • if omitted values should be invented or normalized at runtime, do that in flow-level deriveFlowInput(...)

That is the core path this package is for:

  • extract one working journey
  • keep calling it from Playwright tests
  • run the same flow through sixpack-playwright generate

What it provides

  • Flow discovery across a Playwright codebase
  • Runtime execution for exported flows
  • A local CLI for inspecting and validating exported flows
  • A way to define reusable Playwright flows with explicit inputs and outputs

Two primary execution modes

Most adoption problems become simpler once you treat the same flow as serving two primary execution modes:

  • direct Playwright test calls
  • standalone runtime execution through executePlaywrightFlow(...) or sixpack-playwright generate

The business steps should stay shared across those modes. The operational assumptions often should not.

  • direct test calls reuse test-owned fixtures and page state
  • standalone runtime execution exercises config reuse, baseURL, project selection, and helper construction

Validate important flows in both modes. A flow that is healthy in Playwright Test alone may still fail once it runs outside the test runner.

If you later adapt the same flow into Sixpack, treat that as an additional execution mode with its own operational pressure such as retries and repeated stock generation. The flow should still stay reusable without Sixpack.

Dual-use flow design rules

When one flow must work in tests and standalone runtime execution, use these rules:

  1. Keep the consumer contract flat.
  2. Put shared omitted-field resolution in flow-level deriveFlowInput(...).
  3. Make identity-like defaults unique enough for retries and repeated runs, not only for a single dataset.
  4. Centralize assertion helpers and timeouts when the same checks run outside the test runner.
  5. Preserve navigation and refresh semantics from the original working test.
  6. Prefer resilient assertions over UI checks that assume fast background updates or already-fresh pages.

Two rules to apply immediately when extracting a flow:

  • Flow input is the consumer contract.
  • Fixtures, operator credentials, page objects, workspaces, and runtime helpers do not belong in input.

Recommended input-shape guideline:

  • The effective flow input should resolve to one flat user-facing object whenever practical.
  • It is fine to express that shape through TypeScript aliases, intersections, or reused helper types.
  • But the resolved contract that consumers see in inspect should still look like direct input fields, not wrappers introduced only for code organization.
  • Runtime-only fixtures, operator credentials, page objects, and other injected helpers should live in flow context, provided, or setup(...) output, not in the flow input.

Good:

type CustomerAccount = Credentials & {
	address: string
}

export type CreateUserInput = CustomerAccount

Less useful for consumers:

export type CreateUserInput = {
	account: CustomerAccount
}

The second form is valid TypeScript, but it adds a wrapper that consumers must understand and carry around. Prefer refactoring consuming repos toward the flatter effective contract unless the nesting is part of the real business input.

Consumer Contract Review

After extracting a flow, always run:

npx sixpack-playwright inspect <flow>

Review the resolved input and verify:

  • no operator or runtime-only fields
  • no wrapper objects added only for code organization
  • top-level business fields only
  • a non-Playwright consumer could fill the resolved object directly

If the flow defines deriveFlowInput(...), inspect still shows the consumer input contract. That is the shape CLI users and direct callers provide. setup(...) and run(...) receive the derived resolved input instead.

Rule of thumb:

  • If a field comes from Playwright fixtures or test fixtures, it is not input.
  • If a field exists only to help code organization, it is not input.
  • If inspect does not show a flat object a non-Playwright consumer could fill directly, refactor.

Discovery constraints

Raw exported flows are discovered from source shape, not from runtime behavior. The most reliable shape is:

export const createUser = async ({ page, request }, input: { email: string }) => {
	await page.goto('/signup')
	return { email: input.email }
}

Also supported:

import type { PlaywrightFlowContext } from '@sixpack-dev/playwright-flows'

export const createUser = async (
	context: PlaywrightFlowContext,
	input: { email: string }
) => {
	const { page } = context
	await page.goto('/signup')
	return { email: input.email }
}

Less reliable for discovery, and a common source of "why is my flow missing from list?" confusion:

export const createUser = async (context, input: { email: string }) => {
	const { page } = context
	await page.goto('/signup')
	return { email: input.email }
}

If you want maximum clarity and the least discovery ambiguity, prefer either:

  • direct fixture destructuring in the first parameter
  • or definePlaywrightFlow(...) for wrapped flows

If sixpack-playwright list or validate finds no flows, the CLI now prints a hint pointing at this constraint.

Bad vs Good

Bad:

type CreateUserInput = {
	email: string
	reviewerUsername: string
	reviewerPassword: string
}

Good:

type CreateUserInput = {
	email: string
}

type FlowContext = {
	page: Page
	provided?: {
		reviewerCredentials?: Credentials
	}
}

Bad:

type CreateOrderInput = Credentials & {
	quantities: Record<string, number>
}

Good:

type CreateOrderInput = Credentials & {
	itemApple?: number
	itemBread?: number
	itemMilk?: number
	itemCoffee?: number
}

If you want to make the intent explicit in code, you can declare it on wrapped flows:

import { definePlaywrightFlow, flatInput } from '@sixpack-dev/playwright-flows'

export const createUserWithPageObject = definePlaywrightFlow({
	contract: flatInput<{ email: string }>(),
	async setup(context) {
		return { /* ... */ }
	},
	async run(context, input) {
		return { /* ... */ }
	},
})

For automation or AI tooling, use the validation API directly:

import {
	findPlaywrightFlowByName,
	validatePlaywrightFlowContract,
} from '@sixpack-dev/playwright-flows'

const flow = await findPlaywrightFlowByName('createUserWithPageObject', {
	paths: ['tests/flows'],
})
const validation = validatePlaywrightFlowContract(flow)

for (const issue of validation.issues) {
	console.log(issue.code, issue.path, issue.message)
}

Using more Playwright fixtures

Flows can use the same commonly reusable Playwright fixtures that extracted test bodies often rely on, not only page.

import { expect } from '@playwright/test'

export const createUserWithBuiltInFixtures = async (
	{ page, browserName },
	input: { email: string }
) => {
	await page.goto('/signup')
	await page.fill('#email', input.email)
	await page.click('button[type="submit"]')

	await expect(page.locator('#user-id')).not.toHaveText('')

	return {
		userId: (await page.locator('#user-id').textContent()) ?? '',
		browserName,
	}
}

The same flow can still be called from a Playwright test:

import { test } from '@playwright/test'

import { createUserWithBuiltInFixtures } from './flows/create-user-with-built-in-fixtures'

test('creates a user with built-in fixtures', async fixtures => {
	await createUserWithBuiltInFixtures(fixtures, { email: '[email protected]' })
})

Reusing Playwright config and projects

If your Playwright repo already uses playwright.config.ts and named projects for roles or environments, you can reuse that configuration when executing a flow.

When no explicit Playwright config is provided, @sixpack-dev/playwright-flows now tries to auto-discover the nearest playwright.config.{ts,js,mts,mjs,cts,cjs} from the current working directory upward.

Explicit settings still win:

  • runtime.playwrightProject.config overrides autodiscovery
  • CLI --playwright-config overrides autodiscovery
  • explicit runtime fields such as baseURL or storageState override config-derived defaults

If autodiscovery finds a multi-project Playwright config and no project is selected, the runtime fails with a targeted error telling you to set the project explicitly.

This works when the selected Playwright project launches a browser locally.

If the selected project uses connectOptions to connect to a remote browser farm, @sixpack-dev/playwright-flows does not support that yet and will fail with a clear error instead of silently changing behavior.

If the flow uses relative URLs such as page.goto('/signup'), make sure the standalone runtime receives a base URL. The usual options are:

  • runtime.baseURL
  • runtime.playwrightProject.config and optionally runtime.playwrightProject.project
  • an auto-discovered Playwright config from the current working directory upward

Playwright Test may already provide baseURL during normal test execution, but that does not automatically carry over to executePlaywrightFlow(...) or sixpack-playwright generate.

import { executePlaywrightFlow } from '@sixpack-dev/playwright-flows'

import { createUserWithBuiltInFixtures } from './flows/create-user-with-built-in-fixtures'

await executePlaywrightFlow(createUserWithBuiltInFixtures, {
	input: { email: '[email protected]' },
	runtime: {
		playwrightProject: {
			config: 'playwright.config.ts',
			project: 'signup',
		},
	},
})

CLI equivalent:

npx sixpack-playwright generate createUserWithBuiltInFixtures \
  --playwright-config playwright.config.ts \
  --playwright-project signup \
  --input '{"email":"[email protected]"}'

Wrapped flows with injected custom fixtures

If your Playwright repo builds flows directly from built-in Playwright fixtures like page, request, or browser, raw exported functions are usually enough.

But if the test, and the newly extracted flow, depends on injected custom fixtures, shared page-object graphs, or setup that should also work outside of tests, use definePlaywrightFlow(...).

Wrapped flows are discovered explicitly, and setup(...) gives you one place to resolve page objects or helper fixtures before run(...) executes.

Mental model for definePlaywrightFlow(...):

  • base context: the minimum shape a direct caller provides, usually { page } plus the built-in fixtures you really need
  • provided: optional caller-owned fixtures or page objects the flow should reuse instead of recreating
  • deriveFlowInput(...): optional consumer-input to resolved-input transformation before setup(...) and run(...)
  • setup(context, input): resolves helper objects or fallback fixtures for standalone execution
  • run(resolvedContext, input): executes the business journey against the fully prepared context
  • extraFlowContext: runtime-only helper injection for executePlaywrightFlow(...) or CLI execution, not for direct Playwright test calls

If the same flow should support omitted optional fields or generated defaults in direct calls and CLI usage, keep that policy in the flow through deriveFlowInput(...). If you later adapt the flow into Sixpack, that same derivation stays reusable there too; keep generator-only transformations in the adapter layer instead of the flow.

Generic cheat sheet:

  • TInput: caller-facing input contract
  • TOutput: returned output shape
  • TBaseContext: minimum context direct callers pass in
  • TResolvedContext: context available inside run(...) after setup(...)
  • TResolvedInput: input passed to setup(...) and run(...) after deriveFlowInput(...)

The full generic form is definePlaywrightFlow<TInput, TOutput, TBaseContext, TResolvedContext, TResolvedInput>(...), but most wrapped flows do not need all five generics explicitly.

  • if you only provide run, you usually need at most input, output, and context
  • if you use setup(...), prefer the overload where the setup return type is inferred into the resolved context
  • if you do not use deriveFlowInput(...), TResolvedInput is usually just TInput

Resolving consumer input with deriveFlowInput(...)

Use deriveFlowInput(...) when callers should provide one flat consumer contract, but the flow should run with a stricter resolved input.

Common cases:

  • optional consumer fields that need runtime defaults
  • unique usernames or emails generated at execution time
  • small input normalization before setup(...) and run(...)

The contract stays consumer-friendly, while setup(...) and run(...) receive the fully resolved input.

import { randomUUID } from 'node:crypto'

import { definePlaywrightFlow, flatInput } from '@sixpack-dev/playwright-flows'
import type { Page } from 'playwright'

type CreateCustomerInput = {
	username?: string
	password?: string
	address?: string
}

type ResolvedCustomerInput = {
	username: string
	password: string
	address: string
}

export const createCustomer = definePlaywrightFlow<
	CreateCustomerInput,
	{ username: string; userId: string },
	{ page: Page },
	{ page: Page },
	ResolvedCustomerInput
>({
	contract: flatInput<CreateCustomerInput>(),
	deriveFlowInput({ input, context }) {
		return {
			username:
				input.username ??
				`customer-${context.datasetId}-${randomUUID().slice(0, 6)}`,
			password: input.password ?? 'pw-123',
			address: input.address ?? 'Main Street 1',
		}
	},
	async run({ page }, input) {
		await page.goto('/signup')
		await page.fill('#username', input.username)
		await page.fill('#password', input.password)
		await page.fill('#address', input.address)
		await page.click('button[type="submit"]')

		return {
			username: input.username,
			userId: (await page.locator('#user-id').textContent()) ?? '',
		}
	},
})

Avoid Date.now() for generator-oriented uniqueness defaults. It is easy to write, but it is not safe enough under retries or parallel stock generation.

With this pattern:

  • direct Playwright callers can omit optional fields
  • sixpack-playwright generate can omit the same fields
  • if some fields stay required in the caller-facing contract, sixpack-playwright generate still requires only those required fields
  • later integrations can reuse the same flow without reimplementing the derivation logic

Important direct-call rule:

  • If a flow may call createRolePage() in standalone or direct-call mode, the caller must either pass browser or override createRolePage().
import { definePlaywrightFlow } from '@sixpack-dev/playwright-flows'
import type { Page } from 'playwright'

class SignupPage {
	constructor(
		private readonly page: {
			goto(url: string): Promise<void>
			fill(selector: string, value: string): Promise<void>
			click(selector: string): Promise<void>
			textContent(selector: string): Promise<string | null>
		}
	) {}

	async createUser(email: string) {
		await this.page.goto('/signup')
		await this.page.fill('#email', email)
		await this.page.click('button[type="submit"]')
		return (await this.page.textContent('#user-id')) ?? ''
	}
}

const createSignupPage = (page: Page) => new SignupPage(page)

type CreateUserFlowContext = {
	page: Page
	provided?: {
		signupPage?: SignupPage
	}
}

export const createUserWithPageObject = definePlaywrightFlow<
	{ email: string },
	{ email: string; userId: string },
	CreateUserFlowContext
>({
	async setup({ page, provided }) {
		return {
			signupPage: provided.signupPage ?? createSignupPage(page),
		}
	},
	async run({ signupPage }, input) {
		const userId = await signupPage.createUser(input.email)
		return { email: input.email, userId }
	},
})

With this pattern, the base context type is just the shape callers pass in. run(...) automatically sees the fixtures returned from setup(...), so you do not need a separate resolved-context type for the common provided case.

This example is meant to translate common existing Playwright patterns, not replace them with something entirely new.

In a normal Playwright repo, a page object like SignupPage is usually introduced in one of these ways:

  • inline in the test body: const signupPage = new SignupPage(page)
  • through a shared helper or factory: const signupPage = createSignupPage(page)
  • through a custom fixture: test.extend({ signupPage: ... })

The flow version maps those patterns like this:

  • setup(...) is the portable fallback. If the caller only has { page }, the flow creates what it needs there.
  • shared factories still work well. If your repo already has createSignupPage(page), use the same helper inside setup(...) and your fixtures.
  • provided is the hand-off point from Playwright fixtures into the flow. If the test already has signupPage, pass it through instead of rebuilding it.

Inside setup(...), provided itself is always present. Its fields are optional because they come from Partial<TProvided>. Prefer provided.field ?? fallback, not provided?.field.

The same wrapped flow is still called like a normal function from a Playwright test:

import { test } from '@playwright/test'

test('creates a user', async ({ page }) => {
	await createUserWithPageObject({ page }, { email: '[email protected]' })
})

For multi-role flows, there are two common direct-call patterns:

  1. Let the flow create the extra role page itself by passing { browser, page }:
await approveUserFlow(
	{
		browser,
		page,
	},
	{ email: '[email protected]' }
)
  1. Reuse a caller-owned role page by overriding createRolePage():
import { reuseContextPage } from '@sixpack-dev/playwright-flows'

await approveUserFlow(
	{
		page,
		createRolePage: reuseContextPage({
			context: riskContext,
			page: riskPage,
		}),
	},
	{ email: '[email protected]' }
)

If a direct caller passes only { page } and the flow later calls createRolePage(), the runtime will fail with a targeted error telling the caller to pass browser or override createRolePage().

One small real-repo-shaped pattern tends to work especially well during migration: keep the flow standalone-capable, but let tests pass both existing page objects and an existing secondary page through provided plus reuseContextPage(...).

import { definePlaywrightFlow, reuseContextPage } from '@sixpack-dev/playwright-flows'
import type { BrowserContext, Page } from 'playwright'

class ApprovalPage {
	constructor(
		private readonly page: {
			goto(url: string): Promise<void>
			click(selector: string): Promise<void>
		}
	) {}

	async approveUser(userId: string) {
		await this.page.goto(`/approvals/${userId}`)
		await this.page.click('[data-testid="approve-user"]')
	}
}

const createApprovalPage = (page: Page) => new ApprovalPage(page)

type ApproveCreatedUserFlowContext = {
	page: Page
	browser?: { newContext(): Promise<BrowserContext> }
	provided?: {
		signupPage?: SignupPage
		approvalPage?: ApprovalPage
	}
}

export const approveCreatedUser = definePlaywrightFlow<
	{ email: string },
	{ userId: string },
	ApproveCreatedUserFlowContext
>({
	async setup({ page, provided, createRolePage }) {
		const approvalRole =
			provided.approvalPage
				? null
				: await createRolePage()

		return {
			signupPage: provided.signupPage ?? createSignupPage(page),
			approvalPage:
				provided.approvalPage ??
				createApprovalPage(approvalRole!.page),
		}
	},
	async run({ signupPage, approvalPage }, input) {
		const userId = await signupPage.createUser(input.email)
		await approvalPage.approveUser(userId)
		return { userId }
	},
})

Inside a Playwright test that already owns both pages:

import { test } from './fixtures'
import { reuseContextPage } from '@sixpack-dev/playwright-flows'

test('approves a created user', async ({ page, signupPage, approvalContext, approvalPage }) => {
	await approveCreatedUser(
		{
			page,
			provided: {
				signupPage,
				approvalPage: createApprovalPage(approvalPage),
			},
			createRolePage: reuseContextPage({
				context: approvalContext,
				page: approvalPage,
			}),
		},
		{ email: '[email protected]' }
	)
})

The same flow still works standalone when the caller passes { browser, page } and does not provide the secondary page.

If your repo already injects a page object through a shared fixture helper, pass it through provided and setup(...) will reuse it:

import { test } from './fixtures'

test('creates a user', async ({ page, signupPage }) => {
	await createUserWithPageObject(
		{
			page,
			provided: { signupPage },
		},
		{ email: '[email protected]' }
	)
})

That fixture helper can use normal Playwright fixture composition:

import { test as base } from '@playwright/test'

export const test = base.extend<{
	signupPage: SignupPage
}>({
	signupPage: async ({ page }, use) => {
		await use(createSignupPage(page))
	},
})

If you want a reusable helper type for that direct-call shape, PlaywrightFlowContextWithProvided<TProvided> now defaults to the minimal { page } caller context. Add the second generic only when the flow genuinely depends on more built-in fixtures.

import type { PlaywrightFlowContextWithProvided } from '@sixpack-dev/playwright-flows'
import type { APIRequestContext, Page } from 'playwright'

type SignupFlowContext = PlaywrightFlowContextWithProvided<{
	signupPage: SignupPage
}>

type SignupFlowContextWithRequest = PlaywrightFlowContextWithProvided<
	{ signupPage: SignupPage },
	{
		page: Page
		request: APIRequestContext
	}
>

So the duplication is not supposed to be "construct the same object in two unrelated places". The intended model is:

  1. Keep one shared construction path when that is useful, for example createSignupPage(page).
  2. Let setup(...) call it when the flow runs without Playwright-owned fixtures.
  3. Let Playwright fixtures call the same helper when tests already expose signupPage.
  4. Pass fixture-owned objects through provided so the flow reuses them.

This is the intended migration recipe:

  1. Move the business steps from the test body into run(...).
  2. Move page-object or helper creation into setup(...).
  3. In tests, pass existing objects through provided when you already have them.
  4. In CLI or other execution outside tests, omit provided and let the flow resolve the rest automatically.

Adopting in an existing Playwright repo

For an existing repo with shared fixture files, page objects, and incremental flow adoption, the recommended pattern is:

  1. Keep your current fixture composition and page-object classes.
  2. Define the flow around the smallest base context the test already has, usually { page } plus any built-in fixtures you really use.
  3. Add provided?: { ... } only for fixtures the test may already own.
  4. In setup(...), reuse caller-owned objects through provided first, then create defaults for CLI or standalone execution.
  5. Keep run(...) focused on the business journey so the same flow body works both from Playwright tests and from sixpack-playwright generate.
  6. Treat reused pages or contexts as potentially already authenticated or otherwise already advanced in the journey.

Migrating stateful existing tests

This is the most common migration trap when extracting a journey from an existing suite:

  • the flow works when executed standalone through executePlaywrightFlow(...)
  • the same flow later gets reused inside a longer Playwright test
  • the reused page is already logged in or already past an earlier screen
  • the extracted flow still tries to perform the earlier step unconditionally and times out waiting for UI that is no longer present

That is not usually a runtime defect in @sixpack-dev/playwright-flows. It is a flow precondition issue in the extracted consumer code.

For example, this version is brittle if the caller passes an already-authenticated page:

class CustomerWorkspacePage {
	constructor(private readonly page: Page) {}

	async login(email: string) {
		await this.page.goto('/user/login')
		await this.page.getByLabel('Email').fill(email)
		await this.page.getByRole('button', { name: 'Log in' }).click()
	}
}

For reusable flows, prefer making session-sensitive helpers explicit or idempotent:

class CustomerWorkspacePage {
	constructor(private readonly page: Page) {}

	async isLoggedInAs(email: string): Promise<boolean> {
		await this.page.goto('/user')
		return (await this.page.getByTestId('current-user-email').textContent()) === email
	}

	async loginIfNeeded(email: string): Promise<void> {
		if (await this.isLoggedInAs(email)) {
			return
		}

		await this.page.goto('/user/login')
		await this.page.getByLabel('Email').fill(email)
		await this.page.getByRole('button', { name: 'Log in' }).click()
	}
}

That lets the same flow work both:

  • standalone from executePlaywrightFlow(...) or the CLI
  • inside a larger Playwright test that already established session state before reusing the flow

Use this rule of thumb when extracting existing journeys:

  • If the flow genuinely requires a strict starting state, document and enforce that precondition clearly.
  • If the flow is intended to be reused in mixed contexts, make auth or session-entry steps adapt to the current state instead of assuming the login form or previous screen is always present.

Another common migration trap is dropping a navigation or refresh step that looked redundant in the original test. That often passes under direct test execution, then fails later in standalone execution because the page is stale when an event log or async status assertion runs. If an extracted flow starts failing only outside the original suite, compare it against the last known-good test sequence step-for-step before treating it as a timeout problem.

Advanced scenarios

Use these patterns when the basic { page } or { page, provided } adoption path is not enough.

Common signs you have moved into an advanced scenario:

  • the flow needs more than one browser context or page
  • part of the journey runs as another user, role, or session
  • the Playwright test already owns extra contexts or pages that the flow should reuse
  • you need runtime-created helper objects rather than fixtures passed directly from the test

For these cases, setup(...) also receives helper methods:

  • createContext(): create an extra browser context that inherits the active flow runtime defaults
  • createPage(): create an extra page in an inherited context
  • createRolePage(): create both and return { context, page }

Those helpers inherit baseURL, storageState, and resolved Playwright project context options when the flow runs through executePlaywrightFlow(...) or the CLI. Owned contexts created through them are closed automatically after the flow finishes.

createRolePage() is just a convenience for the common “secondary context plus page” case. It is not limited to literal role-based workflows.

If the caller already owns the extra context or page, overriding these helpers in the direct flow call is an accepted pattern. reuseContextPage(...) makes that intent explicit for the common { context, page } case:

import { reuseContextPage } from '@sixpack-dev/playwright-flows'

await approveApplication(
	{
		page,
		createRolePage: reuseContextPage({
			context: riskContext,
			page: riskPage,
		}),
	},
	{ applicationId: 'app-123' }
)

When you reuse an already-owned context/page pair, do not assume it is in the same session state as a fresh runtime-created page. In practice that usually means the flow should either:

  • enforce a clear precondition before continuing, or
  • use idempotent page-object methods such as loginIfNeeded(...) when the flow is expected to run both standalone and as one step inside a larger test

That means the typed direct-call surface can stay small in normal tests:

  • Use { page } when the flow only needs the active page.
  • Add provided when the test already owns page objects or app helpers.
  • Add request, browserName, or other built-in fixtures only when the flow actually uses them.
  • Override createContext(), createPage(), or createRolePage() only when the flow needs extra pages and the test already owns them.

reuseContextPage(...) does not transfer ownership. The test still owns cleanup for the reused context and page. Only contexts created by the flow runtime itself are closed automatically.

Adding explicit runtime flow context

If you only need to attach helper objects to the runtime-created flow context, extraFlowContext is available.

This is different from provided and setup(...):

  • use provided and setup(...) when a Playwright test is directly calling the flow and may already own fixtures or page objects
  • use extraFlowContext when the flow runs through executePlaywrightFlow(...) or the CLI and you need to attach runtime-created helper objects
class SignupPage {
	constructor(
		private readonly page: {
			goto(url: string): Promise<void>
			fill(selector: string, value: string): Promise<void>
			click(selector: string): Promise<void>
			textContent(selector: string): Promise<string | null>
		}
	) {}

	async createUser(email: string) {
		await this.page.goto('/signup')
		await this.page.fill('#email', email)
		await this.page.click('button[type="submit"]')
		return (await this.page.textContent('#user-id')) ?? ''
	}
}

export const createUserWithExtraFixture = async (
	{ signupPage }: { signupPage: SignupPage },
	input: { email: string }
) => {
	const userId = await signupPage.createUser(input.email)
	return { email: input.email, userId }
}
import { executePlaywrightFlow } from '@sixpack-dev/playwright-flows'

await executePlaywrightFlow(createUserWithExtraFixture, {
	input: { email: '[email protected]' },
	runtime: {
		extraFlowContext: ({ base }) => ({
			signupPage: new SignupPage(base.page),
		}),
	},
})

Discovery and inspection

The package can scan a codebase, discover exported flows, and provide lightweight inspection for each discovered flow. This makes it possible to list available flows, inspect their names and module locations, and check their input and output shape before executing them.

CLI

list discovers exported flows and prints the available flow names.

npx sixpack-playwright list

inspect loads a discovered flow and shows basic information about it, including where it comes from and its input or output shape when available.

The CLI shows both the declared type and the resolved structural shape. Treat the resolved input as the practical contract a consumer will need to fill. If that resolved shape is not a flat user-facing object, it is usually a sign that the flow input should be refactored rather than documented around.

inspect also prints contract warnings when input fields look like runtime fixtures or when the effective input shape is not flat.

  • Fixture-like input fields fail validation by default because they usually indicate a real contract leak.
  • Non-flat input shape warnings become fatal under --strict, which is useful for CI or gradual migration.

Use --strict in CI or automation to enforce the full contract:

npx sixpack-playwright inspect createUser --strict
npx sixpack-playwright inspect createOrder --strict
npx sixpack-playwright inspect createUser

generate executes a discovered flow with the provided input.

npx sixpack-playwright generate createUser --input '{"email":"[email protected]"}'

For CLI input behavior:

  • required properties in the caller-facing flow input must be provided
  • optional properties may be omitted
  • if a flow declares both required and optional properties, generate only complains about the missing required ones
  • if omitted optional values should be computed at runtime, keep that policy in deriveFlowInput(...)

validate scans the codebase for flows and checks for discovery errors or duplicate names.

npx sixpack-playwright validate

Optional flags

  • --path <dir>: limit discovery to one or more directories
  • --json: print JSON output for list, inspect, and validate
  • --strict: fail inspect or validate on all contract issues, including non-flat input warnings
  • --input <json>: provide generator input inline for generate
  • --input-file <file>: read generator input from a JSON file for generate
  • --set <key=value>: provide one flat input property for generate; repeat as needed, with value coercion based on the flow input type
  • --browser <name>: choose chromium, firefox, or webkit for generate
  • --base-url <url>: set Playwright baseURL for generate
  • --storage-state <path>: provide Playwright storage state for generate
  • --playwright-config <file-or-dir>: reuse Playwright config defaults for generate
  • --playwright-project <name>: select a named Playwright project for generate
  • --headed: run the browser headed for generate

If --playwright-config is omitted, generate tries to auto-discover the nearest Playwright config from the current working directory upward.

Notes

This package is usable on its own. You can install it and use the CLI or runtime helpers in any Playwright project.

Playwright config and project reuse depends on Playwright internal config-loading APIs. It is supported for the Playwright peer range above, but it is more version-sensitive than the basic page or request flow runtime.

When wrapped flows create extra contexts from an existing Playwright test invocation rather than from executePlaywrightFlow(...), inherited context defaults may depend on Playwright internal context metadata. That behavior is encapsulated by this package so repos do not need their own _options workarounds.

Direct flow calls from Playwright tests may also override createContext(), createPage(), or createRolePage() when the test already owns those resources. Only contexts created by the package runtime are closed automatically.