From a6057abd7951a05c47b4785114f3e5eff670066e Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Sat, 5 Sep 2026 19:57:01 -0700 Subject: [PATCH] test(import): reproduce and harden resume imports (#3471) --- apps/web/locales/en-US.po | 17 +- .../src/dialogs/resume/import.dialog.test.tsx | 59 ++- apps/web/src/dialogs/resume/import.test.ts | 13 + apps/web/src/dialogs/resume/import.tsx | 11 +- apps/web/src/dialogs/resume/import.utils.ts | 5 +- tests/e2e/fixtures/import.ts | 297 +++++++++++++++ tests/e2e/specs/import-reproduction.spec.ts | 357 ++++++++++++++++++ 7 files changed, 750 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/fixtures/import.ts create mode 100644 tests/e2e/specs/import-reproduction.spec.ts diff --git a/apps/web/locales/en-US.po b/apps/web/locales/en-US.po index 9936baac5..b084d1124 100644 --- a/apps/web/locales/en-US.po +++ b/apps/web/locales/en-US.po @@ -2939,8 +2939,8 @@ msgid "Imported {0} application(s)." msgstr "Imported {0} application(s)." #: src/dialogs/resume/import.tsx -msgid "Importing from PDF or Word requires a connected AI provider." -msgstr "Importing from PDF or Word requires a connected AI provider." +msgid "Importing from Word requires a connected AI provider." +msgstr "Importing from Word requires a connected AI provider." #: src/dialogs/resume/import.tsx msgid "Importing your resume..." @@ -3650,6 +3650,10 @@ msgstr "No Advertising, No Tracking" msgid "No AI provider connected" msgstr "No AI provider connected" +#: src/dialogs/resume/import.tsx +msgid "No AI provider is connected, so we will read the text out of the PDF here in your browser and fill in what we can recognize. Expect to tidy up the result." +msgstr "No AI provider is connected, so we will read the text out of the PDF here in your browser and fill in what we can recognize. Expect to tidy up the result." + #: src/routes/dashboard/applications/index.tsx msgid "No applications match your filters." msgstr "No applications match your filters." @@ -5931,6 +5935,10 @@ msgstr "This link is missing its protocol." msgid "This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page." msgstr "This may take a few minutes, depending on the response of the AI provider. Please do not close the window or refresh the page." +#: src/dialogs/resume/import.tsx +msgid "This may take a moment. Please do not close the window or refresh the page." +msgstr "This may take a moment. Please do not close the window or refresh the page." + #: src/features/ats-checker/report/report-view.tsx msgid "This measures how faithfully software can extract this file's text. It does not predict whether an application will be rejected, and no tool can. Your file was read in this browser and never uploaded." msgstr "This measures how faithfully software can extract this file's text. It does not predict whether an application will be rejected, and no tool can. Your file was read in this browser and never uploaded." @@ -5943,6 +5951,11 @@ msgstr "This page is in columns, and the text extracts out of order." msgid "This page is very densely packed." msgstr "This page is very densely packed." +#. Error shown when a PDF has no extractable text layer during import +#: src/dialogs/resume/import.tsx +msgid "This PDF has no readable text. It is likely a scan, so there is nothing to import." +msgstr "This PDF has no readable text. It is likely a scan, so there is nothing to import." + #: src/features/ats-checker/tool.tsx msgid "This PDF is password protected. Save an unprotected copy and try again." msgstr "This PDF is password protected. Save an unprotected copy and try again." diff --git a/apps/web/src/dialogs/resume/import.dialog.test.tsx b/apps/web/src/dialogs/resume/import.dialog.test.tsx index a6dbd5fd6..4cfa9c160 100644 --- a/apps/web/src/dialogs/resume/import.dialog.test.tsx +++ b/apps/web/src/dialogs/resume/import.dialog.test.tsx @@ -6,11 +6,13 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { i18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; import { Dialog } from "@reactive-resume/ui/components/dialog"; import { useDialogStore } from "@/dialogs/store"; import { ConfirmDialogProvider } from "@/hooks/use-confirm"; const navigate = vi.hoisted(() => vi.fn()); +const importResume = vi.hoisted(() => vi.fn()); // Stands in for the navigation TanStack Router performs from inside . Keeping it separate // from `navigate` lets a test tell "the router took us away" apart from "the dialog took us away". const routerNavigate = vi.hoisted(() => vi.fn()); @@ -47,9 +49,17 @@ vi.mock("@/features/settings/integrations/hooks/use-has-usable-ai-provider", () useHasUsableAiProvider: () => ({ hasUsableProvider: false, isLoading: false }), })); +vi.mock("@/features/resume/import/pdf-text", () => ({ + extractPdfLines: () => ["Empty MIME PDF Probe"], +})); + +vi.mock("@reactive-resume/import/plain-text", () => ({ + parseResumeText: () => structuredClone(sampleResumeData), +})); + vi.mock("@/libs/orpc/client", () => ({ client: {}, - orpc: { resume: { import: { mutationOptions: () => ({ mutationFn: vi.fn() }) } } }, + orpc: { resume: { import: { mutationOptions: () => ({ mutationFn: importResume }) } } }, })); const { ImportResumeDialog } = await import("./import"); @@ -60,6 +70,7 @@ beforeAll(() => { afterEach(() => { navigate.mockReset(); + importResume.mockReset(); routerNavigate.mockReset(); }); @@ -131,6 +142,52 @@ describe("ImportResumeDialog — PDF without a provider", () => { }); }); +describe("ImportResumeDialog — detected files without MIME metadata", () => { + it("imports a valid current JSON file detected from its extension and shape", async () => { + importResume.mockResolvedValue("imported-resume-id"); + renderDialog(); + const input = document.querySelector('input[type="file"]'); + if (!input) throw new Error("File input not found"); + + const data = structuredClone(sampleResumeData); + data.basics.name = "Empty MIME JSON Probe"; + fireEvent.change(input, { + target: { files: [new File([JSON.stringify(data)], "resume.json", { type: "" })] }, + }); + + await screen.findByText("Reactive Resume (JSON)"); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + + await waitFor(() => { + expect(importResume).toHaveBeenCalledOnce(); + }); + expect(navigate).toHaveBeenCalledWith({ + to: "/builder/$resumeId", + params: { resumeId: "imported-resume-id" }, + }); + }); + + it("imports a valid PDF detected from its magic bytes", async () => { + importResume.mockResolvedValue("imported-resume-id"); + renderDialog(); + const input = document.querySelector('input[type="file"]'); + if (!input) throw new Error("File input not found"); + + fireEvent.change(input, { + target: { + files: [new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "resume.bin", { type: "" })], + }, + }); + + await screen.findByText(/read the text out of the PDF here in your browser/); + fireEvent.click(screen.getByRole("button", { name: "Import" })); + + await waitFor(() => { + expect(importResume).toHaveBeenCalledOnce(); + }); + }); +}); + describe("ImportResumeDialog — Set up a provider", () => { // https://github.com/amruthpillai/reactive-resume/issues/3307 it("confirms before leaving instead of navigating behind the dialog", async () => { diff --git a/apps/web/src/dialogs/resume/import.test.ts b/apps/web/src/dialogs/resume/import.test.ts index f5b0a665a..d791297c1 100644 --- a/apps/web/src/dialogs/resume/import.test.ts +++ b/apps/web/src/dialogs/resume/import.test.ts @@ -18,6 +18,19 @@ describe("detectJsonImportType", () => { ); }); + it("detects a real v4 layout even though v4 metadata also has a page key", () => { + expect( + detectJsonImportType({ + basics: {}, + sections: {}, + metadata: { + layout: [[["experience"], ["skills"]]], + page: { margin: 14, format: "a4", options: { breakLine: false, pageNumbers: false } }, + }, + }), + ).toBe("reactive-resume-v4-json"); + }); + it("returns an empty string for unrecognized shapes", () => { expect(detectJsonImportType({})).toBe(""); expect(detectJsonImportType({ foo: "bar" })).toBe(""); diff --git a/apps/web/src/dialogs/resume/import.tsx b/apps/web/src/dialogs/resume/import.tsx index 9ef752ab4..f875d8581 100644 --- a/apps/web/src/dialogs/resume/import.tsx +++ b/apps/web/src/dialogs/resume/import.tsx @@ -41,7 +41,9 @@ const formSchema = z.discriminatedUnion("type", [ }), z.object({ type: z.literal("pdf"), - file: z.instanceof(File).refine((file) => file.type === "application/pdf", { message: "File must be a PDF" }), + file: z + .instanceof(File) + .refine((file) => file.type === "" || file.type === "application/pdf", { message: "File must be a PDF" }), }), z.object({ type: z.literal("docx"), @@ -49,6 +51,7 @@ const formSchema = z.discriminatedUnion("type", [ .instanceof(File) .refine( (file) => + file.type === "" || file.type === "application/msword" || file.type === "application/vnd.openxmlformats-officedocument.wordprocessingml.document", { message: "File must be a Microsoft Word document" }, @@ -58,19 +61,19 @@ const formSchema = z.discriminatedUnion("type", [ type: z.literal("reactive-resume-json"), file: z .instanceof(File) - .refine((file) => file.type === "application/json", { message: "File must be a JSON file" }), + .refine((file) => file.type === "" || file.type === "application/json", { message: "File must be a JSON file" }), }), z.object({ type: z.literal("reactive-resume-v4-json"), file: z .instanceof(File) - .refine((file) => file.type === "application/json", { message: "File must be a JSON file" }), + .refine((file) => file.type === "" || file.type === "application/json", { message: "File must be a JSON file" }), }), z.object({ type: z.literal("json-resume-json"), file: z .instanceof(File) - .refine((file) => file.type === "application/json", { message: "File must be a JSON file" }), + .refine((file) => file.type === "" || file.type === "application/json", { message: "File must be a JSON file" }), }), ]); diff --git a/apps/web/src/dialogs/resume/import.utils.ts b/apps/web/src/dialogs/resume/import.utils.ts index 426fef37c..2d88de86e 100644 --- a/apps/web/src/dialogs/resume/import.utils.ts +++ b/apps/web/src/dialogs/resume/import.utils.ts @@ -7,10 +7,11 @@ export function detectJsonImportType(parsed: unknown): ImportType { // JSON Resume standard: top-level `basics`, without Reactive Resume's `sections`/`metadata`. if ("basics" in data && !("sections" in data) && !("metadata" in data)) return "json-resume-json"; - // Reactive Resume exports carry `sections` + `metadata`; the current schema's metadata has a `page` key, - // the legacy v4 schema does not. Best-effort guess — the user can override the type below. + // Reactive Resume exports carry `sections` + `metadata`. V4 stores layout as nested arrays, while the current + // schema stores a layout object. Both versions can have `metadata.page`, so that key alone cannot distinguish them. if ("sections" in data || "metadata" in data) { const metadata = data.metadata as Record | undefined; + if (metadata && Array.isArray(metadata.layout)) return "reactive-resume-v4-json"; if (metadata && !("page" in metadata)) return "reactive-resume-v4-json"; return "reactive-resume-json"; } diff --git a/tests/e2e/fixtures/import.ts b/tests/e2e/fixtures/import.ts new file mode 100644 index 000000000..886921350 --- /dev/null +++ b/tests/e2e/fixtures/import.ts @@ -0,0 +1,297 @@ +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/specs/import-reproduction.spec.ts b/tests/e2e/specs/import-reproduction.spec.ts new file mode 100644 index 000000000..48c6a9fe0 --- /dev/null +++ b/tests/e2e/specs/import-reproduction.spec.ts @@ -0,0 +1,357 @@ +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, + }); + } +});