@crvouga/mockingbird-notifications 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/README.md +304 -2
- package/dist/chunk-GRGR3QZM.js +1066 -0
- package/dist/chunk-GRGR3QZM.js.map +7 -0
- package/dist/chunk-JQ6KPT2C.js +202 -0
- package/dist/chunk-JQ6KPT2C.js.map +7 -0
- package/dist/chunk-LM7R7AR7.js +183 -0
- package/dist/chunk-LM7R7AR7.js.map +7 -0
- package/dist/chunk-QE3TRUI6.js +444 -0
- package/dist/chunk-QE3TRUI6.js.map +7 -0
- package/dist/cli.js +104 -0
- package/dist/cli.js.map +7 -0
- package/dist/index.d.ts +411 -0
- package/dist/index.js +95 -0
- package/dist/index.js.map +7 -0
- package/dist/server.d.ts +177 -0
- package/dist/server.js +13 -0
- package/dist/server.js.map +7 -0
- package/dist/ui.d.ts +445 -0
- package/dist/ui.js +1923 -0
- package/dist/ui.js.map +7 -0
- package/package.json +88 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_PORT,
|
|
4
|
+
createServer
|
|
5
|
+
} from "./chunk-JQ6KPT2C.js";
|
|
6
|
+
import "./chunk-GRGR3QZM.js";
|
|
7
|
+
import "./chunk-QE3TRUI6.js";
|
|
8
|
+
|
|
9
|
+
// src/cli.ts
|
|
10
|
+
import { parseArgs } from "node:util";
|
|
11
|
+
var HELP = `mockingbird-notifications \u2014 see every SMS, email and push your app sends through Mockingbird
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
mockingbird-notifications serve [options]
|
|
15
|
+
|
|
16
|
+
Where the emulators are (pick one or combine):
|
|
17
|
+
--fleet <url> Running fleet controller adminBase, e.g. http://127.0.0.1:9000/__admin/fleet
|
|
18
|
+
--manifest <file> The fleet's --ready-file (re-read on every sync, so restarts are followed)
|
|
19
|
+
--service <n=url> An emulator by name, e.g. --service twilio=http://127.0.0.1:8798 (repeatable)
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--port <n> Listen port (default ${DEFAULT_PORT}; 0 picks a free one)
|
|
23
|
+
--host <host> Listen host (default 127.0.0.1)
|
|
24
|
+
--namespace <name> Namespace to watch in the background (repeatable; default "default",
|
|
25
|
+
or every fleet namespace with --fleet)
|
|
26
|
+
--poll <ms> Background poll interval (default 750; 0 reads only on demand)
|
|
27
|
+
--admin-key-env <V> Environment variable holding the admin key (default MOCKINGBIRD_ADMIN_KEY)
|
|
28
|
+
--ready-json Print {"url": \u2026} as one JSON line once listening
|
|
29
|
+
-h, --help Show this help
|
|
30
|
+
`;
|
|
31
|
+
var main = async (argv) => {
|
|
32
|
+
const { values, positionals } = parseArgs({
|
|
33
|
+
args: argv,
|
|
34
|
+
allowPositionals: true,
|
|
35
|
+
options: {
|
|
36
|
+
fleet: { type: "string" },
|
|
37
|
+
manifest: { type: "string" },
|
|
38
|
+
service: { type: "string", multiple: true },
|
|
39
|
+
port: { type: "string" },
|
|
40
|
+
host: { type: "string" },
|
|
41
|
+
namespace: { type: "string", multiple: true },
|
|
42
|
+
poll: { type: "string" },
|
|
43
|
+
"admin-key-env": { type: "string" },
|
|
44
|
+
"ready-json": { type: "boolean" },
|
|
45
|
+
help: { type: "boolean", short: "h" }
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
if (values.help || positionals[0] !== "serve") {
|
|
49
|
+
process.stdout.write(HELP);
|
|
50
|
+
return values.help ? 0 : 2;
|
|
51
|
+
}
|
|
52
|
+
const services = {};
|
|
53
|
+
for (const entry of values.service ?? []) {
|
|
54
|
+
const eq = entry.indexOf("=");
|
|
55
|
+
if (eq <= 0) throw new Error(`--service ${entry}: expected name=url`);
|
|
56
|
+
services[entry.slice(0, eq)] = entry.slice(eq + 1);
|
|
57
|
+
}
|
|
58
|
+
if (!values.fleet && !values.manifest && Object.keys(services).length === 0) {
|
|
59
|
+
process.stderr.write("mockingbird-notifications: pass --fleet, --manifest or --service\n\n");
|
|
60
|
+
process.stderr.write(HELP);
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
const port = values.port === void 0 ? DEFAULT_PORT : Number(values.port);
|
|
64
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535)
|
|
65
|
+
throw new Error("--port: expected 0\u201365535");
|
|
66
|
+
const poll = values.poll === void 0 ? void 0 : Number(values.poll);
|
|
67
|
+
if (poll !== void 0 && (!Number.isFinite(poll) || poll < 0))
|
|
68
|
+
throw new Error("--poll: expected milliseconds");
|
|
69
|
+
const adminKey = process.env[values["admin-key-env"] ?? "MOCKINGBIRD_ADMIN_KEY"];
|
|
70
|
+
const server = await createServer({
|
|
71
|
+
port,
|
|
72
|
+
...values.host ? { host: values.host } : {},
|
|
73
|
+
...values.fleet ? { fleet: values.fleet } : {},
|
|
74
|
+
...values.manifest ? { manifest: values.manifest } : {},
|
|
75
|
+
...Object.keys(services).length ? { services } : {},
|
|
76
|
+
...values.namespace?.length ? { namespaces: values.namespace } : {},
|
|
77
|
+
...poll !== void 0 ? { pollIntervalMs: poll } : {},
|
|
78
|
+
...adminKey ? { adminKey } : {}
|
|
79
|
+
});
|
|
80
|
+
if (values["ready-json"]) process.stdout.write(`${JSON.stringify({ url: server.url })}
|
|
81
|
+
`);
|
|
82
|
+
else process.stdout.write(`Notifications harness: ${server.url}
|
|
83
|
+
`);
|
|
84
|
+
await new Promise((resolve) => {
|
|
85
|
+
const stop = () => resolve();
|
|
86
|
+
process.once("SIGINT", stop);
|
|
87
|
+
process.once("SIGTERM", stop);
|
|
88
|
+
});
|
|
89
|
+
await server.close();
|
|
90
|
+
return 0;
|
|
91
|
+
};
|
|
92
|
+
main(process.argv.slice(2)).then(
|
|
93
|
+
(code) => {
|
|
94
|
+
if (code !== 0) process.exitCode = code;
|
|
95
|
+
},
|
|
96
|
+
(error) => {
|
|
97
|
+
process.stderr.write(
|
|
98
|
+
`mockingbird-notifications: ${error instanceof Error ? error.message : String(error)}
|
|
99
|
+
`
|
|
100
|
+
);
|
|
101
|
+
process.exitCode = 1;
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/cli.ts"],
|
|
4
|
+
"sourcesContent": ["#!/usr/bin/env node\n/// <reference types=\"node\" />\nimport { parseArgs } from \"node:util\"\nimport type { ServiceInput } from \"./discovery.js\"\nimport { createServer, DEFAULT_PORT } from \"./server.js\"\n\nconst HELP = `mockingbird-notifications \u2014 see every SMS, email and push your app sends through Mockingbird\n\nUsage:\n mockingbird-notifications serve [options]\n\nWhere the emulators are (pick one or combine):\n --fleet <url> Running fleet controller adminBase, e.g. http://127.0.0.1:9000/__admin/fleet\n --manifest <file> The fleet's --ready-file (re-read on every sync, so restarts are followed)\n --service <n=url> An emulator by name, e.g. --service twilio=http://127.0.0.1:8798 (repeatable)\n\nOptions:\n --port <n> Listen port (default ${DEFAULT_PORT}; 0 picks a free one)\n --host <host> Listen host (default 127.0.0.1)\n --namespace <name> Namespace to watch in the background (repeatable; default \"default\",\n or every fleet namespace with --fleet)\n --poll <ms> Background poll interval (default 750; 0 reads only on demand)\n --admin-key-env <V> Environment variable holding the admin key (default MOCKINGBIRD_ADMIN_KEY)\n --ready-json Print {\"url\": \u2026} as one JSON line once listening\n -h, --help Show this help\n`\n\nconst main = async (argv: string[]): Promise<number> => {\n const { values, positionals } = parseArgs({\n args: argv,\n allowPositionals: true,\n options: {\n fleet: { type: \"string\" },\n manifest: { type: \"string\" },\n service: { type: \"string\", multiple: true },\n port: { type: \"string\" },\n host: { type: \"string\" },\n namespace: { type: \"string\", multiple: true },\n poll: { type: \"string\" },\n \"admin-key-env\": { type: \"string\" },\n \"ready-json\": { type: \"boolean\" },\n help: { type: \"boolean\", short: \"h\" },\n },\n })\n if (values.help || positionals[0] !== \"serve\") {\n process.stdout.write(HELP)\n return values.help ? 0 : 2\n }\n const services: Record<string, ServiceInput> = {}\n for (const entry of values.service ?? []) {\n const eq = entry.indexOf(\"=\")\n if (eq <= 0) throw new Error(`--service ${entry}: expected name=url`)\n services[entry.slice(0, eq)] = entry.slice(eq + 1)\n }\n if (!values.fleet && !values.manifest && Object.keys(services).length === 0) {\n process.stderr.write(\"mockingbird-notifications: pass --fleet, --manifest or --service\\n\\n\")\n process.stderr.write(HELP)\n return 2\n }\n const port = values.port === undefined ? DEFAULT_PORT : Number(values.port)\n if (!Number.isInteger(port) || port < 0 || port > 65535)\n throw new Error(\"--port: expected 0\u201365535\")\n const poll = values.poll === undefined ? undefined : Number(values.poll)\n if (poll !== undefined && (!Number.isFinite(poll) || poll < 0))\n throw new Error(\"--poll: expected milliseconds\")\n const adminKey = process.env[values[\"admin-key-env\"] ?? \"MOCKINGBIRD_ADMIN_KEY\"]\n const server = await createServer({\n port,\n ...(values.host ? { host: values.host } : {}),\n ...(values.fleet ? { fleet: values.fleet } : {}),\n ...(values.manifest ? { manifest: values.manifest } : {}),\n ...(Object.keys(services).length ? { services } : {}),\n ...(values.namespace?.length ? { namespaces: values.namespace } : {}),\n ...(poll !== undefined ? { pollIntervalMs: poll } : {}),\n ...(adminKey ? { adminKey } : {}),\n })\n if (values[\"ready-json\"]) process.stdout.write(`${JSON.stringify({ url: server.url })}\\n`)\n else process.stdout.write(`Notifications harness: ${server.url}\\n`)\n await new Promise<void>((resolve) => {\n const stop = () => resolve()\n process.once(\"SIGINT\", stop)\n process.once(\"SIGTERM\", stop)\n })\n await server.close()\n return 0\n}\n\nmain(process.argv.slice(2)).then(\n (code) => {\n if (code !== 0) process.exitCode = code\n },\n (error: unknown) => {\n process.stderr.write(\n `mockingbird-notifications: ${error instanceof Error ? error.message : String(error)}\\n`,\n )\n process.exitCode = 1\n },\n)\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/** The three kinds of device a notification lands on. */
|
|
2
|
+
type Channel = "sms" | "email" | "push";
|
|
3
|
+
/** Emulators the harness knows how to read. */
|
|
4
|
+
type ServiceKind = "twilio" | "resend" | "fcm" | "ses" | "sns" | "customerio" | "mailosaur";
|
|
5
|
+
declare const SERVICE_KINDS: readonly ServiceKind[];
|
|
6
|
+
declare const SERVICE_LABELS: Record<ServiceKind, string>;
|
|
7
|
+
type Address = {
|
|
8
|
+
name: string | null;
|
|
9
|
+
address: string;
|
|
10
|
+
};
|
|
11
|
+
type SmsContent = {
|
|
12
|
+
body: string;
|
|
13
|
+
/** One-time code the sender included (Twilio Verify), when known. */
|
|
14
|
+
code: string | null;
|
|
15
|
+
media: string[];
|
|
16
|
+
};
|
|
17
|
+
type EmailAttachment = {
|
|
18
|
+
filename: string;
|
|
19
|
+
contentType: string | null;
|
|
20
|
+
size: number | null;
|
|
21
|
+
};
|
|
22
|
+
type EmailContent = {
|
|
23
|
+
subject: string;
|
|
24
|
+
html: string | null;
|
|
25
|
+
text: string | null;
|
|
26
|
+
cc: string[];
|
|
27
|
+
bcc: string[];
|
|
28
|
+
replyTo: string[];
|
|
29
|
+
attachments: EmailAttachment[];
|
|
30
|
+
headers: Record<string, string>;
|
|
31
|
+
};
|
|
32
|
+
type PushPlatform = "android" | "ios" | "web" | "unknown";
|
|
33
|
+
type PushContent = {
|
|
34
|
+
title: string | null;
|
|
35
|
+
body: string | null;
|
|
36
|
+
imageUrl: string | null;
|
|
37
|
+
/** The app the notification belongs to (the FCM project, for example). */
|
|
38
|
+
app: string;
|
|
39
|
+
platform: PushPlatform;
|
|
40
|
+
data: Record<string, string>;
|
|
41
|
+
badge: number | null;
|
|
42
|
+
sound: string | null;
|
|
43
|
+
};
|
|
44
|
+
/** One message as a recipient would receive it, normalized across emulators. */
|
|
45
|
+
type Notification = {
|
|
46
|
+
/** Stable across polls: `<service entry>:<namespace>:<provider id>`. */
|
|
47
|
+
id: string;
|
|
48
|
+
/** The provider's own id (Twilio SID, Resend id, FCM message name, …). */
|
|
49
|
+
providerId: string;
|
|
50
|
+
channel: Channel;
|
|
51
|
+
/** The fleet entry name (`twilio`, or an alias such as `push`). */
|
|
52
|
+
source: string;
|
|
53
|
+
service: ServiceKind;
|
|
54
|
+
namespace: string;
|
|
55
|
+
/** Phone number, email address, or push registration token. */
|
|
56
|
+
to: string;
|
|
57
|
+
from: Address;
|
|
58
|
+
/** ISO-8601, on the emulator's clock. */
|
|
59
|
+
createdAt: string;
|
|
60
|
+
/** Short plain-text preview. */
|
|
61
|
+
preview: string;
|
|
62
|
+
/** Provider delivery state when it reports one (`queued`, `delivered`, `suppressed`, …). */
|
|
63
|
+
status: string | null;
|
|
64
|
+
sms: SmsContent | null;
|
|
65
|
+
email: EmailContent | null;
|
|
66
|
+
push: PushContent | null;
|
|
67
|
+
/** The record exactly as the emulator reported it. */
|
|
68
|
+
raw: unknown;
|
|
69
|
+
};
|
|
70
|
+
type NotificationFilter = {
|
|
71
|
+
channel?: Channel | undefined;
|
|
72
|
+
service?: ServiceKind | undefined;
|
|
73
|
+
source?: string | undefined;
|
|
74
|
+
namespace?: string | undefined;
|
|
75
|
+
/** Exact recipient (case-insensitive for email). */
|
|
76
|
+
to?: string | undefined;
|
|
77
|
+
/** Case-insensitive text search across sender, subject, title and body. */
|
|
78
|
+
contains?: string | undefined;
|
|
79
|
+
/** Only notifications created at or after this instant (ISO-8601 or epoch ms). */
|
|
80
|
+
since?: string | number | undefined;
|
|
81
|
+
};
|
|
82
|
+
type SourceStatus = {
|
|
83
|
+
name: string;
|
|
84
|
+
service: ServiceKind | null;
|
|
85
|
+
url: string;
|
|
86
|
+
ok: boolean;
|
|
87
|
+
error: string | null;
|
|
88
|
+
count: number;
|
|
89
|
+
lastSyncAt: string | null;
|
|
90
|
+
};
|
|
91
|
+
declare const matches: (n: Notification, filter: NotificationFilter) => boolean;
|
|
92
|
+
|
|
93
|
+
type NotificationClientOptions = {
|
|
94
|
+
/** Default namespace for every call that does not name one. */
|
|
95
|
+
namespace?: string;
|
|
96
|
+
fetch?: (request: Request) => Response | Promise<Response>;
|
|
97
|
+
};
|
|
98
|
+
/** Talks to a running harness over HTTP, for tests that run in another process. */
|
|
99
|
+
declare class NotificationClient {
|
|
100
|
+
readonly baseUrl: string;
|
|
101
|
+
private readonly namespace;
|
|
102
|
+
private readonly fetchImpl;
|
|
103
|
+
constructor(baseUrl: string, options?: NotificationClientOptions);
|
|
104
|
+
private get;
|
|
105
|
+
/** Every matching notification, oldest first. */
|
|
106
|
+
list(filter?: NotificationFilter): Promise<Notification[]>;
|
|
107
|
+
/** The newest match, or `null`. */
|
|
108
|
+
latest(filter?: NotificationFilter): Promise<Notification | null>;
|
|
109
|
+
/** Wait until `count` (default 1) notifications match and return them; throws on timeout. */
|
|
110
|
+
waitFor(filter: NotificationFilter, options?: {
|
|
111
|
+
timeoutMs?: number;
|
|
112
|
+
count?: number;
|
|
113
|
+
}): Promise<Notification[]>;
|
|
114
|
+
/** Wait for the next SMS to `to` and return its one-time code (Twilio Verify) or first 4–8 digit run. */
|
|
115
|
+
waitForCode(to: string, filter?: NotificationFilter, timeoutMs?: number): Promise<string>;
|
|
116
|
+
sources(): Promise<SourceStatus[]>;
|
|
117
|
+
/** A UI link that opens one device (or one notification) for a screenshot. */
|
|
118
|
+
link(view?: {
|
|
119
|
+
channel?: "sms" | "email" | "push";
|
|
120
|
+
to?: string;
|
|
121
|
+
id?: string;
|
|
122
|
+
}): string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Where the emulators are. The harness reads the same fleet the application uses: the live fleet
|
|
127
|
+
* controller (`adminBase`), the fleet's ready manifest, or explicit URLs and in-process runtimes.
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
type FetchLike = (request: Request) => Response | Promise<Response>;
|
|
131
|
+
/** A service emulator: its base URL, an `{ url, adminUrl?, service? }` entry, or an in-process runtime. */
|
|
132
|
+
type ServiceInput = string | {
|
|
133
|
+
url: string;
|
|
134
|
+
adminUrl?: string;
|
|
135
|
+
service?: ServiceKind;
|
|
136
|
+
} | {
|
|
137
|
+
fetch: FetchLike;
|
|
138
|
+
service?: ServiceKind;
|
|
139
|
+
adminPrefix?: string;
|
|
140
|
+
};
|
|
141
|
+
/** The subset of a fleet ready manifest (`--ready-file`) the harness reads. */
|
|
142
|
+
type FleetManifest = {
|
|
143
|
+
adminBase?: string;
|
|
144
|
+
services: Record<string, {
|
|
145
|
+
protocol?: string;
|
|
146
|
+
url?: string;
|
|
147
|
+
adminUrl?: string;
|
|
148
|
+
target?: string;
|
|
149
|
+
} | undefined>;
|
|
150
|
+
};
|
|
151
|
+
type Endpoint = {
|
|
152
|
+
/** The fleet entry name, e.g. `twilio`, or an alias such as `push`. */
|
|
153
|
+
name: string;
|
|
154
|
+
/** Null until the emulator's health route has told us what it is. */
|
|
155
|
+
service: ServiceKind | null;
|
|
156
|
+
/** Absolute admin prefix, e.g. `http://127.0.0.1:8798/__admin`. */
|
|
157
|
+
adminUrl: string;
|
|
158
|
+
fetch: FetchLike;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
type HarnessOptions = {
|
|
162
|
+
/** A running fleet controller's `adminBase` (`http://127.0.0.1:9000/__admin/fleet`). */
|
|
163
|
+
fleet?: string;
|
|
164
|
+
/** A fleet ready manifest, or a function that reads the current one (re-read on every sync). */
|
|
165
|
+
manifest?: FleetManifest | (() => FleetManifest | Promise<FleetManifest>);
|
|
166
|
+
/** Explicit emulators by name: base URLs, `{ url, adminUrl }`, or in-process runtimes. */
|
|
167
|
+
services?: Record<string, ServiceInput>;
|
|
168
|
+
/**
|
|
169
|
+
* Namespaces to watch in the background. `"all"` follows the fleet's namespace list.
|
|
170
|
+
* Any namespace an API call or the UI asks for is watched from then on. Default `["default"]`,
|
|
171
|
+
* or `"all"` with `fleet`.
|
|
172
|
+
*/
|
|
173
|
+
namespaces?: "all" | string[];
|
|
174
|
+
/** Background polling interval. `0` turns it off; reads still sync on demand. Default 750 ms. */
|
|
175
|
+
pollIntervalMs?: number;
|
|
176
|
+
/** Sent as `x-mockingbird-admin-key` to the fleet and every emulator. */
|
|
177
|
+
adminKey?: string;
|
|
178
|
+
/** Transport for URL endpoints. Default: global `fetch`. */
|
|
179
|
+
fetch?: FetchLike;
|
|
180
|
+
/** Page title. */
|
|
181
|
+
title?: string;
|
|
182
|
+
};
|
|
183
|
+
type WaitOptions = {
|
|
184
|
+
timeoutMs?: number;
|
|
185
|
+
count?: number;
|
|
186
|
+
};
|
|
187
|
+
type NotificationHarness = {
|
|
188
|
+
/** The harness UI and JSON API as a Fetch handler. */
|
|
189
|
+
fetch(request: Request): Promise<Response>;
|
|
190
|
+
/** Read every emulator now. Resolves after a read that started after this call. */
|
|
191
|
+
sync(namespace?: string): Promise<void>;
|
|
192
|
+
/** Notifications matching `filter`, oldest first, after a fresh sync. */
|
|
193
|
+
list(filter?: NotificationFilter): Promise<Notification[]>;
|
|
194
|
+
/** Resolve once at least `count` (default 1) notifications match; reject after `timeoutMs`. */
|
|
195
|
+
waitFor(filter: NotificationFilter, options?: WaitOptions): Promise<Notification[]>;
|
|
196
|
+
sources(): SourceStatus[];
|
|
197
|
+
close(): void;
|
|
198
|
+
};
|
|
199
|
+
declare const createNotificationHarness: (options?: HarnessOptions) => NotificationHarness;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A small MIME reader for the raw messages SES `SendRawEmail` receives. It covers what mail
|
|
203
|
+
* libraries produce (nodemailer, the AWS samples): folded headers, RFC 2047 encoded words,
|
|
204
|
+
* nested multiparts, base64 and quoted-printable bodies. Everything is decoded as UTF-8.
|
|
205
|
+
*/
|
|
206
|
+
|
|
207
|
+
type ParsedMime = {
|
|
208
|
+
headers: Record<string, string>;
|
|
209
|
+
subject: string;
|
|
210
|
+
from: string | null;
|
|
211
|
+
to: string[];
|
|
212
|
+
cc: string[];
|
|
213
|
+
html: string | null;
|
|
214
|
+
text: string | null;
|
|
215
|
+
attachments: EmailAttachment[];
|
|
216
|
+
};
|
|
217
|
+
declare const parseMime: (raw: string) => ParsedMime;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Every `data-testid` the harness UI renders, and selector helpers for browser tests.
|
|
221
|
+
*
|
|
222
|
+
* Elements that show one notification also carry its fields as attributes, so a test can narrow by
|
|
223
|
+
* what it knows instead of by position:
|
|
224
|
+
*
|
|
225
|
+
* data-notification-id data-channel data-service data-source data-to data-from
|
|
226
|
+
* data-status data-created-at data-unread ("true" | "false")
|
|
227
|
+
*
|
|
228
|
+
* The UI root (`TEST_IDS.app`) reports its state the same way: `data-ready` ("true" once the first
|
|
229
|
+
* read finished), `data-view`, `data-namespace`, `data-count`, `data-version`, `data-connected`,
|
|
230
|
+
* `data-sound` and `data-motion`.
|
|
231
|
+
*
|
|
232
|
+
* This module has no dependencies, so Playwright, Cypress or WebdriverIO specs can import it.
|
|
233
|
+
*/
|
|
234
|
+
declare const TEST_IDS: {
|
|
235
|
+
/** The UI root. */
|
|
236
|
+
readonly app: "notifications-app";
|
|
237
|
+
readonly topbar: "topbar";
|
|
238
|
+
/** `<select>` of namespaces. */
|
|
239
|
+
readonly namespace: "namespace";
|
|
240
|
+
/** Filter `<input>` (recipient, sender, text). */
|
|
241
|
+
readonly search: "search";
|
|
242
|
+
/** View tabs; `aria-pressed="true"` on the active one. */
|
|
243
|
+
readonly viewDevices: "view-devices";
|
|
244
|
+
readonly viewTimeline: "view-timeline";
|
|
245
|
+
/** "Follow new" checkbox. */
|
|
246
|
+
readonly follow: "follow";
|
|
247
|
+
/** Sound on/off button; `aria-pressed`. */
|
|
248
|
+
readonly sound: "sound-toggle";
|
|
249
|
+
/** Live stream indicator; `data-state="live" | "reconnecting"`. */
|
|
250
|
+
readonly connection: "connection";
|
|
251
|
+
/** One pill per emulator; `data-name`, `data-service`, `data-ok`, `data-count`. */
|
|
252
|
+
readonly source: "source";
|
|
253
|
+
/** Discovery or emulator errors, or the "no emulators" hint; `data-kind="error" | "empty"`. */
|
|
254
|
+
readonly notice: "notice";
|
|
255
|
+
/** Bottom-left message after an action, e.g. "Conversation archived." */
|
|
256
|
+
readonly toast: "toast";
|
|
257
|
+
readonly toastUndo: "toast-undo";
|
|
258
|
+
/** The phone; `data-screen="threads" | "conversation"`, `data-recipient`, `data-thread`. */
|
|
259
|
+
readonly smsDevice: "sms-device";
|
|
260
|
+
/** Phone number `<select>`; option values are E.164 numbers. */
|
|
261
|
+
readonly smsRecipient: "sms-recipient";
|
|
262
|
+
/** Unread SMS badge next to the column title (absent when 0). */
|
|
263
|
+
readonly smsUnreadCount: "sms-unread-count";
|
|
264
|
+
/** One conversation in the list; `data-from` (the sender address), `data-unread`, `data-pinned`, `data-muted`. */
|
|
265
|
+
readonly smsThread: "sms-thread";
|
|
266
|
+
readonly smsThreadName: "sms-thread-name";
|
|
267
|
+
readonly smsThreadPreview: "sms-thread-preview";
|
|
268
|
+
readonly smsThreadTime: "sms-thread-time";
|
|
269
|
+
readonly smsThreadUnread: "sms-thread-unread";
|
|
270
|
+
/** Swipe actions behind a thread (also reachable with the keyboard: focus the thread, press Delete). */
|
|
271
|
+
readonly smsThreadDelete: "sms-thread-delete";
|
|
272
|
+
readonly smsThreadMute: "sms-thread-mute";
|
|
273
|
+
readonly smsThreadPin: "sms-thread-pin";
|
|
274
|
+
readonly smsThreadToggleUnread: "sms-thread-toggle-unread";
|
|
275
|
+
/** Shown when the phone has no messages. */
|
|
276
|
+
readonly smsEmpty: "sms-empty";
|
|
277
|
+
/** An open conversation; `data-from`. */
|
|
278
|
+
readonly smsConversation: "sms-conversation";
|
|
279
|
+
readonly smsConversationTitle: "sms-conversation-title";
|
|
280
|
+
readonly smsBack: "sms-back";
|
|
281
|
+
/** One message; carries the notification attributes plus `data-code` when it holds a one-time code. */
|
|
282
|
+
readonly smsBubble: "sms-bubble";
|
|
283
|
+
readonly smsBubbleMedia: "sms-bubble-media";
|
|
284
|
+
/** The time revealed by swiping the conversation left. */
|
|
285
|
+
readonly smsBubbleTime: "sms-bubble-time";
|
|
286
|
+
/** A reaction added with the long-press menu; `data-tapback`. */
|
|
287
|
+
readonly smsTapback: "sms-tapback";
|
|
288
|
+
/** Centered date stamp between messages. */
|
|
289
|
+
readonly smsTimestamp: "sms-timestamp";
|
|
290
|
+
/** One-time code suggestion above the keyboard; `data-code`. */
|
|
291
|
+
readonly smsCode: "sms-code";
|
|
292
|
+
/** Long-press menu on a bubble. */
|
|
293
|
+
readonly smsMenu: "sms-menu";
|
|
294
|
+
readonly smsMenuCopy: "sms-menu-copy";
|
|
295
|
+
/** One tapback in the menu; `data-tapback="heart" | "like" | "dislike" | "laugh" | "emphasize" | "question"`. */
|
|
296
|
+
readonly smsMenuTapback: "sms-menu-tapback";
|
|
297
|
+
/** The phone; `data-skin="ios" | "android"`, `data-recipient`, `data-platform`. */
|
|
298
|
+
readonly pushDevice: "push-device";
|
|
299
|
+
/** Device token `<select>`. */
|
|
300
|
+
readonly pushRecipient: "push-recipient";
|
|
301
|
+
/** Skin buttons; `data-value`, `aria-pressed`. */
|
|
302
|
+
readonly pushSkin: "push-skin";
|
|
303
|
+
readonly pushUnreadCount: "push-unread-count";
|
|
304
|
+
/** One notification; notification attributes plus `data-expanded`, `data-platform`, `data-app`, `data-stacked`. */
|
|
305
|
+
readonly pushNotification: "push-notification";
|
|
306
|
+
readonly pushNotificationApp: "push-notification-app";
|
|
307
|
+
readonly pushNotificationTitle: "push-notification-title";
|
|
308
|
+
readonly pushNotificationBody: "push-notification-body";
|
|
309
|
+
readonly pushNotificationTime: "push-notification-time";
|
|
310
|
+
readonly pushNotificationImage: "push-notification-image";
|
|
311
|
+
/** The `data` payload, shown when expanded. */
|
|
312
|
+
readonly pushNotificationData: "push-notification-data";
|
|
313
|
+
/** Service, status, badge and sound, shown when expanded. */
|
|
314
|
+
readonly pushNotificationMeta: "push-notification-meta";
|
|
315
|
+
/** iOS: a group of notifications from one app; `data-app`, `data-count`, `data-open`. */
|
|
316
|
+
readonly pushGroup: "push-group";
|
|
317
|
+
/** iOS: "N more notifications" under a collapsed group (click to open it); `data-count`. */
|
|
318
|
+
readonly pushGroupMore: "push-group-more";
|
|
319
|
+
/** iOS: "Show less" on an open group. */
|
|
320
|
+
readonly pushGroupCollapse: "push-group-collapse";
|
|
321
|
+
/** iOS: swipe-left actions. */
|
|
322
|
+
readonly pushClear: "push-clear";
|
|
323
|
+
readonly pushOptions: "push-options";
|
|
324
|
+
/** iOS long-press preview; Android long-press settings. */
|
|
325
|
+
readonly pushPreview: "push-preview";
|
|
326
|
+
readonly pushSettings: "push-settings";
|
|
327
|
+
/** Android "Clear all". */
|
|
328
|
+
readonly pushClearAll: "push-clear-all";
|
|
329
|
+
readonly pushEmpty: "push-empty";
|
|
330
|
+
/** The browser window; `data-screen="inbox" | "message"`, `data-recipient`. */
|
|
331
|
+
readonly emailDevice: "email-device";
|
|
332
|
+
/** Mailbox `<select>`; option values are lower-case addresses. */
|
|
333
|
+
readonly emailRecipient: "email-recipient";
|
|
334
|
+
readonly emailUnreadCount: "email-unread-count";
|
|
335
|
+
/** One inbox row; notification attributes plus `data-starred`. */
|
|
336
|
+
readonly emailRow: "email-row";
|
|
337
|
+
readonly emailRowFrom: "email-row-from";
|
|
338
|
+
readonly emailRowSubject: "email-row-subject";
|
|
339
|
+
readonly emailRowSnippet: "email-row-snippet";
|
|
340
|
+
readonly emailRowDate: "email-row-date";
|
|
341
|
+
readonly emailRowService: "email-row-service";
|
|
342
|
+
readonly emailRowAttachment: "email-row-attachment";
|
|
343
|
+
readonly emailRowStar: "email-row-star";
|
|
344
|
+
/** Hover actions on a row. */
|
|
345
|
+
readonly emailRowArchive: "email-row-archive";
|
|
346
|
+
readonly emailRowDelete: "email-row-delete";
|
|
347
|
+
readonly emailRowToggleRead: "email-row-toggle-read";
|
|
348
|
+
readonly emailRefresh: "email-refresh";
|
|
349
|
+
readonly emailEmpty: "email-empty";
|
|
350
|
+
/** The open message; notification attributes. */
|
|
351
|
+
readonly emailOpen: "email-open";
|
|
352
|
+
readonly emailBack: "email-back";
|
|
353
|
+
readonly emailArchive: "email-archive";
|
|
354
|
+
readonly emailDelete: "email-delete";
|
|
355
|
+
readonly emailMarkUnread: "email-mark-unread";
|
|
356
|
+
readonly emailSubject: "email-subject";
|
|
357
|
+
readonly emailFromName: "email-from-name";
|
|
358
|
+
readonly emailFromAddress: "email-from-address";
|
|
359
|
+
/** "to me, …" line; click to toggle the details. */
|
|
360
|
+
readonly emailTo: "email-to";
|
|
361
|
+
readonly emailDate: "email-date";
|
|
362
|
+
readonly emailDetails: "email-details";
|
|
363
|
+
/** One row of the details; `data-field="from" | "to" | "cc" | "bcc" | "reply-to" | "date" | "subject" | "mailed-by" | "message-id"`. */
|
|
364
|
+
readonly emailDetail: "email-detail";
|
|
365
|
+
/** The HTML body `<iframe>` (same origin: use `frameLocator`). */
|
|
366
|
+
readonly emailBody: "email-body";
|
|
367
|
+
/** The plain-text body when there is no HTML. */
|
|
368
|
+
readonly emailText: "email-text";
|
|
369
|
+
readonly emailAttachments: "email-attachments";
|
|
370
|
+
/** One attachment; `data-filename`, `data-content-type`, `data-size`. */
|
|
371
|
+
readonly emailAttachment: "email-attachment";
|
|
372
|
+
readonly timeline: "timeline";
|
|
373
|
+
/** One row; notification attributes. Click it to inspect. */
|
|
374
|
+
readonly timelineRow: "timeline-row";
|
|
375
|
+
readonly timelineEmpty: "timeline-empty";
|
|
376
|
+
/** The JSON inspector; notification attributes. Opened by clicking a timeline row or double-clicking any notification. */
|
|
377
|
+
readonly inspector: "inspector";
|
|
378
|
+
readonly inspectorTitle: "inspector-title";
|
|
379
|
+
readonly inspectorClose: "inspector-close";
|
|
380
|
+
readonly inspectorShowOnDevice: "inspector-show-on-device";
|
|
381
|
+
/** One normalized field; `data-field`. */
|
|
382
|
+
readonly inspectorField: "inspector-field";
|
|
383
|
+
/** The full record, normalized fields plus `raw`, as JSON text. */
|
|
384
|
+
readonly inspectorJson: "inspector-json";
|
|
385
|
+
};
|
|
386
|
+
type TestId = (typeof TEST_IDS)[keyof typeof TEST_IDS];
|
|
387
|
+
/** CSS selectors for the harness UI. Each one works with `page.locator()` or `querySelector`. */
|
|
388
|
+
declare const selectors: {
|
|
389
|
+
/** `[data-testid="…"]` */
|
|
390
|
+
testId: (id: TestId) => string;
|
|
391
|
+
/** The ready UI root: wait for this before asserting. */
|
|
392
|
+
ready: () => string;
|
|
393
|
+
/** Any element showing this notification (bubble, thread, card, row, open email, timeline row). */
|
|
394
|
+
notification: (id: string) => string;
|
|
395
|
+
/** A message bubble, optionally from one sender. */
|
|
396
|
+
smsBubble: (filter?: {
|
|
397
|
+
from?: string;
|
|
398
|
+
id?: string;
|
|
399
|
+
}) => string;
|
|
400
|
+
/** A conversation in the Messages list. */
|
|
401
|
+
smsThread: (from: string) => string;
|
|
402
|
+
/** A push notification card, optionally by id. */
|
|
403
|
+
pushNotification: (id?: string) => string;
|
|
404
|
+
/** An inbox row, optionally by id. */
|
|
405
|
+
emailRow: (id?: string) => string;
|
|
406
|
+
/** A timeline row, optionally by id. */
|
|
407
|
+
timelineRow: (id?: string) => string;
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
export { NotificationClient, SERVICE_KINDS, SERVICE_LABELS, TEST_IDS, createNotificationHarness, matches, parseMime, selectors };
|
|
411
|
+
export type { Address, Channel, EmailAttachment, EmailContent, Endpoint, FetchLike, FleetManifest, HarnessOptions, Notification, NotificationClientOptions, NotificationFilter, NotificationHarness, PushContent, PushPlatform, ServiceInput, ServiceKind, SmsContent, SourceStatus, TestId, WaitOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
TEST_IDS,
|
|
3
|
+
selectors
|
|
4
|
+
} from "./chunk-LM7R7AR7.js";
|
|
5
|
+
import {
|
|
6
|
+
SERVICE_KINDS,
|
|
7
|
+
SERVICE_LABELS,
|
|
8
|
+
createNotificationHarness,
|
|
9
|
+
filterToSearch,
|
|
10
|
+
matches,
|
|
11
|
+
parseMime
|
|
12
|
+
} from "./chunk-GRGR3QZM.js";
|
|
13
|
+
import "./chunk-QE3TRUI6.js";
|
|
14
|
+
|
|
15
|
+
// src/client.ts
|
|
16
|
+
var NotificationClient = class {
|
|
17
|
+
baseUrl;
|
|
18
|
+
namespace;
|
|
19
|
+
fetchImpl;
|
|
20
|
+
constructor(baseUrl, options = {}) {
|
|
21
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
22
|
+
this.namespace = options.namespace;
|
|
23
|
+
this.fetchImpl = options.fetch ?? ((request) => fetch(request));
|
|
24
|
+
}
|
|
25
|
+
async get(path, filter = {}, extra = {}) {
|
|
26
|
+
const params = filterToSearch({ namespace: this.namespace, ...filter });
|
|
27
|
+
for (const [k, v] of Object.entries(extra)) params.set(k, v);
|
|
28
|
+
const query = params.toString();
|
|
29
|
+
const response = await this.fetchImpl(
|
|
30
|
+
new Request(`${this.baseUrl}${path}${query ? `?${query}` : ""}`)
|
|
31
|
+
);
|
|
32
|
+
const body = await response.json();
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const error = new Error(body.error?.message ?? `HTTP ${response.status}`);
|
|
35
|
+
Object.assign(error, { status: response.status, body });
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
return body;
|
|
39
|
+
}
|
|
40
|
+
/** Every matching notification, oldest first. */
|
|
41
|
+
async list(filter = {}) {
|
|
42
|
+
return (await this.get("/api/notifications", filter)).notifications;
|
|
43
|
+
}
|
|
44
|
+
/** The newest match, or `null`. */
|
|
45
|
+
async latest(filter = {}) {
|
|
46
|
+
try {
|
|
47
|
+
return await this.get("/api/notifications/latest", filter);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error.status === 404) return null;
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Wait until `count` (default 1) notifications match and return them; throws on timeout. */
|
|
54
|
+
async waitFor(filter, options = {}) {
|
|
55
|
+
const extra = {};
|
|
56
|
+
if (options.timeoutMs !== void 0) extra.timeoutMs = String(options.timeoutMs);
|
|
57
|
+
if (options.count !== void 0) extra.count = String(options.count);
|
|
58
|
+
return (await this.get("/api/wait", filter, extra)).notifications;
|
|
59
|
+
}
|
|
60
|
+
/** Wait for the next SMS to `to` and return its one-time code (Twilio Verify) or first 4–8 digit run. */
|
|
61
|
+
async waitForCode(to, filter = {}, timeoutMs) {
|
|
62
|
+
const found = await this.waitFor(
|
|
63
|
+
{ channel: "sms", to, ...filter },
|
|
64
|
+
timeoutMs === void 0 ? {} : { timeoutMs }
|
|
65
|
+
);
|
|
66
|
+
const last = found.at(-1);
|
|
67
|
+
const code = last?.sms?.code ?? /\b(\d{4,8})\b/.exec(last?.sms?.body ?? "")?.[1];
|
|
68
|
+
if (!code) throw new Error(`no code in the latest SMS to ${to}`);
|
|
69
|
+
return code;
|
|
70
|
+
}
|
|
71
|
+
async sources() {
|
|
72
|
+
return (await this.get("/api/sources")).sources;
|
|
73
|
+
}
|
|
74
|
+
/** A UI link that opens one device (or one notification) for a screenshot. */
|
|
75
|
+
link(view = {}) {
|
|
76
|
+
const params = new URLSearchParams();
|
|
77
|
+
if (this.namespace) params.set("namespace", this.namespace);
|
|
78
|
+
if (view.channel) params.set("view", view.channel);
|
|
79
|
+
if (view.to) params.set("to", view.to);
|
|
80
|
+
if (view.id) params.set("id", view.id);
|
|
81
|
+
const query = params.toString();
|
|
82
|
+
return `${this.baseUrl}/${query ? `?${query}` : ""}`;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
export {
|
|
86
|
+
NotificationClient,
|
|
87
|
+
SERVICE_KINDS,
|
|
88
|
+
SERVICE_LABELS,
|
|
89
|
+
TEST_IDS,
|
|
90
|
+
createNotificationHarness,
|
|
91
|
+
matches,
|
|
92
|
+
parseMime,
|
|
93
|
+
selectors
|
|
94
|
+
};
|
|
95
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/client.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n filterToSearch,\n type Notification,\n type NotificationFilter,\n type SourceStatus,\n} from \"./model.js\"\n\nexport type NotificationClientOptions = {\n /** Default namespace for every call that does not name one. */\n namespace?: string\n fetch?: (request: Request) => Response | Promise<Response>\n}\n\n/** Talks to a running harness over HTTP, for tests that run in another process. */\nexport class NotificationClient {\n readonly baseUrl: string\n private readonly namespace: string | undefined\n private readonly fetchImpl: (request: Request) => Response | Promise<Response>\n\n constructor(baseUrl: string, options: NotificationClientOptions = {}) {\n this.baseUrl = baseUrl.replace(/\\/+$/, \"\")\n this.namespace = options.namespace\n this.fetchImpl = options.fetch ?? ((request) => fetch(request))\n }\n\n private async get<T>(\n path: string,\n filter: NotificationFilter = {},\n extra: Record<string, string> = {},\n ) {\n const params = filterToSearch({ namespace: this.namespace, ...filter })\n for (const [k, v] of Object.entries(extra)) params.set(k, v)\n const query = params.toString()\n const response = await this.fetchImpl(\n new Request(`${this.baseUrl}${path}${query ? `?${query}` : \"\"}`),\n )\n const body = (await response.json()) as T & { error?: { message?: string } }\n if (!response.ok) {\n const error = new Error(body.error?.message ?? `HTTP ${response.status}`)\n Object.assign(error, { status: response.status, body })\n throw error\n }\n return body\n }\n\n /** Every matching notification, oldest first. */\n async list(filter: NotificationFilter = {}): Promise<Notification[]> {\n return (await this.get<{ notifications: Notification[] }>(\"/api/notifications\", filter))\n .notifications\n }\n\n /** The newest match, or `null`. */\n async latest(filter: NotificationFilter = {}): Promise<Notification | null> {\n try {\n return await this.get<Notification>(\"/api/notifications/latest\", filter)\n } catch (error) {\n if ((error as { status?: number }).status === 404) return null\n throw error\n }\n }\n\n /** Wait until `count` (default 1) notifications match and return them; throws on timeout. */\n async waitFor(\n filter: NotificationFilter,\n options: { timeoutMs?: number; count?: number } = {},\n ): Promise<Notification[]> {\n const extra: Record<string, string> = {}\n if (options.timeoutMs !== undefined) extra.timeoutMs = String(options.timeoutMs)\n if (options.count !== undefined) extra.count = String(options.count)\n return (await this.get<{ notifications: Notification[] }>(\"/api/wait\", filter, extra))\n .notifications\n }\n\n /** Wait for the next SMS to `to` and return its one-time code (Twilio Verify) or first 4\u20138 digit run. */\n async waitForCode(\n to: string,\n filter: NotificationFilter = {},\n timeoutMs?: number,\n ): Promise<string> {\n const found = await this.waitFor(\n { channel: \"sms\", to, ...filter },\n timeoutMs === undefined ? {} : { timeoutMs },\n )\n const last = found.at(-1)\n const code = last?.sms?.code ?? /\\b(\\d{4,8})\\b/.exec(last?.sms?.body ?? \"\")?.[1]\n if (!code) throw new Error(`no code in the latest SMS to ${to}`)\n return code\n }\n\n async sources(): Promise<SourceStatus[]> {\n return (await this.get<{ sources: SourceStatus[] }>(\"/api/sources\")).sources\n }\n\n /** A UI link that opens one device (or one notification) for a screenshot. */\n link(view: { channel?: \"sms\" | \"email\" | \"push\"; to?: string; id?: string } = {}): string {\n const params = new URLSearchParams()\n if (this.namespace) params.set(\"namespace\", this.namespace)\n if (view.channel) params.set(\"view\", view.channel)\n if (view.to) params.set(\"to\", view.to)\n if (view.id) params.set(\"id\", view.id)\n const query = params.toString()\n return `${this.baseUrl}/${query ? `?${query}` : \"\"}`\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|