199 lines
5.7 KiB
TypeScript
199 lines
5.7 KiB
TypeScript
import {
|
|
APIRequestContext,
|
|
Browser,
|
|
BrowserContext,
|
|
expect,
|
|
Page,
|
|
} from "@playwright/test";
|
|
|
|
const mailpitURL = process.env.MAILPIT_URL;
|
|
const baseURL = process.env.BASE_URL;
|
|
|
|
if (!mailpitURL || !baseURL) {
|
|
throw new Error("MAILPIT_URL and BASE_URL are required");
|
|
}
|
|
|
|
const tilePng = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAQAAAAEAAQMAAABmvDolAAAAA1BMVEXu8vcBVSTwAAAAH0lEQVRo3u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAvg0hAAABfxmcpwAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
|
|
export type AuthenticatedBrowser = {
|
|
context: BrowserContext;
|
|
page: Page;
|
|
email: string;
|
|
};
|
|
|
|
export async function newIsolatedContext(browser: Browser): Promise<BrowserContext> {
|
|
const context = await browser.newContext();
|
|
|
|
await context.route("http://tiles.e2e.invalid/**", async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "image/png",
|
|
body: tilePng,
|
|
});
|
|
});
|
|
|
|
return context;
|
|
}
|
|
|
|
export function captureBrowserFailures(page: Page): () => void {
|
|
const failures: string[] = [];
|
|
|
|
page.on("console", (message) => {
|
|
if (message.type() === "error") {
|
|
failures.push(`console: ${message.text()}`);
|
|
}
|
|
});
|
|
|
|
page.on("pageerror", (error) => {
|
|
failures.push(`pageerror: ${error.message}`);
|
|
});
|
|
|
|
page.on("requestfailed", (request) => {
|
|
const url = request.url();
|
|
|
|
if (!url.startsWith("http://tiles.e2e.invalid/")) {
|
|
failures.push(`requestfailed: ${request.method()} ${url} ${request.failure()?.errorText}`);
|
|
}
|
|
});
|
|
|
|
return () => expect(failures, failures.join("\n")).toEqual([]);
|
|
}
|
|
|
|
async function latestMessageID(
|
|
request: APIRequestContext,
|
|
email: string,
|
|
): Promise<string | undefined> {
|
|
const response = await request.get(`${mailpitURL}/api/v1/search`, {
|
|
params: {
|
|
query: `to:"${email}"`,
|
|
limit: "10",
|
|
},
|
|
});
|
|
|
|
expect(response.ok()).toBeTruthy();
|
|
const body = (await response.json()) as { messages?: Array<{ ID?: string }> };
|
|
return body.messages?.[0]?.ID;
|
|
}
|
|
|
|
async function waitForMagicLink(
|
|
request: APIRequestContext,
|
|
email: string,
|
|
previousMessageID?: string,
|
|
): Promise<string> {
|
|
let messageID: string | undefined;
|
|
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
messageID = await latestMessageID(request, email);
|
|
return messageID && messageID !== previousMessageID ? messageID : undefined;
|
|
},
|
|
{
|
|
message: `waiting for the Mailpit login message for ${email}`,
|
|
timeout: 15_000,
|
|
},
|
|
)
|
|
.toBeTruthy();
|
|
|
|
const response = await request.get(`${mailpitURL}/api/v1/message/${messageID}`);
|
|
expect(response.ok()).toBeTruthy();
|
|
|
|
const message = (await response.json()) as {
|
|
Text?: string;
|
|
HTML?: string;
|
|
};
|
|
const content = `${message.Text ?? ""}\n${message.HTML ?? ""}`;
|
|
const match = content.match(/https?:\/\/[^\s"'<>]+\/users\/log-in\/[^\s"'<>]+/);
|
|
|
|
if (!match) {
|
|
throw new Error(`No magic login link found for ${email}`);
|
|
}
|
|
|
|
const link = new URL(match[0].replaceAll("&", "&"));
|
|
const applicationOrigin = new URL(baseURL);
|
|
link.protocol = applicationOrigin.protocol;
|
|
link.host = applicationOrigin.host;
|
|
|
|
return link.toString();
|
|
}
|
|
|
|
export async function registerAndConfirm(
|
|
browser: Browser,
|
|
request: APIRequestContext,
|
|
email: string,
|
|
displayName: string,
|
|
): Promise<AuthenticatedBrowser> {
|
|
const context = await newIsolatedContext(browser);
|
|
const page = await context.newPage();
|
|
|
|
await page.goto("/users/register");
|
|
await page.getByLabel("Email").fill(email);
|
|
await page.getByLabel("Display name").fill(displayName);
|
|
await page
|
|
.getByLabel("I am 18 or older and accept the safety rules")
|
|
.check();
|
|
await page.getByRole("button", { name: "Create an account" }).click();
|
|
await expect(page).toHaveURL(/\/users\/log-in$/);
|
|
await expect(page.getByText(`An email was sent to ${email}`)).toBeVisible();
|
|
|
|
await page.goto(await waitForMagicLink(request, email));
|
|
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
|
|
await page
|
|
.getByRole("button", { name: "Confirm and log in only this time" })
|
|
.click();
|
|
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
|
|
|
return { context, page, email };
|
|
}
|
|
|
|
export async function loginWithMagicLink(
|
|
browser: Browser,
|
|
request: APIRequestContext,
|
|
email: string,
|
|
): Promise<AuthenticatedBrowser> {
|
|
const context = await newIsolatedContext(browser);
|
|
const page = await context.newPage();
|
|
const previousMessageID = await latestMessageID(request, email);
|
|
|
|
await page.goto("/users/log-in");
|
|
const form = page.locator("#login_form_magic");
|
|
await form.getByLabel("Email").fill(email);
|
|
await form.getByRole("button", { name: "Log in with email" }).click();
|
|
await expect(
|
|
page.getByText(
|
|
"If your email is in our system, you will receive instructions for logging in shortly.",
|
|
),
|
|
).toBeVisible();
|
|
|
|
await page.goto(await waitForMagicLink(request, email, previousMessageID));
|
|
await expect(page.getByRole("heading", { name: `Welcome ${email}` })).toBeVisible();
|
|
await page.getByRole("button", { name: "Log me in only this time" }).click();
|
|
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
|
|
|
return { context, page, email };
|
|
}
|
|
|
|
export async function selectOptionContaining(
|
|
page: Page,
|
|
label: string,
|
|
expectedText: string,
|
|
): Promise<void> {
|
|
const select = page.getByLabel(label);
|
|
const options = await select.locator("option").evaluateAll((nodes) =>
|
|
nodes.map((node) => ({
|
|
text: node.textContent?.trim() ?? "",
|
|
value: (node as HTMLOptionElement).value,
|
|
})),
|
|
);
|
|
const option = options.find(({ text }) => text.includes(expectedText));
|
|
|
|
if (!option) {
|
|
throw new Error(`No ${label} option contains ${expectedText}`);
|
|
}
|
|
|
|
await select.selectOption(option.value);
|
|
}
|