Skip to main content

Convert an existing Playwright repo

Use this path when you already have reusable Playwright flows and want to expose them as Sixpack generators without rewriting the business journey.

The recommended migration path is:

  1. Extract or clean up a reusable Playwright flow with @sixpack-dev/playwright-flows
  2. Adapt that flow into a Sixpack generator with @sixpack-dev/playwright-sixpack-adapter
  3. Register the generator through @sixpack-dev/sdk
  4. Add templates only after the generator contract is stable

Treat the same flow as serving three execution modes from the start:

  • direct Playwright test calls
  • standalone sixpack-playwright generate or executePlaywrightFlow(...)
  • live Sixpack supplier execution

That distinction matters because many migration bugs appear only in the third mode. A flow that passes tests can still be a poor generator because of uniqueness assumptions, default 5s assertions, or stale-page assumptions after async UI changes.

First-run checklist

Before you call the integration complete, verify these items:

  1. Reuse the real Playwright config and project whenever the flow depends on baseURL, storageState, headers, or project-specific setup.
  2. Keep flow and generator contracts flat.
  3. Put shared omitted-field behavior in flow-level deriveFlowInput(...).
  4. Make default identity-like fields safe under retries and repeated stock generation.
  5. Validate each important flow in all three execution modes, not just in Playwright Test.
  6. Preserve refresh or second-navigation steps from the original working test when async UI state depends on them.
  7. Centralize assertion helpers and raise timeouts intentionally when they must survive supplier load.

Start from a reusable flow

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 the flow into a generator

import { adaptPlaywrightFlow } from '@sixpack-dev/playwright-sixpack-adapter'
import { s } from '@sixpack-dev/sdk/item'

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

export const createUserGenerator = adaptPlaywrightFlow({
name: 'create-user',
flow: createUser,
inputSchema: {
email: s.string(),
},
outputSchema: {
userId: s.string(),
},
reportIssueUrl: 'https://example.com/support',
runtime: {
playwrightProject: {
config: 'playwright.config.ts',
},
},
})

Keep both contracts flat:

  • flow and generator input should resolve to one flat user-facing object
  • generator output should also be flat
  • outputKind defaults to FLAT; use LIST or LIST_OF_LISTS only when you need collection output

Avoid wrapper shapes such as { account: { ... } } or { result: { ... } } unless the nesting is part of the real consumer contract.

Validate adapter input contract drift

The adapter does not derive Sixpack schemas from the flow automatically. Instead, validate that the generator inputSchema still matches the flow's consumer-facing input contract:

npx sixpack-playwright-adapter validate ./sixpack/generators.ts

This validation compares the discovered flow input contract against the adapted generator inputSchema and fails on:

  • missing fields
  • extra fields
  • type mismatches
  • required versus optional drift

Use --json for machine-readable output and --path <dir-or-file> when the relevant flow sources live outside the default discovery roots.

Discovery constraints for raw exported flows

Raw flow discovery is source-shape based. The most reliable raw flow signature is direct fixture destructuring in the first parameter:

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

Also supported when the first parameter is explicitly typed:

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 }
}

A weaker pattern is an untyped context parameter with only internal destructuring:

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

If discovery becomes ambiguous, prefer:

  • direct fixture destructuring
  • or definePlaywrightFlow(...)

Use sixpack-playwright list and sixpack-playwright validate early. When no flows are found, the CLI now prints a hint pointing at these constraints.

Relative URLs need runtime config

If the flow uses relative URLs such as page.goto('/user'), standalone execution needs Playwright runtime URL context.

Provide one of these:

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

This matters for:

  • executePlaywrightFlow(...)
  • sixpack-playwright generate
  • Sixpack generator execution through the Playwright adapter

Explicit config still wins over autodiscovery. In multi-project Playwright repos, set the project explicitly when the discovered config would otherwise be ambiguous.

Wrapped flows: typing mental model

When simple { page } flows are not enough, use definePlaywrightFlow(...).

Think about the types this way:

  • base context: the minimum context a direct caller passes in
  • provided: optional caller-owned fixtures or page objects to reuse
  • deriveFlowInput(...): optional consumer-input to resolved-input transformation before setup(...) and run(...)
  • setup(...): resolves fallback helpers for standalone execution
  • run(...): executes the business journey against the resolved context
  • extraFlowContext: runtime-only helper injection for standalone execution, not direct test calls

This is usually enough to model:

  • existing Playwright fixtures
  • shared page objects
  • multi-role journeys
  • runtime-created helper objects for standalone execution

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 many flows can rely on inference for the last two.

One important detail: TResolvedContext is the full context run(...) receives after setup(...), not only the delta returned from setup(...).

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

Example using all five type parameters with both deriveFlowInput(...) and setup(...):

import { randomUUID } from 'node:crypto'

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

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

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

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

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

type CustomerFlowContext = PlaywrightFlowContextWithProvided<{
signupPage: SignupPage
}>

type CustomerResolvedContext = CustomerFlowContext & {
signupPage: SignupPage
}

export const createCustomer = definePlaywrightFlow<
CreateCustomerInput,
{ username: string; password: string; address: string; userId: string },
CustomerFlowContext,
CustomerResolvedContext,
ResolvedCustomerInput
>({
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 setup({ page, provided }: PlaywrightSetupContext<CustomerFlowContext>) {
return {
signupPage: provided.signupPage ?? new SignupPage(page),
}
},
async run({ signupPage }, input) {
const userId = await signupPage.createUser(
input.username,
input.password,
input.address
)
return {
username: input.username,
password: input.password,
address: input.address,
userId,
}
},
})

