proxymail-qa 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,6 +52,24 @@ test('sign-up sends a working code', async ({ page, inbox }) => {
52
52
  });
53
53
  ```
54
54
 
55
+ In a run (the GitHub Action, or `startRun` in `globalSetup`), the report can say
56
+ which test each email came from. Use the `qa` fixture from
57
+ `proxymail-qa/playwright`, a client that names its test in every call, and add
58
+ the reporter, which records failed and flaky tests and the totals:
59
+
60
+ ```ts
61
+ // playwright.config.ts
62
+ reporter: [['list'], ['proxymail-qa/playwright/reporter']],
63
+
64
+ // signup.spec.ts
65
+ import { test, expect } from 'proxymail-qa/playwright';
66
+
67
+ test('sign-up sends one code', async ({ page, qa }) => {
68
+ const { address } = await qa.createAddress();
69
+ // …
70
+ });
71
+ ```
72
+
55
73
  ## Cypress
56
74
 
57
75
  Register tasks in `cypress.config.ts`, since Cypress runs them in Node:
@@ -106,6 +124,34 @@ await qa.setPassword(address, { password: 'the-new-test-password' });
106
124
  const result = await qa.checkEmail(address, { type: 'link', expectHost: 'staging.example.com' });
107
125
  expect(result.failed).toEqual([]);
108
126
 
127
+ // A localised email: in German, with the user's name in it.
128
+ await qa.checkEmail(address, { expectLanguage: 'de', expectText: ['Hallo Maya'] });
129
+
130
+ // What to type or pick in each field of a sign-up form, from a new address
131
+ // made for it: a username and password that fit the rules, the date of birth
132
+ // in the field's format, a country the drop-down offers. You fill it in.
133
+ const { values } = await qa.formValues({
134
+ fields: [
135
+ { id: 'email', type: 'email', label: 'Email' },
136
+ { id: 'dob', type: 'text', label: 'Date of birth', placeholder: 'DD/MM/YYYY' },
137
+ ],
138
+ });
139
+
140
+ // Borrow an account in the state the test needs. Your hooks make one when
141
+ // none is free, confirm its state and put it back; they talk to your app,
142
+ // Proxy Mail QA never does.
143
+ await qa.withAccount(
144
+ {
145
+ tags: ['verified', 'premium'],
146
+ create: async (account) => signUpAndUpgrade(account.address, account.password),
147
+ check: async (account) => (await getUser(account.address)).plan === 'premium',
148
+ reset: async (account) => resetUser(account.address),
149
+ },
150
+ async (account) => {
151
+ // …the test…
152
+ },
153
+ );
154
+
109
155
  // A run: this client's calls add what they saw, for a report on the pull
110
156
  // request. In GitHub Actions, the Proxy Mail QA action does this for you and
111
157
  // the client picks the run up from PROXYMAIL_QA_RUN.
package/dist/index.d.ts CHANGED
@@ -3,6 +3,19 @@ export interface ClientOptions {
3
3
  baseUrl?: string;
4
4
  fetch?: typeof fetch;
5
5
  run?: string | null;
6
+ test?: string | null;
7
+ }
8
+ export interface TestResultInput {
9
+ name: string;
10
+ status: 'passed' | 'failed' | 'flaky' | 'skipped';
11
+ durationMs?: number;
12
+ error?: string;
13
+ }
14
+ export interface TestTotals {
15
+ passed: number;
16
+ failed: number;
17
+ skipped?: number;
18
+ flaky?: number;
6
19
  }
7
20
  export interface Persona {
8
21
  first_name: string | null;
@@ -34,6 +47,48 @@ export interface CheckedOutAccount extends Address {
34
47
  until: string;
35
48
  };
36
49
  }
50
+ export interface FormFieldInput {
51
+ id?: string;
52
+ type?: string;
53
+ name?: string;
54
+ label?: string;
55
+ placeholder?: string;
56
+ autocomplete?: string;
57
+ group?: string;
58
+ pattern?: string;
59
+ minLength?: number;
60
+ maxLength?: number;
61
+ options?: Array<{
62
+ value: string;
63
+ label?: string;
64
+ }>;
65
+ }
66
+ export interface FormValuesOptions extends Omit<CreateAddressInput, 'password' | 'persona' | 'name'> {
67
+ fields: FormFieldInput[];
68
+ address?: string;
69
+ }
70
+ export interface FormValue {
71
+ id: string;
72
+ kind: 'email' | 'confirm_email' | 'first_name' | 'last_name' | 'full_name' | 'username' | 'password' | 'confirm_password' | 'date_of_birth' | 'birth_day' | 'birth_month' | 'birth_year' | 'country' | 'other';
73
+ value: string | null;
74
+ note?: string;
75
+ }
76
+ export interface FormValues {
77
+ address: string;
78
+ created: boolean;
79
+ persona: Persona | null;
80
+ password?: string;
81
+ values: FormValue[];
82
+ }
83
+ export interface PreparedAccountOptions extends CheckoutOptions {
84
+ create?: (account: CheckedOutAccount & {
85
+ password: string;
86
+ }) => Promise<void>;
87
+ newAccount?: Omit<CreateAddressInput, 'password'>;
88
+ check?: (account: CheckedOutAccount) => Promise<boolean | void>;
89
+ reset?: (account: CheckedOutAccount) => Promise<void>;
90
+ attempts?: number;
91
+ }
37
92
  export interface ReturnState {
38
93
  addTags?: string[];
39
94
  removeTags?: string[];
@@ -148,7 +203,7 @@ export interface CountOptions {
148
203
  subtype?: CountSubtype | CountSubtype[];
149
204
  }
150
205
  export type CountSubtype = 'otp' | 'coupon' | 'verify' | 'magic_signin' | 'invite' | 'reset' | 'other' | 'any';
151
- export type CheckId = 'spf' | 'dkim' | 'dmarc' | 'local_links' | 'plain_http' | 'expected_host' | 'expiry' | 'plain_text' | 'size' | 'images_only';
206
+ export type CheckId = 'spf' | 'dkim' | 'dmarc' | 'local_links' | 'plain_http' | 'expected_host' | 'expiry' | 'plain_text' | 'size' | 'images_only' | 'expected_text' | 'language';
152
207
  export interface EmailCheck {
153
208
  check: CheckId;
154
209
  status: 'pass' | 'fail' | 'warn' | 'skip';
@@ -172,6 +227,8 @@ export interface EmailChecks {
172
227
  export interface CheckOptions extends CountOptions {
173
228
  emailId?: string;
174
229
  expectHost?: string;
230
+ expectText?: string[];
231
+ expectLanguage?: string;
175
232
  }
176
233
  export interface NoEmail {
177
234
  address: string;
@@ -222,13 +279,16 @@ export declare class ProxyMailQA {
222
279
  private readonly baseUrl;
223
280
  private readonly fetcher;
224
281
  run: string | null;
282
+ readonly test: string | null;
225
283
  constructor(options?: ClientOptions);
284
+ forTest(name: string): ProxyMailQA;
226
285
  private request;
227
286
  project(): Promise<Record<string, unknown>>;
228
287
  createAddress(input: CreateAddressInput & {
229
288
  password: true;
230
289
  }): Promise<CreatedAddressWithPassword>;
231
290
  createAddress(input?: CreateAddressInput): Promise<CreatedAddress>;
291
+ formValues(options: FormValuesOptions): Promise<FormValues>;
232
292
  listAddresses(options?: ListAddressesOptions): Promise<Address[]>;
233
293
  getAddress(address: string): Promise<Address>;
234
294
  checkOutAccount(options?: CheckoutOptions): Promise<CheckedOutAccount>;
@@ -240,7 +300,12 @@ export declare class ProxyMailQA {
240
300
  };
241
301
  }>;
242
302
  returnAccount(account: CheckedOutAccount, state?: ReturnState): Promise<Address>;
243
- withAccount<T>(options: CheckoutOptions, fn: (account: CheckedOutAccount) => Promise<T>): Promise<T>;
303
+ withAccount<T>(options: PreparedAccountOptions, fn: (account: CheckedOutAccount) => Promise<T>): Promise<T>;
304
+ private prepareAccount;
305
+ private newAccount;
306
+ private failedCheck;
307
+ private restoreAccount;
308
+ private note;
244
309
  updateAddress(address: string, patch: AddressPatch): Promise<Address>;
245
310
  deleteAddress(address: string): Promise<void>;
246
311
  waitForCode(address: string, options?: WaitOptions): Promise<Match>;
@@ -279,6 +344,8 @@ export declare class ProxyMailQA {
279
344
  getRun(id?: string): Promise<Run>;
280
345
  runReport(id?: string): Promise<string>;
281
346
  listRuns(limit?: number): Promise<Run[]>;
347
+ recordTest(result: TestResultInput, run?: string): Promise<void>;
348
+ recordTestTotals(totals: TestTotals, run?: string): Promise<void>;
282
349
  addRunNote(text: string, options?: {
283
350
  failed?: boolean;
284
351
  address?: string;
package/dist/index.js CHANGED
@@ -42,15 +42,25 @@ export class ProxyMailQA {
42
42
  fetcher;
43
43
  // The run this client's calls add to, if any.
44
44
  run;
45
+ // The test they belong to, if any.
46
+ test;
45
47
  constructor(options = {}) {
46
48
  const env = globalThis.process?.env;
47
49
  const apiKey = options.apiKey ?? env?.PROXYMAIL_QA_API_KEY;
48
50
  if (!apiKey)
49
51
  throw new Error('Pass apiKey or set PROXYMAIL_QA_API_KEY. Create a key in Project settings → API keys.');
50
52
  this.apiKey = apiKey;
51
- this.baseUrl = (options.baseUrl ?? 'https://api.proxymail.qa').replace(/\/+$/, '');
53
+ // PROXYMAIL_QA_API_URL, as the Python helper reads it, may end in /v1.
54
+ this.baseUrl = (options.baseUrl ?? (env?.PROXYMAIL_QA_API_URL?.trim().replace(/\/v1\/?$/, '') || 'https://api.proxymail.qa')).replace(/\/+$/, '');
52
55
  this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
53
56
  this.run = options.run === undefined ? env?.PROXYMAIL_QA_RUN?.trim() || null : options.run;
57
+ this.test = options.test?.trim() || null;
58
+ }
59
+ // A client for one test: the same key and run, and every call says which
60
+ // test it belongs to, so the run's report shows which test each email came
61
+ // from. proxymail-qa/playwright's qa fixture does this for you.
62
+ forTest(name) {
63
+ return new ProxyMailQA({ apiKey: this.apiKey, baseUrl: this.baseUrl, fetch: this.fetcher, run: this.run, test: name });
54
64
  }
55
65
  // retry: this call is asked again if nothing arrives, so the run only
56
66
  // records the last try's "nothing arrived".
@@ -61,6 +71,8 @@ export class ProxyMailQA {
61
71
  Authorization: `Bearer ${this.apiKey}`,
62
72
  ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
63
73
  ...(this.run ? { 'Proxy-Mail-QA-Run': this.run, ...(retry ? { 'Proxy-Mail-QA-Run-Retry': 'true' } : {}) } : {}),
74
+ // Percent-encoded: a test's name can hold characters headers can't.
75
+ ...(this.run && this.test ? { 'Proxy-Mail-QA-Test': encodeURIComponent(this.test) } : {}),
64
76
  },
65
77
  body: body === undefined ? undefined : JSON.stringify(body),
66
78
  });
@@ -90,6 +102,33 @@ export class ProxyMailQA {
90
102
  country: input.country,
91
103
  });
92
104
  }
105
+ // What to type or pick in each field of a sign-up form, from a new address
106
+ // made for it (a date of birth if it asks, a country it offers, a password
107
+ // that fits its rules) or one made earlier (address). You fill it in.
108
+ formValues(options) {
109
+ return this.request('POST', '/form-values', {
110
+ fields: options.fields.map((f) => ({
111
+ id: f.id,
112
+ type: f.type,
113
+ name: f.name,
114
+ label: f.label,
115
+ placeholder: f.placeholder,
116
+ autocomplete: f.autocomplete,
117
+ group: f.group,
118
+ pattern: f.pattern,
119
+ min_length: f.minLength,
120
+ max_length: f.maxLength,
121
+ options: f.options,
122
+ })),
123
+ address: options.address,
124
+ label: options.label,
125
+ lifetime_days: options.lifetimeDays,
126
+ domain: options.domain,
127
+ age: options.age,
128
+ date_of_birth: options.dateOfBirth,
129
+ country: options.country,
130
+ });
131
+ }
93
132
  // Newest first. With filters, finds test accounts made earlier, such as
94
133
  // an under-18 in GB with a saved password: every filter given must match.
95
134
  listAddresses(options = {}) {
@@ -147,18 +186,140 @@ export class ProxyMailQA {
147
186
  }
148
187
  // Checks out an account, runs fn with it, and always returns it. fn can
149
188
  // say what state it left the account in by returning { addTags, removeTags }.
189
+ // With create, check and reset (PreparedAccountOptions), the account is
190
+ // also confirmed to be in the state the test needs, a new one is made when
191
+ // none is free, and its state is restored afterwards.
150
192
  async withAccount(options, fn) {
151
- const account = await this.checkOutAccount(options);
193
+ const account = await this.prepareAccount(options);
152
194
  let state = {};
195
+ let result;
153
196
  try {
154
- const result = await fn(account);
197
+ result = await fn(account);
155
198
  if (result && typeof result === 'object' && ('addTags' in result || 'removeTags' in result))
156
199
  state = result;
157
- return result;
158
200
  }
159
- finally {
160
- await this.returnAccount(account, state).catch(() => { });
201
+ catch (err) {
202
+ await this.restoreAccount(account, options, state).catch(() => { });
203
+ throw err;
204
+ }
205
+ await this.restoreAccount(account, options, state);
206
+ return result;
207
+ }
208
+ // A checked-out account in the state the test needs, or an error saying why
209
+ // there isn't one.
210
+ async prepareAccount(options) {
211
+ const { create, check, reset, newAccount, attempts = 3, ...filters } = options;
212
+ const tried = [];
213
+ let lastProblem = '';
214
+ for (let i = 0; i < attempts; i++) {
215
+ let account;
216
+ try {
217
+ account = await this.checkOutAccount(filters);
218
+ }
219
+ catch (err) {
220
+ if (!(err instanceof ProxyMailQAError && (err.status === 404 || err.status === 409)))
221
+ throw err;
222
+ // None matches, or every match is busy: make one, if the test can.
223
+ if (create)
224
+ return this.newAccount(options);
225
+ // Accounts that failed their check have left the pool.
226
+ if (tried.length)
227
+ break;
228
+ throw err;
229
+ }
230
+ // The pool has come round again: every match has been tried.
231
+ if (tried.includes(account.address)) {
232
+ await this.returnAccount(account).catch(() => { });
233
+ break;
234
+ }
235
+ tried.push(account.address);
236
+ let problem = await this.failedCheck(check, account);
237
+ if (problem === null)
238
+ return account;
239
+ if (reset) {
240
+ problem = await reset(account).then(() => this.failedCheck(check, account), (err) => `reset failed: ${message(err)}`);
241
+ if (problem === null)
242
+ return account;
243
+ }
244
+ lastProblem = problem;
245
+ await this.note(`Test account failed its check (${problem}); it loses its tags and is tagged check-failed.`, account.address);
246
+ await this.returnAccount(account, { addTags: ['check-failed'], removeTags: filters.tags }).catch(() => { });
247
+ }
248
+ if (create)
249
+ return this.newAccount(options);
250
+ throw new Error(tried.length
251
+ ? `No test account passed the check: tried ${tried.join(', ')}${lastProblem ? ` (last: ${lastProblem})` : ''}. Pass create to make one.`
252
+ : 'No test account passed the check. Pass create to make one.');
253
+ }
254
+ // Makes an account with the test's create hook, holding it from the start
255
+ // so no parallel test takes it half made, then tags and checks it.
256
+ async newAccount(options) {
257
+ const { create, check, newAccount = {}, tags = [], country, minAge, maxAge, minutes } = options;
258
+ if (!create)
259
+ throw new Error('Pass create to make new accounts.');
260
+ if ((minAge !== undefined || maxAge !== undefined) && !newAccount.age && !newAccount.dateOfBirth) {
261
+ throw new Error('Give newAccount an age or dateOfBirth, so new accounts match minAge and maxAge.');
262
+ }
263
+ const created = await this.createAddress({ ...newAccount, country: newAccount.country ?? country, password: true });
264
+ const held = await this.checkOutAccount({ search: created.address, minutes });
265
+ if (held.address !== created.address) {
266
+ await this.returnAccount(held).catch(() => { });
267
+ throw new Error(`Couldn't hold the new account ${created.address}.`);
268
+ }
269
+ try {
270
+ await create({ ...held, password: created.password });
271
+ const tagged = await this.updateAddress(created.address, { tags: [...new Set([...held.tags, ...tags.map((t) => t.toLowerCase())])] });
272
+ const account = { ...tagged, checkout: held.checkout };
273
+ const ages = age(account.persona?.date_of_birth);
274
+ if (ages !== null && ((minAge !== undefined && ages < minAge) || (maxAge !== undefined && ages > maxAge))) {
275
+ throw new Error(`The new account is ${ages}, outside minAge and maxAge: give newAccount a matching age.`);
276
+ }
277
+ const problem = await this.failedCheck(check, account);
278
+ if (problem !== null)
279
+ throw new Error(`The new account ${created.address} failed its check after create: ${problem}`);
280
+ await this.note(`Made a new test account${tags.length ? ` tagged ${tags.join(', ')}` : ''}.`, created.address);
281
+ return account;
282
+ }
283
+ catch (err) {
284
+ // Untagged, it stays out of the pool; it expires with its address.
285
+ await this.updateAddress(created.address, { tags: held.tags }).catch(() => { });
286
+ await this.returnAccount(held).catch(() => { });
287
+ throw err;
288
+ }
289
+ }
290
+ // null when the account passes (or there's no check), or why it doesn't.
291
+ async failedCheck(check, account) {
292
+ if (!check)
293
+ return null;
294
+ try {
295
+ return (await check(account)) === false ? 'check returned false' : null;
296
+ }
297
+ catch (err) {
298
+ return message(err);
299
+ }
300
+ }
301
+ // Runs reset, then returns the account. If reset fails, the account leaves
302
+ // the pool's state (needs-reset) and the error is thrown.
303
+ async restoreAccount(account, options, state) {
304
+ if (options.reset) {
305
+ try {
306
+ await options.reset(account);
307
+ }
308
+ catch (err) {
309
+ await this.note(`Couldn't reset the test account (${message(err)}); it loses its tags and is tagged needs-reset.`, account.address);
310
+ await this.returnAccount(account, {
311
+ addTags: [...(state.addTags ?? []), 'needs-reset'],
312
+ removeTags: [...(state.removeTags ?? []), ...(options.tags ?? [])],
313
+ }).catch(() => { });
314
+ throw err;
315
+ }
161
316
  }
317
+ await this.returnAccount(account, state).catch(() => { });
318
+ }
319
+ // Adds a line to the current run, if there is one, without failing the test.
320
+ async note(text, address) {
321
+ if (this.run)
322
+ await this.addRunNote(text, { address }).catch(() => { });
162
323
  }
