testing
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
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/testRequirements2
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| Option | Type | Description |
|---|---|---|
request | APIRequestContext | The browser context's own request helper, so API setup and the page are one session. MoreThe session is a cookie jar, so "state is created over the API" only means
anything if the jar is the browser's. A standalone |
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 |
target | GatewayTarget | |
session | OpSession | |
customerEmailDomain? | string | The domain |
testSupport? | TestSupportClient | The test-support service, where the environment has one. MoreAbsent elsewhere, production included. A cleanup rollback then warns and skips,
and the scheduled sweep removes what it left; a |
Returns ApiRunner
createTestSupportClientCreates the client for the test-support service at url.
import { createTestSupportClient } from "@evolve-storefront/testing/ops";function createTestSupportClient( options: TestSupportClientOptions,): TestSupportClient| Option | Type | Description |
|---|---|---|
url | string | The service's base URL; a path prefix is kept. |
secret | string | 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>| Option | Type | Description |
|---|---|---|
request | APIRequestContext | |
target | GatewayTarget |
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>| Option | Type | Description |
|---|---|---|
newJar | () => Promise<APIRequestContext> | |
target | GatewayTarget |
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>| Option | Type | Description |
|---|---|---|
endpoint | string | The gateway's GraphQL endpoint, absolute. |
bypassSecret? | string | Value for |
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 | nullReturns 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 | undefinedReturns 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): OperationRecorderReturns 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>| Option | Type | Description |
|---|---|---|
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>| Option | Type | Description |
|---|---|---|
newJar | () => Promise<APIRequestContext> | |
target | GatewayTarget |
Returns Promise<T>
cartRegionThe cart page's own region.
import { cartRegion } from "@evolve-storefront/testing/locators";function cartRegion(page: Page): LocatorReturns Locator
cartSummaryThe cart's totals, where a shipping or discount row appears.
import { cartSummary } from "@evolve-storefront/testing/locators";function cartSummary(page: Page): LocatorReturns Locator
checkoutRegionThe checkout page.
import { checkoutRegion } from "@evolve-storefront/testing/locators";function checkoutRegion(page: Page): LocatorReturns 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): LocatorReturns Locator
orderConfirmationThe order confirmation page, after checkout completes.
import { orderConfirmation } from "@evolve-storefront/testing/locators";function orderConfirmation(page: Page): LocatorReturns Locator
orderDetailA single order's detail page.
import { orderDetail } from "@evolve-storefront/testing/locators";function orderDetail(page: Page): LocatorReturns Locator
orderHistoryThe order list in the account area.
import { orderHistory } from "@evolve-storefront/testing/locators";function orderHistory(page: Page): LocatorReturns Locator
productListA product rail or grid rendered from CMS content.
import { productList } from "@evolve-storefront/testing/locators";function productList(page: Page): LocatorReturns Locator
quoteDetailA single quote's detail page.
import { quoteDetail } from "@evolve-storefront/testing/locators";function quoteDetail(page: Page): LocatorReturns Locator
quoteListThe quote list in the account area.
import { quoteList } from "@evolve-storefront/testing/locators";function quoteList(page: Page): LocatorReturns 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): LocatorReturns 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(): voidReturns 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| Option | Type | Description |
|---|---|---|
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
|
limit? | number | How many of the newest messages to read bodies for. MoreThe 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| Option | Type | Description |
|---|---|---|
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. MoreThe 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";| Member | Description |
|---|---|
auth.signIn | Sign in on the shared jar, so every op and page navigation after it is that customer. |
cartaddSkus
import { cart } from "@evolve-storefront/testing/ops";| Member | Description |
|---|---|
cart.addSkus | Put 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";| Member | Description |
|---|---|
cleanup.businessUnit | A business unit simulate.createBusinessUnit made, with its orders, quote requests, carts and invoices. |
cleanup.cart | A cart: a test customer's, or an anonymous one, which carries no identity to check. |
cleanup.customer | A customer, by id: no sign-in, so it holds whatever a spec did to their password. |
cleanup.membership | Take a customer out of a business unit, for an invite the UI accepts. |
cleanup.order | An order, and the payments it references. |
cleanup.ordersOf | Remove every order a customer placed, for a checkout the UI drives. |
cleanup.payment | A payment, which is also what an invoice or a credit note is. |
cleanup.quoteRequest | A quote request, with the quotes that answer it. |
customerscreate, draft
import { customers } from "@evolve-storefront/testing/ops";| Member | Description |
|---|---|
customers.create | Create 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.draft | A draft nobody else will collide with, its address under domain. |
ordersplace
import { orders } from "@evolve-storefront/testing/ops";| Member | Description |
|---|---|
orders.place | Place an order from a cart, as the signed-in customer. |
quotesrequest
import { quotes } from "@evolve-storefront/testing/ops";| Member | Description |
|---|---|
quotes.request | Request 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";| Member | Description |
|---|---|
simulate.approveReview | A moderator approving a pending product review, so it is shown and counts. |
simulate.cancelOrder | A merchant canceling an order in the back office, rather than the buyer. |
simulate.confirmOrder | A merchant confirming an order in the back office. |
simulate.createBusinessUnit | A company set up in the back office, under draft.key or a key chosen here. |
simulate.declineQuoteRequest | A merchant declining a quote request without sending a quote. |
simulate.deliverOrder | Deliver the given lines, or everything still undelivered. |
simulate.payInvoice | Settle centAmount of an invoice, or its outstanding balance. |
simulate.payOrder | The order's payment provider confirming it paid, as a hosted payment page's return would. |
simulate.raiseCreditNote | Credit an invoice, in full unless centAmount says. |
simulate.raiseInvoice | Finance raising an invoice. |
simulate.rejectReview | A moderator rejecting a pending product review, so it stays hidden. |
simulate.renegotiateQuote | A new offer replacing quote, which is superseded rather than deleted. |
simulate.sendQuote | A merchant answering a quote request. |
simulate.setShipmentState | A merchant moving an order's shipment to state. |
simulate.withdrawQuote | A 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;};
