@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.
Maintainers
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(...)orsixpack-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:
- Keep the consumer contract flat.
- Put shared omitted-field resolution in flow-level
deriveFlowInput(...). - Make identity-like defaults unique enough for retries and repeated runs, not only for a single dataset.
- Centralize assertion helpers and timeouts when the same checks run outside the test runner.
- Preserve navigation and refresh semantics from the original working test.
- 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
inspectshould 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, orsetup(...)output, not in the flow input.
Good:
type CustomerAccount = Credentials & {
address: string
}
export type CreateUserInput = CustomerAccountLess 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
inspectdoes 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.configoverrides autodiscovery- CLI
--playwright-configoverrides autodiscovery - explicit runtime fields such as
baseURLorstorageStateoverride 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.baseURLruntime.playwrightProject.configand optionallyruntime.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 recreatingderiveFlowInput(...): optional consumer-input to resolved-input transformation beforesetup(...)andrun(...)setup(context, input): resolves helper objects or fallback fixtures for standalone executionrun(resolvedContext, input): executes the business journey against the fully prepared contextextraFlowContext: runtime-only helper injection forexecutePlaywrightFlow(...)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 contractTOutput: returned output shapeTBaseContext: minimum context direct callers pass inTResolvedContext: context available insiderun(...)aftersetup(...)TResolvedInput: input passed tosetup(...)andrun(...)afterderiveFlowInput(...)
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(...),TResolvedInputis usually justTInput
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(...)andrun(...)
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 generatecan omit the same fields- if some fields stay required in the caller-facing contract,
sixpack-playwright generatestill 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 passbrowseror overridecreateRolePage().
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 insidesetup(...)and your fixtures. providedis the hand-off point from Playwright fixtures into the flow. If the test already hassignupPage, 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:
- Let the flow create the extra role page itself by passing
{ browser, page }:
await approveUserFlow(
{
browser,
page,
},
{ email: '[email protected]' }
)- 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:
- Keep one shared construction path when that is useful, for example
createSignupPage(page). - Let
setup(...)call it when the flow runs without Playwright-owned fixtures. - Let Playwright fixtures call the same helper when tests already expose
signupPage. - Pass fixture-owned objects through
providedso the flow reuses them.
This is the intended migration recipe:
- Move the business steps from the test body into
run(...). - Move page-object or helper creation into
setup(...). - In tests, pass existing objects through
providedwhen you already have them. - In CLI or other execution outside tests, omit
providedand 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:
- Keep your current fixture composition and page-object classes.
- Define the flow around the smallest base context the test already has, usually
{ page }plus any built-in fixtures you really use. - Add
provided?: { ... }only for fixtures the test may already own. - In
setup(...), reuse caller-owned objects throughprovidedfirst, then create defaults for CLI or standalone execution. - Keep
run(...)focused on the business journey so the same flow body works both from Playwright tests and fromsixpack-playwright generate. - 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 defaultscreatePage(): create an extra page in an inherited contextcreateRolePage(): 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
providedwhen 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(), orcreateRolePage()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
providedandsetup(...)when a Playwright test is directly calling the flow and may already own fixtures or page objects - use
extraFlowContextwhen the flow runs throughexecutePlaywrightFlow(...)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 listinspect 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 --strictnpx sixpack-playwright inspect createUsergenerate 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,
generateonly 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 validateOptional flags
--path <dir>: limit discovery to one or more directories--json: print JSON output forlist,inspect, andvalidate--strict: failinspectorvalidateon all contract issues, including non-flat input warnings--input <json>: provide generator input inline forgenerate--input-file <file>: read generator input from a JSON file forgenerate--set <key=value>: provide one flat input property forgenerate; repeat as needed, with value coercion based on the flow input type--browser <name>: choosechromium,firefox, orwebkitforgenerate--base-url <url>: set PlaywrightbaseURLforgenerate--storage-state <path>: provide Playwright storage state forgenerate--playwright-config <file-or-dir>: reuse Playwright config defaults forgenerate--playwright-project <name>: select a named Playwright project forgenerate--headed: run the browser headed forgenerate
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.
