feat(builder): add hidden section recovery (#3462)

* feat(builder): recover hidden sections

* fix(builder): reopen hidden section recovery
This commit is contained in:
Amruth Pillai
2026-09-05 18:33:11 -07:00
committed by GitHub
parent 549135bb36
commit 5850230f89
9 changed files with 711 additions and 16 deletions
+9
View File
@@ -2761,6 +2761,11 @@ msgstr "Here's your new API key"
msgid "Hidden"
msgstr "Hidden"
#: src/features/resume/builder/section-recovery.tsx
msgid "Hidden sections"
msgstr "Hidden sections"
#: src/features/resume/builder/section-recovery.tsx
#: src/routes/builder/$resumeId/-sidebar/left/sections/custom.tsx
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-item.tsx
#: src/routes/builder/$resumeId/-sidebar/left/shared/section-menu.tsx
@@ -5041,6 +5046,10 @@ msgstr "Show"
msgid "Show {0} more"
msgstr "Show {0} more"
#: src/features/resume/builder/section-recovery.tsx
msgid "Show {title} section"
msgstr "Show {title} section"
#: src/routes/builder/$resumeId/-sidebar/right/sections/sharing.tsx
msgid "Show Download Buttons"
msgstr "Show Download Buttons"
@@ -0,0 +1,229 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Resume } from "./draft";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, beforeEach, 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 { ConfirmDialogProvider } from "@/hooks/use-confirm";
import { CustomSectionBuilder } from "@/routes/builder/$resumeId/-sidebar/left/sections/custom";
import { useResumeStore } from "./draft";
import {
focusLeftSidebarSection,
getVisibleLeftSidebarSections,
SectionEditorList,
SectionRecovery,
} from "./section-recovery";
const routerParams = vi.hoisted(() => ({ resumeId: "section-recovery" }));
vi.mock("@tanstack/react-router", () => ({
useParams: () => routerParams,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
getById: { queryOptions: () => ({ queryKey: ["resume", "section-recovery"] }) },
patch: { call: vi.fn() },
update: { call: vi.fn(() => new Promise(() => undefined)) },
},
},
streamClient: { resume: { updates: { subscribe: vi.fn() } } },
}));
vi.mock("@reactive-resume/ui/components/toast", () => ({
toast: { add: vi.fn(), close: vi.fn() },
}));
function makeResume(data: ResumeData, isLocked = false): Resume {
return {
id: routerParams.resumeId,
name: "Section Recovery",
slug: "section-recovery",
tags: [],
data,
isLocked,
updatedAt: new Date("2026-09-06T00:00:00.000Z"),
};
}
function makeHiddenData(): ResumeData {
const data = structuredClone(sampleResumeData);
data.summary.hidden = true;
data.sections.experience.hidden = true;
data.sections.experience.title = "Work History";
data.customSections[0].hidden = true;
data.customSections[0].title = "Earlier Roles";
return data;
}
function renderRecovery(data = makeHiddenData(), isLocked = false) {
useResumeStore.getState().initialize(makeResume(data, isLocked));
return render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<fieldset disabled={isLocked}>
<SectionRecovery />
</fieldset>
</I18nProvider>
</QueryClientProvider>,
);
}
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeEach(() => {
vi.useFakeTimers();
useResumeStore.getState().reset();
});
afterEach(() => {
cleanup();
vi.clearAllTimers();
vi.useRealTimers();
useResumeStore.getState().reset();
});
describe("hidden section recovery", () => {
it("keeps Picture, Basics, and Custom editors while removing hidden printable editors", () => {
const visible = getVisibleLeftSidebarSections(makeHiddenData());
expect(visible).toContain("picture");
expect(visible).toContain("basics");
expect(visible).toContain("custom");
expect(visible).not.toContain("summary");
expect(visible).not.toContain("experience");
});
it("does not mount full editors for hidden printable sections", () => {
useResumeStore.getState().initialize(makeResume(makeHiddenData()));
render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<SectionEditorList renderSection={(section) => <div data-testid={`editor-${section}`} />} />
</I18nProvider>
</QueryClientProvider>,
);
expect(screen.getByTestId("editor-picture")).toBeInTheDocument();
expect(screen.getByTestId("editor-basics")).toBeInTheDocument();
expect(screen.getByTestId("editor-custom")).toBeInTheDocument();
expect(screen.queryByTestId("editor-summary")).not.toBeInTheDocument();
expect(screen.queryByTestId("editor-experience")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
});
it("keeps the custom editor container while omitting only hidden custom children", () => {
const data = makeHiddenData();
useResumeStore.getState().initialize(makeResume(data));
render(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider i18n={i18n}>
<ConfirmDialogProvider>
<CustomSectionBuilder />
</ConfirmDialogProvider>
</I18nProvider>
</QueryClientProvider>,
);
expect(screen.queryByText("Earlier Roles")).not.toBeInTheDocument();
expect(screen.getAllByText("Cover Letter").length).toBeGreaterThan(0);
expect(screen.getByRole("button", { name: "Add a new custom section" })).toBeInTheDocument();
});
it("lists hidden built-in, summary, and custom sections by effective title", () => {
renderRecovery();
expect(screen.getByRole("region", { name: "Hidden sections" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show Summary section" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Show Earlier Roles section" })).toBeInTheDocument();
});
it("shows via keyboard, changes only hidden state, and participates in undo and redo", async () => {
const data = makeHiddenData();
const before = structuredClone(data);
vi.useRealTimers();
const user = userEvent.setup();
renderRecovery(data);
const show = screen.getByRole("button", { name: "Show Work History section" });
show.focus();
await user.keyboard("{Enter}");
const shown = useResumeStore.getState().resume?.data;
expect(shown?.sections.experience.hidden).toBe(false);
expect({ ...shown?.sections.experience, hidden: true }).toEqual(before.sections.experience);
expect(shown?.metadata.layout).toEqual(before.metadata.layout);
expect(screen.queryByRole("button", { name: "Show Work History section" })).not.toBeInTheDocument();
act(() => useResumeStore.getState().undo());
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true);
expect(screen.getByRole("button", { name: "Show Work History section" })).toBeInTheDocument();
act(() => useResumeStore.getState().redo());
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false);
});
it("shows an unplaced hidden section without choosing a layout location", () => {
const data = makeHiddenData();
for (const page of data.metadata.layout.pages) {
page.main = page.main.filter((id) => id !== "experience");
page.sidebar = page.sidebar.filter((id) => id !== "experience");
}
const layoutBefore = structuredClone(data.metadata.layout);
renderRecovery(data);
act(() => screen.getByRole("button", { name: "Show Work History section" }).click());
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(false);
expect(useResumeStore.getState().resume?.data.metadata.layout).toEqual(layoutBefore);
});
it("disables recovery actions for a locked resume", () => {
renderRecovery(makeHiddenData(), true);
const show = screen.getByRole("button", { name: "Show Work History section" });
expect(show).toBeDisabled();
show.click();
expect(useResumeStore.getState().resume?.data.sections.experience.hidden).toBe(true);
expect(useResumeStore.getState().undoStack).toHaveLength(0);
});
it("reopens a collapsed recovery group before focusing and scrolling the hidden section", async () => {
vi.useRealTimers();
const user = userEvent.setup();
const scrollIntoView = vi.fn();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: scrollIntoView,
});
renderRecovery();
const trigger = screen.getByRole("button", { name: "Hidden sections" });
await user.click(trigger);
await waitFor(() => expect(document.getElementById("sidebar-hidden-experience")).toBeNull());
focusLeftSidebarSection("experience");
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "true"));
const recoveryEntry = await waitFor(() => {
const entry = document.getElementById("sidebar-hidden-experience");
expect(entry).not.toBeNull();
return entry;
});
await waitFor(() => expect(recoveryEntry).toHaveFocus());
expect(scrollIntoView).toHaveBeenCalledWith({
block: "start",
inline: "nearest",
behavior: "smooth",
});
});
});
@@ -0,0 +1,155 @@
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import type { LeftSidebarSection } from "@/libs/resume/section";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { EyeClosedIcon, EyeIcon } from "@phosphor-icons/react";
import { Fragment } from "react";
import { getSectionAvailability } from "@reactive-resume/resume/section-availability";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@reactive-resume/ui/components/accordion";
import { Button } from "@reactive-resume/ui/components/button";
import { Separator } from "@reactive-resume/ui/components/separator";
import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft";
import { getSectionTitle, leftSidebarSections } from "@/libs/resume/section";
export function getVisibleLeftSidebarSections(data: ResumeData): LeftSidebarSection[] {
const hiddenSectionIds = new Set(
getSectionAvailability(data)
.filter((section) => section.hidden)
.map((section) => section.sectionId),
);
return leftSidebarSections.filter(
(section) =>
section === "picture" || section === "basics" || section === "custom" || !hiddenSectionIds.has(section),
);
}
export function focusLeftSidebarSection(section: LeftSidebarSection): void {
const editorTarget = document.getElementById(`sidebar-${section}`);
if (editorTarget) {
editorTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
return;
}
const recoveryTargetId = `sidebar-hidden-${section}`;
const focusRecoveryTarget = () => {
const recoveryTarget = document.getElementById(recoveryTargetId);
if (!recoveryTarget) return;
recoveryTarget.focus({ preventScroll: true });
recoveryTarget.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
};
const trigger = document.getElementById("sidebar-hidden-sections-trigger");
if (trigger?.getAttribute("aria-expanded") === "false") {
trigger.click();
requestAnimationFrame(focusRecoveryTarget);
return;
}
focusRecoveryTarget();
}
type SectionEditorListProps = {
renderSection: (section: LeftSidebarSection) => ReactNode;
};
export function SectionEditorList({ renderSection }: SectionEditorListProps) {
const sectionKey = useCurrentBuilderResumeSelector((resume) => getVisibleLeftSidebarSections(resume.data).join(","));
const sections = sectionKey.split(",") as LeftSidebarSection[];
return (
<>
{sections.map((section) => (
<Fragment key={section}>
{renderSection(section)}
<Separator />
</Fragment>
))}
<SectionRecovery />
</>
);
}
function getRecoverySectionTitle(data: ResumeData, sectionId: string): string {
if (sectionId === "summary") return data.summary.title || getSectionTitle("summary");
if (Object.hasOwn(data.sections, sectionId)) {
const type = sectionId as SectionType;
return data.sections[type].title || getSectionTitle(type);
}
const customSection = data.customSections.find((section) => section.id === sectionId);
return customSection?.title || (customSection ? getSectionTitle(customSection.type) : sectionId);
}
export function SectionRecovery() {
const data = useCurrentBuilderResumeSelector((resume) => resume.data);
const updateResumeData = useUpdateResumeData();
const hiddenSections = getSectionAvailability(data).filter((section) => section.hidden);
if (hiddenSections.length === 0) return null;
const showSection = (sectionId: string) => {
updateResumeData((draft) => {
if (sectionId === "summary") {
draft.summary.hidden = false;
return;
}
if (Object.hasOwn(draft.sections, sectionId)) {
draft.sections[sectionId as SectionType].hidden = false;
return;
}
const customSection = draft.customSections.find((section) => section.id === sectionId);
if (customSection) customSection.hidden = false;
});
};
return (
<section>
<Accordion defaultValue={["hidden-sections"]}>
<AccordionItem value="hidden-sections" className="rounded-md border px-3">
<AccordionTrigger
id="sidebar-hidden-sections-trigger"
className="items-center no-underline hover:no-underline"
>
<span className="flex items-center gap-x-2">
<EyeClosedIcon aria-hidden="true" />
<Trans>Hidden sections</Trans>
</span>
</AccordionTrigger>
<AccordionContent className="pb-3">
<ul className="space-y-2">
{hiddenSections.map(({ sectionId }) => {
const title = getRecoverySectionTitle(data, sectionId);
return (
<li
key={sectionId}
id={`sidebar-hidden-${sectionId}`}
tabIndex={-1}
className="flex items-center justify-between gap-x-3 rounded-md bg-secondary/40 px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="min-w-0 truncate font-medium text-sm">{title}</span>
<Button
size="sm"
variant="ghost"
aria-label={t`Show ${title} section`}
onClick={() => showSection(sectionId)}
>
<EyeIcon aria-hidden="true" />
<Trans>Show</Trans>
</Button>
</li>
);
})}
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
</section>
);
}
@@ -3,17 +3,17 @@ import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { LockSimpleIcon } from "@phosphor-icons/react";
import { useMutation } from "@tanstack/react-query";
import { Fragment, useCallback, useRef } from "react";
import { useCallback, useRef } from "react";
import { match } from "ts-pattern";
import { Avatar, AvatarFallback, AvatarImage } from "@reactive-resume/ui/components/avatar";
import { Button } from "@reactive-resume/ui/components/button";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
import { Separator } from "@reactive-resume/ui/components/separator";
import { toast } from "@reactive-resume/ui/components/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { getInitials } from "@reactive-resume/utils/string";
import { CoverLetterLibraryDialog } from "@/features/cover-letters/library";
import { useCurrentResume, useIsResumeLocked, usePatchResume, useResumeStore } from "@/features/resume/builder/draft";
import { focusLeftSidebarSection, SectionEditorList } from "@/features/resume/builder/section-recovery";
import { UserDropdownMenu } from "@/features/user/dropdown-menu";
import { getResumeErrorMessage } from "@/libs/error-message";
import { orpc } from "@/libs/orpc/client";
@@ -74,12 +74,7 @@ export function BuilderSidebarLeft() {
<CoverLetterLibraryDialog initialResumeId={resume.id} resumeReady={resumeReady} />
<fieldset disabled={isLocked} className="m-0 min-w-0 space-y-4 border-0 p-0">
{leftSidebarSections.map((section) => (
<Fragment key={section}>
{getSectionComponent(section)}
<Separator />
</Fragment>
))}
<SectionEditorList renderSection={getSectionComponent} />
</fieldset>
</div>
</ScrollArea>
@@ -132,11 +127,7 @@ function SidebarEdge() {
const scrollToSection = useCallback(
(section: LeftSidebarSection) => {
toggleSidebar("left", true);
// Section ids are globally unique; document.getElementById reliably resolves the scroll target
// (querying through the ScrollArea ref did not — its ref does not expose the scroll container).
document
.getElementById(`sidebar-${section}`)
?.scrollIntoView({ block: "start", inline: "nearest", behavior: "smooth" });
focusLeftSidebarSection(section);
},
[toggleSidebar],
);
@@ -127,9 +127,11 @@ export function CustomSectionBuilder() {
return (
<SectionBase type="custom" className={cn("space-y-4", customSections.length === 0 && "border-dashed")}>
<AnimatePresence>
{customSections.map((section) => (
<CustomSectionContainer key={section.id} section={section} />
))}
{customSections
.filter((section) => !section.hidden)
.map((section) => (
<CustomSectionContainer key={section.id} section={section} />
))}
</AnimatePresence>
{/* Add Custom Section Button */}
+1
View File
@@ -11,6 +11,7 @@
"./icons": "./src/icons.ts",
"./markdown": "./src/markdown.ts",
"./patch": "./src/patch.ts",
"./section-availability": "./src/section-availability.ts",
"./social-meta": "./src/social-meta.ts",
"./stylesheet": "./src/stylesheet/index.ts",
"./stylesheet/registry": "./src/stylesheet/registry/index.ts",
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { getSectionAvailability } from "./section-availability";
describe("section availability", () => {
it("includes every printable section and records every authored location", () => {
const data = structuredClone(sampleResumeData);
const customSection = data.customSections[0];
const laterPage = data.metadata.layout.pages[2];
if (!customSection || !laterPage) throw new Error("Sample resume lacks expected section fixtures.");
const customSectionId = customSection.id;
laterPage.sidebar.push(customSectionId, customSectionId, "unknown-section");
const availability = getSectionAvailability(data);
const sectionIds = availability.map((entry) => entry.sectionId);
expect(sectionIds).toEqual([
"summary",
...Object.keys(data.sections),
...data.customSections.map((section) => section.id),
]);
expect(sectionIds).not.toContain("picture");
expect(sectionIds).not.toContain("basics");
expect(sectionIds).not.toContain("custom");
expect(sectionIds).not.toContain("unknown-section");
expect(availability.find((entry) => entry.sectionId === customSectionId)?.locations).toEqual([
{ pageIndex: 1, columnId: "main" },
{ pageIndex: 2, columnId: "sidebar" },
{ pageIndex: 2, columnId: "sidebar" },
]);
});
it("derives hidden and placement state independently without mutation", () => {
const data = structuredClone(sampleResumeData);
data.sections.experience.hidden = true;
data.sections.awards.items = [];
for (const page of data.metadata.layout.pages) {
page.main = page.main.filter((id) => id !== "experience");
page.sidebar = page.sidebar.filter((id) => id !== "experience");
page.main = page.main.filter((id) => id !== "projects");
page.sidebar = page.sidebar.filter((id) => id !== "projects");
}
data.summary.hidden = true;
const before = structuredClone(data);
const availability = getSectionAvailability(data);
expect(availability.find((entry) => entry.sectionId === "experience")).toEqual({
sectionId: "experience",
hidden: true,
locations: [],
});
expect(availability.find((entry) => entry.sectionId === "summary")).toEqual({
sectionId: "summary",
hidden: true,
locations: [{ pageIndex: 0, columnId: "main" }],
});
expect(availability.find((entry) => entry.sectionId === "projects")).toEqual({
sectionId: "projects",
hidden: false,
locations: [],
});
expect(availability.find((entry) => entry.sectionId === "awards")).toEqual({
sectionId: "awards",
hidden: false,
locations: [{ pageIndex: 1, columnId: "main" }],
});
expect(data).toEqual(before);
});
});
@@ -0,0 +1,39 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
export type SectionLocation = {
pageIndex: number;
columnId: "main" | "sidebar";
};
export type SectionAvailability = {
sectionId: string;
hidden: boolean;
locations: SectionLocation[];
};
function getSectionLocations(data: ResumeData, sectionId: string): SectionLocation[] {
const locations: SectionLocation[] = [];
for (const [pageIndex, page] of data.metadata.layout.pages.entries()) {
for (const columnId of ["main", "sidebar"] as const) {
for (const id of page[columnId]) {
if (id === sectionId) locations.push({ pageIndex, columnId });
}
}
}
return locations;
}
export function getSectionAvailability(data: ResumeData): SectionAvailability[] {
const sections = [
{ sectionId: "summary", hidden: data.summary.hidden },
...Object.entries(data.sections).map(([sectionId, section]) => ({ sectionId, hidden: section.hidden })),
...data.customSections.map((section) => ({ sectionId: section.id, hidden: section.hidden })),
];
return sections.map((section) => ({
...section,
locations: getSectionLocations(data, section.sectionId),
}));
}
+199
View File
@@ -0,0 +1,199 @@
import type { Page, TestInfo } from "@playwright/test";
import { readFile } from "node:fs/promises";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { Pool } from "pg";
import { createSampleResumeFromDashboard, openResumeCardMenu, openSidebarSection } from "../fixtures/resume";
import { expect, test } from "../fixtures/test";
const summaryMarker = "RECOVERY_SUMMARY_MARKER";
const builtInMarker = "RECOVERY_BUILTIN_MARKER";
const customMarker = "RECOVERY_CUSTOM_MARKER";
type AuthoredLayout = {
pages: Array<{ fullWidth: boolean; main: string[]; sidebar: string[] }>;
};
async function seedRecoveryResume(resumeId: string): Promise<AuthoredLayout> {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
const result = await pool.query<{ data: Record<string, unknown> }>('select data from "resume" where id = $1', [
resumeId,
]);
const data = result.rows[0]?.data as {
summary: { title: string; content: string; hidden: boolean };
sections: { experience: { title: string; hidden: boolean; items: Array<{ company: string }> } };
customSections: Array<{
id: string;
title: string;
hidden: boolean;
items: Array<{ company?: string }>;
}>;
metadata: {
layout: AuthoredLayout;
typography: { body: { fontFamily: string }; heading: { fontFamily: string } };
};
};
if (!data?.sections.experience.items[0] || !data.customSections[0]?.items[0]) {
throw new Error("Sample resume lacks recovery fixture sections.");
}
data.summary.title = "Recovery Summary";
data.summary.content = `<p>${summaryMarker}</p>`;
data.summary.hidden = false;
data.sections.experience.title = "Recovery Experience";
data.sections.experience.items[0].company = builtInMarker;
data.sections.experience.hidden = false;
data.customSections[0].title = "Recovery Custom";
data.customSections[0].items[0].company = customMarker;
data.customSections[0].hidden = false;
data.metadata.typography.body.fontFamily = "Helvetica";
data.metadata.typography.heading.fontFamily = "Helvetica";
const layout = structuredClone(data.metadata.layout);
await pool.query('update "resume" set data = $2, updated_at = now() where id = $1', [resumeId, data]);
return layout;
} finally {
await pool.end();
}
}
async function readRecoveryState(resumeId: string) {
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
try {
const result = await pool.query<{
data: {
summary: { hidden: boolean };
sections: { experience: { hidden: boolean } };
customSections: Array<{ title: string; hidden: boolean }>;
metadata: { layout: AuthoredLayout };
};
}>('select data from "resume" where id = $1', [resumeId]);
const data = result.rows[0]?.data;
if (!data) throw new Error(`Resume ${resumeId} was not found.`);
return data;
} finally {
await pool.end();
}
}
function waitForResumeSave(page: Page) {
return page.waitForResponse((response) => {
if (!response.url().includes("/api/rpc") || response.request().method() !== "POST") return false;
if (!response.ok()) return false;
return response.request().postData()?.includes('"data"') ?? false;
});
}
async function hideStandardSection(page: Page, navigationTitle: string, title: string) {
await page.getByRole("button", { name: navigationTitle, exact: true }).first().click();
const heading = page.getByRole("heading", { name: title, exact: true }).filter({ visible: true }).first();
await expect(heading).toBeVisible();
await heading.locator("xpath=../..").getByRole("button", { name: "Section options" }).click();
const saved = waitForResumeSave(page);
await page.getByRole("menuitem", { name: "Hide", exact: true }).click();
await saved;
}
async function hideCustomSection(page: Page, title: string) {
await openSidebarSection(page, "Custom Sections");
const titleElement = page.getByText(title, { exact: true }).filter({ visible: true }).first();
const card = titleElement.locator("xpath=../../..");
await card.getByRole("button", { name: "Section options" }).click();
const saved = waitForResumeSave(page);
await page.getByRole("menuitem", { name: "Hide", exact: true }).click();
await saved;
}
async function showSection(page: Page, title: string) {
const saved = waitForResumeSave(page);
await page.getByRole("button", { name: `Show ${title} section` }).click();
await saved;
}
async function downloadPdfText(page: Page, testInfo: TestInfo, name: 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(`${name}.pdf`);
await download.saveAs(path);
await page.keyboard.press("Escape");
const loading = getDocument({ data: new Uint8Array(await readFile(path)), useSystemFonts: true });
try {
const pdf = await loading.promise;
const text: string[] = [];
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) {
const pdfPage = await pdf.getPage(pageNumber);
text.push(...(await pdfPage.getTextContent()).items.flatMap((item) => ("str" in item ? [item.str] : [])));
}
return text.join(" ");
} finally {
await loading.destroy();
}
}
test("recovers hidden printable sections without changing authored placement", async ({ authPage: page }, testInfo) => {
test.setTimeout(120_000);
const resumeName = await createSampleResumeFromDashboard(page, testInfo);
const resumeId = new URL(page.url()).pathname.split("/").at(-1);
if (!resumeId) throw new Error("Builder URL lacks resume id.");
const authoredLayout = await seedRecoveryResume(resumeId);
await page.reload();
await hideStandardSection(page, "Summary", "Recovery Summary");
await hideStandardSection(page, "Experience", "Recovery Experience");
await hideCustomSection(page, "Recovery Custom");
await page.reload();
for (const title of ["Recovery Summary", "Recovery Experience", "Recovery Custom"]) {
await expect(page.getByRole("button", { name: `Show ${title} section` })).toBeVisible();
}
await expect(
page.getByRole("heading", { name: "Recovery Summary", exact: true }).filter({ visible: true }),
).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "Recovery Experience", exact: true }).filter({ visible: true }),
).toHaveCount(0);
await expect(page.getByText("Recovery Custom", { exact: true }).filter({ visible: true })).toHaveCount(1);
const hiddenPdf = await downloadPdfText(page, testInfo, "section-recovery-hidden");
expect(hiddenPdf).not.toContain(summaryMarker);
expect(hiddenPdf).not.toContain(builtInMarker);
expect(hiddenPdf).not.toContain(customMarker);
await showSection(page, "Recovery Summary");
await showSection(page, "Recovery Experience");
await showSection(page, "Recovery Custom");
const shownState = await readRecoveryState(resumeId);
expect(shownState.metadata.layout).toEqual(authoredLayout);
expect(shownState.summary.hidden).toBe(false);
expect(shownState.sections.experience.hidden).toBe(false);
expect(shownState.customSections.find((section) => section.title === "Recovery Custom")?.hidden).toBe(false);
const shownPdf = await downloadPdfText(page, testInfo, "section-recovery-shown");
expect(shownPdf).toContain(summaryMarker);
expect(shownPdf).toContain(builtInMarker);
expect(shownPdf).toContain(customMarker);
await page.getByRole("button", { name: "Undo", exact: true }).click();
await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toBeVisible();
await page.getByRole("button", { name: "Redo", exact: true }).click();
await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toHaveCount(0);
const saved = waitForResumeSave(page);
await page.getByRole("button", { name: "Undo", exact: true }).click();
await saved;
await openResumeCardMenu(page, resumeName);
const locked = page.waitForResponse((response) => (response.request().postData() ?? "").includes('"isLocked":true'));
await page.getByRole("menuitem", { name: "Lock" }).click();
await page.getByRole("alertdialog").getByRole("button", { name: "Confirm" }).click();
await locked;
await page.goto(`/builder/${resumeId}`);
await expect(page.getByRole("button", { name: "Show Recovery Custom section" })).toBeDisabled();
expect((await readRecoveryState(resumeId)).metadata.layout).toEqual(authoredLayout);
});