Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions e2e/lib/services/email/EmailService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@

export interface EmailHeader{
id: string;
to: EmailAddress;
from: string;
subject: string;
}

export interface EmailContent{
text: string;
html: string;
emailHeader: EmailHeader;
}

export type EmailAddress = `${string}@${string}.${string}`;

export abstract class EmailService {

/*
* Generate a random email address
*/
abstract generateEmailAddress(): EmailAddress;

/*
* Get EmailContent associated with an Email
*/
abstract getEmailContent(email: EmailHeader): Promise<EmailContent>;

/*
* Get all emails sent to an email address
*/
abstract getInbox(emailAddress: EmailAddress): Promise<EmailHeader[]>;

/*
* Return the first email sent to an email address that contains a specific subject sent. Waits for the email for a default amount of time before timing out.
*/
abstract waitForEmailWithSubject(emailAddress : EmailAddress, subjectSubstring : string): Promise<EmailContent>;

/*
* Get a random base 36 alphanumeric string.
* Used for creating a test email address.
*/
protected randomString(length: number): string {
const numBytes = Math.ceil(length * 2);
return Array.from(crypto.getRandomValues(new Uint8Array(numBytes)))
.map((b) => b.toString(36))
.join('')
.slice(0, length);
}
}
129 changes: 129 additions & 0 deletions e2e/lib/services/email/MailinatorService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { BrowserContext, Page, expect } from '@playwright/test';
import { EmailAddress, EmailContent, EmailHeader, EmailService } from './EmailService';

export class MailinatorService extends EmailService {
context: BrowserContext;

constructor(context: BrowserContext) {
super();
this.context = context;
}

generateEmailAddress(): EmailAddress {
const randomString = this.randomString(10)
return `test+${randomString}@mailinator.com`;
}

// Action functions
async getInbox(emailAddress: EmailAddress): Promise<EmailHeader[]> {
const mailinatorURL = this.buildMailinatorURLToInbox(emailAddress)
const mailinatorPage = await this.openNewPage(mailinatorURL)

// Wait for email rows to load
const rows = mailinatorPage.locator(this.SELECTORS.inbox.emailRow);

const rowCount = await rows.count();
console.log(`Found ${rowCount} email(s) in the inbox.`);

const fromSelector = this.SELECTORS.inbox.cells.from;
const subjectSelector = this.SELECTORS.inbox.cells.subject;

const inboxEntries: EmailHeader[] = await rows.evaluateAll(
(rows, { emailAddress, fromSelector, subjectSelector }) => {
const entries: EmailHeader[] = [];
for (const row of rows) {
const fullRowId = row.getAttribute("id") || ""; // e.g., 'row_test+2f4e8guu-1734141721-9052847012'
const extractedId = fullRowId.split("-").slice(-2).join("-"); // just the ID portion: 1734141721-9052847012
entries.push({
id: extractedId,
to: emailAddress,
from: row.querySelector(fromSelector)?.textContent?.trim() || "",
subject: row.querySelector(subjectSelector)?.textContent?.trim() || "",
});
}
return entries;
},
{ emailAddress, fromSelector, subjectSelector }
);

await mailinatorPage.close()
return inboxEntries;
}

async getEmailContent(emailHeader: EmailHeader): Promise<EmailContent> {
const directEmailURL = this.buildMailinatorURLToEmail(emailHeader)
const emailPage = await this.openNewPage(directEmailURL)

await emailPage.waitForSelector(this.SELECTORS.email.iframe);

const iframe = emailPage.frame({ name: this.SELECTORS.email.iframeName });
const text = await iframe?.textContent(this.SELECTORS.email.body) || '';
const html = await iframe?.evaluate(() => document.body.innerHTML) || '';

await emailPage.close()
return { text, html, emailHeader };
}

async waitForEmailWithSubject(emailAddress: EmailAddress, subjectSubstring: string): Promise<EmailContent> {
const mailinatorURL = this.buildMailinatorURLToInbox(emailAddress)
const mailinatorPage = await this.openNewPage(mailinatorURL)

const matchingEmail = mailinatorPage
.locator(this.SELECTORS.inbox.emailRowTR)
.filter({ hasText: subjectSubstring })
.first();

await matchingEmail.waitFor();
const idAttribute = await matchingEmail.getAttribute("id");
expect(idAttribute, "Email row missing required ID attribute").not.toBeNull();
const emailHeaderId = idAttribute!.split("-").slice(-2).join("-");

const emailHeader: EmailHeader = {
id: emailHeaderId,
to: emailAddress,
from: await matchingEmail.locator("td:nth-of-type(2)").innerText(),
subject: await matchingEmail.locator("td:nth-of-type(3)").innerText(),
};

return this.getEmailContent(emailHeader);
}

private static readonly MAILINATOR_BASE_URL = 'https://www.mailinator.com/v4/public/inboxes.jsp';
private readonly SELECTORS = {
inbox: {
emailRow: '[ng-repeat="email in emails"]',
emailRowTR: "tr[ng-repeat='email in emails']",
cells: {
from: 'td:nth-of-type(2)',
subject: 'td:nth-of-type(3)',
}
},
email: {
iframe: 'iframe[name="html_msg_body"]',
iframeName: 'html_msg_body',
body: 'body',
},
};

// Helpers
private buildMailinatorURLToEmail(emailHeader: EmailHeader): string {
const encodedEmailPrefix = this.getEncodedEmailPrefix(emailHeader.to);
return `${MailinatorService.MAILINATOR_BASE_URL}?msgid=${encodedEmailPrefix}-${emailHeader.id}`;
}

private buildMailinatorURLToInbox(emailAddress: string): string {
const encodedEmailPrefix = this.getEncodedEmailPrefix(emailAddress);
return `${MailinatorService.MAILINATOR_BASE_URL}?to=${encodedEmailPrefix}`;
}

private getEncodedEmailPrefix(emailAddress: string): string {
return encodeURIComponent(emailAddress.split('@')[0]);
}

private async openNewPage(url: string): Promise<Page> {
const page = await this.context.newPage();
await page.goto(url);
return page;
}

}
127 changes: 127 additions & 0 deletions e2e/lib/services/email/MessageCheckerService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { BrowserContext, Locator, Page, expect } from "@playwright/test";
import {
EmailAddress,
EmailContent,
EmailHeader,
EmailService,
} from "./EmailService";

