@sixpack-dev/playwright-sixpack-adapter
v0.5.12
Published
Adapter for exposing reusable Playwright flows as Sixpack SDK generators for test data and setup automation.
Downloads
920
Maintainers
Readme
Sixpack Playwright Adapter
@sixpack-dev/playwright-sixpack-adapter lets you take a Playwright flow you already have and expose it as a Sixpack generator.
Use it when:
- you already have a reusable flow from
@sixpack-dev/playwright-flows - you want to expose that flow through
@sixpack-dev/sdkas a generator - you want the generator runtime to execute the flow through Playwright
In simple terms:
@sixpack-dev/playwright-flowsis where you extract and run reusable UI flows@sixpack-dev/playwright-sixpack-adapteris the thin bridge that plugs one of those flows into Sixpack
This package is not a replacement for @sixpack-dev/playwright-flows. It is the adapter layer between playwright-flows and @sixpack-dev/sdk.
The flow can be either:
- a simple exported Playwright flow function
- a wrapped flow created with
definePlaywrightFlow(...)
Installation
npm install @sixpack-dev/playwright-sixpack-adapter @sixpack-dev/playwright-flows @sixpack-dev/sdk playwright@sixpack-dev/playwright-flows and this adapter use your project's Playwright installation.
If your project defines or imports Playwright flows directly, keep @sixpack-dev/playwright-flows as a direct dependency of that project. Do not rely on it arriving only as a transitive dependency of the adapter.
Supported Playwright peer range: >=1.53.2 <2
If your flow uses relative URLs such as page.goto('/user'), you must provide runtime URL context when the flow runs outside Playwright Test. The usual options are:
runtime.baseURLruntime.playwrightProject.configplus an optional namedruntime.playwrightProject.project- an auto-discovered Playwright config when the supplier process starts inside a Playwright repo
Without one of those, Playwright has no base URL to resolve against in standalone runtime, CLI, or Sixpack execution.
Start Here
Start with one reusable Playwright flow and wrap it as one generator. Keep the first generator small, flat, and runnable through your real playwright.config.*.
Assume you already have a reusable flow. It does not need to use definePlaywrightFlow(...):
export const createUser = async ({ page }, input: { email: string }) => {
await page.goto('/signup')
await page.fill('#email', input.email)
await page.click('button[type="submit"]')
return {
userId: (await page.locator('#user-id').textContent()) ?? '',
}
}Adapt it into a Sixpack generator:
import { Supplier } from '@sixpack-dev/sdk'
import { s } from '@sixpack-dev/sdk/item'
import { adaptPlaywrightFlow } from '@sixpack-dev/playwright-sixpack-adapter'
import { createUser } from './flows/create-user'
const supplier = new Supplier({
name: 'demo-supplier',
reportIssueUrl: 'https://example.com/support',
}).withGenerators(
adaptPlaywrightFlow({
name: 'create-user',
flow: createUser,
inputSchema: {
email: s.string(),
},
outputSchema: {
userId: s.string(),
},
reportIssueUrl: 'https://example.com/support',
})
)That is the intended first migration step:
- keep the existing business journey in the flow
- declare the Sixpack input and output schemas explicitly
- reuse real Playwright runtime config only where needed
End-to-end migration example
This is the compact migration path for a repo that starts with a Playwright flow, exposes it as a Sixpack generator, and wants deterministic adapter validation.
Extract a reusable flow:
// tests/flows/user.flows.ts
export const createApprovedUser = async (
{ page }: { page: { goto(url: string): Promise<void>; fill(selector: string, value: string): Promise<void>; click(selector: string): Promise<void>; locator(selector: string): { textContent(): Promise<string | null> } } },
input: { email: string }
) => {
await page.goto('/signup')
await page.fill('[name="email"]', input.email)
await page.click('[data-testid="submit-signup"]')
return {
userId: (await page.locator('[data-testid="user-id"]').textContent()) ?? '',
email: input.email,
}
}Adapt it into a generator with explicit validation binding:
// sixpack/generators.ts
import { adaptPlaywrightFlow } from '@sixpack-dev/playwright-sixpack-adapter'
import { s } from '@sixpack-dev/sdk/item'
import { createApprovedUser } from '../tests/flows/user.flows'
export const createApprovedUserGenerator = adaptPlaywrightFlow({
name: 'create-approved-user',
flow: createApprovedUser,
inputSchema: {
email: s.string(),
},
outputSchema: {
userId: s.string(),
email: s.string(),
},
runtime: {
playwrightProject: {
config: './playwright.config.ts',
project: 'chromium',
},
},
validation: {
modulePath: './tests/flows/user.flows.ts',
exportName: 'createApprovedUser',
},
})Wire it into a supplier:
// sixpack/supplier.ts
import { Supplier } from '@sixpack-dev/sdk'
import { createApprovedUserGenerator } from './generators'
export const supplier = new Supplier({
name: 'demo-supplier',
reportIssueUrl: 'https://example.com/support',
}).withGenerators(createApprovedUserGenerator)Validate the generator contract against the flow contract:
npx sixpack-playwright-adapter validate ./sixpack/generators.ts --path ./tests/flowsThis example keeps the responsibilities separate:
- the flow owns the business journey
runtimetells the adapter how to execute the flow outside Playwright Testvalidationtells the adapter which discovered flow contract to compare against- the supplier only wires generators into Sixpack
Three execution modes
The most important adoption detail is that the same flow usually ends up running in three different modes:
- direct from a Playwright test that already owns fixtures, pages, and session state
- through
sixpack-playwright generateorexecutePlaywrightFlow(...) - through Sixpack supplier execution after
adaptPlaywrightFlow(...)
Those modes reuse the same business journey, but they do not put the flow under the same pressure.
- direct test calls prove the extracted flow still works inside your suite
- standalone CLI/runtime execution proves runtime config such as
baseURL, project selection, and helper creation are correct - supplier execution proves the flow is safe under retries, repeated stock generation, and slower async behavior
Treat all three as part of the migration path. A flow that is green in tests can still fail as a generator because of uniqueness assumptions, default 5s assertions, or stale-page assumptions after state changes.
Runtime vs validation
It is important to treat runtime execution and adapter validation as different checks:
- runtime success means the generator can execute the flow through Playwright with the current config and environment
- validation success means the adapter can also rediscover that flow and compare
inputSchemaagainst the flow's consumer contract
In practice, runtime can already work while validation still fails because of:
- TS or ESM loader differences
- importing the same flow through different module paths
- validation running from a different working directory than the flow source
When that happens, fix the validation path directly instead of assuming runtime is broken.
First-run checklist
Before you call the integration complete, verify these in order:
- Reuse your real
playwright.config.*and project when the flow uses relative URLs or depends onbaseURL,storageState, headers, or role projects. - Keep the generator contract flat and put shared defaulting behavior in flow-level
deriveFlowInput(...). - Make omitted identity-like fields generator-safe by default so retries and repeated stock execution do not collide.
- Re-run important flows in all three modes: Playwright test,
sixpack-playwright generate, and live supplier execution. - Centralize assertion helpers and raise timeouts intentionally when the same assertions must survive supplier load.
- Preserve the original navigation and refresh semantics from the working test, especially around event logs or async state transitions.
Sharing Schemas With Orchestrators
If an orchestrator needs to request a Playwright-backed generator, keep shared schemas and types in a separate file. Do not import them from the generator file itself.
This matters because Temporal loads and bundles the orchestrator module as workflow code. Importing one exported schema from a generator file still executes that whole generator module at load time, including imports such as @sixpack-dev/playwright-sixpack-adapter, @sixpack-dev/playwright-flows, and Playwright runtime helpers. Those modules belong in generator execution, not in deterministic orchestrator workflow code.
Use this shape:
// customer-profile.schemas.ts
import { s } from '@sixpack-dev/sdk/item'
export const customerProfileInputSchema = {
email: s.string(),
}
export const customerProfileOutputSchema = {
email: s.string(),
fullName: s.string(),
}// customer-profile.generator.ts
import { adaptPlaywrightFlow } from '@sixpack-dev/playwright-sixpack-adapter'
import { customerProfileInputSchema, customerProfileOutputSchema } from './customer-profile.schemas'
import { createCustomerProfile } from './customer-profile.flow'
export const customerProfileGenerator = adaptPlaywrightFlow({
name: 'Customer profile',
flow: createCustomerProfile,
inputSchema: customerProfileInputSchema,
outputSchema: customerProfileOutputSchema,
})// customer-onboarding.orchestrator.ts
import { defineOrchestratorItem } from '@sixpack-dev/sdk/item'
import { customerProfileInputSchema, customerProfileOutputSchema } from './customer-profile.schemas'
export const customerOnboardingOrchestrator = defineOrchestratorItem({
generatePath: __filename,
metadata: {
name: 'Customer onboarding',
inputSchema: customerProfileInputSchema,
outputSchema: customerProfileOutputSchema,
},
})Avoid this shape:
// customer-onboarding.orchestrator.ts
import { customerProfileInputSchema } from './customer-profile.generator'The adapter cannot make this compile-time safe in TypeScript because module imports are file-level, not export-level. The SDK supplier startup detects common Playwright runtime imports during orchestrator worker creation and reports an actionable error, but the reliable prevention is to keep shared schema files separate from generator runtime files.
Adapter Contract
adaptPlaywrightFlow(...) expects:
flow: the Playwright flow to executeinputSchema: Sixpack item schema for generator inputoutputSchema: Sixpack item schema for generator output; keep this flatoutputKind: output shape, defaulting toFLATnameand metadata fields: normal Sixpack generator metadataruntime: optional Playwright runtime configuration forwarded toexecutePlaywrightFlow(...)validation: optional explicit flow binding metadata for deterministic contract validation
The adapter does not change the flow logic. It wraps the flow as a generator item and executes it through the playwright-flows runtime when generate(...) is called.
The practical rule is:
- keep generator input flat
- keep generator output flat
- use
outputKindonly when you want a collection shape such asLIST
Avoid nested output wrappers such as { account: { ... } } unless that nesting is part of the real consumer contract.
When adapted flow execution fails, the adapter now preserves the original error as cause and adds generator-specific context to the top-level message, including:
- generator name
- flow name
- execution mode
- environment, dataset, and iteration
- a compact input summary
Use validation when you want adapter validation to bind to a specific discovered flow without relying on runtime function identity:
adaptPlaywrightFlow({
name: 'create-user',
flow: createUser,
inputSchema: {
email: s.string(),
},
outputSchema: {
userId: s.string(),
},
validation: {
modulePath: './tests/flows/user.flows.ts',
exportName: 'createUser',
},
})This is the preferred escape hatch when the validator and the runtime load the same source through different TS, ESM, or bundled paths.
Deriving flow input
Prefer flow-level deriveFlowInput(...) on definePlaywrightFlow(...) when the same omitted-field or defaulting behavior should apply to:
- direct Playwright calls
sixpack-playwright generate- Sixpack generator execution through this adapter
Use the adapter's deriveGeneratorInput(...) only when the generator contract should differ slightly from the underlying flow contract in a Sixpack-specific way.
Flow-level deriveFlowInput(...) is the intended place for patterns such as:
- optional generator fields with runtime defaults
- generating unique values for stock-safe templates
- small consumer-input to resolved-input transformations shared across every execution path
Adapter-level deriveGeneratorInput(...) is still useful when you need Sixpack-specific enrichment from generator runtime context such as datasetId or environment, but you do not want that behavior in plain flow execution.
deriveGeneratorInput(...) receives:
- the generator input validated against
inputSchema - the Sixpack generator runtime context
Its return value becomes the actual input passed to:
- the Playwright flow
- adapter runtime option factories
This keeps Sixpack-only derivation local to your repo without forcing you to write a separate wrapper flow function.
Validating adapter input contract drift
The adapter does not infer inputSchema from the flow contract. Instead, validate that the declared Sixpack input schema still matches the flow's consumer-facing input:
npx sixpack-playwright-adapter validate ./sixpack/generators.tsThat validation:
- discovers Playwright flows in the current repo
- matches adapted generators back to their source flows
- compares the flow consumer input contract against the adapter
inputSchema - fails on missing fields, extra fields, type mismatches, and required/optional drift
Use --json for machine-readable output in CI or agent workflows, and --path <dir-or-file> when the relevant flow sources live outside the default discovery roots.
If validation fails with flow_not_discovered, work through these in order:
- Confirm the flow source file is inside the discovery roots used by validation.
- Pass one or more
--pathvalues that include the real flow source module. - Add explicit adapter binding metadata with
validation.modulePathandvalidation.exportName.
The stable pattern is:
- let
runtimeanswer "how does this flow execute?" - let
validationanswer "which discovered flow contract should this generator be compared against?"
Flat generator output when the original flow is nested
Many existing Playwright flows were written for tests first and return nested shapes such as:
export const createAccount = async ({ page }, input: { email: string }) => {
// ...
return {
account: {
email: input.email,
id: 'acc-1',
},
}
}That can still be a good internal test flow, but generator consumers usually work better with a flat output contract.
Prefer these rules:
- refactor the original flow directly when the nested wrapper is not part of the real business contract
- keep the original test-oriented flow and add a thin wrapper when tests genuinely benefit from the nested shape
Example wrapper:
const createAccountFlat = async (
context: Parameters<typeof createAccount>[0],
input: Parameters<typeof createAccount>[1]
) => {
const result = await createAccount(context, input)
return {
email: result.account.email,
accountId: result.account.id,
}
}
export const createAccountGenerator = adaptPlaywrightFlow({
name: 'create-account',
flow: createAccountFlat,
inputSchema: {
email: s.string(),
},
outputSchema: {
email: s.string(),
accountId: s.string(),
},
})Use wrapping when it removes consumer-facing nesting without forcing a broad test refactor. Prefer refactoring when the wrapper would only preserve historical structure with no real user value.
Runtime Configuration
Any adapter runtime value is forwarded to @sixpack-dev/playwright-flows.
Reusing Playwright config and projects
adaptPlaywrightFlow({
name: 'create-user',
flow: createUser,
inputSchema: {
email: s.string(),
},
outputSchema: {
userId: s.string(),
},
runtime: {
playwrightProject: {
config: 'playwright.config.ts',
project: 'signup',
},
},
})This is the recommended path when your flows use relative URLs and your repo already defines baseURL, storageState, role projects, or other Playwright defaults in playwright.config.ts.
Adding runtime flow context
Built-in Playwright fixtures such as page, context, browser, browserName, request, and playwright are available automatically.
If the flow depends on repo-specific helpers, add them through extraFlowContext.
This is the standalone runtime path. It is different from provided and setup(...) in playwright-flows, which are used when a Playwright test directly calls a flow and wants to reuse already-owned fixtures or page objects.
class SignupPage {
constructor(private readonly page: { goto(url: string): Promise<void> }) {}
async open() {
await this.page.goto('/signup')
}
}
export const createUserWithSignupPage = async (
{ signupPage }: { signupPage: SignupPage },
input: { email: string }
) => {
await signupPage.open()
return { userId: input.email }
}
adaptPlaywrightFlow({
name: 'create-user',
flow: createUserWithSignupPage,
inputSchema: {
email: s.string(),
},
outputSchema: {
userId: s.string(),
},
runtime: {
extraFlowContext: ({ base }) => ({
signupPage: new SignupPage(base.page),
}),
},
})Unique stock with flow-level deriveFlowInput(...)
A common adoption pattern is:
- keep the flow reusable and user-facing
- allow optional input fields for values callers may want to control
- let flow-level
deriveFlowInput(...)fill omitted fields with unique runtime values - return the resolved values in a flat output object
- use templates with omitted optional fields so stock stays valid across repeated runs
On the Sixpack side, also describe that behavior in inputSchema so UI users can see it. There is no schema-level .default(...) today, so the practical pattern is:
- use
.optional()to allow omission - use
.nullDescription(...)to explain what will happen when the value is not provided - use runtime derivation to actually compute the fallback value
import { randomUUID } from 'node:crypto'
import { definePlaywrightFlow, flatInput } from '@sixpack-dev/playwright-flows'
import { adaptPlaywrightFlow } from '@sixpack-dev/playwright-sixpack-adapter'
import { s, type Template } from '@sixpack-dev/sdk/item'
import type { Page } from 'playwright'
type CreateApprovedCustomerInput = {
username?: string
password?: string
address?: string
}
type ApprovedCustomerFlowInput = {
username: string
password: string
address: string
}
const stockTemplates: Array<Template<CreateApprovedCustomerInput>> = [
{
input: {},
minimum: 3,
},
]
function resolveCustomerInput(input: CreateApprovedCustomerInput): ApprovedCustomerFlowInput {
const uniqueSuffix = randomUUID().slice(0, 8)
return {
username: input.username ?? `stock-customer-${uniqueSuffix}`,
password: input.password ?? 'pw-123',
address: input.address ?? 'Stock Street 1',
}
}
const createApprovedCustomer = definePlaywrightFlow<
CreateApprovedCustomerInput,
{ username: string; password: string; address: string; userId: string },
{ page: Page },
{ page: Page },
ApprovedCustomerFlowInput
>({
contract: flatInput<CreateApprovedCustomerInput>(),
deriveFlowInput({ input, context }) {
return resolveCustomerInput({
...input,
username: input.username ?? `stock-customer-${context.datasetId}-${randomUUID().slice(0, 6)}`,
})
},
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,
password: input.password,
address: input.address,
userId: (await page.locator('#user-id').textContent()) ?? '',
}
},
})
adaptPlaywrightFlow({
name: 'create-approved-customer',
flow: createApprovedCustomer,
inputSchema: {
username: s
.string()
.nullDescription('Auto-generated unique username')
.optional(),
password: s
.string()
.nullDescription('Default password chosen by the generator')
.optional(),
address: s
.string()
.nullDescription('Default stock address')
.optional(),
},
outputSchema: {
username: s.string(),
password: s.string(),
address: s.string(),
userId: s.string(),
},
templates: stockTemplates,
runtime: {
playwrightProject: {
config: 'playwright.config.ts',
},
},
})This pattern avoids static stock collisions for unique business entities and gives callers the generated credentials back in a form they can use directly across CLI, direct runtime, and Sixpack.
If the derivation should exist only for the generator contract, keep using adapter-level deriveGeneratorInput(...) instead.
Make stock output self-contained. If downstream tests or generators need credentials, IDs, or other fields to act on the generated entity, include them in outputSchema and in the returned output. A stocked customer that omits its password, for example, is not reusable input for a later placeOrder flow.
Choosing minimum is a workload decision, not a library default. A practical rule is:
- start from your peak parallel consumers
- add a buffer for generation latency and retries
- increase the minimum when one item is expensive to generate
For example, if you run 4 parallel workers and generating one customer can take around 30 seconds, a minimum around 10 gives you useful headroom instead of running at the edge.
Stock-safe flow design
When a flow is expected to serve both tests and stock generation, apply these rules early:
- generate unique defaults for usernames, emails, or other identity-like fields
- make the uniqueness policy retry-safe, not just dataset-safe
- centralize longer assertion timeouts instead of relying on Playwright's default 5s behavior everywhere
- keep outputs flat so non-Playwright consumers can understand them immediately
- preserve refresh or second-navigation steps from the original working test when async UI state depends on them
- avoid brittle assertions that assume the page is already fresh after a background action
If a helper or assertion is only valid in one execution mode, make that distinction explicit instead of silently sharing it everywhere.
Troubleshooting
page.goto('/...')fails in CLI or supplier execution: Reuseruntime.playwrightProject.configor provideruntime.baseURL. Direct Playwright tests may hide this because the test runner already owns config resolution.- Supplier works in tests but flakes in stock generation: Inspect uniqueness defaults, retries, and any shared assertions that still rely on Playwright's short defaults.
- Event-log or delivery-state assertion looks stale:
Re-check whether the original working test performed an extra
goto()or refresh that the extracted flow dropped. - Supplier cannot connect to Sixpack:
Double-check that you are using the generator URL (
gen...), not the API URL (api...), and verify cert/key paths from the supplier process working directory.
Scope And Limits
- This package assumes the flow already exists. That flow may be a simple exported function or a wrapped
definePlaywrightFlow(...)flow. Flow extraction, discovery, and CLI usage belong to@sixpack-dev/playwright-flows. - Playwright config and project reuse depends on Playwright internal config-loading APIs, so it is more version-sensitive than the basic flow runtime.
- If the selected Playwright project uses
connectOptionsto connect to a remote browser farm, this is not supported yet.
Documentation
For flow authoring and runtime behavior, see @sixpack-dev/playwright-flows.
If you are starting from an existing Playwright test repository and want to gradually refactor some tests into reusable flows, read the README in playwright-flows first. That README covers the migration path, including simple flows, wrapped flows, fixture reuse, and guidance for stateful existing suites.
For broader Sixpack documentation, see:
