@mailsai/cypress 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +164 -2
- package/dist/core.d.ts +285 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +432 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/package.json +55 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mails.ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,165 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mailsai/cypress
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Cypress commands for testing the email your app sends at sign-up, sign-in and password reset. Each test run gets an email address of its own, a plus address of one mails.ai agent, then waits for the email sent to that address and reads the code or the link in it.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
cy.mailsaiPlusAddress().then((email) => {
|
|
7
|
+
// …sign up with `email`…
|
|
8
|
+
cy.mailsaiWaitForCode({ to: email }).then((code) => cy.get("[data-cy=code]").type(code));
|
|
9
|
+
});
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## What you need
|
|
13
|
+
|
|
14
|
+
- A mails.ai API key with the `read` scope.
|
|
15
|
+
- A mails.ai agent whose receiving address gets the test mail, such as `qa.acme@in.mails.ai`. Mail to any plus address of it, such as `qa.acme+run-1842@in.mails.ai`, reaches the same agent.
|
|
16
|
+
- Cypress 13 or later.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
npm install --save-dev @mailsai/cypress
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then import it once, in your support file (`cypress/support/e2e.ts`, or `e2e.js`):
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import "@mailsai/cypress";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That registers the four commands and adds their types to `cy`. TypeScript sees the types through this import when your tsconfig includes the support file. If it does not, add `"@mailsai/cypress"` to `compilerOptions.types` next to `"cypress"`.
|
|
31
|
+
|
|
32
|
+
## Settings
|
|
33
|
+
|
|
34
|
+
Put the settings in the `env` block of your Cypress config, read from your environment, so no key is written into the repository:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// cypress.config.ts
|
|
38
|
+
import { defineConfig } from "cypress";
|
|
39
|
+
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
e2e: {
|
|
42
|
+
baseUrl: "http://localhost:3000",
|
|
43
|
+
},
|
|
44
|
+
env: {
|
|
45
|
+
MAILS_API_KEY: process.env.MAILS_API_KEY,
|
|
46
|
+
MAILS_RECEIVING_ADDRESS: process.env.MAILS_RECEIVING_ADDRESS,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or leave the block out and set each one as an environment variable with the `CYPRESS_` prefix, which Cypress removes:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
CYPRESS_MAILS_API_KEY=mk_live_… CYPRESS_MAILS_RECEIVING_ADDRESS=qa.acme@in.mails.ai npx cypress run
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Setting | |
|
|
58
|
+
| --- | --- |
|
|
59
|
+
| `MAILS_API_KEY` | Required by the three wait commands. |
|
|
60
|
+
| `MAILS_RECEIVING_ADDRESS` | Required by `mailsaiPlusAddress`: the receiving address of the agent the test mail goes to. |
|
|
61
|
+
| `MAILS_BASE_URL` | Optional. `https://api.mails.ai` when unset. |
|
|
62
|
+
| `MAILS_AGENT_ID` | Optional. The wait commands' `agentId` when they are not given one. |
|
|
63
|
+
|
|
64
|
+
The key never appears in the Command Log, a screenshot or a video. The commands read the settings with `cy.env()`, which keeps their values in the Cypress process, and make their requests with `log: false`. Each command writes one Command Log line of its own, showing its filters. On Cypress older than 15.10, which has no `cy.env()`, the settings are read with `Cypress.env()`.
|
|
65
|
+
|
|
66
|
+
## Example
|
|
67
|
+
|
|
68
|
+
A sign-up that sends a 6-digit code:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// cypress/e2e/signup.cy.ts
|
|
72
|
+
describe("sign-up", () => {
|
|
73
|
+
it("verifies a new account with the emailed code", () => {
|
|
74
|
+
cy.mailsaiPlusAddress().then((email) => {
|
|
75
|
+
cy.visit("/signup");
|
|
76
|
+
cy.get("[data-cy=email]").type(email);
|
|
77
|
+
cy.get("[data-cy=submit]").click();
|
|
78
|
+
|
|
79
|
+
cy.mailsaiWaitForCode({ to: email }).then((code) => {
|
|
80
|
+
cy.get("[data-cy=code]").type(code);
|
|
81
|
+
});
|
|
82
|
+
cy.get("[data-cy=verify]").click();
|
|
83
|
+
|
|
84
|
+
cy.contains("Your account is verified");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
For a sign-in link or a password reset, wait for the link and visit it:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
cy.mailsaiWaitForLink({ to: email, kind: "reset_password" }).then((url) => cy.visit(url));
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Commands
|
|
97
|
+
|
|
98
|
+
### `cy.mailsaiPlusAddress(tag?)`
|
|
99
|
+
|
|
100
|
+
Yields a new address for this run: `MAILS_RECEIVING_ADDRESS` with `+<tag>` before the `@`. Without a tag, each call makes a new one from `run-`, the time and 4 random characters, such as `qa.acme+run-mgt2k4x1-7q3z@in.mails.ai`.
|
|
101
|
+
|
|
102
|
+
A tag is 1 to 40 letters, digits, `.`, `_`, `-` or `+`, and is lowercased. Pass a tag of your own when the address must be known in advance, such as `cy.mailsaiPlusAddress("checkout")`. Mail sent to it by an earlier run then counts too, unless the wait is given `since`.
|
|
103
|
+
|
|
104
|
+
The part of an address before the `@` holds at most 64 characters, the `+` and the tag included. When the receiving address leaves room for fewer than the 17 characters of the default tag, the default is as many random characters as fit. With room for fewer than 6, the command fails and says how much room there is, as it does for a tag of your own that is too long.
|
|
105
|
+
|
|
106
|
+
### `cy.mailsaiWaitForMessage(options?)`
|
|
107
|
+
|
|
108
|
+
Yields the newest received email that matches the options, waiting for one to arrive.
|
|
109
|
+
|
|
110
|
+
| Option | |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `to` | Only mail sent to this address. Pass the plus address the test signed up with, so an email meant for another run is never taken. |
|
|
113
|
+
| `from` | Only mail from this address (`noreply@github.com`), or from this domain and its subdomains (`github.com`). |
|
|
114
|
+
| `has` | `"code"`: only mail with a verification code. `"link"`: only mail with a verify, sign-in or password-reset link. |
|
|
115
|
+
| `since` | Only mail received at or after this time (ISO 8601). Without it, the API looks back 5 minutes, so an email that arrived just before the wait began still counts. |
|
|
116
|
+
| `timeout` | Seconds to wait in all, 60 when not given. The command waits in calls of at most 50 seconds each until the time is used up. |
|
|
117
|
+
| `agentId` | Only this agent's mail. `MAILS_AGENT_ID` when not given. |
|
|
118
|
+
|
|
119
|
+
The message it yields carries `codes` and `links` (below), `subject`, `from`, `delivered_to` (the address it was sent to) and the other fields of a received message in the API reference.
|
|
120
|
+
|
|
121
|
+
When nothing arrives in time, the test fails with a message that names the filters:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
mailsaiWaitForMessage: no email arrived within 60 s matching to: qa.acme+run-mgt2k4x1-7q3z@in.mails.ai.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `cy.mailsaiWaitForCode(options?)`
|
|
128
|
+
|
|
129
|
+
Waits as `mailsaiWaitForMessage` does, for mail with a code, and yields the code: `codes[0].value`, the most likely code in the email. Takes the same options, without `has`.
|
|
130
|
+
|
|
131
|
+
### `cy.mailsaiWaitForLink(options?)`
|
|
132
|
+
|
|
133
|
+
Waits as `mailsaiWaitForMessage` does, for mail with a verify, sign-in or password-reset link, and yields a URL. Takes the same options, without `has`, and one more:
|
|
134
|
+
|
|
135
|
+
| Option | |
|
|
136
|
+
| --- | --- |
|
|
137
|
+
| `kind` | `"verify"`, `"sign_in"`, `"reset_password"` or `"other"`: yield the first link of this kind. Without it, the first `verify`, `sign_in` or `reset_password` link. |
|
|
138
|
+
|
|
139
|
+
It never yields an unsubscribe link, and fails when the email has no link it may yield. Only mail with a verify, sign-in or password-reset link is waited for, so `kind: "other"` finds a link of that kind only in such an email.
|
|
140
|
+
|
|
141
|
+
## When a call is refused
|
|
142
|
+
|
|
143
|
+
A refused call fails the test with the API's error code and message, such as:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
mailsaiWaitForCode: mails.ai answered 401 invalid_api_key: Invalid API key (request req_…)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The API takes 60 waits a minute for one workspace. When it answers 429, the command pauses for as long as the answer asks and tries again, as long as what is left of its `timeout` covers the pause.
|
|
150
|
+
|
|
151
|
+
Past the plan's daily plus-address limit, a wait given `to` fails at once with 402 `plan_limit_exceeded` instead of running out its `timeout`, and its message ends with the link to change plan.
|
|
152
|
+
|
|
153
|
+
## Limits
|
|
154
|
+
|
|
155
|
+
A workspace may receive mail at a limited number of different plus addresses a day, set by its plan. Past that, mail to a new plus address keeps only a record, which a wait never yields, while the addresses already used that day keep working. The numbers for each plan are at https://mails.ai/docs/limits.
|
|
156
|
+
|
|
157
|
+
A sign-in code or link from a service such as Google, Microsoft, Apple, a payment app or a bank never reaches a plus address either: such mail keeps only a record, so a wait for it runs out its `timeout`. Unless your app is one of those services, its own emails are not affected.
|
|
158
|
+
|
|
159
|
+
## Tests
|
|
160
|
+
|
|
161
|
+
[`packages/cypress/e2e`](https://github.com/mailsai/mailsai/tree/main/packages/cypress/e2e) in the repository is a Cypress project that uses these commands the way a test suite would, against a stand-in for the mails.ai API and a small sign-up page. In `packages/cypress`, `npm run test:e2e` builds and packs the package, installs it there and runs it headless. After `npm run build`, `node --test test/*.test.mjs` runs the unit tests.
|
|
162
|
+
|
|
163
|
+
## License
|
|
164
|
+
|
|
165
|
+
MIT
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/** Where the API is when MAILS_BASE_URL is not set. */
|
|
2
|
+
export declare const DEFAULT_BASE_URL = "https://api.mails.ai";
|
|
3
|
+
/** The longest one wait call may hold, in seconds: the route's own limit. */
|
|
4
|
+
export declare const MAX_WAIT_SECONDS = 50;
|
|
5
|
+
/** A wait command's whole timeout when none is given, in seconds. */
|
|
6
|
+
export declare const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
7
|
+
/** How much longer than the wait it asked for one request may take, in seconds. */
|
|
8
|
+
export declare const REQUEST_GRACE_SECONDS = 15;
|
|
9
|
+
/** The longest pause after a 429 before the next try, in seconds. The route's limit counts a
|
|
10
|
+
* minute, so it never asks for more. */
|
|
11
|
+
export declare const MAX_RETRY_PAUSE_SECONDS = 60;
|
|
12
|
+
export type LinkKind = "verify" | "sign_in" | "reset_password" | "unsubscribe" | "other";
|
|
13
|
+
/** The kinds mailsaiWaitForLink can be asked for: every kind but `unsubscribe`, which it never yields. */
|
|
14
|
+
export type MailsaiLinkKind = Exclude<LinkKind, "unsubscribe">;
|
|
15
|
+
/** The links that are the point of an email: what `has=link` matches on the API, and what
|
|
16
|
+
* mailsaiWaitForLink yields when no kind is given. */
|
|
17
|
+
export declare const ACTION_LINK_KINDS: readonly LinkKind[];
|
|
18
|
+
/** A file on a received message, as @mailsai/sdk types it. */
|
|
19
|
+
export type MailsaiReceivedAttachment = {
|
|
20
|
+
/** Attachment id (`att_…`), unique within the message. */
|
|
21
|
+
id: string;
|
|
22
|
+
/** The file's name, cleaned: no path, no control characters, at most 255 characters. */
|
|
23
|
+
filename: string;
|
|
24
|
+
/** The MIME type the sender declared, e.g. `application/pdf`. */
|
|
25
|
+
content_type: string;
|
|
26
|
+
/** Size in bytes of the decoded file. */
|
|
27
|
+
size: number;
|
|
28
|
+
/** The part's Content-ID without angle brackets, which the HTML body references as `cid:`. */
|
|
29
|
+
content_id: string | null;
|
|
30
|
+
/** True for an image embedded in the HTML body rather than attached as a file. */
|
|
31
|
+
inline: boolean;
|
|
32
|
+
};
|
|
33
|
+
/** A file the message carried that was not kept, with the reason. */
|
|
34
|
+
export type MailsaiOmittedAttachment = {
|
|
35
|
+
filename: string;
|
|
36
|
+
content_type: string;
|
|
37
|
+
size: number | null;
|
|
38
|
+
reason: "too_large" | "budget" | "over_quota" | "unavailable" | "too_many";
|
|
39
|
+
};
|
|
40
|
+
/** A received message, as @mailsai/sdk types it (its `ReceivedMessage`). */
|
|
41
|
+
export type MailsaiReceivedMessage = {
|
|
42
|
+
id: string;
|
|
43
|
+
agent_id: string;
|
|
44
|
+
thread_id?: string;
|
|
45
|
+
/** The first mailbox of the From header. Proven only when `dmarc` is "pass". */
|
|
46
|
+
from: {
|
|
47
|
+
address: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
};
|
|
50
|
+
/** The envelope sender (SMTP MAIL FROM), where bounces go. Null on mail received before this field was added. */
|
|
51
|
+
envelope_from?: string | null;
|
|
52
|
+
to: string;
|
|
53
|
+
/** The address the email was sent to: the agent's receiving address, or a plus address of it.
|
|
54
|
+
* What the `to` filter matches. Null on mail received before 2026-10-16. */
|
|
55
|
+
delivered_to?: string | null;
|
|
56
|
+
subject?: string;
|
|
57
|
+
is_thread_reply?: boolean;
|
|
58
|
+
in_reply_to_message_id?: string;
|
|
59
|
+
thread_root_message_id?: string;
|
|
60
|
+
/** Full body: single-message `get` only. */
|
|
61
|
+
body_text?: string;
|
|
62
|
+
/** The HTML part: single-message `get` only, when the injection scan read it. */
|
|
63
|
+
body_html?: string;
|
|
64
|
+
extracted_text?: string;
|
|
65
|
+
extracted_html?: string;
|
|
66
|
+
body_text_excerpt?: string;
|
|
67
|
+
/** Verification codes in the email (sign-up, sign-in, one-time), most likely first, at most 5. */
|
|
68
|
+
codes?: {
|
|
69
|
+
value: string;
|
|
70
|
+
}[];
|
|
71
|
+
/** The email's links, `verify`, `sign_in` and `reset_password` first, `unsubscribe` last, at most 25. */
|
|
72
|
+
links?: {
|
|
73
|
+
url: string;
|
|
74
|
+
kind: LinkKind;
|
|
75
|
+
}[];
|
|
76
|
+
spf_pass?: boolean;
|
|
77
|
+
dkim_pass?: boolean;
|
|
78
|
+
dkim?: "pass" | "fail" | "none" | "unknown";
|
|
79
|
+
/** The domain of a passing DKIM signature that matches the domain of `from.address`, or null. */
|
|
80
|
+
dkim_domain?: string | null;
|
|
81
|
+
/** "pass" when the domain of `from.address` publishes DMARC and a DKIM signature aligned with it verifies. */
|
|
82
|
+
dmarc?: "pass" | "fail" | "none" | "unknown";
|
|
83
|
+
/** True when the sender was judged forged. */
|
|
84
|
+
auth_failed?: boolean;
|
|
85
|
+
/** True when the message carries at least one file attachment (embedded images do not count). */
|
|
86
|
+
has_attachment?: boolean;
|
|
87
|
+
/** Every part of the message that is a file, embedded images included. */
|
|
88
|
+
attachments?: MailsaiReceivedAttachment[];
|
|
89
|
+
attachments_omitted?: MailsaiOmittedAttachment[];
|
|
90
|
+
attachments_omitted_count?: number;
|
|
91
|
+
/** Present when the raw email was not kept, and why. */
|
|
92
|
+
raw_omitted?: "too_large" | "budget" | "over_quota" | "unavailable";
|
|
93
|
+
parse_status?: "parsed" | "quarantined" | "pending" | "failed" | "quota_skipped";
|
|
94
|
+
received_at: string;
|
|
95
|
+
created_at?: string;
|
|
96
|
+
};
|
|
97
|
+
export type MailsaiWaitOptions = {
|
|
98
|
+
/** Only mail sent to this address, such as the plus address the test signed up with. */
|
|
99
|
+
to?: string;
|
|
100
|
+
/** Only mail from this address, or from this domain and its subdomains. */
|
|
101
|
+
from?: string;
|
|
102
|
+
/** Only mail with a verification code, or with a verify, sign-in or password-reset link. */
|
|
103
|
+
has?: "code" | "link";
|
|
104
|
+
/** Only mail received at or after this time (ISO 8601). Without it the API looks back 5 minutes. */
|
|
105
|
+
since?: string;
|
|
106
|
+
/** Seconds to wait in all, 60 by default. Spent as waits of at most 50 seconds each. */
|
|
107
|
+
timeout?: number;
|
|
108
|
+
/** Only this agent's mail. MAILS_AGENT_ID by default. */
|
|
109
|
+
agentId?: string;
|
|
110
|
+
};
|
|
111
|
+
export type MailsaiWaitForCodeOptions = Omit<MailsaiWaitOptions, "has">;
|
|
112
|
+
export type MailsaiWaitForLinkOptions = Omit<MailsaiWaitOptions, "has"> & {
|
|
113
|
+
/** The kind of link to yield. Without it: the first verify, sign_in or reset_password link. */
|
|
114
|
+
kind?: MailsaiLinkKind;
|
|
115
|
+
};
|
|
116
|
+
export type WaitCommand = "mailsaiWaitForMessage" | "mailsaiWaitForCode" | "mailsaiWaitForLink";
|
|
117
|
+
/** The settings the commands read, from the `env` block of the Cypress config or CYPRESS_* variables. */
|
|
118
|
+
export declare const CONFIG_KEYS: readonly ["MAILS_API_KEY", "MAILS_RECEIVING_ADDRESS", "MAILS_BASE_URL", "MAILS_AGENT_ID"];
|
|
119
|
+
export type MailsaiConfig = {
|
|
120
|
+
apiKey: string;
|
|
121
|
+
receivingAddress: string;
|
|
122
|
+
baseUrl: string;
|
|
123
|
+
agentId: string;
|
|
124
|
+
};
|
|
125
|
+
/** The settings as the commands use them: trimmed, and "" for one that is unset. */
|
|
126
|
+
export declare function configFrom(env: Record<string, unknown>): MailsaiConfig;
|
|
127
|
+
/** The error a command fails with when a setting it needs is unset. */
|
|
128
|
+
export declare function missingSetting(command: string, key: "MAILS_API_KEY" | "MAILS_RECEIVING_ADDRESS"): Error;
|
|
129
|
+
/** The API's address: MAILS_BASE_URL without its trailing slashes, or https://api.mails.ai when it
|
|
130
|
+
* is empty, as in @mailsai/sdk. */
|
|
131
|
+
export declare function baseUrlOf(raw: string): string;
|
|
132
|
+
/** The most characters the part of an address before the @ may hold: RFC 5321's limit, and what
|
|
133
|
+
* the API's `to` filter takes. A receiving address that already has 64 leaves no room for a tag. */
|
|
134
|
+
export declare const MAX_LOCAL_PART = 64;
|
|
135
|
+
/** The most characters a tag may have. */
|
|
136
|
+
export declare const MAX_TAG = 40;
|
|
137
|
+
/** The fewest random characters a shortened default tag may have and still be unique to its run. */
|
|
138
|
+
export declare const MIN_UNIQUE_TAG = 6;
|
|
139
|
+
/**
|
|
140
|
+
* A plus address of an agent: its receiving address with `+<tag>` before the `@`, which reaches the
|
|
141
|
+
* same agent. The same rules as `plusAddress()` in @mailsai/sdk: the tag is trimmed of ASCII
|
|
142
|
+
* whitespace and lowercased and must be 1 to 40 letters, digits, `.`, `_`, `-` or `+`; only the part
|
|
143
|
+
* of the local part before its first `+` is kept, and with the tag it may hold at most 64
|
|
144
|
+
* characters; the whole address is lowercased.
|
|
145
|
+
*/
|
|
146
|
+
export declare function plusAddress(receivingAddress: string, tag: string): string;
|
|
147
|
+
/** How many characters a tag on `receivingAddress` may have: 40, or fewer when the part before its
|
|
148
|
+
* @ leaves less room under MAX_LOCAL_PART (0 or less: none). */
|
|
149
|
+
export declare function tagRoom(receivingAddress: string): number;
|
|
150
|
+
/**
|
|
151
|
+
* A tag for one run: `run-`, the time in base 36 and 4 random letters or digits, such as
|
|
152
|
+
* `run-mgt2k4x1-7q3z` (17 characters). When `room` (tagRoom()) is less than that, as many random
|
|
153
|
+
* characters as fit, which keep the tag unique while at least MIN_UNIQUE_TAG fit; with fewer, the
|
|
154
|
+
* full tag, which plusAddress() then refuses, naming the room there is.
|
|
155
|
+
*/
|
|
156
|
+
export declare function defaultTag(now?: number, random?: (n: number) => string, room?: number): string;
|
|
157
|
+
/** The address mailsaiPlusAddress yields: plusAddress() with the tag given, or with a new default
|
|
158
|
+
* tag per call that fits the receiving address. */
|
|
159
|
+
export declare function plusAddressFor(receivingAddress: string, tag: unknown, now?: number, random?: (n: number) => string): string;
|
|
160
|
+
/** A wait command's options, checked: the filters as the API takes them and the whole timeout. */
|
|
161
|
+
export type WaitPlan = {
|
|
162
|
+
to?: string;
|
|
163
|
+
from?: string;
|
|
164
|
+
has?: "code" | "link";
|
|
165
|
+
since?: string;
|
|
166
|
+
agentId?: string;
|
|
167
|
+
/** Whole seconds to wait in all. */
|
|
168
|
+
timeout: number;
|
|
169
|
+
/** mailsaiWaitForLink only: the kind of link to yield. */
|
|
170
|
+
kind?: MailsaiLinkKind;
|
|
171
|
+
};
|
|
172
|
+
/** A wait command's options, checked: an unknown option, an empty filter or a bad timeout throws,
|
|
173
|
+
* and mailsaiWaitForCode and mailsaiWaitForLink set `has` themselves. */
|
|
174
|
+
export declare function waitPlan(command: WaitCommand, input: unknown): WaitPlan;
|
|
175
|
+
/** The filters, as the Command Log line and the errors show them. */
|
|
176
|
+
export declare function describeFilters(plan: WaitPlan): string;
|
|
177
|
+
/** The seconds the next wait call asks for: what is left of the timeout, at most 50. */
|
|
178
|
+
export declare function nextWaitSeconds(left: number): number;
|
|
179
|
+
/** How a timeout is spent when nothing arrives: 60 → [50, 10], 120 → [50, 50, 20], 0 → [0]. */
|
|
180
|
+
export declare function splitTimeout(total: number): number[];
|
|
181
|
+
export type WaitRequest = {
|
|
182
|
+
method: "GET";
|
|
183
|
+
url: string;
|
|
184
|
+
headers: Record<string, string>;
|
|
185
|
+
timeout: number;
|
|
186
|
+
};
|
|
187
|
+
/** One call to GET /v1/messages/received/wait asking to hold for `seconds`. `timeout` is how long
|
|
188
|
+
* Cypress gives the request, in milliseconds: the wait and REQUEST_GRACE_SECONDS more. `since` is
|
|
189
|
+
* sent only when given, so the API's own look-back applies. */
|
|
190
|
+
export declare function waitRequest(baseUrl: string, apiKey: string, plan: WaitPlan, seconds: number): WaitRequest;
|
|
191
|
+
/** The API's error body (lib/errors.ts), as read from a refused call. */
|
|
192
|
+
export type ApiErrorDetails = {
|
|
193
|
+
status: number;
|
|
194
|
+
type: string;
|
|
195
|
+
code: string;
|
|
196
|
+
message: string;
|
|
197
|
+
param: string | null;
|
|
198
|
+
requestId: string | null;
|
|
199
|
+
/** Where to change plan: the `upgrade_url` every 402 (payment_error) carries, such as a wait
|
|
200
|
+
* for mail the plan's daily plus-address limit refused. Null otherwise. */
|
|
201
|
+
upgradeUrl: string | null;
|
|
202
|
+
};
|
|
203
|
+
export type WaitAnswer = {
|
|
204
|
+
kind: "message";
|
|
205
|
+
message: MailsaiReceivedMessage;
|
|
206
|
+
} | {
|
|
207
|
+
kind: "timed_out";
|
|
208
|
+
} | {
|
|
209
|
+
kind: "rate_limited";
|
|
210
|
+
error: ApiErrorDetails;
|
|
211
|
+
/** Seconds until the limit lets a wait through, from Retry-After or the body's
|
|
212
|
+
* retry_after_seconds; null when the answer did not say. */
|
|
213
|
+
retryAfterSeconds: number | null;
|
|
214
|
+
/** False when the body's resets_at is null: no wait lifts the limit. */
|
|
215
|
+
waitHelps: boolean;
|
|
216
|
+
} | {
|
|
217
|
+
kind: "error";
|
|
218
|
+
error: ApiErrorDetails;
|
|
219
|
+
};
|
|
220
|
+
type Headers = Record<string, string | string[] | undefined>;
|
|
221
|
+
/** `Retry-After` in seconds: a number of seconds or an HTTP date. Null when absent or unreadable. */
|
|
222
|
+
export declare function parseRetryAfter(value: string | null, now?: number): number | null;
|
|
223
|
+
/** What one wait call's answer means: a message, nothing in time, a 429, or an error. */
|
|
224
|
+
export declare function readWaitResponse(status: number, body: unknown, headers?: Headers): WaitAnswer;
|
|
225
|
+
/** Whole seconds to pause after a 429 before trying again: what the answer asked for, else 1, 2,
|
|
226
|
+
* 4 then 8 for 429s in a row (`times` is how many came before this one), at most
|
|
227
|
+
* MAX_RETRY_PAUSE_SECONDS. Null when no wait lifts the limit. */
|
|
228
|
+
export declare function retryPause(answer: {
|
|
229
|
+
retryAfterSeconds: number | null;
|
|
230
|
+
waitHelps: boolean;
|
|
231
|
+
}, times: number): number | null;
|
|
232
|
+
/** The error of a refused wait, carrying the API's code and message, and on a 402 ending with the
|
|
233
|
+
* link to change plan, so a failed CI run says where to go. */
|
|
234
|
+
export declare class MailsaiApiError extends Error {
|
|
235
|
+
readonly status: number;
|
|
236
|
+
readonly type: string;
|
|
237
|
+
readonly code: string;
|
|
238
|
+
readonly param: string | null;
|
|
239
|
+
readonly requestId: string | null;
|
|
240
|
+
readonly upgradeUrl: string | null;
|
|
241
|
+
constructor(command: string, e: ApiErrorDetails, extra?: string);
|
|
242
|
+
}
|
|
243
|
+
/** The error of a 429 the rest of the timeout cannot wait out. */
|
|
244
|
+
export declare function rateLimitError(command: string, e: ApiErrorDetails, pause: number | null, left: number, timeout: number): MailsaiApiError;
|
|
245
|
+
/** The error when nothing matching arrived within the timeout. */
|
|
246
|
+
export declare function noMailError(command: string, plan: WaitPlan): Error;
|
|
247
|
+
/** Where a wait stands: `left` is the seconds of its timeout not yet spent, `limited` how many 429s
|
|
248
|
+
* in a row came before the next call. A wait starts at { left: timeout, limited: 0 }. */
|
|
249
|
+
export type PollState = {
|
|
250
|
+
left: number;
|
|
251
|
+
limited: number;
|
|
252
|
+
};
|
|
253
|
+
export type PollStep = {
|
|
254
|
+
do: "yield";
|
|
255
|
+
message: MailsaiReceivedMessage;
|
|
256
|
+
} | {
|
|
257
|
+
do: "call";
|
|
258
|
+
state: PollState;
|
|
259
|
+
} | {
|
|
260
|
+
do: "pause";
|
|
261
|
+
seconds: number;
|
|
262
|
+
state: PollState;
|
|
263
|
+
} | {
|
|
264
|
+
do: "fail";
|
|
265
|
+
error: Error;
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* What a wait does after the answer to a call that asked to hold for `seconds`: yield the message,
|
|
269
|
+
* make the next call while time is left (a timeout of 60 is a 50 s call, then a 10 s one), pause
|
|
270
|
+
* after a 429 for as long as it asks when the rest of the timeout covers the pause, or fail.
|
|
271
|
+
*/
|
|
272
|
+
export declare function afterAnswer(command: WaitCommand, plan: WaitPlan, state: PollState, seconds: number, answer: WaitAnswer): PollStep;
|
|
273
|
+
/** The message, as the Command Log and the errors name it. */
|
|
274
|
+
export declare function messageLabel(message: MailsaiReceivedMessage): string;
|
|
275
|
+
/** The code mailsaiWaitForCode yields: `codes[0].value`, or null when there is none. */
|
|
276
|
+
export declare function codeOf(message: MailsaiReceivedMessage): string | null;
|
|
277
|
+
/** The link mailsaiWaitForLink yields: the first of `kind`, or without one the first verify,
|
|
278
|
+
* sign_in or reset_password link. Never an unsubscribe link; null when none fits. */
|
|
279
|
+
export declare function linkOf(message: MailsaiReceivedMessage, kind?: MailsaiLinkKind): string | null;
|
|
280
|
+
/** The error when the message mailsaiWaitForCode got has no code. */
|
|
281
|
+
export declare function noCodeError(command: string, message: MailsaiReceivedMessage): Error;
|
|
282
|
+
/** The error when the message mailsaiWaitForLink got has no link it may yield, naming the kinds it has. */
|
|
283
|
+
export declare function noLinkError(command: string, message: MailsaiReceivedMessage, kind?: MailsaiLinkKind): Error;
|
|
284
|
+
export {};
|
|
285
|
+
//# sourceMappingURL=core.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"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"}
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
// @mailsai/cypress, the part with no Cypress in it: the plus address, the settings, the wait
|
|
2
|
+
// request, reading its answer, picking the code or the link, and spending a command's timeout as
|
|
3
|
+
// waits the API allows. index.ts turns these into Cypress commands; test/ runs them under node:test.
|
|
4
|
+
//
|
|
5
|
+
// The API side is GET /v1/messages/received/wait (app/api/v1/messages/received/wait/route.ts): it
|
|
6
|
+
// holds for up to 50 seconds and answers 200 `{ message, timed_out }`, with `message` null when
|
|
7
|
+
// nothing matched in time, and 429 past 60 waits a minute for one workspace.
|
|
8
|
+
/** Where the API is when MAILS_BASE_URL is not set. */
|
|
9
|
+
export const DEFAULT_BASE_URL = "https://api.mails.ai";
|
|
10
|
+
/** The longest one wait call may hold, in seconds: the route's own limit. */
|
|
11
|
+
export const MAX_WAIT_SECONDS = 50;
|
|
12
|
+
/** A wait command's whole timeout when none is given, in seconds. */
|
|
13
|
+
export const DEFAULT_TIMEOUT_SECONDS = 60;
|
|
14
|
+
/** How much longer than the wait it asked for one request may take, in seconds. */
|
|
15
|
+
export const REQUEST_GRACE_SECONDS = 15;
|
|
16
|
+
/** The longest pause after a 429 before the next try, in seconds. The route's limit counts a
|
|
17
|
+
* minute, so it never asks for more. */
|
|
18
|
+
export const MAX_RETRY_PAUSE_SECONDS = 60;
|
|
19
|
+
/** The links that are the point of an email: what `has=link` matches on the API, and what
|
|
20
|
+
* mailsaiWaitForLink yields when no kind is given. */
|
|
21
|
+
export const ACTION_LINK_KINDS = ["verify", "sign_in", "reset_password"];
|
|
22
|
+
const WANTED_KINDS = ["verify", "sign_in", "reset_password", "other"];
|
|
23
|
+
// ── settings ────────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
/** The settings the commands read, from the `env` block of the Cypress config or CYPRESS_* variables. */
|
|
25
|
+
export const CONFIG_KEYS = ["MAILS_API_KEY", "MAILS_RECEIVING_ADDRESS", "MAILS_BASE_URL", "MAILS_AGENT_ID"];
|
|
26
|
+
function setting(v) {
|
|
27
|
+
if (typeof v === "string")
|
|
28
|
+
return v.trim();
|
|
29
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
30
|
+
return String(v);
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
/** The settings as the commands use them: trimmed, and "" for one that is unset. */
|
|
34
|
+
export function configFrom(env) {
|
|
35
|
+
return {
|
|
36
|
+
apiKey: setting(env.MAILS_API_KEY),
|
|
37
|
+
receivingAddress: setting(env.MAILS_RECEIVING_ADDRESS),
|
|
38
|
+
baseUrl: setting(env.MAILS_BASE_URL),
|
|
39
|
+
agentId: setting(env.MAILS_AGENT_ID),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** The error a command fails with when a setting it needs is unset. */
|
|
43
|
+
export function missingSetting(command, key) {
|
|
44
|
+
const what = key === "MAILS_API_KEY"
|
|
45
|
+
? "a mails.ai API key with the read scope"
|
|
46
|
+
: "the receiving address of the mails.ai agent the test mail goes to, such as qa.acme@in.mails.ai";
|
|
47
|
+
return new Error(`${command} needs ${key}: ${what}. Set it in the env block of cypress.config.ts (env: { ${key}: process.env.${key} }) or as the environment variable CYPRESS_${key}.`);
|
|
48
|
+
}
|
|
49
|
+
/** The API's address: MAILS_BASE_URL without its trailing slashes, or https://api.mails.ai when it
|
|
50
|
+
* is empty, as in @mailsai/sdk. */
|
|
51
|
+
export function baseUrlOf(raw) {
|
|
52
|
+
const base = (raw.trim() || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
53
|
+
if (!/^https?:\/\/[^/\s]/i.test(base)) {
|
|
54
|
+
throw new Error(`MAILS_BASE_URL must be an http:// or https:// address, such as ${DEFAULT_BASE_URL}. Received: ${JSON.stringify(raw)}`);
|
|
55
|
+
}
|
|
56
|
+
return base;
|
|
57
|
+
}
|
|
58
|
+
// ── plus addresses ──────────────────────────────────────────────────────────────────────────
|
|
59
|
+
/** The most characters the part of an address before the @ may hold: RFC 5321's limit, and what
|
|
60
|
+
* the API's `to` filter takes. A receiving address that already has 64 leaves no room for a tag. */
|
|
61
|
+
export const MAX_LOCAL_PART = 64;
|
|
62
|
+
/** The most characters a tag may have. */
|
|
63
|
+
export const MAX_TAG = 40;
|
|
64
|
+
/** The fewest random characters a shortened default tag may have and still be unique to its run. */
|
|
65
|
+
export const MIN_UNIQUE_TAG = 6;
|
|
66
|
+
/** `s` without leading and trailing ASCII whitespace (space, tab, CR, LF), as the Python SDK trims a tag. */
|
|
67
|
+
function trimAscii(s) {
|
|
68
|
+
return s.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, "");
|
|
69
|
+
}
|
|
70
|
+
/** The part of the receiving address before its @, cut at its first `+`. */
|
|
71
|
+
function baseLocalPart(receivingAddress, at) {
|
|
72
|
+
return receivingAddress.slice(0, at).split("+")[0];
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A plus address of an agent: its receiving address with `+<tag>` before the `@`, which reaches the
|
|
76
|
+
* same agent. The same rules as `plusAddress()` in @mailsai/sdk: the tag is trimmed of ASCII
|
|
77
|
+
* whitespace and lowercased and must be 1 to 40 letters, digits, `.`, `_`, `-` or `+`; only the part
|
|
78
|
+
* of the local part before its first `+` is kept, and with the tag it may hold at most 64
|
|
79
|
+
* characters; the whole address is lowercased.
|
|
80
|
+
*/
|
|
81
|
+
export function plusAddress(receivingAddress, tag) {
|
|
82
|
+
const at = receivingAddress.lastIndexOf("@");
|
|
83
|
+
if (at < 1)
|
|
84
|
+
throw new Error(`Not an address: ${JSON.stringify(receivingAddress)}`);
|
|
85
|
+
const t = trimAscii(tag).toLowerCase();
|
|
86
|
+
if (!/^[a-z0-9._+-]{1,40}$/.test(t))
|
|
87
|
+
throw new Error(`A tag is 1 to 40 letters, digits, ".", "_", "-" or "+". Received: ${JSON.stringify(tag)}`);
|
|
88
|
+
const local = baseLocalPart(receivingAddress, at);
|
|
89
|
+
if (local.length + 1 + t.length > MAX_LOCAL_PART) {
|
|
90
|
+
const room = MAX_LOCAL_PART - local.length - 1;
|
|
91
|
+
throw new Error(room < 1
|
|
92
|
+
? `${receivingAddress} has no room for a tag: the part of an address before the @ holds at most ${MAX_LOCAL_PART} characters, and it already has ${local.length}.`
|
|
93
|
+
: `The part of an address before the @ holds at most ${MAX_LOCAL_PART} characters, so a tag on ${receivingAddress} can have at most ${room}. Received ${t.length}: ${JSON.stringify(tag)}`);
|
|
94
|
+
}
|
|
95
|
+
return `${local}+${t}@${receivingAddress.slice(at + 1)}`.toLowerCase();
|
|
96
|
+
}
|
|
97
|
+
/** How many characters a tag on `receivingAddress` may have: 40, or fewer when the part before its
|
|
98
|
+
* @ leaves less room under MAX_LOCAL_PART (0 or less: none). */
|
|
99
|
+
export function tagRoom(receivingAddress) {
|
|
100
|
+
const at = receivingAddress.lastIndexOf("@");
|
|
101
|
+
const local = at < 1 ? "" : baseLocalPart(receivingAddress, at);
|
|
102
|
+
return Math.min(MAX_TAG, MAX_LOCAL_PART - local.length - 1);
|
|
103
|
+
}
|
|
104
|
+
const TAG_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
105
|
+
function randomChars(n) {
|
|
106
|
+
const bytes = new Uint8Array(n);
|
|
107
|
+
const c = globalThis.crypto;
|
|
108
|
+
if (c?.getRandomValues)
|
|
109
|
+
c.getRandomValues(bytes);
|
|
110
|
+
else
|
|
111
|
+
for (let i = 0; i < n; i++)
|
|
112
|
+
bytes[i] = Math.floor(Math.random() * 256);
|
|
113
|
+
return Array.from(bytes, (b) => TAG_ALPHABET[b % TAG_ALPHABET.length]).join("");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A tag for one run: `run-`, the time in base 36 and 4 random letters or digits, such as
|
|
117
|
+
* `run-mgt2k4x1-7q3z` (17 characters). When `room` (tagRoom()) is less than that, as many random
|
|
118
|
+
* characters as fit, which keep the tag unique while at least MIN_UNIQUE_TAG fit; with fewer, the
|
|
119
|
+
* full tag, which plusAddress() then refuses, naming the room there is.
|
|
120
|
+
*/
|
|
121
|
+
export function defaultTag(now = Date.now(), random = randomChars, room = MAX_TAG) {
|
|
122
|
+
const time = now.toString(36);
|
|
123
|
+
if (`run-${time}-xxxx`.length <= room || room < MIN_UNIQUE_TAG)
|
|
124
|
+
return `run-${time}-${random(4)}`;
|
|
125
|
+
return random(room);
|
|
126
|
+
}
|
|
127
|
+
/** The address mailsaiPlusAddress yields: plusAddress() with the tag given, or with a new default
|
|
128
|
+
* tag per call that fits the receiving address. */
|
|
129
|
+
export function plusAddressFor(receivingAddress, tag, now = Date.now(), random = randomChars) {
|
|
130
|
+
if (tag === undefined || tag === null)
|
|
131
|
+
return plusAddress(receivingAddress, defaultTag(now, random, tagRoom(receivingAddress)));
|
|
132
|
+
if (typeof tag !== "string")
|
|
133
|
+
throw new TypeError(`mailsaiPlusAddress takes a tag that is a string, or nothing. Received: ${describe(tag)}`);
|
|
134
|
+
return plusAddress(receivingAddress, tag);
|
|
135
|
+
}
|
|
136
|
+
const OPTION_NAMES = {
|
|
137
|
+
mailsaiWaitForMessage: ["to", "from", "has", "since", "timeout", "agentId"],
|
|
138
|
+
mailsaiWaitForCode: ["to", "from", "since", "timeout", "agentId"],
|
|
139
|
+
mailsaiWaitForLink: ["to", "from", "since", "timeout", "agentId", "kind"],
|
|
140
|
+
};
|
|
141
|
+
function describe(v) {
|
|
142
|
+
if (typeof v === "string")
|
|
143
|
+
return JSON.stringify(v);
|
|
144
|
+
if (v === null || typeof v !== "object")
|
|
145
|
+
return String(v);
|
|
146
|
+
try {
|
|
147
|
+
return JSON.stringify(v);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return Object.prototype.toString.call(v);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function stringOption(command, name, v) {
|
|
154
|
+
if (v === undefined)
|
|
155
|
+
return undefined;
|
|
156
|
+
if (typeof v !== "string")
|
|
157
|
+
throw new TypeError(`${command}: ${name} must be a string. Received: ${describe(v)}`);
|
|
158
|
+
const s = v.trim();
|
|
159
|
+
// An empty filter is a mistake (an address that was never set), and leaving it out would widen
|
|
160
|
+
// the wait to mail meant for another test run.
|
|
161
|
+
if (!s)
|
|
162
|
+
throw new TypeError(`${command}: ${name} is empty. Give a value, or leave ${name} out.`);
|
|
163
|
+
return s;
|
|
164
|
+
}
|
|
165
|
+
function timeoutOf(command, v) {
|
|
166
|
+
if (v === undefined)
|
|
167
|
+
return DEFAULT_TIMEOUT_SECONDS;
|
|
168
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 0) {
|
|
169
|
+
throw new TypeError(`${command}: timeout is the seconds to wait in all, a number from 0 up. Received: ${describe(v)}`);
|
|
170
|
+
}
|
|
171
|
+
return Math.ceil(v);
|
|
172
|
+
}
|
|
173
|
+
/** A wait command's options, checked: an unknown option, an empty filter or a bad timeout throws,
|
|
174
|
+
* and mailsaiWaitForCode and mailsaiWaitForLink set `has` themselves. */
|
|
175
|
+
export function waitPlan(command, input) {
|
|
176
|
+
const given = input === undefined || input === null ? {} : input;
|
|
177
|
+
if (typeof given !== "object" || Array.isArray(given)) {
|
|
178
|
+
throw new TypeError(`${command} takes an options object, such as { to: email }. Received: ${describe(input)}`);
|
|
179
|
+
}
|
|
180
|
+
const o = given;
|
|
181
|
+
const names = OPTION_NAMES[command];
|
|
182
|
+
for (const key of Object.keys(o)) {
|
|
183
|
+
if (o[key] !== undefined && !names.includes(key))
|
|
184
|
+
throw new TypeError(`${command} has no option "${key}". Its options are ${names.join(", ")}.`);
|
|
185
|
+
}
|
|
186
|
+
const plan = { timeout: timeoutOf(command, o.timeout) };
|
|
187
|
+
for (const key of ["to", "from", "since", "agentId"]) {
|
|
188
|
+
const v = stringOption(command, key, o[key]);
|
|
189
|
+
if (v !== undefined)
|
|
190
|
+
plan[key] = v;
|
|
191
|
+
}
|
|
192
|
+
if (command === "mailsaiWaitForCode")
|
|
193
|
+
plan.has = "code";
|
|
194
|
+
else if (command === "mailsaiWaitForLink") {
|
|
195
|
+
plan.has = "link";
|
|
196
|
+
if (o.kind !== undefined) {
|
|
197
|
+
if (!WANTED_KINDS.includes(o.kind)) {
|
|
198
|
+
throw new TypeError(`${command}: kind must be one of ${WANTED_KINDS.map((k) => `"${k}"`).join(", ")}. Received: ${describe(o.kind)}`);
|
|
199
|
+
}
|
|
200
|
+
plan.kind = o.kind;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (o.has !== undefined) {
|
|
204
|
+
if (o.has !== "code" && o.has !== "link")
|
|
205
|
+
throw new TypeError(`${command}: has must be "code" or "link". Received: ${describe(o.has)}`);
|
|
206
|
+
plan.has = o.has;
|
|
207
|
+
}
|
|
208
|
+
return plan;
|
|
209
|
+
}
|
|
210
|
+
/** The filters, as the Command Log line and the errors show them. */
|
|
211
|
+
export function describeFilters(plan) {
|
|
212
|
+
const parts = [];
|
|
213
|
+
if (plan.to)
|
|
214
|
+
parts.push(`to: ${plan.to}`);
|
|
215
|
+
if (plan.from)
|
|
216
|
+
parts.push(`from: ${plan.from}`);
|
|
217
|
+
if (plan.has)
|
|
218
|
+
parts.push(`has: ${plan.has}`);
|
|
219
|
+
if (plan.kind)
|
|
220
|
+
parts.push(`kind: ${plan.kind}`);
|
|
221
|
+
if (plan.since)
|
|
222
|
+
parts.push(`since: ${plan.since}`);
|
|
223
|
+
if (plan.agentId)
|
|
224
|
+
parts.push(`agent: ${plan.agentId}`);
|
|
225
|
+
return parts.length ? parts.join(", ") : "any received mail";
|
|
226
|
+
}
|
|
227
|
+
/** The seconds the next wait call asks for: what is left of the timeout, at most 50. */
|
|
228
|
+
export function nextWaitSeconds(left) {
|
|
229
|
+
return Math.max(0, Math.min(MAX_WAIT_SECONDS, left));
|
|
230
|
+
}
|
|
231
|
+
/** How a timeout is spent when nothing arrives: 60 → [50, 10], 120 → [50, 50, 20], 0 → [0]. */
|
|
232
|
+
export function splitTimeout(total) {
|
|
233
|
+
const parts = [nextWaitSeconds(total)];
|
|
234
|
+
for (let left = total - parts[0]; left > 0; left -= parts[parts.length - 1])
|
|
235
|
+
parts.push(nextWaitSeconds(left));
|
|
236
|
+
return parts;
|
|
237
|
+
}
|
|
238
|
+
/** One call to GET /v1/messages/received/wait asking to hold for `seconds`. `timeout` is how long
|
|
239
|
+
* Cypress gives the request, in milliseconds: the wait and REQUEST_GRACE_SECONDS more. `since` is
|
|
240
|
+
* sent only when given, so the API's own look-back applies. */
|
|
241
|
+
export function waitRequest(baseUrl, apiKey, plan, seconds) {
|
|
242
|
+
const qs = new URLSearchParams();
|
|
243
|
+
if (plan.agentId)
|
|
244
|
+
qs.set("agent_id", plan.agentId);
|
|
245
|
+
if (plan.from)
|
|
246
|
+
qs.set("from", plan.from);
|
|
247
|
+
if (plan.to)
|
|
248
|
+
qs.set("to", plan.to);
|
|
249
|
+
if (plan.since)
|
|
250
|
+
qs.set("since", plan.since);
|
|
251
|
+
if (plan.has)
|
|
252
|
+
qs.set("has", plan.has);
|
|
253
|
+
qs.set("timeout", String(seconds));
|
|
254
|
+
return {
|
|
255
|
+
method: "GET",
|
|
256
|
+
url: `${baseUrlOf(baseUrl)}/api/v1/messages/received/wait?${qs.toString()}`,
|
|
257
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
258
|
+
timeout: (seconds + REQUEST_GRACE_SECONDS) * 1000,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
function header(headers, name) {
|
|
262
|
+
if (!headers)
|
|
263
|
+
return null;
|
|
264
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
265
|
+
if (k.toLowerCase() === name)
|
|
266
|
+
return Array.isArray(v) ? (v[0] ?? null) : (v ?? null);
|
|
267
|
+
}
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
/** `Retry-After` in seconds: a number of seconds or an HTTP date. Null when absent or unreadable. */
|
|
271
|
+
export function parseRetryAfter(value, now = Date.now()) {
|
|
272
|
+
if (!value || !value.trim())
|
|
273
|
+
return null;
|
|
274
|
+
const seconds = Number(value);
|
|
275
|
+
if (Number.isFinite(seconds))
|
|
276
|
+
return Math.max(0, seconds);
|
|
277
|
+
const at = Date.parse(value);
|
|
278
|
+
return Number.isNaN(at) ? null : Math.max(0, (at - now) / 1000);
|
|
279
|
+
}
|
|
280
|
+
function asObject(body) {
|
|
281
|
+
let b = body;
|
|
282
|
+
// cy.request parses a JSON body only when the answer says it is JSON.
|
|
283
|
+
if (typeof b === "string") {
|
|
284
|
+
try {
|
|
285
|
+
b = JSON.parse(b);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return b !== null && typeof b === "object" && !Array.isArray(b) ? b : null;
|
|
292
|
+
}
|
|
293
|
+
function str(v) {
|
|
294
|
+
return typeof v === "string" && v ? v : null;
|
|
295
|
+
}
|
|
296
|
+
/** What one wait call's answer means: a message, nothing in time, a 429, or an error. */
|
|
297
|
+
export function readWaitResponse(status, body, headers) {
|
|
298
|
+
const b = asObject(body);
|
|
299
|
+
if (status >= 200 && status < 300) {
|
|
300
|
+
if (b && b.message !== null && typeof b.message === "object" && !Array.isArray(b.message)) {
|
|
301
|
+
return { kind: "message", message: b.message };
|
|
302
|
+
}
|
|
303
|
+
if (b && b.message === null)
|
|
304
|
+
return { kind: "timed_out" };
|
|
305
|
+
return {
|
|
306
|
+
kind: "error",
|
|
307
|
+
error: {
|
|
308
|
+
status,
|
|
309
|
+
type: "api_error",
|
|
310
|
+
code: "unexpected_response",
|
|
311
|
+
message: "The answer was not a mails.ai wait result ({ message, timed_out }). Is MAILS_BASE_URL the API's address?",
|
|
312
|
+
param: null,
|
|
313
|
+
requestId: null,
|
|
314
|
+
upgradeUrl: null,
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const e = b && b.error !== null && typeof b.error === "object" ? b.error : null;
|
|
319
|
+
const error = {
|
|
320
|
+
status,
|
|
321
|
+
type: str(e?.type) ?? (status === 429 ? "rate_limit_error" : "api_error"),
|
|
322
|
+
code: str(e?.code) ?? "unknown",
|
|
323
|
+
message: str(e?.message) ?? `The API answered ${status} without a mails.ai error body. Is MAILS_BASE_URL the API's address?`,
|
|
324
|
+
param: str(e?.param),
|
|
325
|
+
requestId: str(e?.request_id) ?? header(headers, "x-request-id"),
|
|
326
|
+
upgradeUrl: str(e?.upgrade_url),
|
|
327
|
+
};
|
|
328
|
+
if (status !== 429)
|
|
329
|
+
return { kind: "error", error };
|
|
330
|
+
const fromHeader = parseRetryAfter(header(headers, "retry-after"));
|
|
331
|
+
const fromBody = typeof e?.retry_after_seconds === "number" && Number.isFinite(e.retry_after_seconds) ? e.retry_after_seconds : null;
|
|
332
|
+
return { kind: "rate_limited", error, retryAfterSeconds: fromHeader ?? fromBody, waitHelps: !(e && e.resets_at === null) };
|
|
333
|
+
}
|
|
334
|
+
/** Whole seconds to pause after a 429 before trying again: what the answer asked for, else 1, 2,
|
|
335
|
+
* 4 then 8 for 429s in a row (`times` is how many came before this one), at most
|
|
336
|
+
* MAX_RETRY_PAUSE_SECONDS. Null when no wait lifts the limit. */
|
|
337
|
+
export function retryPause(answer, times) {
|
|
338
|
+
if (!answer.waitHelps)
|
|
339
|
+
return null;
|
|
340
|
+
const s = answer.retryAfterSeconds ?? Math.min(8, 2 ** times);
|
|
341
|
+
return Math.min(MAX_RETRY_PAUSE_SECONDS, Math.max(1, Math.ceil(s)));
|
|
342
|
+
}
|
|
343
|
+
/** The error of a refused wait, carrying the API's code and message, and on a 402 ending with the
|
|
344
|
+
* link to change plan, so a failed CI run says where to go. */
|
|
345
|
+
export class MailsaiApiError extends Error {
|
|
346
|
+
status;
|
|
347
|
+
type;
|
|
348
|
+
code;
|
|
349
|
+
param;
|
|
350
|
+
requestId;
|
|
351
|
+
upgradeUrl;
|
|
352
|
+
constructor(command, e, extra = "") {
|
|
353
|
+
const upgrade = e.upgradeUrl ? ` Change your plan: ${e.upgradeUrl}` : "";
|
|
354
|
+
super(`${command}: mails.ai answered ${e.status} ${e.code}: ${e.message}${extra}${upgrade}${e.requestId ? ` (request ${e.requestId})` : ""}`);
|
|
355
|
+
this.name = "MailsaiApiError";
|
|
356
|
+
this.status = e.status;
|
|
357
|
+
this.type = e.type;
|
|
358
|
+
this.code = e.code;
|
|
359
|
+
this.param = e.param;
|
|
360
|
+
this.requestId = e.requestId;
|
|
361
|
+
this.upgradeUrl = e.upgradeUrl ?? null;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/** The error of a 429 the rest of the timeout cannot wait out. */
|
|
365
|
+
export function rateLimitError(command, e, pause, left, timeout) {
|
|
366
|
+
const why = pause === null
|
|
367
|
+
? " Waiting does not lift this limit."
|
|
368
|
+
: ` Waiting for it takes ${pause} s, more than the ${left} s left of this command's ${timeout} s timeout.`;
|
|
369
|
+
return new MailsaiApiError(command, e, why);
|
|
370
|
+
}
|
|
371
|
+
/** The error when nothing matching arrived within the timeout. */
|
|
372
|
+
export function noMailError(command, plan) {
|
|
373
|
+
return new Error(`${command}: no email arrived within ${plan.timeout} s matching ${describeFilters(plan)}.`);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* What a wait does after the answer to a call that asked to hold for `seconds`: yield the message,
|
|
377
|
+
* make the next call while time is left (a timeout of 60 is a 50 s call, then a 10 s one), pause
|
|
378
|
+
* after a 429 for as long as it asks when the rest of the timeout covers the pause, or fail.
|
|
379
|
+
*/
|
|
380
|
+
export function afterAnswer(command, plan, state, seconds, answer) {
|
|
381
|
+
switch (answer.kind) {
|
|
382
|
+
case "message":
|
|
383
|
+
return { do: "yield", message: answer.message };
|
|
384
|
+
case "timed_out": {
|
|
385
|
+
const left = state.left - seconds;
|
|
386
|
+
return left > 0 ? { do: "call", state: { left, limited: 0 } } : { do: "fail", error: noMailError(command, plan) };
|
|
387
|
+
}
|
|
388
|
+
case "rate_limited": {
|
|
389
|
+
const pause = retryPause(answer, state.limited);
|
|
390
|
+
if (pause === null || pause > state.left)
|
|
391
|
+
return { do: "fail", error: rateLimitError(command, answer.error, pause, state.left, plan.timeout) };
|
|
392
|
+
return { do: "pause", seconds: pause, state: { left: state.left - pause, limited: state.limited + 1 } };
|
|
393
|
+
}
|
|
394
|
+
case "error":
|
|
395
|
+
return { do: "fail", error: new MailsaiApiError(command, answer.error) };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// ── what the commands yield ─────────────────────────────────────────────────────────────────
|
|
399
|
+
/** The message, as the Command Log and the errors name it. */
|
|
400
|
+
export function messageLabel(message) {
|
|
401
|
+
return message.subject ? `${message.id} ("${message.subject}")` : String(message.id);
|
|
402
|
+
}
|
|
403
|
+
/** The code mailsaiWaitForCode yields: `codes[0].value`, or null when there is none. */
|
|
404
|
+
export function codeOf(message) {
|
|
405
|
+
const first = Array.isArray(message.codes) ? message.codes[0] : undefined;
|
|
406
|
+
return first && typeof first.value === "string" && first.value ? first.value : null;
|
|
407
|
+
}
|
|
408
|
+
/** The link mailsaiWaitForLink yields: the first of `kind`, or without one the first verify,
|
|
409
|
+
* sign_in or reset_password link. Never an unsubscribe link; null when none fits. */
|
|
410
|
+
export function linkOf(message, kind) {
|
|
411
|
+
const links = Array.isArray(message.links) ? message.links : [];
|
|
412
|
+
const hit = links.find((l) => l !== null &&
|
|
413
|
+
typeof l === "object" &&
|
|
414
|
+
typeof l.url === "string" &&
|
|
415
|
+
l.url !== "" &&
|
|
416
|
+
l.kind !== "unsubscribe" &&
|
|
417
|
+
(kind ? l.kind === kind : ACTION_LINK_KINDS.includes(l.kind)));
|
|
418
|
+
return hit ? hit.url : null;
|
|
419
|
+
}
|
|
420
|
+
/** The error when the message mailsaiWaitForCode got has no code. */
|
|
421
|
+
export function noCodeError(command, message) {
|
|
422
|
+
return new Error(`${command}: the email ${messageLabel(message)} has no code.`);
|
|
423
|
+
}
|
|
424
|
+
/** The error when the message mailsaiWaitForLink got has no link it may yield, naming the kinds it has. */
|
|
425
|
+
export function noLinkError(command, message, kind) {
|
|
426
|
+
const wanted = kind ? `link of kind "${kind}"` : "verify, sign_in or reset_password link";
|
|
427
|
+
const counts = new Map();
|
|
428
|
+
for (const l of Array.isArray(message.links) ? message.links : [])
|
|
429
|
+
counts.set(String(l?.kind), (counts.get(String(l?.kind)) ?? 0) + 1);
|
|
430
|
+
const has = counts.size ? ` Its links: ${[...counts].map(([k, n]) => `${k} (${n})`).join(", ")}.` : " It has no links.";
|
|
431
|
+
return new Error(`${command}: the email ${messageLabel(message)} has no ${wanted}.${has}`);
|
|
432
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/// <reference types="cypress" preserve="true" />
|
|
2
|
+
import { type MailsaiReceivedMessage, type MailsaiWaitForCodeOptions, type MailsaiWaitForLinkOptions, type MailsaiWaitOptions } from "./core.js";
|
|
3
|
+
export type { LinkKind, MailsaiLinkKind, MailsaiOmittedAttachment, MailsaiReceivedAttachment, MailsaiReceivedMessage, MailsaiWaitForCodeOptions, MailsaiWaitForLinkOptions, MailsaiWaitOptions, } from "./core.js";
|
|
4
|
+
export { MailsaiApiError } from "./core.js";
|
|
5
|
+
declare global {
|
|
6
|
+
namespace Cypress {
|
|
7
|
+
interface Chainable<Subject = any> {
|
|
8
|
+
/**
|
|
9
|
+
* A new email address for this test run: a plus address of MAILS_RECEIVING_ADDRESS, which
|
|
10
|
+
* reaches the same agent. Without a tag, a new one per call (`run-<time>-<4 random>`).
|
|
11
|
+
* @example cy.mailsaiPlusAddress().then((email) => cy.get("[data-cy=email]").type(email))
|
|
12
|
+
*/
|
|
13
|
+
mailsaiPlusAddress(tag?: string): Chainable<string>;
|
|
14
|
+
/**
|
|
15
|
+
* The newest received email matching the filters, waiting for one to arrive for up to
|
|
16
|
+
* `timeout` seconds in all (60 by default). Fails the test when none arrives.
|
|
17
|
+
* @example cy.mailsaiWaitForMessage({ to: email }).its("subject").should("contain", "Welcome")
|
|
18
|
+
*/
|
|
19
|
+
mailsaiWaitForMessage(options?: MailsaiWaitOptions): Chainable<MailsaiReceivedMessage>;
|
|
20
|
+
/**
|
|
21
|
+
* The verification code in the newest email with one that matches the filters, waiting for
|
|
22
|
+
* it as mailsaiWaitForMessage does.
|
|
23
|
+
* @example cy.mailsaiWaitForCode({ to: email }).then((code) => cy.get("[data-cy=code]").type(code))
|
|
24
|
+
*/
|
|
25
|
+
mailsaiWaitForCode(options?: MailsaiWaitForCodeOptions): Chainable<string>;
|
|
26
|
+
/**
|
|
27
|
+
* The link in the newest email with a verify, sign-in or password-reset link that matches
|
|
28
|
+
* the filters: the first of `kind`, or without it the first verify, sign_in or
|
|
29
|
+
* reset_password link. Never an unsubscribe link.
|
|
30
|
+
* @example cy.mailsaiWaitForLink({ to: email }).then((url) => cy.visit(url))
|
|
31
|
+
*/
|
|
32
|
+
mailsaiWaitForLink(options?: MailsaiWaitForLinkOptions): Chainable<string>;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAeA,OAAO,EAkBL,KAAK,sBAAsB,EAC3B,KAAK,yBAAyB,EAC9B,KAAK,yBAAyB,EAC9B,KAAK,kBAAkB,EAIxB,MAAM,WAAW,CAAC;AAEnB,YAAY,EACV,QAAQ,EACR,eAAe,EACf,wBAAwB,EACxB,yBAAyB,EACzB,sBAAsB,EACtB,yBAAyB,EACzB,yBAAyB,EACzB,kBAAkB,GACnB,MAAM,WAAW,CAAC;AACnB,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAE5C,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,OAAO,CAAC;QAChB,UAAU,SAAS,CAAC,OAAO,GAAG,GAAG;YAC/B;;;;eAIG;YACH,kBAAkB,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;YACpD;;;;eAIG;YACH,qBAAqB,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,SAAS,CAAC,sBAAsB,CAAC,CAAC;YACvF;;;;eAIG;YACH,kBAAkB,CAAC,OAAO,CAAC,EAAE,yBAAyB,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;YAC3E;;;;;eAKG;YACH,kBAAkB,CAAC,OAAO,CAAC,EAAE,yBAAyB,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;SAC5E;KACF;CACF"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/// <reference types="cypress" preserve="true" />
|
|
2
|
+
// @mailsai/cypress: Cypress commands for testing the email an app sends at sign-up, sign-in and
|
|
3
|
+
// password reset. `import "@mailsai/cypress"` in the support file registers them:
|
|
4
|
+
//
|
|
5
|
+
// cy.mailsaiPlusAddress() a new address for this run (a plus address of one agent)
|
|
6
|
+
// cy.mailsaiWaitForMessage({ to }) the email sent to it, once it arrives
|
|
7
|
+
// cy.mailsaiWaitForCode({ to }) its verification code
|
|
8
|
+
// cy.mailsaiWaitForLink({ to }) its verify, sign-in or password-reset link
|
|
9
|
+
//
|
|
10
|
+
// The logic is in core.ts. This file reads the settings, makes the requests and writes one Command
|
|
11
|
+
// Log line per command. The API key never reaches the Command Log, a screenshot or a video: the
|
|
12
|
+
// settings are read with cy.env() and { log: false }, and every request is made with { log: false }
|
|
13
|
+
// and failOnStatusCode: false, since Cypress's own error for a failed status prints the request's
|
|
14
|
+
// headers, the key among them.
|
|
15
|
+
import { afterAnswer, baseUrlOf, codeOf, CONFIG_KEYS, configFrom, describeFilters, linkOf, messageLabel, missingSetting, nextWaitSeconds, noCodeError, noLinkError, plusAddressFor, readWaitResponse, waitPlan, waitRequest, } from "./core.js";
|
|
16
|
+
export { MailsaiApiError } from "./core.js";
|
|
17
|
+
/** The settings, read so that their values never reach the Command Log: with cy.env() (Cypress
|
|
18
|
+
* 15.10 and later; Cypress 16 removed Cypress.env()), else with Cypress.env(). */
|
|
19
|
+
function settings() {
|
|
20
|
+
const keys = [...CONFIG_KEYS];
|
|
21
|
+
if (typeof cy.env === "function") {
|
|
22
|
+
return cy.env(keys, { log: false }).then((env) => configFrom(env));
|
|
23
|
+
}
|
|
24
|
+
const legacy = Cypress;
|
|
25
|
+
return cy.wrap(configFrom(Object.fromEntries(keys.map((k) => [k, legacy.env(k)]))), { log: false });
|
|
26
|
+
}
|
|
27
|
+
/** Marks the command's log line failed, then fails the test with `err`. */
|
|
28
|
+
function fail(log, err) {
|
|
29
|
+
if (err instanceof Error)
|
|
30
|
+
log.error(err);
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
/** `fn()`, with a throw marking the log line failed first. */
|
|
34
|
+
function attempt(log, fn) {
|
|
35
|
+
try {
|
|
36
|
+
return fn();
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return fail(log, err);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
Cypress.Commands.add("mailsaiPlusAddress", (tag) => {
|
|
43
|
+
const log = Cypress.log({ name: "mailsaiPlusAddress", message: tag ?? "", autoEnd: false });
|
|
44
|
+
return settings().then((config) => {
|
|
45
|
+
const address = attempt(log, () => {
|
|
46
|
+
if (!config.receivingAddress)
|
|
47
|
+
throw missingSetting("mailsaiPlusAddress", "MAILS_RECEIVING_ADDRESS");
|
|
48
|
+
return plusAddressFor(config.receivingAddress, tag);
|
|
49
|
+
});
|
|
50
|
+
log.set({ message: address, consoleProps: () => ({ "Receiving address": config.receivingAddress, Yielded: address }) }).end();
|
|
51
|
+
return cy.wrap(address, { log: false });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
/** One wait call, then what afterAnswer() says: yield, call again, pause after a 429, or fail. */
|
|
55
|
+
function poll(w, state) {
|
|
56
|
+
const seconds = nextWaitSeconds(state.left);
|
|
57
|
+
const req = waitRequest(w.baseUrl, w.apiKey, w.plan, seconds);
|
|
58
|
+
return cy
|
|
59
|
+
.request({ method: req.method, url: req.url, headers: req.headers, timeout: req.timeout, failOnStatusCode: false, log: false })
|
|
60
|
+
.then((res) => {
|
|
61
|
+
const step = afterAnswer(w.command, w.plan, state, seconds, readWaitResponse(res.status, res.body, res.headers));
|
|
62
|
+
switch (step.do) {
|
|
63
|
+
case "yield":
|
|
64
|
+
return cy.wrap(step.message, { log: false });
|
|
65
|
+
case "call":
|
|
66
|
+
return poll(w, step.state);
|
|
67
|
+
case "pause":
|
|
68
|
+
return cy.wait(step.seconds * 1000, { log: false }).then(() => poll(w, step.state));
|
|
69
|
+
case "fail":
|
|
70
|
+
return fail(w.log, step.error);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
/** A wait command: check the options, read the settings, wait, then yield what `pick` takes from
|
|
75
|
+
* the message. */
|
|
76
|
+
function waitFor(command, options, pick) {
|
|
77
|
+
const log = Cypress.log({ name: command, message: "", autoEnd: false });
|
|
78
|
+
const given = attempt(log, () => waitPlan(command, options));
|
|
79
|
+
return settings().then((config) => {
|
|
80
|
+
const w = attempt(log, () => {
|
|
81
|
+
if (!config.apiKey)
|
|
82
|
+
throw missingSetting(command, "MAILS_API_KEY");
|
|
83
|
+
const plan = !given.agentId && config.agentId ? { ...given, agentId: config.agentId } : given;
|
|
84
|
+
return { command, plan, baseUrl: baseUrlOf(config.baseUrl), apiKey: config.apiKey, log };
|
|
85
|
+
});
|
|
86
|
+
const filters = describeFilters(w.plan);
|
|
87
|
+
const props = { Filters: filters, "Timeout (s)": w.plan.timeout, API: w.baseUrl };
|
|
88
|
+
log.set({ message: `${filters}, within ${w.plan.timeout} s`, consoleProps: () => props });
|
|
89
|
+
return poll(w, { left: w.plan.timeout, limited: 0 }).then((message) => {
|
|
90
|
+
const { value, shown } = attempt(log, () => pick(message, w.plan));
|
|
91
|
+
log.set({ message: `${filters} → ${shown}`, consoleProps: () => ({ ...props, Message: message, Yielded: value }) }).end();
|
|
92
|
+
return cy.wrap(value, { log: false });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
Cypress.Commands.add("mailsaiWaitForMessage", (options) => waitFor("mailsaiWaitForMessage", options, (message) => ({ value: message, shown: messageLabel(message) })));
|
|
97
|
+
Cypress.Commands.add("mailsaiWaitForCode", (options) => waitFor("mailsaiWaitForCode", options, (message) => {
|
|
98
|
+
const code = codeOf(message);
|
|
99
|
+
if (code === null)
|
|
100
|
+
throw noCodeError("mailsaiWaitForCode", message);
|
|
101
|
+
return { value: code, shown: code };
|
|
102
|
+
}));
|
|
103
|
+
Cypress.Commands.add("mailsaiWaitForLink", (options) => waitFor("mailsaiWaitForLink", options, (message, plan) => {
|
|
104
|
+
const url = linkOf(message, plan.kind);
|
|
105
|
+
if (url === null)
|
|
106
|
+
throw noLinkError("mailsaiWaitForLink", message, plan.kind);
|
|
107
|
+
return { value: url, shown: url };
|
|
108
|
+
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mailsai/cypress",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cypress commands for mails.ai: give each test run its own email address, then wait for the sign-up code or link sent to it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"prepublishOnly": "npm run build",
|
|
23
|
+
"test:e2e": "npm run build && node e2e/run.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"cypress",
|
|
27
|
+
"cypress-plugin",
|
|
28
|
+
"email",
|
|
29
|
+
"email-testing",
|
|
30
|
+
"otp",
|
|
31
|
+
"verification-code",
|
|
32
|
+
"magic-link",
|
|
33
|
+
"e2e",
|
|
34
|
+
"mails.ai"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/mailsai/mailsai.git",
|
|
40
|
+
"directory": "packages/cypress"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://mails.ai",
|
|
43
|
+
"bugs": { "url": "https://mails.ai/contact" },
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"cypress": ">=13.0.0"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"cypress": "^16.2.0",
|
|
55
|
+
"typescript": "^5"
|
|
56
|
+
}
|
|
57
|
+
}
|