Skip to content

testing

@evolve-storefront/testing · v1.0.0

The end-to-end parts a storefront suite is built from: the operation runner, the gateway transport, region locators and two matchers. The suite itself lives in the site it tests, in frontend/<site>/testing/, and evolve add e2e writes its wiring.

Get started

  1. Install the package with the peer dependencies it needs. It comes from the Evolve registry, which needs a one-time sign-in.

    pnpm add @evolve-storefront/testing @playwright/test
    Requirements2
    node>=24.8
    @playwright/test>=1.56 <2

Then render it on a page:

import { type ApiFixtures, type ApiOptions, apiFixture } from "@evolve-storefront/testing/fixtures";
import { auth, cart, customers } from "@evolve-storefront/testing/ops";
import { test as base, expect } from "@playwright/test";
export const test = base.extend<ApiOptions & ApiFixtures>({ ...apiFixture });
test("a signed-in shopper sees their cart", async ({ api, page }) => {
// Each op is rolled back after the test, in reverse order.
const customer = await api.run(customers.create());
await api.run(auth.signIn(customer));
await api.run(cart.addSkus(["ABC-123"]));
await page.goto("/cart");
await expect(page.getByText("ABC-123")).toBeVisible();
});

Functions

createApiCreate the per-test op runner.
import { createApi } from "@evolve-storefront/testing/ops";

Teardown is last-in-first-out, sequential, and best effort. LIFO because the ops were created in dependency order, so the cart goes before the customer that owns it. Sequential because they share one cookie jar: two customers rolled back at once means two logins racing for one session. Best effort because one failed rollback must not strand the rest: every one runs, and the failures come back for the fixture to report, which by default fails the test.

function createApi(ctx: OpContext): ApiRunner
OptionTypeDescription
requestAPIRequestContext

The browser context's own request helper, so API setup and the page are one session.

More

The session is a cookie jar, so "state is created over the API" only means anything if the jar is the browser's. A standalone playwright.request context shares nothing with the page: signing in over the API then navigating lands on a guest page, and the failure surfaces several steps later as a missing element rather than at the seam.

newJar() => Promise<APIRequestContext>

A throwaway jar, for a call that must neither act as the signed-in customer nor sign the shared session in. Fresh each time — see withIsolatedJar.

targetGatewayTarget
sessionOpSession
customerEmailDomain?string

The domain customers.create puts a new customer's address under. Absent, example.test; a backend that refuses a reserved top-level domain needs one under a real one, such as example.com.

testSupport?TestSupportClient

The test-support service, where the environment has one.

More

Absent elsewhere, production included. A cleanup rollback then warns and skips, and the scheduled sweep removes what it left; a simulate op throws.

Returns ApiRunner

createTestSupportClientCreates the client for the test-support service at url.
import { createTestSupportClient } from "@evolve-storefront/testing/ops";
function createTestSupportClient(
options: TestSupportClientOptions,
): TestSupportClient
OptionTypeDescription
urlstring

The service's base URL; a path prefix is kept.

secretstring | undefined

Sent as a bearer token. Unset, every call is refused with a 401.

timeoutMs?number

How long one call may take, in milliseconds. Well below a test's own timeout, so a hung call fails its rollback and teardown moves on to the next one.

