diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 8a252e387..9da613ad2 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -76,8 +76,8 @@ jobs: - name: Build run: pnpm build - - name: Run Baseline E2E Tests - run: pnpm exec playwright test --grep-invert "@semantic-css" + - name: Run E2E Tests + run: pnpm exec playwright test - name: Upload Playwright Report if: always() diff --git a/playwright.config.ts b/playwright.config.ts index 6cf0bb442..4f7538396 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,9 +9,7 @@ export default defineConfig({ fullyParallel: true, forbidOnly: isCI, retries: 0, - // Semantic CSS acceptance includes deterministic PDF preflight and 15 visual renders. Keep it serial so - // independent browser workers do not contend for the fixed production preflight deadline. - workers: 1, + workers: isCI ? 4 : undefined, timeout: 30_000, expect: { timeout: 10_000, diff --git a/tests/e2e/README.md b/tests/e2e/README.md index f9bcdde4d..fd26072a3 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -26,30 +26,14 @@ 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` -## Semantic CSS - -Run the six Semantic CSS scenarios: default mode, legacy conversion, source-only autosave preservation, portable -selector rendering, break-inside pagination, and all-template visual regression: - -```bash -pnpm exec playwright test tests/e2e/specs/semantic-css -``` - -Linux/Chromium visual baselines are updated intentionally with: - -```bash -pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts \ - --project=chromium --update-snapshots -``` - ## Coverage - Email/password auth smoke. -- Dashboard sample resume creation. -- Builder basics edit and autosave persistence. +- Dashboard resume lifecycle: create, rename, duplicate, delete. +- Builder section editing, autosave/navigation, and locking. - JSON export/import. - Public sharing for anonymous visitors. -- Semantic CSS default mode, legacy conversion, source-only autosave preservation, portable selector rendering, - break-inside pagination, and all-template visual regression. -PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate. +Visual regression, PDF/DOCX rasterization parity, thumbnail resolution, and import-fixture reproduction are +intentionally outside the PR gate to keep it fast; the opt-in geometry, offline-font, and root-resume suites stay +behind their environment flags. diff --git a/tests/e2e/fixtures/import.ts b/tests/e2e/fixtures/import.ts deleted file mode 100644 index 886921350..000000000 --- a/tests/e2e/fixtures/import.ts +++ /dev/null @@ -1,297 +0,0 @@ -import type { Browser, Page, Request, Response, TestInfo } from "@playwright/test"; -import type { E2EAccount } from "./data"; -import { readFile } from "node:fs/promises"; -import { Pool } from "pg"; -import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; - -export type ImportFormat = "pdf" | "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json"; - -export type SyntheticImportFile = { - name: string; - mimeType: string; - buffer: Buffer; - declaredFormat: ImportFormat; - expectedName?: string; -}; - -type ImportRpcSummary = { - method: string; - path: string; - status: number; - durationMs: number; -}; - -const IMPORT_RPC_PATH = "/api/rpc/resume/import"; - -function jsonFile( - name: string, - data: unknown, - declaredFormat: Exclude, - expectedName?: string, -): SyntheticImportFile { - return { - name, - mimeType: "application/json", - buffer: Buffer.from(JSON.stringify(data)), - declaredFormat, - expectedName, - }; -} - -export function currentJsonFixture(): SyntheticImportFile { - const data = structuredClone(sampleResumeData); - data.basics.name = "Current JSON Import Probe"; - data.picture.hidden = true; - data.picture.url = ""; - data.metadata.typography.body.fontFamily = "Helvetica"; - data.metadata.typography.heading.fontFamily = "Helvetica"; - - return jsonFile("current-resume.json", data, "reactive-resume-json", data.basics.name); -} - -// Minimal synthetic shape copied from packages/import/src/reactive-resume-v4-json.test.ts. -export function v4JsonFixture(): SyntheticImportFile { - const section = (id: string) => ({ - name: id, - columns: 1, - separateLinks: false, - visible: false, - id, - items: [], - }); - const data = { - basics: { - name: "V4 JSON Import Probe", - headline: "Diagnostic Engineer", - email: "v4@example.test", - phone: "", - location: "Berlin", - url: { label: "", href: "" }, - customFields: [], - picture: { - url: "", - size: 80, - aspectRatio: 1, - borderRadius: 0, - effects: { hidden: true, border: false, grayscale: false }, - }, - }, - sections: { - summary: { - name: "Summary", - columns: 1, - separateLinks: false, - visible: false, - id: "summary", - content: "", - }, - awards: section("awards"), - certifications: section("certifications"), - education: section("education"), - experience: { - ...section("experience"), - visible: true, - items: [ - { - id: "v4-experience", - visible: true, - company: "Synthetic Systems", - position: "Engineer", - location: "Berlin", - date: "2021 - Present", - summary: "Built deterministic import fixtures.", - url: { label: "", href: "" }, - }, - ], - }, - volunteer: section("volunteer"), - interests: section("interests"), - languages: section("languages"), - profiles: section("profiles"), - projects: section("projects"), - publications: section("publications"), - references: section("references"), - skills: section("skills"), - }, - metadata: { - template: "onyx", - layout: [[["experience"], []]], - css: { value: "", visible: false }, - page: { margin: 14, format: "a4", options: { breakLine: false, pageNumbers: false } }, - theme: { background: "#ffffff", text: "#000000", primary: "#dc2626" }, - typography: { - font: { family: "Helvetica", subset: "latin", variants: ["regular"], size: 10 }, - lineHeight: 1.5, - hideIcons: false, - underlineLinks: false, - }, - notes: "", - }, - }; - - return jsonFile("v4-resume.json", data, "reactive-resume-v4-json", data.basics.name); -} - -// Minimal synthetic shape copied from packages/import/src/json-resume.test.ts. -export function jsonResumeFixture(): SyntheticImportFile { - const data = { - basics: { - name: "JSON Resume Import Probe", - label: "Diagnostic Engineer", - email: "json-resume@example.test", - location: { city: "Berlin", countryCode: "DE" }, - }, - work: [ - { - name: "Synthetic Systems", - position: "Engineer", - startDate: "2021-01", - endDate: "2025-01", - highlights: ["Built deterministic import fixtures"], - }, - ], - }; - - return jsonFile("json-resume.json", data, "json-resume-json", data.basics.name); -} - -export function malformedJsonFixture(): SyntheticImportFile { - return { - name: "malformed.json", - mimeType: "application/json", - buffer: Buffer.from('{"basics":'), - declaredFormat: "reactive-resume-json", - }; -} - -export function structurallyInvalidCurrentJsonFixture(): SyntheticImportFile { - const data = structuredClone(sampleResumeData) as unknown as Record; - delete data.picture; - - return jsonFile("missing-picture.json", data, "reactive-resume-json"); -} - -export function withoutMimeType(file: SyntheticImportFile): SyntheticImportFile { - return { ...file, mimeType: "" }; -} - -export async function pdfFixture( - browser: Browser, - testInfo: TestInfo, - options: { blank?: boolean; mimeType?: string } = {}, -): Promise { - const name = options.blank ? "blank-resume.pdf" : "text-resume.pdf"; - const path = testInfo.outputPath(name); - const page = await browser.newPage(); - - try { - await page.setContent(` - - - - - - ${ - options.blank - ? "" - : `

PDF Import Probe

-

pdf-import@example.test · Berlin

-

Experience

-

Synthetic Systems · Diagnostic Engineer

-

2021 – Present

-
  • Built deterministic browser PDF fixtures.
  • Verified offline resume import.
` - } - - `); - await page.pdf({ path, format: "A4", printBackground: true }); - } finally { - await page.close(); - } - - return { - name, - mimeType: options.mimeType ?? "application/pdf", - buffer: await readFile(path), - declaredFormat: "pdf", - expectedName: options.blank ? undefined : "PDF Import Probe", - }; -} - -export async function countUserResumes(account: E2EAccount): Promise { - if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for import reproduction E2E."); - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - - try { - const result = await pool.query<{ count: string }>( - `select count(*)::text as count - from "resume" - join "user" on "user".id = "resume".user_id - where "user".email = $1`, - [account.email], - ); - return Number(result.rows[0]?.count ?? 0); - } finally { - await pool.end(); - } -} - -export function observeImport(page: Page) { - const pageErrors: Array<{ message: string; stack: string }> = []; - const rpc: ImportRpcSummary[] = []; - const requestStart = new WeakMap(); - - const onPageError = (error: Error) => { - pageErrors.push({ message: error.message, stack: error.stack ?? error.message }); - }; - const onRequest = (request: Request) => { - if (new URL(request.url()).pathname !== IMPORT_RPC_PATH) return; - requestStart.set(request, performance.now()); - }; - const onResponse = (response: Response) => { - const request = response.request(); - const path = new URL(response.url()).pathname; - if (path !== IMPORT_RPC_PATH) return; - rpc.push({ - method: request.method(), - path, - status: response.status(), - durationMs: Math.round(performance.now() - (requestStart.get(request) ?? performance.now())), - }); - }; - - page.on("pageerror", onPageError); - page.on("request", onRequest); - page.on("response", onResponse); - - return { - pageErrors, - rpc, - async attach( - testInfo: TestInfo, - details: { - fixture: string; - detectedFormat: ImportFormat | ""; - selectedFormat: ImportFormat; - providerState: "none"; - uiOutcome: string; - beforeCount: number; - afterCount: number; - }, - ) { - await testInfo.attach("import-diagnostics", { - contentType: "application/json", - body: Buffer.from(JSON.stringify({ ...details, rpc, pageErrors }, null, 2)), - }); - }, - dispose() { - page.off("pageerror", onPageError); - page.off("request", onRequest); - page.off("response", onResponse); - }, - }; -} diff --git a/tests/e2e/fixtures/pdf-borders.test.ts b/tests/e2e/fixtures/pdf-borders.test.ts deleted file mode 100644 index 509a3c769..000000000 --- a/tests/e2e/fixtures/pdf-borders.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { BorderPath } from "./pdf-borders"; -import { describe, expect, it } from "vitest"; -import { countTableBorderGeometry } from "./pdf-borders"; - -describe("table border geometry", () => { - it("does not count a later path after stale magenta stroke state", () => { - const paths: BorderPath[] = [ - { color: "#cc00cc", bounds: [0, 0, 100, 1] }, - { color: "#cc00cc", bounds: [0, 19.65, 358.93, 20.65] }, - ]; - const oldHorizontal = paths.filter(({ color, bounds: [x0, y0, x1, y1] }) => { - const width = Math.abs((x1 ?? 0) - (x0 ?? 0)); - const height = Math.abs((y1 ?? 0) - (y0 ?? 0)); - return color === "#cc00cc" && height > 0 && height <= 1.01 && width > height; - }).length; - - expect(oldHorizontal).toBe(2); - expect(countTableBorderGeometry(paths)).toEqual({ horizontal: 1, vertical: 0 }); - }); -}); diff --git a/tests/e2e/fixtures/pdf-borders.ts b/tests/e2e/fixtures/pdf-borders.ts deleted file mode 100644 index 1276fc7c5..000000000 --- a/tests/e2e/fixtures/pdf-borders.ts +++ /dev/null @@ -1,35 +0,0 @@ -export type BorderPath = { - color: string; - bounds: readonly number[]; -}; - -export type BorderGeometry = { - horizontal: number; - vertical: number; -}; - -// The imported-table fixture is explicitly 300pt wide. Row height can change when a cell is edited, -// so use width as stable topology while excluding unrelated paths that inherit the table's stroke color. -const TABLE_GRID_MAX_X = 300; - -const isInsideTableWidth = (bounds: readonly number[]) => { - const [x0, y0, x1, y1] = bounds; - if (![x0, y0, x1, y1].every((value) => Number.isFinite(value))) return false; - return Math.min(x0 ?? 0, x1 ?? 0) >= 0 && Math.max(x0 ?? 0, x1 ?? 0) <= TABLE_GRID_MAX_X; -}; - -export function countTableBorderGeometry(paths: readonly BorderPath[]): BorderGeometry { - let horizontal = 0; - let vertical = 0; - - for (const path of paths) { - if (path.color !== "#cc00cc" || !isInsideTableWidth(path.bounds)) continue; - const [x0, y0, x1, y1] = path.bounds; - const width = Math.abs((x1 ?? 0) - (x0 ?? 0)); - const height = Math.abs((y1 ?? 0) - (y0 ?? 0)); - if (height > 0 && height <= 1.01 && width > height) horizontal++; - if (width > 0 && width <= 1.01 && height > width) vertical++; - } - - return { horizontal, vertical }; -} diff --git a/tests/e2e/fixtures/preview.test.ts b/tests/e2e/fixtures/preview.test.ts deleted file mode 100644 index d590cd11e..000000000 --- a/tests/e2e/fixtures/preview.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -// @vitest-environment happy-dom - -import { afterEach, describe, expect, it } from "vitest"; -import { ACTIVE_PREVIEW_PAGE_SELECTOR, activePreviewPageSelector, readPreviewPageDataUrl } from "./preview"; - -describe("preview helpers", () => { - afterEach(() => { - document.body.replaceChildren(); - }); - - it("reads the active preview while an exiting layer remains mounted", () => { - document.body.innerHTML = ` - -
- -
- `; - const [exiting, active] = document.querySelectorAll("canvas"); - if (!exiting || !active) throw new Error("Expected both preview layers."); - exiting.toDataURL = () => "exiting"; - active.toDataURL = () => "active"; - - expect(readPreviewPageDataUrl(ACTIVE_PREVIEW_PAGE_SELECTOR)).toBe("active"); - }); - - it("accepts a requested active template with the same bitmap as the previous layer", () => { - document.body.innerHTML = ` - -
- -
- `; - for (const canvas of document.querySelectorAll("canvas")) { - canvas.toDataURL = () => "same-bitmap"; - } - - expect(readPreviewPageDataUrl(activePreviewPageSelector("glalie"))).toBe("same-bitmap"); - }); - - it("does not accept an unrelated active template", () => { - document.body.innerHTML = ` -
- -
- - `; - - expect(() => readPreviewPageDataUrl(activePreviewPageSelector("glalie"))).toThrow( - "Expected an active first-page canvas.", - ); - }); -}); diff --git a/tests/e2e/fixtures/preview.ts b/tests/e2e/fixtures/preview.ts deleted file mode 100644 index bf2e94c8f..000000000 --- a/tests/e2e/fixtures/preview.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const ACTIVE_PREVIEW_PAGE_SELECTOR = '[aria-hidden="false"] canvas[aria-label^="Resume page 1 of"]'; - -export const activePreviewPageSelector = (template: string) => - `[aria-hidden="false"][data-resume-preview-template="${template}"] canvas[aria-label^="Resume page 1 of"]`; - -export function readPreviewPageDataUrl(selector: string) { - const canvas = document.querySelector(selector); - if (!canvas) throw new Error("Expected an active first-page canvas."); - return canvas.toDataURL(); -} diff --git a/tests/e2e/fixtures/semantic-css.test.ts b/tests/e2e/fixtures/semantic-css.test.ts deleted file mode 100644 index 18d118f49..000000000 --- a/tests/e2e/fixtures/semantic-css.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Page } from "@playwright/test"; -import { describe, expect, it, vi } from "vitest"; -import { activePreviewPageSelector } from "./preview"; -import { switchTemplate } from "./semantic-css"; - -vi.mock("@playwright/test", () => { - const expect = Object.assign(() => ({ toBeVisible: async () => {} }), { - poll: (read: () => Promise) => ({ - toBe: async () => { - await read(); - await read(); - }, - }), - }); - return { expect }; -}); - -vi.mock("./resume", () => ({ - createSampleResumeFromDashboard: vi.fn(), - openSidebarSection: vi.fn(), -})); - -describe("switchTemplate", () => { - it("waits for the requested active preview when the template is already selected", async () => { - let selectedPreview = ""; - const locator = { - filter: () => locator, - first: () => locator, - }; - const section = { - getByRole: () => ({ isVisible: async () => true }), - }; - const page = { - evaluate: async () => "same-bitmap", - getByRole: (role: string) => (role === "region" ? section : locator), - locator: (selector: string) => { - selectedPreview = selector; - return locator; - }, - } as unknown as Page; - - await switchTemplate(page, "Glalie"); - - expect(selectedPreview).toBe(activePreviewPageSelector("glalie")); - }); -}); diff --git a/tests/e2e/fixtures/semantic-css.ts b/tests/e2e/fixtures/semantic-css.ts deleted file mode 100644 index a7f9594cd..000000000 --- a/tests/e2e/fixtures/semantic-css.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { Locator, Page, TestInfo } from "@playwright/test"; -import { expect } from "@playwright/test"; -import { updateSemanticCssFixture } from "./db"; -import { ACTIVE_PREVIEW_PAGE_SELECTOR, activePreviewPageSelector, readPreviewPageDataUrl } from "./preview"; -import { createSampleResumeFromDashboard, openSidebarSection } from "./resume"; - -const EMPTY_SEMANTIC_STYLESHEET = { - languageVersion: 1, - text: "@version 1;\n", -} as const; - -type SemanticStylesheetSeed = { - mode: "semantic"; - source: { languageVersion: number; text: string }; -}; - -export const PORTABLE_STYLESHEET = `@version 1; - -:root { - --accent: var(--resume-primary-color); -} - -header > name { - color: var(--accent); -} - -section:is([type="experience"], [type="education"]) > section-heading { - text-transform: uppercase; -} - -section[id="projects"] > section-items > item { - padding: 6pt; -} - -section[id="experience"] item[id="experience-item-2"] field[name="period"] { - color: var(--accent); -} - -rich-text list-item > list-item-content { - line-height: 1.25; -} - -region[placement="sidebar"] section { - background-color: rgba(0, 0, 0, 0.04); -} - -section[type="projects"] { - break-inside: avoid; - -resume-min-presence-ahead: 24pt; -} - -@media (max-width: 600pt) { - region[placement="sidebar"] section-heading { - font-size: 9pt; - } -} - -resume[template="azurill"] template-part[name="timeline-dot"] { - background-color: var(--accent); -} -`; - -export const resumeIdFromPage = (page: Page) => { - const resumeId = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1]; - if (!resumeId) throw new Error(`Expected a builder URL, received ${page.url()}.`); - return resumeId; -}; - -export async function createSemanticCssResume(page: Page, testInfo: TestInfo) { - await createSampleResumeFromDashboard(page, testInfo); - const resumeId = resumeIdFromPage(page); - await openSemanticCssEditor(page); - return resumeId; -} - -export async function seedSemanticCssResume( - page: Page, - resumeId: string, - { - basicsName, - portableLayout, - experienceItemId, - hidePicture = false, - stylesheet = { - mode: "semantic", - source: EMPTY_SEMANTIC_STYLESHEET, - }, - }: { - basicsName?: string; - portableLayout?: "balanced" | "pagination-stress"; - experienceItemId?: string; - hidePicture?: boolean; - stylesheet?: SemanticStylesheetSeed; - } = {}, -) { - await updateSemanticCssFixture(resumeId, { - basicsName, - portableLayout, - experienceItemId, - hidePicture, - stylesheet, - }); - await page.reload(); - await openSemanticCssEditor(page); - await waitForStylesheetStatus(page, "Valid"); -} - -export async function openSemanticCssEditor(page: Page) { - await openSidebarSection(page, "Custom Styles"); - await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toBeVisible(); -} - -export async function replaceStylesheet(page: Page, source: string) { - const editor = page.getByRole("textbox", { name: "Semantic CSS stylesheet" }); - await editor.fill(source); - await expect.poll(() => readStylesheetSource(page)).toBe(source); -} - -export function readStylesheetSource(page: Page) { - const editor = page.getByRole("textbox", { name: "Semantic CSS stylesheet" }); - return editor.evaluate((element) => - Array.from(element.querySelectorAll(".cm-line"), (line) => line.textContent ?? "").join("\n"), - ); -} - -async function waitForStylesheetStatus(page: Page, status: string) { - await expect(page.getByText(status, { exact: true }).filter({ visible: true }).last()).toBeVisible({ - timeout: 30_000, - }); -} - -export async function activateStylesheet(page: Page) { - const button = page.getByRole("button", { name: "Activate Semantic CSS" }); - await expect(button).toBeEnabled({ timeout: 30_000 }); - await button.click(); - await waitForStylesheetStatus(page, "Valid"); -} - -async function firstPreviewPage(page: Page, selector = ACTIVE_PREVIEW_PAGE_SELECTOR): Promise { - const canvas = page.locator(selector).filter({ visible: true }).first(); - await expect(canvas).toBeVisible({ timeout: 30_000 }); - return canvas; -} - -export async function waitForStablePreview(page: Page, selector = ACTIVE_PREVIEW_PAGE_SELECTOR): Promise { - const canvas = await firstPreviewPage(page, selector); - let previous: string | undefined; - let stableSamples = 0; - await expect - .poll( - async () => { - const current = await page.evaluate(readPreviewPageDataUrl, selector); - stableSamples = previous === current ? stableSamples + 1 : 0; - previous = current; - return stableSamples >= 1; - }, - { timeout: 15_000, intervals: [250] }, - ) - .toBe(true); - return canvas; -} - -export async function switchTemplate(page: Page, template: string) { - await openSidebarSection(page, "Template"); - const section = page.getByRole("region", { name: "Toggle Template section" }); - const targetPreview = activePreviewPageSelector(template.toLowerCase()); - if (!(await section.getByRole("heading", { name: template, exact: true }).isVisible())) { - await section.getByRole("button").first().click(); - const gallery = page.getByRole("dialog", { name: "Template Gallery" }); - await expect(gallery).toBeVisible(); - const save = page.waitForResponse((response) => { - const body = response.request().postData() ?? ""; - return response.url().includes("/api/rpc") && response.ok() && body.includes(template.toLowerCase()); - }); - await gallery.getByRole("img", { name: template, exact: true }).click(); - await save; - await page.keyboard.press("Escape"); - } - await waitForStablePreview(page, targetPreview); -} diff --git a/tests/e2e/specs/authored-page-guidance.spec.ts b/tests/e2e/specs/authored-page-guidance.spec.ts deleted file mode 100644 index 0920e093b..000000000 --- a/tests/e2e/specs/authored-page-guidance.spec.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; -import { expect, test } from "../fixtures/test"; - -test("explains authored pages and automatic PDF overflow in Layout", async ({ authPage: page }, testInfo) => { - await createSampleResumeFromDashboard(page, testInfo); - await openSidebarSection(page, "Layout"); - - const guidance = page.getByRole("note", { name: "Authored pages and PDF overflow" }); - await expect(guidance).toBeVisible(); - await expect(guidance).toContainText("overflow pages are not saved or editable separately"); - await expect(guidance).toContainText("Move to → New Page"); - await expect(guidance).toContainText("Full Width"); -}); diff --git a/tests/e2e/specs/import-reproduction.spec.ts b/tests/e2e/specs/import-reproduction.spec.ts deleted file mode 100644 index 48c6a9fe0..000000000 --- a/tests/e2e/specs/import-reproduction.spec.ts +++ /dev/null @@ -1,357 +0,0 @@ -import type { Locator, Page, TestInfo } from "@playwright/test"; -import type { E2EAccount } from "../fixtures/data"; -import type { ImportFormat, SyntheticImportFile } from "../fixtures/import"; -import { parseJSONResume } from "@reactive-resume/import/json-resume"; -import { parseReactiveResumeJSON } from "@reactive-resume/import/reactive-resume-json"; -import { parseReactiveResumeV4JSON } from "@reactive-resume/import/reactive-resume-v4-json"; -import { - countUserResumes, - currentJsonFixture, - jsonResumeFixture, - malformedJsonFixture, - observeImport, - pdfFixture, - structurallyInvalidCurrentJsonFixture, - v4JsonFixture, - withoutMimeType, -} from "../fixtures/import"; -import { expect, test } from "../fixtures/test"; - -const formatLabels: Record = { - "reactive-resume-json": "Reactive Resume (JSON)", - "reactive-resume-v4-json": "Reactive Resume v4 (JSON)", - "json-resume-json": "JSON Resume", - pdf: "PDF", -}; - -async function openImportDialog(page: Page): Promise { - await page.goto("/dashboard/resumes"); - await page.getByText("Import an existing resume", { exact: true }).click(); - return page.getByRole("dialog", { name: "Import an existing resume" }); -} - -async function selectImportFile( - page: Page, - dialog: Locator, - file: SyntheticImportFile, - selectedFormat = file.declaredFormat, -): Promise { - await dialog.locator('input[type="file"]').setInputFiles({ - name: file.name, - mimeType: file.mimeType, - buffer: file.buffer, - }); - - const combobox = dialog.getByRole("combobox"); - await expect(combobox).toContainText(formatLabels[file.declaredFormat]); - if (selectedFormat === file.declaredFormat) return; - - await combobox.click(); - await page.getByRole("option", { name: formatLabels[selectedFormat], exact: true }).click(); - await expect(combobox).toContainText(formatLabels[selectedFormat]); -} - -async function assertImportedName(page: Page, expectedName: string): Promise { - await page.waitForURL(/\/builder\/.+/); - await page.getByTestId("left").getByRole("button", { name: "Basics", exact: true }).click(); - await expect(page.getByLabel("Name", { exact: true })).toHaveValue(expectedName); -} - -async function assertErrorToast(page: Page, expected: string | RegExp, timeout = 10_000): Promise { - const toast = page.locator('[data-slot="toast"][data-type="error"]'); - await expect(toast).toBeVisible({ timeout }); - const description = toast.locator('[data-slot="toast-description"]'); - await expect(description).toContainText(expected); - return (await description.textContent()) ?? ""; -} - -async function attachDiagnostics( - observation: ReturnType, - testInfo: TestInfo, - details: { - fixture: string; - detectedFormat: ImportFormat | ""; - selectedFormat: ImportFormat; - uiOutcome: string; - beforeCount: number; - afterCount: number; - }, -): Promise { - await observation.attach(testInfo, { ...details, providerState: "none" }); - observation.dispose(); -} - -async function importSuccessfully( - page: Page, - account: E2EAccount, - testInfo: TestInfo, - file: SyntheticImportFile, -): Promise { - const observation = observeImport(page); - const beforeCount = await countUserResumes(account); - let afterCount = beforeCount; - let uiOutcome = "test failed before UI outcome"; - - try { - const dialog = await openImportDialog(page); - await selectImportFile(page, dialog, file); - await dialog.getByRole("button", { name: "Import", exact: true }).click(); - await assertImportedName(page, file.expectedName ?? ""); - await expect.poll(() => countUserResumes(account)).toBe(beforeCount + 1); - afterCount = await countUserResumes(account); - uiOutcome = `builder:${new URL(page.url()).pathname}`; - - expect(observation.rpc).toEqual([ - expect.objectContaining({ method: "POST", path: "/api/rpc/resume/import", status: 200 }), - ]); - expect(observation.pageErrors).toEqual([]); - } finally { - await attachDiagnostics(observation, testInfo, { - fixture: file.name, - detectedFormat: file.declaredFormat, - selectedFormat: file.declaredFormat, - uiOutcome, - beforeCount, - afterCount, - }); - } -} - -test.describe("synthetic import fixtures", () => { - test("valid JSON fixtures parse through their declared importers", () => { - const current = currentJsonFixture(); - const v4 = v4JsonFixture(); - const jsonResume = jsonResumeFixture(); - - expect(parseReactiveResumeJSON(current.buffer.toString()).basics.name).toBe(current.expectedName); - expect(parseReactiveResumeV4JSON(v4.buffer.toString()).basics.name).toBe(v4.expectedName); - expect(parseJSONResume(jsonResume.buffer.toString()).basics.name).toBe(jsonResume.expectedName); - }); - - test("invalid JSON fixtures fail for their intended reason", () => { - expect(() => parseReactiveResumeJSON(malformedJsonFixture().buffer.toString())).toThrow(/JSON/i); - expect(() => parseReactiveResumeJSON(structurallyInvalidCurrentJsonFixture().buffer.toString())).toThrow( - /picture/i, - ); - }); -}); - -for (const fixtureFactory of [v4JsonFixture, jsonResumeFixture]) { - const fixture = fixtureFactory(); - test(`imports ${fixture.declaredFormat} through real dialog and RPC`, async ({ - authPage: page, - account, - }, testInfo) => { - await importSuccessfully(page, account, testInfo, fixtureFactory()); - }); -} - -test("imports current JSON while close cancellation preserves pending dialog state", async ({ - authPage: page, - account, -}, testInfo) => { - const file = currentJsonFixture(); - const observation = observeImport(page); - const beforeCount = await countUserResumes(account); - let afterCount = beforeCount; - let uiOutcome = "test failed before UI outcome"; - let releaseImport = () => {}; - const barrier = new Promise((resolve) => { - releaseImport = resolve; - }); - let interceptedImport = () => {}; - const intercepted = new Promise((resolve) => { - interceptedImport = resolve; - }); - - await page.route("**/api/rpc/resume/import", async (route) => { - interceptedImport(); - await barrier; - try { - await route.continue(); - } catch (error) { - if (!page.isClosed()) throw error; - } - }); - - try { - const dialog = await openImportDialog(page); - await selectImportFile(page, dialog, file); - await dialog.getByRole("button", { name: "Import", exact: true }).click(); - await intercepted; - await dialog.getByRole("button", { name: "Close", exact: true }).click(); - const confirmation = page.getByRole("alertdialog", { name: "Are you sure you want to close this dialog?" }); - await expect(confirmation).toBeVisible(); - await confirmation.getByRole("button", { name: "Stay", exact: true }).click(); - await expect(dialog).toBeVisible(); - await expect(dialog.getByText(file.name, { exact: true })).toBeVisible(); - await expect(dialog.getByRole("button", { name: /Importing/ })).toBeDisabled(); - - releaseImport(); - await assertImportedName(page, file.expectedName ?? ""); - await expect.poll(() => countUserResumes(account)).toBe(beforeCount + 1); - afterCount = await countUserResumes(account); - uiOutcome = `close-cancelled:file-retained;builder:${new URL(page.url()).pathname}`; - - expect(observation.rpc).toEqual([ - expect.objectContaining({ method: "POST", path: "/api/rpc/resume/import", status: 200 }), - ]); - expect(observation.pageErrors).toEqual([]); - } finally { - releaseImport(); - if (!page.isClosed()) await page.unroute("**/api/rpc/resume/import"); - await attachDiagnostics(observation, testInfo, { - fixture: file.name, - detectedFormat: file.declaredFormat, - selectedFormat: file.declaredFormat, - uiOutcome, - beforeCount, - afterCount, - }); - } -}); - -test("imports current JSON when browser supplies no MIME metadata", async ({ authPage: page, account }, testInfo) => { - await importSuccessfully(page, account, testInfo, withoutMimeType(currentJsonFixture())); -}); - -test("imports offline text PDF generated by Playwright Chromium", async ({ - authPage: page, - account, - browser, -}, testInfo) => { - test.setTimeout(60_000); - await importSuccessfully(page, account, testInfo, await pdfFixture(browser, testInfo)); -}); - -test("imports magic-byte PDF when browser supplies no MIME metadata", async ({ - authPage: page, - account, - browser, -}, testInfo) => { - test.setTimeout(60_000); - await importSuccessfully(page, account, testInfo, await pdfFixture(browser, testInfo, { mimeType: "" })); -}); - -test("keeps blank PDF in dialog and creates no resume", async ({ authPage: page, account, browser }, testInfo) => { - test.setTimeout(60_000); - const file = await pdfFixture(browser, testInfo, { blank: true }); - const observation = observeImport(page); - const beforeCount = await countUserResumes(account); - let afterCount = beforeCount; - let uiOutcome = "test failed before UI outcome"; - - try { - const dialog = await openImportDialog(page); - await selectImportFile(page, dialog, file); - await dialog.getByRole("button", { name: "Import", exact: true }).click(); - await assertErrorToast( - page, - "This PDF has no readable text. It is likely a scan, so there is nothing to import.", - 30_000, - ); - await expect(dialog).toBeVisible(); - await expect(dialog.getByText(file.name, { exact: true })).toBeVisible(); - afterCount = await countUserResumes(account); - uiOutcome = "error:no-readable-text;dialog-and-file-retained"; - - expect(afterCount).toBe(beforeCount); - expect(observation.rpc).toEqual([]); - expect(observation.pageErrors).toEqual([]); - } finally { - await attachDiagnostics(observation, testInfo, { - fixture: file.name, - detectedFormat: file.declaredFormat, - selectedFormat: file.declaredFormat, - uiOutcome, - beforeCount, - afterCount, - }); - } -}); - -for (const file of [malformedJsonFixture(), structurallyInvalidCurrentJsonFixture()]) { - test(`keeps invalid fixture ${file.name} in dialog and creates no resume`, async ({ - authPage: page, - account, - }, testInfo) => { - const observation = observeImport(page); - const beforeCount = await countUserResumes(account); - let afterCount = beforeCount; - let uiOutcome = "test failed before UI outcome"; - - try { - const dialog = await openImportDialog(page); - await dialog.locator('input[type="file"]').setInputFiles({ - name: file.name, - mimeType: file.mimeType, - buffer: file.buffer, - }); - const detectedFormat = file.name === "malformed.json" ? "" : file.declaredFormat; - if (detectedFormat === "") { - await expect(dialog.getByText("We couldn't detect the format automatically. Choose it above.")).toBeVisible(); - await dialog.getByRole("combobox").click(); - await page.getByRole("option", { name: formatLabels[file.declaredFormat], exact: true }).click(); - } else { - await expect(dialog.getByRole("combobox")).toContainText(formatLabels[file.declaredFormat]); - } - await dialog.getByRole("button", { name: "Import", exact: true }).click(); - const expectedError = - file.name === "malformed.json" - ? /Unexpected end of JSON input/ - : /The file could not be read as a valid resume/; - await assertErrorToast(page, expectedError); - await expect(dialog.getByText(file.name, { exact: true })).toBeVisible(); - afterCount = await countUserResumes(account); - uiOutcome = "parse-error;dialog-and-file-retained"; - - expect(afterCount).toBe(beforeCount); - expect(observation.rpc).toEqual([]); - expect(observation.pageErrors).toEqual([]); - } finally { - await attachDiagnostics(observation, testInfo, { - fixture: file.name, - detectedFormat: file.name === "malformed.json" ? "" : file.declaredFormat, - selectedFormat: file.declaredFormat, - uiOutcome, - beforeCount, - afterCount, - }); - } - }); -} - -test("rejects a JSON Resume deliberately selected as current JSON without creating a resume", async ({ - authPage: page, - account, -}, testInfo) => { - const file = jsonResumeFixture(); - const selectedFormat = "reactive-resume-json" as const; - const observation = observeImport(page); - const beforeCount = await countUserResumes(account); - let afterCount = beforeCount; - let uiOutcome = "test failed before UI outcome"; - - try { - const dialog = await openImportDialog(page); - await selectImportFile(page, dialog, file, selectedFormat); - await dialog.getByRole("button", { name: "Import", exact: true }).click(); - await assertErrorToast(page, /The file could not be read as a valid resume/); - await expect(dialog.getByText(file.name, { exact: true })).toBeVisible(); - afterCount = await countUserResumes(account); - uiOutcome = "selected-format-parse-error;dialog-and-file-retained"; - - expect(afterCount).toBe(beforeCount); - expect(observation.rpc).toEqual([]); - expect(observation.pageErrors).toEqual([]); - } finally { - await attachDiagnostics(observation, testInfo, { - fixture: file.name, - detectedFormat: file.declaredFormat, - selectedFormat, - uiOutcome, - beforeCount, - afterCount, - }); - } -}); diff --git a/tests/e2e/specs/imported-table.spec.ts b/tests/e2e/specs/imported-table.spec.ts deleted file mode 100644 index 7721cec03..000000000 --- a/tests/e2e/specs/imported-table.spec.ts +++ /dev/null @@ -1,202 +0,0 @@ -import type { Page, TestInfo } from "@playwright/test"; -import { readFile, writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { Pool } from "pg"; -import { defaultResumeData } from "@reactive-resume/schema/resume/default"; -import { countTableBorderGeometry } from "../fixtures/pdf-borders"; -import { ACTIVE_PREVIEW_PAGE_SELECTOR } from "../fixtures/preview"; -import { openSidebarSection } from "../fixtures/resume"; -import { expect, test } from "../fixtures/test"; - -const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`); -const tableHtml = `
AlphaBetaGamma
DeltaEpsilonZeta
`; - -const fixture = () => { - const data = structuredClone(defaultResumeData); - data.basics.name = "Imported Table Probe"; - data.picture.hidden = true; - data.summary.content = tableHtml; - data.metadata.template = "ditgar"; - data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }]; - data.metadata.typography.body.fontFamily = "Helvetica"; - data.metadata.typography.heading.fontFamily = "Helvetica"; - data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: "@version 1;" } }; - return data; -}; - -async function readImportedResume(id: string) { - if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for imported table E2E."); - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - try { - const result = await pool.query<{ - data: { basics: { name: string }; summary: { content: string } }; - slug: string; - updatedAt: Date; - }>('select data, slug, updated_at as "updatedAt" from "resume" where id = $1', [id]); - const row = result.rows[0]; - if (!row) throw new Error(`Resume ${id} was not found.`); - return row; - } finally { - await pool.end(); - } -} - -async function publishImportedResume(id: string) { - if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for imported table E2E."); - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - try { - await pool.query('update "resume" set is_public = true where id = $1', [id]); - } finally { - await pool.end(); - } -} - -async function inspectPdf(bytes: Uint8Array) { - const { getDocument, OPS } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs")); - const loading = getDocument({ data: bytes.slice(), useSystemFonts: true }); - try { - const document = await loading.promise; - const page = await document.getPage(1); - const text = (await page.getTextContent()).items.flatMap((item: { str?: string }) => item.str ?? []); - const operators = await page.getOperatorList(); - let stroke = ""; - const borderPaths = []; - for (const [index, fn] of operators.fnArray.entries()) { - if (fn === OPS.setStrokeRGBColor) stroke = operators.argsArray[index][0]; - if (fn !== OPS.constructPath) continue; - const bounds = operators.argsArray[index][2] as ArrayLike; - borderPaths.push({ - color: stroke, - bounds: [bounds[0] ?? 0, bounds[1] ?? 0, bounds[2] ?? 0, bounds[3] ?? 0], - }); - } - const { horizontal, vertical } = countTableBorderGeometry(borderPaths); - return { text, horizontal, vertical }; - } finally { - await loading.destroy(); - } -} - -async function downloadBuilderPdf(page: Page, testInfo: TestInfo, stage: string) { - await openSidebarSection(page, "Export"); - await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click(); - const pending = page.waitForEvent("download"); - await page.getByRole("button", { name: "Download PDF", exact: true }).click(); - const download = await pending; - const path = testInfo.outputPath(`${stage}-browser.pdf`); - await download.saveAs(path); - return new Uint8Array(await readFile(path)); -} - -async function previewBorderPixels(page: Page, testInfo: TestInfo, stage: string) { - const canvas = page.locator(ACTIVE_PREVIEW_PAGE_SELECTOR); - await expect(canvas).toBeVisible({ timeout: 30_000 }); - await canvas.screenshot({ path: testInfo.outputPath(`${stage}-preview.png`), animations: "disabled" }); - return canvas.evaluate((element) => { - const context = (element as HTMLCanvasElement).getContext("2d"); - if (!context) throw new Error("Missing preview canvas context."); - const pixels = context.getImageData(0, 0, context.canvas.width, context.canvas.height).data; - let magenta = 0; - for (let index = 0; index < pixels.length; index += 4) { - if ((pixels[index] ?? 0) > 180 && (pixels[index + 1] ?? 255) < 80 && (pixels[index + 2] ?? 0) > 180) magenta++; - } - return magenta; - }); -} - -test("edits and exports a synthetic imported table without losing its grid", async ({ - authPage: page, - account, -}, testInfo) => { - test.setTimeout(180_000); - const importPath = testInfo.outputPath("imported-table.json"); - await writeFile(importPath, JSON.stringify(fixture())); - - await page.goto("/dashboard/resumes"); - await page.getByText("Import an existing resume", { exact: true }).click(); - const dialog = page.getByRole("dialog", { name: "Import an existing resume" }); - await dialog.locator('input[type="file"]').setInputFiles(importPath); - await dialog.getByRole("button", { name: "Import", exact: true }).click(); - await page.waitForURL(/\/builder\/.+/); - const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1]; - if (!id) throw new Error("Missing imported resume id."); - await publishImportedResume(id); - let stored = await readImportedResume(id); - const slug = stored.slug; - - const captureStage = async (stage: string, expectedCell: string) => { - const summaryHtml = (await readImportedResume(id)).data.summary.content; - await writeFile(testInfo.outputPath(`${stage}-summary.html`), summaryHtml); - expect(summaryHtml).toContain(" (await readImportedResume(id)).data.basics.name).toBe(unrelatedName); - stored = await readImportedResume(id); - expect(stored.updatedAt.getTime()).toBeGreaterThan(beforeUnrelatedEdit.getTime()); - await page.reload(); - stored = await readImportedResume(id); - expect(stored.data.basics.name).toBe(unrelatedName); - expect(stored.data.summary.content).toBe(tableHtml); - await openSidebarSection(page, "Basics"); - await expect(page.getByLabel("Name")).toHaveValue(unrelatedName); - await openSidebarSection(page, "Summary"); - await expect(summary.locator("table td")).toHaveCount(6); - await captureStage("unrelated-edit", "Beta"); - - const beta = summary.locator("td").filter({ hasText: "Beta" }); - await beta.click(); - await page.keyboard.press("End"); - await page.keyboard.type("!"); - await expect(beta).toContainText("Beta!"); - await page.keyboard.press("Control+z"); - await expect(beta).toContainText("Beta"); - await page.keyboard.press("Control+Shift+z"); - await expect(beta).toContainText("Beta!"); - await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible(); - await page.reload(); - await openSidebarSection(page, "Summary"); - await expect(summary.locator("table tr")).toHaveCount(2); - await expect(summary.locator("table td")).toHaveCount(6); - await expect(summary.locator("td").filter({ hasText: "Beta!" })).toBeVisible(); - stored = await readImportedResume(id); - expect(stored.data.summary.content).toContain(" LIT A\tB END

REF A B END

CTL A B END

\tTAB A B END

'; - -async function readSummary(id: string) { - if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for literal-whitespace E2E."); - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - try { - const result = await pool.query<{ content: string }>( - `select data->'summary'->>'content' as content from "resume" where id = $1`, - [id], - ); - const row = result.rows[0]; - if (!row) throw new Error(`Resume ${id} was not found.`); - return row.content; - } finally { - await pool.end(); - } -} - -async function download(page: Page, testInfo: TestInfo, format: "PDF" | "DOCX" | "JSON") { - await openSidebarSection(page, "Export"); - await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click(); - const pending = page.waitForEvent("download"); - await page.getByRole("button", { name: `Download ${format}`, exact: true }).click(); - const result = await pending; - const path = testInfo.outputPath(`literal-whitespace.${format.toLowerCase()}`); - await result.saveAs(path); - return path; -} - -async function pdfLineMetrics(path: string) { - const { getDocument } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs")); - const loading = getDocument({ data: new Uint8Array(await readFile(path)), useSystemFonts: true }); - try { - const document = await loading.promise; - const lines: Array<{ marker: string; start: number; gap: number; tailWidth: number }> = []; - for (let pageIndex = 0; pageIndex < document.numPages; pageIndex++) { - const page = await document.getPage(pageIndex + 1); - const items = (await page.getTextContent()).items.flatMap((item) => - "str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5], width: item.width }] : [], - ); - for (const marker of ["LIT", "REF", "CTL", "TAB"]) { - const anchor = items.find((item) => item.text.includes(marker)); - if (!anchor) continue; - const tail = items.find( - (item) => item.x > anchor.x && Math.abs(item.y - anchor.y) < 0.01 && item.text.includes("B END"), - ); - if (!tail) continue; - const anchorEnd = anchor.x + anchor.width; - lines.push({ marker, start: anchor.x, gap: tail.x - anchorEnd, tailWidth: tail.x + tail.width - anchorEnd }); - } - } - return lines; - } finally { - await loading.destroy(); - } -} - -test("persists typed and pasted literal whitespace through JSON, PDF, and DOCX", async ({ - authPage: page, -}, testInfo) => { - test.setTimeout(180_000); - await createSampleResumeFromDashboard(page, testInfo); - const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1]; - if (!id) throw new Error("Missing resume id."); - - await openSidebarSection(page, "Summary"); - const editor = page.locator("#sidebar-summary [data-editor=true]"); - await editor.click(); - await page.keyboard.press("ControlOrMeta+a"); - await page.keyboard.press("Backspace"); - await page.keyboard.insertText(" LIT A\tB END "); - await page.keyboard.press("Enter"); - await editor.evaluate((element) => { - const clipboard = new DataTransfer(); - clipboard.setData("text/plain", " REF A B END \nCTL A B END\n\tTAB A B END"); - element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard })); - }); - - await expect.poll(() => readSummary(id)).toBe(preservedHtml); - await page.reload(); - await openSidebarSection(page, "Summary"); - const reloadedEditor = page.locator("#sidebar-summary [data-editor=true]"); - await expect(reloadedEditor).toBeVisible(); - expect(await reloadedEditor.evaluate((element) => element.textContent)).toBe( - " LIT A\tB END REF A B END CTL A B END\tTAB A B END", - ); - expect( - await reloadedEditor - .locator("p") - .first() - .evaluate((element) => getComputedStyle(element).whiteSpace), - ).toBe("pre-wrap"); - expect(await readSummary(id)).toBe(preservedHtml); - - const jsonPath = await download(page, testInfo, "JSON"); - const exported = JSON.parse(await readFile(jsonPath, "utf8")) as { summary: { content: string } }; - expect(exported.summary.content).toBe(preservedHtml); - - const pdfPath = await download(page, testInfo, "PDF"); - const lines = await pdfLineMetrics(pdfPath); - const literal = lines.find((line) => line.marker === "LIT"); - const reference = lines.find((line) => line.marker === "REF"); - expect(literal).toBeDefined(); - expect(reference).toBeDefined(); - if (!literal || !reference) throw new Error("Expected literal whitespace PDF lines."); - expect(literal.start).toBeCloseTo(reference.start, 2); - expect(literal.gap).toBeCloseTo(reference.gap, 2); - expect(literal.tailWidth).toBeCloseTo(reference.tailWidth, 2); - const control = lines.find((line) => line.marker === "CTL"); - const leadingTab = lines.find((line) => line.marker === "TAB"); - if (!control || !leadingTab) throw new Error("Expected unpadded and leading-tab PDF controls."); - expect(literal.gap).toBeGreaterThan(0); - expect(literal.start - control.start).toBeCloseTo(literal.gap / 2, 2); - expect(leadingTab.start - control.start).toBeCloseTo(literal.gap, 2); - await testInfo.attach("pdf-whitespace-geometry", { - body: JSON.stringify(lines, null, 2), - contentType: "application/json", - }); - - const docxPath = await download(page, testInfo, "DOCX"); - const { stdout: documentXml } = await execFileAsync("unzip", ["-p", docxPath, "word/document.xml"], { - encoding: "utf8", - }); - expect(documentXml).toContain('xml:space="preserve"> LIT A B END '); - expect(documentXml).toContain('xml:space="preserve"> REF A B END '); - expect(documentXml).toContain('xml:space="preserve">CTL A B END'); - expect(documentXml).toContain('xml:space="preserve"> TAB A B END'); -}); - -test("displays each stored tab as four spaces with usable caret and selection", async ({ - authPage: page, -}, testInfo) => { - await createSampleResumeFromDashboard(page, testInfo); - await openSidebarSection(page, "Summary"); - const editor = page.locator("#sidebar-summary [data-editor=true]"); - await editor.click(); - await page.keyboard.press("ControlOrMeta+a"); - await page.keyboard.press("Backspace"); - await editor.evaluate((element) => { - const clipboard = new DataTransfer(); - clipboard.setData("text/plain", "A\tB\nAB\nABC\tD\nABCD\nA B\n\tZ\nZ\nA\t\tB"); - element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard })); - }); - for (const direction of ["ltr", "rtl"] as const) { - const geometry = await editor.evaluate((element, direction) => { - element.dir = direction; - element.style.fontFamily = "Arial"; - // Measure ordinary-space advances without Arial's A/space pair kerning. - element.style.fontKerning = "none"; - const blocks = Array.from(element.querySelectorAll("p")); - const glyph = (index: number, offset: number) => { - const walker = document.createTreeWalker(blocks[index], NodeFilter.SHOW_TEXT); - let remaining = offset; - for (let node = walker.nextNode(); node; node = walker.nextNode()) { - if (remaining < (node.textContent?.length ?? 0)) { - const range = document.createRange(); - range.setStart(node, remaining); - range.setEnd(node, remaining + 1); - return range.getBoundingClientRect(); - } - remaining -= node.textContent?.length ?? 0; - } - throw new Error("Missing glyph"); - }; - return { - one: glyph(0, 2).x - glyph(0, 0).right, - three: glyph(2, 4).x - glyph(2, 2).right, - spaces: glyph(4, 5).x - glyph(4, 0).right, - leading: (direction === "rtl" ? -1 : 1) * (glyph(5, 1).x - glyph(6, 0).x), - two: glyph(7, 3).x - glyph(7, 0).right, - text: blocks.map((block) => block.textContent), - }; - }, direction); - await testInfo.attach(`tab-geometry-${direction}`, { - body: JSON.stringify(geometry, null, 2), - contentType: "application/json", - }); - expect(geometry.spaces).toBeGreaterThan(0); - for (const width of [geometry.one, geometry.three, geometry.leading]) expect(width).toBeCloseTo(geometry.spaces, 1); - expect(geometry.two).toBeCloseTo(geometry.spaces * 2, 1); - expect(geometry.text).toEqual(["A\tB", "AB", "ABC\tD", "ABCD", "A B", "\tZ", "Z", "A\t\tB"]); - } - const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1]; - if (!id) throw new Error("Missing resume id."); - await expect.poll(() => readSummary(id)).toContain('data-resume-whitespace="preserve">A\tB

'); - await page.reload(); - await openSidebarSection(page, "Summary"); - await editor.click(); - await editor.evaluate((element) => { - const text = element.querySelector("p")?.firstChild; - if (!text) throw new Error("Missing caret target"); - const range = document.createRange(); - range.setStart(text, 1); - range.collapse(true); - getSelection()?.removeAllRanges(); - getSelection()?.addRange(range); - }); - await page.keyboard.press("Shift+ArrowRight"); - expect(await page.evaluate(() => getSelection()?.toString())).toBe("\t"); - await page.keyboard.insertText("Q"); - await expect(editor.locator("p").first()).toHaveText("AQB"); - await page.keyboard.press("ControlOrMeta+z"); - expect(await editor.locator("p").first().textContent()).toBe("A\tB"); -}); diff --git a/tests/e2e/specs/oauth-consent.spec.ts b/tests/e2e/specs/oauth-consent.spec.ts index ac27f3d57..9b0dec3df 100644 --- a/tests/e2e/specs/oauth-consent.spec.ts +++ b/tests/e2e/specs/oauth-consent.spec.ts @@ -1,24 +1,17 @@ import { createHash, randomBytes } from "node:crypto"; import { expect, test } from "../fixtures/test"; -for (const { accept, login, metadataResource } of [ - { accept: false, login: undefined }, - { accept: true, login: undefined }, - { accept: true, login: "signed-out" }, - { accept: true, login: "fresh" }, - { accept: true, login: undefined, metadataResource: true }, -]) { - test(`requires explicit OAuth consent before ${accept ? "allowing" : "denying"} access (${metadataResource ? "advertised resource" : (login ?? "existing session")})`, async ({ +for (const accept of [false, true]) { + test(`requires explicit OAuth consent before ${accept ? "allowing" : "denying"} access`, async ({ authPage: page, baseURL, - account, }, testInfo) => { const origin = new URL(baseURL ?? "http://localhost:3000").origin; const metadata = await page.request.get("/.well-known/oauth-protected-resource"); expect(metadata.status()).toBe(200); const advertisedResource = (await metadata.json()).resource; expect(advertisedResource).toBe(origin); - const resource = metadataResource ? advertisedResource : `${origin}/mcp`; + const resource = `${origin}/mcp`; const callback = "http://127.0.0.1:33921/callback"; const registration = await page.request.post("/api/auth/oauth2/register", { headers: { origin }, @@ -37,27 +30,17 @@ for (const { accept, login, metadataResource } of [ resource, state: "browser-consent-state", }); - if (!metadataResource) query.append("resource", origin); + query.append("resource", origin); await page.route(`${callback}**`, (route) => route.fulfill({ body: "Client callback" })); - if (login === "fresh") query.set("prompt", "login"); - if (login === "signed-out") await page.context().clearCookies(); await page.goto(`/api/auth/oauth2/authorize?${query}`); - if (login) { - await expect(page.getByRole("heading", { name: "Sign in to your account" })).toBeVisible(); - 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", exact: true }).click(); - } await expect(page.getByRole("heading", { name: "Connect an application" })).toBeVisible(); await expect(page.getByText("Consent test client", { exact: true })).toBeVisible(); await expect(page.getByText(/reading and changing your resumes and job applications/)).toBeVisible(); await expect(page.getByRole("button", { name: "Allow access", exact: true })).toBeEnabled(); const before = await page.request.get("/api/auth/oauth2/get-consents"); expect(await before.json()).toEqual([]); - expect(new URL(page.url()).searchParams.getAll("resource")).toEqual( - metadataResource ? [origin] : [`${origin}/mcp`, origin], - ); - if (accept && !login) { + expect(new URL(page.url()).searchParams.getAll("resource")).toEqual([`${origin}/mcp`, origin]); + if (accept) { await page.getByRole("button", { name: "Allow access", exact: true }).click({ trial: true }); await page.screenshot({ path: testInfo.outputPath("consent-desktop.png"), animations: "disabled" }); await page.setViewportSize({ width: 390, height: 600 }); diff --git a/tests/e2e/specs/picture-rendering.spec.ts b/tests/e2e/specs/picture-rendering.spec.ts deleted file mode 100644 index a0f4fb17f..000000000 --- a/tests/e2e/specs/picture-rendering.spec.ts +++ /dev/null @@ -1,237 +0,0 @@ -import type { Page, TestInfo } from "@playwright/test"; -import { readFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { Pool } from "pg"; -import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; -import { expect, test } from "../fixtures/test"; - -type MarkerBounds = { - left: number; - right: number; - top: number; - bottom: number; - count: number; -}; - -type MarkerMetrics = Record<"red" | "green" | "blue" | "yellow", MarkerBounds | null>; - -const requireRoot = createRequire(`${process.cwd()}/package.json`); - -async function installPdfJsRoute(page: Page) { - await page.route("**/__picture_pdfjs/*", async (route) => { - const worker = new URL(route.request().url()).pathname.endsWith("worker.mjs"); - await route.fulfill({ - contentType: "text/javascript", - path: requireRoot.resolve(`pdfjs-dist/legacy/build/${worker ? "pdf.worker.mjs" : "pdf.mjs"}`), - }); - }); -} - -function markedLandscapePng(dataUrl: string) { - return Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64"); -} - -function createMarkedLandscape(page: Page) { - return page.evaluate(() => { - const canvas = document.createElement("canvas"); - canvas.width = 800; - canvas.height = 600; - const context = canvas.getContext("2d"); - if (!context) throw new Error("Canvas is unavailable"); - context.fillStyle = "#ffffff"; - context.fillRect(0, 0, 800, 600); - context.fillStyle = "#ff0000"; - context.fillRect(0, 0, 64, 600); - context.fillStyle = "#00ff00"; - context.fillRect(736, 0, 64, 600); - context.fillStyle = "#0000ff"; - context.fillRect(64, 0, 672, 48); - context.fillStyle = "#ffff00"; - context.fillRect(64, 552, 672, 48); - context.fillStyle = "#000000"; - context.fillRect(394, 48, 12, 504); - context.fillRect(64, 294, 672, 12); - return canvas.toDataURL("image/png"); - }); -} - -function markerMetricsFromPdf(page: Page, bytes: Uint8Array): Promise { - return page.evaluate(async (pdfBytes) => { - const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import( - `${location.origin}/__picture_pdfjs/pdf.mjs` - ); - pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__picture_pdfjs/worker.mjs`; - const task = pdfjs.getDocument({ data: Uint8Array.from(pdfBytes) }); - try { - const pdfPage = await task.promise.then((pdf) => pdf.getPage(1)); - const viewport = pdfPage.getViewport({ scale: 1.5 }); - const canvas = document.createElement("canvas"); - canvas.width = Math.ceil(viewport.width); - canvas.height = Math.ceil(viewport.height); - const context = canvas.getContext("2d"); - if (!context) throw new Error("Canvas is unavailable"); - await pdfPage.render({ canvas, canvasContext: context, viewport, background: "white" }).promise; - const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; - const predicates = { - red: (r: number, g: number, b: number) => r > 220 && g < 50 && b < 50, - green: (r: number, g: number, b: number) => r < 50 && g > 220 && b < 50, - blue: (r: number, g: number, b: number) => r < 50 && g < 50 && b > 220, - yellow: (r: number, g: number, b: number) => r > 220 && g > 220 && b < 50, - }; - const result = {} as Record; - for (const [name, predicate] of Object.entries(predicates) as Array< - [keyof typeof predicates, (r: number, g: number, b: number) => boolean] - >) { - let left = Number.POSITIVE_INFINITY; - let right = Number.NEGATIVE_INFINITY; - let top = Number.POSITIVE_INFINITY; - let bottom = Number.NEGATIVE_INFINITY; - let count = 0; - for (let index = 0; index < pixels.length; index += 4) { - if (!predicate(pixels[index] ?? 0, pixels[index + 1] ?? 0, pixels[index + 2] ?? 0)) continue; - const x = (index / 4) % canvas.width; - const y = Math.floor(index / 4 / canvas.width); - left = Math.min(left, x); - right = Math.max(right, x); - top = Math.min(top, y); - bottom = Math.max(bottom, y); - count++; - } - result[name] = count > 0 ? { left, right, top, bottom, count } : null; - } - return result; - } finally { - await task.destroy(); - } - }, Array.from(bytes)); -} - -async function exportBuilderFile(page: Page, testInfo: TestInfo, buttonName: "Download JSON" | "Download PDF") { - await openSidebarSection(page, "Export"); - await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click(); - const pending = page.waitForEvent("download"); - await page.getByRole("button", { name: buttonName, exact: true }).click(); - const download = await pending; - const path = testInfo.outputPath(download.suggestedFilename()); - await download.saveAs(path); - await page.keyboard.press("Escape"); - return path; -} - -test("uploads and persists full Contain image with browser/server PDF parity", async ({ - authPage: page, - account, -}, info) => { - test.setTimeout(120_000); - await page.setViewportSize({ width: 1920, height: 950 }); - await installPdfJsRoute(page); - await createSampleResumeFromDashboard(page, info); - const resumeId = page.url().split("/").at(-1); - if (!resumeId) throw new Error("Missing resume id"); - - await openSidebarSection(page, "Picture"); - await page.getByRole("button", { name: "Contain", exact: true }).click(); - await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true"); - await page.getByRole("button", { name: "Undo", exact: true }).click(); - await expect(page.getByRole("button", { name: "Cover", exact: true })).toHaveAttribute("aria-pressed", "true"); - await page.getByRole("button", { name: "Redo", exact: true }).click(); - await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true"); - const dataUrl = await createMarkedLandscape(page); - const inputBytes = markedLandscapePng(dataUrl); - await info.attach("marked-landscape.png", { body: inputBytes, contentType: "image/png" }); - await page.locator('#sidebar-picture input[type="file"]').setInputFiles({ - name: "marked-landscape.png", - mimeType: "image/png", - buffer: inputBytes, - }); - await expect(page.getByRole("dialog", { name: "Crop picture" })).toHaveCount(0); - const pictureUrl = page.locator("#sidebar-picture input[name=url]"); - await expect(pictureUrl).toHaveValue(/\/uploads\//); - const sidebarPreview = page.getByRole("button", { name: "Delete picture" }).locator("img"); - await expect(sidebarPreview).toBeVisible(); - await expect(sidebarPreview).toHaveCSS("object-fit", "contain"); - const storedUrl = await pictureUrl.inputValue(); - const storedRequestUrl = storedUrl.startsWith("/uploads/") ? `/api${storedUrl}` : storedUrl; - const storedResponse = await page.request.get(storedRequestUrl); - expect(storedResponse.ok()).toBe(true); - expect(storedResponse.headers()["content-type"]).toMatch(/^image\//); - const storedBytes = await storedResponse.body(); - await info.attach("stored-landscape", { body: storedBytes, contentType: storedResponse.headers()["content-type"] }); - const storedGeometry = await page.evaluate(async (url) => { - const image = new Image(); - image.src = url; - await image.decode(); - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const context = canvas.getContext("2d"); - if (!context) throw new Error("Canvas is unavailable"); - context.drawImage(image, 0, 0); - const sample = (x: number, y: number) => [...context.getImageData(x, y, 1, 1).data.slice(0, 3)]; - return { - width: image.naturalWidth, - height: image.naturalHeight, - left: sample(Math.floor(image.naturalWidth * 0.02), Math.floor(image.naturalHeight / 2)), - right: sample(Math.floor(image.naturalWidth * 0.98), Math.floor(image.naturalHeight / 2)), - top: sample(Math.floor(image.naturalWidth / 4), Math.floor(image.naturalHeight * 0.02)), - bottom: sample(Math.floor(image.naturalWidth / 4), Math.floor(image.naturalHeight * 0.98)), - }; - }, storedRequestUrl); - expect(storedGeometry).toMatchObject({ width: 800, height: 600 }); - expect(storedGeometry.left[0]).toBeGreaterThan(220); - expect(storedGeometry.left[1]).toBeLessThan(50); - expect(storedGeometry.right[0]).toBeLessThan(50); - expect(storedGeometry.right[1]).toBeGreaterThan(220); - expect(storedGeometry.top[2]).toBeGreaterThan(220); - expect(storedGeometry.bottom[0]).toBeGreaterThan(220); - expect(storedGeometry.bottom[1]).toBeGreaterThan(220); - - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - let slug = ""; - try { - await expect - .poll(async () => { - const result = await pool.query<{ data: { picture: { fit?: string; url?: string } }; slug: string }>( - "select data, slug from resume where id = $1", - [resumeId], - ); - slug = result.rows[0]?.slug ?? ""; - return result.rows[0]?.data.picture; - }) - .toMatchObject({ fit: "contain", url: storedUrl }); - - await page.getByRole("button", { name: "Cover", exact: true }).click(); - await page.getByRole("button", { name: "Contain", exact: true }).click(); - expect(await pictureUrl.inputValue()).toBe(storedUrl); - - await page.reload(); - await openSidebarSection(page, "Picture"); - await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true"); - await expect(page.locator("#sidebar-picture input[name=url]")).toHaveValue(storedUrl); - - await pool.query("update resume set is_public = true, updated_at = now() where id = $1", [resumeId]); - } finally { - await pool.end(); - } - - const jsonPath = await exportBuilderFile(page, info, "Download JSON"); - const exported = JSON.parse(await readFile(jsonPath, "utf8")) as { picture: { fit?: string; url?: string } }; - expect(exported.picture).toMatchObject({ fit: "contain", url: storedUrl }); - - const browserPdfPath = await exportBuilderFile(page, info, "Download PDF"); - const browserPdf = new Uint8Array(await readFile(browserPdfPath)); - const serverResponse = await page.request.get(`/api/resumes/${account.username}/${slug}/pdf`); - expect(serverResponse.ok()).toBe(true); - const serverPdf = new Uint8Array(await serverResponse.body()); - const browserMarkers = await markerMetricsFromPdf(page, browserPdf); - const serverMarkers = await markerMetricsFromPdf(page, serverPdf); - for (const name of ["red", "green", "blue", "yellow"] as const) { - expect(browserMarkers[name]?.count).toBeGreaterThan(100); - expect(serverMarkers[name]?.count).toBeGreaterThan(100); - expect(serverMarkers[name]).toEqual(browserMarkers[name]); - } - await info.attach("pdf-marker-metrics", { - body: JSON.stringify({ browserMarkers, serverMarkers }), - contentType: "application/json", - }); -}); diff --git a/tests/e2e/specs/preview-raster-direction.spec.ts b/tests/e2e/specs/preview-raster-direction.spec.ts deleted file mode 100644 index 4461dde03..000000000 --- a/tests/e2e/specs/preview-raster-direction.spec.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Page } from "@playwright/test"; -import { writeFile } from "node:fs/promises"; -import { createRequire } from "node:module"; -import { Pool } from "pg"; -import { expect, test } from "../fixtures/test"; - -type PdfCaptureWindow = Window & { resumePdfBytes?: number[] }; - -async function capturePdfBytes(page: Page) { - await page.addInitScript(() => { - const read = Blob.prototype.arrayBuffer; - Blob.prototype.arrayBuffer = async function () { - const buffer = await read.call(this); - const bytes = new Uint8Array(buffer); - if (String.fromCharCode(...bytes.subarray(0, 5)) === "%PDF-") { - (window as PdfCaptureWindow).resumePdfBytes = Array.from(bytes); - } - return buffer; - }; - }); -} - -async function compareWithPdfReference(page: Page) { - const require = createRequire(`${process.cwd()}/package.json`); - await page.route("**/__pdf_reference/*", async (route) => { - const worker = new URL(route.request().url()).pathname.endsWith("worker.mjs"); - await route.fulfill({ - contentType: "text/javascript", - path: require.resolve(`pdfjs-dist/legacy/build/${worker ? "pdf.worker.mjs" : "pdf.mjs"}`), - }); - }); - - return page.evaluate(async () => { - const canvas = document.querySelector('[aria-hidden="false"] canvas'); - const bytes = (window as PdfCaptureWindow).resumePdfBytes; - if (!canvas || !bytes) throw new Error("Missing rendered preview or original PDF bytes"); - const actual = canvas.getContext("2d")?.getImageData(0, 0, canvas.width, canvas.height); - if (!actual) throw new Error("Missing preview pixels"); - const moduleUrl = `${location.origin}/__pdf_reference/pdf.mjs`; - const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(moduleUrl); - pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__pdf_reference/worker.mjs`; - const task = pdfjs.getDocument({ data: Uint8Array.from(bytes) }); - try { - const pdf = await task.promise; - const pdfPage = await pdf.getPage(1); - const reference = document.createElement("canvas"); - reference.width = canvas.width; - reference.height = canvas.height; - // Match inherited font rasterization styles as well as the original bitmap size. - canvas.parentElement?.append(reference); - const context = reference.getContext("2d"); - if (!context) throw new Error("Missing reference context"); - // PDF coordinates are physical. Reference must not inherit the interface's text direction. - context.direction = "ltr"; - await pdfPage.render({ - canvas: reference, - canvasContext: context, - viewport: pdfPage.getViewport({ scale: 1 }), - transform: [4, 0, 0, 4, 0, 0], - annotationMode: pdfjs.AnnotationMode.DISABLE, - background: "white", - }).promise; - const expected = context.getImageData(0, 0, reference.width, reference.height); - reference.remove(); - let differentPixels = 0; - let inkPixels = 0; - for (let i = 0; i < actual.data.length; i += 4) { - if (expected.data[i] !== 255 || expected.data[i + 1] !== 255 || expected.data[i + 2] !== 255) inkPixels++; - if ( - actual.data[i] !== expected.data[i] || - actual.data[i + 1] !== expected.data[i + 1] || - actual.data[i + 2] !== expected.data[i + 2] || - actual.data[i + 3] !== expected.data[i + 3] - ) { - differentPixels++; - } - } - return { - differentPixels, - actualPng: canvas.toDataURL(), - referencePng: reference.toDataURL(), - inkPixels, - pageDirection: canvas.closest("[dir]")?.getAttribute("dir"), - uiDirection: document.documentElement.dir, - }; - } finally { - await task.destroy(); - } - }); -} - -for (const uiLanguage of ["English", "Arabic"]) { - for (const resumeLocale of ["en-US", "ar-SA"]) { - test(`preserves PDF glyph pixels with ${uiLanguage} UI and ${resumeLocale} resume`, async ({ - authPage: page, - }, info) => { - test.setTimeout(60_000); - await page.setViewportSize({ width: 1920, height: 950 }); - await capturePdfBytes(page); - await page.goto("/dashboard/resumes"); - await page.getByText("Create a new resume", { exact: true }).click(); - const dialog = page.getByRole("dialog", { name: "Create a new resume" }); - await dialog.getByLabel("Name", { exact: true }).fill("Preview direction fixture"); - await dialog.getByRole("button", { name: "Create", exact: true }).click(); - await page.waitForURL(/\/builder\/.+/); - const builderUrl = page.url(); - const resumeId = builderUrl.split("/").at(-1); - await page.goto("/dashboard/resumes"); - const pool = new Pool({ connectionString: process.env.DATABASE_URL }); - try { - await pool.query( - `update resume set data = jsonb_set(jsonb_set(jsonb_set(data, '{metadata,page,locale}', $2::jsonb), '{basics,name}', '"Preview direction fixture"'::jsonb), '{basics,headline}', '"Software مهندس Engineer"'::jsonb) where id = $1`, - [resumeId, JSON.stringify(resumeLocale)], - ); - } finally { - await pool.end(); - } - await page.goto(builderUrl); - if (uiLanguage === "Arabic") { - await page.getByRole("button", { name: "Account menu", exact: true }).click(); - await page.getByRole("menuitem", { name: "Language", exact: true }).click(); - await page.getByRole("menuitemradio", { name: "Arabic", exact: true }).click(); - await expect(page.locator("html")).toHaveAttribute("dir", "rtl"); - await page.reload(); - } - await expect(page.locator('[aria-hidden="false"] canvas').first()).toBeVisible(); - const result = await compareWithPdfReference(page); - const { actualPng, referencePng, ...metrics } = result; - await writeFile( - info.outputPath("actual.png"), - Buffer.from(actualPng.slice(actualPng.indexOf(",") + 1), "base64"), - ); - await writeFile( - info.outputPath("reference.png"), - Buffer.from(referencePng.slice(referencePng.indexOf(",") + 1), "base64"), - ); - await info.attach("raster-comparison", { body: JSON.stringify(metrics), contentType: "application/json" }); - expect(result.inkPixels).toBeGreaterThan(1000); - expect(result.uiDirection).toBe(uiLanguage === "Arabic" ? "rtl" : "ltr"); - expect(result.pageDirection).toBe(resumeLocale === "ar-SA" ? "rtl" : "ltr"); - expect(result.differentPixels).toBe(0); - }); - } -} diff --git a/tests/e2e/specs/resume-lifecycle.spec.ts b/tests/e2e/specs/resume-lifecycle.spec.ts deleted file mode 100644 index 03c6944ac..000000000 --- a/tests/e2e/specs/resume-lifecycle.spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -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); -}); diff --git a/tests/e2e/specs/section-date-sorting.spec.ts b/tests/e2e/specs/section-date-sorting.spec.ts index 4697765a8..9c2cfba53 100644 --- a/tests/e2e/specs/section-date-sorting.spec.ts +++ b/tests/e2e/specs/section-date-sorting.spec.ts @@ -1,6 +1,5 @@ import { Pool } from "pg"; import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume"; -import { resumeIdFromPage } from "../fixtures/semantic-css"; import { expect, test } from "../fixtures/test"; type ExperienceItem = { @@ -70,7 +69,8 @@ test("sorts Experience once while preserving undo, persistence, later edits, and }, testInfo) => { test.setTimeout(90_000); await createSampleResumeFromDashboard(page, testInfo); - const resumeId = resumeIdFromPage(page); + const resumeId = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1]; + if (!resumeId) throw new Error(`Expected a builder URL, received ${page.url()}.`); const authoredItems = [ experienceItem("unknown-sort", "Mystery Co", "Recently"), experienceItem("older-sort", "Older Co", "2018 - 2020"), diff --git a/tests/e2e/specs/semantic-css/color-picker.spec.ts b/tests/e2e/specs/semantic-css/color-picker.spec.ts deleted file mode 100644 index d3708fc4e..000000000 --- a/tests/e2e/specs/semantic-css/color-picker.spec.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { createSemanticCssResume, readStylesheetSource, seedSemanticCssResume } from "../../fixtures/semantic-css"; -import { expect, test } from "../../fixtures/test"; - -test("@semantic-css keeps color picker edits and swatches aligned through dismissal and undo", async ({ - authPage: page, -}, testInfo) => { - test.setTimeout(60_000); - const errors: string[] = []; - page.on("pageerror", (error) => errors.push(error.message)); - const resumeId = await createSemanticCssResume(page, testInfo); - const source = "@version 1;\nsection {\n\tcolor: #f00;\n\tbackground-color: #fff;\n}"; - await seedSemanticCssResume(page, resumeId, { - stylesheet: { mode: "semantic", source: { languageVersion: 1, text: source } }, - }); - const swatches = page.locator(".semantic-css-color-swatch"); - const trigger = page.locator("[data-semantic-css-color-picker-trigger]"); - const picker = page.getByRole("dialog").filter({ has: page.getByText("Presets", { exact: true }) }); - await expect(swatches).toHaveCount(2); - await page.getByRole("button", { name: "Edit color #f00", exact: true }).click(); - - const first = source.replace("#f00", "#000000"); - await picker.getByRole("button", { name: "Use color rgba(0, 0, 0, 1)", exact: true }).click(); - await expect.poll(() => readStylesheetSource(page)).toBe(first); - await expect(page.getByText("Presets", { exact: true })).toBeVisible(); - - const second = source.replace("#f00", "#e7000b"); - await picker.getByRole("button", { name: "Use color rgba(231, 0, 11, 1)", exact: true }).click(); - await expect.poll(() => readStylesheetSource(page)).toBe(second); - await expect(swatches).toHaveCount(2); - await page.keyboard.press("Escape"); - await expect(page.getByText("Presets", { exact: true })).toHaveCount(0); - await expect(trigger).toHaveCount(0); - await page.getByRole("button", { name: "Edit color #e7000b", exact: true }).click(); - await expect(page.getByText("Presets", { exact: true })).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(trigger).toHaveCount(0); - - await page.getByRole("button", { name: "Undo stylesheet edit", exact: true }).click(); - // Rapid presets can coalesce into one undo step; either complete prior stylesheet is valid. - await expect.poll(async () => [source, first].includes(await readStylesheetSource(page))).toBe(true); - await expect(trigger).toHaveCount(0); - await page.getByRole("button", { name: "Redo stylesheet edit", exact: true }).click(); - await expect.poll(() => readStylesheetSource(page)).toBe(second); - await expect(swatches).toHaveCount(2); - - await page.getByRole("button", { name: "Edit color #fff", exact: true }).click(); - await picker.getByRole("button", { name: "Use color rgba(21, 93, 252, 1)", exact: true }).click(); - await expect.poll(() => readStylesheetSource(page)).toBe(second.replace("#fff", "#155dfc")); - await page.keyboard.press("Escape"); - await expect(trigger).toHaveCount(0); - await expect(swatches).toHaveCount(2); - await expect(page.getByRole("button", { name: "Edit color #e7000b", exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Edit color #155dfc", exact: true })).toBeVisible(); - await page.getByRole("button", { name: "Open focus mode", exact: true }).click(); - await expect(page.getByRole("button", { name: "Edit color #e7000b", exact: true })).toBeInViewport(); - await expect(page.getByRole("button", { name: "Edit color #155dfc", exact: true })).toBeInViewport(); - await expect(page.getByText("Saved", { exact: true })).toBeVisible(); - await page.screenshot({ path: testInfo.outputPath("color-picker-after-undo.png"), animations: "disabled" }); - expect(errors).toEqual([]); -}); - -for (const inputColor of ["#FF000080", "red", "rgb(100% 0% 0% / 50%)"]) { - test(`@semantic-css preserves alpha when editing ${inputColor} and reopening hex colors`, async ({ - authPage: page, - }, testInfo) => { - const resumeId = await createSemanticCssResume(page, testInfo); - const source = `@version 1;\nsection { color: ${inputColor}; background-color: #fff; }`; - await seedSemanticCssResume(page, resumeId, { - stylesheet: { mode: "semantic", source: { languageVersion: 1, text: source } }, - }); - await page.getByRole("button", { name: `Edit color ${inputColor}`, exact: true }).click(); - const picker = page.getByRole("dialog").filter({ has: page.getByText("Presets", { exact: true }) }); - await expect(picker).toBeVisible(); - expect(await readStylesheetSource(page)).toBe(source); - const alpha = picker.locator(".w-color-alpha:not(.w-color-hue)"); - const bounds = await alpha.boundingBox(); - if (!bounds) throw new Error("Missing alpha control"); - await alpha.click({ position: { x: 0, y: bounds.height / 2 } }); - await expect.poll(() => readStylesheetSource(page)).toBe(source.replace(inputColor, "#ff000000")); - await page.keyboard.press("Escape"); - await page.getByRole("button", { name: "Edit color #ff000000", exact: true }).click(); - await expect(picker).toBeVisible(); - await alpha.click({ position: { x: bounds.width / 2, y: bounds.height / 2 } }); - await expect - .poll(async () => { - const value = await readStylesheetSource(page); - const match = value.match(/color: #ff0000([\da-f]{2});/); - return match ? Number.parseInt(match[1] ?? "", 16) : -1; - }) - .toBeGreaterThanOrEqual(127); - const halfTransparent = await readStylesheetSource(page); - // Browser pointer coordinates round to pixels; the midpoint can land on either adjacent alpha byte. - expect(halfTransparent).toMatch(/color: #ff0000(?:7f|80);/); - expect(halfTransparent).toContain("background-color: #fff;"); - await page.keyboard.press("Escape"); - await page.getByRole("button", { name: /^Edit color #ff0000(?:7f|80)$/ }).click(); - await expect(picker).toBeVisible(); - expect(await readStylesheetSource(page)).toBe(halfTransparent); - }); -} diff --git a/tests/e2e/specs/semantic-css/default-mode.spec.ts b/tests/e2e/specs/semantic-css/default-mode.spec.ts deleted file mode 100644 index b754f99b7..000000000 --- a/tests/e2e/specs/semantic-css/default-mode.spec.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createSemanticCssResume, readStylesheetSource } from "../../fixtures/semantic-css"; -import { expect, test } from "../../fixtures/test"; - -test("@semantic-css starts new resumes in semantic mode", async ({ authPage: page }, testInfo) => { - await createSemanticCssResume(page, testInfo); - - await expect(page.getByText("Converted stylesheet draft", { exact: true })).toHaveCount(0); - await expect.poll(() => readStylesheetSource(page)).toBe("@version 1;\n"); - await expect(page.getByText("Valid", { exact: true }).filter({ visible: true })).toBeVisible(); -}); diff --git a/tests/e2e/specs/semantic-css/legacy-conversion.spec.ts b/tests/e2e/specs/semantic-css/legacy-conversion.spec.ts deleted file mode 100644 index da3cc51f0..000000000 --- a/tests/e2e/specs/semantic-css/legacy-conversion.spec.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { updateSemanticCssFixture } from "../../fixtures/db"; -import { activateStylesheet, createSemanticCssResume, openSemanticCssEditor } from "../../fixtures/semantic-css"; -import { expect, test } from "../../fixtures/test"; - -test.setTimeout(120_000); - -test("@semantic-css converts legacy rules into an inactive draft before activation", async ({ - authPage: page, -}, testInfo) => { - const resumeId = await createSemanticCssResume(page, testInfo); - await updateSemanticCssFixture(resumeId, { legacyStyleRule: true }); - await page.reload(); - await openSemanticCssEditor(page); - - await expect(page.getByText("Converted stylesheet draft", { exact: true })).toBeVisible(); - await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toContainText("@version 1;"); - await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toContainText( - 'section[type="experience"]', - ); - await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toContainText("padding-left: 7pt;"); - await expect(page.getByText(/^Ready to activate(?: with warnings)?$/)).toBeVisible({ timeout: 30_000 }); - - await activateStylesheet(page); - await page.reload(); - await expect(page.getByText("Converted stylesheet draft", { exact: true })).toHaveCount(0); - await expect(page.getByText("Valid", { exact: true }).filter({ visible: true })).toBeVisible(); -}); diff --git a/tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts b/tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts deleted file mode 100644 index 3f858434c..000000000 --- a/tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { readSemanticCssFixture } from "../../fixtures/db"; -import { - createSemanticCssResume, - openSemanticCssEditor, - PORTABLE_STYLESHEET, - replaceStylesheet, - seedSemanticCssResume, - switchTemplate, - waitForStablePreview, -} from "../../fixtures/semantic-css"; -import { expect, test } from "../../fixtures/test"; - -test.setTimeout(180_000); - -const PORTABLE_MARKERS = { - exactItemField: [161, 193, 129, 255], - exactSection: [254, 215, 102, 255], - groupSelector: [42, 183, 202, 255], - header: [254, 74, 73, 255], - mediaQuery: [110, 231, 183, 255], - pagination: [212, 165, 165, 255], - placement: [247, 140, 107, 255], - richText: [179, 136, 235, 255], - systemVariable: [0, 132, 209, 255], - templatePart: [255, 0, 255, 255], -} as const; - -const PORTABLE_ACCEPTANCE_STYLESHEET = `${PORTABLE_STYLESHEET} - -page { - background-color: var(--accent); -} - -header { - background-color: rgb(254, 74, 73); -} - -section:is([type="experience"], [type="education"]) > section-heading { - background-color: rgb(42, 183, 202); -} - -section[id="projects"] > section-items > item { - background-color: rgb(254, 215, 102); -} - -section[id="experience"] item[id="experience-item-2"] field[name="period"] { - color: rgb(161, 193, 129); -} - -rich-text list-item > list-item-content { - color: rgb(179, 136, 235); -} - -region[placement="sidebar"] section { - background-color: rgb(247, 140, 107); -} - -section[type="projects"] { - background-color: rgb(212, 165, 165); - padding: 12pt; -} - -@media (max-width: 600pt) { - region[placement="sidebar"] section-heading { - background-color: rgb(110, 231, 183); - } -} - -resume[template="azurill"] template-part[name="timeline-dot"] { - background-color: rgb(255, 0, 255); -} -`; - -type PortableMarker = keyof typeof PORTABLE_MARKERS; - -const BREAK_INSIDE_DIRECTIVE = "\tbreak-inside: avoid;\n"; -const MIN_PRESENCE_AHEAD_DIRECTIVE = "\t-resume-min-presence-ahead: 24pt;\n"; -const WITHOUT_PAGINATION_DIRECTIVES = PORTABLE_ACCEPTANCE_STYLESHEET.replace(BREAK_INSIDE_DIRECTIVE, "").replace( - MIN_PRESENCE_AHEAD_DIRECTIVE, - "", -); -const BREAK_INSIDE_ONLY_STYLESHEET = PORTABLE_ACCEPTANCE_STYLESHEET.replace(MIN_PRESENCE_AHEAD_DIRECTIVE, ""); - -const GENERIC_PORTABLE_MARKERS = [ - "exactItemField", - "exactSection", - "groupSelector", - "header", - "mediaQuery", - "pagination", - "placement", - "richText", -] as const satisfies readonly PortableMarker[]; - -async function countPortableMarkersByPage(page: Parameters[0]) { - await waitForStablePreview(page); - return page.locator('canvas[aria-label^="Resume page "]').evaluateAll((elements, markers) => { - const markerEntries = Object.entries(markers) as Array<[PortableMarker, readonly number[]]>; - const markerByColor = new Map( - markerEntries.map(([name, color]) => [ - (((color[0] << 24) | (color[1] << 16) | (color[2] << 8) | color[3]) >>> 0).toString(), - name, - ]), - ); - - return elements - .filter((element) => !element.closest('[aria-hidden="true"]')) - .map((element) => { - const canvas = element as HTMLCanvasElement; - const context = canvas.getContext("2d"); - if (!context) throw new Error("Expected a 2D preview canvas."); - const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; - const counts = Object.fromEntries(markerEntries.map(([name]) => [name, 0])) as Record; - - for (let index = 0; index < pixels.length; index += 4) { - const key = - ((pixels[index] << 24) | (pixels[index + 1] << 16) | (pixels[index + 2] << 8) | pixels[index + 3]) >>> 0; - const marker = markerByColor.get(key.toString()); - if (marker) counts[marker] += 1; - } - - return counts; - }); - }, PORTABLE_MARKERS); -} - -async function waitForPortableValid(page: Parameters[0]) { - await expect( - page - .getByText(/^Valid(?: with warnings)?$/) - .filter({ visible: true }) - .last(), - ).toBeVisible({ - timeout: 30_000, - }); -} - -async function applyPortableStylesheet( - page: Parameters[0], - resumeId: string, - source: string, -) { - await openSemanticCssEditor(page); - await replaceStylesheet(page, source); - await expect - .poll(async () => (await readSemanticCssFixture(resumeId)).stylesheet?.source.text, { timeout: 30_000 }) - .toBe(source); - await waitForPortableValid(page); -} - -test("@semantic-css applies every portable selector behavior across Onyx, Azurill, and Ditto", async ({ - authPage: page, -}, testInfo) => { - const resumeId = await createSemanticCssResume(page, testInfo); - await seedSemanticCssResume(page, resumeId, { - portableLayout: "balanced", - experienceItemId: "experience-item-2", - hidePicture: true, - }); - await applyPortableStylesheet(page, resumeId, PORTABLE_ACCEPTANCE_STYLESHEET); - await page.reload(); - await openSemanticCssEditor(page); - await waitForPortableValid(page); - - for (const template of ["Onyx", "Azurill", "Ditto"]) { - await switchTemplate(page, template); - const pages = await countPortableMarkersByPage(page); - console.info(`PORTABLE_MARKER_EVIDENCE ${template} ${JSON.stringify(pages)}`); - for (const marker of GENERIC_PORTABLE_MARKERS) { - expect( - pages.reduce((total, page) => total + page[marker], 0), - `${template} should render the ${marker} sentinel`, - ).toBeGreaterThan(20); - } - expect( - pages.reduce((total, page) => total + page.systemVariable, 0), - `${template} should resolve the system variable sentinel`, - ).toBeGreaterThan(10_000); - if (template === "Azurill") { - expect( - pages.reduce((total, page) => total + page.templatePart, 0), - "Azurill should render the template-part sentinel", - ).toBeGreaterThan(5); - } - } -}); - -test("@semantic-css break-inside keeps a portable project section together", async ({ authPage: page }, testInfo) => { - const resumeId = await createSemanticCssResume(page, testInfo); - await seedSemanticCssResume(page, resumeId, { - portableLayout: "pagination-stress", - experienceItemId: "experience-item-2", - hidePicture: true, - }); - await switchTemplate(page, "Onyx"); - - await applyPortableStylesheet(page, resumeId, WITHOUT_PAGINATION_DIRECTIVES); - const withoutBreakInside = await countPortableMarkersByPage(page); - expect(withoutBreakInside.reduce((total, page) => total + page.pagination, 0)).toBeGreaterThan(20); - const withoutBreakInsideDistribution = withoutBreakInside.map((page) => page.pagination); - - await applyPortableStylesheet(page, resumeId, BREAK_INSIDE_ONLY_STYLESHEET); - const breakInsideOnlyDistribution = (await countPortableMarkersByPage(page)).map((page) => page.pagination); - - console.info( - `PORTABLE_PAGINATION_EVIDENCE ${JSON.stringify({ - breakInside: { - withoutDirective: withoutBreakInsideDistribution, - withDirective: breakInsideOnlyDistribution, - }, - })}`, - ); - expect(breakInsideOnlyDistribution, "break-inside: avoid must independently change project pagination").not.toEqual( - withoutBreakInsideDistribution, - ); -}); diff --git a/tests/e2e/specs/semantic-css/preservation.spec.ts b/tests/e2e/specs/semantic-css/preservation.spec.ts deleted file mode 100644 index de7569a49..000000000 --- a/tests/e2e/specs/semantic-css/preservation.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { readSemanticCssFixture, updateSemanticCssFixture } from "../../fixtures/db"; -import { createSampleResumeFromDashboard, openSidebarSection } from "../../fixtures/resume"; -import { resumeIdFromPage } from "../../fixtures/semantic-css"; -import { expect, test } from "../../fixtures/test"; - -test("@semantic-css preserves a persisted stylesheet through an old-client resume update", async ({ - authPage: page, -}, testInfo) => { - await createSampleResumeFromDashboard(page, testInfo); - const resumeId = resumeIdFromPage(page); - const source = { languageVersion: 1, text: "@version 1;\nname { color: #dc2626; }\n" }; - await updateSemanticCssFixture(resumeId, { stylesheet: { mode: "semantic", source } }); - await page.reload(); - - await openSidebarSection(page, "Basics"); - await page.getByLabel("Headline").fill("Preserves semantic stylesheet"); - await expect - .poll(() => readSemanticCssFixture(resumeId)) - .toMatchObject({ - headline: "Preserves semantic stylesheet", - stylesheet: { - mode: "semantic", - source, - }, - }); -}); diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts b/tests/e2e/specs/semantic-css/template-visual.spec.ts deleted file mode 100644 index 6858d5df2..000000000 --- a/tests/e2e/specs/semantic-css/template-visual.spec.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { ACTIVE_PREVIEW_PAGE_SELECTOR, readPreviewPageDataUrl } from "../../fixtures/preview"; -import { - createSemanticCssResume, - PORTABLE_STYLESHEET, - seedSemanticCssResume, - switchTemplate, -} from "../../fixtures/semantic-css"; -import { expect, test } from "../../fixtures/test"; - -const templates = [ - "Azurill", - "Bronzor", - "Chikorita", - "Ditgar", - "Ditto", - "Gengar", - "Glalie", - "Kakuna", - "Lapras", - "Leafish", - "Meowth", - "Onyx", - "Pikachu", - "Rhyhorn", - "Scizor", -] as const; - -test.setTimeout(240_000); - -async function previewClip(page: Parameters[0]) { - let previous: string | undefined; - await expect - .poll( - async () => { - const current = await page.evaluate(readPreviewPageDataUrl, ACTIVE_PREVIEW_PAGE_SELECTOR); - const stable = current === previous; - previous = current; - return stable; - }, - { intervals: [250], timeout: 15_000 }, - ) - .toBe(true); - const dimensions = await page.evaluate((selector) => { - const source = document.querySelector(selector); - if (!source) throw new Error("Expected an active first-page canvas."); - const box = source.getBoundingClientRect(); - const width = Math.ceil(box.width); - const height = Math.ceil(box.height); - document.querySelector("[data-semantic-css-visual-page]")?.remove(); - const surface = document.createElement("div"); - surface.dataset.semanticCssVisualPage = ""; - Object.assign(surface.style, { - background: "white", - height: "632px", - left: "0", - position: "fixed", - top: "0", - width: "447px", - zIndex: "2147483647", - }); - const snapshot = document.createElement("canvas"); - snapshot.width = source.width; - snapshot.height = source.height; - snapshot.style.width = "447px"; - snapshot.style.height = "632px"; - const context = snapshot.getContext("2d"); - if (!context) throw new Error("Expected a 2D canvas context for the visual snapshot."); - context.drawImage(source, 0, 0); - surface.append(snapshot); - document.body.append(surface); - return { width, height }; - }, ACTIVE_PREVIEW_PAGE_SELECTOR); - const { width, height } = dimensions; - expect({ width, height }).toEqual({ width: 447, height: 632 }); - const surface = page.locator("[data-semantic-css-visual-page]"); - const surfaceBox = await surface.boundingBox(); - if (!surfaceBox) throw new Error("Expected a visible page-only visual snapshot surface."); - const clip = { - x: Math.floor(surfaceBox.x), - y: Math.floor(surfaceBox.y), - width: Math.ceil(surfaceBox.x + surfaceBox.width) - Math.floor(surfaceBox.x), - height: Math.ceil(surfaceBox.y + surfaceBox.height) - Math.floor(surfaceBox.y), - }; - expect({ width: clip.width, height: clip.height }).toEqual({ width: 447, height: 632 }); - return clip; -} - -test("@semantic-css renders deterministic first-page previews for all templates", async ({ - authPage: page, -}, testInfo) => { - const resumeId = await createSemanticCssResume(page, testInfo); - const portable = { languageVersion: 1, text: PORTABLE_STYLESHEET }; - await seedSemanticCssResume(page, resumeId, { - basicsName: "Semantic CSS Acceptance", - experienceItemId: "experience-item-2", - hidePicture: true, - stylesheet: { mode: "semantic", source: portable }, - }); - - for (const template of templates) { - await switchTemplate(page, template); - await expect(page).toHaveScreenshot(`${template.toLowerCase()}-first-page.png`, { - animations: "disabled", - caret: "hide", - clip: await previewClip(page), - maxDiffPixelRatio: 0, - }); - await page.locator("[data-semantic-css-visual-page]").evaluate((element) => element.remove()); - } -}); diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/azurill-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/azurill-first-page-chromium-linux.png deleted file mode 100644 index 4ff5ec229..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/azurill-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/bronzor-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/bronzor-first-page-chromium-linux.png deleted file mode 100644 index 4e6f24363..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/bronzor-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/chikorita-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/chikorita-first-page-chromium-linux.png deleted file mode 100644 index a65ce7f7a..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/chikorita-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/ditgar-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/ditgar-first-page-chromium-linux.png deleted file mode 100644 index fc464cd7c..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/ditgar-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/ditto-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/ditto-first-page-chromium-linux.png deleted file mode 100644 index e1189d212..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/ditto-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/gengar-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/gengar-first-page-chromium-linux.png deleted file mode 100644 index 0a1e3514f..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/gengar-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/glalie-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/glalie-first-page-chromium-linux.png deleted file mode 100644 index b59d5f711..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/glalie-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/kakuna-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/kakuna-first-page-chromium-linux.png deleted file mode 100644 index 353ebabc5..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/kakuna-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/lapras-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/lapras-first-page-chromium-linux.png deleted file mode 100644 index 4695d50da..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/lapras-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/leafish-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/leafish-first-page-chromium-linux.png deleted file mode 100644 index 741aa870b..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/leafish-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/meowth-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/meowth-first-page-chromium-linux.png deleted file mode 100644 index ac982443f..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/meowth-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/onyx-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/onyx-first-page-chromium-linux.png deleted file mode 100644 index 7f9f1d57f..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/onyx-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/pikachu-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/pikachu-first-page-chromium-linux.png deleted file mode 100644 index acb1e608c..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/pikachu-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/rhyhorn-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/rhyhorn-first-page-chromium-linux.png deleted file mode 100644 index aaa354859..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/rhyhorn-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/scizor-first-page-chromium-linux.png b/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/scizor-first-page-chromium-linux.png deleted file mode 100644 index 6e1aaa75f..000000000 Binary files a/tests/e2e/specs/semantic-css/template-visual.spec.ts-snapshots/scizor-first-page-chromium-linux.png and /dev/null differ diff --git a/tests/e2e/specs/settings-profile.spec.ts b/tests/e2e/specs/settings-profile.spec.ts deleted file mode 100644 index d70dc25bb..000000000 --- a/tests/e2e/specs/settings-profile.spec.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect, test } from "../fixtures/test"; - -test("updates the profile name from settings and persists it", async ({ authPage: page }) => { - await page.goto("/dashboard/settings/profile"); - - const updatedName = `E2E Renamed ${Date.now()}`; - const nameField = page.getByLabel("Name", { exact: true }); - await expect(nameField).toBeVisible(); - await nameField.fill(updatedName); - await page.getByRole("button", { name: "Save Changes" }).click(); - - // The save round-trips through the API; a reload must show the new value - await page.reload(); - await expect(page.getByLabel("Name", { exact: true })).toHaveValue(updatedName); -}); diff --git a/tests/e2e/specs/thumbnail-resolution.spec.ts b/tests/e2e/specs/thumbnail-resolution.spec.ts deleted file mode 100644 index 4ddc203ca..000000000 --- a/tests/e2e/specs/thumbnail-resolution.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Page } from "@playwright/test"; -import { writeFile } from "node:fs/promises"; -import { createSampleResumeFromDashboard } from "../fixtures/resume"; -import { expect, test } from "../fixtures/test"; - -async function measureThumbnail(page: Page) { - const image = page.locator('a[href^="/builder/"] [style*="background-image"]').first(); - await expect(image).toBeVisible({ timeout: 60_000 }); - return image.evaluate(async (element) => { - const style = getComputedStyle(element); - const url = style.backgroundImage.slice(5, -2); - const blob = await (await fetch(url)).blob(); - const bitmap = await createImageBitmap(blob); - const rect = element.getBoundingClientRect(); - const scale = Math.min(rect.width / bitmap.width, rect.height / bitmap.height); - const result = { - url, - viewport: innerWidth, - dpr: devicePixelRatio, - cssWidth: rect.width, - cssHeight: rect.height, - pngWidth: bitmap.width, - pngHeight: bitmap.height, - coverage: 1 / (scale * devicePixelRatio), - }; - bitmap.close(); - return result; - }); -} - -async function expectSharpThumbnail(page: Page) { - await page.mouse.move(0, 0); - await expect - .poll(async () => (await measureThumbnail(page)).coverage, { timeout: 60_000 }) - .toBeGreaterThanOrEqual(0.99); - return measureThumbnail(page); -} - -test("dashboard thumbnails cover physical pixels in fresh and cached Grid and Compact views", async ({ - authPage, - browser, -}, info) => { - test.setTimeout(180_000); - await createSampleResumeFromDashboard(authPage, info); - const storageState = await authPage.context().storageState(); - const measurements: unknown[] = []; - for (const deviceScaleFactor of [1, 2, 3]) { - const context = await browser.newContext({ - baseURL: String(info.project.use.baseURL), - storageState, - deviceScaleFactor, - viewport: { width: 1440, height: 1000 }, - }); - try { - const page = await context.newPage(); - await page.goto("/dashboard/resumes?view=grid"); - const desktop = await expectSharpThumbnail(page); - measurements.push({ scenario: "fresh-desktop-grid", ...desktop }); - // Immediately below sm, one Grid column is 607 CSS px wide. - await page.setViewportSize({ width: 639, height: 1000 }); - const grown = await expectSharpThumbnail(page); - expect(grown.pngWidth).toBeGreaterThan(desktop.pngWidth); - measurements.push({ scenario: "grown-cached-grid", ...grown }); - await page.screenshot({ path: info.outputPath(`grid-dpr${deviceScaleFactor}.png`) }); - await page.getByRole("tab", { name: "Compact", exact: true }).click(); - const compact = await expectSharpThumbnail(page); - expect(compact.url).toBe(grown.url); - measurements.push({ scenario: "cached-compact", ...compact }); - await page.reload(); - measurements.push({ scenario: "fresh-compact", ...(await expectSharpThumbnail(page)) }); - await page.getByRole("tab", { name: "Grid", exact: true }).click(); - measurements.push({ scenario: "compact-to-grid", ...(await expectSharpThumbnail(page)) }); - await page.reload(); - measurements.push({ scenario: "fresh-mobile-grid", ...(await expectSharpThumbnail(page)) }); - await page.setViewportSize({ width: 390, height: 1000 }); - const phone = await expectSharpThumbnail(page); - measurements.push({ scenario: "phone-grid", ...phone }); - if (deviceScaleFactor === 1) { - const cdp = await context.newCDPSession(page); - await cdp.send("Emulation.setDeviceMetricsOverride", { - width: 390, - height: 1000, - deviceScaleFactor: 3, - mobile: false, - }); - await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBe(3); - const higherDpr = await expectSharpThumbnail(page); - expect(higherDpr.pngWidth).toBeGreaterThan(phone.pngWidth); - measurements.push({ scenario: "changed-dpr-grid", ...higherDpr }); - } - } finally { - await context.close(); - } - } - await writeFile(info.outputPath("measurements.json"), JSON.stringify(measurements, null, 2)); -});