356 lines
17 KiB
TypeScript
356 lines
17 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const apiUrl = "http://localhost:5302/api";
|
|
|
|
type Rgba = { red: number; green: number; blue: number; alpha: number };
|
|
|
|
function parseCssColour(value: string): Rgba {
|
|
const parts = value.match(/[\d.]+/g)?.map(Number) ?? [];
|
|
expect(parts.length, `expected an rgb/rgba colour but received ${value}`).toBeGreaterThanOrEqual(3);
|
|
return { red: parts[0], green: parts[1], blue: parts[2], alpha: parts[3] ?? 1 };
|
|
}
|
|
|
|
function composite(foreground: Rgba, background: Rgba): Rgba {
|
|
return {
|
|
red: foreground.red * foreground.alpha + background.red * (1 - foreground.alpha),
|
|
green: foreground.green * foreground.alpha + background.green * (1 - foreground.alpha),
|
|
blue: foreground.blue * foreground.alpha + background.blue * (1 - foreground.alpha),
|
|
alpha: 1,
|
|
};
|
|
}
|
|
|
|
function relativeLuminance(colour: Rgba) {
|
|
const linear = [colour.red, colour.green, colour.blue].map((channel) => {
|
|
const value = channel / 255;
|
|
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
});
|
|
return linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722;
|
|
}
|
|
|
|
function contrastRatio(foreground: Rgba, background: Rgba) {
|
|
const foregroundLuminance = relativeLuminance(foreground);
|
|
const backgroundLuminance = relativeLuminance(background);
|
|
return (Math.max(foregroundLuminance, backgroundLuminance) + 0.05)
|
|
/ (Math.min(foregroundLuminance, backgroundLuminance) + 0.05);
|
|
}
|
|
|
|
async function loginAs(page: Page, email: string, password: string) {
|
|
await page.goto("/login");
|
|
await page.getByLabel("Email").fill(email);
|
|
await page.getByLabel("Current password").fill(password);
|
|
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
await expect(page.getByRole("heading", { name: "Dashboard", exact: true })).toBeVisible();
|
|
}
|
|
|
|
async function login(page: Page) {
|
|
await loginAs(page, "e2e@example.test", "E2ePassword123!");
|
|
}
|
|
|
|
async function csrfHeader(page: Page) {
|
|
const cookie = (await page.context().cookies()).find((value) => value.name === "XSRF-TOKEN");
|
|
expect(cookie, "login should issue a CSRF cookie").toBeTruthy();
|
|
return { "X-CSRF-TOKEN": cookie!.value };
|
|
}
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en"));
|
|
});
|
|
|
|
test("public plans stay honest, responsive and keyboard accessible", async ({ page }) => {
|
|
for (const scheme of ["light", "dark"] as const) {
|
|
await page.addInitScript((value) => window.localStorage.setItem("jobtracker.themeMode", value), scheme);
|
|
await page.goto("/");
|
|
await expect(page.getByRole("heading", { name: "Two clear plans" })).toBeVisible();
|
|
await expect(page.locator('section[aria-labelledby^="plan-"]')).toHaveCount(2);
|
|
await expect(page.getByRole("heading", { name: "Free", exact: true })).toBeVisible();
|
|
await expect(page.getByRole("heading", { name: "Pro", exact: true })).toBeVisible();
|
|
await expect(page.getByText(/Bring your own key|Unlimited AI|Billed monthly|Most popular/i)).toHaveCount(0);
|
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", scheme);
|
|
|
|
for (const width of [375, 768, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
}
|
|
|
|
await page.goto("/login");
|
|
await expect(page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible();
|
|
for (const width of [375, 768, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
}
|
|
}
|
|
|
|
await page.goto("/");
|
|
const freeAction = page.getByRole("button", { name: "Create a Free account" });
|
|
await freeAction.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(page).toHaveURL(/\/register$/);
|
|
|
|
await page.goBack();
|
|
const proAction = page.getByRole("button", { name: "Sign in to view Pro" });
|
|
await proAction.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(page).toHaveURL(/\/login$/);
|
|
});
|
|
|
|
test("login establishes an authenticated session", async ({ page }) => {
|
|
await login(page);
|
|
await expect(page.getByText("e2e@example.test").first()).toBeVisible();
|
|
await expect(page.getByTestId("admin-version-badge")).toHaveText("ve2e-verification");
|
|
await expect(page.getByLabel("Application version e2e-verification, commit e2e1234")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Notifications" }).click();
|
|
await expect(page.getByLabel("Notifications panel")).toBeVisible();
|
|
await expect(page).toHaveURL(/\/dashboard$/);
|
|
await page.getByRole("button", { name: "Close" }).click();
|
|
});
|
|
|
|
test("a Free account keeps manual work available while AI actions stay honestly locked", async ({ page }) => {
|
|
const suffix = Date.now().toString();
|
|
const email = `free-${suffix}@example.test`;
|
|
const password = "FreeUserPassword123!";
|
|
|
|
await login(page);
|
|
const headers = await csrfHeader(page);
|
|
const createUser = await page.request.post(`${apiUrl}/users`, {
|
|
headers,
|
|
data: { email, password, displayName: "Free E2E User", roles: [] },
|
|
});
|
|
expect(createUser.ok()).toBeTruthy();
|
|
|
|
await page.context().clearCookies();
|
|
await loginAs(page, email, password);
|
|
await expect(page.getByTestId("admin-version-badge")).toHaveCount(0);
|
|
|
|
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark"));
|
|
await page.setViewportSize({ width: 375, height: 900 });
|
|
await page.goto("/career");
|
|
await expect(page.getByText(/Build your Career Profile faster with Pro/)).toBeVisible();
|
|
await expect(page.getByLabel("Professional headline")).toBeEnabled();
|
|
await page.getByRole("button", { name: "Dismiss Build your Career Profile faster with Pro." }).click();
|
|
await expect(page.getByText(/Build your Career Profile faster with Pro/)).toHaveCount(0);
|
|
|
|
await page.goto("/settings");
|
|
await expect(page.getByText(/Free plan includes core job tracking without AI/i)).toBeVisible();
|
|
await expect(page.getByText(/Pro upgrades are not available in this deployment yet/i)).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "Upgrade to Pro" })).toHaveCount(0);
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("a saved job can be created through the reviewed UI flow", async ({ page }) => {
|
|
const suffix = Date.now().toString();
|
|
const title = `E2E Engineer ${suffix}`;
|
|
|
|
await login(page);
|
|
await page.goto("/jobs");
|
|
await page.getByRole("button", { name: "Add Job" }).click();
|
|
await page.getByRole("button", { name: "Enter details manually" }).click();
|
|
await page.getByLabel("Company").fill(`E2E Company ${suffix}`);
|
|
await page.getByLabel("Job title").fill(title);
|
|
await page.getByRole("button", { name: "Continue" }).click();
|
|
for (let step = 0; step < 3; step += 1) {
|
|
await page.getByRole("button", { name: "Skip and continue" }).click();
|
|
}
|
|
await page.getByRole("button", { name: "Create job", exact: true }).click();
|
|
|
|
await expect(page.getByText(title, { exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("the dedicated application workspace survives deep links, long data and unsaved navigation", async ({ page }) => {
|
|
const suffix = Date.now().toString();
|
|
const companyName = `Application Workspace Company With A Deliberately Long Name ${suffix}`;
|
|
const title = `Principal Platform Reliability Engineer For Distributed Customer Systems ${suffix}`;
|
|
|
|
await login(page);
|
|
const headers = await csrfHeader(page);
|
|
let companyResponse = await page.request.post(`${apiUrl}/companies`, {
|
|
headers,
|
|
data: { name: companyName, location: "Oslo and remote across Europe", source: "direct" },
|
|
});
|
|
for (let attempt = 0; attempt < 2 && !companyResponse.ok(); attempt += 1) {
|
|
await page.waitForTimeout(250);
|
|
companyResponse = await page.request.post(`${apiUrl}/companies`, {
|
|
headers,
|
|
data: { name: companyName, location: "Oslo and remote across Europe", source: "direct" },
|
|
});
|
|
}
|
|
const company = await companyResponse.json();
|
|
expect(companyResponse.ok(), `company create failed: ${companyResponse.status()} ${JSON.stringify(company)}`).toBeTruthy();
|
|
|
|
const jobData = {
|
|
jobTitle: title,
|
|
companyId: company.id,
|
|
status: "Applied",
|
|
location: "Oslo and remote across Europe",
|
|
salary: null,
|
|
salaryMin: null,
|
|
salaryMax: null,
|
|
salaryCurrency: null,
|
|
salaryPeriod: null,
|
|
nextAction: "Prepare a concise application answer",
|
|
followUpAt: null,
|
|
notes: "Ask about platform ownership and the incident response rotation.",
|
|
description: `Build reliable distributed systems. ${"Long responsibility text ".repeat(80)} https://example.test/${"very-long-path-segment/".repeat(12)}`,
|
|
translatedDescription: null,
|
|
descriptionLanguage: "en",
|
|
tags: JSON.stringify([".NET", "Kubernetes", "Incident response"]),
|
|
deadline: null,
|
|
coverLetterText: null,
|
|
jobUrl: "https://example.test/jobs/platform-reliability",
|
|
dateApplied: new Date().toISOString(),
|
|
feedbackRequestedAt: null,
|
|
source: "direct",
|
|
countryCode: "NO",
|
|
};
|
|
let jobResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: jobData });
|
|
for (let attempt = 0; attempt < 2 && !jobResponse.ok(); attempt += 1) {
|
|
await page.waitForTimeout(250);
|
|
jobResponse = await page.request.post(`${apiUrl}/jobapplications`, { headers, data: jobData });
|
|
}
|
|
expect(jobResponse.ok()).toBeTruthy();
|
|
const job = await jobResponse.json();
|
|
|
|
await page.goto("/jobs");
|
|
const applicationRow = page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") });
|
|
await applicationRow.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`));
|
|
await expect(page.getByRole("heading", { name: title })).toBeVisible();
|
|
|
|
await page.goBack();
|
|
await expect(page).toHaveURL(/\/jobs$/);
|
|
await page.goForward();
|
|
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}$`));
|
|
|
|
const workspaceNav = page.getByRole("navigation", { name: "Workspace sections" });
|
|
await workspaceNav.getByRole("button", { name: "Cover Letter", exact: true }).click();
|
|
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=cover-letter$`));
|
|
await page.getByLabel("Application answer").fill("A reviewed answer that must not be lost.");
|
|
await workspaceNav.getByRole("button", { name: "Match", exact: true }).click();
|
|
await expect(page.getByRole("dialog", { name: "Unsaved application changes" })).toBeVisible();
|
|
await page.getByRole("button", { name: "Keep editing" }).click();
|
|
await expect(page.getByLabel("Application answer")).toHaveValue("A reviewed answer that must not be lost.");
|
|
await page.getByRole("button", { name: "Save application drafts" }).click();
|
|
await expect(page.getByText("Unsaved changes")).toHaveCount(0);
|
|
|
|
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "light"));
|
|
await page.reload();
|
|
for (const width of [375, 768, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await expect(page.getByRole("heading", { name: title })).toBeVisible();
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
}
|
|
|
|
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark"));
|
|
await page.reload();
|
|
await expect(page.getByLabel("Application answer")).toHaveValue("A reviewed answer that must not be lost.");
|
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark");
|
|
|
|
for (const width of [375, 768, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await expect(page.getByRole("heading", { name: title })).toBeVisible();
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
}
|
|
|
|
await workspaceNav.getByRole("button", { name: "Job Details", exact: true }).click();
|
|
await expect(page).toHaveURL(new RegExp(`/jobs/${job.id}\\?section=job-details$`));
|
|
await expect(page.getByText("Ask about platform ownership and the incident response rotation.")).toBeVisible();
|
|
await expect(page.getByText("<<<APPLICATION_ANSWER_DRAFT>>>")).toHaveCount(0);
|
|
|
|
await page.getByRole("button", { name: "Back to applications" }).click();
|
|
await expect(page).toHaveURL(/\/jobs$/);
|
|
await expect(page.getByRole("row", { name: new RegExp(`Open ${title}`, "i") })).toBeFocused();
|
|
|
|
await page.goto("/jobs/2147483647");
|
|
const missingJobAlert = page.getByRole("alert").filter({ hasText: /Not Found|Could not open this application/i });
|
|
await expect(missingJobAlert).toBeVisible();
|
|
const alertColours = await missingJobAlert.evaluate((element) => {
|
|
const styles = window.getComputedStyle(element);
|
|
const parentStyles = window.getComputedStyle(element.parentElement!);
|
|
return {
|
|
foreground: styles.color,
|
|
background: styles.backgroundColor,
|
|
parentBackground: parentStyles.backgroundColor,
|
|
};
|
|
});
|
|
const alertBackground = composite(parseCssColour(alertColours.background), parseCssColour(alertColours.parentBackground));
|
|
expect(contrastRatio(parseCssColour(alertColours.foreground), alertBackground)).toBeGreaterThanOrEqual(4.5);
|
|
await expect(page.getByRole("button", { name: "Back to applications" })).toBeVisible();
|
|
});
|
|
|
|
test("Career Workspace loads from the authenticated application shell", async ({ page }) => {
|
|
await login(page);
|
|
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "light"));
|
|
await page.goto("/career");
|
|
await expect(page.getByRole("heading", { name: "Career Workspace" })).toBeVisible();
|
|
await expect(page.getByRole("link", { name: "Open CV Builder" })).toHaveAttribute("href", "/career/builder");
|
|
|
|
for (const width of [375, 768, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
}
|
|
|
|
await page.getByRole("link", { name: "Open CV Builder" }).click();
|
|
await expect(page).toHaveURL(/\/career\/builder$/);
|
|
await page.getByRole("button", { name: "New CV" }).first().click();
|
|
await expect(page).toHaveURL(/\/career\/builder\/\d+$/);
|
|
await expect(page.getByLabel("CV name")).toBeVisible();
|
|
|
|
await page.evaluate(() => window.localStorage.setItem("jobtracker.themeMode", "dark"));
|
|
await page.reload();
|
|
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark");
|
|
for (const width of [375, 768, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await expect(page.getByLabel("CV name")).toBeVisible();
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
}
|
|
});
|
|
|
|
test("a public CV renders anonymously and downloads as PDF", async ({ page }) => {
|
|
await login(page);
|
|
const headers = await csrfHeader(page);
|
|
const create = await page.request.post(`${apiUrl}/cv/variants`, {
|
|
headers,
|
|
data: { name: "E2E Public CV" },
|
|
});
|
|
expect(create.ok()).toBeTruthy();
|
|
const variant = await create.json();
|
|
|
|
const publish = await page.request.put(`${apiUrl}/cv/variants/${variant.id}/public`, {
|
|
headers,
|
|
data: { isPublic: true },
|
|
});
|
|
expect(publish.ok()).toBeTruthy();
|
|
const published = await publish.json();
|
|
|
|
await page.context().clearCookies();
|
|
await page.setViewportSize({ width: 375, height: 900 });
|
|
await page.goto(`/cv/${published.publicSlug}`);
|
|
const publicCvFrame = page.getByTitle("Public CV");
|
|
await expect(publicCvFrame).toBeVisible();
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
expect(overflow).toBeLessThanOrEqual(1);
|
|
const publicCvMetrics = await publicCvFrame.evaluate((element) => {
|
|
const frame = element as HTMLIFrameElement;
|
|
return {
|
|
renderedWidth: frame.getBoundingClientRect().width,
|
|
innerOverflow: (frame.contentDocument?.documentElement.scrollWidth ?? 0) - (frame.contentDocument?.documentElement.clientWidth ?? 0),
|
|
};
|
|
});
|
|
expect(publicCvMetrics.renderedWidth).toBeLessThanOrEqual(343);
|
|
expect(publicCvMetrics.innerOverflow).toBeLessThanOrEqual(1);
|
|
await expect(page.getByRole("link", { name: "Download PDF" })).toHaveAttribute("href", /\/pdf$/);
|
|
const pdf = await page.request.get(`${apiUrl}/public-cv/${published.publicSlug}/pdf`);
|
|
expect(pdf.ok()).toBeTruthy();
|
|
expect(pdf.headers()["content-type"]).toContain("application/pdf");
|
|
expect((await pdf.body()).subarray(0, 5).toString()).toBe("%PDF-");
|
|
});
|