@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 CHANGED
@@ -1,3 +1,305 @@
1
- # Temporary Holding Version
1
+ # @crvouga/mockingbird-notifications
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Local emulators. Real API contracts. Part of [Mockingbird](https://github.com/crvouga/mockingbird).
4
+
5
+ A notification test harness for a Mockingbird fleet. It reads the same emulators your application
6
+ sends through and shows every SMS, email and push the way the recipient would see it: a phone's
7
+ Messages app, a phone lock screen (iOS) or notification shade (Android), and a webmail inbox.
8
+ Automated tests get the same data from a JSON API and a small client.
9
+
10
+ | Channel | Emulators read | Shown as |
11
+ | --- | --- | --- |
12
+ | SMS | Twilio (Messages and Verify codes), Amazon SNS (direct `PhoneNumber` and `sms` topic subscribers), Customer.io, Mailosaur | Messages app: threads per sender, bubbles, one-time code suggestion |
13
+ | Email | Resend, Amazon SES (`SendEmail` and MIME `SendRawEmail`), Amazon SNS (`email` / `email-json` subscribers), Customer.io, Mailosaur, Twilio Verify email | Webmail: inbox per recipient (to, cc, bcc), rendered HTML, attachments, headers |
14
+ | Push | Firebase Cloud Messaging | iOS lock screen or Android shade, chosen from the device token's platform; title, body, image, data, badge, sound |
15
+
16
+ The [Notifications guide](https://mockingbird.chrisvouga.dev/docs/notifications) on the docs site
17
+ splits this README into pages, and the
18
+ [lab ordering example](https://mockingbird.chrisvouga.dev/examples/medical-testing) runs it live.
19
+
20
+ The harness only reads. It sends `GET` requests to each emulator's admin routes (`/__admin/outbox`
21
+ or `/__admin/state/...`) and never changes emulator state. The device art is generic: no vendor
22
+ logos.
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ npm install -D @crvouga/mockingbird-notifications
28
+ ```
29
+
30
+ Node 22 or newer. Install it next to `@crvouga/mockingbird-fleet` and the emulator packages your
31
+ application uses.
32
+
33
+ ## Run it against your fleet
34
+
35
+ Start the fleet as usual, then point the harness at the fleet controller or its ready file:
36
+
37
+ ```sh
38
+ mockingbird-fleet serve --config mockingbird.json --ready-file .mockingbird/ready.json
39
+ mockingbird-notifications serve --manifest .mockingbird/ready.json # http://127.0.0.1:8790
40
+ # or, with the controller URL from the ready file:
41
+ mockingbird-notifications serve --fleet http://127.0.0.1:9000/__admin/fleet
42
+ ```
43
+
44
+ With `--fleet`, the harness asks the controller what each entry is, so aliases work (`push` with
45
+ `target: "fcm"`), and it follows every namespace the fleet has. With `--manifest`, the file is
46
+ re-read on every sync, so a restarted fleet on new ports is picked up. Entries that are not
47
+ messaging emulators (Stripe, Postgres, …) are skipped. Emulators outside a fleet can be named
48
+ directly:
49
+
50
+ ```sh
51
+ mockingbird-notifications serve --service twilio=http://127.0.0.1:8798 --service resend=http://127.0.0.1:8794
52
+ ```
53
+
54
+ | Flag | Meaning |
55
+ | --- | --- |
56
+ | `--fleet <url>` | Fleet controller `adminBase`. |
57
+ | `--manifest <file>` | The fleet's `--ready-file`. |
58
+ | `--service <name=url>` | One emulator (repeatable). The name is the service (`twilio`, `fcm`, …) or any alias; an alias is identified from the emulator's health route. |
59
+ | `--port`, `--host` | Listener. Default `127.0.0.1:8790`; `--port 0` picks a free port. |
60
+ | `--namespace <name>` | Watch this namespace in the background (repeatable). Default `default`, or every fleet namespace with `--fleet`. Any namespace the UI or an API call asks for is watched from then on. |
61
+ | `--poll <ms>` | Background poll interval, default 750. `0` reads only when asked. |
62
+ | `--admin-key-env <VAR>` | Environment variable with the admin key (default `MOCKINGBIRD_ADMIN_KEY`), sent as `x-mockingbird-admin-key`. |
63
+ | `--ready-json` | Print `{"url": …}` once listening. |
64
+
65
+ ## The UI
66
+
67
+ `/` shows three devices side by side. Each has a recipient picker (phone number, push token,
68
+ mailbox) that follows the newest arrival unless **Follow new** is off. The **Timeline** tab lists
69
+ everything in one table; double-click any message, or click a timeline row, to inspect the
70
+ normalized record and the emulator's raw one. Read state is kept per browser.
71
+
72
+ Links open one device for a screenshot, for example in a Playwright test:
73
+
74
+ | Query | Effect |
75
+ | --- | --- |
76
+ | `?namespace=worker-3` | Show that namespace. |
77
+ | `?view=sms\|push\|email&to=<recipient>` | Show only that device, opened on that recipient (an SMS link opens the newest thread). |
78
+ | `?id=<notification id>` | Open that notification on its device. |
79
+ | `?view=timeline` | Open the timeline. |
80
+ | `?follow=0` | Do not jump to new arrivals. |
81
+
82
+ The devices behave like the real apps, in a generic style with no vendor marks:
83
+
84
+ - **Messages:** swipe a conversation left for Hide Alerts and Delete, or right for Pin and
85
+ Unread. Swipe the open conversation left to see each message's time, and long-press a message
86
+ for tapbacks and Copy. One-time codes show above the keyboard.
87
+ - **Lock screen (iOS):** notifications group by app. Tap a group to open it, swipe a card left for
88
+ Options and Clear, and long-press it for a preview.
89
+ - **Notification shade (Android):** tap a card to expand it, swipe it either way to dismiss it,
90
+ long-press it for its settings, and use Clear all.
91
+ - **Mail:** hover a row for Archive, Delete and Mark as read, then Undo from the toast. Star rows,
92
+ and open a message to read its HTML.
93
+
94
+ Every gesture also works with a click or the keyboard. Dismissing, archiving or deleting only hides
95
+ the message in this browser; the harness keeps every record, and a reload brings it back.
96
+
97
+ **Sound** (off by default; the top bar's speaker toggles it, and `?sound=1` turns it on): new
98
+ arrivals play a synthesized chime in each platform's style, and phones vibrate where the browser
99
+ allows. **Motion:** arrivals, swipes, stacks and screen changes are animated. A viewer's
100
+ reduced-motion setting turns animation off, and so does the `motion: "reduce"` option.
101
+
102
+ ## End-to-end tests
103
+
104
+ The UI is built to be driven by Playwright, Cypress or any WebDriver client. It gives tests three
105
+ things:
106
+
107
+ 1. **A ready signal.** The root, `[data-testid="notifications-app"]`, gets `data-ready="true"`
108
+ once its first read is drawn. It also reports `data-count`, `data-version`, `data-view`,
109
+ `data-namespace`, `data-connected`, `data-sound` and `data-motion`.
110
+ 2. **A `data-testid` on everything a user can see or press.** `TEST_IDS` names them all, and its
111
+ typings say what each one marks.
112
+ 3. **The notification's fields on each element that shows it.** Every bubble, thread, card, row,
113
+ open email and timeline row carries `data-notification-id`, `data-channel`, `data-service`,
114
+ `data-source`, `data-to`, `data-from`, `data-status`, `data-created-at` and `data-unread`. A
115
+ bubble that holds a one-time code also has `data-code`. Use these to find an element by what the
116
+ test knows instead of by its position.
117
+
118
+ Wait for the API first, then open a link to the message and assert on what the user would see:
119
+
120
+ ```ts
121
+ import { NotificationClient, selectors, TEST_IDS } from "@crvouga/mockingbird-notifications"
122
+ import { expect, test } from "@playwright/test"
123
+
124
+ const notifications = new NotificationClient("http://127.0.0.1:8790")
125
+
126
+ test("the invitation email reaches Ada's inbox", async ({ page }) => {
127
+ // …the app sends the invitation…
128
+ const [invite] = await notifications.waitFor({ channel: "email", to: "ada@example.com", contains: "invited" })
129
+ if (!invite) throw new Error("no invitation")
130
+
131
+ await page.goto(notifications.link({ id: invite.id })) // opens the inbox on that email
132
+ await page.locator(selectors.ready()).waitFor()
133
+
134
+ await expect(page.getByTestId(TEST_IDS.emailSubject)).toHaveText("You're invited")
135
+ // The HTML body is a same-origin iframe.
136
+ const body = page.frameLocator(selectors.testId(TEST_IDS.emailBody))
137
+ await expect(body.getByRole("link", { name: "Accept" })).toBeVisible()
138
+ })
139
+
140
+ test("the sign-in code is offered above the keyboard", async ({ page }) => {
141
+ const code = await notifications.waitForCode("+12025550123")
142
+ const [text] = await notifications.list({ channel: "sms", to: "+12025550123", contains: code })
143
+ await page.goto(notifications.link({ id: text?.id }))
144
+ await expect(page.locator(selectors.smsBubble({ id: text?.id }))).toHaveAttribute("data-code", code)
145
+ await expect(page.getByTestId(TEST_IDS.smsCode)).toHaveAttribute("data-code", code)
146
+ })
147
+ ```
148
+
149
+ `selectors` builds the common CSS selectors: `ready()`, `testId(id)`, `notification(id)`,
150
+ `smsBubble({from, id})`, `smsThread(from)`, `pushNotification(id)`, `emailRow(id)` and
151
+ `timelineRow(id)`.
152
+
153
+ | Device | Main test ids | State attributes |
154
+ | --- | --- | --- |
155
+ | Top bar | `namespace`, `search`, `view-devices`, `view-timeline`, `follow`, `sound-toggle`, `connection`, `source`, `notice`, `toast`, `toast-undo` | `source`: `data-name`, `data-service`, `data-ok`, `data-count`. `connection`: `data-state`. |
156
+ | Messages | `sms-device`, `sms-recipient`, `sms-thread` (`-name`, `-preview`, `-time`, `-unread`), `sms-conversation`, `sms-back`, `sms-bubble`, `sms-code`, `sms-menu`, `sms-menu-tapback`, `sms-tapback`, `sms-empty` | `sms-device`: `data-screen` (`threads` or `conversation`), `data-recipient`, `data-thread`. `sms-thread`: `data-pinned`, `data-muted`. |
157
+ | Push | `push-device`, `push-recipient`, `push-skin`, `push-group`, `push-group-more`, `push-notification` (`-title`, `-body`, `-app`, `-time`, `-image`, `-data`, `-meta`), `push-clear`, `push-options`, `push-preview`, `push-settings`, `push-clear-all`, `push-empty` | `push-device`: `data-skin` (`ios` or `android`), `data-platform`. `push-notification`: `data-expanded`, `data-app`, `data-stacked`. |
158
+ | Mail | `email-device`, `email-recipient`, `email-row` (`-from`, `-subject`, `-snippet`, `-date`, `-star`, `-archive`, `-delete`), `email-open`, `email-subject`, `email-from-name`, `email-from-address`, `email-to`, `email-details`, `email-detail`, `email-body`, `email-text`, `email-attachment`, `email-empty` | `email-device`: `data-screen` (`inbox` or `message`). `email-detail`: `data-field`. `email-attachment`: `data-filename`, `data-content-type`, `data-size`. |
159
+ | Timeline | `timeline`, `timeline-row`, `inspector`, `inspector-json`, `inspector-field`, `inspector-close`, `inspector-show-on-device` | `inspector-json` holds the whole record, `raw` included, as JSON. |
160
+
161
+ Controls are real buttons, selects and inputs with accessible names, so `getByRole` works as well.
162
+ New arrivals are drawn live, so a test can keep a page open while the app sends. When several tests
163
+ share a harness, give each one its own namespace (`?namespace=`) or open a deep link to one
164
+ recipient: a link with `to=` stays on that recipient instead of following new arrivals. Set
165
+ `?follow=0` on any other link that should stay put.
166
+
167
+ [`e2e/ui.e2e.ts`](https://github.com/crvouga/mockingbird/blob/main/packages/notifications/e2e/ui.e2e.ts)
168
+ in this repository is a complete, passing example with real Twilio, Resend and FCM emulators.
169
+
170
+ ## Embed the UI
171
+
172
+ `@crvouga/mockingbird-notifications/ui` mounts the same UI on any element, for example as a tab in
173
+ your own development shell. It reads an in-process harness directly, so the emulators, the harness
174
+ and the UI can all run in one browser tab with no server:
175
+
176
+ ```ts
177
+ import { createNotificationHarness } from "@crvouga/mockingbird-notifications"
178
+ import { mountNotifications } from "@crvouga/mockingbird-notifications/ui"
179
+ import { createRuntime as resend } from "@crvouga/mockingbird-service-resend"
180
+ import { createRuntime as twilio } from "@crvouga/mockingbird-service-twilio"
181
+
182
+ const harness = createNotificationHarness({ services: { twilio: twilio(), resend: resend() } })
183
+ const panel = document.getElementById("notifications") as HTMLElement
184
+ const ui = mountNotifications(panel, { harness, title: "My app notifications" })
185
+ await ui.ready
186
+ // ui.show({ channel: "sms", to: "+12025550123" }); ui.setSound(true); ui.setNamespace("worker-3")
187
+ ui.destroy()
188
+ ```
189
+
190
+ Its styles are scoped to the UI's root (`.mb-notifications`) and added once to the document, or to
191
+ the shadow root that holds `panel`. Options: `harness`, or `fetch` and `baseUrl` for a harness on
192
+ another origin; `channels` (which devices to show); `chrome` (show the top bar); `sound`; `motion`
193
+ (`auto`, `full` or `reduce`); `onArrive`; and `syncUrl`, which reads and writes `?view=`-style
194
+ links on the page. The [lab ordering example](https://github.com/crvouga/mockingbird/tree/main/packages/examples/medical-testing)
195
+ mounts it as its Notifications tab.
196
+
197
+ ## Usage
198
+
199
+ `NotificationClient` talks to a running harness:
200
+
201
+ ```ts
202
+ import { NotificationClient } from "@crvouga/mockingbird-notifications"
203
+
204
+ const notifications = new NotificationClient("http://127.0.0.1:8790", { namespace: "worker-3" })
205
+
206
+ // The app texts a sign-in code; the test reads it the way the user would.
207
+ const code = await notifications.waitForCode("+12025550123")
208
+
209
+ // The app emails an invitation.
210
+ const [invite] = await notifications.waitFor({ channel: "email", to: "ada@example.com", contains: "invited" })
211
+ console.log(invite?.email?.subject, invite?.email?.html)
212
+
213
+ // A link to the inbox opened on that email, e.g. for Playwright's `page.goto(url)` and a screenshot.
214
+ const url = notifications.link({ id: invite?.id })
215
+ ```
216
+
217
+ Or run the harness inside the test process, against servers or in-process runtimes:
218
+
219
+ ```ts
220
+ import { createNotificationHarness } from "@crvouga/mockingbird-notifications"
221
+ import { createRuntime as twilio } from "@crvouga/mockingbird-service-twilio"
222
+
223
+ const harness = createNotificationHarness({ services: { twilio: twilio() }, pollIntervalMs: 0 })
224
+ // …the app sends through the same runtime…
225
+ const [sms] = await harness.waitFor({ channel: "sms", to: "+12025550123" })
226
+ harness.close()
227
+ ```
228
+
229
+ `createServer` from `@crvouga/mockingbird-notifications/server` serves the same harness over
230
+ `node:http` and takes the CLI's options (`fleet`, `manifest` as a path or object, `services`,
231
+ `namespaces`, `pollIntervalMs`, `adminKey`, `port`, `host`).
232
+
233
+ A read always reflects sends that finished before it started: `list`, `waitFor` and every API read
234
+ sync the emulators first instead of waiting for the next poll.
235
+
236
+ ## HTTP API
237
+
238
+ All routes are `GET`. Filters: `channel` (`sms`, `email`, `push`), `service`, `source` (the fleet
239
+ entry name), `namespace` (default `default`), `to`, `contains` (case-insensitive text), `since`
240
+ (ISO-8601 or epoch ms).
241
+
242
+ | Route | Answer |
243
+ | --- | --- |
244
+ | `/api/notifications` | `{namespace, version, notifications}`, oldest first (`order=newest` reverses). |
245
+ | `/api/notifications/latest` | The newest match, or 404. |
246
+ | `/api/notifications/{id}` | One notification. |
247
+ | `/api/wait?timeoutMs=&count=` | `{notifications}` once `count` (default 1) match; 408 with what was found after `timeoutMs` (default 10000, at most 120000). |
248
+ | `/api/events?namespace=` | Server-sent events: `hello`, `change` (`{added, version}`) and `sources` every 2 s. |
249
+ | `/api/sources` | Each emulator: name, service, admin URL, `ok`, last error and count. |
250
+ | `/api/namespaces` | Namespaces watched. |
251
+
252
+ A notification:
253
+
254
+ ```jsonc
255
+ {
256
+ "id": "twilio:default:SMb6c1…", // <fleet entry>:<namespace>:<provider id>[#mailbox]
257
+ "providerId": "SMb6c1…",
258
+ "channel": "sms", // sms | email | push
259
+ "source": "twilio", "service": "twilio", "namespace": "default",
260
+ "to": "+12025550123", // phone, mailbox (lower case), or push token
261
+ "from": { "name": null, "address": "+15005550006" },
262
+ "createdAt": "2026-11-10T06:39:06.886Z", // on the emulator's clock
263
+ "preview": "Your code is 123456",
264
+ "status": "queued",
265
+ "sms": { "body": "Your code is 123456", "code": null, "media": [] },
266
+ "email": null, // {subject, html, text, cc, bcc, replyTo, attachments, headers}
267
+ "push": null, // {title, body, imageUrl, app, platform, data, badge, sound}
268
+ "raw": { /* the emulator's own record */ }
269
+ }
270
+ ```
271
+
272
+ An email to several recipients appears once in each mailbox (`to`, `cc` and `bcc`). When Resend
273
+ forwards to Mailosaur (`--forward-to-inbox`), the Mailosaur copy is hidden so the message shows once.
274
+
275
+ ## API
276
+
277
+ | Export | Kind | Description |
278
+ | --- | --- | --- |
279
+ | `createNotificationHarness` | function | The harness as a Fetch handler: `fetch`, `sync(namespace?)`, `list(filter)`, `waitFor(filter, {timeoutMs, count})`, `sources()`, `close()`. Options: `fleet`, `manifest`, `services`, `namespaces`, `pollIntervalMs`, `adminKey`, `fetch`, `title`. |
280
+ | `NotificationClient` | class | HTTP client for a running harness: `list`, `latest`, `waitFor`, `waitForCode`, `sources`, `link`. Options: `namespace`, `fetch`. |
281
+ | `matches` | function | `(notification, filter)` → whether it matches, as the API filters. |
282
+ | `parseMime` | function | The MIME reader used for SES raw email: headers, subject, addresses, text, HTML, attachments. |
283
+ | `SERVICE_KINDS`, `SERVICE_LABELS` | values | The emulators the harness reads, and their display names. |
284
+ | `TEST_IDS`, `selectors` | values | Every `data-testid` the UI renders, and CSS selector builders for browser tests. Also exported from `./ui`. |
285
+ | `mountNotifications` (`./ui`) | function | `(element, {harness, …})` → `{ready, show, setNamespace, setSound, render, destroy}`. Mounts the UI on an element. |
286
+ | `createApp`, `STYLES`, `ROOT_CLASS` (`./ui`) | browser | The UI without the style injection and embedded defaults, its scoped stylesheet, and its root class. |
287
+ | `createServer`, `manifestFile`, `DEFAULT_PORT` (`./server`) | Node | Serve the harness over `node:http`; `manifestFile(path)` re-reads a fleet ready file. Port 8790. |
288
+
289
+ ## Not covered
290
+
291
+ - Customer.io renders templates on its side and the emulator keeps no body, so its email shows the
292
+ template id and `message_data`. In-app (`inbox_message`) sends are not shown.
293
+ - SNS mobile push (platform endpoints) is not emulated, and SNS topic subscribers are matched by
294
+ topic only.
295
+ - Twilio MMS media is not in the emulator's outbox, so bubbles show text only.
296
+ - Replying from a device. The harness shows what was sent; inbound traffic goes through each
297
+ emulator's own admin routes.
298
+
299
+ ## Develop
300
+
301
+ ```sh
302
+ bun run --cwd packages/notifications dev # a fleet of every messaging emulator, sample traffic, harness on :8790
303
+ bun run --cwd packages/notifications e2e # the browser tests (bunx playwright install chromium first)
304
+ bun run --cwd packages/notifications storybook # every UI state, from fixtures
305
+ ```