API reference
End-to-end testing guide
A standalone Playwright project (TypeScript). Every code sample on this page is a file from it.
Set up
- As the workspace owner, create a key in Settings > API keys with the E2E testing preset (see scopes). One key per CI job.
- For browser tests, create synthetic accounts in Team > Synthetic accounts, one per parallel worker, and note each one's password and authenticator (TOTP) secret. Give each account the Full accessrole (the dialog's default) or Disputes: the browser specs accept disputes, which needs
dispute.write. A View only or Reports account is refused with403 missing_permission, so the fixture checks each account's permissions right after it signs in and stops with the account's name and role. - Unzip the example. It extracts into an
e2e-playwright-example/folder; run the rest from inside it:
unzip e2e-playwright-example.zip
cd e2e-playwright-example
npm install
npx playwright install chromium # browser specs only
DM_API_KEY=pctk_live_… npm run test:api
DM_API_KEY=pctk_live_… DM_SYNTHETIC_ACCOUNTS='[{"identifier":"…","password":"…","totp_secret":"…"}]' npm test| Variable | What |
|---|---|
DM_API_KEY | The key. Keep it in your CI secret store. |
DM_SYNTHETIC_ACCOUNTS | Browser specs: a JSON list of synthetic accounts with Full access or Disputes, one per worker. |
DM_RUN_ID | Optional prefix of every correlation id in the run, e.g. your CI job id. Find the run later with correlation_id=<RUN_ID>:*. |
DM_WORKERS | Optional. Default 4, at most 8 per key. |
DM_BASE_URL, DM_PORTAL_URL | Optional. Default the production API and portal. |
The fixture contract
All of it lives in one fixture, src/dm.ts. Import test and expect from it instead of @playwright/test.
| Member | Does |
|---|---|
dm.correlationId | ${RUN_ID}:${testInfo.testId}, checked against the API's pattern before use, so a typo fails at once instead of timing out. |
dm.api | An API client that sends the key and X-Correlation-Id on every request, and retries 429, 503 and 409 generate_in_progress after Retry-After. |
mark() | The workspace's current high-water seq (GET /v1/audit-events/cursor). |
waitFor(filter) | One /wait call (repeated only to cover a timeout over 25 s); returns the matching events, or throws listing the ones it did see. |
expectSequence(filter, expected, {mode, timeout}) | Waits repeatedly, moving after_seq to high_water_seq, until expected matches; otherwise throws a diff of everything seen. subsequence (default) lets other events interleave; exact needs an action filter so only the listed actions count. |
generate() | Makes fixtures only this test touches and deletes exactly them after the test. |
onCleanup(fn) | Runs after the test, newest first, even when the test failed. |
flowProfile(name, variants) | Creates <name>-<hash8> once and returns its pinned ref. See long-running flows. |
useFlow(ref) | Pins a flow profile for the rest of the test (owner and owner-minted keys only). |
fastForward(filter) | Moves scheduled jobs to now and waits until they ran, up to 60 s. |
sink() | A dev webhook sink plus a webhook on it, scoped to the run, deleted after the test. |
A filter's action is typed against the whole action catalogue (src/generated/audit-actions.ts), so you can wait on any action without editing the example, and a misspelled name fails npm run typecheck. After an API release, refresh it with npm run actions.
examples/e2e-playwright/src/dm.ts · show the file
/**
* The dispute.markets e2e fixture. Import `test` and `expect` from here
* instead of `@playwright/test`.
*
* Contract (docs: https://dispute.markets/docs/api/e2e-testing):
* - Every test gets its own correlation id, `${RUN_ID}:${testInfo.testId}`,
* checked against the API's pattern before use. `dm.api` sends it as
* `X-Correlation-Id`; for the browser it is the `dm_correlation_id`
* cookie on the portal host, which the portal forwards. Every audit
* event a test causes carries it.
* - `mark()` returns the workspace's current audit high-water `seq`.
* - `waitFor(filter)` calls `GET /v1/audit-events/wait` once and returns
* the matching events, or throws with the events it did see.
* - `expectSequence(filter, expected, {mode, timeout})` waits repeatedly,
* moving `after_seq` to `high_water_seq`, until `expected` matches or
* the timeout passes, then throws a diff of everything seen.
* `subsequence` (default) lets other events interleave; `exact` needs
* an `action` filter so only the listed actions count.
* - 429, 503 and 409 `generate_in_progress` are retried after `Retry-After`.
* - `generate()` makes fixtures only this test touches (by
* `generate_job_id`) and deletes exactly them after the test.
* Never call `POST /v1/dev/reset`: it wipes every run's fixtures.
*
* Long-running flows (docs: https://dispute.markets/docs/api/flows):
* - Every `dm.api` call sends `X-DM-Flow: fp_platform` and every browser
* context gets the `dm_flow=fp_platform` cookie, so the workspace default
* a person sets in the portal never reaches CI. `dm.useFlow(ref)` pins
* another profile for the rest of the test.
* - `dm.flowProfile(name, variants)` creates `<name>-<hash8>` once
* (idempotent, never deleted) and returns its pinned `ref`.
* - `dm.fastForward(filter)` moves scheduled jobs to now (or
* `to_remaining`) and waits until they ran, up to 60 s. No sleeps.
* - `dm.sink()` is a dev webhook sink plus a webhook on it, scoped to this
* run's correlation prefix; both are deleted after the test.
* - Flow opt-in is owner-only: `ownerTest` contexts (signed in as
* `DM_OWNER`) may `useFlow`; synthetic-account contexts (`uiTest`) see
* the profile the owner assigned to their account (`dmWorker.assignFlow`,
* worker setup only).
* - Global teardown cancels every scheduled job the run left behind.
*/
import { test as base, expect } from '@playwright/test';
import type { APIRequestContext, Browser, BrowserContext, Page, PlaywrightWorkerArgs } from '@playwright/test';
import { existsSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { ApiError, DmApi } from './api';
import { describeEvent, ExpectedEvent, matchesSequence, sequenceDiff, SequenceMode } from './audit';
import { assertCorrelationId, CORRELATION_COOKIE, CORRELATION_ID_HEADER } from './correlation';
import { API_URL, apiKey, otherApiKey, ownerAccount, PORTAL_URL, RUN_ID, SyntheticAccount, syntheticAccounts } from './env';
import {
assertPinnedRef,
cancelRun,
ensureFlowProfile,
fastForward,
FastForwardOptions,
FastForwardResult,
FLOW_COOKIE,
FLOW_HEADER,
FlowProfileRef,
FlowVariants,
listScheduledJobs,
PLATFORM_FLOW,
} from './flows';
import { totp, totpSecondsLeft } from './totp';
import type {
AuditCursor,
AuditEvent,
AuditEventList,
AuditFilter,
AuditWaitResult,
BackgroundTaskFeed,
ExpireSessionsResult,
GenerateJob,
GenerateRequest,
ScheduledJob,
ScheduledJobFilter,
ScheduledJobQuery,
Webhook,
WebhookSink,
Whoami,
} from './types';
/** The API caps one wait at 25 s; longer timeouts wait again. */
const WAIT_MAX_S = 25;
export interface WaitOptions {
/** How many matching events to wait for (1-50). Default 1. */
minCount?: number;
/** Milliseconds. Default 15 000. */
timeout?: number;
}
export interface SequenceOptions {
mode?: SequenceMode;
/** Milliseconds. Default 15 000. */
timeout?: number;
}
export interface GeneratedFixtures {
job: GenerateJob;
/** The `generate_job.run` event: `metadata.disputes` says how many disputes it made. */
run: AuditEvent;
}
/** A dev webhook sink and a webhook on it, scoped to this run (`dm.sink()`). */
export interface RunSink {
sink: WebhookSink;
webhook: Webhook;
}
export interface SinkOptions {
/** Event types the webhook subscribes to. Default: all. */
events?: string[];
}
/** The `dm_prefs` keys a browser may override (Settings > Preferences, per browser). */
export interface BrowserPrefs {
/** An IANA time zone, e.g. `Asia/Tokyo`. */
tz?: string;
locale?: 'en' | 'pt' | 'ro' | 'nn' | 'no' | 'he';
date_format?: 'locale' | 'iso';
hour_cycle?: 'h12' | 'h23';
page_size?: 25 | 50 | 100 | 200;
}
/** Which kind of portal session a browser context holds. */
export type BrowserSession = 'owner' | 'synthetic_account';
export interface Dm {
/** The API, authenticated with the key and tagged with this test's correlation id. */
api: DmApi;
correlationId: string;
mark(): Promise<number>;
waitFor(filter: AuditFilter, options?: WaitOptions): Promise<AuditEvent[]>;
/** Every matching event, oldest first (follows `next_cursor`). */
events(filter: AuditFilter): Promise<AuditEvent[]>;
expectSequence(filter: AuditFilter, expected: readonly ExpectedEvent[], options?: SequenceOptions): Promise<AuditEvent[]>;
/** Starts a generate job, waits until it ran, and deletes its data after the test. */
generate(request?: Partial<GenerateRequest>): Promise<GeneratedFixtures>;
/** Runs after the test, newest first, even when the test failed. */
onCleanup(fn: () => Promise<unknown>): void;
/** Creates `<name>-<hash8 of the canonical variants>` once (idempotent, cached, never deleted). */
flowProfile(name: string, variants: FlowVariants): Promise<FlowProfileRef>;
/**
* Pins a profile (`fp_platform` or `fp_…@N`) for the rest of the test:
* `X-DM-Flow` on `dm.api` and, in an owner browser, the `dm_flow`
* cookie. Throws in a synthetic-account browser, whose cookie the
* portal ignores (use `dmWorker.assignFlow` in worker setup instead).
*/
useFlow(ref: string): Promise<void>;
/** The profile `dm.api` sends now. */
readonly flowRef: string;
/** Moves the matching scheduled jobs to now (or `to_remaining`) and, by default, waits until they ran (60 s ceiling). */
fastForward(filter: ScheduledJobFilter, options?: FastForwardOptions): Promise<FastForwardResult>;
/** Every scheduled job matching `query`, newest first. */
scheduledJobs(query: ScheduledJobQuery): Promise<ScheduledJob[]>;
/** Cancels every scheduled job of this run (correlation prefix `<RUN_ID>:`). Global teardown calls it too. */
cancelRun(): Promise<number>;
/** A dev webhook sink and a webhook on it, both scoped to this run; deleted after the test. */
sink(options?: SinkOptions): Promise<RunSink>;
/** Sets the `dm_prefs` cookie (time zone, language, …) on a browser context. */
prefs(context: BrowserContext, prefs: BrowserPrefs): Promise<void>;
/** The signed-in user's background tasks (`GET /v1/me/background-tasks`), read in the browser's session. */
tasks(page: Page): Promise<BackgroundTaskFeed>;
/** Makes every current session of a synthetic account answer 401 `session_expired` (key with `flow.write`). */
expireSessions(accountId: string): Promise<ExpireSessionsResult>;
}
/** The API's query parameters for a filter. */
export function toQuery(filter: AuditFilter): Record<string, string | number | undefined> {
const { action, after_seq, ...rest } = filter;
const q: Record<string, string | number | undefined> = { ...rest, after_seq: String(after_seq) };
if (action !== undefined) q.action = typeof action === 'string' ? action : action.join(',');
return q;
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
class DmFixture implements Dm {
private readonly cleanups: Array<() => Promise<unknown>> = [];
private browser: { context: BrowserContext; session: BrowserSession } | null = null;
constructor(
readonly api: DmApi,
readonly correlationId: string,
) {
api.setDefaultHeader(FLOW_HEADER, PLATFORM_FLOW);
}
/** Called by the browser fixtures: which context `useFlow` sets the cookie on. */
bindBrowser(context: BrowserContext, session: BrowserSession): void {
this.browser = { context, session };
}
get flowRef(): string {
return this.api.defaultHeader(FLOW_HEADER) ?? PLATFORM_FLOW;
}
flowProfile(name: string, variants: FlowVariants): Promise<FlowProfileRef> {
return ensureFlowProfile(this.api, name, variants);
}
async useFlow(ref: string): Promise<void> {
assertPinnedRef(ref);
if (this.browser?.session === 'synthetic_account') {
throw new Error(
'dm.useFlow in a synthetic-account browser: the portal ignores dm_flow for anyone but the owner ' +
'(docs/api/flows, "Only the owner opts in"). Assign the account a profile in worker setup ' +
'(dmWorker.assignFlow), or test this variant in an ownerTest context.',
);
}
this.api.setDefaultHeader(FLOW_HEADER, ref);
if (this.browser) {
await this.browser.context.addCookies([{ name: FLOW_COOKIE, value: ref, url: PORTAL_URL }]);
}
}
fastForward(filter: ScheduledJobFilter, options?: FastForwardOptions): Promise<FastForwardResult> {
return fastForward(this.api, filter, options);
}
scheduledJobs(query: ScheduledJobQuery): Promise<ScheduledJob[]> {
return listScheduledJobs(this.api, query);
}
cancelRun(): Promise<number> {
return cancelRun(this.api);
}
async sink(options: SinkOptions = {}): Promise<RunSink> {
const filter = { correlation_prefix: `${RUN_ID}:` };
const sink = await this.api.post<WebhookSink>('/v1/dev/webhook-sinks', { filter });
// Free the sink's slot (10 live per workspace) as soon as the test ends.
this.onCleanup(() => this.api.delete(`/v1/dev/webhook-sinks/${sink.id}`));
const webhook = await this.api.post<Webhook>('/v1/webhooks', {
url: sink.url,
...(options.events ? { events: options.events } : {}),
filter,
});
this.onCleanup(() => this.api.delete(`/v1/webhooks/${webhook.id}`));
return { sink, webhook };
}
async prefs(context: BrowserContext, prefs: BrowserPrefs): Promise<void> {
const value = Object.entries(prefs)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => `${k}=${v}`)
.join(';');
await context.addCookies([{ name: 'dm_prefs', value: encodeURIComponent(value), url: PORTAL_URL }]);
}
async tasks(page: Page): Promise<BackgroundTaskFeed> {
// The portal forwards /api/proxy/* to the API with the session's token.
const res = await page.request.get(`${PORTAL_URL}/api/proxy/me/background-tasks`, { params: { limit: 20 } });
if (!res.ok()) throw new Error(`GET /v1/me/background-tasks answered ${res.status()}`);
return (await res.json()) as BackgroundTaskFeed;
}
expireSessions(accountId: string): Promise<ExpireSessionsResult> {
return this.api.post<ExpireSessionsResult>(`/v1/team/synthetic-accounts/${accountId}/expire-sessions`);
}
async mark(): Promise<number> {
const res = await this.api.get<AuditCursor>('/v1/audit-events/cursor');
return res.seq;
}
private async waitOnce(filter: AuditFilter, minCount: number, timeoutMs: number): Promise<AuditWaitResult> {
const timeout_s = Math.max(1, Math.min(WAIT_MAX_S, Math.ceil(timeoutMs / 1000)));
return this.api.get<AuditWaitResult>('/v1/audit-events/wait', {
...toQuery(filter),
min_count: minCount,
timeout_s,
});
}
async waitFor(filter: AuditFilter, options: WaitOptions = {}): Promise<AuditEvent[]> {
const minCount = options.minCount ?? 1;
const deadline = Date.now() + (options.timeout ?? 15_000);
for (;;) {
const res = await this.waitOnce(filter, minCount, deadline - Date.now());
if (res.satisfied) return res.data;
if (Date.now() >= deadline) {
throw new Error(
`waitFor(${JSON.stringify(filter)}) saw ${res.data.length} of ${minCount} event(s):\n` +
res.data.map((e) => ` ${describeEvent(e)}`).join('\n'),
);
}
}
}
async events(filter: AuditFilter): Promise<AuditEvent[]> {
const out: AuditEvent[] = [];
let cursor: string | undefined;
do {
const page = await this.api.get<AuditEventList>('/v1/audit-events', {
...toQuery(filter),
order: 'asc',
limit: 200,
cursor,
});
out.push(...page.data);
cursor = page.pagination.next_cursor ?? undefined;
} while (cursor);
return out;
}
async expectSequence(
filter: AuditFilter,
expected: readonly ExpectedEvent[],
options: SequenceOptions = {},
): Promise<AuditEvent[]> {
const mode = options.mode ?? 'subsequence';
if (mode === 'exact' && filter.action === undefined) {
throw new Error("expectSequence mode 'exact' needs an action filter, so only the listed actions count");
}
const deadline = Date.now() + (options.timeout ?? 15_000);
const seen: AuditEvent[] = [];
let afterSeq = filter.after_seq;
for (;;) {
const res = await this.waitOnce({ ...filter, after_seq: afterSeq }, 1, deadline - Date.now());
seen.push(...res.data);
afterSeq = Math.max(afterSeq, res.high_water_seq);
if (matchesSequence(seen, expected, mode)) return seen;
if (mode === 'exact' && seen.length >= expected.length) break;
if (Date.now() >= deadline) break;
}
throw new Error(`expectSequence failed for ${JSON.stringify(filter)}\n${sequenceDiff(seen, expected, mode)}`);
}
async generate(request: Partial<GenerateRequest> = {}): Promise<GeneratedFixtures> {
const mark = await this.mark();
const body: GenerateRequest = {
count: 5,
dispute_rate: 1,
currency: 'USD',
range_days: 30,
...request,
};
const job = await this.api.post<GenerateJob>('/v1/dev/generate', body);
this.onCleanup(() => this.api.post(`/v1/dev/generate-jobs/${job.id}/cleanup`));
const [run] = await this.waitFor(
{ action: 'generate_job.run', target_id: job.id, after_seq: mark },
{ timeout: 60_000 },
);
if (run.outcome !== 'success') {
throw new Error(`generate job ${job.id} failed: ${describeEvent(run)}`);
}
return { job, run };
}
onCleanup(fn: () => Promise<unknown>): void {
this.cleanups.push(fn);
}
async runCleanups(): Promise<void> {
const errors: unknown[] = [];
for (const fn of this.cleanups.reverse()) {
try {
await fn();
} catch (err) {
errors.push(err);
}
}
if (errors.length) throw new AggregateError(errors, `${errors.length} cleanup step(s) failed`);
}
}
/**
* What the browser specs do in the portal: read disputes and act on them.
* A role without `dispute.write` (View only, Reports) gets no action
* buttons in the portal and 403 `missing_permission` from the API, so
* without this check it would fail a spec far from the cause.
*/
const UI_REQUIRED_PERMISSIONS = ['dispute.read', 'dispute.write'] as const;
/** Fails the worker at once, naming the account and its role, when it cannot run the browser specs. */
async function assertUiPermissions(page: import('@playwright/test').Page, identifier: string): Promise<void> {
// The portal forwards /api/proxy/* to the API with the session's token.
const res = await page.request.get('/api/proxy/auth/me');
if (!res.ok()) throw new Error(`GET /v1/auth/me as ${identifier} answered ${res.status()}`);
const me = (await res.json()) as { is_owner?: boolean; tenant_role?: string | null; permissions?: string[] };
const missing = me.is_owner ? [] : UI_REQUIRED_PERMISSIONS.filter((p) => !me.permissions?.includes(p));
if (missing.length > 0) {
throw new Error(
`${identifier} has role ${me.tenant_role ?? 'none'} and lacks ${missing.join(', ')}. ` +
'The browser specs act on disputes: give the account Full access (or Disputes) in Team > Synthetic accounts.',
);
}
}
/** Answers the sign-in MFA prompt. A code is accepted once: when another run (or worker) just used this window's code for the same account, it tries the next window's. */
async function answerTotp(page: Page, account: SyntheticAccount): Promise<void> {
if (!account.totp_secret) throw new Error(`${account.identifier} needs MFA but has no totp_secret`);
const code = page.getByLabel('Code', { exact: true });
for (let attempt = 1; ; attempt++) {
if (totpSecondsLeft() < 5) await sleep(totpSecondsLeft() * 1000 + 500);
const window = Math.floor(Date.now() / 30_000);
await code.fill(totp(account.totp_secret));
await page.getByRole('button', { name: 'Verify' }).click();
const ok = await page
.waitForURL(/\/dashboard/, { timeout: 10_000 })
.then(() => true)
.catch(() => false);
if (ok) return;
if (attempt === 3) throw new Error(`${account.identifier}: the TOTP code was refused 3 times`);
await sleep(Math.max(0, (window + 1) * 30_000 - Date.now()) + 500);
}
}
/**
* Signs `account` in through the portal's own form (answering TOTP when
* asked) and saves the session to `file`. `checkRole` (default true)
* fails at once when a synthetic account cannot act on disputes.
*/
export async function signInTo(
browser: Browser,
account: SyntheticAccount,
file: string,
{ checkRole = true }: { checkRole?: boolean } = {},
): Promise<void> {
mkdirSync(dirname(file), { recursive: true });
const page = await browser.newPage({ baseURL: PORTAL_URL, storageState: undefined });
try {
// Several CI jobs signing in from one address can meet the sign-in
// rate limit (429, shown on the form): wait and try again, twice.
for (let attempt = 1; ; attempt++) {
await page.goto('/en/sign-in');
await page.getByLabel('Username or email').fill(account.identifier);
await page.getByLabel('Password', { exact: true }).fill(account.password);
await page.getByRole('button', { name: 'Sign in' }).click();
const code = page.getByLabel('Code', { exact: true });
const landed = page.waitForURL(/\/dashboard/, { timeout: 30_000 }).then(() => 'done' as const);
const challenged = code.waitFor({ timeout: 30_000 }).then(() => 'mfa' as const);
// Whichever loses the race must not reject unobserved.
landed.catch(() => undefined);
challenged.catch(() => undefined);
const outcome = await Promise.race([landed, challenged]).catch(() => 'stuck' as const);
if (outcome === 'stuck') {
const shown = (await page.getByRole('alert').allInnerTexts().catch(() => [])).join(' ').trim();
if (attempt === 3) throw new Error(`${account.identifier} could not sign in: ${shown || 'no dashboard, no MFA prompt'}`);
await sleep(20_000);
continue;
}
if (outcome === 'mfa') await answerTotp(page, account);
break;
}
if (checkRole) await assertUiPermissions(page, account.identifier);
await page.context().storageState({ path: file });
} finally {
await page.close();
}
}
/** Signs one synthetic account in and returns the saved session file. */
async function signIn(browser: Browser, index: number, outputDir: string): Promise<string> {
const accounts = syntheticAccounts();
if (accounts.length === 0) {
throw new Error('UI specs need DM_SYNTHETIC_ACCOUNTS (a JSON list, one synthetic account per worker).');
}
const account = accounts[index % accounts.length];
// Workers that share an account sign in one after another (sign-in is
// rate limited per identifier); each still gets its own session.
await sleep(Math.floor(index / accounts.length) * 15_000);
const file = join(outputDir, '.auth', `${RUN_ID}-worker-${index}.json`);
await signInTo(browser, account, file);
return file;
}
/** Where global setup saves the owner's session (one per run, shared by the owner contexts of every worker). */
export const OWNER_STATE_FILE = join(__dirname, '..', '.auth', `${RUN_ID}-owner.json`);
/** Global setup: signs `DM_OWNER` in once for the run. A no-op without it. */
export async function signInOwner(browser: Browser): Promise<boolean> {
const owner = ownerAccount();
if (!owner) return false;
await signInTo(browser, owner, OWNER_STATE_FILE, { checkRole: false });
return true;
}
/**
* The owner's portal session as an API client (`/api/proxy/*` → `/v1/*`):
* the routes no key may call (team, the workspace default).
*/
export async function ownerRequest(playwright: PlaywrightWorkerArgs['playwright']): Promise<APIRequestContext> {
if (!existsSync(OWNER_STATE_FILE)) {
throw new Error('Owner-only setup needs DM_OWNER (global setup signs the workspace owner in once per run).');
}
return playwright.request.newContext({ baseURL: `${PORTAL_URL}/api/proxy/`, storageState: OWNER_STATE_FILE });
}
/** The synthetic account's id (`sa_…`): from DM_SYNTHETIC_ACCOUNTS, else looked up by email as the owner. */
async function accountId(owner: APIRequestContext, account: SyntheticAccount): Promise<string> {
if (account.id) return account.id;
const res = await owner.get('team/synthetic-accounts');
if (!res.ok()) throw new Error(`GET /v1/team/synthetic-accounts as the owner answered ${res.status()}`);
const { items } = (await res.json()) as { items: Array<{ id: string; email: string }> };
const found = items.find((a) => a.email.toLowerCase() === account.identifier.toLowerCase());
if (!found) throw new Error(`no synthetic account with email ${account.identifier}: add its "id" to DM_SYNTHETIC_ACCOUNTS`);
return found.id;
}
/** Worker-scoped helpers for owner-only setup (`dmWorker`). */
export interface DmWorker {
/** This worker's synthetic account (DM_SYNTHETIC_ACCOUNTS), or null when none is set. */
account: SyntheticAccount | null;
/**
* Assigns a synthetic account's flow profile (`fp_platform`, `fp_…@N`,
* or null for the workspace default) as the owner. Worker setup only:
* accounts are shared by concurrent runs, so never toggle one inside a
* test. The worker restores the previous assignment when it ends.
*/
assignFlow(account: SyntheticAccount, ref: string | null): Promise<void>;
}
export const test = base.extend<{ dm: Dm; correlationId: string }>({
// Playwright requires a destructuring pattern as a fixture's first argument.
// eslint-disable-next-line no-empty-pattern
correlationId: async ({}, use, testInfo) => {
await use(assertCorrelationId(`${RUN_ID}:${testInfo.testId}`));
},
// Browser specs: the portal forwards this cookie as X-Correlation-Id,
// so the test's clicks land under its correlation id too.
context: async ({ context, correlationId }, use) => {
await context.addCookies([
{ name: CORRELATION_COOKIE, value: correlationId, url: PORTAL_URL },
{ name: FLOW_COOKIE, value: PLATFORM_FLOW, url: PORTAL_URL },
]);
await use(context);
},
dm: async ({ playwright, correlationId }, use) => {
await withDm(playwright, apiKey(), correlationId, use);
},
});
/** A `Dm` on its own request context for `key`; runs its cleanups and disposes it after `use`. */
async function withDm(
playwright: PlaywrightWorkerArgs['playwright'],
key: string,
correlationId: string,
use: (dm: Dm) => Promise<void>,
): Promise<void> {
const request = await playwright.request.newContext({
baseURL: API_URL,
extraHTTPHeaders: {
Authorization: `Bearer ${key}`,
[CORRELATION_ID_HEADER]: correlationId,
},
});
const dm = new DmFixture(new DmApi(request), correlationId);
try {
await use(dm);
} finally {
try {
await dm.runCleanups();
} finally {
await request.dispose();
}
}
}
/**
* For specs that span two workspaces: `test` plus `other`, a second `Dm`
* on `DM_API_KEY_B` (a key from another workspace of the same person),
* with its own cleanups. Skips the test when `DM_API_KEY_B` is not set.
* Both fixtures tag requests with the same correlation id; each
* workspace keeps its own audit trail.
*/
export const workspacesTest = test.extend<{ other: Dm }>({
other: async ({ playwright, correlationId }, use, testInfo) => {
const key = otherApiKey();
testInfo.skip(!key, 'needs DM_API_KEY_B, a key from a second workspace (README, "One workspace per environment")');
await withDm(playwright, key!, correlationId, use);
},
});
/** `GET /v1/whoami`: the workspace (with its environment label) this key acts in. */
export function whoami(dm: Dm): Promise<Whoami> {
return dm.api.get<Whoami>('/v1/whoami');
}
/**
* `GET /v1/whoami` for a key, outside any test's fixture (browser specs
* use it to learn which workspaces their keys point at).
*/
export async function whoamiOfKey(playwright: PlaywrightWorkerArgs['playwright'], key: string): Promise<Whoami> {
const request = await playwright.request.newContext({
baseURL: API_URL,
extraHTTPHeaders: { Authorization: `Bearer ${key}`, [CORRELATION_ID_HEADER]: `${RUN_ID}:whoami` },
});
try {
const res = await request.get('/v1/whoami');
if (!res.ok()) throw new Error(`GET /v1/whoami answered ${res.status()} ${await res.text()}`);
return (await res.json()) as Whoami;
} finally {
await request.dispose();
}
}
/** Worker options of the browser specs. */
export interface UiWorkerOptions {
/**
* Which DM_SYNTHETIC_ACCOUNTS entry this worker signs in as. Default:
* the worker's index (wrapping). Set it with `test.use` when a spec needs
* two specific accounts at once.
*/
accountSlot: number | undefined;
/**
* The flow profile the owner assigns to this worker's account before it
* signs in, restored when the worker ends: `fp_platform`, `fp_…@N`,
* `{name, variants}` (created like `dm.flowProfile`, then pinned) or
* null (the workspace default). `undefined` (the default) leaves the
* assignment alone. A worker option, so it changes only between
* workers, never inside a test.
*/
accountFlow: string | null | { name: string; variants: FlowVariants } | undefined;
}
/**
* For browser specs: `test` plus a signed-in portal session per worker.
* (Kept apart so API-only specs never start a browser.)
*/
export const uiTest = test.extend<{ session: BrowserSession }, UiWorkerOptions & { workerStorageState: string; dmWorker: DmWorker }>({
// One session per worker, never shared: Heimdall revokes a session
// whose refresh token is reused after its 60 s grace.
storageState: async ({ workerStorageState }, use) => {
await use(workerStorageState);
},
session: ['synthetic_account', { option: true }],
dm: async ({ dm, context, session }, use) => {
(dm as DmFixture).bindBrowser(context, session);
await use(dm);
},
accountSlot: [undefined, { scope: 'worker', option: true }],
accountFlow: [undefined, { scope: 'worker', option: true }],
dmWorker: [
async ({ playwright, accountSlot }, use, workerInfo) => {
const accounts = syntheticAccounts();
const account = accounts.length ? accounts[(accountSlot ?? workerInfo.parallelIndex) % accounts.length] : null;
const restore: Array<() => Promise<void>> = [];
const state: { owner: APIRequestContext | null } = { owner: null };
const patch = async (id: string, ref: string | null): Promise<unknown> => {
const res = await state.owner!.patch(`team/synthetic-accounts/${id}`, { data: { flow_profile: ref } });
if (!res.ok()) throw new Error(`PATCH /v1/team/synthetic-accounts/${id} answered ${res.status()} ${await res.text()}`);
return res.json();
};
const worker: DmWorker = {
account,
async assignFlow(acc, ref) {
if (ref !== null) assertPinnedRef(ref);
state.owner ??= await ownerRequest(playwright);
const owner = state.owner;
const id = await accountId(owner, acc);
const list = await owner.get('team/synthetic-accounts');
const { items } = (await list.json()) as { items: Array<{ id: string; flow_profile?: { ref: string } | null }> };
const before = items.find((a) => a.id === id)?.flow_profile?.ref ?? null;
await patch(id, ref);
restore.push(() => patch(id, before).then(() => undefined));
},
};
try {
await use(worker);
} finally {
for (const fn of restore.reverse()) await fn();
await state.owner?.dispose();
}
},
{ scope: 'worker' },
],
workerStorageState: [
async ({ browser, playwright, dmWorker, accountFlow, accountSlot }, use, workerInfo) => {
if (accountFlow !== undefined) {
if (!dmWorker.account) throw new Error('accountFlow needs DM_SYNTHETIC_ACCOUNTS');
let ref: string | null;
if (accountFlow === null || typeof accountFlow === 'string') {
ref = accountFlow;
} else {
const request = await playwright.request.newContext({
baseURL: API_URL,
extraHTTPHeaders: { Authorization: `Bearer ${apiKey()}`, [CORRELATION_ID_HEADER]: `${RUN_ID}:worker-setup` },
});
try {
ref = (await ensureFlowProfile(new DmApi(request), accountFlow.name, accountFlow.variants)).ref;
} finally {
await request.dispose();
}
}
await dmWorker.assignFlow(dmWorker.account, ref);
}
await use(await signIn(browser, accountSlot ?? workerInfo.parallelIndex, workerInfo.project.outputDir));
},
{ scope: 'worker' },
],
});
/**
* For browser specs that choose a flow: the workspace owner's session
* (`DM_OWNER`, signed in once per run by global setup and shared by every
* worker). Only the owner may opt into a flow (`dm.useFlow`); keep
* everything else in `uiTest`, as a synthetic account.
*/
export const ownerTest = test.extend<{ owner: APIRequestContext }>({
// eslint-disable-next-line no-empty-pattern
storageState: async ({}, use) => {
if (!existsSync(OWNER_STATE_FILE)) throw new Error('ownerTest needs DM_OWNER (see the README).');
await use(OWNER_STATE_FILE);
},
dm: async ({ dm, context }, use) => {
(dm as DmFixture).bindBrowser(context, 'owner');
await use(dm);
},
// The owner's session as an API client for routes no key may call
// (`owner.post('team/synthetic-accounts', …)` is `POST /v1/team/synthetic-accounts`).
owner: async ({ playwright }, use) => {
const owner = await ownerRequest(playwright);
try {
await use(owner);
} finally {
await owner.dispose();
}
},
});
/** True when the owner was signed in for this run (DM_OWNER set). */
export function hasOwner(): boolean {
return ownerAccount() !== null;
}
export { expect, ApiError };
export type { ExpectedEvent };Mark, act, wait
Every test has the same shape:
- Mark: take the current
seqbefore doing anything. - Act: through the API or the portal, tagged with the test's correlation id.
- Wait: for the events, filtered by
after_seq: markandcorrelation_id: dm.correlationId, and assert on them.
import { expect, test } from '../../src/dm';
import type { DisputePage } from '../../src/types';
// (1) API only: make fixtures, accept one dispute, and assert the whole
// story from the audit trail under this test's correlation id.
test('a key generates fixtures and accepts a dispute; the trail tells the story in order', async ({ dm }) => {
const mark = await dm.mark();
const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
const page = await dm.api.get<DisputePage>('/v1/disputes', {
generate_job_id: job.id,
status: 'evidence_required',
page_size: 1,
});
expect(page.items, 'the seeded job has a dispute awaiting evidence').toHaveLength(1);
const dispute = page.items[0];
await dm.api.post(`/v1/disputes/${dispute.id}/accept`, {});
await dm.expectSequence({ after_seq: mark, correlation_id: dm.correlationId }, [
{ action: 'generate_job.create', actor: { type: 'api_key' }, request: { source: 'api' }, target: { id: job.id } },
{ action: 'generate_job.run', outcome: 'success', actor: { type: 'system' }, target: { id: job.id } },
{
action: 'dispute.accept',
outcome: 'success',
actor: { type: 'api_key' },
request: { source: 'api', correlation_id: dm.correlationId },
target: { type: 'dispute', id: dispute.id },
changes: { status: { from: 'evidence_required', to: 'accepted' } },
},
]);
});The audit event is written in the same transaction as the action, so a synchronous action's event exists the moment the call returns. Work that happens later (the generate job here) is waited for, never slept for.
Parallel isolation
All workers and all concurrent CI runs share one workspace. They stay apart because each test only touches what it made:
POST /v1/dev/generatereturns a job id (gj_…) that is stamped on every transaction and dispute the job creates. Read your fixtures withGET /v1/disputes?generate_job_id=gj_….- Clean up with
POST /v1/dev/generate-jobs/:id/cleanup, which deletes exactly that job's data. The fixture does it for you. - Read the trail by your own correlation id plus your mark, never “the latest event”.
- Give a seed (
seed: 42) and the same job always produces the same disputes.
async generate(request: Partial<GenerateRequest> = {}): Promise<GeneratedFixtures> {
const mark = await this.mark();
const body: GenerateRequest = {
count: 5,
dispute_rate: 1,
currency: 'USD',
range_days: 30,
...request,
};
const job = await this.api.post<GenerateJob>('/v1/dev/generate', body);
this.onCleanup(() => this.api.post(`/v1/dev/generate-jobs/${job.id}/cleanup`));
const [run] = await this.waitFor(
{ action: 'generate_job.run', target_id: job.id, after_seq: mark },
{ timeout: 60_000 },
);
if (run.outcome !== 'success') {
throw new Error(`generate job ${job.id} failed: ${describeEvent(run)}`);
}
return { job, run };
}Deterministic lifecycle
Disputes generated with an API key do not move on their own: the hourly lifecycle run skips them (lifecycle: "manual", the default for keys). Move them yourself with POST /v1/dev/advance-lifecycle, limited to your job (generate_job_id) or a list of dispute_ids, at a chosen as_of time and with a fixed issuer_decision:
import { expect, test } from '../../src/dm';
import type { AdvanceLifecycleResult, DisputePage } from '../../src/types';
const DAY_MS = 24 * 60 * 60 * 1000;
// (4) Time travel on this test's own disputes only: past the 55-day
// issuer review window, with the verdict fixed, so every dispute under
// review is ruled `won`.
test('advance-lifecycle rules exactly this job\'s disputes under review', async ({ dm }) => {
const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
const underReview = await dm.api.get<DisputePage>('/v1/disputes', {
generate_job_id: job.id,
status: 'under_review',
page_size: 200,
});
expect(underReview.items.length, 'the seeded job has disputes under review').toBeGreaterThan(0);
const mark = await dm.mark();
const result = await dm.api.post<AdvanceLifecycleResult>('/v1/dev/advance-lifecycle', {
// Generated disputes may open up to ~15 days ahead; 120 days clears the window for all of them.
as_of: new Date(Date.now() + 120 * DAY_MS).toISOString(),
issuer_decision: 'won',
generate_job_id: job.id,
});
expect(result.ruled).toBe(underReview.items.length);
const ruled = await dm.waitFor(
{ after_seq: mark, correlation_id: dm.correlationId, action: 'dispute.rule' },
{ minCount: underReview.items.length },
);
expect(ruled.map((e) => e.target?.id).sort()).toEqual(underReview.items.map((d) => d.id).sort());
for (const event of ruled) {
expect(event).toMatchObject({
outcome: 'success',
changes: { status: { from: 'under_review', to: 'won' } },
metadata: { issuer_decision: 'won' },
});
}
const [run] = await dm.waitFor({ after_seq: mark, correlation_id: dm.correlationId, action: 'lifecycle.run' });
expect(run.metadata).toMatchObject({ ruled: underReview.items.length, generate_job_id: job.id });
expect(ruled.every((e) => e.parent_id === run.id)).toBe(true);
});Testing refusals
Narrow the key for one request with X-DM-Scopes; the refusal is answered with 403 missing_scope and recorded as a denied event you can wait for.
import { expect, test } from '../../src/dm';
import type { DisputePage, ErrorBody } from '../../src/types';
// (3) A denied path without a second key: `X-DM-Scopes` narrows the key
// to `dispute.read` for one request, so a challenge is refused, and the
// refusal itself is in the trail.
test('a key narrowed to dispute.read cannot challenge; the denial is audited', async ({ dm }) => {
const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
const list = await dm.api.get<DisputePage>('/v1/disputes', { generate_job_id: job.id, page_size: 1 });
expect(list.items).toHaveLength(1);
const dispute = list.items[0];
const mark = await dm.mark();
const res = await dm.api.fetch<ErrorBody>('POST', `/v1/disputes/${dispute.id}/challenge`, {
data: {},
headers: { 'X-DM-Scopes': 'dispute.read' },
});
expect(res.status).toBe(403);
expect(res.body.code).toBe('missing_scope');
const [denied] = await dm.waitFor({ after_seq: mark, correlation_id: dm.correlationId, action: 'dispute.challenge' });
expect(denied).toMatchObject({
outcome: 'denied',
error_code: 'missing_scope',
actor: { type: 'api_key' },
target: { type: 'dispute', id: dispute.id },
request: { source: 'api', id: res.body.request_id },
});
});examples/e2e-playwright/tests/api/06-team-refused.spec.ts · show the file
import { expect, test } from '../../src/dm';
import type { ErrorBody } from '../../src/types';
// (6) Keys never manage people or credentials: team and key routes
// refuse them outright, whatever their scopes.
for (const [method, path] of [
['GET', '/v1/team/synthetic-accounts'],
['POST', '/v1/team/synthetic-accounts'],
// Setup emails too: only the owner can send one (the id is never looked up).
['POST', '/v1/team/synthetic-accounts/sa_example/email-setup'],
['GET', '/v1/api-keys'],
] as const) {
test(`a key is refused on ${method} ${path}`, async ({ dm }) => {
const res = await dm.api.fetch<ErrorBody>(method, path, method === 'POST' ? { data: {} } : {});
expect(res.status).toBe(401);
expect(res.body.code).toBe('api_key_not_allowed');
});
}The revoked-key spec is a manual check: an API key cannot revoke keys, and revoking takes a workspace owner in the portal. Revoke a throwaway key in Settings > API keys, then run the spec within a minute with DM_REVOKED_API_KEY and DM_REVOKED_AT (when you revoked it, in milliseconds). Without them it skips, which is the expected result in CI.
examples/e2e-playwright/tests/api/07-revoked-key.spec.ts · show the file
import { expect, request as playwrightRequest, test } from '@playwright/test';
import { API_URL } from '../../src/env';
// (7) Revocation: a key revoked in Settings > API keys stops working on
// every server within 30 s (60 s budget here).
//
// This is a manual, occasional check, not part of an unattended run: an
// API key cannot revoke keys (/v1/api-keys answers 401
// api_key_not_allowed), and revoking takes a signed-in workspace owner.
// So a person creates a throwaway key, revokes it in Settings > API keys,
// and runs this spec within a minute, passing the key and the time of
// the revoke (README, "The revoked-key check"). Without them it skips,
// which is the expected result in CI.
const revokedKey = process.env.DM_REVOKED_API_KEY;
const revokedAt = Number(process.env.DM_REVOKED_AT ?? Date.now());
test.skip(
!revokedKey,
'manual check: revoke a throwaway key in Settings > API keys, then set DM_REVOKED_API_KEY (and DM_REVOKED_AT, ms since epoch) within 60 s',
);
test('a revoked key is refused within 60 s of revocation', async () => {
const api = await playwrightRequest.newContext({
baseURL: API_URL,
extraHTTPHeaders: { Authorization: `Bearer ${revokedKey}` },
});
try {
// Requests may land on any server; three refusals in a row (6 s
// apart, longer than one server's cache answer) count as "everywhere".
const deadline = revokedAt + 60_000;
let streak = 0;
let code: string | undefined;
while (streak < 3 && Date.now() < deadline) {
const res = await api.get('/v1/whoami');
code = ((await res.json().catch(() => null)) as { code?: string } | null)?.code;
streak = res.status() === 401 ? streak + 1 : 0;
if (streak < 3) await new Promise((r) => setTimeout(r, 2_000));
}
expect(streak, `refused 3 times in a row by ${new Date(deadline).toISOString()}`).toBe(3);
expect(code).toBe('invalid_api_key');
} finally {
await api.dispose();
}
});One workspace per environment
Give each environment its own workspace (Development, Staging, Production) and each CI job the key of exactly one. A key acts only in the workspace it was created in, so set DM_EXPECTED_ENVIRONMENT to that workspace's environment label: the first spec checks GET /v1/whoamiand fails the run when a job holds another environment's key. The same file shows that X-DM-Workspace naming another workspace is refused with 409 workspace_mismatch on reads and writes, and, with a second key (DM_API_KEY_B), that another workspace's ids are 404 not_found to the first key. See Workspaces and environments for creating and switching workspaces.
import { expect, test, whoami, workspacesTest } from '../../src/dm';
import { expectedEnvironment } from '../../src/env';
import type { DisputePage, ErrorBody } from '../../src/types';
// One workspace per environment (README, "One workspace per environment").
// A key acts in exactly one workspace, for good. These specs check that the
// run is pointed at the environment it thinks it is, that a request pinned
// to another workspace does nothing, and that a key never reaches the data
// of another workspace, even one the same person owns.
/** `details` of 409 `workspace_mismatch`. */
interface WorkspaceMismatchDetails {
current_workspace_id: string;
}
/** A well-formed workspace id that is not the key's own (used when no second key is set). */
const SOME_OTHER_WORKSPACE = 'tnt_00000000000000000000000000';
function sameLabel(actual: string | null, expected: string | null): boolean {
return (actual ?? '').toLocaleLowerCase() === (expected ?? '').toLocaleLowerCase();
}
test('the key acts in the workspace of the expected environment', async ({ dm }) => {
const expected = expectedEnvironment();
test.skip(expected === undefined, 'set DM_EXPECTED_ENVIRONMENT to the label of the workspace DM_API_KEY belongs to');
const { workspace } = await whoami(dm);
expect(workspace.id).toMatch(/^tnt_/);
expect(
sameLabel(workspace.environment_label, expected!),
`DM_API_KEY acts in ${workspace.name} (${workspace.id}), labelled ${JSON.stringify(workspace.environment_label)}, ` +
`not ${JSON.stringify(expected)}: is this job holding another environment's key?`,
).toBe(true);
});
test('X-DM-Workspace naming another workspace is refused on reads and writes, and nothing happens', async ({ dm }) => {
const { workspace: own } = await whoami(dm);
// Both directions: the key's own workspace passes...
const pinned = await dm.api.fetch('GET', '/v1/whoami', { headers: { 'X-DM-Workspace': own.id } });
expect(pinned.status).toBe(200);
// ...any other is 409 workspace_mismatch, naming the workspace the key acts in.
const read = await dm.api.fetch<ErrorBody>('GET', '/v1/whoami', { headers: { 'X-DM-Workspace': SOME_OTHER_WORKSPACE } });
expect(read.status).toBe(409);
expect(read.body.code).toBe('workspace_mismatch');
expect(read.body.details as unknown as WorkspaceMismatchDetails).toMatchObject({ current_workspace_id: own.id });
const mark = await dm.mark();
const write = await dm.api.fetch<ErrorBody>('POST', '/v1/dev/generate', {
data: { count: 1, dispute_rate: 1, currency: 'USD', range_days: 30 },
headers: { 'X-DM-Workspace': SOME_OTHER_WORKSPACE },
});
expect(write.status).toBe(409);
expect(write.body.code).toBe('workspace_mismatch');
// The refused write created nothing: no generate job under this test's correlation id.
const created = await dm.events({ after_seq: mark, correlation_id: dm.correlationId, action: 'generate_job.create' });
expect(created).toHaveLength(0);
});
workspacesTest('the other workspace is a different one, with its own environment label', async ({ dm, other }) => {
const [{ workspace: a }, { workspace: b }] = await Promise.all([whoami(dm), whoami(other)]);
expect(b.id, 'DM_API_KEY and DM_API_KEY_B are keys of the same workspace').not.toBe(a.id);
const expectedB = expectedEnvironment('b');
if (expectedB !== undefined) {
expect(
sameLabel(b.environment_label, expectedB),
`DM_API_KEY_B acts in ${b.name} (${b.id}), labelled ${JSON.stringify(b.environment_label)}, not ${JSON.stringify(expectedB)}`,
).toBe(true);
}
// Pinning a key to the other workspace's real id is refused like any other id.
const res = await dm.api.fetch<ErrorBody>('GET', '/v1/whoami', { headers: { 'X-DM-Workspace': b.id } });
expect(res.status).toBe(409);
expect(res.body.code).toBe('workspace_mismatch');
expect(res.body.details as unknown as WorkspaceMismatchDetails).toMatchObject({ current_workspace_id: a.id });
});
workspacesTest("a key gets 404 for another workspace's ids, and cannot change them", async ({ dm, other }) => {
// Fixtures in workspace B, made (and cleaned up) with B's key.
const { job } = await other.generate({ count: 6, dispute_rate: 1, seed: 100 });
const inB = await other.api.get<DisputePage>('/v1/disputes', {
generate_job_id: job.id,
status: 'evidence_required',
page_size: 1,
});
expect(inB.items, "B's key sees the dispute it made").toHaveLength(1);
const dispute = inB.items[0];
// A's key: the id does not exist for it, on reads and writes alike.
const read = await dm.api.fetch<ErrorBody>('GET', `/v1/disputes/${dispute.id}`);
expect(read.status).toBe(404);
expect(read.body.code).toBe('not_found');
const accept = await dm.api.fetch<ErrorBody>('POST', `/v1/disputes/${dispute.id}/accept`, { data: {} });
expect(accept.status).toBe(404);
expect(accept.body.code).toBe('not_found');
// Lists are scoped too: filtering by B's job finds nothing with A's key.
const listed = await dm.api.get<DisputePage>('/v1/disputes', { generate_job_id: job.id, page_size: 1 });
expect(listed.total).toBe(0);
// And the dispute in B is untouched.
const after = await other.api.get<{ status: string }>(`/v1/disputes/${dispute.id}`);
expect(after.status).toBe('evidence_required');
});The browser spec signs DM_OWNERin on a session of its own (switching moves that session, so it never uses the run's shared owner session), checks the switcher's environment chip, the stripe and the tab title, switches from a deep page and expects the other workspace's dashboard, then switches back.
examples/e2e-playwright/tests/ui/workspace-switcher.spec.ts · show the file
import type { BrowserContext, Page } from '@playwright/test';
import { expect, signInTo, test, whoamiOfKey } from '../../src/dm';
import { apiKey, expectedEnvironment, otherApiKey, ownerAccount, PORTAL_URL } from '../../src/env';
import type { WhoamiWorkspace } from '../../src/types';
// The workspace switcher, as a person (DM_OWNER) who belongs to both the
// workspace of DM_API_KEY ("A") and that of DM_API_KEY_B ("B"): the
// chrome names the environment you are in, and switching lands on the
// dashboard of the other workspace.
//
// The test signs the person in on a session of its own: switching moves
// that session to another workspace, so it must never be the run's
// shared owner session. It switches back to A at the end, so the next
// sign-in lands where it did before (sign-in picks the workspace used
// last).
test.skip(!ownerAccount() || !otherApiKey(), 'needs DM_OWNER and DM_API_KEY_B (README, "One workspace per environment")');
/** The sidebar's switcher trigger: [ENV chip] name / "dispute.markets · <Role>". */
const trigger = (page: Page) => page.getByTestId('workspace-switcher');
/** `#d97706` → `rgb(217, 119, 6)`, as computed styles report colours. */
function rgb(hex: string): string {
const n = parseInt(hex.slice(1), 16);
return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
}
/** Matches a tab title that starts with `label · ` (case-insensitive). */
function titlePrefix(label: string): RegExp {
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp('^' + escaped + ' · ', 'i');
}
/** The chrome shows `w`: its name and environment chip on the switcher, and the stripe in its colour (none without a label). */
async function expectChrome(page: Page, w: WhoamiWorkspace): Promise<void> {
await expect(trigger(page)).toContainText(w.name);
const stripe = page.getByTestId('environment-stripe');
if (w.environment_label) {
await expect(trigger(page).getByTestId('env-chip')).toHaveText(w.environment_label, { ignoreCase: true });
await expect(stripe).toBeVisible();
if (w.environment_color) await expect(stripe).toHaveCSS('background-color', rgb(w.environment_color));
// The tab title starts with the environment: "Staging · Disputes · dispute.markets".
await expect(page).toHaveTitle(titlePrefix(w.environment_label));
} else {
await expect(trigger(page).getByTestId('env-chip')).toHaveCount(0);
await expect(stripe).toHaveCount(0);
}
}
/** Switches through the menu; the portal reloads on the dashboard of the chosen workspace. */
async function switchTo(page: Page, w: WhoamiWorkspace): Promise<void> {
await trigger(page).click();
const row = page.locator(`[data-testid="workspace-switcher-row"][data-workspace-id="${w.id}"]`);
await expect(row, `${w.name} (${w.id}) is in the switcher: is DM_OWNER a member of it?`).toBeVisible();
await row.click();
await page.waitForURL(/\/dashboard(\?|$)/);
await expect(trigger(page)).toContainText(w.name);
}
/** The workspace this browser's session acts in, as the API sees it. */
async function sessionWorkspace(context: BrowserContext): Promise<string | undefined> {
const res = await context.request.get(`${PORTAL_URL}/api/proxy/auth/me`);
expect(res.ok(), `GET /v1/auth/me answered ${res.status()}`).toBe(true);
return ((await res.json()) as { workspace?: { id: string } | null }).workspace?.id;
}
test('the switcher names the environment, and switching lands on the dashboard of the other workspace', async ({
browser,
playwright,
}, testInfo) => {
const [{ workspace: a }, { workspace: b }] = await Promise.all([
whoamiOfKey(playwright, apiKey()),
whoamiOfKey(playwright, otherApiKey()!),
]);
expect(b.id, 'DM_API_KEY and DM_API_KEY_B are keys of the same workspace').not.toBe(a.id);
const expected = expectedEnvironment();
if (expected !== undefined) expect((a.environment_label ?? '').toLowerCase()).toBe((expected ?? '').toLowerCase());
const stateFile = testInfo.outputPath('person.json');
await signInTo(browser, ownerAccount()!, stateFile, { checkRole: false });
const context = await browser.newContext({ baseURL: PORTAL_URL, storageState: stateFile });
const page = await context.newPage();
try {
// Start in A, whichever workspace the sign-in landed in.
await page.goto('/en/dashboard');
if ((await sessionWorkspace(context)) !== a.id) await switchTo(page, a);
await expectChrome(page, a);
// Switch from a deep page: ids belong to one workspace, so the switch
// lands on B's dashboard, never on the same URL.
await page.goto('/en/disputes');
await expectChrome(page, a);
await switchTo(page, b);
await expect(page).toHaveURL(/\/en\/dashboard(\?|$)/);
await expectChrome(page, b);
// Nothing of A's environment stays on screen.
if (a.environment_label && a.environment_label !== b.environment_label) {
await expect(page).not.toHaveTitle(titlePrefix(a.environment_label));
}
expect(await sessionWorkspace(context)).toBe(b.id);
} finally {
// Back to A, so the next sign-in lands where it did before; then sign out.
await context.request
.post(`${PORTAL_URL}/api/auth/switch-workspace`, { data: { workspace_id: a.id } })
.catch(() => undefined);
await context.request.post(`${PORTAL_URL}/api/auth/sign-out`).catch(() => undefined);
await context.close();
}
});Long-running flows
Flow profiles switch operations into other flows: a report that takes three hours, a ruling that is always lost, a webhook that arrives twice. The waits they add are scheduled jobs, database rows the test moves forward instead of sleeping. This is how the fixture handles them.
The fixture's default is fp_platform
Every dm.api call sends X-DM-Flow: fp_platform and every browser context gets the dm_flow=fp_platform cookie, so a workspace default someone sets in Settings › Flows never reaches CI. A test that never calls dm.useFlowgets today's behaviour:
import { expect, test } from '../../src/dm';
import { FLOW_APPLIED_HEADER, PLATFORM_FLOW } from '../../src/flows';
import type { CreatedReport } from '../../src/types';
// (8) The default path. A test that never calls `dm.useFlow` still sends
// `X-DM-Flow: fp_platform`, so whatever default a person set in Settings >
// Flows, the API applies today's behaviour: a report is built during the
// request, exactly as before flows existed.
test('without useFlow the API applies fp_platform and builds a report synchronously', async ({ dm }) => {
expect(dm.flowRef).toBe(PLATFORM_FLOW);
const mark = await dm.mark();
const res = await dm.api.fetch<CreatedReport>('POST', '/v1/reports', {
data: { name: `default-path ${dm.correlationId}`, kind: 'disputes' },
});
expect(res.status).toBe(201);
dm.onCleanup(() => dm.api.delete(`/v1/reports/${res.body.id}`));
expect(res.headers[FLOW_APPLIED_HEADER.toLowerCase()]).toBe(PLATFORM_FLOW);
expect(res.body).toMatchObject({
status: 'done',
ready_at: null,
flow: { profile_id: PLATFORM_FLOW },
});
const [created] = await dm.waitFor({
after_seq: mark,
correlation_id: dm.correlationId,
action: 'report.create',
});
expect(created).toMatchObject({ outcome: 'success', target: { id: res.body.id } });
// fp_platform changes nothing, so the row carries no flow.
expect(created.flow ?? null).toBeNull();
// Nothing was scheduled for it.
expect(await dm.scheduledJobs({ target_id: res.body.id })).toHaveLength(0);
});dm.flowProfile: hashed names, never deleted
dm.flowProfile(name, variants) creates <name>-<hash8>, where the hash is of the variants, and returns its pinned ref (fp_…@N). The API answers 200 for an identical body, so every worker and every run may call it. Two branches with different variants get two profiles and never edit each other's. Profiles are never deleted: they cost nothing, and the API archives the ones unused for 30 days.
export function profileName(name: string, variants: FlowVariants): string {
if (!NAME_PATTERN.test(name)) {
throw new Error(`flow profile name ${JSON.stringify(name)}: use 1-55 characters of a-z 0-9 -`);
}
const hash8 = createHash('sha256').update(canonicalJson(variants)).digest('hex').slice(0, 8);
return `${name}-${hash8}`;
}export function ensureFlowProfile(api: DmApi, name: string, variants: FlowVariants): Promise<FlowProfileRef> {
const full = profileName(name, variants);
let pending = profiles.get(full);
if (!pending) {
pending = api
.post<FlowProfile>('/v1/flow-profiles', { name: full, variants })
.then((p) => ({ id: p.id, version: p.current_version, ref: p.ref }));
// A failed create is not cached: the next caller tries again.
pending.catch(() => profiles.delete(full));
profiles.set(full, pending);
}
return pending;
}dm.useFlow: owner only
dm.useFlow(ref) pins a profile for the rest of the test: the X-DM-Flow header on dm.api and the dm_flowcookie in the test's browser. Only the owner may choose a flow, so it works in API specs (a key the owner minted with the E2E preset) and in ownerTest browsers, and throws in a synthetic-account browser, where the portal would ignore the cookie.
async useFlow(ref: string): Promise<void> {
assertPinnedRef(ref);
if (this.browser?.session === 'synthetic_account') {
throw new Error(
'dm.useFlow in a synthetic-account browser: the portal ignores dm_flow for anyone but the owner ' +
'(docs/api/flows, "Only the owner opts in"). Assign the account a profile in worker setup ' +
'(dmWorker.assignFlow), or test this variant in an ownerTest context.',
);
}
this.api.setDefaultHeader(FLOW_HEADER, ref);
if (this.browser) {
await this.browser.context.addCookies([{ name: FLOW_COOKIE, value: ref, url: PORTAL_URL }]);
}
}Owner browsers: ownerTest and DM_OWNER
Browser specs that choose a flow run as the workspace owner. Set DM_OWNER ({"identifier","password","totp_secret"}); global setup signs the owner in once per run and every ownerTest worker reuses that session. Without it those specs skip. Keep everything else in uiTest, as synthetic accounts.
export const ownerTest = test.extend<{ owner: APIRequestContext }>({
// eslint-disable-next-line no-empty-pattern
storageState: async ({}, use) => {
if (!existsSync(OWNER_STATE_FILE)) throw new Error('ownerTest needs DM_OWNER (see the README).');
await use(OWNER_STATE_FILE);
},
dm: async ({ dm, context }, use) => {
(dm as DmFixture).bindBrowser(context, 'owner');
await use(dm);
},
// The owner's session as an API client for routes no key may call
// (`owner.post('team/synthetic-accounts', …)` is `POST /v1/team/synthetic-accounts`).
owner: async ({ playwright }, use) => {
const owner = await ownerRequest(playwright);
try {
await use(owner);
} finally {
await owner.dispose();
}
},
});dm.fastForward: no sleeps, a 60 s ceiling
dm.fastForward(filter) moves the matching jobs to now (or { to_remaining: 'PT1H' }, or { by: 'PT2H' }), and the resource's timeline (ready_at, respond_by) moves with them. It then waits until every moved job is terminal: one wait: truecall (the API answers within 25 s), then job waits, up to a 60 second ceiling, after which it throws with the job's state, run_at and attempts.
export async function fastForward(
api: DmApi,
filter: ScheduledJobFilter,
options: FastForwardOptions = {},
): Promise<FastForwardResult> {
const wait = options.wait ?? true;
const deadline = Date.now() + FAST_FORWARD_WAIT_CEILING_MS;
const res = await api.post<ScheduledJobBulkResult>('/v1/scheduled-jobs/fast-forward', {
...filter,
...(options.to_remaining ? { to_remaining: options.to_remaining } : {}),
...(options.by ? { by: options.by } : {}),
wait,
});
if (!wait) return { ...res, jobs: [] };
const jobs: ScheduledJob[] = [];
for (const m of res.matched) {
let job = await api.get<ScheduledJob>(`/v1/scheduled-jobs/${m.id}`);
while (!isTerminal(job.state) && Date.now() < deadline) {
const wait_s = Math.max(1, Math.min(25, Math.ceil((deadline - Date.now()) / 1000)));
job = await api.get<ScheduledJob>(`/v1/scheduled-jobs/${m.id}`, { wait_s });
}
if (!isTerminal(job.state)) {
throw new Error(
`fastForward: job ${job.id} (${job.kind} on ${job.target?.id ?? '-'}) is still ${job.state} after ` +
`${FAST_FORWARD_WAIT_CEILING_MS / 1000} s; run_at ${job.run_at}, attempts ${job.attempts}`,
);
}
jobs.push(job);
}
return { ...res, jobs };
}The three-hour report from the portal, in the owner's browser:
import { expect, hasOwner, ownerTest as test } from '../../src/dm';
import type { Report } from '../../src/types';
test.skip(!hasOwner(), 'set DM_OWNER to run the owner browser specs');
const HOUR_MS = 3600_000;
// (1) A report that takes three hours, through the portal (docs/12 §0).
// Choosing a flow is owner-only, so this runs in the owner's browser. The
// wait is a database row: the test moves it forward instead of sleeping,
// and the portal shows the report ready.
test('a three-hour report is queued in the portal, fast-forwarded, then downloadable', async ({ page, dm }) => {
const slow = await dm.flowProfile('report-3h', {
'report.delivery': { mode: 'async', delay_s: 3 * 3600 },
'report.download_url': { ttl_s: 60 },
});
await dm.useFlow(slow.ref); // X-DM-Flow on dm.api, dm_flow in this browser
const mark = await dm.mark();
const name = `3h report ${dm.correlationId}`.slice(0, 100);
await page.goto('/en/reports');
await page.getByRole('button', { name: 'Create report' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name').fill(name);
await dialog.getByTestId('report-create-submit').click();
const [created] = await dm.waitFor({ after_seq: mark, correlation_id: dm.correlationId, action: 'report.create' });
expect(created).toMatchObject({ outcome: 'success', request: { source: 'portal' }, flow: { profile_id: slow.ref } });
const id = created.target!.id!;
dm.onCleanup(() => dm.api.delete(`/v1/reports/${id}`));
const row = page.getByTestId(`report-row-${id}`);
await expect(row).toHaveAttribute('data-status', 'queued');
await expect(row.getByText('Queued')).toBeVisible();
const queued = await dm.api.get<Report>(`/v1/reports/${id}`);
expect(Date.parse(queued.ready_at!) - Date.now()).toBeGreaterThan(2.9 * HOUR_MS);
// Shifts the job and the report's ready_at together, then waits for the build.
const moved = await dm.fastForward({ target_id: id });
expect(moved.jobs.map((j) => [j.kind, j.state])).toEqual([['report.build', 'succeeded']]);
const done = await dm.api.get<Report>(`/v1/reports/${id}`);
expect(done.status).toBe('done');
expect(Date.parse(done.ready_at!)).toBeLessThan(Date.parse(queued.ready_at!) - 2.9 * HOUR_MS);
expect(done.fast_forwarded_by).toBeTruthy();
// The page polls while something is pending.
await expect(row).toHaveAttribute('data-status', 'done');
await expect(page.getByTestId(`report-download-${id}`)).toBeEnabled();
});examples/e2e-playwright/tests/ui/22-deadline-countdown.spec.ts · show the file
import { expect, hasOwner, ownerTest as test } from '../../src/dm';
import type { Dispute, DisputePage } from '../../src/types';
test.skip(!hasOwner(), 'set DM_OWNER to run the owner browser specs');
const HOUR_MS = 3600_000;
// (7) "One hour before the deadline" without waiting days: the dispute's
// response window is a scheduled job; `to_remaining: 'PT1H'` moves its
// deadline (and the job) to one hour from now. The browser shows the
// countdown in the time zone set by `dm.prefs`.
test('a deadline moved to one hour out shows the T-1 h countdown in the chosen time zone', async ({ page, context, dm }) => {
const windows = await dm.flowProfile('evidence-window-3d', { 'lifecycle.windows': { evidence_s: 3 * 24 * 3600 } });
await dm.useFlow(windows.ref);
const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
const list = await dm.api.get<DisputePage>('/v1/disputes', {
generate_job_id: job.id,
status: 'evidence_required',
page_size: 1,
});
expect(list.items).toHaveLength(1);
const id = list.items[0].id;
const before = await dm.api.get<Dispute>(`/v1/disputes/${id}`);
expect(before.lifecycle_mode).toBe('scheduled');
// Leave one hour on the clock; the job stays scheduled, so no wait.
const moved = await dm.fastForward({ target_id: id, kind: ['dispute.deadline'] }, { to_remaining: 'PT1H', wait: false });
expect(moved.count).toBe(1);
const after = await dm.api.get<Dispute>(`/v1/disputes/${id}`);
const left = Date.parse(after.respond_by) - Date.now();
expect(left).toBeGreaterThan(0.9 * HOUR_MS);
expect(left).toBeLessThanOrEqual(HOUR_MS);
await dm.prefs(context, { tz: 'Asia/Tokyo' });
await page.goto(`/en/disputes/${id}`);
const deadline = page.getByTestId('dispute-deadline');
await expect(deadline).toContainText(/(59 min \d{2} s|1 h 0 min) left/);
await expect(deadline.getByTestId('deadline-zone')).toHaveAttribute('title', 'Asia/Tokyo');
});to_remaining: a deadline moved to one hour out, with the countdown in the chosen time zone.
Wait for the domain action, not the clock
After a fast-forward, assert on what the job did: the audit row it wrote (report.build, dispute.rule, evidence.process…), filtered by your mark and correlation id, or the job itself. Work a job does is a system row under the correlation id of the request that scheduled it, and its flow block names the profile and each variant's source.
dm.sink(): webhooks scoped to the run
dm.sink()creates a dev webhook sink and a webhook on it, both scoped to the run's correlation prefix (filter: {correlation_prefix}), and deletes both after the test. Another run's deliveries never reach it. Filter webhook_sink.receive by correlation_id: dm.correlationId to count only what your test caused.
async sink(options: SinkOptions = {}): Promise<RunSink> {
const filter = { correlation_prefix: `${RUN_ID}:` };
const sink = await this.api.post<WebhookSink>('/v1/dev/webhook-sinks', { filter });
// Free the sink's slot (10 live per workspace) as soon as the test ends.
this.onCleanup(() => this.api.delete(`/v1/dev/webhook-sinks/${sink.id}`));
const webhook = await this.api.post<Webhook>('/v1/webhooks', {
url: sink.url,
...(options.events ? { events: options.events } : {}),
filter,
});
this.onCleanup(() => this.api.delete(`/v1/webhooks/${webhook.id}`));
return { sink, webhook };
}import { expect, test } from '../../src/dm';
import type { DisputePage } from '../../src/types';
// (2) A scheduled lifecycle: the issuer always rules against you, a day
// after the dispute went under review, and every transition fires a
// webhook. The test skips the day with fast-forward and catches the
// webhook in its own run-scoped sink, without a public receiver.
test('a scheduled always_lost ruling fires one webhook into the run-scoped sink', async ({ dm }) => {
const lost = await dm.flowProfile('ruling-lost-1d', {
'lifecycle.ruling': { mode: 'always_lost' },
'lifecycle.review': { duration_s: 86_400 },
'lifecycle.webhooks': { enabled: true },
});
await dm.useFlow(lost.ref);
const { sink } = await dm.sink({ events: ['dispute.status_changed'] });
// The profile is stamped on every dispute generate creates.
const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
const page = await dm.api.get<DisputePage>('/v1/disputes', {
generate_job_id: job.id,
status: 'under_review',
page_size: 1,
});
expect(page.items, 'the seeded job has a dispute under review').toHaveLength(1);
const dispute = page.items[0];
// The ruling is a scheduled job, a day out.
const [rule] = await dm.scheduledJobs({ target_id: dispute.id, kind: 'dispute.rule' });
expect(rule).toMatchObject({ state: 'scheduled', correlation_id: dm.correlationId });
expect(Date.parse(rule.run_at) - Date.now()).toBeGreaterThan(23 * 3600_000);
const mark = await dm.mark();
const moved = await dm.fastForward({ target_id: dispute.id, kind: ['dispute.rule'] });
expect(moved.jobs.map((j) => [j.kind, j.state])).toEqual([['dispute.rule', 'succeeded']]);
const [ruled] = await dm.waitFor({ after_seq: mark, action: 'dispute.rule', target_id: dispute.id });
expect(ruled).toMatchObject({
outcome: 'success',
actor: { type: 'system' },
request: { correlation_id: dm.correlationId },
changes: { status: { from: 'under_review', to: 'lost' } },
flow: { profile_id: lost.ref },
});
// Only keys that changed this action are listed, each with its scope.
expect(ruled.flow?.variants?.['lifecycle.ruling']).toEqual({ source: 'stamped', value: { mode: 'always_lost' } });
// Exactly one delivery reached this test's sink: the ruling.
const filter = { after_seq: mark, action: 'webhook_sink.receive', target_id: sink.id, correlation_id: dm.correlationId } as const;
const [received] = await dm.waitFor(filter, { timeout: 30_000 });
expect(received.metadata).toMatchObject({ signature_valid: true, event: 'dispute.status_changed' });
await dm.waitFor({ after_seq: mark, action: 'webhook_delivery.send', correlation_id: dm.correlationId });
expect(await dm.events(filter)).toHaveLength(1);
});Per-worker synthetic accounts with assigned profiles
A synthetic account cannot choose a flow; the owner assigns it one (see per-synthetic-account profiles). In the example, test.use({ accountSlot, accountFlow }) pins a spec's worker to one account and makes the owner assign it a profile in worker setup (dmWorker.assignFlow), restoring the previous assignment when the worker ends. Workers signed in as different accounts then run different variants at once. Never change an assignment inside a test: concurrent runs share the account pool. Run the specs that reassign accounts in one CI job only (--grep-invert @account-profiles in the others).
export interface UiWorkerOptions {
/**
* Which DM_SYNTHETIC_ACCOUNTS entry this worker signs in as. Default:
* the worker's index (wrapping). Set it with `test.use` when a spec needs
* two specific accounts at once.
*/
accountSlot: number | undefined;
/**
* The flow profile the owner assigns to this worker's account before it
* signs in, restored when the worker ends: `fp_platform`, `fp_…@N`,
* `{name, variants}` (created like `dm.flowProfile`, then pinned) or
* null (the workspace default). `undefined` (the default) leaves the
* assignment alone. A worker option, so it changes only between
* workers, never inside a test.
*/
accountFlow: string | null | { name: string; variants: FlowVariants } | undefined;
}examples/e2e-playwright/tests/ui/23-account-profile-failing.spec.ts · show the file
import { expect, hasOwner, uiTest as test } from '../../src/dm';
import { syntheticAccounts } from '../../src/env';
import { disputeReadyToChallenge, SCHEME_DOWN, submitChallenge } from './account-profiles';
test.skip(syntheticAccounts().length < 2 || !hasOwner(), 'needs DM_OWNER and two DM_SYNTHETIC_ACCOUNTS');
// (9a) Per-account flow profiles. The owner assigns this worker's account
// (the first in DM_SYNTHETIC_ACCOUNTS) a profile that fails every
// challenge, in worker setup, and the worker restores the old assignment
// when it ends. A synthetic account cannot choose a flow itself (its
// dm_flow cookie is ignored); it gets what the owner assigned. The other
// half (23-account-profile-platform) runs at the same time as another
// account and sees the normal path. Tagged @account-profiles: concurrent
// CI jobs share the account pool, so only one of them runs the pair
// (`--grep-invert @account-profiles` in the others).
test.use({ accountSlot: 0, accountFlow: SCHEME_DOWN });
test('@account-profiles an account assigned a failing profile sees the scheme outage', async ({ page, dm }) => {
const { ref } = await dm.flowProfile(SCHEME_DOWN.name, SCHEME_DOWN.variants);
const disputeId = await disputeReadyToChallenge(dm);
const mark = await dm.mark();
await submitChallenge(page, disputeId);
await expect(page.getByText('The card scheme is unavailable; the action was not applied. Retry later.')).toBeVisible();
const [row] = await dm.waitFor({ after_seq: mark, correlation_id: dm.correlationId, action: 'dispute.challenge' });
expect(row).toMatchObject({
outcome: 'failed',
error_code: 'simulated_scheme_unavailable',
actor: { type: 'synthetic_account' },
request: { source: 'portal' },
target: { id: disputeId },
flow: { profile_id: ref, profile_source: 'account' },
});
expect((await dm.api.get<{ status: string }>(`/v1/disputes/${disputeId}`)).status).toBe('evidence_required');
});examples/e2e-playwright/tests/ui/account-profiles.ts · show the file
import type { Page } from '@playwright/test';
import { Dm, expect } from '../../src/dm';
import { uploadEvidence } from '../../src/evidence';
import type { FlowVariants } from '../../src/flows';
import type { DisputePage } from '../../src/types';
/**
* Shared by the two per-account profile specs (23-…). Each file pins its
* worker to one synthetic account with a worker option, so the two run
* in two workers at the same time, each signed in as its own account.
*/
/** Every challenge fails before anything is written, as a scheme outage. */
export const SCHEME_DOWN = {
name: 'challenge-scheme-down',
variants: {
'dispute.action_failure': { actions: ['challenge'], mode: 'always', error: 'scheme_unavailable' },
} satisfies FlowVariants,
};
/** A dispute awaiting evidence with one available file, made with the key under fp_platform. */
export async function disputeReadyToChallenge(dm: Dm): Promise<string> {
const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
const list = await dm.api.get<DisputePage>('/v1/disputes', {
generate_job_id: job.id,
status: 'evidence_required',
page_size: 1,
});
expect(list.items).toHaveLength(1);
await uploadEvidence(dm, list.items[0].id);
return list.items[0].id;
}
export async function submitChallenge(page: Page, disputeId: string): Promise<void> {
await page.goto(`/en/disputes/${disputeId}/challenge`);
await page.getByRole('button', { name: /^Submit challenge/ }).click();
}Restricted roles and per-test variants.When a restricted-role browser needs a variant that changes from test to test on the same account: create the resource with the owner's key under X-DM-Flow so the variant is stamped on it, then drive it from the restricted browser; use expire-sessions for expiry; and test request-scope failure UX (dispute.action_failure, api.*) in an ownerTest context.
The waiter budget
Long waits hold a slot on an API server. There are two kinds:
| Wait | Limit per API server | Over it |
|---|---|---|
GET /v1/audit-events/wait | 12 concurrent per caller and 12 per workspace | 429 too_many_waits, Retry-After: 1 |
GET /v1/scheduled-jobs/:id?wait_s=, fast-forward with wait: true | 8 concurrent per caller | 429 too_many_waits |
The fixture holds at most one wait per test at a time: waitFor and expectSequence make one call after another, and fastForward waits on its jobs one by one. With at most 8 workers per key that stays inside both limits. Do not start waits in parallel inside a test (Promise.all over several waitFors); wait once with a broader filter, or one after another. The fixture retries a 429 after Retry-After.
Expiry without sleeps
'report.download_url': { mode: 'expired' }(orevidence.download_url) hands out links signed in the past, which storage refuses at once.dm.expireSessions(accountId)(POST /v1/team/synthetic-accounts/:id/expire-sessions) sends a synthetic account back to sign-in on its next request; a profile withsession.max_ageassigned to the account makes every session expire after 60 to 3,600 seconds.
import { expect, test } from '../../src/dm';
import type { CreatedReport, SignedDownloadUrl } from '../../src/types';
// (6, API half) Expiry without sleeping: a profile that hands out download
// links signed in the past. Storage refuses them at once, so the client's
// "link expired, fetch a new one" path runs in milliseconds.
test('an expired download link is refused by storage at once; a fresh one works', async ({ dm }) => {
const report = await dm.api.post<CreatedReport>('/v1/reports', { name: `expiry ${dm.correlationId}`, kind: 'disputes' });
dm.onCleanup(() => dm.api.delete(`/v1/reports/${report.id}`));
expect(report.status).toBe('done');
const expired = await dm.flowProfile('download-expired', { 'report.download_url': { mode: 'expired' } });
await dm.useFlow(expired.ref);
const dead = await dm.api.get<SignedDownloadUrl>(`/v1/reports/${report.id}/download-url`);
expect(Date.parse(dead.expires_at)).toBeLessThan(Date.now());
expect((await fetch(dead.url)).status).toBe(403);
// Back to today's behaviour for the same report: a 300 s link that works.
await dm.useFlow('fp_platform');
const fresh = await dm.api.get<SignedDownloadUrl>(`/v1/reports/${report.id}/download-url`);
expect(Date.parse(fresh.expires_at)).toBeGreaterThan(Date.now());
const file = await fetch(fresh.url);
expect(file.status).toBe(200);
expect(await file.text()).toContain(',');
});examples/e2e-playwright/tests/ui/21-session-expiry.spec.ts · show the file
import { CORRELATION_COOKIE } from '../../src/correlation';
import { expect, hasOwner, ownerTest as test, signInTo } from '../../src/dm';
import { PORTAL_URL } from '../../src/env';
test.skip(!hasOwner(), 'set DM_OWNER to run the owner browser specs');
// (6, browser half) Session expiry without waiting an hour: the owner's
// key expires every session of a synthetic account, and the portal sends
// that browser back to sign-in on its next page. The account is a
// throwaway the owner creates for this test, so no other test (or
// concurrent run) loses its session.
test('expire-sessions sends a synthetic account back to sign-in', async ({ browser, owner, dm, correlationId }, testInfo) => {
const created = await owner.post('team/synthetic-accounts', {
data: { label: `expiry ${correlationId}`.slice(0, 80), role: 'read_only', notes: 'e2e: deleted by the test' },
});
expect(created.status()).toBe(201);
const { account, credentials } = (await created.json()) as {
account: { id: string };
credentials: { email: string; password: string };
};
try {
const file = testInfo.outputPath('expiry-account.json');
await signInTo(browser, { identifier: credentials.email, password: credentials.password }, file, { checkRole: false });
const context = await browser.newContext({ baseURL: PORTAL_URL, storageState: file });
await context.addCookies([{ name: CORRELATION_COOKIE, value: correlationId, url: PORTAL_URL }]);
const page = await context.newPage();
await page.goto('/en/disputes');
await expect(page).toHaveURL(/\/en\/disputes/);
const mark = await dm.mark();
const res = await dm.expireSessions(account.id);
expect(res.id).toBe(account.id);
await page.goto('/en/transactions');
await expect(page).toHaveURL(/\/sign-in/);
await expect(page.getByLabel('Username or email')).toBeVisible();
const [expired] = await dm.waitFor({
after_seq: mark,
correlation_id: correlationId,
action: 'synthetic_account.expire_sessions',
});
expect(expired).toMatchObject({ outcome: 'success', actor: { type: 'api_key' }, target: { id: account.id } });
await context.close();
} finally {
await owner.delete(`team/synthetic-accounts/${account.id}`);
}
});A real multi-hour wait, in a nightly job
To prove a long job really runs on its own (through deploys and restarts), skip the fast-forward and wait with job waits. The spec skips unless DM_REAL_WAIT=1; DM_REAL_WAIT_S shortens the two-hour default. Run it from a nightly job, not on every push.
import { expect, test } from '../../src/dm';
import type { CreatedReport, Report, ScheduledJob } from '../../src/types';
// The real wait, for customers who want proof that a long job survives
// on its own (deploys, restarts) instead of being fast-forwarded: a report
// that is ready only after DM_REAL_WAIT_S seconds (default two hours),
// waited for with job waits, never sleeps. Skipped by default; run it by
// hand or from a nightly job:
//
// DM_REAL_WAIT=1 DM_API_KEY=pctk_live_… npx playwright test --project=api tests/api/nightly-real-wait.spec.ts
//
// DM_REAL_WAIT_S shortens the wait (at least 60 s).
const enabled = process.env.DM_REAL_WAIT === '1';
const delayS = Math.max(60, Number(process.env.DM_REAL_WAIT_S ?? 2 * 3600));
/** The API's own lateness budget on top of the delay (a due job starts within seconds; 15 s during a rollout). */
const SLACK_S = 300;
test.skip(!enabled, 'nightly only: set DM_REAL_WAIT=1 (and DM_REAL_WAIT_S to shorten the wait)');
test(`a report delayed ${delayS} s is built for real, with no fast-forward`, async ({ dm }) => {
test.setTimeout((delayS + SLACK_S + 120) * 1000);
const slow = await dm.flowProfile(`report-real-wait-${delayS}s`, { 'report.delivery': { mode: 'async', delay_s: delayS } });
await dm.useFlow(slow.ref);
const report = await dm.api.post<CreatedReport>('/v1/reports', { name: `real wait ${dm.correlationId}`, kind: 'disputes' });
dm.onCleanup(() => dm.api.delete(`/v1/reports/${report.id}`));
expect(report.status).toBe('queued');
const readyAt = Date.parse(report.ready_at!);
const [job] = await dm.scheduledJobs({ target_id: report.id, kind: 'report.build' });
expect(job.state).toBe('scheduled');
// One job wait at a time (≤25 s each): the API holds no connection while it waits.
let current: ScheduledJob = job;
const deadline = readyAt + SLACK_S * 1000;
while (!['succeeded', 'failed', 'canceled'].includes(current.state) && Date.now() < deadline) {
current = await dm.api.get<ScheduledJob>(`/v1/scheduled-jobs/${job.id}`, { wait_s: 25 });
}
expect(current).toMatchObject({ state: 'succeeded', fast_forwarded_by: null });
expect(Date.parse(current.started_at!)).toBeGreaterThanOrEqual(readyAt - 1000);
const done = await dm.api.get<Report>(`/v1/reports/${report.id}`);
expect(done).toMatchObject({ status: 'done', fast_forwarded_by: null });
});Cleaning up a run
Global teardown cancels every job the run left scheduled (a report never fast-forwarded, a deadline still ticking), by the run's correlation prefix, so nothing the run started fires after it. Profiles stay.
examples/e2e-playwright/src/global-teardown.ts · show the file
import { request } from '@playwright/test';
import { DmApi } from './api';
import { CORRELATION_ID_HEADER } from './correlation';
import { API_URL, RUN_ID } from './env';
import { cancelRun } from './flows';
/**
* Cancels every scheduled job this run left behind (a report still
* queued, a deadline that was never fast-forwarded), so nothing the run
* started fires after it. Flow profiles stay: they are content-addressed
* and archived by the API after 30 days unused.
*/
export default async function globalTeardown(): Promise<void> {
const key = process.env.DM_API_KEY;
if (!key) return;
const ctx = await request.newContext({
baseURL: API_URL,
extraHTTPHeaders: { Authorization: `Bearer ${key}`, [CORRELATION_ID_HEADER]: `${RUN_ID}:teardown` },
});
try {
const canceled = await cancelRun(new DmApi(ctx));
if (canceled > 0) console.log(`global teardown: canceled ${canceled} scheduled job(s) of run ${RUN_ID}`);
} finally {
await ctx.dispose();
}
}Test that your agent cleans up before creating
An agent that creates reports should tidy up after itself, and when a workspace allows only so many reports at once it has to delete one before it can create the next. The report.limits flow variant sets that limit, so a test can make your agent run into it and check what the agent does next.
Counted per run, set only by the owner
- Per run.The limit counts the live reports of the caller's run: the part of
X-Correlation-Idbefore the first:(every report has it asrun_key). Two CI jobs with differentDM_RUN_IDs never take each other's places and are never offered each other's reports to delete. The spec below gives itself its own run,<RUN_ID>-report-limit, so that reports other specs of the same run have open never count against it. A request without a correlation id counts in the rundefault. - What counts. Reports that are
queued,running,doneorfailed. Expired and deleted reports do not, and a delete frees its place at once. - Owner-only to set. Only the workspace owner chooses flows: in a flow profile (the owner, or a key the owner minted with the E2E testingpreset), as the workspace default (the owner's session only), or with the owner's
X-DM-Flow. Everyone else only meets the limit, through the workspace default. - Unset by default. Without the variant there is no limit and nothing changes. Deleting a report (
DELETE /v1/reports/:id,report.write) works in every workspace.
The profile is content-addressed and created once; the test pins it:
// Owner-only to set (a key the owner minted holds flow.write).
const limited = await dm.flowProfile('report-limit-2', { 'report.limits': { max_live: 2 } });
await dm.useFlow(limited.ref);The 409 your agent gets
At the limit, POST /v1/reports answers 409 report_limit_reached. It has no Retry-After: waiting never frees a place, deleting does.
{"status_code":409,"error":"Conflict","code":"report_limit_reached",
"message":"This run already has 2 live reports (limit 2). Delete one, then retry.",
"request_id":"req_…",
"details":{"limit":2,"live_count":2,"scope":"run","run_key":"run-20261029-ab12cd-report-limit",
"counted_statuses":["queued","running","done","failed"],
"oldest":[{"id":"rpt_…","name":"…","status":"done","created_at":"…"}],
"delete":"DELETE /v1/reports/{id}",
"list":"/v1/reports?run_key=run-20261029-ab12cd-report-limit&order=created_asc&limit=5"}}| details field | Meaning |
|---|---|
limit | The max_live in force for this request. |
live_count | How many live reports the run has now. |
scope | Always run: what is counted is the caller's run. |
run_key | The run that is full: your correlation id's prefix, or default. |
counted_statuses | The statuses that take a place. |
oldest | Up to 5 of the run's live reports, oldest first (id, name, status, created_at). Only your own run's reports, so deleting from this list never touches another run. |
delete | The route that frees a place. |
list | The same list as a URL; the Link: <…>; rel="oldest-deletable" header carries it too. |
The recovery is: delete details.oldest[0].id, then create again with a new Idempotency-Key. A delete answers 200 with a tombstone (deleted: true, status_at_delete, canceled_job_id). Deleting again is safe: 200 with already_deleted: true. Reading a deleted report answers 410 report_deleted, so your agent can tell “I deleted it” apart from a wrong id (404 report_not_found).
Pointing it at your agent
The spec plays the agent itself. To test your agent, keep the setup and the assertions and swap the middle:
- Create the profile, take a
mark(), and start your agent with the test's correlation id (dm.correlationId) and the pinned profile (X-DM-Flow: fp_…@N) on every API call it makes, and a key the owner minted. - Give it a task that needs one more report than the limit allows, so it hits the 409.
- Assert from the audit trail, filtered by your correlation id, that its next actions are a
report.deleteof a report fromdetails.oldestand then a successfulreport.create. UseexpectSequencewithmode: 'exact'and anactionfilter: an agent that retries blindly writes a second failedreport.createand fails the sequence, and so does one that deletes more than it needs to. - Check that every row carries the limit (
flow.variants['report.limits']), so you know the limit really was in force, and clean up every report the run made.
import { randomUUID } from 'node:crypto';
import { assertCorrelationId } from '../../src/correlation';
import { ApiError, expect, test as base } from '../../src/dm';
import { RUN_ID } from '../../src/env';
import type {
CreatedReport,
CreateReportRequest,
DeletedReport,
DeleteReportParams,
ErrorBody,
Report,
ReportDeletedDetails,
ReportLimitReachedDetails,
ReportListQuery,
} from '../../src/types';
// `report.limits {max_live}` counts the live reports of one *run*: the part
// of the correlation id before the first `:`. Every test of a run shares
// RUN_ID, so a report another spec has open at the same moment would take
// one of this test's two places. This spec gets a run of its own,
// `<RUN_ID>-report-limit`, derived from the run id: two CI jobs (two
// RUN_IDs) still never count, or delete, each other's reports.
const RUN_KEY = `${RUN_ID}-report-limit`;
const test = base.extend({
// Playwright requires a destructuring pattern as a fixture's first argument.
// eslint-disable-next-line no-empty-pattern
correlationId: async ({}, use, testInfo) => {
await use(assertCorrelationId(`${RUN_KEY}:${testInfo.testId}`));
},
});
// (9) An agent at the workspace's report limit must clean up before it
// creates: hit 409 `report_limit_reached`, delete one of the run's own
// oldest reports, then retry with a new Idempotency-Key. The audit trail
// proves the order: refused create, delete, successful create.
test('at the report limit: 409, delete the oldest of the run, retry', async ({ dm }) => {
const runKey = dm.correlationId.split(':')[0];
expect(runKey).toBe(RUN_KEY);
// Owner-only to set (a key the owner minted holds flow.write).
const limited = await dm.flowProfile('report-limit-2', { 'report.limits': { max_live: 2 } });
await dm.useFlow(limited.ref);
/** Creates a report; every one that exists is deleted after the test, even when it fails half-way. */
const create = async (name: string): Promise<CreatedReport> => {
const body: CreateReportRequest = { name: `${name} ${dm.correlationId}`, kind: 'disputes' };
// A new Idempotency-Key per attempt: never replay a refused create.
const report = await dm.api.post<CreatedReport>('/v1/reports', body, { 'Idempotency-Key': randomUUID() });
// DELETE is idempotent, so cleaning up a report the test already deleted is a no-op.
dm.onCleanup(() => deleteReport({ id: report.id }));
return report;
};
const deleteReport = (params: DeleteReportParams) => dm.api.delete<DeletedReport>(`/v1/reports/${params.id}`);
// 1. Two reports fill the run's two places.
const first = await create('limit-1');
const second = await create('limit-2');
expect(first.run_key).toBe(runKey);
// 2. The third is refused, and the refusal says what to delete.
const mark = await dm.mark();
const refused = await create('limit-3').then(
() => null,
(err: unknown) => err,
);
expect(refused).toBeInstanceOf(ApiError);
const err = refused as ApiError;
expect(err.status).toBe(409);
expect(err.code).toBe('report_limit_reached');
const details = err.body?.details as unknown as ReportLimitReachedDetails;
expect(details).toMatchObject({ limit: 2, live_count: 2, scope: 'run', run_key: runKey });
expect(details.oldest.map((r) => r.id)).toEqual([first.id, second.id]);
// 3. Delete the oldest: a tombstone. A repeat changes nothing; a read answers 410.
const victim = details.oldest[0];
const deleted = await deleteReport({ id: victim.id });
expect(deleted).toMatchObject({ id: first.id, object: 'report', deleted: true, already_deleted: false, status_at_delete: 'done' });
const again = await deleteReport({ id: victim.id });
expect(again).toMatchObject({ id: first.id, deleted: true, already_deleted: true, deleted_at: deleted.deleted_at });
const gone = await dm.api.fetch<ErrorBody>('GET', `/v1/reports/${victim.id}`);
expect(gone.status).toBe(410);
expect(gone.body.code).toBe('report_deleted');
expect(gone.body.details as unknown as ReportDeletedDetails).toMatchObject({ id: first.id, deleted_at: deleted.deleted_at });
// 4. The retry (a new Idempotency-Key) fits.
const third = await create('limit-3');
const query: ReportListQuery = { run_key: runKey, order: 'created_asc' };
const live = await dm.api.get<Report[]>('/v1/reports', query);
expect(live.map((r) => r.id)).toEqual([second.id, third.id]);
// 5. The trail, in order: refused create, delete, repeat delete, create.
// Each row carries the limit that was in force.
const limit = { source: 'request', value: { max_live: 2 } };
const seen = await dm.expectSequence(
{ after_seq: mark, correlation_id: dm.correlationId, action: ['report.create', 'report.delete'] },
[
{ action: 'report.create', outcome: 'failed', error_code: 'report_limit_reached', metadata: { limit: 2, live_count: 2 } },
{ action: 'report.delete', outcome: 'success', target: { id: first.id }, metadata: { already_deleted: false } },
{ action: 'report.delete', outcome: 'success', target: { id: first.id }, metadata: { already_deleted: true } },
{ action: 'report.create', outcome: 'success', target: { id: third.id } },
],
{ mode: 'exact' },
);
for (const row of seen) {
expect(row.flow?.profile_id).toBe(limited.ref);
expect(row.flow?.variants?.['report.limits']).toEqual(limit);
}
});Agents that work in the portal
A synthetic account driving the portal cannot choose a flow, and the portal sends no correlation prefix of its own, so its reports count in the run default: in practice the limit is workspace-wide. To put such an agent under the limit, the owner sets a profile with report.limits as the workspace default (Settings > Flows, or the owner's session):
PUT /v1/flow-profiles/default {"profile_id": "fp_…@N"} # before the agent starts
PUT /v1/flow-profiles/default {"profile_id": null} # in finally, alwaysBrowser tests
One sign-in per worker
Each worker signs in its own synthetic account through the portal's form (password, then a TOTP code when the account has an authenticator) and keeps that session for its tests. Never save one session and share it between workers: the sign-in service revokes a session whose refresh token is reused, which logs every worker out at once. With fewer accounts than workers, workers share accounts but sign in one after another.
export const uiTest = test.extend<{ session: BrowserSession }, UiWorkerOptions & { workerStorageState: string; dmWorker: DmWorker }>({
// One session per worker, never shared: Heimdall revokes a session
// whose refresh token is reused after its 60 s grace.
storageState: async ({ workerStorageState }, use) => {
await use(workerStorageState);
},
session: ['synthetic_account', { option: true }],
dm: async ({ dm, context, session }, use) => {
(dm as DmFixture).bindBrowser(context, session);
await use(dm);
},
accountSlot: [undefined, { scope: 'worker', option: true }],
accountFlow: [undefined, { scope: 'worker', option: true }],
dmWorker: [
async ({ playwright, accountSlot }, use, workerInfo) => {
const accounts = syntheticAccounts();
const account = accounts.length ? accounts[(accountSlot ?? workerInfo.parallelIndex) % accounts.length] : null;
const restore: Array<() => Promise<void>> = [];
const state: { owner: APIRequestContext | null } = { owner: null };
const patch = async (id: string, ref: string | null): Promise<unknown> => {
const res = await state.owner!.patch(`team/synthetic-accounts/${id}`, { data: { flow_profile: ref } });
if (!res.ok()) throw new Error(`PATCH /v1/team/synthetic-accounts/${id} answered ${res.status()} ${await res.text()}`);
return res.json();
};
const worker: DmWorker = {
account,
async assignFlow(acc, ref) {
if (ref !== null) assertPinnedRef(ref);
state.owner ??= await ownerRequest(playwright);
const owner = state.owner;
const id = await accountId(owner, acc);
const list = await owner.get('team/synthetic-accounts');
const { items } = (await list.json()) as { items: Array<{ id: string; flow_profile?: { ref: string } | null }> };
const before = items.find((a) => a.id === id)?.flow_profile?.ref ?? null;
await patch(id, ref);
restore.push(() => patch(id, before).then(() => undefined));
},
};
try {
await use(worker);
} finally {
for (const fn of restore.reverse()) await fn();
await state.owner?.dispose();
}
},
{ scope: 'worker' },
],
workerStorageState: [
async ({ browser, playwright, dmWorker, accountFlow, accountSlot }, use, workerInfo) => {
if (accountFlow !== undefined) {
if (!dmWorker.account) throw new Error('accountFlow needs DM_SYNTHETIC_ACCOUNTS');
let ref: string | null;
if (accountFlow === null || typeof accountFlow === 'string') {
ref = accountFlow;
} else {
const request = await playwright.request.newContext({
baseURL: API_URL,
extraHTTPHeaders: { Authorization: `Bearer ${apiKey()}`, [CORRELATION_ID_HEADER]: `${RUN_ID}:worker-setup` },
});
try {
ref = (await ensureFlowProfile(new DmApi(request), accountFlow.name, accountFlow.variants)).ref;
} finally {
await request.dispose();
}
}
await dmWorker.assignFlow(dmWorker.account, ref);
}
await use(await signIn(browser, accountSlot ?? workerInfo.parallelIndex, workerInfo.project.outputDir));
},
{ scope: 'worker' },
],
});The correlation cookie
A browser cannot add headers to the portal's own requests, so the test sets a cookie instead:
| Name | dm_correlation_id |
| Who sets it | Your test, never the portal. The portal only reads it. |
| Scope | The portal host only (set it with the portal URL). It never goes to the API or any other site. |
| Lifetime | A session cookie: it lives as long as the test’s browser context. |
| Effect | The portal forwards it as X-Correlation-Id on every API call it makes for that page, so clicks are recorded with source: "portal" under the test's correlation id. |
| Invalid value | Dropped, with X-Correlation-Id-Rejected on the response and a console warning; the page still loads. |
context: async ({ context, correlationId }, use) => {
await context.addCookies([
{ name: CORRELATION_COOKIE, value: correlationId, url: PORTAL_URL },
{ name: FLOW_COOKIE, value: PLATFORM_FLOW, url: PORTAL_URL },
]);
await use(context);
},import { expect, uiTest as test } from '../../src/dm';
import { syntheticAccounts } from '../../src/env';
import type { DisputePage } from '../../src/types';
test.skip(syntheticAccounts().length === 0, 'set DM_SYNTHETIC_ACCOUNTS to run the browser specs');
// (2) The browser: a synthetic account accepts a dispute in the portal.
// The fixture is made with the key; the click is attributed to the
// account, from the portal, under this test's correlation id (cookie).
test('a synthetic account accepts a dispute in the portal', async ({ page, dm }) => {
const mark = await dm.mark();
const { job } = await dm.generate({ count: 12, dispute_rate: 1, seed: 42 });
const list = await dm.api.get<DisputePage>('/v1/disputes', {
generate_job_id: job.id,
status: 'evidence_required',
page_size: 1,
});
expect(list.items).toHaveLength(1);
const dispute = list.items[0];
await page.goto(`/en/disputes/${dispute.id}`);
await page.getByRole('button', { name: 'Accept dispute' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Yes, accept' }).click();
await expect(page.getByText('Dispute accepted.')).toBeVisible();
await dm.expectSequence({ after_seq: mark, correlation_id: dm.correlationId, action: 'dispute.accept' }, [
{
action: 'dispute.accept',
outcome: 'success',
actor: { type: 'synthetic_account' },
request: { source: 'portal', correlation_id: dm.correlationId },
target: { type: 'dispute', id: dispute.id },
changes: { status: { from: 'evidence_required', to: 'accepted' } },
},
], { mode: 'exact' });
});The display preferences cookie
Time zone, language, date format, clock and page size are saved per account in Settings › Preferences. Workers that share a synthetic account would change them for each other, so a test that needs its own values sets them for its browser context only:
| Name | dm_prefs |
| Value | tz=Asia/Tokyo;locale=pt;date_format=iso;hour_cycle=h23;page_size=25, URL-encoded (a cookie value cannot hold a raw ;; & also separates pairs). Set only the keys you need. |
| Keys | tz an IANA zone; locale en, pt, he, no, nn or ro (where sign-in lands); date_format locale or iso; hour_cycle h12 or h23; page_size 25, 50, 100 or 200. |
| Scope | The portal host only. It overrides the saved preferences in that browser context and changes nothing on the account. |
| Invalid value | Ignored with a console warning, validated like the API; the saved preference applies for that key. |
Dates in the portal always use the chosen zone (the cookie's, else the account's, else the workspace's, else UTC), never the browser's, so the zone of the machine running the tests does not matter. For example, with Playwright: context.addCookies([{ name: 'dm_prefs', value: encodeURIComponent('tz=Asia/Tokyo;date_format=iso'), url: PORTAL_URL }]).
Testing a setup-link flow
A synthetic account in email mode gets its password from a link mailed to its plus-address. Only the workspace owner can create such an account or send a link (an API key gets 401 api_key_not_allowed), so the owner creates it once, in the portal, and the suite uses the link.
Which addresses to use
- One real inbox your tests can read, verified once as one of the owner's emails: a test-inbox service such as Mailosaur, a Gmail or Google Workspace mailbox read through the Gmail API, or any mailbox you read over IMAP. It must accept
+addresses, because the account's address isbase+agent-…@domain. Match theTo:address exactly: every account's mail lands in the same inbox. - Never invented addresses. A made-up domain or mailbox bounces, the address is then blocked for setup emails (
setup_email_suppressed), and bounces count against the sender's reputation. - The Amazon SES mailbox simulator (
success@simulator.amazonses.com,bounce@simulator.amazonses.com, both of which accept+labels) cannot be used as a base: the base must be a verified email of yours, and the simulator never delivers the verification code to anyone.
State tests without a click-through
Most of the flow is testable without reading mail. The public POST /v1/auth/account-setup/inspect answers 404 setup_link_invalidfor a malformed or unknown token, and the account's setup.status (pending, expired, send_failed, bounced, completed) and the synthetic_account.send_setup_email / synthetic_account.complete_setup audit rows tell you what happened. Keep these calls few: inspect and complete allow 20 attempts a minute per IP and 10 per link, and sending is limited to one per account per minute, so never loop over the send.
examples/e2e-playwright/tests/api/08-account-setup-link-errors.spec.ts · show the file
import { randomBytes } from 'node:crypto';
import { expect, test } from '@playwright/test';
import { API_URL } from '../../src/env';
import type { ErrorBody, SetupToken } from '../../src/types';
// (8) A setup link the API does not know. The account-setup routes are
// public (the token is the credential), so these calls carry no key.
// Keep such calls few: inspect and complete allow 20 attempts a minute
// per IP. Inspect consumes nothing, so it is safe to call on any link.
for (const [kind, token] of [
['a malformed', 'not-a-setup-token'],
['an unknown, well-formed', `dmsu_${randomBytes(32).toString('base64url')}`],
] as const) {
test(`inspect answers 404 setup_link_invalid for ${kind} token`, async ({ request }) => {
const res = await request.post(`${API_URL}/v1/auth/account-setup/inspect`, {
data: { token } satisfies SetupToken,
});
expect(res.status()).toBe(404);
expect(((await res.json()) as ErrorBody).code).toBe('setup_link_invalid');
});
}The click-through
The browser spec takes the link from a test inbox, opens it, checks the page names the account, sets a generated password (never logged: traces, screenshots and videos are off for this spec), keeps the authenticator secret when the page shows one, signs in as the account and checks that opening the link again says it was already used. It skips unless DM_SETUP_ACCOUNT_EMAIL(the account's sign-in address) and an inbox are set. An account with email-code two-factor needs a sign-in code from the inbox too, so the spec skips it: use no two-factor or an authenticator.
import { randomBytes } from 'node:crypto';
import { rmSync } from 'node:fs';
import { expect, test } from '@playwright/test';
import { signInTo } from '../../src/dm';
import { configuredInbox } from '../../src/inbox';
import { totp } from '../../src/totp';
import type { SetupDetails } from '../../src/types';
const inbox = configuredInbox();
/** The email-mode account's sign-in address (`base+agent-…@domain`), as Team > Synthetic accounts shows it. */
const accountEmail = process.env.DM_SETUP_ACCOUNT_EMAIL;
/** A link lasts 72 hours: an older email cannot hold a usable one. */
const LINK_LIFETIME_MS = 72 * 3600_000;
test.skip(
!inbox || !accountEmail,
'set DM_SETUP_ACCOUNT_EMAIL and an inbox (DM_SETUP_LINK, or DM_IMAP_*) to run the setup-email spec',
);
// The page shows a password, an authenticator secret and recovery codes:
// keep them out of traces, screenshots and videos.
test.use({ trace: 'off', screenshot: 'off', video: 'off' });
// (9) The owner created an email-mode account in the portal; whoever reads
// its inbox sets the password from the link and signs in as the account.
// The link works once, so a run needs a fresh one (Resend in the portal).
test('a synthetic account sets its password from the setup email and signs in', async ({ page, browser }, testInfo) => {
const email = accountEmail as string;
const link = await inbox!.latestSetupLink({ to: email, after: new Date(Date.now() - LINK_LIFETIME_MS) });
// The page reads the token from the fragment and asks the API what the link is for.
const inspected = page.waitForResponse((r) => r.url().endsWith('/api/auth/account-setup/inspect'));
await page.goto(link);
const inspect = await inspected;
expect(inspect.status(), 'the link is unknown, used, revoked or expired: send a new one').toBe(200);
const details = (await inspect.json()) as SetupDetails;
expect(details.sign_in_email.toLowerCase()).toBe(email.toLowerCase());
// It names the account and where to sign in.
await expect(page.getByRole('heading', { name: `Set a password for ${details.label}` })).toBeVisible();
await expect(page.getByTestId('setup-details')).toContainText(details.sign_in_email);
// Inspect consumed nothing, so skipping here leaves the link usable.
test.skip(details.mfa_method === 'email', 'email-code two-factor needs the sign-in code from the inbox; use an account with no two-factor or an authenticator');
// A fresh password, never logged; it is spent once a browser typed it.
const password = `${randomBytes(18).toString('base64url')}-Aa1`;
await page.getByLabel('New password', { exact: true }).fill(password);
await page.getByLabel('Confirm password', { exact: true }).fill(password);
await page.getByRole('button', { name: 'Set password' }).click();
const done = page.getByTestId('setup-done');
await expect(done).toBeVisible();
let totpSecret: string | undefined;
if (details.mfa_method === 'totp') {
// Shown once, on this page only: the owner never holds it.
await expect(done).toHaveAttribute('data-mfa', 'totp');
totpSecret = (await page.getByTestId('setup-totp-secret').textContent())?.trim();
expect(totpSecret, 'the page shows the authenticator secret').toBeTruthy();
expect(totp(totpSecret!)).toMatch(/^\d{6}$/);
await expect(page.getByTestId('setup-recovery-codes').getByRole('listitem')).not.toHaveCount(0);
await page.getByTestId('setup-saved').click();
} else {
await expect(done).toHaveAttribute('data-mfa', 'none');
}
await expect(page.getByTestId('setup-sign-in')).toHaveAttribute('href', /sign-in\?identifier=/);
// Sign in as the account through the portal's own form (TOTP when set up);
// it lands on the dashboard. The session file is removed afterwards.
const state = testInfo.outputPath('setup-account-session.json');
try {
await signInTo(browser, { identifier: details.sign_in_email, password, totp_secret: totpSecret }, state, {
checkRole: false,
});
} finally {
rmSync(state, { force: true });
}
// The link worked once: opening it again says so.
const again = await browser.newPage();
try {
await again.goto(link);
const blocked = again.getByTestId('setup-blocked');
await expect(blocked).toHaveAttribute('data-code', 'setup_link_used');
await expect(again.getByRole('heading', { name: 'This link was already used' })).toBeVisible();
} finally {
await again.close();
}
});The inbox is one interface. DM_SETUP_LINK takes a link you copied by hand; DM_IMAP_HOST, DM_IMAP_USER and DM_IMAP_PASSWORD read it over IMAP (with imapflow), polling every 5 seconds for up to 2 minutes and matching the To: address exactly. Any other inbox (Mailosaur, the Gmail API) is one class with the same method.
export interface Inbox {
/**
* The setup link (`https://portal.dispute.markets/setup-account#token=dmsu_…`)
* of the newest setup email sent to `to` at or after `after`. Throws when
* none arrives in time.
*/
latestSetupLink(query: { to: string; after: Date }): Promise<string>;
}Afterwards archive the account: a password typed into a browser is spent, and archiving revokes any unused link. A link is single use and lasts 72 hours, so one run of the click-through needs one fresh link (Send a new setup link…in the account's ⋯ menu).
Budget
At most 8 workers per key and one key per CI job. A key may make 600 requests a minute per API server (300 writes, 60 on /v1/dev/*); the fixture absorbs short bursts by retrying after Retry-After. See rate limits.
examples/e2e-playwright/playwright.config.ts · show the file
import { defineConfig, devices } from '@playwright/test';
import { PORTAL_URL } from './src/env';
/**
* Worker budget: at most 8 workers per API key, one key per CI job
* (the API allows a key 600 requests a minute per server). The key and
* correlation headers are set by the `dm` fixture on its own request
* context, never here: `extraHTTPHeaders` in this file would send the
* key to the portal and every page the browser opens.
*/
export default defineConfig({
// Signs DM_OWNER in once (owner browser specs); cancels the run's scheduled jobs at the end.
globalSetup: require.resolve('./src/global-setup'),
globalTeardown: require.resolve('./src/global-teardown'),
// Room for a generate that waits on the workspace budget (src/api.ts, GENERATE_BUDGET_WAIT_MS).
timeout: 300_000,
expect: { timeout: 15_000 },
fullyParallel: true,
workers: Number(process.env.DM_WORKERS ?? 4),
retries: 0,
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list',
projects: [
{ name: 'api', testDir: './tests/api' },
{
name: 'ui',
testDir: './tests/ui',
use: { ...devices['Desktop Chrome'], baseURL: PORTAL_URL, trace: 'retain-on-failure' },
},
],
});Get the example
The zip holds the whole suite: the fixture, the specs above, types generated from the OpenAPI document and a README. No keys or accounts are included; it reads them from the environment.
Download the example (zip)