163
324
  updateAddress(address, patch) {
164
325
  return this.request('PATCH', `/addresses/${encodeURIComponent(address)}`, patch);
@@ -199,15 +360,21 @@ export class ProxyMailQA {
199
360
  }
200
361
  // Checks one email the address received: SPF, DKIM and DMARC, links to
201
362
  // localhost or plain http, where its link goes (expectHost), a stated
202
- // expiry, a plain-text part, size and image-only emails. Doesn't wait: wait
203
- // for the email first. Throws ProxyMailQAError (404) when none matches.
363
+ // expiry, a plain-text part, size and image-only emails, and given phrases
364
+ // (expectText) and its language (expectLanguage). Doesn't wait: wait for
365
+ // the email first. Throws ProxyMailQAError (404) when none matches.
204
366
  checkEmail(address, options = {}) {
205
367
  const extra = {};
206
368
  if (options.emailId)
207
369
  extra.email_id = options.emailId;
208
370
  if (options.expectHost)
209
371
  extra.expect_host = options.expectHost;
210
- return this.request('GET', `/addresses/${encodeURIComponent(address)}/checks${countQuery(options, extra)}`);
372
+ if (options.expectLanguage)
373
+ extra.expect_language = options.expectLanguage;
374
+ let query = countQuery(options, extra);
375
+ for (const phrase of options.expectText ?? [])
376
+ query += `${query ? '&' : '?'}expect_text=${encodeURIComponent(phrase)}`;
377
+ return this.request('GET', `/addresses/${encodeURIComponent(address)}/checks${query}`);
211
378
  }
212
379
  // Resolves when no matching email arrives within timeout seconds (default
213
380
  // 30; longer repeats the request), for checks such as "an unsubscribed user
@@ -266,6 +433,23 @@ export class ProxyMailQA {
266
433
  }
267
434
  // A line of your own in the run, such as a test that failed for a reason
268
435
  // of its own. failed: true fails the run.
436
+ // A test's result in the run (a failed one fails it), from a test runner's
437
+ // reporter: proxymail-qa/playwright/reporter sends failed and flaky tests,
438
+ // then the totals.
439
+ recordTest(result, run) {
440
+ const id = run ?? this.requireRun();
441
+ return this.request('POST', `/runs/${encodeURIComponent(id)}/tests`, {
442
+ name: result.name,
443
+ status: result.status,
444
+ duration_ms: result.durationMs,
445
+ error: result.error,
446
+ });
447
+ }
448
+ // How the suite went, for the top of the run's report.
449
+ recordTestTotals(totals, run) {
450
+ const id = run ?? this.requireRun();
451
+ return this.request('POST', `/runs/${encodeURIComponent(id)}/tests`, { totals });
452
+ }
269
453
  addRunNote(text, options = {}) {
270
454
  const id = options.run ?? this.requireRun();
271
455
  return this.request('POST', `/runs/${encodeURIComponent(id)}/notes`, { text, failed: options.failed ?? false, address: options.address });
@@ -313,3 +497,12 @@ export class ProxyMailQA {
313
497
  }
314
498
  }
315
499
  }