fetch?((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>)

Returns TestSupportClient

gqlRequestSend a setup operation as the current session.
import { gqlRequest } from "@evolve-storefront/testing/transport";

The session is the context's cookie jar, not a token: no mutation in the schema hands one back, the gateway sets cookies, and this context keeps them. That is what makes the calls after auth.signIn act as that customer — and, because the fixture hands over the browser context's jar, what makes the page see it too.

function gqlRequest<T>(
ctx: { request: APIRequestContext; target: GatewayTarget; },
document: string,
variables?: Record<string, unknown>,
): Promise<T>
OptionTypeDescription
requestAPIRequestContext
targetGatewayTarget

Returns Promise<T>

gqlRequestIsolatedSend one operation as nobody.
import { gqlRequestIsolated } from "@evolve-storefront/testing/transport";

For a call that must not act as the signed-in customer, and must not sign the shared session in either — creating a customer does both if left on the shared jar.

function gqlRequestIsolated<T>(
ctx: { newJar: () => Promise<APIRequestContext>; target: GatewayTarget; },
document: string,
variables?: Record<string, unknown>,
): Promise<T>
OptionTypeDescription
newJar() => Promise<APIRequestContext>
targetGatewayTarget

Returns Promise<T>

gqlRequestOnSend on a jar you already hold.
import { gqlRequestOn } from "@evolve-storefront/testing/transport";
function gqlRequestOn<T>(
jar: APIRequestContext,
target: GatewayTarget,
document: string,
variables?: Record<string, unknown>,
): Promise<T>
OptionTypeDescription
endpointstring

The gateway's GraphQL endpoint, absolute.

bypassSecret?string

Value for BYPASS_HEADER. An op writes its own queries, so they are unregistered by definition and a gateway with persisted documents enabled refuses them without this.

headers?Record<string, string>

Store context and anything else every setup call carries.

Returns Promise<T>

operationNameOfThe operation name a GraphQL request carries, or null when it is not one.
import { operationNameOf } from "@evolve-storefront/testing/transport";

Evolve storefronts put the name in the query string, not the body. Every feature package fetches through @labdigital/graphql-fetcher, which sends ?op=<name> on both of its paths — a persisted query as a GET, and the POST fallback appending the same parameter — while createRequestBody sends only documentId, variables and extensions. Reading the body first therefore matched nothing an Evolve storefront sends, and with trusted documents enabled a query is not even a POST.

The body is still read as a fallback, for a client that posts a plain GraphQL envelope.

Restricted to fetch/xhr so a navigation to a URL that happens to carry op is not mistaken for an operation.

function operationNameOf(request: Request): string | null

Returns string | null

recorderForThe recorder attached to a page, if recordGraphQLOperations ever ran on it.
import { recorderFor } from "@evolve-storefront/testing/transport";
function recorderFor(page: Page): OperationRecorder | undefined

Returns OperationRecorder | undefined

recordGraphQLOperationsRecord every GraphQL operation the page sends, for toHaveReceived.
import { recordGraphQLOperations } from "@evolve-storefront/testing/transport";

Opt-in rather than always on: it attaches a request listener for the life of the page, and a matcher that silently records nothing would report every operation as absent — a passing negative assertion that proves nothing.

function recordGraphQLOperations(page: Page): OperationRecorder

Returns OperationRecorder

waitForGraphQLOperationRun action and resolve once the response to operationName has arrived.
import { waitForGraphQLOperation } from "@evolve-storefront/testing/transport";

Wait on the operation, never on the network. networkidle is a guess that turns a suite into a coin flip under load, and a fixed timeout is the same guess with a worse failure mode. A named operation is a real signal.

The listener is attached before the action so a fast answer cannot arrive first.

function waitForGraphQLOperation(
page: Page,
operationName: string,
action: () => unknown,
options?: { timeout?: number | undefined; },
): Promise<Response>
OptionTypeDescription
timeout?number

Returns Promise<Response>

withIsolatedJarRun a sequence on a jar of its own, then throw the jar away.
import { withIsolatedJar } from "@evolve-storefront/testing/transport";

A fresh jar per call, not one reused for the test. Several of these calls establish a session — customerCreate is a sign-up, not an admin create — so a long-lived "isolated" context ends up authenticated and the next call on it is refused with ALREADY_AUTHENTICATED. That would break creating a second customer, and it would break the rollback that has to sign in as the one it is deleting.

A sequence rather than a single request, because signing in and then acting as that customer has to happen on the same jar.

function withIsolatedJar<T>(
ctx: { newJar: () => Promise<APIRequestContext>; target: GatewayTarget; },
run: (jar: APIRequestContext) => Promise<T>,
): Promise<T>
OptionTypeDescription
newJar() => Promise<APIRequestContext>
targetGatewayTarget

Returns Promise<T>

cartRegionThe cart page's own region.
import { cartRegion } from "@evolve-storefront/testing/locators";
function cartRegion(page: Page): Locator

Returns Locator

cartSummaryThe cart's totals, where a shipping or discount row appears.
import { cartSummary } from "@evolve-storefront/testing/locators";
function cartSummary(page: Page): Locator

Returns Locator

checkoutRegionThe checkout page.
import { checkoutRegion } from "@evolve-storefront/testing/locators";
function checkoutRegion(page: Page): Locator

Returns Locator

checkoutSectionOne checkout step, by the id the project gave its section.
import { checkoutSection } from "@evolve-storefront/testing/locators";

data-section carries that id, so a spec names the step it means rather than counting cards — which changes the moment a project adds one.

function checkoutSection(page: Page, id: string): Locator

Returns Locator

orderConfirmationThe order confirmation page, after checkout completes.
import { orderConfirmation } from "@evolve-storefront/testing/locators";
function orderConfirmation(page: Page): Locator

Returns Locator

orderDetailA single order's detail page.
import { orderDetail } from "@evolve-storefront/testing/locators";
function orderDetail(page: Page): Locator

Returns Locator

orderHistoryThe order list in the account area.
import { orderHistory } from "@evolve-storefront/testing/locators";
function orderHistory(page: Page): Locator

Returns Locator

productListA product rail or grid rendered from CMS content.
import { productList } from "@evolve-storefront/testing/locators";
function productList(page: Page): Locator

Returns Locator

quoteDetailA single quote's detail page.
import { quoteDetail } from "@evolve-storefront/testing/locators";
function quoteDetail(page: Page): Locator

Returns Locator

quoteListThe quote list in the account area.
import { quoteList } from "@evolve-storefront/testing/locators";
function quoteList(page: Page): Locator

Returns Locator

regionRegion scopes for the areas the packages render.
import { region } from "@evolve-storefront/testing/locators";

A role locator comes first; these answer only which region it sits in. That is the same container-plus-role shape the styling model uses, and for the same reason: a locator per control would put every spec on package internals, so a button losing a wrapper breaks a dozen suites.

They key on data-component, which is the one attribute a package can offer a test honestly — it is a published, invariant contract with its own test, not an implementation detail that happens to be stable today.

These live in the package rather than the scaffold because they track the packages' own markup: copied into a project, a renamed region leaves a stale file that nothing fails on.

function region(page: Page, name: string): Locator

Returns Locator

registerMatchersAccessibility matchers and the operation matcher.
import { registerMatchers } from "@evolve-storefront/testing/matchers";

Register once from the project's fixture file:

import { registerMatchers } from "@evolve-storefront/testing/matchers";
registerMatchers();

A failure here is fixed, not excluded. A locator that falls back to a CSS selector because a control has no accessible name is a test documenting a defect instead of reporting it, and exclude exists for other people's iframes.

function registerMatchers(): void

Returns void

mailpitMailpit as a mailbox adapter: a local sink, for a suite where no mail may leave the machine.
import { mailpit } from "@evolve-storefront/testing/fixtures";

It sits beside spamok() because the adapter is the seam that lets one spec run in two environments — a developer reads the sink, a pipeline reads a public inbox, and the spec knows neither. That only holds while both are the same interface.

Mailpit rather than MailHog, which has had no release since 2020. Mailpit is its stated successor, keeps the same ports, and filters by recipient server-side.

function mailpit(options?: MailpitOptions | undefined): MailboxAdapter
OptionTypeDescription
baseUrl?string

Where the sink listens. A project picks the port, unlike a hosted inbox.

prefix?string

Prefix on the generated local part, so a box is recognisable in the sink.

domain?string

Domain for generated addresses.

More

example.test is IANA-reserved and undeliverable by construction, so a suite pointed at a real transport by mistake still cannot reach a person.

limit?number

How many of the newest messages to read bodies for.

More

The search result carries a snippet rather than a body, so every message costs a second request. Bodies are cached by id, which makes a poll loop cost one request plus whatever arrived since the last one.

fetch?((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>)

Injected by the tests. A project passes nothing.

Returns MailboxAdapter

spamokSpamOK as a mailbox adapter: throwaway public inboxes, no account.
import { spamok } from "@evolve-storefront/testing/fixtures";

Offered, never a default. mailbox stays undefined unless a project writes mailbox: spamok(), for two reasons. A public inbox is readable by anyone who guesses the address, so nothing resembling real personal data may pass through one — a project should have to say it accepts that rather than inherit it. And a suite with no mail specs still needs no inbox at all.

It ships here although the package deliberately does not ship inbox providers, because the reason it gives — an account, a domain, a rate limit somebody agreed to — describes none of this one. It is two unauthenticated GETs against a fixed host, so the choice a project would be making is not a choice.

function spamok(options?: SpamokOptions | undefined): MailboxAdapter
OptionTypeDescription
prefix?string

Prefix on the generated local part, so a box is recognisable in the service.

limit?number

How many of the newest messages to read bodies for.

More

The box listing carries a preview rather than a body, so every message costs a second request. Bodies are cached by id, which makes a poll loop cost one request plus whatever arrived since the last one — but the first poll of a busy box still pays this many, so it is bounded.

baseUrl?string

Overridable for a self-hosted instance.

fetch?((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>)

Injected by the tests. A project passes nothing.

Returns MailboxAdapter

Namespaces

authsignIn
import { auth } from "@evolve-storefront/testing/ops";
MemberDescription
auth.signInSign in on the shared jar, so every op and page navigation after it is that customer.
cartaddSkus
import { cart } from "@evolve-storefront/testing/ops";
MemberDescription
cart.addSkusPut skus in the session's cart, creating one if there is none.
cleanupbusinessUnit, cart, customer, membership, order, ordersOf, payment, quoteRequest
import { cleanup } from "@evolve-storefront/testing/ops";
MemberDescription
cleanup.businessUnitA business unit simulate.createBusinessUnit made, with its orders, quote requests, carts and invoices.
cleanup.cartA cart: a test customer's, or an anonymous one, which carries no identity to check.
cleanup.customerA customer, by id: no sign-in, so it holds whatever a spec did to their password.
cleanup.membershipTake a customer out of a business unit, for an invite the UI accepts.
cleanup.orderAn order, and the payments it references.
cleanup.ordersOfRemove every order a customer placed, for a checkout the UI drives.
cleanup.paymentA payment, which is also what an invoice or a credit note is.
cleanup.quoteRequestA quote request, with the quotes that answer it.
customerscreate, draft
import { customers } from "@evolve-storefront/testing/ops";
MemberDescription
customers.createCreate a customer over the API, on a throwaway jar: customerCreate signs the new customer in, and on the shared jar the spec's own auth.signIn would then be refused with ALREADY_AUTHENTICATED.
customers.draftA draft nobody else will collide with, its address under domain.
ordersplace
import { orders } from "@evolve-storefront/testing/ops";
MemberDescription
orders.placePlace an order from a cart, as the signed-in customer.
quotesrequest
import { quotes } from "@evolve-storefront/testing/ops";
MemberDescription
quotes.requestRequest a quote for a cart, as the signed-in customer.
simulateapproveReview, cancelOrder, confirmOrder, createBusinessUnit, declineQuoteRequest, deliverOrder, payInvoice, payOrder, raiseCreditNote, raiseInvoice, rejectReview, renegotiateQuote, sendQuote, setShipmentState, withdrawQuote
import { simulate } from "@evolve-storefront/testing/ops";
MemberDescription
simulate.approveReviewA moderator approving a pending product review, so it is shown and counts.
simulate.cancelOrderA merchant canceling an order in the back office, rather than the buyer.
simulate.confirmOrderA merchant confirming an order in the back office.
simulate.createBusinessUnitA company set up in the back office, under draft.key or a key chosen here.
simulate.declineQuoteRequestA merchant declining a quote request without sending a quote.
simulate.deliverOrderDeliver the given lines, or everything still undelivered.
simulate.payInvoiceSettle centAmount of an invoice, or its outstanding balance.
simulate.payOrderThe order's payment provider confirming it paid, as a hosted payment page's return would.
simulate.raiseCreditNoteCredit an invoice, in full unless centAmount says.
simulate.raiseInvoiceFinance raising an invoice.
simulate.rejectReviewA moderator rejecting a pending product review, so it stays hidden.
simulate.renegotiateQuoteA new offer replacing quote, which is superseded rather than deleted.
simulate.sendQuoteA merchant answering a quote request.
simulate.setShipmentStateA merchant moving an order's shipment to state.
simulate.withdrawQuoteA merchant withdrawing a quote they sent.

Classes

TestSupportTimeoutErrorA call that got no answer in time.
import { TestSupportTimeoutError } from "@evolve-storefront/testing/ops";

Extends Error

new TestSupportTimeoutError(message?: string)
GqlRequestErrorA gateway answer that is not usable data.
import { GqlRequestError } from "@evolve-storefront/testing/transport";

Setup fails loudly: a spec that continues past a failed customers.create asserts against state that does not exist and reports the failure several steps later.

Extends Error

new GqlRequestError(message: string, status: number, body: string)

Constants

BYPASS_HEADERThe header the gateway's require_id expression reads to waive the trusted-document requirement.
import { BYPASS_HEADER } from "@evolve-storefront/testing/transport";

Lowercase, and that is load-bearing. Hive Router lowercases incoming header names before the expression sees them, so a mixed-case key never matches — and a bypass that never matches fails exactly like a client with the wrong secret, which is an afternoon spent on the wrong hypothesis. transport.test.ts pins the casing.

const BYPASS_HEADER: "x-trusteddocument-bypasssecret"
apiFixtureThe api fixture: one op runner per test, torn down after it.
import { apiFixture } from "@evolve-storefront/testing/fixtures";

Compose it into the suite's own test rather than importing a ready-made one — which fixtures a suite has is the project's decision, and a package exporting one test would own it forever.

export const test = base.extend<ApiOptions & ApiFixtures>({ ...apiFixture });
const apiFixture: Fixtures<ApiFixtures & ApiOptions, object, PlaywrightTestArgs & PlaywrightTestOptions, PlaywrightWorkerArgs & PlaywrightWorkerOptions>
mailFixtureThe mail fixture.
import { mailFixture } from "@evolve-storefront/testing/fixtures";

Absent, it throws when a spec first touches mail rather than at startup: a suite with no mail specs should not have to configure an inbox to run.

const mailFixture: Fixtures<MailFixtures & MailOptions>
translationsFixtureThe t fixture: every visible string a spec asserts on.
import { translationsFixture } from "@evolve-storefront/testing/fixtures";

A literal in a spec is a second translation file that goes stale without failing. Reading the project's own built catalogue means a copy change breaks the spec that depends on it, which is the point.

The catalogue is read once per test and cached by path, so a suite of a hundred specs does not re-parse it a hundred times.

const translationsFixture: Fixtures<TranslationFixtures & TranslationOptions, object, PlaywrightTestArgs & PlaywrightTestOptions>

Types

ApiWhat a spec calls.
import { type Api } from "@evolve-storefront/testing/ops";
type Api = {
run<T>(op: Op<T>): Promise<T>;
enroll(step: RollbackOnly): void;
readonly session: OpSession;
};
ApiRunner
import { type ApiRunner } from "@evolve-storefront/testing/ops";
type ApiRunner = Api & {
teardown(): Promise<RollbackFailure[]>;
};
BusinessUnitDraft
import { type BusinessUnitDraft } from "@evolve-storefront/testing/ops";
type BusinessUnitDraft = Request<"business-unit/create">;
CreditNoteDraft
import { type CreditNoteDraft } from "@evolve-storefront/testing/ops";
type CreditNoteDraft = Omit<Request<"invoice/credit">, "invoiceId">;
DeliveredOrder
import { type DeliveredOrder } from "@evolve-storefront/testing/ops";
type DeliveredOrder = SimulatedOrder & {
remaining: Response<"order/deliver">["remaining"];
};
Delivery
import { type Delivery } from "@evolve-storefront/testing/ops";
type Delivery = Omit<Request<"order/deliver">, "order">;
InvoiceDraft
import { type InvoiceDraft } from "@evolve-storefront/testing/ops";
type InvoiceDraft = Request<"invoice/raise">;
Offer
import { type Offer } from "@evolve-storefront/testing/ops";
type Offer = Omit<Request<"quote/send">, "quoteRequestId">;
OpA setup action paired with its undo.
import { type Op } from "@evolve-storefront/testing/ops";

One declaration rather than a method on a shared API object, so an op is deletable on its own — the failure mode of these suites is a thousand-line helper class every spec depends on and nobody can change.

type Op<T> = {
forward: (ctx: OpContext) => Promise<T>;
rollback?: (ctx: OpContext, result: T) => Promise<void>;
rollbackFirst?: (ctx: OpContext) => Promise<void>;
};
OpContextEverything an op's forward and rollback are given.
import { type OpContext } from "@evolve-storefront/testing/ops";
type OpContext = {
request: APIRequestContext;
newJar: () => Promise<APIRequestContext>;
target: GatewayTarget;
session: OpSession;
customerEmailDomain?: string;
testSupport?: TestSupportClient;
};
OpSessionWhat an op carries to the ops after it.
import { type OpSession } from "@evolve-storefront/testing/ops";

The signed-in session is not in here — it is the cookie jar. No mutation in the schema hands back a token; the gateway sets cookies, so ctx.request is what makes the calls after auth.signIn act as that customer. What is left is the handful of values a later op cannot be handed by the spec.

type OpSession = {
cart?: {
id: string;
version: string | null;
};
};
PlacedOrder
import { type PlacedOrder } from "@evolve-storefront/testing/ops";
type PlacedOrder = {
id: string;
orderNumber: string;
};
RequestedQuote
import { type RequestedQuote } from "@evolve-storefront/testing/ops";
type RequestedQuote = {
id: string;
};
ReviewReferenceA product review by its id, or by its key where the spec gave it one.
import { type ReviewReference } from "@evolve-storefront/testing/ops";
type ReviewReference = {
id: string;
} | {
key: string;
};
RollbackFailureA rollback that threw.
import { type RollbackFailure } from "@evolve-storefront/testing/ops";
type RollbackFailure = {
error: unknown;
};
RollbackOnlyAn undo with no forward, for something the UI is about to create.
import { type RollbackOnly } from "@evolve-storefront/testing/ops";
type RollbackOnly = {
rollback: (ctx: OpContext) => Promise<void>;
};
ShipmentState
import { type ShipmentState } from "@evolve-storefront/testing/ops";
type ShipmentState = Request<"order/shipment-state">["state"];
SimulatedBusinessUnit
import { type SimulatedBusinessUnit } from "@evolve-storefront/testing/ops";
type SimulatedBusinessUnit = {
id: string;
key: string;
contactEmail: string;
};
SimulatedInvoice
import { type SimulatedInvoice } from "@evolve-storefront/testing/ops";
type SimulatedInvoice = {
id: string;
invoiceNumber: string;
};
SimulatedOrder
import { type SimulatedOrder } from "@evolve-storefront/testing/ops";
type SimulatedOrder = {
id: string;
};
SimulatedQuote
import { type SimulatedQuote } from "@evolve-storefront/testing/ops";
type SimulatedQuote = {
id: string;
};
SimulatedReview
import { type SimulatedReview } from "@evolve-storefront/testing/ops";
type SimulatedReview = {
id: string;
};
TestSupportClientThe test-support service, one POST per action, typed from its published contract.
import { type TestSupportClient } from "@evolve-storefront/testing/ops";

A response that does not match the contract throws rather than being trusted: a service on another contract version otherwise fails later, in whatever read the missing field.

type TestSupportClient = {
simulate<A extends Action<"simulate">>(action: A, request: TestSupportRequest<"simulate", A>): Promise<TestSupportResponse<"simulate", A>>;
cleanup<A extends Action<"cleanup">>(action: A, request: TestSupportRequest<"cleanup", A>): Promise<TestSupportResponse<"cleanup", A>>;
};
GatewayTargetWhat a request needs to reach the gateway.
import { type GatewayTarget } from "@evolve-storefront/testing/transport";
type GatewayTarget = {
endpoint: string;
bypassSecret?: string;
headers?: Record<string, string>;
};
GqlDocumentA document an op sends.
import { type GqlDocument } from "@evolve-storefront/testing/transport";
type GqlDocument = string;
OperationRecorderOperation names a page has been asked for, in arrival order.
import { type OperationRecorder } from "@evolve-storefront/testing/transport";
type OperationRecorder = {
names(): string[];
received(operationName: string): boolean;
};
ApiFixtures
import { type ApiFixtures } from "@evolve-storefront/testing/fixtures";
type ApiFixtures = {
api: ApiRunner;
backofficeGate: void;
};
InboxMessageOne message an inbox handed back.
import { type InboxMessage } from "@evolve-storefront/testing/fixtures";
type InboxMessage = {
subject: string;
body: string;
receivedAt: Date;
};
Mail
import { type Mail } from "@evolve-storefront/testing/fixtures";
type Mail = {
address: string;
waitFor(predicate: (message: InboxMessage) => boolean, options?: {
timeout?: number;
interval?: number;
}): Promise<InboxMessage>;
};
MailboxAdapterA throwaway inbox, whichever service a project uses.
import { type MailboxAdapter } from "@evolve-storefront/testing/fixtures";

Evolve ships the polling, not the provider. Which inbox a suite talks to is an environment choice — it needs an account, a domain and a rate limit somebody agreed to — so it belongs with the config the project owns. What is mechanism is the waiting: a fixed sleep makes the wait intermittent instead of correct, and every suite writes that loop wrong the same way.

Nothing resembling real personal data goes through a public inbox; they are readable by anyone.

type MailboxAdapter = {
address(): Promise<string> | string;
messages(address: string): Promise<InboxMessage[]>;
};
MailFixtures
import { type MailFixtures } from "@evolve-storefront/testing/fixtures";
type MailFixtures = {
mail: Mail;
};
RollbackFailures
import { type RollbackFailures } from "@evolve-storefront/testing/fixtures";
type RollbackFailures = "fail" | "warn";
TestSupport
import { type TestSupport } from "@evolve-storefront/testing/fixtures";
type TestSupport = {
url: string;
secret: string | undefined;
timeoutMs?: number;
};
TranslateLook up one already-built message by its dotted key.
import { type Translate } from "@evolve-storefront/testing/fixtures";
type Translate = (key: string) => string;
TranslationFixtures
import { type TranslationFixtures } from "@evolve-storefront/testing/fixtures";
type TranslationFixtures = {
t: Translate;
};