export class MessageCheckerService extends EmailService {
context: BrowserContext;

constructor(context: BrowserContext) {
super();
this.context = context;
}

generateEmailAddress(): EmailAddress {
const randomString = this.randomString(10);
return `test+${randomString}@message-checker.appspotmail.com`;
}

// Action functions
async getInbox(emailAddress: EmailAddress): Promise<EmailHeader[]> {
const messageCheckerURL = this.buildMessageCheckerURLToInbox(emailAddress);
const messageCheckerPage = await this.openNewPage(messageCheckerURL);

try {
const inboxEntries: EmailHeader[] = [];

// Get all rows excluding the table header row
const rows = (await messageCheckerPage.getByRole("row").all()).slice(1);

for (const row of rows) {
const emailHeader = await this.createEmailHeaderFromRow(
row,
emailAddress,
);
inboxEntries.push(emailHeader);
}
return inboxEntries;
} finally {
await messageCheckerPage.close();
}
}

async getEmailContent(emailHeader: EmailHeader): Promise<EmailContent> {
const directEmailURL = this.buildMessageCheckerURLToEmail(emailHeader);
const emailPage = await this.openNewPage(directEmailURL);
try {
const container = emailPage.getByRole("document");
const text = await container.innerText();
const html = await container.innerHTML();

return { text, html, emailHeader };
} finally {
await emailPage.close();
}
}

async waitForEmailWithSubject(
emailAddress: EmailAddress,
subjectSubstring: string,
): Promise<EmailContent> {
const inboxURL = this.buildMessageCheckerURLToInbox(emailAddress);
const inboxPage = await this.openNewPage(inboxURL);
try {
const matchingRow = inboxPage
.getByRole("row")
.filter({ hasText: subjectSubstring })
.first();

await matchingRow.waitFor();

const emailHeader = await this.createEmailHeaderFromRow(
matchingRow,
emailAddress,
);

return this.getEmailContent(emailHeader);
} finally {
await inboxPage.close();
}
}

private static readonly MESSAGE_CHECKER_BASE_URL =
"https://message-checker.appspot.com";

// Helpers
private buildMessageCheckerURLToEmail(emailHeader: EmailHeader): string {
return `${MessageCheckerService.MESSAGE_CHECKER_BASE_URL}/message-body/${emailHeader.id}`;
}

private async createEmailHeaderFromRow(
row: Locator,
emailAddress: EmailAddress,
): Promise<EmailHeader> {
const subjectLink = row.getByRole("cell").first().getByRole("link");
const subject = (await subjectLink.innerText()).trim();

// EmailLink format: /message-body/{email-id}
// Split by "/" and get item at index 2 to extract email-id
const emailLink = (await subjectLink.getAttribute("href")) ?? "";
const emailId = emailLink.split("/")[2];

return {
id: emailId,
to: emailAddress,
from: "", // No sender information in MessageChecker
subject,
};
}

private buildMessageCheckerURLToInbox(emailAddress: string): string {
const encodedEmailPrefix = this.getEncodedEmailPrefix(emailAddress);
return `${MessageCheckerService.MESSAGE_CHECKER_BASE_URL}/address/${encodedEmailPrefix}`;
}

private getEncodedEmailPrefix(emailAddress: string): string {
return encodeURIComponent(emailAddress.split("@")[0]);
}

private async openNewPage(url: string): Promise<Page> {
const page = await this.context.newPage();
await page.goto(url);
return page;
}
}
16 changes: 16 additions & 0 deletions e2e/lib/util.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Merge a base and derived config
export function deepMerge(obj1, obj2) {
const result = { ...obj1 };

for (let key in obj2) {
if (obj2.hasOwnProperty(key)) {
if (obj2[key] instanceof Object && obj1[key] instanceof Object) {
result[key] = deepMerge(obj1[key], obj2[key]);
} else {
result[key] = obj2[key];
}
}
}

return result;
}
2 changes: 1 addition & 1 deletion e2e/{{app_name}}/playwright.config.js.jinja
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import baseConfig from '../playwright.config';
import { deepMerge } from '../util';
import { deepMerge } from '../lib/util';
import { defineConfig } from '@playwright/test';

export default defineConfig(deepMerge(
Expand Down