500
+ const message = (err) => (err instanceof Error ? err.message : String(err));
501
+ // Whole years from a YYYY-MM-DD date of birth, or null without one.
502
+ function age(dateOfBirth) {
503
+ if (!dateOfBirth)
504
+ return null;
505
+ const [y, m, d] = dateOfBirth.split('-').map(Number);
506
+ const now = new Date();
507
+ return now.getUTCFullYear() - y - (now.getUTCMonth() + 1 < m || (now.getUTCMonth() + 1 === m && now.getUTCDate() < d) ? 1 : 0);
508
+ }
@@ -0,0 +1,11 @@
1
+ import type { FullResult, Reporter, Suite } from '@playwright/test/reporter';
2
+ import { type ClientOptions } from './index.js';
3
+ export default class ProxyMailQAReporter implements Reporter {
4
+ private readonly options;
5
+ private suite;
6
+ constructor(options?: Pick<ClientOptions, 'apiKey' | 'baseUrl'>);
7
+ onBegin(_config: unknown, suite: Suite): void;
8
+ onEnd(_result: FullResult): Promise<void>;
9
+ printsToStdio(): boolean;
10
+ private client;
11
+ }
@@ -0,0 +1,64 @@
1
+ import { ProxyMailQA } from './index.js';
2
+ import { testName } from './test-name.js';
3
+ // Enough for a report; the rest are in Playwright's own.
4
+ const MAX_RESULTS = 200;
5
+ export default class ProxyMailQAReporter {
6
+ options;
7
+ suite = null;
8
+ constructor(options = {}) {
9
+ this.options = options;
10
+ }
11
+ onBegin(_config, suite) {
12
+ this.suite = suite;
13
+ }
14
+ async onEnd(_result) {
15
+ const qa = this.client();
16
+ if (!qa || !this.suite)
17
+ return;
18
+ const totals = { passed: 0, failed: 0, skipped: 0, flaky: 0 };
19
+ const results = [];
20
+ for (const test of this.suite.allTests()) {
21
+ const outcome = test.outcome();
22
+ if (outcome === 'expected')
23
+ totals.passed++;
24
+ else if (outcome === 'skipped')
25
+ totals.skipped++;
26
+ else {
27
+ const flaky = outcome === 'flaky';
28
+ if (flaky)
29
+ totals.flaky++;
30
+ else
31
+ totals.failed++;
32
+ const last = test.results.at(-1);
33
+ results.push({
34
+ name: nameOf(test),
35
+ status: flaky ? 'flaky' : 'failed',
36
+ durationMs: last?.duration,
37
+ error: flaky ? undefined : last?.error?.message,
38
+ });
39
+ }
40
+ }
41
+ // Failures never fail the test run itself: the report is evidence.
42
+ for (const result of results.slice(0, MAX_RESULTS))
43
+ await qa.recordTest(result).catch(() => { });
44
+ await qa.recordTestTotals(totals).catch(() => { });
45
+ }
46
+ printsToStdio() {
47
+ return false;
48
+ }
49
+ client() {
50
+ try {
51
+ const qa = new ProxyMailQA(this.options);
52
+ return qa.run ? qa : null;
53
+ }
54
+ catch {
55
+ // No API key: nothing to report to.
56
+ return null;
57
+ }
58
+ }
59
+ }
60
+ // titlePath: the root, the project, the file, then describe blocks and the title.
61
+ function nameOf(test) {
62
+ const [, project = '', , ...path] = test.titlePath();
63
+ return testName(project, path);
64
+ }
@@ -0,0 +1,5 @@
1
+ import { ProxyMailQA } from './index.js';
2
+ export declare const test: import("playwright/test").TestType<import("playwright/test").PlaywrightTestArgs & import("playwright/test").PlaywrightTestOptions & {
3
+ qa: ProxyMailQA;
4
+ }, import("playwright/test").PlaywrightWorkerArgs & import("playwright/test").PlaywrightWorkerOptions>;
5
+ export { expect } from '@playwright/test';
@@ -0,0 +1,25 @@
1
+ // Playwright tests with Proxy Mail QA. The qa fixture is a client for the
2
+ // test it's in: in a run (PROXYMAIL_QA_RUN, set by the GitHub Action or by
3
+ // startRun in globalSetup), each call says which test it belongs to, so the
4
+ // run's report shows which test each email came from.
5
+ //
6
+ // import { test, expect } from 'proxymail-qa/playwright';
7
+ //
8
+ // test('sign-up sends one code', async ({ page, qa }) => {
9
+ // const { address } = await qa.createAddress();
10
+ // …
11
+ // });
12
+ //
13
+ // Add proxymail-qa/playwright/reporter to the config's reporters to record
14
+ // failed tests and the totals in the run too.
15
+ import { test as base } from '@playwright/test';
16
+ import { ProxyMailQA } from './index.js';
17
+ import { testName } from './test-name.js';
18
+ export const test = base.extend({
19
+ // eslint-disable-next-line no-empty-pattern
20
+ qa: async ({}, use, testInfo) => {
21
+ // titlePath starts with the file.
22
+ await use(new ProxyMailQA().forTest(testName(testInfo.project.name, testInfo.titlePath.slice(1))));
23
+ },
24
+ });
25
+ export { expect } from '@playwright/test';
@@ -0,0 +1 @@
1
+ export declare const testName: (project: string, path: readonly string[]) => string;
@@ -0,0 +1,3 @@
1
+ // A test's name as the run shows it, the same in the qa fixture and the
2
+ // reporter: the project (when named), the describe blocks and the title.
3
+ export const testName = (project, path) => [project, ...path].filter(Boolean).join(' › ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "proxymail-qa",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Disposable test inboxes for Playwright, Cypress and scripts: create an address, then wait for its verification code or link.",
5
5
  "license": "MIT",
6
6
  "author": "Proxy Mail QA <hello@proxymail.qa> (https://proxymail.qa)",
@@ -29,6 +29,14 @@
29
29
  ".": {
30
30
  "types": "./dist/index.d.ts",
31
31
  "default": "./dist/index.js"
32
+ },
33
+ "./playwright": {
34
+ "types": "./dist/playwright.d.ts",
35
+ "default": "./dist/playwright.js"
36
+ },
37
+ "./playwright/reporter": {
38
+ "types": "./dist/playwright-reporter.d.ts",
39
+ "default": "./dist/playwright-reporter.js"
32
40
  }
33
41
  },
34
42
  "files": [
@@ -46,7 +54,16 @@
46
54
  "typecheck": "tsc -p tsconfig.json --noEmit",
47
55
  "prepublishOnly": "npm run build && npm test"
48
56
  },
57
+ "peerDependencies": {
58
+ "@playwright/test": ">=1.40.0"
59
+ },
60
+ "peerDependenciesMeta": {
61
+ "@playwright/test": {
62
+ "optional": true
63
+ }
64
+ },
49
65
  "devDependencies": {
66
+ "@playwright/test": "^1.64.1",
50
67
  "typescript": "^5.6.0",
51
68
  "vitest": "^5.0.3"
52
69
  }