mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-11 13:35:13 +10:00
Add Playwright E2E test setup (#3169)
* docs: design e2e test setup Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * docs: plan e2e test implementation Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * test: add playwright e2e scripts Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * test: configure playwright Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * test: add core e2e fixtures and specs Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * ci: run e2e tests on pull requests Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * [autofix.ci] apply automated fixes * test: stabilize e2e suite Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * test: ignore playwright artifacts Co-authored-by: Amruth Pillai <im.amruth@gmail.com> * Update .github/workflows/e2e.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * test: address e2e review feedback Co-authored-by: Amruth Pillai <im.amruth@gmail.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
# E2E Tests
|
||||
|
||||
Reactive Resume uses Playwright for PR-gated browser coverage of deterministic core flows.
|
||||
|
||||
## Local setup
|
||||
|
||||
Start PostgreSQL:
|
||||
|
||||
`sudo docker compose -f compose.dev.yml up -d postgres`
|
||||
|
||||
Generate local test secrets:
|
||||
|
||||
`export AUTH_SECRET=$(openssl rand -hex 32)`
|
||||
|
||||
`export ENCRYPTION_SECRET=$(openssl rand -hex 32)`
|
||||
|
||||
Run database migrations:
|
||||
|
||||
`APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm db:migrate`
|
||||
|
||||
Build the production app:
|
||||
|
||||
`APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm build`
|
||||
|
||||
Run tests:
|
||||
|
||||
`APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm test:e2e`
|
||||
|
||||
## Coverage
|
||||
|
||||
- Email/password auth smoke.
|
||||
- Dashboard sample resume creation.
|
||||
- Builder basics edit and autosave persistence.
|
||||
- JSON export/import.
|
||||
- Public sharing for anonymous visitors.
|
||||
|
||||
PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate.
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { APIRequestContext, Browser, BrowserContext, Page } from "@playwright/test";
|
||||
import type { E2EAccount } from "./data";
|
||||
|
||||
async function assertAuthResponse(response: Awaited<ReturnType<APIRequestContext["post"]>>) {
|
||||
if (response.ok()) return;
|
||||
|
||||
throw new Error(`Authentication request failed with ${response.status()}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
export async function registerViaUi(page: Page, account: E2EAccount) {
|
||||
await page.goto("/auth/register");
|
||||
await page.getByRole("textbox", { name: "Name", exact: true }).fill(account.name);
|
||||
await page.getByLabel("Username").fill(account.username);
|
||||
await page.getByLabel("Email Address", { exact: true }).fill(account.email);
|
||||
await page.getByLabel("Password", { exact: true }).fill(account.password);
|
||||
await page.getByRole("button", { name: "Sign up" }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
}
|
||||
|
||||
export async function loginViaUi(page: Page, account: E2EAccount) {
|
||||
await page.goto("/auth/login");
|
||||
await page.getByLabel("Email Address", { exact: true }).fill(account.email);
|
||||
await page.getByLabel("Password", { exact: true }).fill(account.password);
|
||||
await page.getByRole("button", { name: "Sign in" }).click();
|
||||
await page.waitForURL(/\/dashboard/);
|
||||
}
|
||||
|
||||
export async function logoutViaUi(page: Page, account: E2EAccount) {
|
||||
await page.getByText(account.email).click();
|
||||
await page.getByRole("menuitem", { name: "Logout" }).click();
|
||||
await page.goto("/auth/login");
|
||||
}
|
||||
|
||||
async function registerViaApi(request: APIRequestContext, account: E2EAccount, baseURL: string) {
|
||||
const response = await request.post("/api/auth/sign-up/email", {
|
||||
headers: {
|
||||
origin: baseURL,
|
||||
referer: `${baseURL}/auth/register`,
|
||||
},
|
||||
data: {
|
||||
name: account.name,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
username: account.username,
|
||||
displayUsername: account.username,
|
||||
callbackURL: "/dashboard",
|
||||
},
|
||||
});
|
||||
|
||||
await assertAuthResponse(response);
|
||||
}
|
||||
|
||||
export async function createAuthenticatedContext(
|
||||
browser: Browser,
|
||||
request: APIRequestContext,
|
||||
account: E2EAccount,
|
||||
baseURL: string,
|
||||
): Promise<BrowserContext> {
|
||||
await registerViaApi(request, account, baseURL);
|
||||
|
||||
return browser.newContext({
|
||||
baseURL,
|
||||
storageState: await request.storageState(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { TestInfo } from "@playwright/test";
|
||||
|
||||
const sanitize = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
|
||||
export type E2EAccount = {
|
||||
name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
function createRunSlug(testInfo: TestInfo) {
|
||||
const worker = testInfo.workerIndex;
|
||||
const title = sanitize(testInfo.titlePath.join("-")).slice(0, 32);
|
||||
const suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
return `e2e-${worker}-${title}-${suffix}`;
|
||||
}
|
||||
|
||||
export function createAccount(testInfo: TestInfo): E2EAccount {
|
||||
const username = createRunSlug(testInfo).replaceAll("-", "_").slice(0, 64);
|
||||
|
||||
return {
|
||||
name: "E2E Test User",
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
password: "Password123!",
|
||||
};
|
||||
}
|
||||
|
||||
export function createResumeName(testInfo: TestInfo) {
|
||||
return `E2E Resume ${createRunSlug(testInfo)}`;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { E2EAccount } from "./data";
|
||||
import { Pool } from "pg";
|
||||
|
||||
function getDatabaseUrl() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is required for E2E cleanup.");
|
||||
|
||||
return databaseUrl;
|
||||
}
|
||||
|
||||
export async function deleteE2EUser(account: E2EAccount) {
|
||||
const pool = new Pool({ connectionString: getDatabaseUrl() });
|
||||
|
||||
try {
|
||||
await pool.query('delete from "user" where email = $1 or username = $2', [account.email, account.username]);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { createResumeName } from "./data";
|
||||
|
||||
export async function createSampleResumeFromDashboard(page: Page, testInfo: TestInfo) {
|
||||
const resumeName = createResumeName(testInfo);
|
||||
|
||||
await page.goto("/dashboard/resumes");
|
||||
await page.getByText("Create a new resume").click();
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Create a new resume" });
|
||||
await dialog.getByLabel("Name").fill(resumeName);
|
||||
|
||||
const createGroup = dialog.getByRole("group", { name: "Create resume with options" });
|
||||
await createGroup.getByRole("button").last().click();
|
||||
await page.getByRole("menuitem", { name: "Create a Sample Resume" }).click();
|
||||
|
||||
const resumeLink = page.getByRole("link", { name: new RegExp(resumeName) });
|
||||
await expect(resumeLink).toBeVisible();
|
||||
await resumeLink.click();
|
||||
await page.waitForURL(/\/builder\/.+/);
|
||||
|
||||
return resumeName;
|
||||
}
|
||||
|
||||
export async function openSidebarSection(page: Page, title: string) {
|
||||
await page.getByTitle(title, { exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { BrowserContext, Page } from "@playwright/test";
|
||||
import type { E2EAccount } from "./data";
|
||||
import { test as base, expect } from "@playwright/test";
|
||||
import { createAuthenticatedContext } from "./auth";
|
||||
import { createAccount } from "./data";
|
||||
import { deleteE2EUser } from "./db";
|
||||
|
||||
type Fixtures = {
|
||||
account: E2EAccount;
|
||||
authContext: BrowserContext;
|
||||
authPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
account: async ({ baseURL }, use, testInfo) => {
|
||||
void baseURL;
|
||||
const account = createAccount(testInfo);
|
||||
|
||||
try {
|
||||
await use(account);
|
||||
} finally {
|
||||
await deleteE2EUser(account);
|
||||
}
|
||||
},
|
||||
authContext: async ({ browser, request, account }, use, testInfo) => {
|
||||
const baseURL = String(testInfo.project.use.baseURL ?? "http://localhost:3000");
|
||||
const context = await createAuthenticatedContext(browser, request, account, baseURL);
|
||||
|
||||
try {
|
||||
await use(context);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
},
|
||||
authPage: async ({ authContext }, use) => {
|
||||
const page = await authContext.newPage();
|
||||
|
||||
try {
|
||||
await use(page);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { loginViaUi, logoutViaUi, registerViaUi } from "../fixtures/auth";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
test("registers and logs in with email credentials", async ({ page, account }) => {
|
||||
await registerViaUi(page, account);
|
||||
await expect(page.getByRole("heading", { name: "Resumes" })).toBeVisible();
|
||||
|
||||
await logoutViaUi(page, account);
|
||||
await expect(page.getByRole("heading", { name: "Sign in to your account" })).toBeVisible();
|
||||
|
||||
await loginViaUi(page, account);
|
||||
await expect(page.getByRole("heading", { name: "Resumes" })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
test("exports and imports a resume JSON backup", async ({ authPage: page }, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
|
||||
await openSidebarSection(page, "Export");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: /^JSON/ }).click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toMatch(/\.json$/);
|
||||
|
||||
const downloadPath = testInfo.outputPath(download.suggestedFilename());
|
||||
await download.saveAs(downloadPath);
|
||||
const exportedData = JSON.parse(await readFile(downloadPath, "utf-8")) as { basics: { name: string } };
|
||||
|
||||
await page.goto("/dashboard/resumes");
|
||||
await page.getByText("Import an existing resume").click();
|
||||
const dialog = page.getByRole("dialog", { name: "Import an existing resume" });
|
||||
await dialog.getByRole("combobox").click();
|
||||
await page.getByRole("option", { name: "Reactive Resume (JSON)" }).click();
|
||||
await dialog.locator('input[type="file"]').setInputFiles(downloadPath);
|
||||
await dialog.getByRole("button", { name: "Import", exact: true }).click();
|
||||
|
||||
await page.waitForURL(/\/builder\/.+/);
|
||||
await openSidebarSection(page, "Basics");
|
||||
await expect(page.getByLabel("Name")).toHaveValue(exportedData.basics.name);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
test("publishes a resume and renders it for an anonymous visitor", async ({ browser, authPage: page }, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
await openSidebarSection(page, "Sharing");
|
||||
|
||||
await page.getByRole("switch", { name: /Allow Public Access/ }).click();
|
||||
const sharingUrl = page.locator("#sharing-url");
|
||||
await expect(sharingUrl).toHaveValue(/\/e2e_/);
|
||||
const publicUrl = await sharingUrl.inputValue();
|
||||
expect(publicUrl).toMatch(/\/e2e_/);
|
||||
|
||||
const anonymous = await browser.newPage();
|
||||
try {
|
||||
await anonymous.goto(publicUrl);
|
||||
await expect(anonymous.getByRole("button", { name: "Download PDF" })).toBeVisible();
|
||||
} finally {
|
||||
await anonymous.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
test("creates a sample resume and persists a basics edit", async ({ authPage: page }, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
|
||||
const updatedName = `E2E Edited ${Date.now()}`;
|
||||
await openSidebarSection(page, "Basics");
|
||||
const savePromise = page.waitForResponse((response) => {
|
||||
if (!response.url().includes("/api/rpc")) return false;
|
||||
if (response.request().method() !== "POST") return false;
|
||||
if (!response.ok()) return false;
|
||||
|
||||
const body = response.request().postData() ?? "";
|
||||
return body.includes(updatedName);
|
||||
});
|
||||
await page.getByLabel("Name").fill(updatedName);
|
||||
await savePromise;
|
||||
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Basics");
|
||||
await expect(page.getByLabel("Name")).toHaveValue(updatedName);
|
||||
});
|
||||
Reference in New Issue
Block a user