proxymail-qa 0.5.1 → 0.7.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 +28 -0
- package/dist/index.d.ts +75 -1
- package/dist/index.js +212 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,6 +106,34 @@ await qa.setPassword(address, { password: 'the-new-test-password' });
|
|
|
106
106
|
const result = await qa.checkEmail(address, { type: 'link', expectHost: 'staging.example.com' });
|
|
107
107
|
expect(result.failed).toEqual([]);
|
|
108
108
|
|
|
109
|
+
// A localised email: in German, with the user's name in it.
|
|
110
|
+
await qa.checkEmail(address, { expectLanguage: 'de', expectText: ['Hallo Maya'] });
|
|
111
|
+
|
|
112
|
+
// What to type or pick in each field of a sign-up form, from a new address
|
|
113
|
+
// made for it: a username and password that fit the rules, the date of birth
|
|
114
|
+
// in the field's format, a country the drop-down offers. You fill it in.
|
|
115
|
+
const { values } = await qa.formValues({
|
|
116
|
+
fields: [
|
|
117
|
+
{ id: 'email', type: 'email', label: 'Email' },
|
|
118
|
+
{ id: 'dob', type: 'text', label: 'Date of birth', placeholder: 'DD/MM/YYYY' },
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Borrow an account in the state the test needs. Your hooks make one when
|
|
123
|
+
// none is free, confirm its state and put it back; they talk to your app,
|
|
124
|
+
// Proxy Mail QA never does.
|
|
125
|
+
await qa.withAccount(
|
|
126
|
+
{
|
|
127
|
+
tags: ['verified', 'premium'],
|
|
128
|
+
create: async (account) => signUpAndUpgrade(account.address, account.password),
|
|
129
|
+
check: async (account) => (await getUser(account.address)).plan === 'premium',
|
|
130
|
+
reset: async (account) => resetUser(account.address),
|
|
131
|
+
},
|
|
132
|
+
async (account) => {
|
|
133
|
+
// …the test…
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
|
|
109
137
|
// A run: this client's calls add what they saw, for a report on the pull
|
|
110
138
|
// request. In GitHub Actions, the Proxy Mail QA action does this for you and
|
|
111
139
|
// the client picks the run up from PROXYMAIL_QA_RUN.
|
package/dist/index.d.ts
CHANGED
|
@@ -23,6 +23,62 @@ export interface Address {
|
|
|
23
23
|
has_password: boolean;
|
|
24
24
|
email_count: number;
|
|
25
25
|
latest: Omit<Match, 'address' | 'email_id' | 'service_domain'> | null;
|
|
26
|
+
checked_out_until?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface CheckoutOptions extends Omit<ListAddressesOptions, 'limit' | 'before'> {
|
|
29
|
+
minutes?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface CheckedOutAccount extends Address {
|
|
32
|
+
checkout: {
|
|
33
|
+
token: string;
|
|
34
|
+
until: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export interface FormFieldInput {
|
|
38
|
+
id?: string;
|
|
39
|
+
type?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
label?: string;
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
autocomplete?: string;
|
|
44
|
+
group?: string;
|
|
45
|
+
pattern?: string;
|
|
46
|
+
minLength?: number;
|
|
47
|
+
maxLength?: number;
|
|
48
|
+
options?: Array<{
|
|
49
|
+
value: string;
|
|
50
|
+
label?: string;
|
|
51
|
+
}>;
|
|
52
|
+
}
|
|
53
|
+
export interface FormValuesOptions extends Omit<CreateAddressInput, 'password' | 'persona' | 'name'> {
|
|
54
|
+
fields: FormFieldInput[];
|
|
55
|
+
address?: string;
|
|
56
|
+
}
|
|
57
|
+
export interface FormValue {
|
|
58
|
+
id: string;
|
|
59
|
+
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';
|
|
60
|
+
value: string | null;
|
|
61
|
+
note?: string;
|
|
62
|
+
}
|
|
63
|
+
export interface FormValues {
|
|
64
|
+
address: string;
|
|
65
|
+
created: boolean;
|
|
66
|
+
persona: Persona | null;
|
|
67
|
+
password?: string;
|
|
68
|
+
values: FormValue[];
|
|
69
|
+
}
|
|
70
|
+
export interface PreparedAccountOptions extends CheckoutOptions {
|
|
71
|
+
create?: (account: CheckedOutAccount & {
|
|
72
|
+
password: string;
|
|
73
|
+
}) => Promise<void>;
|
|
74
|
+
newAccount?: Omit<CreateAddressInput, 'password'>;
|
|
75
|
+
check?: (account: CheckedOutAccount) => Promise<boolean | void>;
|
|
76
|
+
reset?: (account: CheckedOutAccount) => Promise<void>;
|
|
77
|
+
attempts?: number;
|
|
78
|
+
}
|
|
79
|
+
export interface ReturnState {
|
|
80
|
+
addTags?: string[];
|
|
81
|
+
removeTags?: string[];
|
|
26
82
|
}
|
|
27
83
|
export interface CreatedAddress extends Address {
|
|
28
84
|
password?: string;
|
|
@@ -134,7 +190,7 @@ export interface CountOptions {
|
|
|
134
190
|
subtype?: CountSubtype | CountSubtype[];
|
|
135
191
|
}
|
|
136
192
|
export type CountSubtype = 'otp' | 'coupon' | 'verify' | 'magic_signin' | 'invite' | 'reset' | 'other' | 'any';
|
|
137
|
-
export type CheckId = 'spf' | 'dkim' | 'dmarc' | 'local_links' | 'plain_http' | 'expected_host' | 'expiry' | 'plain_text' | 'size' | 'images_only';
|
|
193
|
+
export type CheckId = 'spf' | 'dkim' | 'dmarc' | 'local_links' | 'plain_http' | 'expected_host' | 'expiry' | 'plain_text' | 'size' | 'images_only' | 'expected_text' | 'language';
|
|
138
194
|
export interface EmailCheck {
|
|
139
195
|
check: CheckId;
|
|
140
196
|
status: 'pass' | 'fail' | 'warn' | 'skip';
|
|
@@ -158,6 +214,8 @@ export interface EmailChecks {
|
|
|
158
214
|
export interface CheckOptions extends CountOptions {
|
|
159
215
|
emailId?: string;
|
|
160
216
|
expectHost?: string;
|
|
217
|
+
expectText?: string[];
|
|
218
|
+
expectLanguage?: string;
|
|
161
219
|
}
|
|
162
220
|
export interface NoEmail {
|
|
163
221
|
address: string;
|
|
@@ -215,8 +273,24 @@ export declare class ProxyMailQA {
|
|
|
215
273
|
password: true;
|
|
216
274
|
}): Promise<CreatedAddressWithPassword>;
|
|
217
275
|
createAddress(input?: CreateAddressInput): Promise<CreatedAddress>;
|
|
276
|
+
formValues(options: FormValuesOptions): Promise<FormValues>;
|
|
218
277
|
listAddresses(options?: ListAddressesOptions): Promise<Address[]>;
|
|
219
278
|
getAddress(address: string): Promise<Address>;
|
|
279
|
+
checkOutAccount(options?: CheckoutOptions): Promise<CheckedOutAccount>;
|
|
280
|
+
extendCheckout(account: CheckedOutAccount, minutes?: number): Promise<{
|
|
281
|
+
address: string;
|
|
282
|
+
checkout: {
|
|
283
|
+
token: string;
|
|
284
|
+
until: string;
|
|
285
|
+
};
|
|
286
|
+
}>;
|
|
287
|
+
returnAccount(account: CheckedOutAccount, state?: ReturnState): Promise<Address>;
|
|
288
|
+
withAccount<T>(options: PreparedAccountOptions, fn: (account: CheckedOutAccount) => Promise<T>): Promise<T>;
|
|
289
|
+
private prepareAccount;
|
|
290
|
+
private newAccount;
|
|
291
|
+
private failedCheck;
|
|
292
|
+
private restoreAccount;
|
|
293
|
+
private note;
|
|
220
294
|
updateAddress(address: string, patch: AddressPatch): Promise<Address>;
|
|
221
295
|
deleteAddress(address: string): Promise<void>;
|
|
222
296
|
waitForCode(address: string, options?: WaitOptions): Promise<Match>;
|
package/dist/index.js
CHANGED
|
@@ -90,6 +90,33 @@ export class ProxyMailQA {
|
|
|
90
90
|
country: input.country,
|
|
91
91
|
});
|
|
92
92
|
}
|
|
93
|
+
// What to type or pick in each field of a sign-up form, from a new address
|
|
94
|
+
// made for it (a date of birth if it asks, a country it offers, a password
|
|
95
|
+
// that fits its rules) or one made earlier (address). You fill it in.
|
|
96
|
+
formValues(options) {
|
|
97
|
+
return this.request('POST', '/form-values', {
|
|
98
|
+
fields: options.fields.map((f) => ({
|
|
99
|
+
id: f.id,
|
|
100
|
+
type: f.type,
|
|
101
|
+
name: f.name,
|
|
102
|
+
label: f.label,
|
|
103
|
+
placeholder: f.placeholder,
|
|
104
|
+
autocomplete: f.autocomplete,
|
|
105
|
+
group: f.group,
|
|
106
|
+
pattern: f.pattern,
|
|
107
|
+
min_length: f.minLength,
|
|
108
|
+
max_length: f.maxLength,
|
|
109
|
+
options: f.options,
|
|
110
|
+
})),
|
|
111
|
+
address: options.address,
|
|
112
|
+
label: options.label,
|
|
113
|
+
lifetime_days: options.lifetimeDays,
|
|
114
|
+
domain: options.domain,
|
|
115
|
+
age: options.age,
|
|
116
|
+
date_of_birth: options.dateOfBirth,
|
|
117
|
+
country: options.country,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
93
120
|
// Newest first. With filters, finds test accounts made earlier, such as
|
|
94
121
|
// an under-18 in GB with a saved password: every filter given must match.
|
|
95
122
|
listAddresses(options = {}) {
|
|
@@ -115,6 +142,173 @@ export class ProxyMailQA {
|
|
|
115
142
|
getAddress(address) {
|
|
116
143
|
return this.request('GET', `/addresses/${encodeURIComponent(address)}`);
|
|
117
144
|
}
|
|
145
|
+
// Checks out an account matching every filter that nobody else holds, the
|
|
146
|
+
// least recently used first, for `minutes` (default 10). Throws
|
|
147
|
+
// ProxyMailQAError 409 when every match is checked out, 404 when none
|
|
148
|
+
// match. Tag accounts with their state (verified, premium…) to make pools.
|
|
149
|
+
checkOutAccount(options = {}) {
|
|
150
|
+
return this.request('POST', '/addresses/checkout', {
|
|
151
|
+
tags: options.tags,
|
|
152
|
+
country: options.country,
|
|
153
|
+
min_age: options.minAge,
|
|
154
|
+
max_age: options.maxAge,
|
|
155
|
+
has_password: options.hasPassword,
|
|
156
|
+
search: options.search,
|
|
157
|
+
minutes: options.minutes,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
// More time from now (default 10 minutes).
|
|
161
|
+
extendCheckout(account, minutes) {
|
|
162
|
+
return this.request('POST', `/addresses/${encodeURIComponent(account.address)}/extend`, {
|
|
163
|
+
token: account.checkout.token,
|
|
164
|
+
minutes,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Frees it for the next test, recording any change to its state.
|
|
168
|
+
returnAccount(account, state = {}) {
|
|
169
|
+
return this.request('POST', `/addresses/${encodeURIComponent(account.address)}/return`, {
|
|
170
|
+
token: account.checkout.token,
|
|
171
|
+
add_tags: state.addTags,
|
|
172
|
+
remove_tags: state.removeTags,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Checks out an account, runs fn with it, and always returns it. fn can
|
|
176
|
+
// say what state it left the account in by returning { addTags, removeTags }.
|
|
177
|
+
// With create, check and reset (PreparedAccountOptions), the account is
|
|
178
|
+
// also confirmed to be in the state the test needs, a new one is made when
|
|
179
|
+
// none is free, and its state is restored afterwards.
|
|
180
|
+
async withAccount(options, fn) {
|
|
181
|
+
const account = await this.prepareAccount(options);
|
|
182
|
+
let state = {};
|
|
183
|
+
let result;
|
|
184
|
+
try {
|
|
185
|
+
result = await fn(account);
|
|
186
|
+
if (result && typeof result === 'object' && ('addTags' in result || 'removeTags' in result))
|
|
187
|
+
state = result;
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
await this.restoreAccount(account, options, state).catch(() => { });
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
await this.restoreAccount(account, options, state);
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
// A checked-out account in the state the test needs, or an error saying why
|
|
197
|
+
// there isn't one.
|
|
198
|
+
async prepareAccount(options) {
|
|
199
|
+
const { create, check, reset, newAccount, attempts = 3, ...filters } = options;
|
|
200
|
+
const tried = [];
|
|
201
|
+
let lastProblem = '';
|
|
202
|
+
for (let i = 0; i < attempts; i++) {
|
|
203
|
+
let account;
|
|
204
|
+
try {
|
|
205
|
+
account = await this.checkOutAccount(filters);
|
|
206
|
+
}
|
|
207
|
+
catch (err) {
|
|
208
|
+
if (!(err instanceof ProxyMailQAError && (err.status === 404 || err.status === 409)))
|
|
209
|
+
throw err;
|
|
210
|
+
// None matches, or every match is busy: make one, if the test can.
|
|
211
|
+
if (create)
|
|
212
|
+
return this.newAccount(options);
|
|
213
|
+
// Accounts that failed their check have left the pool.
|
|
214
|
+
if (tried.length)
|
|
215
|
+
break;
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
// The pool has come round again: every match has been tried.
|
|
219
|
+
if (tried.includes(account.address)) {
|
|
220
|
+
await this.returnAccount(account).catch(() => { });
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
tried.push(account.address);
|
|
224
|
+
let problem = await this.failedCheck(check, account);
|
|
225
|
+
if (problem === null)
|
|
226
|
+
return account;
|
|
227
|
+
if (reset) {
|
|
228
|
+
problem = await reset(account).then(() => this.failedCheck(check, account), (err) => `reset failed: ${message(err)}`);
|
|
229
|
+
if (problem === null)
|
|
230
|
+
return account;
|
|
231
|
+
}
|
|
232
|
+
lastProblem = problem;
|
|
233
|
+
await this.note(`Test account failed its check (${problem}); it loses its tags and is tagged check-failed.`, account.address);
|
|
234
|
+
await this.returnAccount(account, { addTags: ['check-failed'], removeTags: filters.tags }).catch(() => { });
|
|
235
|
+
}
|
|
236
|
+
if (create)
|
|
237
|
+
return this.newAccount(options);
|
|
238
|
+
throw new Error(tried.length
|
|
239
|
+
? `No test account passed the check: tried ${tried.join(', ')}${lastProblem ? ` (last: ${lastProblem})` : ''}. Pass create to make one.`
|
|
240
|
+
: 'No test account passed the check. Pass create to make one.');
|
|
241
|
+
}
|
|
242
|
+
// Makes an account with the test's create hook, holding it from the start
|
|
243
|
+
// so no parallel test takes it half made, then tags and checks it.
|
|
244
|
+
async newAccount(options) {
|
|
245
|
+
const { create, check, newAccount = {}, tags = [], country, minAge, maxAge, minutes } = options;
|
|
246
|
+
if (!create)
|
|
247
|
+
throw new Error('Pass create to make new accounts.');
|
|
248
|
+
if ((minAge !== undefined || maxAge !== undefined) && !newAccount.age && !newAccount.dateOfBirth) {
|
|
249
|
+
throw new Error('Give newAccount an age or dateOfBirth, so new accounts match minAge and maxAge.');
|
|
250
|
+
}
|
|
251
|
+
const created = await this.createAddress({ ...newAccount, country: newAccount.country ?? country, password: true });
|
|
252
|
+
const held = await this.checkOutAccount({ search: created.address, minutes });
|
|
253
|
+
if (held.address !== created.address) {
|
|
254
|
+
await this.returnAccount(held).catch(() => { });
|
|
255
|
+
throw new Error(`Couldn't hold the new account ${created.address}.`);
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
await create({ ...held, password: created.password });
|
|
259
|
+
const tagged = await this.updateAddress(created.address, { tags: [...new Set([...held.tags, ...tags.map((t) => t.toLowerCase())])] });
|
|
260
|
+
const account = { ...tagged, checkout: held.checkout };
|
|
261
|
+
const ages = age(account.persona?.date_of_birth);
|
|
262
|
+
if (ages !== null && ((minAge !== undefined && ages < minAge) || (maxAge !== undefined && ages > maxAge))) {
|
|
263
|
+
throw new Error(`The new account is ${ages}, outside minAge and maxAge: give newAccount a matching age.`);
|
|
264
|
+
}
|
|
265
|
+
const problem = await this.failedCheck(check, account);
|
|
266
|
+
if (problem !== null)
|
|
267
|
+
throw new Error(`The new account ${created.address} failed its check after create: ${problem}`);
|
|
268
|
+
await this.note(`Made a new test account${tags.length ? ` tagged ${tags.join(', ')}` : ''}.`, created.address);
|
|
269
|
+
return account;
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
// Untagged, it stays out of the pool; it expires with its address.
|
|
273
|
+
await this.updateAddress(created.address, { tags: held.tags }).catch(() => { });
|
|
274
|
+
await this.returnAccount(held).catch(() => { });
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// null when the account passes (or there's no check), or why it doesn't.
|
|
279
|
+
async failedCheck(check, account) {
|
|
280
|
+
if (!check)
|
|
281
|
+
return null;
|
|
282
|
+
try {
|
|
283
|
+
return (await check(account)) === false ? 'check returned false' : null;
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
return message(err);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Runs reset, then returns the account. If reset fails, the account leaves
|
|
290
|
+
// the pool's state (needs-reset) and the error is thrown.
|
|
291
|
+
async restoreAccount(account, options, state) {
|
|
292
|
+
if (options.reset) {
|
|
293
|
+
try {
|
|
294
|
+
await options.reset(account);
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
await this.note(`Couldn't reset the test account (${message(err)}); it loses its tags and is tagged needs-reset.`, account.address);
|
|
298
|
+
await this.returnAccount(account, {
|
|
299
|
+
addTags: [...(state.addTags ?? []), 'needs-reset'],
|
|
300
|
+
removeTags: [...(state.removeTags ?? []), ...(options.tags ?? [])],
|
|
301
|
+
}).catch(() => { });
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
await this.returnAccount(account, state).catch(() => { });
|
|
306
|
+
}
|
|
307
|
+
// Adds a line to the current run, if there is one, without failing the test.
|
|
308
|
+
async note(text, address) {
|
|
309
|
+
if (this.run)
|
|
310
|
+
await this.addRunNote(text, { address }).catch(() => { });
|
|
311
|
+
}
|
|
118
312
|
updateAddress(address, patch) {
|
|
119
313
|
return this.request('PATCH', `/addresses/${encodeURIComponent(address)}`, patch);
|
|
120
314
|
}
|
|
@@ -154,15 +348,21 @@ export class ProxyMailQA {
|
|
|
154
348
|
}
|
|
155
349
|
// Checks one email the address received: SPF, DKIM and DMARC, links to
|
|
156
350
|
// localhost or plain http, where its link goes (expectHost), a stated
|
|
157
|
-
// expiry, a plain-text part, size and image-only emails
|
|
158
|
-
//
|
|
351
|
+
// expiry, a plain-text part, size and image-only emails, and given phrases
|
|
352
|
+
// (expectText) and its language (expectLanguage). Doesn't wait: wait for
|
|
353
|
+
// the email first. Throws ProxyMailQAError (404) when none matches.
|
|
159
354
|
checkEmail(address, options = {}) {
|
|
160
355
|
const extra = {};
|
|
161
356
|
if (options.emailId)
|
|
162
357
|
extra.email_id = options.emailId;
|
|
163
358
|
if (options.expectHost)
|
|
164
359
|
extra.expect_host = options.expectHost;
|
|
165
|
-
|
|
360
|
+
if (options.expectLanguage)
|
|
361
|
+
extra.expect_language = options.expectLanguage;
|
|
362
|
+
let query = countQuery(options, extra);
|
|
363
|
+
for (const phrase of options.expectText ?? [])
|
|
364
|
+
query += `${query ? '&' : '?'}expect_text=${encodeURIComponent(phrase)}`;
|
|
365
|
+
return this.request('GET', `/addresses/${encodeURIComponent(address)}/checks${query}`);
|
|
166
366
|
}
|
|
167
367
|
// Resolves when no matching email arrives within timeout seconds (default
|
|
168
368
|
// 30; longer repeats the request), for checks such as "an unsubscribed user
|
|
@@ -268,3 +468,12 @@ export class ProxyMailQA {
|
|
|
268
468
|
}
|
|
269
469
|
}
|
|
270
470
|
}
|
|
471
|
+
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
472
|
+
// Whole years from a YYYY-MM-DD date of birth, or null without one.
|
|
473
|
+
function age(dateOfBirth) {
|
|
474
|
+
if (!dateOfBirth)
|
|
475
|
+
return null;
|
|
476
|
+
const [y, m, d] = dateOfBirth.split('-').map(Number);
|
|
477
|
+
const now = new Date();
|
|
478
|
+
return now.getUTCFullYear() - y - (now.getUTCMonth() + 1 < m || (now.getUTCMonth() + 1 === m && now.getUTCDate() < d) ? 1 : 0);
|
|
479
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "proxymail-qa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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)",
|