diff --git a/apps/web/src/features/resume/editor/write/dates-field.test.tsx b/apps/web/src/features/resume/editor/write/dates-field.test.tsx index 8ea08fec9..a882dfa42 100644 --- a/apps/web/src/features/resume/editor/write/dates-field.test.tsx +++ b/apps/web/src/features/resume/editor/write/dates-field.test.tsx @@ -4,21 +4,12 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { i18n } from "@lingui/core"; import { I18nProvider } from "@lingui/react"; -import { DatesField, readTypedDate } from "./dates-field"; +import { DatesField } from "./dates-field"; beforeAll(() => { i18n.loadAndActivate({ locale: "en-US", messages: {} }); }); -describe("readTypedDate", () => { - it.each([ - ["", null], - ["soon", undefined], - ])("reads %j as %j", (text, expected) => { - expect(readTypedDate(text, "en-US")).toBe(expected); - }); -}); - describe("DatesField", () => { const renderField = (dates: ResumeDates, onChange = vi.fn()) => { render( @@ -29,12 +20,20 @@ describe("DatesField", () => { return onChange; }; - it("saves a typed date and clears the review note", () => { + it("saves and clears readable dates without committing invalid input or the review note", () => { const onChange = renderField({ start: "2016-06", end: "2018", present: false, raw: "Summer 2016 - 2018" }); expect(screen.getByText(/We read "Summer 2016 - 2018"/)).toBeInTheDocument(); fireEvent.change(screen.getByRole("textbox", { name: "Start" }), { target: { value: "Jul 2016" } }); expect(onChange).toHaveBeenLastCalledWith({ start: "2016-07", end: "2018", present: false }); + + onChange.mockClear(); + fireEvent.change(screen.getByRole("textbox", { name: "Start" }), { target: { value: "" } }); + expect(onChange).toHaveBeenCalledWith({ start: null, end: "2018", present: false }); + + onChange.mockClear(); + fireEvent.change(screen.getByRole("textbox", { name: "Start" }), { target: { value: "soon" } }); + expect(onChange).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/features/resume/editor/write/dates-field.tsx b/apps/web/src/features/resume/editor/write/dates-field.tsx index 24082124e..3dcbd0e71 100644 --- a/apps/web/src/features/resume/editor/write/dates-field.tsx +++ b/apps/web/src/features/resume/editor/write/dates-field.tsx @@ -12,7 +12,7 @@ import { cn } from "@reactive-resume/utils/style"; type DateSettings = { locale: string; format?: DateFormat | undefined }; /** What someone typed as a date: a year-month, null when cleared, or undefined when it can't be read. */ -export function readTypedDate(text: string, locale: string): YearMonth | null | undefined { +function readTypedDate(text: string, locale: string): YearMonth | null | undefined { const value = text.trim(); if (!value) return null; const reading = readSingleDate(value, locale); diff --git a/packages/api/src/features/agent/service.test.ts b/packages/api/src/features/agent/service.test.ts index 8a7a0ba5b..61011d315 100644 --- a/packages/api/src/features/agent/service.test.ts +++ b/packages/api/src/features/agent/service.test.ts @@ -478,174 +478,6 @@ describe("agentService.messages.send", () => { ); }); - it("merges an answered ask-user-question tool result into the existing assistant message", async () => { - const activeThread = buildActiveThread(); - const userMessage = { - id: "message-user-1", - userId: "user-1", - threadId: "thread-1", - role: "user", - status: "completed", - sequence: 0, - uiMessage: { - id: "ui-user-1", - role: "user", - parts: [{ type: "text", text: "Change the name" }], - }, - }; - const unansweredAssistantMessage = { - id: "message-assistant-1", - userId: "user-1", - threadId: "thread-1", - role: "assistant", - status: "completed", - sequence: 1, - uiMessage: { - id: "ui-assistant-1", - role: "assistant", - parts: [ - { - type: "tool-ask_user_question", - toolCallId: "call-1", - state: "input-available", - input: { - question: "How broadly should I rename?", - choices: ["Only change the main resume header name"], - }, - }, - ], - }, - }; - const answeredAssistantMessage = { - ...unansweredAssistantMessage, - uiMessage: { - ...unansweredAssistantMessage.uiMessage, - parts: [ - { - type: "tool-ask_user_question", - toolCallId: "call-1", - state: "output-available", - input: { - question: "How broadly should I rename?", - choices: ["Only change the main resume header name"], - }, - output: "Only change the main resume header name", - callProviderMetadata: { openai: { itemId: "fc_duplicate_item" } }, - resultProviderMetadata: { openai: { itemId: "fc_duplicate_item" } }, - }, - ], - }, - }; - const answeredAssistantModelInput = { - ...answeredAssistantMessage.uiMessage, - parts: [ - { - type: "tool-ask_user_question", - toolCallId: "call-1", - state: "output-available", - input: { - question: "How broadly should I rename?", - choices: ["Only change the main resume header name"], - }, - output: "Only change the main resume header name", - }, - ], - }; - const updateSets: unknown[] = []; - - dbMock.select - .mockImplementationOnce(() => selectLimitResult([activeThread])) - .mockImplementationOnce(() => selectOrderByResult([userMessage, unansweredAssistantMessage])) - .mockImplementationOnce(() => selectOrderByResult([userMessage, answeredAssistantMessage])); - - dbMock.update.mockImplementation(() => ({ - set: vi.fn((value) => { - updateSets.push(value); - return { where: vi.fn(async () => undefined) }; - }), - })); - - claimActiveAgentRunMock.mockResolvedValue(true); - aiProvidersServiceMock.getRunnableById.mockResolvedValue({ - id: "provider-1", - provider: "openai", - model: "gpt-5", - apiKey: "secret", - baseURL: null, - }); - aiProvidersServiceMock.markUsed.mockResolvedValue(undefined); - - const [{ convertToModelMessages, ToolLoopAgent }, { agentStreamLifecycle }] = await Promise.all([ - import("ai"), - import("./streams"), - ]); - vi.mocked(convertToModelMessages).mockResolvedValue([ - { role: "user", content: [{ type: "text", text: "Change the name" }] }, - { - role: "assistant", - content: [{ type: "tool-call", toolCallId: "call-1", toolName: "ask_user_question", input: {} }], - }, - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call-1", - toolName: "ask_user_question", - output: "Only change the main resume header name", - }, - ], - }, - ] as never); - class MockToolLoopAgent { - stream = vi.fn(async () => ({ toUIMessageStream: vi.fn(() => new ReadableStream()) })); - } - vi.mocked(ToolLoopAgent).mockImplementation(MockToolLoopAgent as never); - vi.mocked(agentStreamLifecycle.create).mockResolvedValue(new ReadableStream()); - vi.mocked(streamToEventIterator).mockReturnValue("iterator" as never); - - const { agentService } = await import("./service"); - - await agentService.messages.send({ - threadId: "thread-1", - userId: "user-1", - message: { - id: "ui-assistant-1", - role: "assistant", - parts: [ - { - type: "tool-ask_user_question", - toolCallId: "call-1", - state: "output-available", - input: { - question: "How broadly should I rename?", - choices: ["Only change the main resume header name"], - }, - output: "Only change the main resume header name", - }, - ], - // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test - } as any, - }); - - expect(dbMock.insert).not.toHaveBeenCalled(); - expect(updateSets).toContainEqual( - expect.objectContaining({ - uiMessage: expect.objectContaining({ - parts: [ - expect.objectContaining({ - type: "tool-ask_user_question", - toolCallId: "call-1", - state: "output-available", - output: "Only change the main resume header name", - }), - ], - }), - }), - ); - expect(convertToModelMessages).toHaveBeenCalledWith([userMessage.uiMessage, answeredAssistantModelInput]); - }); - // Regression (defect 8): a question continuation streams into the SAME uiMessage id; onFinish // must upsert the existing assistant row instead of inserting a duplicate row. it("continues the existing assistant row on a question continuation instead of inserting a duplicate", async () => { @@ -673,27 +505,41 @@ describe("agentService.messages.send", () => { parts: [{ type: "tool-ask_user_question", toolCallId: "call-1", state: "input-available", input: question }], }, }; + const answeredAssistantModelInput = { + ...unansweredAssistantMessage.uiMessage, + parts: [ + { + type: "tool-ask_user_question", + toolCallId: "call-1", + state: "output-available", + input: question, + output: "Only the header", + }, + ], + }; const answeredAssistantMessage = { ...unansweredAssistantMessage, uiMessage: { - ...unansweredAssistantMessage.uiMessage, - parts: [ - { - type: "tool-ask_user_question", - toolCallId: "call-1", - state: "output-available", - input: question, - output: "Only the header", - }, - ], + ...answeredAssistantModelInput, + parts: answeredAssistantModelInput.parts.map((part) => ({ + ...part, + callProviderMetadata: { openai: { itemId: "fc_duplicate_item" } }, + resultProviderMetadata: { openai: { itemId: "fc_duplicate_item" } }, + })), }, }; + const updateSets: unknown[] = []; dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) .mockImplementationOnce(() => selectOrderByResult([userMessage, unansweredAssistantMessage])) .mockImplementationOnce(() => selectOrderByResult([userMessage, answeredAssistantMessage])); - dbMock.update.mockImplementation(() => ({ set: vi.fn(() => ({ where: vi.fn(async () => undefined) })) })); + dbMock.update.mockImplementation(() => ({ + set: vi.fn((value) => { + updateSets.push(value); + return { where: vi.fn(async () => undefined) }; + }), + })); claimActiveAgentRunMock.mockResolvedValue(true); aiProvidersServiceMock.getRunnableById.mockResolvedValue({ @@ -735,9 +581,12 @@ describe("agentService.messages.send", () => { threadId: "thread-1", userId: "user-1", // biome-ignore lint/suspicious/noExplicitAny: minimal fixture for unit test - message: answeredAssistantMessage.uiMessage as any, + message: answeredAssistantModelInput as any, }); + expect(updateSets).toContainEqual(expect.objectContaining({ uiMessage: answeredAssistantModelInput })); + expect(convertToModelMessages).toHaveBeenCalledWith([userMessage.uiMessage, answeredAssistantModelInput]); + const onFinish = uiStreamOptions?.onFinish as (event: Record) => Promise; const continuedMessage = { ...answeredAssistantMessage.uiMessage, diff --git a/packages/import/src/plain-text.test.ts b/packages/import/src/plain-text.test.ts index 9ca3ca393..b186a8f92 100644 --- a/packages/import/src/plain-text.test.ts +++ b/packages/import/src/plain-text.test.ts @@ -36,10 +36,6 @@ AWS Solutions Architect Amazon 2021 describe("parseResumeText", () => { const data = parseResumeText(SAMPLE); - it("always returns schema-valid resume data", () => { - expect(() => resumeDataSchema.parse(data)).not.toThrow(); - }); - it("reads the contact block", () => { expect(data.basics).toMatchObject({ name: "Ada Lovelace", @@ -149,18 +145,6 @@ describe("parseResumeText edge cases", () => { }); }); -describe("parseResumeText review findings", () => { - it("keeps a section whose heading is the first one in the document", () => { - const data = parseResumeText( - "Ada Lovelace\nada@example.com\n\nCAREER HIGHLIGHTS\nShipped the difference engine\nMentored the team\n", - ); - - expect(data.customSections).toHaveLength(1); - expect(data.customSections[0]).toMatchObject({ title: "CAREER HIGHLIGHTS" }); - expect(JSON.stringify(data)).toContain("Shipped the difference engine"); - }); -}); - describe("parseResumeText multi-line entry preambles", () => { it("keeps an uppercase company followed by a separate role line as one entry", () => { const data = parseResumeText( diff --git a/packages/pdf/src/semantic/base-reset-fidelity.test.tsx b/packages/pdf/src/semantic/base-reset-fidelity.test.tsx index 7265da7a6..1a79a2d89 100644 --- a/packages/pdf/src/semantic/base-reset-fidelity.test.tsx +++ b/packages/pdf/src/semantic/base-reset-fidelity.test.tsx @@ -63,7 +63,9 @@ const finalTextStyle = async (template: Template, text: string, rule = "") => { const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters[0]; const instance = pdf(element); await expect.poll(() => instance.container.document).not.toBeNull(); - return mergedStyle(findText(instance.container.document as HostNode, text)); + const node = findText(instance.container.document as HostNode, text); + expect(node).toBeDefined(); + return mergedStyle(node); }; describe("PDF semantic base and reset fidelity", () => { diff --git a/packages/pdf/src/semantic/test/rasterize-pdf.ts b/packages/pdf/src/semantic/test/rasterize-pdf.ts index 44d322efa..9d97f8134 100644 --- a/packages/pdf/src/semantic/test/rasterize-pdf.ts +++ b/packages/pdf/src/semantic/test/rasterize-pdf.ts @@ -23,18 +23,8 @@ type RasterLoadingTask = { destroy(): Promise; }; -export type RasterizePdfDependencies = { - loadDocument?: (bytes: Uint8Array) => RasterLoadingTask; -}; - -const loadPdfDocument = (bytes: Uint8Array): RasterLoadingTask => - getDocument({ data: bytes }) as unknown as RasterLoadingTask; - -export async function rasterizePdf( - bytes: Uint8Array, - dependencies: RasterizePdfDependencies = {}, -): Promise { - const loadingTask = (dependencies.loadDocument ?? loadPdfDocument)(bytes); +export async function rasterizePdf(bytes: Uint8Array): Promise { + const loadingTask = getDocument({ data: bytes }) as unknown as RasterLoadingTask; const pages: RasterizedPdfPage[] = []; try { diff --git a/packages/pdf/src/templates/gengar/gengar-skills.test.tsx b/packages/pdf/src/templates/gengar/gengar-skills.test.tsx index 9dd66666e..76e3742e1 100644 --- a/packages/pdf/src/templates/gengar/gengar-skills.test.tsx +++ b/packages/pdf/src/templates/gengar/gengar-skills.test.tsx @@ -95,7 +95,7 @@ const renderGengar = async (data: ResumeData, template: Template = "gengar") => const page = await pdf.getPage(index); pages.push((await page.getTextContent()).items.flatMap((item) => ("str" in item ? [item] : []))); } - return { bytes, pages, raster: await rasterizePdf(bytes.slice()) }; + return { bytes, pages }; } finally { await loading.destroy(); } @@ -141,7 +141,8 @@ describe("Gengar skill rating placement (#2611)", () => { const data = gengarFixture({ design: "rectangle" }); const result = await renderGengar(data); const text = result.pages.flat().map((item) => item.str); - const raster = required(result.raster[0]); + const rasters = await rasterizePdf(result.bytes.slice()); + const raster = required(rasters[0]); const rows = Array.from({ length: raster.height }, (_, y) => { let pixels = 0; for (let x = 0; x < raster.width; x++) { @@ -159,6 +160,6 @@ describe("Gengar skill rating placement (#2611)", () => { expect(ratingY).toBeLessThan(nameY); expect(ratingY).toBeGreaterThan(proficiencyY); expect(text.join(" ")).toContain("Engineering"); - expect(result.raster.length).toBe(1); + expect(rasters).toHaveLength(1); }); }); diff --git a/packages/pdf/src/templates/shared/columns.test.ts b/packages/pdf/src/templates/shared/columns.test.ts deleted file mode 100644 index fe2c2b18d..000000000 --- a/packages/pdf/src/templates/shared/columns.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getSectionItemRows } from "./columns"; - -describe("getSectionItemRows", () => { - it("groups items into rows of N columns", () => { - expect(getSectionItemRows([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]); - }); -}); diff --git a/packages/pdf/src/templates/shared/picture-border.test.tsx b/packages/pdf/src/templates/shared/picture-border.test.tsx index a6fcc1373..b5c98acd8 100644 --- a/packages/pdf/src/templates/shared/picture-border.test.tsx +++ b/packages/pdf/src/templates/shared/picture-border.test.tsx @@ -68,9 +68,9 @@ async function picturePixels(template: "onyx" | "ditto" | "glalie", borderWidth: } describe("picture border visibility (#3017)", () => { - it.each(["onyx"] as const)("draws the border around an opaque picture (%s)", async (template) => { - const plain = await picturePixels(template, 0); - const bordered = await picturePixels(template, 10); + it("draws a border and a soft centered shadow without moving the photo", async () => { + const plain = await picturePixels("onyx", 0); + const bordered = await picturePixels("onyx", 10); expect(plain.borderPixels).toBe(0); expect(bordered.borderPixels).toBeGreaterThan(1000); expect(bordered.imagePixels).toBeGreaterThan(1000); @@ -78,10 +78,7 @@ describe("picture border visibility (#3017)", () => { // Rasterized border/image edges can differ by one antialiased pixel. for (const edge of ["left", "right", "top", "bottom"] as const) expect(Math.abs(bordered.bounds[edge] - plain.bounds[edge])).toBeLessThanOrEqual(1); - }); - it.each(["onyx"] as const)("draws a soft centered shadow without moving the photo (%s)", async (template) => { - const plain = await picturePixels(template, 0); - const shadow = await picturePixels(template, 0, 10); + const shadow = await picturePixels("onyx", 0, 10); expect(plain.shadowPixels).toBe(0); expect(shadow.shadowPixels).toBeGreaterThan(100); expect(shadow.bounds).toEqual(plain.bounds); diff --git a/packages/pdf/src/templates/shared/section-heading-color.test.tsx b/packages/pdf/src/templates/shared/section-heading-color.test.tsx index 81d3347a3..e99e26fdc 100644 --- a/packages/pdf/src/templates/shared/section-heading-color.test.tsx +++ b/packages/pdf/src/templates/shared/section-heading-color.test.tsx @@ -11,7 +11,8 @@ const renderHeading = async (css: string, hideSectionIcons = false) => { data.metadata.typography.body.fontFamily = "Helvetica"; data.metadata.typography.heading.fontFamily = "Helvetica"; data.metadata.page.hideSectionIcons = hideSectionIcons; - data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }]; + data.metadata.layout.pages = [{ fullWidth: true, main: ["skills", "summary"], sidebar: [] }]; + data.summary.content = "

Summary body

"; data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: `@version 1; ${css}` } }; data.sections.skills.items = [ { id: "skill", hidden: false, name: "Skill", proficiency: "", level: 0, keywords: [], icon: "", iconColor: "" }, @@ -53,12 +54,12 @@ const renderHeading = async (css: string, hideSectionIcons = false) => { }; describe("Semantic section heading colors (#3348)", () => { - it.each([false])("colors heading text with hideSectionIcons=%s", async (hidden) => { - const { text } = await renderHeading("section-heading { color: #1234ef; }", hidden); + it("applies heading color and text styles with section icons visible", async () => { + const { text, sizes } = await renderHeading( + "section-heading { color: #1234ef; } section[type='skills'] section-heading { font-size: 21pt; text-transform: lowercase; }", + ); + expect(text).toContainEqual({ value: "heading", fill: "#1234ef" }); expect(text).toContainEqual({ value: "HEADING", fill: "#1234ef" }); - }); - it.each([false])("gives heading text its text styles with hideSectionIcons=%s", async (hidden) => { - const { sizes } = await renderHeading("section-heading { font-size: 21pt; text-transform: lowercase; }", hidden); expect(sizes).toContainEqual({ value: "heading", size: 21 }); }); }); diff --git a/packages/pdf/src/templates/shared/skill-level-alignment.test.tsx b/packages/pdf/src/templates/shared/skill-level-alignment.test.tsx index 9ab8f0896..f281066a6 100644 --- a/packages/pdf/src/templates/shared/skill-level-alignment.test.tsx +++ b/packages/pdf/src/templates/shared/skill-level-alignment.test.tsx @@ -92,6 +92,8 @@ describe("skill rating alignment (#3343)", () => { expect(rows.map((row) => row.circles)).toEqual([10, 5]); expect(text).toContain("Keyword11"); expect(text).toContain("Skill 2"); + expect(text.indexOf("Skill 0")).toBeLessThan(text.indexOf("Skill 1")); + expect(text.indexOf("Skill 1")).toBeLessThan(text.indexOf("Skill 2")); }); it("aligns language ratings in multi-column rows with unequal fluency text", async () => { const { diff --git a/packages/resume/src/ats-pdf/harvest.ts b/packages/resume/src/ats-pdf/harvest.ts index bc4c44422..69055252e 100644 --- a/packages/resume/src/ats-pdf/harvest.ts +++ b/packages/resume/src/ats-pdf/harvest.ts @@ -67,8 +67,6 @@ export type HarvestOptions = { operatorBudgetPerPageMs?: number; onProgress?: (progress: HarvestProgress) => void; signal?: { aborted: boolean }; - /** Injected for tests; defaults to `Date.now`. */ - monotonicNow?: () => number; }; export const HARVEST_DEFAULTS = { @@ -253,7 +251,6 @@ async function withTimeout(work: Promise, budgetMs: number): Promise { - const now = options.monotonicNow ?? Date.now; const maxPages = options.maxPages ?? HARVEST_DEFAULTS.maxPages; const totalBudgetMs = options.operatorBudgetMs ?? HARVEST_DEFAULTS.operatorBudgetMs; const perPageBudgetMs = options.operatorBudgetPerPageMs ?? HARVEST_DEFAULTS.operatorBudgetPerPageMs; @@ -280,7 +277,7 @@ export async function harvestPdfDocument(document: PdfDocumentLike, options: Har const fontRefs = new Set(); const fonts = new Map(); - const operatorStartedAt = now(); + const operatorStartedAt = Date.now(); let operatorsAttempted = false; let operatorsSucceeded = false; @@ -311,7 +308,7 @@ export async function harvestPdfDocument(document: PdfDocumentLike, options: Har links.push(...toLinkAnnotations(annotations, pageNumber)); let operators: PageOperatorSummary | null = null; - const elapsed = now() - operatorStartedAt; + const elapsed = Date.now() - operatorStartedAt; const remaining = Math.min(perPageBudgetMs, totalBudgetMs - elapsed); if (remaining > 0) { diff --git a/packages/schema/src/resume/data.test.ts b/packages/schema/src/resume/data.test.ts index 1f0f42f70..0cb317e4f 100644 --- a/packages/schema/src/resume/data.test.ts +++ b/packages/schema/src/resume/data.test.ts @@ -4,7 +4,6 @@ import { baseSectionSchema, customFieldSchema, customSectionSchema, - experienceItemSchema, pageSchema, parseResumeData, pictureSchema, @@ -89,23 +88,6 @@ describe("customFieldSchema", () => { }); }); -describe("experienceItemSchema", () => { - it("defaults roles to [] via .catch when missing", () => { - const result = experienceItemSchema.safeParse({ - id: "x", - hidden: false, - company: "Acme", - position: "", - location: "", - period: "", - website: { url: "", label: "", inlineLink: false }, - description: "", - }); - expect(result.success).toBe(true); - if (result.success) expect(result.data.roles).toEqual([]); - }); -}); - describe("pageSchema", () => { it("defaults hideLinkUnderline to false when missing", () => { const { hideLinkUnderline: _, ...pageWithout } = defaultResumeData.metadata.page; diff --git a/tests/e2e/specs/json-export-import.spec.ts b/tests/e2e/specs/json-export-import.spec.ts index 34569b6c1..21443ffb4 100644 --- a/tests/e2e/specs/json-export-import.spec.ts +++ b/tests/e2e/specs/json-export-import.spec.ts @@ -2,7 +2,9 @@ import { readFile, writeFile } from "node:fs/promises"; import { createSampleResumeFromDashboard, openDownloadDialog, openSidebarSection } from "../fixtures/resume"; import { expect, test } from "../fixtures/test"; -test("exports and imports a resume JSON backup", async ({ authPage: page }, testInfo) => { +test("round-trips a JSON backup and splits a legacy embedded letter on import", async ({ + authPage: page, +}, testInfo) => { await createSampleResumeFromDashboard(page, testInfo); const sheet = await openDownloadDialog(page); @@ -14,7 +16,11 @@ test("exports and imports a resume JSON backup", async ({ authPage: page }, test const downloadPath = testInfo.outputPath(download.suggestedFilename()); await download.saveAs(downloadPath); - const exportedData = JSON.parse(await readFile(downloadPath, "utf-8")) as { basics: { name: string } }; + const exportedData = JSON.parse(await readFile(downloadPath, "utf-8")) as { + basics: { name: string }; + customSections: unknown[]; + metadata: { layout: { pages: { fullWidth: boolean; main: string[]; sidebar: string[] }[] } }; + }; // A name only this file carries, so a builder that opens anything but the imported resume fails. exportedData.basics.name = `Imported ${Date.now()}`; await writeFile(downloadPath, JSON.stringify(exportedData)); @@ -29,24 +35,9 @@ test("exports and imports a resume JSON backup", async ({ authPage: page }, test await page.waitForURL(/\/builder\/.+/); await openSidebarSection(page, "Basics"); await expect(page.getByRole("textbox", { name: "Full name", exact: true })).toHaveValue(exportedData.basics.name); -}); - -test("imports a resume file that carries a cover letter as a resume and a letter of its own", async ({ - authPage: page, -}, testInfo) => { - await createSampleResumeFromDashboard(page, testInfo); - const sheet = await openDownloadDialog(page); - await sheet.getByRole("radio", { name: /^JSON/ }).click(); - const downloadPromise = page.waitForEvent("download"); - await sheet.getByRole("button", { name: "Download JSON" }).click(); - const exported = await downloadPromise; - const data = JSON.parse(await readFile((await exported.path()) as string, "utf-8")) as { - customSections: unknown[]; - metadata: { layout: { pages: { fullWidth: boolean; main: string[]; sidebar: string[] }[] } }; - }; // A file from an older version, with the letter inside the resume. - data.customSections.push({ + exportedData.customSections.push({ id: "old-letter", type: "cover-letter", title: "Letter to Globex", @@ -57,9 +48,9 @@ test("imports a resume file that carries a cover letter as a resume and a letter startOnNewPage: false, items: [{ id: "old-letter-item", hidden: false, recipient: "

Globex

", content: "

Dear Globex team,

" }], }); - data.metadata.layout.pages.push({ fullWidth: true, main: ["old-letter"], sidebar: [] }); + exportedData.metadata.layout.pages.push({ fullWidth: true, main: ["old-letter"], sidebar: [] }); const path = testInfo.outputPath("with-letter.json"); - await writeFile(path, JSON.stringify(data)); + await writeFile(path, JSON.stringify(exportedData)); await page.goto("/dashboard"); await page.getByRole("button", { name: "New", exact: true }).click(); diff --git a/tests/e2e/specs/oauth-consent.spec.ts b/tests/e2e/specs/oauth-consent.spec.ts index 06fbc33e5..a4023a5de 100644 --- a/tests/e2e/specs/oauth-consent.spec.ts +++ b/tests/e2e/specs/oauth-consent.spec.ts @@ -1,37 +1,34 @@ import { createHash, randomBytes } from "node:crypto"; import { expect, test } from "../fixtures/test"; -for (const accept of [false, true]) { - test(`requires explicit OAuth consent before ${accept ? "allowing" : "denying"} access`, async ({ - authPage: page, - baseURL, - }) => { - 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 = `${origin}/mcp`; - const callback = "http://127.0.0.1:33921/callback"; - const registration = await page.request.post("/api/auth/oauth2/register", { - headers: { origin }, - data: { client_name: "Consent test client", redirect_uris: [callback] }, - }); - expect(registration.status(), await registration.text()).toBe(201); - const client = await registration.json(); - const verifier = randomBytes(32).toString("base64url"); - const query = new URLSearchParams({ - client_id: client.client_id, - redirect_uri: callback, - response_type: "code", - scope: "openid profile offline_access", - code_challenge: createHash("sha256").update(verifier).digest("base64url"), - code_challenge_method: "S256", - resource, - state: "browser-consent-state", - }); - query.append("resource", origin); - await page.route(`${callback}**`, (route) => route.fulfill({ body: "Client callback" })); +test("requires explicit OAuth consent before denying or allowing access", async ({ authPage: page, baseURL }) => { + 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 = `${origin}/mcp`; + const callback = "http://127.0.0.1:33921/callback"; + const registration = await page.request.post("/api/auth/oauth2/register", { + headers: { origin }, + data: { client_name: "Consent test client", redirect_uris: [callback] }, + }); + expect(registration.status(), await registration.text()).toBe(201); + const client = await registration.json(); + const verifier = randomBytes(32).toString("base64url"); + const query = new URLSearchParams({ + client_id: client.client_id, + redirect_uri: callback, + response_type: "code", + scope: "openid profile offline_access", + code_challenge: createHash("sha256").update(verifier).digest("base64url"), + code_challenge_method: "S256", + resource, + state: "browser-consent-state", + }); + query.append("resource", origin); + await page.route(`${callback}**`, (route) => route.fulfill({ body: "Client callback" })); + for (const accept of [false, true]) { await page.goto(`/api/auth/oauth2/authorize?${query}`); await expect(page.getByRole("heading", { name: "Connect an application" })).toBeVisible(); await expect(page.getByText("Consent test client", { exact: true })).toBeVisible(); @@ -49,7 +46,7 @@ for (const accept of [false, true]) { expect(target.searchParams.has("code")).toBe(false); const after = await page.request.get(`${origin}/api/auth/oauth2/get-consents`); expect(await after.json()).toEqual([]); - return; + continue; } expect(target.searchParams.get("code")).toBeTruthy(); const token = await page.request.post(`${origin}/api/auth/oauth2/token`, { @@ -89,5 +86,5 @@ for (const accept of [false, true]) { data: initializePayload, }); expect(wrongAudience.status()).toBe(401); - }); -} + } +});