@payzen/comms
v1.0.2
Published
Read SMS/email OTP codes from JS/TS tests (wraps the payzen-comms Python CLI)
Maintainers
Keywords
Readme
@payzen-code/comms
Read SMS and email OTP codes from JS/TS tests. This is a thin wrapper
around the payzen-comms
Python package — it spawns python -m payzen_comms and parses the JSON it
returns. Secrets are read by the Python process from the environment, so the JS
side never touches credentials.
Install
The wrapper is published to GitHub Packages under the @payzen-code scope.
(It can't be git-installed from this monorepo subdirectory — npm/pnpm don't
support installing a subfolder of a git repo as a package, so it's published as
a normal package instead.)
1. Tell your package manager where @payzen-code lives — add an .npmrc
in the consuming repo:
@payzen-code:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}GITHUB_TOKEN needs read:packages. In GitHub Actions the built-in
secrets.GITHUB_TOKEN works; locally use a personal access token.
2. Install:
pnpm add @payzen-code/comms # or npm install @payzen-code/comms3. Install the Python engine (required — needs Python 3.9+). The wrapper shells out to a small Python package; install it once:
pip install "git+ssh://[email protected]/payzen-code/quality.git@master#subdirectory=comms"(pip does support installing from a subdirectory of a git repo, via
#subdirectory=. If the engine is missing at runtime, the wrapper throws a
clear EngineNotInstalled error with this exact command.)
CI: add a
setup-pythonstep, then run thepip installabove. For HTTPS auth usePRIVATE_REPO_TOKEN(same as the existing suites):pip install "git+https://${PRIVATE_REPO_TOKEN}@github.com/payzen-code/quality.git@master#subdirectory=comms".
You need read access to the payzen-code/quality repo (you already have it as a
PayZen dev).
Usage
Avoiding stale mail — read this. There is only one real mailbox; every
automations+<x>@payzen.comalias lands in it. So a plain lookup by alias can return an email from a previous run. Two defenses, use both:
uniqueEmail()— get a fresh, time-based alias per test.since— only count mail that arrived after a cutoff you captured before triggering the flow.uniqueEmail()returns that cutoff for you.
import {
getEmailOtp, getSmsOtp, getLatestEmail, waitForEmail,
uniqueEmail, now, CommsError,
} from "@payzen-code/comms";
// 1. Get a unique alias + a "since" cutoff BEFORE triggering anything.
const { email, since } = uniqueEmail({ tag: "rollup" });
// -> { email: "[email protected]", since: 1717352999123 }
// ...use `email` in your signup/login flow, which sends the message...
// 2. OTP, scoped to mail newer than `since` so an old code can't be reused.
const { otp } = await getEmailOtp({ address: email, since, timeoutMs: 120_000 });
const sms = await getSmsOtp({ phone: "+15627356015", since, timeoutMs: 30_000 });
// 3. Verify email CONTENT (not just OTP): waitForEmail resolves with the
// matching email, or rejects with a TimeoutError if no match arrives in time.
const confirmation = await waitForEmail({
address: email,
contains: ["Payment confirmed", "$1,068.00"], // substrings; all must be in the body
subjectContains: "confirmed",
since,
timeoutMs: 120_000,
});
expect(confirmation.body).toContain("Erica");
try {
await getEmailOtp({ address: "[email protected]", timeoutMs: 5_000 });
} catch (e) {
if (e instanceof CommsError) console.error(e.type, e.message); // e.g. "TimeoutError"
}Playwright example
await page.getByLabel("Email address").fill(email);
await page.getByRole("button", { name: "Get Login Code" }).click();
const { otp } = await getEmailOtp({ address: email, timeoutMs: 120_000 });
await page.getByLabel("Code").fill(otp);Config
| Env var | Default | Purpose |
|---|---|---|
| PAYZEN_COMMS_PYTHON | python3 | Python executable used to run the CLI |
API
uniqueEmail({ prefix?, domain?, tag? })→{ email, since }— fresh alias + cutoffnow()→number— current Unix-ms, for use assincegetEmailOtp({ address, timeoutMs?, bodyContains?, initialWaitMs?, since? })→{ otp, subject, receivedAt }getLatestEmail({ address, bodyContains?, maxResults?, since? })→{ subject, body, receivedAt }waitForEmail({ address, contains?, subjectContains?, timeoutMs?, initialWaitMs?, maxResults?, since? })→{ subject, body, receivedAt }getSmsOtp({ phone, timeoutMs?, since? })→{ otp, body, receivedAt }getLatestSms({ phone, maxResults?, since? })→{ body, receivedAt }
contains matches substrings (an array requires all of them), so partial
text is fine — it never needs to be the whole body.
All reject with a CommsError (carrying .type, e.g. TimeoutError,
RuntimeError) on failure.
