import { APIRequestContext } from '@playwright/test'; import WebSocket from 'ws'; export const WORKER_URL = process.env.WORKER_URL!; export const WORKER_URL_SUBDOMAIN = process.env.WORKER_URL_SUBDOMAIN || ''; export const WORKER_URL_ENV_OFF = process.env.WORKER_URL_ENV_OFF || ''; export const WORKER_GZIP_URL = process.env.WORKER_GZIP_URL || ''; export const FRONTEND_URL = process.env.FRONTEND_URL!; export const MAILPIT_API = process.env.MAILPIT_API!; export const TEST_DOMAIN = 'test.example.com'; /** * Create a new email address via the worker API. * Appends a timestamp suffix to avoid UNIQUE constraint collisions * with persistent D1 data from previous test runs. * Returns the JWT and full address string. */ export async function createTestAddress( ctx: APIRequestContext, name: string, domain: string = TEST_DOMAIN ): Promise<{ jwt: string; address: string; address_id: number }> { const uniqueName = `${name}${Date.now()}`; const res = await ctx.post(`${WORKER_URL}/api/new_address`, { data: { name: uniqueName, domain }, }); if (!res.ok()) { throw new Error(`Failed to create address: ${res.status()} ${await res.text()}`); } const body = await res.json(); return { jwt: body.jwt, address: body.address, address_id: body.address_id }; } /** * Seed a test email by exercising the real worker email() handler * via the admin test endpoint. */ export async function seedTestMail( ctx: APIRequestContext, address: string, opts: { subject?: string; html?: string; text?: string; from?: string } ): Promise { const from = opts.from || `sender@${TEST_DOMAIN}`; const subject = opts.subject || 'Test Email'; const boundary = `----E2E${Date.now()}`; const htmlPart = opts.html || `

${opts.text || 'Hello from E2E'}

`; const textPart = opts.text || 'Hello from E2E'; const messageId = ``; const raw = [ `From: ${from}`, `To: ${address}`, `Subject: ${subject}`, `Message-ID: ${messageId}`, `MIME-Version: 1.0`, `Content-Type: multipart/alternative; boundary="${boundary}"`, ``, `--${boundary}`, `Content-Type: text/plain; charset=utf-8`, ``, textPart, `--${boundary}`, `Content-Type: text/html; charset=utf-8`, ``, htmlPart, `--${boundary}--`, ].join('\r\n'); const res = await ctx.post(`${WORKER_URL}/admin/test/receive_mail`, { data: { from, to: address, raw }, }); if (!res.ok()) { throw new Error(`Failed to seed mail: ${res.status()} ${await res.text()}`); } const body = await res.json(); if (!body.success) { throw new Error(`Mail was rejected: ${body.rejected || 'unknown reason'}`); } } /** * Send a mail via admin/send_mail, which saves to sendbox. */ export async function sendTestMail( ctx: APIRequestContext, fromAddress: string, opts: { to_mail: string; subject?: string; content?: string; is_html?: boolean } ): Promise { const res = await ctx.post(`${WORKER_URL}/admin/send_mail`, { data: { from_name: '', from_mail: fromAddress, to_name: '', to_mail: opts.to_mail, subject: opts.subject || 'Test Sent Mail', content: opts.content || 'Sent mail body from E2E', is_html: opts.is_html ?? false, }, }); if (!res.ok()) { throw new Error(`Failed to send mail: ${res.status()} ${await res.text()}`); } } /** * Delete all messages in Mailpit. */ export async function deleteAllMailpitMessages(ctx: APIRequestContext) { const res = await ctx.delete(`${MAILPIT_API}/v1/messages`); if (!res.ok()) { throw new Error(`Failed to delete Mailpit messages: ${res.status()} ${await res.text()}`); } } /** * Derive the Mailpit WebSocket URL from the REST API URL. * MAILPIT_API is like "http://mailpit:8025/api" → ws://mailpit:8025/api/events */ function mailpitWsUrl(): string { return MAILPIT_API.replace(/^http/, 'ws') + '/events'; } /** * Wait for a message matching `predicate` to arrive in Mailpit. * * Connects to Mailpit's WebSocket `/api/events` and listens for * `Type: "new"` events. When a matching message arrives, resolves * immediately — no polling, no arbitrary sleeps. * * Returns `{ ready, message }`: * - `ready` resolves when the WebSocket connection is open * - `message` resolves with the matched message summary * * Usage: await ready before triggering the send to avoid race conditions. */ export function onMailpitMessage( predicate: (msg: any) => boolean, { timeout = 10_000 }: { timeout?: number } = {} ): { ready: Promise; message: Promise } { let readyResolve: () => void; let readyReject: (err: Error) => void; const ready = new Promise((resolve, reject) => { readyResolve = resolve; readyReject = reject; }); const message = new Promise((resolve, reject) => { let settled = false; const ws = new WebSocket(mailpitWsUrl()); const timer = setTimeout(() => { ws.close(); if (!settled) { settled = true; reject(new Error('Mailpit message not received within timeout')); } }, timeout); ws.on('open', () => readyResolve()); ws.on('message', (data: WebSocket.Data) => { try { const event = JSON.parse(data.toString()); if (event.Type === 'new' && predicate(event.Data)) { clearTimeout(timer); ws.close(); if (!settled) { settled = true; resolve(event.Data); } } } catch { /* ignore parse errors */ } }); ws.on('close', () => { clearTimeout(timer); if (!settled) { settled = true; reject(new Error('Mailpit WebSocket closed before matching message')); } }); ws.on('error', (err: Error) => { clearTimeout(timer); readyReject(err); if (!settled) { settled = true; reject(err); } }); }); return { ready, message }; } /** * Request send mail access for an address. * Must be called before sending mail — creates the address_sender row * with the DEFAULT_SEND_BALANCE configured in the worker. */ export async function requestSendAccess( ctx: APIRequestContext, jwt: string ): Promise { const res = await ctx.post(`${WORKER_URL}/api/request_send_mail_access`, { headers: { Authorization: `Bearer ${jwt}` }, }); if (!res.ok()) { throw new Error(`Failed to request send access: ${res.status()} ${await res.text()}`); } } /** * Delete a test address via its JWT. */ export async function deleteAddress( ctx: APIRequestContext, jwt: string ): Promise { const res = await ctx.delete(`${WORKER_URL}/api/delete_address`, { headers: { Authorization: `Bearer ${jwt}` }, }); if (!res.ok()) { throw new Error(`Failed to delete address: ${res.status()} ${await res.text()}`); } }