Unique stocked entities

Static template values clash quickly for unique business entities such as usernames or emails.

Prefer this pattern:

  1. Make unique fields optional in generator input
  2. Use templates that omit those optional fields
  3. Generate unique defaults at runtime through flow-level deriveFlowInput(...)
  4. Return the generated values in flat output

Also describe that behavior in the Sixpack 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

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

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 customerStockTemplates: 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()) ?? '',
}
},
})

export const createApprovedCustomerGenerator = 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(),
},
outputKind: 'FLAT',
templates: customerStockTemplates,
runtime: {
playwrightProject: {
config: 'playwright.config.ts',
},
},
})

This keeps stock valid across repeated runs and gives callers the generated credentials back directly across CLI, direct runtime, and Sixpack.

The uniqueness policy is still local to your repo. Put it in the flow when every execution path should share it. Use adapter-level deriveGeneratorInput(...) only when the transformation is Sixpack-specific.

Make stock output self-contained. If downstream flows or tests need credentials, IDs, or other fields to act on the generated entity, include them in the generator output. A stocked customer without its password, for example, is not reusable input for a later order 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.

Flat generator outputs for existing nested test flows

Many teams start from test-oriented flows that return nested results such as:

export const createAccount = async ({ page }, input: { email: string }) => {
// ...
return {
account: {
email: input.email,
id: 'acc-1',
},
}
}

That can stay valid for tests, but generator consumers usually work better with flat outputs.

Use this rule:

  • refactor the original flow when the nested wrapper has no real consumer value
  • add a thin wrapper when tests genuinely benefit from the nested result but generator consumers should receive a flat contract
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,
}
}

This keeps generator contracts flat without forcing every test call site to change immediately.

Stock-safe flow design rules

When a flow is expected to run in supplier stock generation, apply these rules early:

  • unique defaults should be retry-safe, not just dataset-safe
  • shared assertions should avoid relying on Playwright's default 5s timeout
  • event-log or state-transition assertions should not assume the current page is already fresh
  • navigation or refresh steps from the original test should be preserved unless you can prove they were redundant
  • outputs should stay flat so downstream consumers can understand them immediately

Treat flow defaults as production-like behavior, not test convenience. A default username or email that is only unique for one happy-path test run will fail quickly under retries or repeated standalone runs.

Supplier config from a local module

If your repo keeps configuration in .env.local or a local module, wire it through fluent setters so the supplier uses the effective values you expect.

import { Supplier } from '@sixpack-dev/sdk'

import { generators } from './generators'
import { localSixpackConfig } from './local-config'

export function createSupplier(): Supplier {
return new Supplier({
name: localSixpackConfig.supplierName,
description: 'Playwright-backed generators extracted from reusable UI flows.',
reportIssueUrl: 'https://example.com/support',
})
.withSixpackUrl(localSixpackConfig.sixpackUrl)
.withOrganization(localSixpackConfig.organization)
.withAuthToken(localSixpackConfig.authToken)
.withEnvironment(localSixpackConfig.environment)
.withRunMode(localSixpackConfig.runMode)
.withClientCertificatePath(localSixpackConfig.clientCertificatePath)
.withClientKeyPath(localSixpackConfig.clientKeyPath)
.withGenerators(...generators)
}

For early feedback before a real bootstrap, call:

const supplier = createSupplier()
supplier.validate()
await supplier.bootstrap()

Supplier quickstart checklist:

  • supplier name
  • supplier reportIssueUrl or reportIssueEmail
  • generator metadata.name
  • generator inputSchema
  • generator outputSchema
  • generator outputKind
  • Sixpack URL, account, environment, auth token, and cert/key paths before bootstrap()

.env-first local starter shape

For local supplier projects, prefer a small .env-backed config module instead of manual shell exports.

import 'dotenv/config'

function required(name: string): string {
const value = process.env[name]
if (!value) {
throw new Error(`Missing required environment variable ${name}`)
}
return value
}

Recommended file layout:

  • sixpack/.env
  • sixpack/config.ts
  • sixpack/supplier.ts
  • package.json scripts for sixpack:validate and sixpack:start

This is the simplest repeatable local setup until we provide a more opinionated public starter repository.

Dual-use maintenance guidance

For long-lived flows reused by both tests and generators:

  • keep one source of business steps
  • centralize assertion helpers and timeouts
  • run sixpack-playwright inspect --strict in CI
  • run sixpack-playwright-adapter validate ./sixpack/generators.ts in CI when Playwright flows are adapted into generators
  • run supplier.validate() in CI
  • treat flow types and adapter schemas as a contract pair that must evolve together

Today there is no built-in schema derivation or doctor command for the whole toolchain, so these checks are the minimum guardrails against contract drift.

Troubleshooting

  • Relative page.goto('/...') works in tests but fails in CLI or supplier execution: Reuse runtime.playwrightProject.config or provide runtime.baseURL. Direct Playwright tests already have config context, so they can hide this setup gap.
  • Supplier connection fails immediately: Verify that you are using the generator URL such as gen.sixpack.dev:443, not the API URL, and verify client cert/key paths from the supplier process working directory.
  • Stock generation flakes on duplicate usernames or emails: Move uniqueness policy into flow-level deriveFlowInput(...) and make it safe for retries and repeated runs, not just one dataset.
  • Shared assertions pass in tests and fail under supplier execution: Revisit default timeouts and page refresh assumptions before only increasing waits.
  • Event log or delivery-state assertions read stale UI: Compare the extracted flow against the original working test sequence step-for-step. A dropped second goto() or refresh is a common cause.