Skip to content

Maestro

One script covers native iOS and Android, React Native and Flutter apps, because Maestro drives the app from outside. Save it next to your flows as proxymail.js:

proxymail.js
// Proxy Mail QA for Maestro flows: works for native iOS and Android, React
// Native and Flutter apps. Guide: https://proxymail.qa/docs/maestro
//
// - runScript: { file: proxymail.js, env: { ACTION: create } }
// output.address, output.password, output.since. Add ENVIRONMENT
// (staging), AGE (adult, under18…) or COUNTRY (GB) to set them.
// - runScript: { file: proxymail.js, env: { ACTION: code } } (or link)
// output.code (or output.link), for mail received since output.since.
// Add APP_LINK: true for the link that opens the app (myapp://…) when
// the email also has a web one, and UNUSED: true to skip a used code.
// - runScript: { file: proxymail.js, env: { ACTION: count } }
// output.count, the emails received since output.since
// - runScript: { file: proxymail.js, env: { ACTION: check, EXPECT_HOST: links.example.com } }
// fails the flow if the newest email fails a check (SPF, DKIM, DMARC,
// local or http links, where its link goes…); output.failed lists them
// - runScript: { file: proxymail.js, env: { ACTION: none } }
// fails the flow if an email arrives within 30 seconds (TIMEOUT)
// - runScript: { file: proxymail.js, env: { ACTION: signin, TAGS: verified } }
// output.address and output.password of a saved test account; needs a
// key allowed to read saved passwords
// - runScript: { file: proxymail.js, env: { ACTION: checkout, TAGS: verified } }
// borrows an account no other test has: output.address, output.password,
// output.token. ACTION: return gives it back (ADD_TAGS, REMOVE_TAGS).
//
// The API key comes from the run: maestro test -e PROXYMAIL_QA_API_KEY=... flow.yaml
// Add -e PROXYMAIL_QA_RUN=$PROXYMAIL_QA_RUN to add the calls to a run (the
// GitHub Action sets it), and TEST: a name, in env, to name the step.
const API = 'https://api.proxymail.qa/v1';
const headers = { Authorization: 'Bearer ' + PROXYMAIL_QA_API_KEY, 'Content-Type': 'application/json' };
if (typeof PROXYMAIL_QA_RUN !== 'undefined' && PROXYMAIL_QA_RUN) {
headers['Proxy-Mail-QA-Run'] = PROXYMAIL_QA_RUN;
// Percent-encoded: a test's name can hold characters headers can't.
if (typeof TEST !== 'undefined' && TEST) headers['Proxy-Mail-QA-Test'] = encodeURIComponent(TEST);
}
function call(method, path, body, extra) {
const options = { headers: extra || headers };
if (body) options.body = JSON.stringify(body);
const response = method === 'POST' ? http.post(API + path, options) : http.get(API + path, options);
return { ok: response.ok, status: response.status, body: response.body ? json(response.body) : {} };
}
function fail(response) {
// When nothing arrives, the hint says why (an email provider in sandbox mode, say).
throw new Error('Proxy Mail QA: ' + (response.body.message || response.status) + ' ' + (response.body.hint || ''));
}
function tags(value) {
return String(value).split(',').map(function (tag) { return tag.trim(); }).filter(function (tag) { return tag; });
}
function account(body) {
output.address = body.address;
output.password = body.password;
output.since = new Date().toISOString();
}
const at = typeof output.address === 'undefined' ? '' : '/addresses/' + encodeURIComponent(output.address) + '/';
const since = '?since=' + encodeURIComponent(output.since);
if (ACTION === 'create') {
const body = { password: true, persona: true };
if (typeof ENVIRONMENT !== 'undefined') body.environment = ENVIRONMENT;
if (typeof AGE !== 'undefined') body.age = AGE;
if (typeof COUNTRY !== 'undefined') body.country = COUNTRY;
const created = call('POST', '/addresses', body);
if (!created.ok) fail(created);
account(created.body);
} else if (ACTION === 'count') {
const counted = call('GET', at + 'count' + since);
if (!counted.ok) fail(counted);
output.count = counted.body.count;
} else if (ACTION === 'check') {
const host = typeof EXPECT_HOST !== 'undefined' ? '&expect_host=' + encodeURIComponent(EXPECT_HOST) : '';
const checked = call('GET', at + 'checks' + since + host);
if (!checked.ok) fail(checked);
output.failed = checked.body.failed.join(', ');
const failures = checked.body.checks.filter(function (c) { return c.status === 'fail'; }).map(function (c) { return c.check + ': ' + c.detail; });
if (failures.length) throw new Error('Proxy Mail QA: the email failed ' + failures.join(' '));
} else if (ACTION === 'none') {
const timeout = typeof TIMEOUT !== 'undefined' ? TIMEOUT : 30;
const watched = call('GET', at + 'none' + since + '&timeout=' + timeout);
if (!watched.ok) fail(watched);
} else if (ACTION === 'signin') {
const body = { tags: typeof TAGS !== 'undefined' ? tags(TAGS) : [] };
if (typeof ENVIRONMENT !== 'undefined') body.environment = ENVIRONMENT;
const found = call('POST', '/addresses/sign-in', body);
if (!found.ok) fail(found);
account(found.body);
} else if (ACTION === 'checkout') {
const held = call('POST', '/addresses/checkout', { tags: tags(TAGS) });
if (!held.ok) fail(held);
// Its saved password, when the key may read it.
const secret = call('GET', '/addresses/' + encodeURIComponent(held.body.address) + '/password');
account({ address: held.body.address, password: secret.ok ? secret.body.secret : undefined });
output.token = held.body.checkout.token;
} else if (ACTION === 'return') {
const body = { token: output.token };
if (typeof ADD_TAGS !== 'undefined') body.add_tags = tags(ADD_TAGS);
if (typeof REMOVE_TAGS !== 'undefined') body.remove_tags = tags(REMOVE_TAGS);
const returned = call('POST', at + 'return', body);
if (!returned.ok) fail(returned);
} else {
// Each request waits up to 10 seconds; four tries cover the email taking up to 40.
const kind = ACTION === 'link' ? 'link' : 'code';
const app = kind === 'link' && typeof APP_LINK !== 'undefined' && String(APP_LINK) === 'true' ? '&app=true' : '';
const unused = typeof UNUSED !== 'undefined' && String(UNUSED) === 'true' ? '&unused=true' : '';
let found = null;
for (let i = 0; i < 4 && !found; i++) {
// Only the last try's "nothing arrived" counts in a run's report.
const retry = JSON.parse(JSON.stringify(headers));
if (i < 3) retry['Proxy-Mail-QA-Run-Retry'] = 'true';
found = call('GET', at + kind + since + '&timeout=10' + app + unused, null, retry);
if (!found.ok && i < 3) found = null;
}
if (!found.ok) throw new Error('Proxy Mail QA: no ' + kind + ' arrived. ' + (found.body.hint || ''));
output[kind] = found.body.value;
}

