mail.ts 3.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import { expect } from '@playwright/test'
  2. import type { CapturedAttachment, CapturedMail } from '../mail-sink'
  3. /**
  4. * Reading what the app posted.
  5. *
  6. * The harness runs a mail server that delivers nothing and keeps everything
  7. * (`e2e/mail-sink.ts`); this is the other end of it. A spec asks for the mail
  8. * an address was sent and pulls the link out of it, which is as close as a
  9. * test gets to a person opening their inbox and clicking.
  10. */
  11. const api = process.env.E2E_MAIL_API ?? 'http://127.0.0.1:8025'
  12. export type { CapturedAttachment, CapturedMail }
  13. /** One file the mail carried, by name, as bytes. */
  14. export function attachment(mail: CapturedMail, name: RegExp): CapturedAttachment {
  15. const found = mail.attachments.find((a) => name.test(a.filename))
  16. if (!found) {
  17. throw new Error(
  18. `no attachment matching ${name} on "${mail.subject}". Carried: ${
  19. mail.attachments.map((a) => a.filename).join(', ') || 'nothing'
  20. }`
  21. )
  22. }
  23. return found
  24. }
  25. /** The bytes of an attachment the sink kept. */
  26. export function attachmentBytes(file: CapturedAttachment): Buffer {
  27. if (!file.content) throw new Error(`the sink did not keep ${file.filename} (${file.size} bytes)`)
  28. return Buffer.from(file.content, 'base64')
  29. }
  30. /** Everything the sink holds for an address, newest first. */
  31. export async function mailsTo(address: string): Promise<CapturedMail[]> {
  32. const response = await fetch(`${api}/messages?to=${encodeURIComponent(address)}`)
  33. if (!response.ok) {
  34. throw new Error(`the mail sink answered ${response.status}. Is e2e/mail-sink.ts running?`)
  35. }
  36. return (await response.json()) as CapturedMail[]
  37. }
  38. /** Forgets every mail, so a spec can be sure the next one it reads is its own. */
  39. export async function clearMailbox(): Promise<void> {
  40. const response = await fetch(`${api}/messages`, { method: 'DELETE' })
  41. if (!response.ok) throw new Error(`the mail sink answered ${response.status} to a clear`)
  42. }
  43. /**
  44. * The newest mail to an address, waited for. Mail is sent while the request
  45. * that triggered it is still being answered, so it can arrive a moment after
  46. * the page says it did.
  47. */
  48. export async function waitForMail(
  49. address: string,
  50. options: { subject?: RegExp; timeout?: number } = {}
  51. ): Promise<CapturedMail> {
  52. let found: CapturedMail | undefined
  53. await expect(async () => {
  54. const mails = await mailsTo(address)
  55. found = options.subject ? mails.find((m) => options.subject?.test(m.subject)) : mails[0]
  56. expect(found, `a mail to ${address}`).toBeDefined()
  57. }).toPass({ timeout: options.timeout ?? 20_000 })
  58. return found as CapturedMail
  59. }
  60. /**
  61. * The first link in a mail that matches. Mails are written as HTML with a
  62. * plain-text half beside them, and the address that matters is in both, so
  63. * whichever half carries it is fine.
  64. */
  65. export function linkIn(mail: CapturedMail, pattern: RegExp): string {
  66. const body = `${mail.html}\n${mail.text}`
  67. const links = body.match(/https?:\/\/[^\s"'<>)]+/g) ?? []
  68. // `&amp;` is HTML, not part of the address it was written into.
  69. const link = links.map((l) => l.replace(/&amp;/g, '&')).find((l) => pattern.test(l))
  70. if (!link) {
  71. throw new Error(
  72. `no link matching ${pattern} in "${mail.subject}". Links found: ${links.join(', ') || 'none'}`
  73. )
  74. }
  75. return link
  76. }