Then use it in a flow: create the address, sign up with it, and fill in the code.

signup.yaml
appId: com.example.app
---
- launchApp
- runScript:
file: proxymail.js
env:
ACTION: create
- tapOn: "Email"
- inputText: ${output.address}
- tapOn: "Password"
- inputText: ${output.password}
- tapOn: "Sign up"
- runScript:
file: proxymail.js
env:
ACTION: code
- inputText: ${output.code}

Run it with your API key:

Terminal window
maestro test -e PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY signup.yaml

For a link instead of a code, use ACTION: link and then - openLink: ${output.link}, which opens it in the app. If the email has both a web link and one that opens the app (myapp://…), add APP_LINK: true to the script’s env for the app one.

- runScript:
file: proxymail.js
env:
ACTION: count
- assertTrue: ${output.count == 1}
- runScript:
file: proxymail.js
env:
ACTION: check
EXPECT_HOST: links.example.com

ACTION: check fails the flow, naming what failed, when the newest email fails a check: SPF, DKIM and DMARC, links to localhost or plain http, where its link goes (EXPECT_HOST, the domain your app’s links use), a stated expiry, its size and more (what each check means). ACTION: none fails it if an email arrives within 30 seconds, for “an unsubscribed user gets nothing”. After a resend, UNUSED: true on ACTION: code skips the code already used.

On ACTION: create, ENVIRONMENT: staging records which environment the account is for, and AGE: under18 or COUNTRY: GB give its persona a date of birth or a country, for forms that ask.

For a sign-in flow, ACTION: signin with TAGS: verified sets output.address and output.password to a saved test account’s. When flows run in parallel, ACTION: checkout with TAGS: verified lends one no other flow has, with its password, and ACTION: return gives it back (ADD_TAGS and REMOVE_TAGS record what the flow changed):

- runScript:
file: proxymail.js
env:
ACTION: checkout
TAGS: verified
# …sign in with ${output.address} and ${output.password}…
- runScript:
file: proxymail.js
env:
ACTION: return

Both read saved passwords, so the key needs Can read saved passwords (Project settings → API keys). Tag accounts with their state in the app to make pools: see an account pool.

With the GitHub Action in the job, pass the run it starts like the key, and add TEST to each script’s env to name the step:

Terminal window
maestro test -e PROXYMAIL_QA_API_KEY=$PROXYMAIL_QA_API_KEY -e PROXYMAIL_QA_RUN=$PROXYMAIL_QA_RUN signup.yaml