mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 22:11:42 +10:00
refactor(stylesheet): move Semantic CSS to the browser (#3329)
This commit is contained in:
@@ -35,10 +35,6 @@ const toastMocks = vi.hoisted(() => ({
|
||||
error: vi.fn(() => "sync-error-toast"),
|
||||
}));
|
||||
|
||||
const stylesheetMocks = vi.hoisted(() => ({
|
||||
refresh: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@orpc/client", () => ({
|
||||
consumeEventIterator: consumeEventIteratorMock,
|
||||
}));
|
||||
@@ -81,10 +77,6 @@ vi.mock("sonner", () => ({
|
||||
toast: toastMocks,
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/stylesheet/store", () => ({
|
||||
refreshStylesheetStore: stylesheetMocks.refresh,
|
||||
}));
|
||||
|
||||
function cloneResumeData(data: ResumeData): ResumeData {
|
||||
return structuredClone(data);
|
||||
}
|
||||
@@ -135,7 +127,6 @@ describe("builder resume autosave", () => {
|
||||
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
||||
toastMocks.dismiss.mockClear();
|
||||
toastMocks.error.mockClear();
|
||||
stylesheetMocks.refresh.mockReset();
|
||||
useResumeStore.getState().reset();
|
||||
});
|
||||
|
||||
@@ -169,6 +160,26 @@ describe("builder resume autosave", () => {
|
||||
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("autosaves stylesheet source through the ordinary full-data update", async () => {
|
||||
const initial = makeResume("resume-stylesheet-autosave");
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" };
|
||||
const updated = makeResume(initial.id);
|
||||
updated.data.metadata.stylesheet = { mode: "semantic", source };
|
||||
orpcMocks.updateResume.mockResolvedValue(updated);
|
||||
useResumeStore.getState().initialize(initial);
|
||||
|
||||
useResumeStore.getState().updateResumeData((draft) => {
|
||||
draft.metadata.stylesheet = { mode: "semantic", source };
|
||||
});
|
||||
vi.advanceTimersByTime(500);
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(orpcMocks.updateResume).toHaveBeenCalledWith(
|
||||
{ id: initial.id, data: updated.data },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves the latest pending snapshot after an in-flight save resolves", async () => {
|
||||
const initial = makeResume("resume-in-flight");
|
||||
const first = withBasicsName(initial, "First Name");
|
||||
@@ -507,25 +518,13 @@ describe("resume update stream subscription", () => {
|
||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
|
||||
});
|
||||
|
||||
it("refetches canonical stylesheet state for stylesheet SSE events", async () => {
|
||||
it("applies stylesheet source from the ordinary resume SSE flow", async () => {
|
||||
const initial = makeResume("resume-stylesheet");
|
||||
consumeEventIteratorMock.mockReturnValue(vi.fn().mockResolvedValue(undefined));
|
||||
routerParamsMock.value = { resumeId: initial.id };
|
||||
useResumeStore.getState().initialize(initial);
|
||||
|
||||
renderHook(() => useBuilderResumeUpdateSubscription());
|
||||
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as {
|
||||
onEvent: (event: { mutation: string }) => Promise<void>;
|
||||
const remote = makeResume("resume-stylesheet");
|
||||
remote.data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" },
|
||||
};
|
||||
await act(async () => handlers.onEvent({ mutation: "stylesheet" }));
|
||||
|
||||
expect(stylesheetMocks.refresh).toHaveBeenCalledWith(initial.id);
|
||||
expect(orpcMocks.getResumeById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes the render-data version after content SSE events", async () => {
|
||||
const initial = makeResume("resume-content");
|
||||
const remote = withBasicsName(initial, "Remote");
|
||||
consumeEventIteratorMock.mockReturnValue(vi.fn().mockResolvedValue(undefined));
|
||||
orpcMocks.getResumeById.mockResolvedValue(remote);
|
||||
routerParamsMock.value = { resumeId: initial.id };
|
||||
@@ -537,6 +536,7 @@ describe("resume update stream subscription", () => {
|
||||
};
|
||||
await act(async () => handlers.onEvent({ mutation: "update" }));
|
||||
|
||||
expect(stylesheetMocks.refresh).toHaveBeenCalledWith(initial.id, remote.data);
|
||||
expect(orpcMocks.getResumeById).toHaveBeenCalledWith({ id: initial.id });
|
||||
expect(useResumeStore.getState().resume?.data.metadata.stylesheet).toEqual(remote.data.metadata.stylesheet);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { immer } from "zustand/middleware/immer";
|
||||
import { create } from "zustand/react";
|
||||
import { refreshStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||
import { orpc, streamClient } from "@/libs/orpc/client";
|
||||
|
||||
export type Resume = {
|
||||
@@ -26,7 +25,7 @@ export type Resume = {
|
||||
};
|
||||
|
||||
// Mirrors the server-side ResumeUpdatedEvent discriminator (packages/api resume/events.ts).
|
||||
type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete" | "stylesheet";
|
||||
type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
|
||||
type ResumeUpdateEvent = { mutation: ResumeUpdateMutation };
|
||||
|
||||
type SaveStatus = "idle" | "saving" | "saved" | "error";
|
||||
@@ -616,14 +615,9 @@ export function useBuilderResumeUpdateSubscription() {
|
||||
if (!resumeId) return;
|
||||
|
||||
bindRuntimeQueryClient(resumeId, queryClient);
|
||||
if (event.mutation === "stylesheet") {
|
||||
await refreshStylesheetStore(resumeId);
|
||||
return;
|
||||
}
|
||||
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
|
||||
|
||||
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
|
||||
await refreshStylesheetStore(resumeId, resume.data);
|
||||
|
||||
if (hasPendingLocalChanges(resumeId)) {
|
||||
useResumeStore.getState().mergeResumeMetadata(resume);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import { useMemo } from "react";
|
||||
import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser";
|
||||
@@ -11,22 +9,6 @@ type ResumePdfRenderOptions = {
|
||||
includeCoverLetterHeader?: boolean;
|
||||
};
|
||||
|
||||
export type ResumePdfPresentation =
|
||||
| { stylesheet: Pick<SemanticStylesheet, "mode"> & { applied: StylesheetSource } }
|
||||
| { publicStyleProjection: PublicStyleProjection };
|
||||
|
||||
const withAppliedStylesheet = (data: ResumeData, presentation?: ResumePdfPresentation): ResumeData => {
|
||||
if (!presentation || !("stylesheet" in presentation)) return data;
|
||||
const { mode, applied } = presentation.stylesheet;
|
||||
return {
|
||||
...data,
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
stylesheet: { mode, source: applied, applied },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
|
||||
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
|
||||
|
||||
@@ -47,17 +29,13 @@ export const createResumePdfBlob = async (
|
||||
data: ResumeData,
|
||||
template?: Template,
|
||||
renderOptions?: ResumePdfRenderOptions,
|
||||
presentation?: ResumePdfPresentation,
|
||||
) => {
|
||||
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
|
||||
|
||||
return createPdfBlob({
|
||||
data: withAppliedStylesheet(data, presentation),
|
||||
data,
|
||||
template,
|
||||
...(renderOptions ? { renderOptions } : {}),
|
||||
...(presentation && "publicStyleProjection" in presentation
|
||||
? { publicStyleProjection: presentation.publicStyleProjection }
|
||||
: {}),
|
||||
resolveSectionTitle: sectionTitleResolver,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { useResumeExport } from "./use-resume-export";
|
||||
|
||||
@@ -14,22 +13,13 @@ const mocks = vi.hoisted(() => ({
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./pdf-document", () => ({
|
||||
vi.mock("@/features/resume/export/pdf-document", () => ({
|
||||
createResumePdfBlob: mocks.createResumePdfBlob,
|
||||
}));
|
||||
vi.mock("@reactive-resume/utils/file", () => ({
|
||||
downloadWithAnchor: mocks.downloadWithAnchor,
|
||||
generateFilename: (name: string, extension: string) => `${name}.${extension}`,
|
||||
}));
|
||||
vi.mock("@/features/resume/stylesheet/store", () => ({
|
||||
useStylesheetStore: (selector: (state: object) => unknown) =>
|
||||
selector({
|
||||
resumeId: undefined,
|
||||
mode: "legacy",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
}),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
loading: vi.fn(() => "toast"),
|
||||
@@ -49,21 +39,16 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("useResumeExport public PDF", () => {
|
||||
it("downloads the authorized server blob after one mismatched-projection refetch", async () => {
|
||||
it("downloads the authorized server blob after public browser rendering rejects", async () => {
|
||||
const semanticData = structuredClone(sampleResumeData);
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||
semanticData.metadata.stylesheet = { mode: "semantic", source, applied: source };
|
||||
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||
const mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
|
||||
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
|
||||
semanticData.metadata.stylesheet = { mode: "semantic", source };
|
||||
mocks.createResumePdfBlob.mockRejectedValueOnce(new Error("browser renderer failed"));
|
||||
const { result } = renderHook(() =>
|
||||
useResumeExport(
|
||||
{ name: "Sample", slug: "sample", data: sampleResumeData },
|
||||
{ name: "Sample", slug: "sample", data: semanticData },
|
||||
{
|
||||
publicResumePdf: {
|
||||
stylesheetMode: "semantic",
|
||||
styleProjection: mismatchedProjection,
|
||||
refetchStyleProjection,
|
||||
publicResume: { username: "amruth", slug: "sample" },
|
||||
},
|
||||
},
|
||||
@@ -72,19 +57,14 @@ describe("useResumeExport public PDF", () => {
|
||||
|
||||
await act(() => result.current.onDownloadPDF());
|
||||
|
||||
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createResumePdfBlob).toHaveBeenCalledWith(semanticData);
|
||||
expect(mocks.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
|
||||
const blob = mocks.downloadWithAnchor.mock.calls[0]?.[0] as Blob;
|
||||
expect(await blob.text()).toBe("server");
|
||||
});
|
||||
|
||||
it("does not download an unstyled PDF when semantic rendering rejects", async () => {
|
||||
mocks.createResumePdfBlob.mockRejectedValueOnce(
|
||||
new Error("The semantic stylesheet could not be rendered.", {
|
||||
cause: [{ code: "RESOURCE_LIMIT", severity: "error" }],
|
||||
}),
|
||||
);
|
||||
it("does not download a PDF when the renderer rejects", async () => {
|
||||
mocks.createResumePdfBlob.mockRejectedValueOnce(new Error("PDF renderer failed"));
|
||||
const { result } = renderHook(() => useResumeExport({ name: "Sample", slug: "sample", data: sampleResumeData }));
|
||||
|
||||
await act(() => result.current.onDownloadPDF());
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { PublicResumePdfOptions } from "@/features/resume/public/public-pdf";
|
||||
import type { ResumePdfPresentation } from "./pdf-document";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { buildDocx } from "@reactive-resume/docx";
|
||||
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
|
||||
@@ -12,7 +10,6 @@ import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resu
|
||||
import { buildMarkdown } from "@reactive-resume/resume/markdown";
|
||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
||||
import { resolvePublicResumePdfBlob } from "@/features/resume/public/public-pdf";
|
||||
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
|
||||
import { createResumePdfBlob } from "./pdf-document";
|
||||
|
||||
@@ -54,43 +51,12 @@ type DownloadPdfOptions = {
|
||||
export function useResumeExport(resume: ExportableResume | undefined, exportOptions: UseResumeExportOptions = {}) {
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const hasCoverLetter = resume ? resumeHasCoverLetter(resume.data) : false;
|
||||
const stylesheetResumeId = useStylesheetStore((state) => state.resumeId);
|
||||
const stylesheetMode = useStylesheetStore((state) => state.mode);
|
||||
const stylesheetSource = useStylesheetStore((state) => state.source);
|
||||
const stylesheetApplied = useStylesheetStore((state) => state.applied);
|
||||
const canonicalStylesheet = useMemo<SemanticStylesheet | undefined>(
|
||||
() =>
|
||||
resume?.id && resume.id === stylesheetResumeId
|
||||
? {
|
||||
mode: stylesheetMode,
|
||||
source: stylesheetSource,
|
||||
applied: stylesheetApplied,
|
||||
}
|
||||
: undefined,
|
||||
[resume?.id, stylesheetApplied, stylesheetMode, stylesheetResumeId, stylesheetSource],
|
||||
);
|
||||
const pdfPresentation = useMemo<ResumePdfPresentation | undefined>(
|
||||
() =>
|
||||
canonicalStylesheet
|
||||
? { stylesheet: { mode: canonicalStylesheet.mode, applied: canonicalStylesheet.applied } }
|
||||
: undefined,
|
||||
[canonicalStylesheet],
|
||||
);
|
||||
|
||||
const onDownloadJSON = useCallback(() => {
|
||||
if (!resume) return;
|
||||
const data = canonicalStylesheet
|
||||
? {
|
||||
...resume.data,
|
||||
metadata: {
|
||||
...resume.data.metadata,
|
||||
stylesheet: canonicalStylesheet,
|
||||
},
|
||||
}
|
||||
: resume.data;
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||
const blob = new Blob([JSON.stringify(resume.data, null, 2)], { type: "application/json" });
|
||||
downloadWithAnchor(blob, generateFilename(getExportName(resume), "json"));
|
||||
}, [canonicalStylesheet, resume]);
|
||||
}, [resume]);
|
||||
|
||||
const onDownloadMarkdown = useCallback(
|
||||
async (target: ResumeExportTarget = "resume") => {
|
||||
@@ -136,7 +102,6 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
|
||||
target === "cover-letter"
|
||||
? { includeCoverLetterHeader: downloadOptions?.includeCoverLetterHeader }
|
||||
: undefined,
|
||||
pdfPresentation,
|
||||
);
|
||||
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
|
||||
} catch {
|
||||
@@ -146,7 +111,7 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
},
|
||||
[exportOptions.publicResumePdf, pdfPresentation, resume],
|
||||
[exportOptions.publicResumePdf, resume],
|
||||
);
|
||||
|
||||
const onPrint = useCallback(async () => {
|
||||
@@ -156,7 +121,7 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
|
||||
try {
|
||||
const blob = exportOptions.publicResumePdf
|
||||
? await resolvePublicResumePdfBlob({ data: resume.data, ...exportOptions.publicResumePdf })
|
||||
: await createResumePdfBlob(resume.data, undefined, undefined, pdfPresentation);
|
||||
: await createResumePdfBlob(resume.data);
|
||||
const url = URL.createObjectURL(blob);
|
||||
// ponytail: print the generated PDF via a hidden iframe (reliable in Chromium). If the browser
|
||||
// blocks iframe printing, fall back to opening the PDF in a new tab so the user can print manually.
|
||||
@@ -182,7 +147,7 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
|
||||
setIsExporting(false);
|
||||
toast.dismiss(toastId);
|
||||
}
|
||||
}, [exportOptions.publicResumePdf, pdfPresentation, resume]);
|
||||
}, [exportOptions.publicResumePdf, resume]);
|
||||
|
||||
return { onDownloadJSON, onDownloadMarkdown, onDownloadDOCX, onDownloadPDF, onPrint, isExporting, hasCoverLetter };
|
||||
}
|
||||
|
||||
@@ -8,14 +8,7 @@ import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { ResumePreviewClient } from "./preview.browser";
|
||||
|
||||
const previewMock = vi.hoisted(() => ({
|
||||
builderResumeId: undefined as string | undefined,
|
||||
builderResumeData: undefined as ResumeData | undefined,
|
||||
stylesheet: {
|
||||
resumeId: undefined as string | undefined,
|
||||
mode: "legacy" as "legacy" | "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
},
|
||||
toastError: vi.fn(),
|
||||
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||
}));
|
||||
@@ -53,22 +46,9 @@ vi.mock("sonner", () => ({
|
||||
|
||||
vi.mock("../builder/draft", () => ({
|
||||
useResumeData: () => previewMock.builderResumeData,
|
||||
useResumeStore: (selector: (state: { resumeId?: string }) => unknown) =>
|
||||
selector({ resumeId: previewMock.builderResumeId }),
|
||||
usePreviewPausedStore: (selector: (state: { paused: boolean }) => unknown) => selector({ paused: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/stylesheet/store", () => ({
|
||||
useStylesheetStore: (
|
||||
selector: (state: {
|
||||
resumeId?: string;
|
||||
mode: "legacy" | "semantic";
|
||||
source: { languageVersion: number; text: string };
|
||||
applied: { languageVersion: number; text: string };
|
||||
}) => unknown,
|
||||
) => selector(previewMock.stylesheet),
|
||||
}));
|
||||
|
||||
vi.mock("./pdf-canvas", async () => {
|
||||
const React = await import("react");
|
||||
const pdfDocument = { numPages: 1 };
|
||||
@@ -102,14 +82,7 @@ describe("ResumePreviewClient", () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
previewMock.builderResumeId = undefined;
|
||||
previewMock.builderResumeData = undefined;
|
||||
previewMock.stylesheet = {
|
||||
resumeId: undefined,
|
||||
mode: "legacy",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
};
|
||||
previewMock.toBlob.mockReset();
|
||||
previewMock.toBlob.mockImplementation(async () => new Blob(["%PDF"], { type: "application/pdf" }));
|
||||
previewMock.toastError.mockReset();
|
||||
@@ -135,7 +108,7 @@ describe("ResumePreviewClient", () => {
|
||||
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, undefined);
|
||||
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData);
|
||||
});
|
||||
|
||||
it("keeps the rendered template identity on the active layer while its replacement renders", async () => {
|
||||
@@ -159,47 +132,39 @@ describe("ResumePreviewClient", () => {
|
||||
expect(activeLayer?.getAttribute("data-resume-preview-template")).toBe("azurill");
|
||||
});
|
||||
|
||||
it("renders the canonical applied stylesheet and ignores invalid editable source", async () => {
|
||||
const validApplied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||
previewMock.builderResumeId = "resume-1";
|
||||
previewMock.builderResumeData = sampleResumeData;
|
||||
previewMock.stylesheet = {
|
||||
resumeId: "resume-1",
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "section {" },
|
||||
applied: validApplied,
|
||||
it("renders the current stylesheet source from the ordinary builder draft", async () => {
|
||||
const source = { languageVersion: 1, text: "section {" };
|
||||
previewMock.builderResumeData = {
|
||||
...sampleResumeData,
|
||||
metadata: {
|
||||
...sampleResumeData.metadata,
|
||||
stylesheet: { mode: "semantic", source },
|
||||
},
|
||||
};
|
||||
|
||||
render(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
|
||||
|
||||
await waitFor(() => expect(previewMock.toBlob).toHaveBeenCalledTimes(1));
|
||||
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, {
|
||||
stylesheet: { mode: "semantic", applied: validApplied },
|
||||
});
|
||||
expect(previewMock.toBlob).toHaveBeenCalledWith(previewMock.builderResumeData);
|
||||
|
||||
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the active PDF visible and reports later semantic render diagnostics", async () => {
|
||||
previewMock.builderResumeId = "resume-1";
|
||||
it("keeps the active PDF visible and reports a later renderer failure", async () => {
|
||||
previewMock.builderResumeData = sampleResumeData;
|
||||
previewMock.stylesheet = {
|
||||
resumeId: "resume-1",
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||
};
|
||||
const view = render(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
|
||||
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
|
||||
|
||||
previewMock.toBlob.mockRejectedValueOnce(
|
||||
new Error("The semantic stylesheet could not be rendered.", {
|
||||
cause: [{ code: "RESOURCE_LIMIT", severity: "error" }],
|
||||
}),
|
||||
);
|
||||
previewMock.stylesheet.applied = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nname { color: #654321; }\n",
|
||||
previewMock.toBlob.mockRejectedValueOnce(new Error("PDF renderer failed"));
|
||||
previewMock.builderResumeData = {
|
||||
...sampleResumeData,
|
||||
metadata: {
|
||||
...sampleResumeData.metadata,
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname { color: #654321; }\n" },
|
||||
},
|
||||
},
|
||||
};
|
||||
view.rerender(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
|
||||
|
||||
|
||||
@@ -4,13 +4,12 @@ import type { ResolvedResumePreviewProps } from "./preview.shared";
|
||||
import type { PreviewPageSize } from "./preview.shared.utils";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { AnimatePresence, m } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { isRTL } from "@reactive-resume/utils/locale";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||
import { usePreviewPausedStore, useResumeData, useResumeStore } from "../builder/draft";
|
||||
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
|
||||
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
|
||||
import { ResumePreviewLoader } from "./preview.shared";
|
||||
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
|
||||
@@ -102,17 +101,6 @@ export function ResumePreviewClient({
|
||||
}: ResolvedResumePreviewProps) {
|
||||
const builderResumeData = useResumeData();
|
||||
const resumeData = data ?? builderResumeData;
|
||||
const builderResumeId = useResumeStore((state) => state.resumeId);
|
||||
const stylesheetResumeId = useStylesheetStore((state) => state.resumeId);
|
||||
const mode = useStylesheetStore((state) => state.mode);
|
||||
const applied = useStylesheetStore((state) => state.applied);
|
||||
const presentation = useMemo(
|
||||
() =>
|
||||
data === undefined && builderResumeId !== undefined && stylesheetResumeId === builderResumeId
|
||||
? { stylesheet: { mode, applied } }
|
||||
: undefined,
|
||||
[applied, builderResumeId, data, mode, stylesheetResumeId],
|
||||
);
|
||||
const paused = usePreviewPausedStore((state) => state.paused);
|
||||
|
||||
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
|
||||
@@ -133,7 +121,7 @@ export function ResumePreviewClient({
|
||||
const generatePdfPreview = async () => {
|
||||
try {
|
||||
if (cancelled || requestId !== requestIdRef.current) return;
|
||||
const blob = await createResumePdfBlob(resumeData, undefined, undefined, presentation);
|
||||
const blob = await createResumePdfBlob(resumeData);
|
||||
|
||||
if (!cancelled && requestId === requestIdRef.current) {
|
||||
const nextPdf = createPreviewPdf(
|
||||
@@ -162,7 +150,7 @@ export function ResumePreviewClient({
|
||||
cancelled = true;
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [paused, presentation, resumeData]);
|
||||
}, [paused, resumeData]);
|
||||
|
||||
if (!resumeData) return null;
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
|
||||
const pdfViewerMock = vi.hoisted(() => {
|
||||
@@ -120,67 +119,25 @@ describe("PdfViewer", () => {
|
||||
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders a valid public projection through the shared PDF entrypoint", async () => {
|
||||
it("renders exposed semantic source through the shared PDF entrypoint", async () => {
|
||||
const semanticData = structuredClone(sampleResumeData);
|
||||
const applied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||
semanticData.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||
const refetchStyleProjection = vi.fn();
|
||||
semanticData.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||
};
|
||||
|
||||
render(
|
||||
<PdfViewer
|
||||
data={sampleResumeData}
|
||||
stylesheetMode="semantic"
|
||||
styleProjection={projection}
|
||||
refetchStyleProjection={refetchStyleProjection}
|
||||
publicResume={{ username: "amruth", slug: "sample" }}
|
||||
/>,
|
||||
);
|
||||
render(<PdfViewer data={semanticData} publicResume={{ username: "amruth", slug: "sample" }} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(pdfViewerMock.createResumePdfBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, {
|
||||
publicStyleProjection: projection,
|
||||
}),
|
||||
);
|
||||
expect(refetchStyleProjection).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(pdfViewerMock.createResumePdfBlob).toHaveBeenCalledWith(semanticData));
|
||||
expect(pdfViewerMock.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refetches a mismatched projection once before using the authorized PDF fallback", async () => {
|
||||
const semanticData = structuredClone(sampleResumeData);
|
||||
const applied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||
semanticData.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||
const mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
|
||||
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
|
||||
it("uses the authorized PDF fallback after browser generation rejects", async () => {
|
||||
pdfViewerMock.createResumePdfBlob.mockRejectedValueOnce(new Error("browser renderer failed"));
|
||||
|
||||
const view = render(
|
||||
<PdfViewer
|
||||
data={sampleResumeData}
|
||||
stylesheetMode="semantic"
|
||||
styleProjection={mismatchedProjection}
|
||||
refetchStyleProjection={refetchStyleProjection}
|
||||
publicResume={{ username: "amruth", slug: "sample" }}
|
||||
/>,
|
||||
);
|
||||
render(<PdfViewer data={sampleResumeData} publicResume={{ username: "amruth", slug: "sample" }} />);
|
||||
|
||||
await waitFor(() => expect(refetchStyleProjection).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(pdfViewerMock.fetch).toHaveBeenCalledTimes(1));
|
||||
expect(String(pdfViewerMock.fetch.mock.calls[0]?.[0])).toContain("/api/resumes/amruth/sample/pdf");
|
||||
expect(String(pdfViewerMock.fetch.mock.calls[0]?.[0])).toContain("reason=render-data-hash");
|
||||
expect(pdfViewerMock.createResumePdfBlob).not.toHaveBeenCalled();
|
||||
|
||||
view.rerender(
|
||||
<PdfViewer
|
||||
data={sampleResumeData}
|
||||
stylesheetMode="semantic"
|
||||
styleProjection={{ ...mismatchedProjection, renderDataHash: "1".repeat(64) }}
|
||||
refetchStyleProjection={refetchStyleProjection}
|
||||
publicResume={{ username: "amruth", slug: "sample" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(pdfViewerMock.fetch).toHaveBeenCalledTimes(2));
|
||||
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
|
||||
expect(pdfViewerMock.fetch).toHaveBeenCalledWith("/api/resumes/amruth/sample/pdf", { credentials: "include" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { AnnotationMode, GlobalWorkerOptions, getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||
import { EventBus, LinkTarget, PDFLinkService, PDFViewer } from "pdfjs-dist/legacy/web/pdf_viewer.mjs";
|
||||
@@ -17,9 +15,6 @@ GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.
|
||||
type PdfViewerProps = {
|
||||
className?: string;
|
||||
data: ResumeData;
|
||||
stylesheetMode?: SemanticStylesheet["mode"];
|
||||
styleProjection?: PublicStyleProjection;
|
||||
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
|
||||
publicResume?: {
|
||||
username: string;
|
||||
slug: string;
|
||||
@@ -77,21 +72,11 @@ function pdfViewerReducer(state: PdfViewerState, action: PdfViewerAction): PdfVi
|
||||
}
|
||||
}
|
||||
|
||||
export function PdfViewer({
|
||||
className,
|
||||
data,
|
||||
stylesheetMode,
|
||||
styleProjection,
|
||||
refetchStyleProjection,
|
||||
publicResume,
|
||||
}: PdfViewerProps) {
|
||||
export function PdfViewer({ className, data, publicResume }: PdfViewerProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const fileRef = useRef<Blob | null>(null);
|
||||
const projectionRetryRef = useRef<{ data?: ResumeData; publicKey?: string; retried: boolean }>({
|
||||
retried: false,
|
||||
});
|
||||
const [{ error, fileVersion, isReady, viewerHeight }, dispatch] = useReducer(
|
||||
pdfViewerReducer,
|
||||
INITIAL_PDF_VIEWER_STATE,
|
||||
@@ -103,27 +88,8 @@ export function PdfViewer({
|
||||
fileRef.current = null;
|
||||
dispatch({ type: "resetForData" });
|
||||
|
||||
const createPdf = () => {
|
||||
if (!stylesheetMode || !publicResume) return createResumePdfBlob(data);
|
||||
const publicKey = `${publicResume.username}/${publicResume.slug}`;
|
||||
if (projectionRetryRef.current.data !== data || projectionRetryRef.current.publicKey !== publicKey) {
|
||||
projectionRetryRef.current = { data, publicKey, retried: false };
|
||||
}
|
||||
const retryProjection =
|
||||
refetchStyleProjection && !projectionRetryRef.current.retried
|
||||
? () => {
|
||||
projectionRetryRef.current.retried = true;
|
||||
return refetchStyleProjection();
|
||||
}
|
||||
: undefined;
|
||||
return resolvePublicResumePdfBlob({
|
||||
data,
|
||||
stylesheetMode,
|
||||
publicResume,
|
||||
...(styleProjection ? { styleProjection } : {}),
|
||||
...(retryProjection ? { refetchStyleProjection: retryProjection } : {}),
|
||||
});
|
||||
};
|
||||
const createPdf = () =>
|
||||
publicResume ? resolvePublicResumePdfBlob({ data, publicResume }) : createResumePdfBlob(data);
|
||||
|
||||
void createPdf()
|
||||
.then((blob) => {
|
||||
@@ -142,7 +108,7 @@ export function PdfViewer({
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [data, publicResume, refetchStyleProjection, styleProjection, stylesheetMode]);
|
||||
}, [data, publicResume]);
|
||||
|
||||
useEffect(() => {
|
||||
void fileVersion;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { resolvePublicResumePdfBlob } from "./public-pdf";
|
||||
|
||||
@@ -15,58 +14,35 @@ vi.mock("@/features/resume/export/pdf-document", () => ({
|
||||
const publicResume = { username: "amruth", slug: "sample" };
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.createResumePdfBlob.mockClear();
|
||||
mocks.fetch.mockClear();
|
||||
mocks.createResumePdfBlob.mockReset();
|
||||
mocks.createResumePdfBlob.mockResolvedValue(new Blob(["local"], { type: "application/pdf" }));
|
||||
mocks.fetch.mockReset();
|
||||
mocks.fetch.mockResolvedValue(new Response(new Blob(["server"], { type: "application/pdf" })));
|
||||
vi.stubGlobal("fetch", mocks.fetch);
|
||||
});
|
||||
|
||||
describe("resolvePublicResumePdfBlob", () => {
|
||||
it("keeps legitimate legacy resumes on the local PDF path", async () => {
|
||||
await resolvePublicResumePdfBlob({
|
||||
data: sampleResumeData,
|
||||
stylesheetMode: "legacy",
|
||||
publicResume,
|
||||
});
|
||||
it("renders the exposed stylesheet source directly in the browser", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||
};
|
||||
|
||||
const blob = await resolvePublicResumePdfBlob({ data, publicResume });
|
||||
|
||||
expect(mocks.createResumePdfBlob).toHaveBeenCalledWith(data);
|
||||
expect(mocks.fetch).not.toHaveBeenCalled();
|
||||
expect(await blob.text()).toBe("local");
|
||||
});
|
||||
|
||||
it("fetches the server PDF only after browser rendering rejects", async () => {
|
||||
mocks.createResumePdfBlob.mockRejectedValue(new Error("browser renderer failed"));
|
||||
|
||||
const blob = await resolvePublicResumePdfBlob({ data: sampleResumeData, publicResume });
|
||||
|
||||
expect(mocks.createResumePdfBlob).toHaveBeenCalledWith(sampleResumeData);
|
||||
expect(mocks.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the authorized server fallback when a semantic projection is unavailable", async () => {
|
||||
const refetchStyleProjection = vi.fn().mockRejectedValue(new Error("projection unavailable"));
|
||||
|
||||
await resolvePublicResumePdfBlob({
|
||||
data: sampleResumeData,
|
||||
stylesheetMode: "semantic",
|
||||
publicResume,
|
||||
refetchStyleProjection,
|
||||
});
|
||||
|
||||
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
|
||||
expect(String(mocks.fetch.mock.calls[0]?.[0])).toContain("/api/resumes/amruth/sample/pdf");
|
||||
expect(String(mocks.fetch.mock.calls[0]?.[0])).toContain("reason=missing-projection");
|
||||
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refetches a mismatched projection once before returning the authorized server blob", async () => {
|
||||
const semanticData = structuredClone(sampleResumeData);
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||
semanticData.metadata.stylesheet = { mode: "semantic", source, applied: source };
|
||||
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||
const mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
|
||||
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
|
||||
|
||||
const blob = await resolvePublicResumePdfBlob({
|
||||
data: sampleResumeData,
|
||||
stylesheetMode: "semantic",
|
||||
styleProjection: mismatchedProjection,
|
||||
publicResume,
|
||||
refetchStyleProjection,
|
||||
});
|
||||
|
||||
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.fetch).toHaveBeenCalledWith("/api/resumes/amruth/sample/pdf", { credentials: "include" });
|
||||
expect(await blob.text()).toBe("server");
|
||||
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,97 +1,28 @@
|
||||
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import {
|
||||
getPublicStyleProjectionFingerprints,
|
||||
PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
|
||||
SEMANTIC_TREE_VERSION,
|
||||
validatePublicStyleProjection,
|
||||
} from "@reactive-resume/pdf/public-projection";
|
||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||
|
||||
export type PublicResumePdfOptions = {
|
||||
stylesheetMode: SemanticStylesheet["mode"];
|
||||
styleProjection?: PublicStyleProjection;
|
||||
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
|
||||
publicResume: {
|
||||
username: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ProjectionMismatchReason =
|
||||
| "format-version"
|
||||
| "language-version"
|
||||
| "semantic-tree-version"
|
||||
| "registry-fingerprint"
|
||||
| "adapter-fingerprint"
|
||||
| "render-data-hash"
|
||||
| "invalid-projection";
|
||||
|
||||
const projectionMismatchReason = async (
|
||||
data: ResumeData,
|
||||
projection: PublicStyleProjection,
|
||||
): Promise<ProjectionMismatchReason | null> => {
|
||||
if (projection.formatVersion !== PUBLIC_STYLE_PROJECTION_FORMAT_VERSION) return "format-version";
|
||||
if (projection.languageVersion !== 1) return "language-version";
|
||||
if (projection.semanticTreeVersion !== SEMANTIC_TREE_VERSION) return "semantic-tree-version";
|
||||
const fingerprints = await getPublicStyleProjectionFingerprints();
|
||||
if (projection.registryFingerprint !== fingerprints.registryFingerprint) return "registry-fingerprint";
|
||||
if (projection.adapterFingerprint !== fingerprints.adapterFingerprint) return "adapter-fingerprint";
|
||||
return (await validatePublicStyleProjection(data, projection)) ? null : "render-data-hash";
|
||||
};
|
||||
|
||||
const fetchPublicResumePdf = async (
|
||||
publicResume: PublicResumePdfOptions["publicResume"],
|
||||
reason: ProjectionMismatchReason | "missing-projection",
|
||||
projection?: PublicStyleProjection,
|
||||
) => {
|
||||
const search = new URLSearchParams({
|
||||
reason,
|
||||
...(projection
|
||||
? {
|
||||
registryFingerprint: projection.registryFingerprint,
|
||||
adapterFingerprint: projection.adapterFingerprint,
|
||||
}
|
||||
: {}),
|
||||
const fetchPublicResumePdf = async ({ username, slug }: PublicResumePdfOptions["publicResume"]) => {
|
||||
const response = await fetch(`/api/resumes/${encodeURIComponent(username)}/${encodeURIComponent(slug)}/pdf`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const response = await fetch(
|
||||
`/api/resumes/${encodeURIComponent(publicResume.username)}/${encodeURIComponent(publicResume.slug)}/pdf?${search}`,
|
||||
{ credentials: "include" },
|
||||
);
|
||||
if (!response.ok) throw new Error(`Public PDF fallback failed with ${response.status}`);
|
||||
return response.blob();
|
||||
};
|
||||
|
||||
export async function resolvePublicResumePdfBlob({
|
||||
data,
|
||||
...options
|
||||
publicResume,
|
||||
}: PublicResumePdfOptions & { data: ResumeData }): Promise<Blob> {
|
||||
if (options.stylesheetMode === "legacy") return createResumePdfBlob(data);
|
||||
|
||||
let projection = options.styleProjection;
|
||||
let refetched = false;
|
||||
const refetch = async () => {
|
||||
if (!options.refetchStyleProjection || refetched) return;
|
||||
refetched = true;
|
||||
try {
|
||||
projection = await options.refetchStyleProjection();
|
||||
} catch {
|
||||
projection = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
if (!projection) await refetch();
|
||||
if (!projection) return fetchPublicResumePdf(options.publicResume, "missing-projection");
|
||||
|
||||
let reason = await projectionMismatchReason(data, projection).catch(() => "invalid-projection" as const);
|
||||
if (reason) {
|
||||
await refetch();
|
||||
if (!projection) return fetchPublicResumePdf(options.publicResume, "missing-projection");
|
||||
reason = await projectionMismatchReason(data, projection).catch(() => "invalid-projection" as const);
|
||||
try {
|
||||
return await createResumePdfBlob(data);
|
||||
} catch {
|
||||
return fetchPublicResumePdf(publicResume);
|
||||
}
|
||||
|
||||
return reason
|
||||
? fetchPublicResumePdf(options.publicResume, reason, projection)
|
||||
: createResumePdfBlob(data, undefined, undefined, { publicStyleProjection: projection });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ReactNode } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
@@ -12,30 +11,12 @@ import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
type PdfViewerProps = {
|
||||
className?: string;
|
||||
data: ResumeData;
|
||||
stylesheetMode?: "legacy" | "semantic";
|
||||
styleProjection?: PublicStyleProjection;
|
||||
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
|
||||
publicResume?: { username: string; slug: string };
|
||||
};
|
||||
|
||||
const publicResumeMock = vi.hoisted(() => ({
|
||||
onDownloadPDF: vi.fn(),
|
||||
PdfViewer: vi.fn<(_props: PdfViewerProps) => ReactNode>(() => null),
|
||||
projection: {
|
||||
formatVersion: 1,
|
||||
languageVersion: 1,
|
||||
semanticTreeVersion: 1,
|
||||
registryFingerprint: "1".repeat(64),
|
||||
adapterFingerprint: "2".repeat(64),
|
||||
renderDataHash: "3".repeat(64),
|
||||
nodes: { resume: {} },
|
||||
} as PublicStyleProjection,
|
||||
refetchProjection: vi.fn(),
|
||||
projectionResult: {
|
||||
data: undefined as PublicStyleProjection | undefined,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
},
|
||||
useResumeExport: vi.fn(),
|
||||
resume: undefined as
|
||||
| undefined
|
||||
@@ -43,61 +24,28 @@ const publicResumeMock = vi.hoisted(() => ({
|
||||
data: ResumeData;
|
||||
name: string;
|
||||
slug: string;
|
||||
stylesheetMode: "legacy" | "semantic";
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (options: { query: "resume" | "projection" }) =>
|
||||
options.query === "resume"
|
||||
? { data: publicResumeMock.resume }
|
||||
: { ...publicResumeMock.projectionResult, refetch: publicResumeMock.refetchProjection },
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: publicResumeMock.resume }) }));
|
||||
vi.mock("@tanstack/react-router", () => ({
|
||||
getRouteApi: () => ({
|
||||
useParams: () => ({ username: "amruth", slug: "sample" }),
|
||||
}),
|
||||
getRouteApi: () => ({ useParams: () => ({ username: "amruth", slug: "sample" }) }),
|
||||
}));
|
||||
|
||||
vi.mock("./pdf-viewer", () => ({
|
||||
PdfViewer: publicResumeMock.PdfViewer,
|
||||
}));
|
||||
|
||||
vi.mock("./pdf-viewer", () => ({ PdfViewer: publicResumeMock.PdfViewer }));
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
getBySlug: { queryOptions: () => ({ query: "resume" }) },
|
||||
getStyleProjection: { queryOptions: () => ({ query: "projection" }) },
|
||||
},
|
||||
},
|
||||
orpc: { resume: { getBySlug: { queryOptions: () => ({ query: "resume" }) } } },
|
||||
}));
|
||||
|
||||
vi.mock("@/features/resume/export/use-resume-export", () => ({
|
||||
useResumeExport: publicResumeMock.useResumeExport,
|
||||
}));
|
||||
|
||||
const { PublicResumeRoute } = await import("./public-resume");
|
||||
|
||||
beforeAll(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
|
||||
|
||||
beforeEach(() => {
|
||||
publicResumeMock.resume = {
|
||||
data: sampleResumeData,
|
||||
name: "Sample Resume",
|
||||
slug: "sample",
|
||||
stylesheetMode: "semantic",
|
||||
};
|
||||
publicResumeMock.projectionResult = {
|
||||
data: publicResumeMock.projection,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
};
|
||||
publicResumeMock.resume = { data: sampleResumeData, name: "Sample Resume", slug: "sample" };
|
||||
publicResumeMock.PdfViewer.mockClear();
|
||||
publicResumeMock.refetchProjection.mockReset();
|
||||
publicResumeMock.refetchProjection.mockResolvedValue({ data: publicResumeMock.projection });
|
||||
publicResumeMock.useResumeExport.mockReset();
|
||||
publicResumeMock.useResumeExport.mockReturnValue({
|
||||
onDownloadPDF: publicResumeMock.onDownloadPDF,
|
||||
@@ -116,78 +64,26 @@ const renderPublicResumeRoute = () =>
|
||||
);
|
||||
|
||||
describe("PublicResumeRoute", () => {
|
||||
it("renders the public resume through the route-local PDF.js viewer", () => {
|
||||
it("passes exposed source data directly to the browser viewer and export fallback", () => {
|
||||
renderPublicResumeRoute();
|
||||
|
||||
expect(screen.getByTestId("pdf-viewer")).toHaveClass("block", "w-full");
|
||||
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: sampleResumeData }),
|
||||
expect.objectContaining({
|
||||
data: sampleResumeData,
|
||||
publicResume: { username: "amruth", slug: "sample" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(publicResumeMock.resume, {
|
||||
publicResumePdf: expect.objectContaining({
|
||||
stylesheetMode: "semantic",
|
||||
styleProjection: publicResumeMock.projection,
|
||||
publicResume: { username: "amruth", slug: "sample" },
|
||||
}),
|
||||
publicResumePdf: { publicResume: { username: "amruth", slug: "sample" } },
|
||||
});
|
||||
});
|
||||
|
||||
it("routes a missing semantic projection to the shared fallback seam", () => {
|
||||
publicResumeMock.projectionResult = { data: undefined, isError: true, isPending: false };
|
||||
|
||||
renderPublicResumeRoute();
|
||||
|
||||
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stylesheetMode: "semantic",
|
||||
styleProjection: undefined,
|
||||
refetchStyleProjection: expect.any(Function),
|
||||
publicResume: { username: "amruth", slug: "sample" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(
|
||||
publicResumeMock.resume,
|
||||
expect.objectContaining({
|
||||
publicResumePdf: expect.objectContaining({
|
||||
stylesheetMode: "semantic",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps missing legacy projections on the local rendering path", () => {
|
||||
if (publicResumeMock.resume) publicResumeMock.resume.stylesheetMode = "legacy";
|
||||
publicResumeMock.projectionResult = { data: undefined, isError: false, isPending: false };
|
||||
|
||||
renderPublicResumeRoute();
|
||||
|
||||
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stylesheetMode: "legacy", styleProjection: undefined }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("loads the public projection and passes it to the shared viewer", () => {
|
||||
renderPublicResumeRoute();
|
||||
|
||||
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
styleProjection: publicResumeMock.projection,
|
||||
refetchStyleProjection: expect.any(Function),
|
||||
publicResume: { username: "amruth", slug: "sample" },
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the public resume page grow to the full PDF length", () => {
|
||||
renderPublicResumeRoute();
|
||||
|
||||
const viewerFrame = screen.getByTestId("pdf-viewer").parentElement;
|
||||
const page = viewerFrame?.parentElement;
|
||||
|
||||
expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden");
|
||||
expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden");
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
|
||||
import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getRouteApi } from "@tanstack/react-router";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||
import { Button } from "@reactive-resume/ui/components/button";
|
||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||
@@ -17,30 +17,12 @@ export function PublicResumeRoute() {
|
||||
const { username, slug } = publicResumeRoute.useParams();
|
||||
|
||||
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
|
||||
const projectionQuery = useQuery(
|
||||
orpc.resume.getStyleProjection.queryOptions({ input: { username, slug }, enabled: resume !== undefined }),
|
||||
);
|
||||
const styleProjection =
|
||||
projectionQuery.data && Object.keys(projectionQuery.data.nodes).length > 0 ? projectionQuery.data : undefined;
|
||||
const publicResume = useMemo(() => ({ username, slug }), [slug, username]);
|
||||
const refetchStyleProjection = useCallback(async () => {
|
||||
const result = await projectionQuery.refetch();
|
||||
return result.data;
|
||||
}, [projectionQuery.refetch]);
|
||||
const { onDownloadPDF, isExporting } = useResumeExport(resume, {
|
||||
...(resume
|
||||
? {
|
||||
publicResumePdf: {
|
||||
stylesheetMode: resume.stylesheetMode,
|
||||
publicResume,
|
||||
refetchStyleProjection,
|
||||
...(styleProjection ? { styleProjection } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(resume ? { publicResumePdf: { publicResume } } : {}),
|
||||
});
|
||||
|
||||
if (!resume || projectionQuery.isPending) return <LoadingScreen />;
|
||||
if (!resume) return <LoadingScreen />;
|
||||
|
||||
const { basics, picture } = resume.data;
|
||||
|
||||
@@ -66,14 +48,7 @@ export function PublicResumeRoute() {
|
||||
</header>
|
||||
|
||||
<main className="w-full max-w-5xl bg-white print:max-w-full">
|
||||
<PdfViewer
|
||||
data={resume.data}
|
||||
className="block w-full"
|
||||
stylesheetMode={resume.stylesheetMode}
|
||||
styleProjection={styleProjection}
|
||||
publicResume={publicResume}
|
||||
refetchStyleProjection={refetchStyleProjection}
|
||||
/>
|
||||
<PdfViewer data={resume.data} className="block w-full" publicResume={publicResume} />
|
||||
</main>
|
||||
|
||||
<footer className="flex justify-center print:hidden">
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { SemanticCssDiagnostic, StyleProgram } from "@reactive-resume/resume/stylesheet";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
|
||||
import StylesheetEditorShell, { StylesheetCodeEditor } from "./editor";
|
||||
import { LegacyStylesheetBanner } from "./legacy-banner";
|
||||
import { StylesheetStatus } from "./status";
|
||||
import { useStylesheetStore } from "./store";
|
||||
|
||||
const media = vi.hoisted(() => ({ mobile: false }));
|
||||
const compileWorker = vi.hoisted(() => ({
|
||||
program: { languageVersion: 1, rules: [] } as StyleProgram | null,
|
||||
diagnostics: [] as SemanticCssDiagnostic[],
|
||||
}));
|
||||
const builder = vi.hoisted(() => ({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
data: undefined as typeof defaultResumeData | undefined,
|
||||
isLocked: false,
|
||||
undo: vi.fn(),
|
||||
redo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("usehooks-ts", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("usehooks-ts")>()),
|
||||
@@ -23,16 +35,52 @@ vi.mock("@/features/theme/provider", () => ({
|
||||
useTheme: () => ({ theme: "light" }),
|
||||
}));
|
||||
|
||||
const error: SemanticCssDiagnostic = {
|
||||
code: "SEMANTIC_CSS_UNKNOWN_PROPERTY",
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useResumeData: () => builder.data,
|
||||
useIsResumeLocked: () => builder.isLocked,
|
||||
useUpdateResumeData: () => (update: (draft: typeof defaultResumeData) => void) => {
|
||||
if (builder.data) update(builder.data);
|
||||
},
|
||||
useResumeStore: (selector: (state: object) => unknown) =>
|
||||
selector({ canUndo: builder.canUndo, canRedo: builder.canRedo, undo: builder.undo, redo: builder.redo }),
|
||||
}));
|
||||
|
||||
vi.mock("./worker-client", () => ({
|
||||
createCompileWorkerClient: () => ({
|
||||
compile: vi.fn(async ({ editGeneration }: { editGeneration: number }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: compileWorker.program,
|
||||
diagnostics: compileWorker.diagnostics,
|
||||
colorTokens: [],
|
||||
})),
|
||||
destroy: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const recoverableError: SemanticCssDiagnostic = {
|
||||
code: "INVALID_VALUE",
|
||||
severity: "error",
|
||||
message: "Unknown property",
|
||||
message: "Invalid value",
|
||||
range: {
|
||||
start: { line: 2, column: 3, offset: 17 },
|
||||
end: { line: 2, column: 9, offset: 23 },
|
||||
},
|
||||
};
|
||||
|
||||
const fatalError: SemanticCssDiagnostic = {
|
||||
...recoverableError,
|
||||
code: "VERSION_MISMATCH",
|
||||
message: "Version mismatch",
|
||||
};
|
||||
|
||||
const resolutionError: SemanticCssDiagnostic = {
|
||||
...recoverableError,
|
||||
code: "UNRESOLVED_VARIABLE",
|
||||
message: "Undefined variable --missing",
|
||||
};
|
||||
|
||||
const guideName = /read the applying custom styles guide.*opens in new tab/i;
|
||||
|
||||
const expectGuideLink = (root: HTMLElement) => {
|
||||
@@ -47,28 +95,48 @@ beforeAll(() => {
|
||||
Object.defineProperty(Element.prototype, "getAnimations", { configurable: true, value: () => [] });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
media.mobile = false;
|
||||
builder.data = structuredClone(defaultResumeData);
|
||||
builder.isLocked = false;
|
||||
builder.canUndo = false;
|
||||
builder.canRedo = false;
|
||||
builder.undo.mockReset();
|
||||
builder.redo.mockReset();
|
||||
compileWorker.program = { languageVersion: 1, rules: [] };
|
||||
compileWorker.diagnostics = [];
|
||||
});
|
||||
|
||||
const renderWithI18n = (element: React.ReactNode) => render(<I18nProvider i18n={i18n}>{element}</I18nProvider>);
|
||||
|
||||
describe("stylesheet editor status", () => {
|
||||
it("shows that invalid source keeps the last valid preview", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="semantic" status="error" diagnostics={[error]} />);
|
||||
it("explains that fatal source falls back to base styles", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="semantic" status="idle" diagnostics={[fatalError]} />);
|
||||
|
||||
expect(screen.getByText(/preview and export use the last valid version/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Unknown property")).toBeInTheDocument();
|
||||
expect(screen.getByText(/preview and export fall back to base styles/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Version mismatch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("explains that recoverable errors preserve valid styles", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="semantic" status="idle" diagnostics={[recoverableError]} />);
|
||||
|
||||
expect(screen.getByText("Valid with errors")).toBeInTheDocument();
|
||||
expect(screen.getByText(/preview and export keep valid styles/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/fall back to base styles/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels a valid legacy draft as ready to activate", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[]} />);
|
||||
|
||||
expect(screen.getByText("Ready to activate")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Applied")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("labels legacy warnings without claiming they are applied", () => {
|
||||
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[{ ...error, severity: "warning" }]} />);
|
||||
it("labels a legacy draft with warnings as ready to activate", () => {
|
||||
renderWithI18n(
|
||||
<StylesheetStatus mode="legacy" status="idle" diagnostics={[{ ...recoverableError, severity: "warning" }]} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Ready to activate with warnings")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Applied with warnings")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables activation while the converted draft has errors", () => {
|
||||
@@ -113,7 +181,7 @@ describe("StylesheetCodeEditor", () => {
|
||||
<StylesheetCodeEditor
|
||||
value={"@version 1;\nsection { color: red; }\n"}
|
||||
{...props}
|
||||
diagnostics={[error]}
|
||||
diagnostics={[recoverableError]}
|
||||
theme="dark"
|
||||
readOnly
|
||||
/>
|
||||
@@ -173,18 +241,105 @@ describe("StylesheetEditorShell", () => {
|
||||
expectGuideLink(container);
|
||||
});
|
||||
|
||||
it("makes the editor and mutation controls read-only while a restore is pending", () => {
|
||||
media.mobile = false;
|
||||
useStylesheetStore.setState({
|
||||
mode: "legacy",
|
||||
it("has no apply or save action for an already-semantic stylesheet", async () => {
|
||||
if (!builder.data) throw new Error("Missing resume fixture");
|
||||
builder.data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
};
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TooltipProvider>
|
||||
<StylesheetEditorShell />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await screen.findByText("Valid");
|
||||
expect(screen.queryByRole("button", { name: /activate semantic css/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /save|apply/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("writes semantic edits into the ordinary resume draft immediately", () => {
|
||||
if (!builder.data) throw new Error("Missing resume fixture");
|
||||
builder.data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
};
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TooltipProvider>
|
||||
<StylesheetEditorShell />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const textbox = screen.getByRole("textbox", { name: "Semantic CSS stylesheet" });
|
||||
const view = EditorView.findFromDOM(textbox);
|
||||
if (!view) throw new Error("Missing editor view");
|
||||
|
||||
act(() => view.dispatch({ changes: { from: view.state.doc.length, insert: "name { color: blue; }\n" } }));
|
||||
|
||||
expect(builder.data.metadata.stylesheet.source.text).toBe("@version 1;\nname { color: blue; }\n");
|
||||
});
|
||||
|
||||
it("switches a converted legacy draft through the ordinary resume update", async () => {
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TooltipProvider>
|
||||
<StylesheetEditorShell />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const activate = await screen.findByRole("button", { name: "Activate Semantic CSS" });
|
||||
await waitFor(() => expect(activate).toBeEnabled());
|
||||
fireEvent.click(activate);
|
||||
|
||||
expect(builder.data?.metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
diagnostics: [],
|
||||
status: "idle",
|
||||
canUndo: true,
|
||||
canRedo: true,
|
||||
restoreLocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a converted legacy draft with recoverable errors to activate", async () => {
|
||||
compileWorker.diagnostics = [resolutionError];
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TooltipProvider>
|
||||
<StylesheetEditorShell />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
const activate = await screen.findByRole("button", { name: "Activate Semantic CSS" });
|
||||
await waitFor(() => expect(activate).toBeEnabled());
|
||||
fireEvent.click(activate);
|
||||
|
||||
expect(builder.data?.metadata.stylesheet?.mode).toBe("semantic");
|
||||
});
|
||||
|
||||
it("keeps legacy activation disabled for fatal diagnostics", async () => {
|
||||
compileWorker.diagnostics = [fatalError];
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<TooltipProvider>
|
||||
<StylesheetEditorShell />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
await screen.findByText("Fatal error");
|
||||
expect(screen.getByRole("button", { name: "Activate Semantic CSS" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("makes editor mutation controls read-only while the resume is locked", () => {
|
||||
builder.isLocked = true;
|
||||
builder.canUndo = true;
|
||||
builder.canRedo = true;
|
||||
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
@@ -202,19 +357,10 @@ describe("StylesheetEditorShell", () => {
|
||||
expect(screen.getByRole("button", { name: "Undo stylesheet edit" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Redo stylesheet edit" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Format stylesheet" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Reset to applied stylesheet" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("moves the only visible editor into a titled mobile sheet", async () => {
|
||||
media.mobile = true;
|
||||
useStylesheetStore.setState({
|
||||
mode: "legacy",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
diagnostics: [{ ...error, severity: "warning" }],
|
||||
status: "idle",
|
||||
restoreLocked: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
@@ -231,8 +377,7 @@ describe("StylesheetEditorShell", () => {
|
||||
expect(within(sheet).getByRole("heading", { name: "Semantic CSS stylesheet" })).toBeInTheDocument();
|
||||
expect(within(sheet).getByRole("button", { name: "Activate Semantic CSS" })).toBeInTheDocument();
|
||||
expect(within(sheet).getByRole("toolbar", { name: "Stylesheet editor" })).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Ready to activate with warnings")).toBeInTheDocument();
|
||||
expect(within(sheet).getByText("Unknown property")).toBeInTheDocument();
|
||||
await within(sheet).findByText("Ready to activate");
|
||||
expectGuideLink(sheet);
|
||||
expect(document.querySelectorAll(".cm-editor")).toHaveLength(1);
|
||||
media.mobile = false;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { SemanticCssColorToken } from "./color-tokens";
|
||||
import type { SemanticCssEditorMetadata } from "./protocol";
|
||||
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
|
||||
@@ -17,11 +19,20 @@ import {
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { BookOpenIcon } from "@phosphor-icons/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { convertLegacyStyleRules } from "@reactive-resume/pdf/semantic-legacy";
|
||||
import {
|
||||
buildSemanticTree,
|
||||
getTemplateSemanticManifest,
|
||||
semanticNodeKeys,
|
||||
shouldShowResumeHeader,
|
||||
} from "@reactive-resume/pdf/semantic-tree";
|
||||
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
|
||||
import { ColorPicker } from "@/components/input/color-picker";
|
||||
import { useIsResumeLocked, useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { useTheme } from "@/features/theme/provider";
|
||||
import { useBuilderSidebarStore } from "@/routes/builder/$resumeId/-store/sidebar";
|
||||
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
|
||||
@@ -29,8 +40,8 @@ import { enterStylesheetFocusMode } from "./focus-mode";
|
||||
import { formatEditorDocument } from "./formatter";
|
||||
import { LegacyStylesheetBanner } from "./legacy-banner";
|
||||
import { StylesheetStatus } from "./status";
|
||||
import { useStylesheetStore } from "./store";
|
||||
import { StylesheetToolbar } from "./toolbar";
|
||||
import { createCompileWorkerClient } from "./worker-client";
|
||||
|
||||
const externalReplacement = Annotation.define<boolean>();
|
||||
const emptyMetadata: SemanticCssEditorMetadata = {
|
||||
@@ -314,30 +325,78 @@ type StylesheetEditorShellProps = {
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const pageDimensions = (data: ResumeData) => {
|
||||
const size = data.metadata.page.format === "letter" ? { width: 612, height: 792 } : { width: 595.28, height: 841.89 };
|
||||
return data.metadata.layout.pages.map((_page, index) => ({
|
||||
pageKey: semanticNodeKeys.page(index + 1),
|
||||
...size,
|
||||
}));
|
||||
};
|
||||
|
||||
const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
|
||||
const pages = data.metadata.layout.pages.map((page, index) =>
|
||||
buildSemanticTree({
|
||||
data,
|
||||
template: data.metadata.template,
|
||||
page,
|
||||
pageNumber: index + 1,
|
||||
showHeader: shouldShowResumeHeader(data, index),
|
||||
}),
|
||||
);
|
||||
const semanticTree: SemanticNode = {
|
||||
key: semanticNodeKeys.resume(),
|
||||
kind: "resume",
|
||||
attributes: { template: data.metadata.template },
|
||||
roles: [],
|
||||
children: pages.flatMap(({ children }) => children),
|
||||
};
|
||||
return {
|
||||
semanticTree,
|
||||
templateParts: getTemplateSemanticManifest(data.metadata.template).parts.map(({ name }) => name),
|
||||
};
|
||||
};
|
||||
|
||||
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
|
||||
const { theme } = useTheme();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
|
||||
const [focusOpen, setFocusOpen] = useState(false);
|
||||
const [diagnostics, setDiagnostics] = useState<readonly SemanticCssDiagnostic[]>([]);
|
||||
const [colorTokens, setColorTokens] = useState<readonly SemanticCssColorToken[]>([]);
|
||||
const [status, setStatus] = useState<"idle" | "compiling" | "error">("compiling");
|
||||
const [compiler, setCompiler] = useState<ReturnType<typeof createCompileWorkerClient>>();
|
||||
const restoreDesktopRef = useRef<(() => void) | null>(null);
|
||||
const mode = useStylesheetStore((state) => state.mode);
|
||||
const source = useStylesheetStore((state) => state.source.text);
|
||||
const applied = useStylesheetStore((state) => state.applied.text);
|
||||
const diagnostics = useStylesheetStore((state) => state.diagnostics);
|
||||
const colorTokens = useStylesheetStore((state) => state.colorTokens);
|
||||
const metadata = useStylesheetStore((state) => state.editorMetadata);
|
||||
const status = useStylesheetStore((state) => state.status);
|
||||
const restoreLocked = useStylesheetStore((state) => state.restoreLocked);
|
||||
const canUndo = useStylesheetStore((state) => state.canUndo);
|
||||
const canRedo = useStylesheetStore((state) => state.canRedo);
|
||||
const setSourceText = useStylesheetStore((state) => state.setSourceText);
|
||||
const setFocused = useStylesheetStore((state) => state.setFocused);
|
||||
const activate = useStylesheetStore((state) => state.activate);
|
||||
const undo = useStylesheetStore((state) => state.undo);
|
||||
const redo = useStylesheetStore((state) => state.redo);
|
||||
const refreshIntelligence = useStylesheetStore((state) => state.refreshIntelligence);
|
||||
const data = useResumeData();
|
||||
const updateResumeData = useUpdateResumeData();
|
||||
const isLocked = useIsResumeLocked();
|
||||
const canUndo = useResumeStore((state) => state.canUndo);
|
||||
const canRedo = useResumeStore((state) => state.canRedo);
|
||||
const undo = useResumeStore((state) => state.undo);
|
||||
const redo = useResumeStore((state) => state.redo);
|
||||
const editorViewRef = useRef<EditorView | null>(null);
|
||||
const hasErrors = status === "error" || diagnostics.some(({ severity }) => severity === "error");
|
||||
const isChecking = status === "compiling" || status === "preflighting" || status === "saving";
|
||||
const compileGenerationRef = useRef(0);
|
||||
useEffect(() => {
|
||||
const client = createCompileWorkerClient(
|
||||
() =>
|
||||
new Worker(new URL("./stylesheet.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
name: "semantic-css-compiler",
|
||||
}),
|
||||
);
|
||||
setCompiler(client);
|
||||
return () => client.destroy();
|
||||
}, []);
|
||||
const stylesheet = data?.metadata.stylesheet;
|
||||
const mode = stylesheet?.mode ?? "legacy";
|
||||
const source = useMemo<StylesheetSource>(
|
||||
() =>
|
||||
stylesheet?.source ??
|
||||
(data ? convertLegacyStyleRules(data).source : { languageVersion: 1, text: "@version 1;\n" }),
|
||||
[data, stylesheet],
|
||||
);
|
||||
const metadata = useMemo(() => (data ? createEditorMetadata(data) : emptyMetadata), [data]);
|
||||
const hasFatalErrors = status === "error" || diagnostics.some(isFatalStylesheetDiagnostic);
|
||||
const isChecking = status === "compiling";
|
||||
const disabled = readOnly || isLocked;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -347,8 +406,59 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshIntelligence();
|
||||
}, [refreshIntelligence]);
|
||||
if (!compiler || !data) return;
|
||||
let cancelled = false;
|
||||
const editGeneration = ++compileGenerationRef.current;
|
||||
setStatus("compiling");
|
||||
setColorTokens([]);
|
||||
const timer = window.setTimeout(() => {
|
||||
void compiler
|
||||
.compile({
|
||||
editGeneration,
|
||||
source,
|
||||
semanticTree: metadata.semanticTree,
|
||||
baseSettings: {
|
||||
picture: data.picture,
|
||||
template: data.metadata.template,
|
||||
design: data.metadata.design,
|
||||
typography: data.metadata.typography,
|
||||
page: data.metadata.page,
|
||||
layout: { sidebarWidth: data.metadata.layout.sidebarWidth },
|
||||
},
|
||||
pages: pageDimensions(data),
|
||||
})
|
||||
.then((result) => {
|
||||
if (cancelled || result.editGeneration !== compileGenerationRef.current) return;
|
||||
setDiagnostics(result.diagnostics);
|
||||
setColorTokens(result.colorTokens ?? []);
|
||||
setStatus(result.program && !result.diagnostics.some(isFatalStylesheetDiagnostic) ? "idle" : "error");
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && editGeneration === compileGenerationRef.current) setStatus("error");
|
||||
});
|
||||
}, 180);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [compiler, data, metadata, source]);
|
||||
|
||||
if (!data) return null;
|
||||
|
||||
const setSourceText = (text: string) => {
|
||||
if (disabled || text === source.text) return;
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.stylesheet = { mode, source: { ...source, text } };
|
||||
});
|
||||
};
|
||||
|
||||
const activate = () => {
|
||||
if (disabled || mode === "semantic" || hasFatalErrors || isChecking) return;
|
||||
updateResumeData((draft) => {
|
||||
draft.metadata.stylesheet = { mode: "semantic", source };
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFocus = () => {
|
||||
if (isMobile) {
|
||||
@@ -374,15 +484,14 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
|
||||
|
||||
const editor = (
|
||||
<StylesheetCodeEditor
|
||||
value={source}
|
||||
value={source.text}
|
||||
diagnostics={diagnostics}
|
||||
colorTokens={colorTokens}
|
||||
metadata={metadata}
|
||||
theme={theme}
|
||||
readOnly={readOnly || restoreLocked}
|
||||
readOnly={disabled}
|
||||
label={t`Semantic CSS stylesheet`}
|
||||
onChange={setSourceText}
|
||||
onFocusChange={setFocused}
|
||||
onReady={(view) => {
|
||||
editorViewRef.current = view;
|
||||
}}
|
||||
@@ -393,22 +502,21 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
|
||||
const editorChrome = (
|
||||
<div className="space-y-3">
|
||||
{mode === "legacy" && (
|
||||
<LegacyStylesheetBanner disabled={restoreLocked || hasErrors || isChecking} onActivate={activate} />
|
||||
<LegacyStylesheetBanner disabled={disabled || hasFatalErrors || isChecking} onActivate={activate} />
|
||||
)}
|
||||
|
||||
<StylesheetToolbar
|
||||
source={source}
|
||||
source={source.text}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
focused={focusOpen}
|
||||
disabled={restoreLocked}
|
||||
disabled={disabled}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onFormat={() => {
|
||||
const view = editorViewRef.current;
|
||||
if (view) void formatEditorDocument(view).catch(() => undefined);
|
||||
}}
|
||||
onReset={() => setSourceText(applied)}
|
||||
onFocusToggle={toggleFocus}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { inspectPdfPageCount } from "./pdf-inspection";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workerDestroy: vi.fn(),
|
||||
getDocument: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||
PDFWorker: class {
|
||||
destroy = mocks.workerDestroy;
|
||||
},
|
||||
getDocument: mocks.getDocument,
|
||||
}));
|
||||
|
||||
describe("inspectPdfPageCount", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("inspects a copy through a nested worker without detaching the result buffer", async () => {
|
||||
const destroy = vi.fn();
|
||||
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||
mocks.getDocument.mockReturnValue({ promise: Promise.resolve({ numPages: 3 }), destroy });
|
||||
const pdf = Uint8Array.of(1, 2, 3, 4).buffer;
|
||||
|
||||
await expect(inspectPdfPageCount(pdf, () => nestedWorker)).resolves.toBe(3);
|
||||
|
||||
expect(mocks.getDocument).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ data: expect.any(ArrayBuffer), worker: expect.any(Object) }),
|
||||
);
|
||||
const inspectedPdf = mocks.getDocument.mock.calls[0]?.[0].data as ArrayBuffer;
|
||||
expect(inspectedPdf).not.toBe(pdf);
|
||||
expect(Array.from(new Uint8Array(inspectedPdf))).toEqual([1, 2, 3, 4]);
|
||||
expect(Array.from(new Uint8Array(pdf))).toEqual([1, 2, 3, 4]);
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("destroys the loading task and nested worker when parsing fails", async () => {
|
||||
const destroy = vi.fn();
|
||||
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||
mocks.getDocument.mockReturnValue({ promise: Promise.reject(new Error("invalid PDF")), destroy });
|
||||
|
||||
await expect(inspectPdfPageCount(new ArrayBuffer(4), () => nestedWorker)).rejects.toThrow("invalid PDF");
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("terminates the nested worker even when loading-task cleanup fails", async () => {
|
||||
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||
mocks.getDocument.mockReturnValue({
|
||||
promise: Promise.resolve({ numPages: 1 }),
|
||||
destroy: vi.fn().mockRejectedValue(new Error("cleanup failed")),
|
||||
});
|
||||
|
||||
await expect(inspectPdfPageCount(new ArrayBuffer(4), () => nestedWorker)).rejects.toThrow("cleanup failed");
|
||||
|
||||
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
let pdfModule: Promise<typeof import("pdfjs-dist/legacy/build/pdf.mjs")>;
|
||||
|
||||
const loadPdfModule = () => (pdfModule ??= import("pdfjs-dist/legacy/build/pdf.mjs"));
|
||||
|
||||
const createNestedWorker = () =>
|
||||
new Worker(new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url), {
|
||||
type: "module",
|
||||
name: "semantic-css-pdfjs",
|
||||
});
|
||||
|
||||
export async function initializePdfInspection(): Promise<void> {
|
||||
await loadPdfModule();
|
||||
}
|
||||
|
||||
export async function inspectPdfPageCount(
|
||||
pdf: ArrayBuffer,
|
||||
createWorker: () => Worker = createNestedWorker,
|
||||
): Promise<number> {
|
||||
const { PDFWorker, getDocument } = await loadPdfModule();
|
||||
const nestedWorker = createWorker();
|
||||
const WorkerWithPort = PDFWorker as unknown as new (options: { port: Worker }) => InstanceType<typeof PDFWorker>;
|
||||
const worker = new WorkerWithPort({ port: nestedWorker });
|
||||
let loadingTask: ReturnType<typeof getDocument> | undefined;
|
||||
|
||||
try {
|
||||
// PDF.js transfers its input to the nested worker and detaches the buffer.
|
||||
// Keep the caller's buffer intact so preflight can return those same bytes.
|
||||
loadingTask = getDocument({ data: pdf.slice(0), worker });
|
||||
const document = await loadingTask.promise;
|
||||
return document.numPages;
|
||||
} finally {
|
||||
try {
|
||||
if (loadingTask) await loadingTask.destroy();
|
||||
else worker.destroy();
|
||||
} finally {
|
||||
nestedWorker.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { PreflightWorkerRequest } from "./protocol";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
renderPreflightPdf: vi.fn(),
|
||||
initializePdfInspection: vi.fn(async () => undefined),
|
||||
inspectPdfPageCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/pdf/preflight", () => ({
|
||||
renderPreflightPdf: mocks.renderPreflightPdf,
|
||||
}));
|
||||
|
||||
vi.mock("./pdf-inspection", () => ({
|
||||
initializePdfInspection: mocks.initializePdfInspection,
|
||||
inspectPdfPageCount: mocks.inspectPdfPageCount,
|
||||
}));
|
||||
|
||||
describe("stylesheet preflight worker", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("serializes schema errors into a correlated preflight error packet", async () => {
|
||||
let handler: ((event: MessageEvent<PreflightWorkerRequest>) => Promise<void>) | undefined;
|
||||
const postMessage = vi.fn();
|
||||
vi.stubGlobal("self", {
|
||||
postMessage,
|
||||
addEventListener: vi.fn((_type, listener) => {
|
||||
handler = listener as typeof handler;
|
||||
}),
|
||||
});
|
||||
const issues = [{ path: ["customSections", 0, "items", 0, "company"] }];
|
||||
mocks.renderPreflightPdf.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Invalid resume data"), { name: "ZodError", issues }),
|
||||
);
|
||||
vi.resetModules();
|
||||
await import("./preflight.worker");
|
||||
|
||||
await handler?.({
|
||||
data: {
|
||||
type: "preflight",
|
||||
requestId: 7,
|
||||
editGeneration: 3,
|
||||
input: {} as never,
|
||||
limits: {} as never,
|
||||
},
|
||||
} as unknown as MessageEvent<PreflightWorkerRequest>);
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith({
|
||||
type: "preflight_error",
|
||||
requestId: 7,
|
||||
editGeneration: 3,
|
||||
cause: { name: "ZodError", message: "Invalid resume data", issues },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes a sanitized cause when PDF preflight throws an unexpected error", async () => {
|
||||
let handler: ((event: MessageEvent<PreflightWorkerRequest>) => Promise<void>) | undefined;
|
||||
const postMessage = vi.fn();
|
||||
vi.stubGlobal("self", {
|
||||
postMessage,
|
||||
addEventListener: vi.fn((_type, listener) => {
|
||||
handler = listener as typeof handler;
|
||||
}),
|
||||
});
|
||||
mocks.renderPreflightPdf.mockRejectedValueOnce(new Error("Canvas is already closed"));
|
||||
vi.resetModules();
|
||||
await import("./preflight.worker");
|
||||
|
||||
await handler?.({
|
||||
data: {
|
||||
type: "preflight",
|
||||
requestId: 8,
|
||||
editGeneration: 4,
|
||||
input: {} as never,
|
||||
limits: {} as never,
|
||||
},
|
||||
} as unknown as MessageEvent<PreflightWorkerRequest>);
|
||||
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "preflight_result",
|
||||
requestId: 8,
|
||||
editGeneration: 4,
|
||||
result: expect.objectContaining({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||
message: expect.stringContaining("Canvas is already closed"),
|
||||
diagnostics: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type { PdfPreflightFailure } from "@reactive-resume/pdf/preflight";
|
||||
import type {
|
||||
PreflightWorkerError,
|
||||
PreflightWorkerRequest,
|
||||
PreflightWorkerResponse,
|
||||
SerializedPreflightCause,
|
||||
} from "./protocol";
|
||||
import { Buffer } from "buffer";
|
||||
import { initializePdfInspection, inspectPdfPageCount } from "./pdf-inspection";
|
||||
import { getPreflightTransferables } from "./protocol";
|
||||
|
||||
Object.assign(globalThis, { Buffer });
|
||||
|
||||
const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({
|
||||
ok: false,
|
||||
code,
|
||||
message,
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
const sanitizeWorkerCause = (cause: unknown): string => {
|
||||
if (!(cause instanceof Error)) return "The PDF preflight worker failed.";
|
||||
const detail = cause.message.replace(/\s+/g, " ").trim().slice(0, 200);
|
||||
if (!detail) return "The PDF preflight worker failed.";
|
||||
return `The PDF preflight worker failed. (${cause.name}: ${detail})`;
|
||||
};
|
||||
|
||||
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
||||
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
||||
return;
|
||||
}
|
||||
return { name: cause.name, message: cause.message, issues: cause.issues };
|
||||
};
|
||||
|
||||
const initialization = Promise.all([import("@reactive-resume/pdf/preflight"), initializePdfInspection()] as const);
|
||||
void initialization.then(() => self.postMessage({ type: "preflight_ready" }));
|
||||
|
||||
self.addEventListener("message", async ({ data }: MessageEvent<PreflightWorkerRequest>) => {
|
||||
if (data.type !== "preflight") return;
|
||||
const [{ renderPreflightPdf }] = await initialization;
|
||||
let rendered: Awaited<ReturnType<typeof renderPreflightPdf>>;
|
||||
|
||||
try {
|
||||
rendered = await renderPreflightPdf(data.input, data.limits);
|
||||
} catch (cause) {
|
||||
const serializedCause = serializeZodCause(cause);
|
||||
if (serializedCause) {
|
||||
const response: PreflightWorkerError = {
|
||||
type: "preflight_error",
|
||||
requestId: data.requestId,
|
||||
editGeneration: data.editGeneration,
|
||||
cause: serializedCause,
|
||||
};
|
||||
self.postMessage(response);
|
||||
return;
|
||||
}
|
||||
const result = failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", sanitizeWorkerCause(cause));
|
||||
const response: PreflightWorkerResponse = {
|
||||
type: "preflight_result",
|
||||
requestId: data.requestId,
|
||||
editGeneration: data.editGeneration,
|
||||
result,
|
||||
};
|
||||
self.postMessage(response);
|
||||
return;
|
||||
}
|
||||
|
||||
let result: PreflightWorkerResponse["result"];
|
||||
if (!rendered.ok) {
|
||||
result = rendered;
|
||||
} else if (rendered.bytes.byteLength > data.limits.maxBytes) {
|
||||
result = failure("STYLESHEET_PREFLIGHT_BYTE_LIMIT", "The PDF exceeds the preflight byte limit.");
|
||||
} else {
|
||||
try {
|
||||
const pdf = Uint8Array.from(rendered.bytes).buffer;
|
||||
const pageCount = await inspectPdfPageCount(pdf);
|
||||
result =
|
||||
pageCount > data.limits.maxPages
|
||||
? failure("STYLESHEET_PREFLIGHT_PAGE_LIMIT", "The PDF exceeds the preflight page limit.")
|
||||
: {
|
||||
ok: true,
|
||||
pageCount,
|
||||
byteCount: pdf.byteLength,
|
||||
diagnostics: rendered.diagnostics,
|
||||
pdf,
|
||||
};
|
||||
} catch {
|
||||
result = failure("STYLESHEET_PREFLIGHT_PARSE_FAILED", "The generated PDF could not be inspected.");
|
||||
}
|
||||
}
|
||||
|
||||
const response: PreflightWorkerResponse = {
|
||||
type: "preflight_result",
|
||||
requestId: data.requestId,
|
||||
editGeneration: data.editGeneration,
|
||||
result,
|
||||
};
|
||||
self.postMessage(response, { transfer: getPreflightTransferables(response) });
|
||||
});
|
||||
@@ -1,8 +1,3 @@
|
||||
import type {
|
||||
BrowserPdfPreflightResult,
|
||||
PdfPreflightPageLimits,
|
||||
StylesheetPreflightInput,
|
||||
} from "@reactive-resume/pdf/preflight";
|
||||
import type {
|
||||
AuthoredPageContext,
|
||||
BaseSettingsSnapshot,
|
||||
@@ -39,47 +34,3 @@ export type CompileWorkerResponse = {
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
colorTokens?: readonly SemanticCssColorToken[];
|
||||
};
|
||||
|
||||
type PreflightLimits = PdfPreflightPageLimits & {
|
||||
maxPages: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
export type PreflightWorkerInput = {
|
||||
editGeneration: number;
|
||||
input: StylesheetPreflightInput;
|
||||
limits: PreflightLimits;
|
||||
};
|
||||
|
||||
export type PreflightWorkerRequest = PreflightWorkerInput & {
|
||||
type: "preflight";
|
||||
requestId: number;
|
||||
};
|
||||
|
||||
export type PreflightWorkerResponse = {
|
||||
type: "preflight_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
result: BrowserPdfPreflightResult;
|
||||
};
|
||||
|
||||
export type SerializedPreflightCause = {
|
||||
name: string;
|
||||
message: string;
|
||||
issues: readonly unknown[];
|
||||
};
|
||||
|
||||
export type PreflightWorkerError = {
|
||||
type: "preflight_error";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
cause: SerializedPreflightCause;
|
||||
};
|
||||
|
||||
export type PreflightWorkerReady = {
|
||||
type: "preflight_ready";
|
||||
};
|
||||
|
||||
export function getPreflightTransferables(response: PreflightWorkerResponse): Transferable[] {
|
||||
return response.result.ok ? [response.result.pdf] : [];
|
||||
}
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getState: vi.fn(),
|
||||
workers: [] as FakeWorker[],
|
||||
}));
|
||||
|
||||
class FakeWorker {
|
||||
terminated = false;
|
||||
|
||||
constructor() {
|
||||
mocks.workers.push(this);
|
||||
}
|
||||
|
||||
postMessage() {}
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
terminate() {
|
||||
this.terminated = true;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock("@/libs/orpc/client", () => ({
|
||||
orpc: {
|
||||
resume: {
|
||||
stylesheet: {
|
||||
getState: { call: mocks.getState },
|
||||
mutate: { call: vi.fn() },
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const stylesheet = (text: string): SemanticStylesheet => {
|
||||
const source = { languageVersion: 1, text };
|
||||
return { mode: "semantic", source, applied: source };
|
||||
};
|
||||
|
||||
describe("stylesheet store reinitialization", () => {
|
||||
beforeEach(() => {
|
||||
mocks.workers.length = 0;
|
||||
vi.stubGlobal("Worker", FakeWorker);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it("suspends edits while a delayed restore is pending, then atomically installs the restored state", async () => {
|
||||
const storeModule = await import("./store");
|
||||
const cleanup = storeModule.initializeStylesheetStore({
|
||||
resumeId: "resume-1",
|
||||
initial: { stylesheet: stylesheet("old"), revision: 3, renderDataVersion: 7 },
|
||||
resumeData: defaultResumeData,
|
||||
});
|
||||
let finishRestore: (() => void) | undefined;
|
||||
const delayedRestore = new Promise<void>((resolve) => {
|
||||
finishRestore = resolve;
|
||||
});
|
||||
const restore = async () => {
|
||||
const token = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||
await delayedRestore;
|
||||
return storeModule.replaceStylesheetStoreAfterRestore({
|
||||
resumeId: "resume-1",
|
||||
resumeData: defaultResumeData,
|
||||
initial: { stylesheet: stylesheet("restored"), revision: 9, renderDataVersion: 12 },
|
||||
token,
|
||||
});
|
||||
};
|
||||
|
||||
const pendingRestore = restore();
|
||||
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(true);
|
||||
const editGeneration = storeModule.useStylesheetStore.getState().editGeneration;
|
||||
storeModule.useStylesheetStore.getState().setSourceText("edit while restoring");
|
||||
storeModule.useStylesheetStore.getState().deactivate();
|
||||
storeModule.useStylesheetStore.getState().undo();
|
||||
expect(storeModule.useStylesheetStore.getState().source.text).toBe("old");
|
||||
expect(storeModule.useStylesheetStore.getState().editGeneration).toBe(editGeneration);
|
||||
finishRestore?.();
|
||||
const replaced = await pendingRestore;
|
||||
expect(replaced).toBe(true);
|
||||
expect(mocks.getState).not.toHaveBeenCalled();
|
||||
expect(storeModule.useStylesheetStore.getState()).toMatchObject({
|
||||
resumeId: "resume-1",
|
||||
source: { text: "restored" },
|
||||
applied: { text: "restored" },
|
||||
revision: 9,
|
||||
renderDataVersion: 12,
|
||||
restoreLocked: false,
|
||||
});
|
||||
storeModule.useStylesheetStore.getState().setSourceText("later edit");
|
||||
expect(storeModule.useStylesheetStore.getState().source.text).toBe("later edit");
|
||||
expect(mocks.workers).toHaveLength(4);
|
||||
expect(mocks.workers.slice(0, 2).every((worker) => worker.terminated)).toBe(true);
|
||||
|
||||
cleanup();
|
||||
|
||||
expect(mocks.workers.slice(2).every((worker) => worker.terminated)).toBe(true);
|
||||
expect(storeModule.useStylesheetStore.getState().resumeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("unlocks interaction after a restore request fails", async () => {
|
||||
const storeModule = await import("./store");
|
||||
const cleanup = storeModule.initializeStylesheetStore({
|
||||
resumeId: "resume-1",
|
||||
initial: { stylesheet: stylesheet("old"), revision: 3, renderDataVersion: 7 },
|
||||
resumeData: defaultResumeData,
|
||||
});
|
||||
|
||||
const token = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(true);
|
||||
expect(storeModule.unlockStylesheetStoreAfterRestore(token)).toBe(true);
|
||||
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(false);
|
||||
|
||||
storeModule.useStylesheetStore.getState().setSourceText("edit after failure");
|
||||
expect(storeModule.useStylesheetStore.getState().source.text).toBe("edit after failure");
|
||||
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("ignores a stale same-resume restore completion after away-and-back runtime replacement", async () => {
|
||||
const storeModule = await import("./store");
|
||||
const cleanupFirst = storeModule.initializeStylesheetStore({
|
||||
resumeId: "resume-1",
|
||||
initial: { stylesheet: stylesheet("first"), revision: 1, renderDataVersion: 1 },
|
||||
resumeData: defaultResumeData,
|
||||
});
|
||||
const staleToken = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||
|
||||
cleanupFirst();
|
||||
const cleanupSecond = storeModule.initializeStylesheetStore({
|
||||
resumeId: "resume-1",
|
||||
initial: { stylesheet: stylesheet("second"), revision: 2, renderDataVersion: 2 },
|
||||
resumeData: defaultResumeData,
|
||||
});
|
||||
|
||||
const replaced = storeModule.replaceStylesheetStoreAfterRestore({
|
||||
resumeId: "resume-1",
|
||||
resumeData: defaultResumeData,
|
||||
initial: { stylesheet: stylesheet("stale restore"), revision: 3, renderDataVersion: 3 },
|
||||
token: staleToken,
|
||||
});
|
||||
|
||||
expect(replaced).toBe(false);
|
||||
expect(storeModule.useStylesheetStore.getState()).toMatchObject({
|
||||
source: { text: "second" },
|
||||
revision: 2,
|
||||
renderDataVersion: 2,
|
||||
});
|
||||
|
||||
cleanupSecond();
|
||||
});
|
||||
});
|
||||
@@ -1,50 +1,67 @@
|
||||
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { WarningCircleIcon, WarningIcon } from "@phosphor-icons/react";
|
||||
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||
|
||||
export type StylesheetStatusProps = {
|
||||
mode: "legacy" | "semantic";
|
||||
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
|
||||
status: "idle" | "compiling" | "error";
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
};
|
||||
|
||||
export function StylesheetStatus({ mode, status, diagnostics }: StylesheetStatusProps) {
|
||||
const errors = diagnostics.filter(({ severity }) => severity === "error");
|
||||
const warnings = diagnostics.filter(({ severity }) => severity === "warning");
|
||||
const hasErrors = status === "error" || errors.length > 0;
|
||||
const isPending = status === "compiling" || status === "preflighting" || status === "saving";
|
||||
const hasFatalErrors = status === "error" || diagnostics.some(isFatalStylesheetDiagnostic);
|
||||
const hasRecoverableErrors = !hasFatalErrors && errors.length > 0;
|
||||
const isPending = status === "compiling";
|
||||
|
||||
return (
|
||||
<div className="space-y-2" aria-live="polite">
|
||||
{hasErrors ? (
|
||||
{hasFatalErrors ? (
|
||||
<Badge variant="destructive">
|
||||
<WarningCircleIcon data-icon="inline-start" />
|
||||
<Trans>Error</Trans>
|
||||
<Trans>Fatal error</Trans>
|
||||
</Badge>
|
||||
) : isPending ? (
|
||||
<Badge variant="outline">{mode === "legacy" ? <Trans>Checking draft</Trans> : <Trans>Checking</Trans>}</Badge>
|
||||
) : hasRecoverableErrors ? (
|
||||
<Badge variant="secondary">
|
||||
<WarningCircleIcon data-icon="inline-start" />
|
||||
{mode === "legacy" ? <Trans>Ready to activate with errors</Trans> : <Trans>Valid with errors</Trans>}
|
||||
</Badge>
|
||||
) : warnings.length > 0 ? (
|
||||
<Badge variant="secondary">
|
||||
<WarningIcon data-icon="inline-start" />
|
||||
{mode === "legacy" ? <Trans>Ready to activate with warnings</Trans> : <Trans>Applied with warnings</Trans>}
|
||||
{mode === "legacy" ? <Trans>Ready to activate with warnings</Trans> : <Trans>Valid with warnings</Trans>}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{mode === "legacy" ? <Trans>Ready to activate</Trans> : <Trans>Applied</Trans>}
|
||||
</Badge>
|
||||
<Badge variant="secondary">{mode === "legacy" ? <Trans>Ready to activate</Trans> : <Trans>Valid</Trans>}</Badge>
|
||||
)}
|
||||
|
||||
{hasErrors && (
|
||||
{hasFatalErrors && (
|
||||
<Alert variant="destructive">
|
||||
<WarningCircleIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Stylesheet has errors</Trans>
|
||||
<Trans>Stylesheet has fatal errors</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview and export use the last valid version.</Trans>
|
||||
<Trans>Preview and export fall back to base styles.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hasRecoverableErrors && (
|
||||
<Alert>
|
||||
<WarningCircleIcon />
|
||||
<AlertTitle>
|
||||
<Trans>Some styles were ignored</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>Preview and export keep valid styles and ignore invalid styles.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -1,940 +0,0 @@
|
||||
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createStylesheetStoreRuntime } from "./store";
|
||||
|
||||
const source = (text: string): StylesheetSource => ({ languageVersion: 1, text });
|
||||
const stylesheet = (text: string): SemanticStylesheet => ({
|
||||
mode: "semantic",
|
||||
source: source(text),
|
||||
applied: source(text),
|
||||
});
|
||||
|
||||
const initial = {
|
||||
stylesheet: stylesheet("generation zero"),
|
||||
revision: 3,
|
||||
renderDataVersion: 7,
|
||||
};
|
||||
|
||||
type MutationResult = {
|
||||
stylesheet: SemanticStylesheet;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
editGeneration: number;
|
||||
diagnostics: [];
|
||||
};
|
||||
|
||||
describe("stylesheet store runtime", () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
|
||||
it("clears compiler-confirmed color tokens synchronously when same-length source text changes", () => {
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial: { ...initial, stylesheet: stylesheet("section { color: red; }") },
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 1_000_000,
|
||||
compile: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
runtime.store.setState({ colorTokens: [{ from: 17, to: 20, value: "red" }] });
|
||||
|
||||
runtime.store.getState().setSourceText("section { color: var; }");
|
||||
|
||||
expect(runtime.store.getState().source.text).toBe("section { color: var; }");
|
||||
expect(runtime.store.getState().colorTokens).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects delayed editor intelligence for a canonically replaced source", async () => {
|
||||
let resolveCompile!: (value: {
|
||||
type: "compile_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
program: { languageVersion: number; rules: [] };
|
||||
diagnostics: [
|
||||
{
|
||||
code: string;
|
||||
severity: "error";
|
||||
message: string;
|
||||
range: {
|
||||
start: { line: number; column: number; offset: number };
|
||||
end: { line: number; column: number; offset: number };
|
||||
};
|
||||
},
|
||||
];
|
||||
colorTokens: [{ from: number; to: number; value: string }];
|
||||
}) => void;
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial: { ...initial, stylesheet: stylesheet("section { color: red; }") },
|
||||
resumeData: defaultResumeData,
|
||||
compile: () => new Promise((resolve) => (resolveCompile = resolve)),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
runtime.store.getState().refreshIntelligence();
|
||||
runtime.rebaseCanonical({
|
||||
stylesheet: stylesheet("section { color: blue; }"),
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
});
|
||||
resolveCompile({
|
||||
type: "compile_result",
|
||||
requestId: 1,
|
||||
editGeneration: 0,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [
|
||||
{
|
||||
code: "OLD_SOURCE",
|
||||
severity: "error",
|
||||
message: "Old source diagnostic",
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 2, offset: 1 },
|
||||
},
|
||||
},
|
||||
],
|
||||
colorTokens: [{ from: 17, to: 20, value: "red" }],
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
source: source("section { color: blue; }"),
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("consumes stale acknowledgements before saving the replaceable pending edit", async () => {
|
||||
const resolvers: Array<(value: MutationResult) => void> = [];
|
||||
const mutate = vi.fn(
|
||||
(_input: unknown) =>
|
||||
new Promise<MutationResult>((resolve) => {
|
||||
resolvers.push((value) => resolve(value));
|
||||
}),
|
||||
);
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("generation one");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.store.getState().setSourceText("generation two");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
resolvers[0]?.({
|
||||
stylesheet: stylesheet("generation one"),
|
||||
revision: 4,
|
||||
renderDataVersion: 8,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
revision: 4,
|
||||
renderDataVersion: 8,
|
||||
source: source("generation two"),
|
||||
applied: source("generation zero"),
|
||||
});
|
||||
expect(mutate).toHaveBeenCalledTimes(2);
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
expectedRevision: 4,
|
||||
expectedRenderDataVersion: 8,
|
||||
editGeneration: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rebases conflicts without dropping the focused local draft", async () => {
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce({
|
||||
code: "STYLESHEET_REVISION_CONFLICT",
|
||||
data: {
|
||||
state: { stylesheet: stylesheet("remote"), revision: 8, renderDataVersion: 11 },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("local unsaved source"),
|
||||
revision: 9,
|
||||
renderDataVersion: 11,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setFocused(true);
|
||||
runtime.store.getState().setSourceText("local unsaved source");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
revision: 9,
|
||||
renderDataVersion: 11,
|
||||
source: source("local unsaved source"),
|
||||
});
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({ expectedRevision: 8, expectedRenderDataVersion: 11 });
|
||||
});
|
||||
|
||||
it("keeps the newer pending edit when an older request conflicts", async () => {
|
||||
let rejectFirst!: (error: unknown) => void;
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("newer"),
|
||||
revision: 9,
|
||||
renderDataVersion: 11,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("older");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.store.getState().setSourceText("newer");
|
||||
await vi.runAllTimersAsync();
|
||||
rejectFirst({
|
||||
code: "STYLESHEET_REVISION_CONFLICT",
|
||||
data: { state: { stylesheet: stylesheet("remote"), revision: 8, renderDataVersion: 11 } },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
transition: "edit_source",
|
||||
editGeneration: 2,
|
||||
source: source("newer"),
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates pending preflight eligibility on content changes and keeps versions monotonic", async () => {
|
||||
let resolveMutation!: (result: MutationResult) => void;
|
||||
let resolveRepreflight!: (result: {
|
||||
type: "preflight_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
result: {
|
||||
ok: true;
|
||||
pageCount: number;
|
||||
byteCount: number;
|
||||
diagnostics: [];
|
||||
pdf: ArrayBuffer;
|
||||
};
|
||||
}) => void;
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new Promise<MutationResult>((resolve) => (resolveMutation = resolve)))
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("newer"),
|
||||
revision: 11,
|
||||
renderDataVersion: 20,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}))
|
||||
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRepreflight = resolve;
|
||||
}),
|
||||
);
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight,
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("older");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.store.getState().setSourceText("newer");
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
|
||||
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 10,
|
||||
renderDataVersion: 20,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(preflight).toHaveBeenCalledTimes(3);
|
||||
|
||||
resolveMutation({
|
||||
stylesheet: stylesheet("older"),
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(runtime.store.getState()).toMatchObject({ revision: 10, renderDataVersion: 20 });
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRepreflight({
|
||||
type: "preflight_result",
|
||||
requestId: 3,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
source: source("newer"),
|
||||
expectedRevision: 10,
|
||||
expectedRenderDataVersion: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not requeue an invalidated in-flight candidate after conflict", async () => {
|
||||
let rejectMutation!: (error: unknown) => void;
|
||||
let resolveRepreflight!: (result: {
|
||||
type: "preflight_result";
|
||||
requestId: number;
|
||||
editGeneration: number;
|
||||
result: {
|
||||
ok: true;
|
||||
pageCount: number;
|
||||
byteCount: number;
|
||||
diagnostics: [];
|
||||
pdf: ArrayBuffer;
|
||||
};
|
||||
}) => void;
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new Promise<MutationResult>((_resolve, reject) => (rejectMutation = reject)))
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("newer"),
|
||||
revision: 11,
|
||||
renderDataVersion: 20,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}))
|
||||
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}))
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveRepreflight = resolve;
|
||||
}),
|
||||
);
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight,
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("older");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.store.getState().setSourceText("newer");
|
||||
await vi.runAllTimersAsync();
|
||||
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 10,
|
||||
renderDataVersion: 20,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
rejectMutation({
|
||||
code: "STYLESHEET_REVISION_CONFLICT",
|
||||
data: { state: { stylesheet: stylesheet("remote"), revision: 10, renderDataVersion: 20 } },
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(mutate).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRepreflight({
|
||||
type: "preflight_result",
|
||||
requestId: 3,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
source: source("newer"),
|
||||
expectedRevision: 10,
|
||||
expectedRenderDataVersion: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("reconciles a deferred focused canonical source on blur", () => {
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
compile: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
runtime.store.getState().setFocused(true);
|
||||
runtime.rebaseCanonical({
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 8,
|
||||
renderDataVersion: 11,
|
||||
});
|
||||
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
source: source("generation zero"),
|
||||
applied: source("remote"),
|
||||
revision: 8,
|
||||
renderDataVersion: 11,
|
||||
});
|
||||
|
||||
runtime.store.getState().setFocused(false);
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
source: source("remote"),
|
||||
applied: source("remote"),
|
||||
});
|
||||
});
|
||||
|
||||
it("persists invalid source while preserving applied and restores stylesheet history separately", async () => {
|
||||
const mutate = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source: source("invalid {"),
|
||||
applied: source("generation zero"),
|
||||
},
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 1,
|
||||
diagnostics: [
|
||||
{
|
||||
code: "PARSE_ERROR",
|
||||
severity: "error",
|
||||
message: "Invalid",
|
||||
range: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 1, offset: 0 } },
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stylesheet: stylesheet("generation zero"),
|
||||
revision: 5,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration, source: candidate }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: candidate.text === "invalid {" ? null : { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("invalid {");
|
||||
await vi.runAllTimersAsync();
|
||||
expect(runtime.store.getState()).toMatchObject({
|
||||
source: source("invalid {"),
|
||||
applied: source("generation zero"),
|
||||
});
|
||||
expect(mutate.mock.calls[0]?.[0]).toMatchObject({ transition: "edit_source", source: source("invalid {") });
|
||||
|
||||
runtime.store.getState().undo();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||
transition: "restore_history",
|
||||
restore: stylesheet("generation zero"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not publish historical applied state before restore acknowledgement", async () => {
|
||||
const mutate = vi.fn(() => new Promise<MutationResult>(() => {}));
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial: { ...initial, stylesheet: stylesheet("current applied") },
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
runtime.store.setState({
|
||||
source: source("local invalid"),
|
||||
applied: source("current applied"),
|
||||
undoStack: [stylesheet("historical")],
|
||||
canUndo: true,
|
||||
});
|
||||
|
||||
runtime.store.getState().undo();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().source).toEqual(source("historical"));
|
||||
expect(runtime.store.getState().applied).toEqual(source("current applied"));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transition: "restore_history", restore: stylesheet("historical") }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries the focused draft against a newer content render-data version", async () => {
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
stylesheet: stylesheet("local"),
|
||||
revision: 4,
|
||||
renderDataVersion: 12,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setFocused(true);
|
||||
runtime.store.getState().setSourceText("local");
|
||||
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 9,
|
||||
renderDataVersion: 12,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().source).toEqual(source("local"));
|
||||
expect(mutate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ expectedRevision: 9, expectedRenderDataVersion: 12, source: source("local") }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a queued draft when content changes after editor blur", async () => {
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
stylesheet: stylesheet("local"),
|
||||
revision: 10,
|
||||
renderDataVersion: 12,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("local");
|
||||
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||
stylesheet: stylesheet("remote"),
|
||||
revision: 9,
|
||||
renderDataVersion: 12,
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().source).toEqual(source("local"));
|
||||
expect(mutate).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ expectedRevision: 9, expectedRenderDataVersion: 12, source: source("local") }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("queues activation only after browser preflight succeeds", async () => {
|
||||
const mutate = vi.fn().mockResolvedValue({
|
||||
stylesheet: stylesheet("generation zero"),
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 2,
|
||||
diagnostics: [],
|
||||
});
|
||||
const preflight = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
type: "preflight_result",
|
||||
requestId: 1,
|
||||
editGeneration: 1,
|
||||
result: {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
|
||||
message: "failed",
|
||||
diagnostics: [],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
type: "preflight_result",
|
||||
requestId: 2,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
});
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial: { ...initial, stylesheet: { ...initial.stylesheet, mode: "legacy" } },
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight,
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().activate();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate).not.toHaveBeenCalled();
|
||||
|
||||
runtime.store.getState().activate();
|
||||
await vi.runAllTimersAsync();
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transition: "activate", source: source("generation zero") }),
|
||||
expect.any(AbortSignal),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not leave Checking stuck when compile rejects for the current edit", async () => {
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: () => Promise.reject(new Error("Discarded stale stylesheet compiler result.")),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("edited source");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().status).not.toBe("compiling");
|
||||
expect(runtime.store.getState().status).toBe("error");
|
||||
});
|
||||
|
||||
it("surfaces browser preflight failures as diagnostics when the worker returns an empty list", async () => {
|
||||
let resolveMutate!: (value: MutationResult & { diagnostics: never[] }) => void;
|
||||
const mutate = vi.fn(
|
||||
() =>
|
||||
new Promise<MutationResult & { diagnostics: never[] }>((resolve) => {
|
||||
resolveMutate = resolve;
|
||||
}),
|
||||
);
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: async ({ editGeneration }) => ({
|
||||
type: "compile_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
}),
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||
message: "The PDF preflight worker failed.",
|
||||
diagnostics: [],
|
||||
},
|
||||
}),
|
||||
mutate,
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("@version 1;\nsection { color: teal; }");
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||
severity: "error",
|
||||
message: "The PDF preflight worker failed.",
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 1, offset: 0 },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(runtime.store.getState().diagnostics.some(({ severity }) => severity === "error")).toBe(true);
|
||||
expect(mutate).toHaveBeenCalled();
|
||||
resolveMutate({
|
||||
stylesheet: stylesheet("@version 1;\n"),
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
editGeneration: 1,
|
||||
diagnostics: [],
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
it("finishes an edit when refreshIntelligence interleaves through the shared compile client", async () => {
|
||||
const { createCompileWorkerClient } = await import("./worker-client");
|
||||
const listeners = new Map<string, Set<EventListener>>();
|
||||
const fake = {
|
||||
postMessage: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
addEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
const bucket = listeners.get(type) ?? new Set();
|
||||
bucket.add(listener);
|
||||
listeners.set(type, bucket);
|
||||
}),
|
||||
removeEventListener: vi.fn((type: string, listener: EventListener) => {
|
||||
listeners.get(type)?.delete(listener);
|
||||
}),
|
||||
emit(data: unknown) {
|
||||
for (const listener of listeners.get("message") ?? []) {
|
||||
(listener as (event: MessageEvent) => void)(new MessageEvent("message", { data }));
|
||||
}
|
||||
},
|
||||
};
|
||||
const client = createCompileWorkerClient(() => fake);
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 0,
|
||||
compile: client.compile,
|
||||
preflight: async ({ editGeneration }) => ({
|
||||
type: "preflight_result",
|
||||
requestId: editGeneration,
|
||||
editGeneration,
|
||||
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||
}),
|
||||
mutate: async ({ editGeneration }) => ({
|
||||
stylesheet: stylesheet("edited source"),
|
||||
revision: 4,
|
||||
renderDataVersion: 7,
|
||||
editGeneration,
|
||||
diagnostics: [],
|
||||
}),
|
||||
});
|
||||
|
||||
runtime.store.getState().setSourceText("edited source");
|
||||
await vi.runAllTimersAsync();
|
||||
expect(runtime.store.getState().status).toBe("compiling");
|
||||
expect(fake.postMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Mobile Design remount / accordion reopen refreshes intelligence while the edit is compiling.
|
||||
runtime.store.getState().refreshIntelligence();
|
||||
expect(fake.postMessage).toHaveBeenCalledTimes(2);
|
||||
|
||||
fake.emit({
|
||||
type: "compile_result",
|
||||
requestId: 1,
|
||||
editGeneration: 1,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
});
|
||||
fake.emit({
|
||||
type: "compile_result",
|
||||
requestId: 2,
|
||||
editGeneration: 1,
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
});
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(runtime.store.getState().status).toBe("applied");
|
||||
expect(runtime.store.getState().applied.text).toBe("edited source");
|
||||
});
|
||||
|
||||
it("terminates both worker clients and clears the store on cleanup", () => {
|
||||
const destroy = vi.fn();
|
||||
let mutationSignal: AbortSignal | undefined;
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
compile: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn((_input: unknown, signal: AbortSignal) => {
|
||||
mutationSignal = signal;
|
||||
return new Promise<MutationResult>(() => {});
|
||||
}),
|
||||
destroy,
|
||||
});
|
||||
|
||||
runtime.store.getState().deactivate();
|
||||
runtime.destroy();
|
||||
|
||||
expect(destroy).toHaveBeenCalledOnce();
|
||||
expect(mutationSignal?.aborted).toBe(true);
|
||||
expect(runtime.store.getState().resumeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("coalesces rapid source edits and bounds stylesheet history", () => {
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
resumeId: "resume-1",
|
||||
initial,
|
||||
resumeData: defaultResumeData,
|
||||
debounceMs: 1_000_000,
|
||||
compile: vi.fn(),
|
||||
preflight: vi.fn(),
|
||||
mutate: vi.fn(),
|
||||
});
|
||||
|
||||
for (let index = 0; index < 10; index++) runtime.store.getState().setSourceText(`rapid ${index}`);
|
||||
expect(runtime.store.getState().undoStack).toHaveLength(1);
|
||||
expect(runtime.store.getState().undoStack[0]).toEqual(stylesheet("generation zero"));
|
||||
|
||||
for (let index = 0; index < 60; index++) {
|
||||
vi.advanceTimersByTime(501);
|
||||
runtime.store.getState().setSourceText(`separate ${index}`);
|
||||
}
|
||||
expect(runtime.store.getState().undoStack).toHaveLength(50);
|
||||
});
|
||||
});
|
||||
@@ -1,699 +0,0 @@
|
||||
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { StoreApi } from "zustand/vanilla";
|
||||
import type { SemanticCssColorToken } from "./color-tokens";
|
||||
import type {
|
||||
CompileWorkerInput,
|
||||
CompileWorkerResponse,
|
||||
PreflightWorkerInput,
|
||||
PreflightWorkerResponse,
|
||||
SemanticCssEditorMetadata,
|
||||
} from "./protocol";
|
||||
import { create } from "zustand/react";
|
||||
import { createStore } from "zustand/vanilla";
|
||||
import {
|
||||
buildSemanticTree,
|
||||
getTemplateSemanticManifest,
|
||||
semanticNodeKeys,
|
||||
shouldShowResumeHeader,
|
||||
} from "@reactive-resume/pdf/semantic-tree";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
|
||||
|
||||
export type StylesheetCanonicalState = {
|
||||
stylesheet: SemanticStylesheet;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
};
|
||||
|
||||
type StylesheetMutationResult = StylesheetCanonicalState & {
|
||||
editGeneration: number;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
};
|
||||
|
||||
type EditMutation = {
|
||||
id: string;
|
||||
expectedRevision: number;
|
||||
expectedRenderDataVersion: number;
|
||||
editGeneration: number;
|
||||
transition: "edit_source";
|
||||
source: StylesheetSource;
|
||||
};
|
||||
|
||||
type RestoreMutation = {
|
||||
id: string;
|
||||
expectedRevision: number;
|
||||
expectedRenderDataVersion: number;
|
||||
editGeneration: number;
|
||||
transition: "restore_history";
|
||||
restore: SemanticStylesheet;
|
||||
};
|
||||
|
||||
type ActivateMutation = Omit<EditMutation, "transition"> & { transition: "activate" };
|
||||
type DeactivateMutation = Omit<EditMutation, "transition" | "source"> & { transition: "deactivate" };
|
||||
type StylesheetMutation = EditMutation | RestoreMutation | ActivateMutation | DeactivateMutation;
|
||||
|
||||
type Candidate =
|
||||
| { generation: number; transition: "edit_source"; source: StylesheetSource }
|
||||
| { generation: number; transition: "restore_history"; restore: SemanticStylesheet }
|
||||
| { generation: number; transition: "activate"; source: StylesheetSource }
|
||||
| { generation: number; transition: "deactivate" };
|
||||
|
||||
export type StylesheetStoreState = {
|
||||
resumeId?: string;
|
||||
mode: SemanticStylesheet["mode"];
|
||||
source: StylesheetSource;
|
||||
applied: StylesheetSource;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
editGeneration: number;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
colorTokens: readonly SemanticCssColorToken[];
|
||||
editorMetadata: SemanticCssEditorMetadata;
|
||||
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
|
||||
restoreLocked: boolean;
|
||||
focused: boolean;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
undoStack: SemanticStylesheet[];
|
||||
redoStack: SemanticStylesheet[];
|
||||
setSourceText(text: string): void;
|
||||
setFocused(focused: boolean): void;
|
||||
activate(): void;
|
||||
deactivate(): void;
|
||||
undo(): void;
|
||||
redo(): void;
|
||||
refreshIntelligence(): void;
|
||||
};
|
||||
|
||||
type RuntimeDependencies = {
|
||||
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse>;
|
||||
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse>;
|
||||
mutate(input: StylesheetMutation, signal: AbortSignal): Promise<StylesheetMutationResult>;
|
||||
destroy?(): void;
|
||||
};
|
||||
|
||||
type CreateStylesheetStoreRuntimeOptions = RuntimeDependencies & {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
debounceMs?: number;
|
||||
store?: StoreApi<StylesheetStoreState>;
|
||||
};
|
||||
|
||||
const emptySource = (): StylesheetSource => ({ languageVersion: 1, text: "@version 1;\n" });
|
||||
const emptySemanticTree = (): SemanticNode => ({
|
||||
key: "resume",
|
||||
kind: "resume",
|
||||
attributes: {},
|
||||
roles: [],
|
||||
children: [],
|
||||
});
|
||||
const HISTORY_COALESCE_MS = 500;
|
||||
const MAX_HISTORY_ENTRIES = 50;
|
||||
|
||||
const preflightFailureDiagnostic = (
|
||||
result: Extract<PreflightWorkerResponse["result"], { ok: false }>,
|
||||
): SemanticCssDiagnostic => ({
|
||||
code: result.code,
|
||||
severity: "error",
|
||||
message: result.message,
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 1, offset: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const inactiveState = (): Omit<
|
||||
StylesheetStoreState,
|
||||
"setSourceText" | "setFocused" | "activate" | "deactivate" | "undo" | "redo" | "refreshIntelligence"
|
||||
> => ({
|
||||
resumeId: undefined,
|
||||
mode: "legacy",
|
||||
source: emptySource(),
|
||||
applied: emptySource(),
|
||||
revision: 0,
|
||||
renderDataVersion: 0,
|
||||
editGeneration: 0,
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
editorMetadata: { semanticTree: emptySemanticTree(), templateParts: [] },
|
||||
status: "idle",
|
||||
restoreLocked: false,
|
||||
focused: false,
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
});
|
||||
|
||||
const sourceFromText = (source: StylesheetSource, text: string): StylesheetSource => ({ ...source, text });
|
||||
const sourcesEqual = (left: StylesheetSource, right: StylesheetSource) =>
|
||||
left.languageVersion === right.languageVersion && left.text === right.text;
|
||||
const isEditorFocused = () =>
|
||||
typeof document !== "undefined" && document.activeElement instanceof HTMLElement
|
||||
? document.activeElement.closest(".cm-editor") !== null
|
||||
: false;
|
||||
const currentStylesheet = (state: StylesheetStoreState): SemanticStylesheet => ({
|
||||
mode: state.mode,
|
||||
source: structuredClone(state.source),
|
||||
applied: structuredClone(state.applied),
|
||||
});
|
||||
const appendHistory = (stack: SemanticStylesheet[], value: SemanticStylesheet) =>
|
||||
[...stack, value].slice(-MAX_HISTORY_ENTRIES);
|
||||
|
||||
const pageDimensions = (data: ResumeData) => {
|
||||
const format = data.metadata.page.format;
|
||||
const size = format === "letter" ? { width: 612, height: 792 } : { width: 595.28, height: 841.89 };
|
||||
return data.metadata.layout.pages.map((_page, index) => ({
|
||||
pageKey: semanticNodeKeys.page(index + 1),
|
||||
...size,
|
||||
}));
|
||||
};
|
||||
|
||||
const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
|
||||
const pages = data.metadata.layout.pages.map((page, index) =>
|
||||
buildSemanticTree({
|
||||
data,
|
||||
template: data.metadata.template,
|
||||
page,
|
||||
pageNumber: index + 1,
|
||||
showHeader: shouldShowResumeHeader(data, index),
|
||||
}),
|
||||
);
|
||||
const semanticTree: SemanticNode = {
|
||||
key: semanticNodeKeys.resume(),
|
||||
kind: "resume",
|
||||
attributes: { template: data.metadata.template },
|
||||
roles: [],
|
||||
children: pages.flatMap(({ children }) => children),
|
||||
};
|
||||
return {
|
||||
semanticTree,
|
||||
templateParts: getTemplateSemanticManifest(data.metadata.template).parts.map(({ name }) => name),
|
||||
};
|
||||
};
|
||||
|
||||
const compileInput = (
|
||||
data: ResumeData,
|
||||
source: StylesheetSource,
|
||||
editGeneration: number,
|
||||
semanticTree: SemanticNode,
|
||||
): CompileWorkerInput => {
|
||||
return {
|
||||
editGeneration,
|
||||
source,
|
||||
semanticTree,
|
||||
baseSettings: {
|
||||
picture: data.picture,
|
||||
template: data.metadata.template,
|
||||
design: data.metadata.design,
|
||||
typography: data.metadata.typography,
|
||||
page: data.metadata.page,
|
||||
layout: { sidebarWidth: data.metadata.layout.sidebarWidth },
|
||||
},
|
||||
pages: pageDimensions(data),
|
||||
};
|
||||
};
|
||||
|
||||
const conflictState = (error: unknown): StylesheetCanonicalState | undefined => {
|
||||
if (!error || typeof error !== "object") return;
|
||||
const value = error as { code?: string; data?: { state?: StylesheetCanonicalState } };
|
||||
return value.code === "STYLESHEET_REVISION_CONFLICT" ? value.data?.state : undefined;
|
||||
};
|
||||
|
||||
export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRuntimeOptions) {
|
||||
let resumeData = structuredClone(options.resumeData);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let inFlight: Candidate | undefined;
|
||||
let pending: Candidate | undefined;
|
||||
let latestCandidate: Candidate | undefined;
|
||||
let deferredCanonical: StylesheetCanonicalState | undefined;
|
||||
let validationEpoch = 0;
|
||||
let intelligenceEpoch = 0;
|
||||
let historyLastEditAt = 0;
|
||||
let historyCanCoalesce = false;
|
||||
let destroyed = false;
|
||||
const abortController = new AbortController();
|
||||
const debounceMs = options.debounceMs ?? 180;
|
||||
const initial = options.initial.stylesheet;
|
||||
let editorMetadata = createEditorMetadata(resumeData);
|
||||
const store =
|
||||
options.store ??
|
||||
createStore<StylesheetStoreState>(() => ({
|
||||
...inactiveState(),
|
||||
setSourceText: () => {},
|
||||
setFocused: () => {},
|
||||
activate: () => {},
|
||||
deactivate: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
refreshIntelligence: () => {},
|
||||
}));
|
||||
|
||||
const patch = (next: Partial<StylesheetStoreState>) => store.setState(next);
|
||||
const replaceCanonical = (canonical: StylesheetCanonicalState, preserveSource: boolean) => {
|
||||
const state = store.getState();
|
||||
const next: Partial<StylesheetStoreState> = {
|
||||
revision: Math.max(state.revision, canonical.revision),
|
||||
renderDataVersion: Math.max(state.renderDataVersion, canonical.renderDataVersion),
|
||||
};
|
||||
if (canonical.revision >= state.revision) {
|
||||
next.mode = canonical.stylesheet.mode;
|
||||
const nextSource = preserveSource ? state.source : canonical.stylesheet.source;
|
||||
next.source = nextSource;
|
||||
next.applied = canonical.stylesheet.applied;
|
||||
if (!sourcesEqual(nextSource, state.source)) {
|
||||
intelligenceEpoch += 1;
|
||||
next.colorTokens = [];
|
||||
}
|
||||
}
|
||||
patch(next);
|
||||
};
|
||||
const resetHistoryCoalescing = () => {
|
||||
historyLastEditAt = 0;
|
||||
historyCanCoalesce = false;
|
||||
};
|
||||
|
||||
const startNext = () => {
|
||||
if (destroyed || inFlight || !pending) return;
|
||||
const candidate = pending;
|
||||
pending = undefined;
|
||||
inFlight = candidate;
|
||||
const requestValidationEpoch = validationEpoch;
|
||||
const state = store.getState();
|
||||
const common = {
|
||||
id: options.resumeId,
|
||||
expectedRevision: state.revision,
|
||||
expectedRenderDataVersion: state.renderDataVersion,
|
||||
editGeneration: candidate.generation,
|
||||
};
|
||||
let input: StylesheetMutation;
|
||||
if (candidate.transition === "edit_source" || candidate.transition === "activate") {
|
||||
input = { ...common, transition: candidate.transition, source: candidate.source };
|
||||
} else if (candidate.transition === "restore_history") {
|
||||
input = { ...common, transition: "restore_history", restore: candidate.restore };
|
||||
} else {
|
||||
input = { ...common, transition: "deactivate" };
|
||||
}
|
||||
patch({ status: "saving" });
|
||||
|
||||
void options
|
||||
.mutate(input, abortController.signal)
|
||||
.then((result) => {
|
||||
if (destroyed) return;
|
||||
const state = store.getState();
|
||||
const staleStylesheet = result.revision < state.revision;
|
||||
patch({
|
||||
revision: Math.max(state.revision, result.revision),
|
||||
renderDataVersion: Math.max(state.renderDataVersion, result.renderDataVersion),
|
||||
});
|
||||
if (result.editGeneration !== store.getState().editGeneration) return;
|
||||
if (staleStylesheet) return;
|
||||
const sourceChanged = !sourcesEqual(result.stylesheet.source, state.source);
|
||||
if (sourceChanged) intelligenceEpoch += 1;
|
||||
patch({
|
||||
mode: result.stylesheet.mode,
|
||||
source: result.stylesheet.source,
|
||||
applied: result.stylesheet.applied,
|
||||
diagnostics: result.diagnostics,
|
||||
colorTokens: sourceChanged ? [] : state.colorTokens,
|
||||
status: result.diagnostics.some(({ severity }) => severity === "error") ? "error" : "applied",
|
||||
});
|
||||
if (latestCandidate?.generation === result.editGeneration) latestCandidate = undefined;
|
||||
if (deferredCanonical && result.revision >= deferredCanonical.revision) deferredCanonical = undefined;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (destroyed) return;
|
||||
const canonical = conflictState(error);
|
||||
if (!canonical) {
|
||||
patch({ status: "error" });
|
||||
return;
|
||||
}
|
||||
replaceCanonical(canonical, true);
|
||||
if (requestValidationEpoch === validationEpoch) pending ??= candidate;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight = undefined;
|
||||
startNext();
|
||||
});
|
||||
};
|
||||
|
||||
const queue = (candidate: Candidate) => {
|
||||
latestCandidate = candidate;
|
||||
pending = candidate;
|
||||
startNext();
|
||||
};
|
||||
|
||||
const processCandidate = async (candidate: Candidate) => {
|
||||
if (destroyed || candidate.generation !== store.getState().editGeneration) return;
|
||||
const candidateValidationEpoch = validationEpoch;
|
||||
if (candidate.transition === "deactivate") {
|
||||
queue(candidate);
|
||||
return;
|
||||
}
|
||||
const source = candidate.transition === "restore_history" ? candidate.restore.applied : candidate.source;
|
||||
patch({ status: "compiling" });
|
||||
let compiled: CompileWorkerResponse;
|
||||
try {
|
||||
compiled = await options.compile(
|
||||
compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree),
|
||||
);
|
||||
} catch {
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (destroyed || candidate.generation !== store.getState().editGeneration) return;
|
||||
patch({ status: "error" });
|
||||
return;
|
||||
}
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (destroyed || compiled.editGeneration !== store.getState().editGeneration) return;
|
||||
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
|
||||
|
||||
if (!compiled.program) {
|
||||
if (candidate.transition === "edit_source") queue(candidate);
|
||||
else patch({ status: "error" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (compiled.program) {
|
||||
patch({ status: "preflighting" });
|
||||
let preflight: PreflightWorkerResponse;
|
||||
try {
|
||||
preflight = await options.preflight({
|
||||
editGeneration: candidate.generation,
|
||||
input: { data: resumeData, template: resumeData.metadata.template, stylesheet: source },
|
||||
limits: {
|
||||
maxPages: 20,
|
||||
maxBytes: 10_000_000,
|
||||
maxPageWidthPt: 2_000,
|
||||
maxPageHeightPt: 20_000,
|
||||
maxPageAreaPt2: 20_000_000,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (candidate.generation !== store.getState().editGeneration) return;
|
||||
patch({ status: "error" });
|
||||
if (candidate.transition === "edit_source") queue(candidate);
|
||||
return;
|
||||
}
|
||||
if (candidateValidationEpoch !== validationEpoch) return;
|
||||
if (destroyed || preflight.editGeneration !== store.getState().editGeneration) return;
|
||||
if (!preflight.result.ok) {
|
||||
patch({
|
||||
diagnostics: [
|
||||
...compiled.diagnostics,
|
||||
...preflight.result.diagnostics,
|
||||
preflightFailureDiagnostic(preflight.result),
|
||||
],
|
||||
status: "error",
|
||||
});
|
||||
if (candidate.transition !== "edit_source") return;
|
||||
}
|
||||
}
|
||||
|
||||
queue(candidate);
|
||||
};
|
||||
|
||||
const schedule = (candidate: Candidate) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
latestCandidate = candidate;
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void processCandidate(candidate);
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
const restore = (target: SemanticStylesheet, opposite: "undoStack" | "redoStack") => {
|
||||
const state = store.getState();
|
||||
const stack = opposite === "undoStack" ? state.undoStack : state.redoStack;
|
||||
const previous = stack.at(-1);
|
||||
if (!previous) return;
|
||||
const generation = state.editGeneration + 1;
|
||||
const other = opposite === "undoStack" ? "redoStack" : "undoStack";
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
source: previous.source,
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
[opposite]: stack.slice(0, -1),
|
||||
[other]: appendHistory(state[other], target),
|
||||
canUndo: opposite === "redoStack" || stack.length > 1,
|
||||
canRedo: opposite === "undoStack" || stack.length > 1,
|
||||
});
|
||||
schedule({ generation, transition: "restore_history", restore: previous });
|
||||
};
|
||||
|
||||
store.setState({
|
||||
resumeId: options.resumeId,
|
||||
mode: initial.mode,
|
||||
source: structuredClone(initial.source),
|
||||
applied: structuredClone(initial.applied),
|
||||
revision: options.initial.revision,
|
||||
renderDataVersion: options.initial.renderDataVersion,
|
||||
editGeneration: 0,
|
||||
diagnostics: [],
|
||||
colorTokens: [],
|
||||
editorMetadata,
|
||||
status: "idle",
|
||||
restoreLocked: false,
|
||||
focused: false,
|
||||
undoStack: [],
|
||||
redoStack: [],
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
setSourceText(text) {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || text === state.source.text) return;
|
||||
const generation = state.editGeneration + 1;
|
||||
const nextSource = sourceFromText(state.source, text);
|
||||
const now = Date.now();
|
||||
const undoStack =
|
||||
historyCanCoalesce && now - historyLastEditAt <= HISTORY_COALESCE_MS
|
||||
? state.undoStack
|
||||
: appendHistory(state.undoStack, currentStylesheet(state));
|
||||
historyLastEditAt = now;
|
||||
historyCanCoalesce = true;
|
||||
patch({
|
||||
source: nextSource,
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack,
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
schedule({ generation, transition: "edit_source", source: nextSource });
|
||||
},
|
||||
setFocused(focused) {
|
||||
patch({ focused });
|
||||
if (focused || !deferredCanonical) return;
|
||||
const canonical = deferredCanonical;
|
||||
deferredCanonical = undefined;
|
||||
const candidate = latestCandidate;
|
||||
const hasLocalDraft =
|
||||
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
replaceCanonical(canonical, hasLocalDraft);
|
||||
if (hasLocalDraft && candidate) schedule(candidate);
|
||||
else resetHistoryCoalescing();
|
||||
},
|
||||
activate() {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || state.mode === "semantic") return;
|
||||
const generation = state.editGeneration + 1;
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
schedule({ generation, transition: "activate", source: state.source });
|
||||
},
|
||||
deactivate() {
|
||||
const state = store.getState();
|
||||
if (state.restoreLocked || state.mode === "legacy") return;
|
||||
const generation = state.editGeneration + 1;
|
||||
resetHistoryCoalescing();
|
||||
patch({
|
||||
editGeneration: generation,
|
||||
colorTokens: [],
|
||||
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
|
||||
redoStack: [],
|
||||
canUndo: true,
|
||||
canRedo: false,
|
||||
});
|
||||
queue({ generation, transition: "deactivate" });
|
||||
},
|
||||
undo() {
|
||||
if (store.getState().restoreLocked) return;
|
||||
restore(currentStylesheet(store.getState()), "undoStack");
|
||||
},
|
||||
redo() {
|
||||
if (store.getState().restoreLocked) return;
|
||||
restore(currentStylesheet(store.getState()), "redoStack");
|
||||
},
|
||||
refreshIntelligence() {
|
||||
const state = store.getState();
|
||||
const generation = state.editGeneration;
|
||||
const source = structuredClone(state.source);
|
||||
const requestEpoch = ++intelligenceEpoch;
|
||||
void options
|
||||
.compile(compileInput(resumeData, source, generation, editorMetadata.semanticTree))
|
||||
.then((compiled) => {
|
||||
const current = store.getState();
|
||||
if (
|
||||
destroyed ||
|
||||
requestEpoch !== intelligenceEpoch ||
|
||||
current.editGeneration !== generation ||
|
||||
!sourcesEqual(current.source, source)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
store,
|
||||
replaceResumeSnapshot(data: ResumeData, canonical: StylesheetCanonicalState) {
|
||||
const candidate = latestCandidate;
|
||||
resumeData = structuredClone(data);
|
||||
editorMetadata = createEditorMetadata(resumeData);
|
||||
patch({ editorMetadata });
|
||||
const renderDataChanged = canonical.renderDataVersion > store.getState().renderDataVersion;
|
||||
const preserveSource = store.getState().focused || isEditorFocused() || candidate !== undefined;
|
||||
replaceCanonical(canonical, preserveSource);
|
||||
if (renderDataChanged) {
|
||||
validationEpoch += 1;
|
||||
pending = undefined;
|
||||
if (candidate) schedule(candidate);
|
||||
}
|
||||
},
|
||||
rebaseCanonical(canonical: StylesheetCanonicalState) {
|
||||
const candidate = latestCandidate;
|
||||
const hasLocalDraft =
|
||||
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
const focused = store.getState().focused || isEditorFocused();
|
||||
const sourceChanged = store.getState().source.text !== canonical.stylesheet.source.text;
|
||||
if (focused && sourceChanged && canonical.revision >= store.getState().revision) deferredCanonical = canonical;
|
||||
const preserveSource = (focused && sourceChanged) || hasLocalDraft;
|
||||
replaceCanonical(canonical, preserveSource);
|
||||
if (hasLocalDraft && candidate) schedule(candidate);
|
||||
else if (!preserveSource) resetHistoryCoalescing();
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
abortController.abort();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
pending = undefined;
|
||||
latestCandidate = undefined;
|
||||
deferredCanonical = undefined;
|
||||
options.destroy?.();
|
||||
store.setState(inactiveState());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const useStylesheetStore = create<StylesheetStoreState>(() => ({
|
||||
...inactiveState(),
|
||||
setSourceText: () => {},
|
||||
setFocused: () => {},
|
||||
activate: () => {},
|
||||
deactivate: () => {},
|
||||
undo: () => {},
|
||||
redo: () => {},
|
||||
refreshIntelligence: () => {},
|
||||
}));
|
||||
|
||||
let activeRuntime: ReturnType<typeof createStylesheetStoreRuntime> | undefined;
|
||||
declare const stylesheetRuntimeTokenBrand: unique symbol;
|
||||
export type StylesheetRuntimeToken = Readonly<{ [stylesheetRuntimeTokenBrand]: true }>;
|
||||
let activeRuntimeToken: StylesheetRuntimeToken | undefined;
|
||||
|
||||
const compilerClient = () =>
|
||||
createCompileWorkerClient(
|
||||
() =>
|
||||
new Worker(new URL("./stylesheet.worker.ts", import.meta.url), { type: "module", name: "semantic-css-compiler" }),
|
||||
);
|
||||
const preflightClient = () =>
|
||||
createPreflightWorkerClient(
|
||||
() =>
|
||||
new Worker(new URL("./preflight.worker.ts", import.meta.url), { type: "module", name: "semantic-css-preflight" }),
|
||||
5_000,
|
||||
);
|
||||
|
||||
export function initializeStylesheetStore(input: {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
}) {
|
||||
activeRuntime?.destroy();
|
||||
const compiler = compilerClient();
|
||||
const preflight = preflightClient();
|
||||
preflight.warmup();
|
||||
const runtime = createStylesheetStoreRuntime({
|
||||
...input,
|
||||
store: useStylesheetStore,
|
||||
compile: compiler.compile,
|
||||
preflight: preflight.preflight,
|
||||
mutate: (mutation, signal) => orpc.resume.stylesheet.mutate.call(mutation, { signal }),
|
||||
destroy: () => {
|
||||
compiler.destroy();
|
||||
preflight.destroy();
|
||||
},
|
||||
});
|
||||
activeRuntime = runtime;
|
||||
activeRuntimeToken = {} as StylesheetRuntimeToken;
|
||||
return () => {
|
||||
if (activeRuntime?.store.getState().resumeId !== input.resumeId) return;
|
||||
activeRuntime.destroy();
|
||||
activeRuntime = undefined;
|
||||
activeRuntimeToken = undefined;
|
||||
};
|
||||
}
|
||||
|
||||
export function lockStylesheetStoreForRestore(resumeId: string): StylesheetRuntimeToken | undefined {
|
||||
if (!activeRuntime || !activeRuntimeToken) return;
|
||||
const state = activeRuntime.store.getState();
|
||||
if (state.resumeId !== resumeId || state.restoreLocked) return;
|
||||
activeRuntime.store.setState({ restoreLocked: true });
|
||||
return activeRuntimeToken;
|
||||
}
|
||||
|
||||
export function unlockStylesheetStoreAfterRestore(token: StylesheetRuntimeToken | undefined): boolean {
|
||||
if (!activeRuntime || !token || activeRuntimeToken !== token) return false;
|
||||
activeRuntime.store.setState({ restoreLocked: false });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function replaceStylesheetStoreAfterRestore(input: {
|
||||
resumeId: string;
|
||||
initial: StylesheetCanonicalState;
|
||||
resumeData: ResumeData;
|
||||
token: StylesheetRuntimeToken | undefined;
|
||||
}): boolean {
|
||||
if (
|
||||
!activeRuntime ||
|
||||
!input.token ||
|
||||
activeRuntimeToken !== input.token ||
|
||||
activeRuntime.store.getState().resumeId !== input.resumeId ||
|
||||
!activeRuntime.store.getState().restoreLocked
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
initializeStylesheetStore(input);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function refreshStylesheetStore(resumeId: string, resumeData?: ResumeData) {
|
||||
if (!activeRuntime || activeRuntime.store.getState().resumeId !== resumeId) return;
|
||||
const canonical = await orpc.resume.stylesheet.getState.call({ id: resumeId });
|
||||
if (resumeData) activeRuntime.replaceResumeSnapshot(resumeData, canonical);
|
||||
else activeRuntime.rebaseCanonical(canonical);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
function request(source: string): CompileWorkerRequest {
|
||||
return {
|
||||
type: "compile",
|
||||
requestId: 1,
|
||||
editGeneration: 1,
|
||||
source: { languageVersion: 1, text: source },
|
||||
semanticTree: {
|
||||
key: "resume",
|
||||
kind: "resume",
|
||||
attributes: {},
|
||||
roles: [],
|
||||
children: [{ key: "name", kind: "name", attributes: {}, roles: [], children: [] }],
|
||||
},
|
||||
baseSettings: {
|
||||
picture: defaultResumeData.picture,
|
||||
template: defaultResumeData.metadata.template,
|
||||
design: defaultResumeData.metadata.design,
|
||||
typography: defaultResumeData.metadata.typography,
|
||||
page: defaultResumeData.metadata.page,
|
||||
layout: { sidebarWidth: defaultResumeData.metadata.layout.sidebarWidth },
|
||||
},
|
||||
pages: [{ pageKey: "page-1", width: 595.28, height: 841.89 }],
|
||||
};
|
||||
}
|
||||
|
||||
describe("stylesheet worker", () => {
|
||||
let handleMessage: ((event: MessageEvent<CompileWorkerRequest>) => void) | undefined;
|
||||
const postMessage = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
handleMessage = undefined;
|
||||
postMessage.mockReset();
|
||||
vi.resetModules();
|
||||
vi.stubGlobal("self", {
|
||||
addEventListener: vi.fn((type: string, listener: (event: MessageEvent<CompileWorkerRequest>) => void) => {
|
||||
if (type === "message") handleMessage = listener;
|
||||
}),
|
||||
postMessage,
|
||||
});
|
||||
await import("./stylesheet.worker");
|
||||
});
|
||||
|
||||
it("returns diagnostics from variable resolution", () => {
|
||||
handleMessage?.(
|
||||
new MessageEvent("message", {
|
||||
data: request("@version 1;\nname { color: var(--missing); }\n"),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = postMessage.mock.calls[0]?.[0] as CompileWorkerResponse | undefined;
|
||||
expect(response?.diagnostics).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: "UNRESOLVED_VARIABLE", severity: "error" })]),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns overlapping semantic diagnostics once", () => {
|
||||
handleMessage?.(
|
||||
new MessageEvent("message", {
|
||||
data: request('@version 1;\nsection[type="education"] { color: red; }\n'),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = postMessage.mock.calls[0]?.[0] as CompileWorkerResponse | undefined;
|
||||
expect(response?.diagnostics.filter(({ code }) => code === "SELECTOR_NO_MATCH")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,21 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import type { CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
|
||||
import { analyzeStylesheet, compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { compileStylesheet, resolveStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { collectCompiledColorTokens } from "./color-tokens";
|
||||
|
||||
self.addEventListener("message", ({ data }: MessageEvent<CompileWorkerRequest>) => {
|
||||
if (data.type !== "compile") return;
|
||||
const compiled = compileStylesheet(data.source);
|
||||
const diagnostics = compiled.program
|
||||
? [...compiled.diagnostics, ...analyzeStylesheet(compiled.program, data.semanticTree)]
|
||||
? [
|
||||
...compiled.diagnostics,
|
||||
...resolveStylesheet(compiled.program, data.semanticTree, {
|
||||
baseStyles: {},
|
||||
baseSettings: data.baseSettings,
|
||||
pages: data.pages,
|
||||
}).diagnostics,
|
||||
]
|
||||
: compiled.diagnostics;
|
||||
const response: CompileWorkerResponse = {
|
||||
type: "compile_result",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
ArrowCounterClockwiseIcon,
|
||||
ArrowsInIcon,
|
||||
ArrowsOutIcon,
|
||||
ArrowUUpLeftIcon,
|
||||
@@ -43,7 +42,6 @@ export type StylesheetToolbarProps = {
|
||||
onUndo(): void;
|
||||
onRedo(): void;
|
||||
onFormat(): void;
|
||||
onReset(): void;
|
||||
onFocusToggle(): void;
|
||||
};
|
||||
|
||||
@@ -56,7 +54,6 @@ export function StylesheetToolbar({
|
||||
onUndo,
|
||||
onRedo,
|
||||
onFormat,
|
||||
onReset,
|
||||
onFocusToggle,
|
||||
}: StylesheetToolbarProps) {
|
||||
return (
|
||||
@@ -73,9 +70,6 @@ export function StylesheetToolbar({
|
||||
<ToolbarButton label={t`Format stylesheet`} disabled={disabled} onClick={onFormat}>
|
||||
<MagicWandIcon data-icon="inline-start" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={t`Reset to applied stylesheet`} disabled={disabled} onClick={onReset}>
|
||||
<ArrowCounterClockwiseIcon data-icon="inline-start" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton label={focused ? t`Exit focus mode` : t`Open focus mode`} onClick={onFocusToggle}>
|
||||
{focused ? <ArrowsInIcon data-icon="inline-start" /> : <ArrowsOutIcon data-icon="inline-start" />}
|
||||
</ToolbarButton>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getPreflightTransferables } from "./protocol";
|
||||
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
|
||||
import { createCompileWorkerClient } from "./worker-client";
|
||||
|
||||
type Listener = (event: MessageEvent) => void;
|
||||
|
||||
@@ -23,15 +22,13 @@ function worker() {
|
||||
}
|
||||
},
|
||||
emitError(event: ErrorEvent) {
|
||||
for (const listener of listeners.get("error") ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
for (const listener of listeners.get("error") ?? []) listener(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("stylesheet worker clients", () => {
|
||||
it("resolves older compiler results so callers can generation-check without aborting", async () => {
|
||||
describe("stylesheet compiler worker client", () => {
|
||||
it("resolves every compiler result so the editor can generation-check", async () => {
|
||||
const fake = worker();
|
||||
const client = createCompileWorkerClient(() => fake);
|
||||
const first = client.compile({ editGeneration: 1 } as never);
|
||||
@@ -54,181 +51,14 @@ describe("stylesheet worker clients", () => {
|
||||
await expect(pending).rejects.toThrow("Failed to load compiler worker");
|
||||
});
|
||||
|
||||
it("terminates and recreates a timed-out preflight worker", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = worker();
|
||||
const replacement = worker();
|
||||
const createWorker = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement);
|
||||
const client = createPreflightWorkerClient(createWorker, 10);
|
||||
|
||||
const timedOut = client.preflight({ editGeneration: 1 } as never);
|
||||
first.emit({ type: "preflight_ready" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
await expect(timedOut).resolves.toMatchObject({
|
||||
result: { ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" },
|
||||
});
|
||||
expect(first.terminate).toHaveBeenCalledOnce();
|
||||
|
||||
const next = client.preflight({ editGeneration: 2 } as never);
|
||||
replacement.emit({ type: "preflight_ready" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
replacement.emit({
|
||||
type: "preflight_result",
|
||||
requestId: 2,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||
});
|
||||
await expect(next).resolves.toMatchObject({ requestId: 2 });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("warms the preflight worker before a request starts its deadline", () => {
|
||||
it("terminates the compiler worker and rejects pending work on destroy", async () => {
|
||||
const fake = worker();
|
||||
const createWorker = vi.fn(() => fake);
|
||||
const client = createPreflightWorkerClient(createWorker, 5_000);
|
||||
|
||||
client.warmup();
|
||||
|
||||
expect(createWorker).toHaveBeenCalledOnce();
|
||||
expect(fake.addEventListener).toHaveBeenCalledTimes(2);
|
||||
expect(fake.addEventListener).toHaveBeenCalledWith("message", expect.any(Function));
|
||||
expect(fake.addEventListener).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
expect(fake.postMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for readiness without consuming the request deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fake = worker();
|
||||
const client = createPreflightWorkerClient(() => fake, 5, 20);
|
||||
const result = client.preflight({ editGeneration: 1 } as never);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
expect(fake.terminate).not.toHaveBeenCalled();
|
||||
expect(fake.postMessage).not.toHaveBeenCalled();
|
||||
|
||||
fake.emit({ type: "preflight_ready" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fake.postMessage).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await expect(result).resolves.toMatchObject({ result: { code: "STYLESHEET_PREFLIGHT_TIMEOUT" } });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects pending preflight work when the worker emits an error event", async () => {
|
||||
const fake = worker();
|
||||
const client = createPreflightWorkerClient(() => fake, 5_000);
|
||||
const pending = client.preflight({ editGeneration: 1 } as never);
|
||||
const outcome = pending.catch((error: unknown) => error);
|
||||
|
||||
fake.emit({ type: "preflight_ready" });
|
||||
await Promise.resolve();
|
||||
fake.emitError({ message: "Worker crashed" } as ErrorEvent);
|
||||
|
||||
expect(await outcome).toMatchObject({ message: "Worker crashed" });
|
||||
expect(fake.terminate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects structured resume-data failures without waiting for the timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fake = worker();
|
||||
const client = createPreflightWorkerClient(() => fake, 5_000);
|
||||
const pending = client.preflight({ editGeneration: 1 } as never);
|
||||
const outcome = pending.catch((error: unknown) => error);
|
||||
|
||||
fake.emit({ type: "preflight_ready" });
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
fake.emit({
|
||||
type: "preflight_error",
|
||||
requestId: 1,
|
||||
editGeneration: 1,
|
||||
cause: {
|
||||
name: "ZodError",
|
||||
message: "Invalid resume data",
|
||||
issues: [{ path: ["customSections", 0, "items", 0, "company"] }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await outcome).toMatchObject({
|
||||
name: "ZodError",
|
||||
issues: [{ path: ["customSections", 0, "items", 0, "company"] }],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(fake.terminate).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("bounds readiness, recreates once, and rejects after the retry also times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const first = worker();
|
||||
const replacement = worker();
|
||||
const client = createPreflightWorkerClient(
|
||||
vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement),
|
||||
5,
|
||||
10,
|
||||
);
|
||||
const result = client.preflight({ editGeneration: 1 } as never);
|
||||
const rejection = expect(result).rejects.toThrow("did not become ready");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(first.terminate).toHaveBeenCalledOnce();
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
|
||||
await rejection;
|
||||
expect(replacement.terminate).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not recreate a warming worker after destroy", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fake = worker();
|
||||
const createWorker = vi.fn(() => fake);
|
||||
const client = createPreflightWorkerClient(createWorker, 5, 10);
|
||||
client.warmup();
|
||||
const client = createCompileWorkerClient(() => fake);
|
||||
const pending = client.compile({ editGeneration: 1 } as never);
|
||||
|
||||
client.destroy();
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(createWorker).toHaveBeenCalledOnce();
|
||||
expect(fake.terminate).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("terminates stale preflight work when a newer request starts", async () => {
|
||||
const first = worker();
|
||||
const replacement = worker();
|
||||
const client = createPreflightWorkerClient(
|
||||
vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement),
|
||||
1_000,
|
||||
);
|
||||
const stale = client.preflight({ editGeneration: 1 } as never);
|
||||
first.emit({ type: "preflight_ready" });
|
||||
await Promise.resolve();
|
||||
const staleOutcome = stale.catch((error: unknown) => error);
|
||||
const current = client.preflight({ editGeneration: 2 } as never);
|
||||
|
||||
expect(first.terminate).toHaveBeenCalledOnce();
|
||||
replacement.emit({ type: "preflight_ready" });
|
||||
await Promise.resolve();
|
||||
replacement.emit({
|
||||
type: "preflight_result",
|
||||
requestId: 2,
|
||||
editGeneration: 2,
|
||||
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||
});
|
||||
expect(await staleOutcome).toEqual(expect.objectContaining({ message: expect.stringContaining("stale") }));
|
||||
await expect(current).resolves.toMatchObject({ requestId: 2 });
|
||||
});
|
||||
|
||||
it("transfers the generated PDF buffer", () => {
|
||||
const pdf = new ArrayBuffer(4);
|
||||
expect(
|
||||
getPreflightTransferables({
|
||||
type: "preflight_result",
|
||||
requestId: 1,
|
||||
editGeneration: 1,
|
||||
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf },
|
||||
}),
|
||||
).toEqual([pdf]);
|
||||
await expect(pending).rejects.toThrow("terminated");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import type {
|
||||
CompileWorkerInput,
|
||||
CompileWorkerRequest,
|
||||
CompileWorkerResponse,
|
||||
PreflightWorkerError,
|
||||
PreflightWorkerInput,
|
||||
PreflightWorkerReady,
|
||||
PreflightWorkerRequest,
|
||||
PreflightWorkerResponse,
|
||||
} from "./protocol";
|
||||
import type { CompileWorkerInput, CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
|
||||
|
||||
type WorkerListener = (event: MessageEvent<unknown>) => void;
|
||||
type WorkerErrorListener = (event: ErrorEvent) => void;
|
||||
@@ -67,164 +58,3 @@ export function createCompileWorkerClient(createWorker: () => StylesheetWorker)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutResult = (request: PreflightWorkerRequest): PreflightWorkerResponse => ({
|
||||
type: "preflight_result",
|
||||
requestId: request.requestId,
|
||||
editGeneration: request.editGeneration,
|
||||
result: {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_TIMEOUT",
|
||||
message: "The PDF preflight exceeded its deadline.",
|
||||
diagnostics: [],
|
||||
},
|
||||
});
|
||||
|
||||
export function createPreflightWorkerClient(
|
||||
createWorker: () => StylesheetWorker,
|
||||
timeoutMs: number,
|
||||
readinessTimeoutMs = 10_000,
|
||||
) {
|
||||
let worker: StylesheetWorker | undefined;
|
||||
let requestId = 0;
|
||||
let ready = false;
|
||||
let destroyed = false;
|
||||
let readiness:
|
||||
| (Pending<StylesheetWorker> & {
|
||||
promise: Promise<StylesheetWorker>;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
})
|
||||
| undefined;
|
||||
const pending = new Map<number, Pending<PreflightWorkerResponse> & { timer?: ReturnType<typeof setTimeout> }>();
|
||||
|
||||
const onMessage: WorkerListener = ({ data }) => {
|
||||
if ((data as PreflightWorkerReady)?.type === "preflight_ready") {
|
||||
if (!worker || !readiness) return;
|
||||
clearTimeout(readiness.timer);
|
||||
ready = true;
|
||||
readiness.resolve(worker);
|
||||
readiness = undefined;
|
||||
return;
|
||||
}
|
||||
const workerError = data as PreflightWorkerError;
|
||||
if (workerError?.type === "preflight_error") {
|
||||
const request = pending.get(workerError.requestId);
|
||||
if (!request) return;
|
||||
if (request.timer) clearTimeout(request.timer);
|
||||
pending.delete(workerError.requestId);
|
||||
request.reject(
|
||||
Object.assign(new Error(workerError.cause.message), {
|
||||
name: workerError.cause.name,
|
||||
issues: workerError.cause.issues,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const response = data as PreflightWorkerResponse;
|
||||
if (response?.type !== "preflight_result") return;
|
||||
const request = pending.get(response.requestId);
|
||||
if (!request) return;
|
||||
if (request.timer) clearTimeout(request.timer);
|
||||
pending.delete(response.requestId);
|
||||
request.resolve(response);
|
||||
};
|
||||
const failPending = (error: Error) => {
|
||||
for (const request of pending.values()) {
|
||||
if (request.timer) clearTimeout(request.timer);
|
||||
request.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
const onError: WorkerErrorListener = (event) => {
|
||||
const error = new Error(event.message || "Stylesheet preflight worker failed.");
|
||||
terminate();
|
||||
failPending(error);
|
||||
};
|
||||
|
||||
const terminate = () => {
|
||||
if (!worker) return;
|
||||
worker.removeEventListener("message", onMessage);
|
||||
worker.removeEventListener("error", onError);
|
||||
worker.terminate();
|
||||
worker = undefined;
|
||||
ready = false;
|
||||
if (readiness) {
|
||||
clearTimeout(readiness.timer);
|
||||
readiness.reject(new Error("Stylesheet preflight worker did not become ready."));
|
||||
readiness = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getReadyWorker = () => {
|
||||
if (destroyed) return Promise.reject(new Error("Stylesheet preflight worker was terminated."));
|
||||
if (worker && ready) return Promise.resolve(worker);
|
||||
if (readiness) return readiness.promise;
|
||||
worker = createWorker();
|
||||
worker.addEventListener("message", onMessage);
|
||||
worker.addEventListener("error", onError);
|
||||
let resolve!: (value: StylesheetWorker) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<StylesheetWorker>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
const timer = setTimeout(() => terminate(), readinessTimeoutMs);
|
||||
readiness = { promise, resolve, reject, timer };
|
||||
return promise;
|
||||
};
|
||||
|
||||
const waitUntilReady = async () => {
|
||||
try {
|
||||
return await getReadyWorker();
|
||||
} catch {
|
||||
return await getReadyWorker();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
warmup() {
|
||||
void waitUntilReady().catch(() => {});
|
||||
},
|
||||
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse> {
|
||||
if (pending.size > 0) {
|
||||
terminate();
|
||||
for (const stale of pending.values()) {
|
||||
if (stale.timer) clearTimeout(stale.timer);
|
||||
stale.reject(new Error("Discarded stale stylesheet preflight result."));
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
const request: PreflightWorkerRequest = { ...input, type: "preflight", requestId: ++requestId };
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(request.requestId, { resolve, reject });
|
||||
void waitUntilReady()
|
||||
.then((readyWorker) => {
|
||||
const current = pending.get(request.requestId);
|
||||
if (!current) return;
|
||||
current.timer = setTimeout(() => {
|
||||
pending.delete(request.requestId);
|
||||
terminate();
|
||||
resolve(timeoutResult(request));
|
||||
}, timeoutMs);
|
||||
readyWorker.postMessage(request);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const current = pending.get(request.requestId);
|
||||
if (!current) return;
|
||||
pending.delete(request.requestId);
|
||||
reject(error instanceof Error ? error : new Error("Stylesheet preflight worker failed to start."));
|
||||
});
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
terminate();
|
||||
for (const request of pending.values()) {
|
||||
if (request.timer) clearTimeout(request.timer);
|
||||
request.reject(new Error("Stylesheet preflight worker was terminated."));
|
||||
}
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,11 +17,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||
import { useResumeStore } from "@/features/resume/builder/draft";
|
||||
import {
|
||||
lockStylesheetStoreForRestore,
|
||||
replaceStylesheetStoreAfterRestore,
|
||||
unlockStylesheetStoreAfterRestore,
|
||||
} from "@/features/resume/stylesheet/store";
|
||||
import { useConfirm } from "@/hooks/use-confirm";
|
||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||
import { formatRelativeTime } from "@/libs/locale";
|
||||
@@ -53,26 +48,13 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const token = lockStylesheetStoreForRestore(resumeId);
|
||||
if (!token) return;
|
||||
try {
|
||||
const restored = await restoreVersion({ resumeId, versionId });
|
||||
const applied = replaceStylesheetStoreAfterRestore({
|
||||
resumeId,
|
||||
resumeData: restored.resume.data,
|
||||
initial: restored.stylesheetState,
|
||||
token,
|
||||
});
|
||||
if (!applied) {
|
||||
unlockStylesheetStoreAfterRestore(token);
|
||||
return;
|
||||
}
|
||||
replaceResumeFromServer(restored.resume as Resume);
|
||||
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored.resume);
|
||||
replaceResumeFromServer(restored as Resume);
|
||||
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored);
|
||||
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
|
||||
toast.success(t`Your resume has been restored to the selected version.`);
|
||||
} catch (error) {
|
||||
unlockStylesheetStoreAfterRestore(token);
|
||||
toast.error(getResumeErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
@@ -17,16 +18,8 @@ const resumeMock = vi.hoisted(() => ({
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
data: typeof defaultResumeData;
|
||||
data: ResumeData;
|
||||
},
|
||||
stylesheet: {
|
||||
resumeId: "r1" as string | undefined,
|
||||
mode: "semantic" as "legacy" | "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname {" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||
revision: 42,
|
||||
renderDataVersion: 7,
|
||||
},
|
||||
}));
|
||||
|
||||
type SectionBaseProps = {
|
||||
@@ -50,9 +43,6 @@ vi.mock("@/libs/resume/section-title-locale", () => ({
|
||||
vi.mock("@/features/resume/builder/draft", () => ({
|
||||
useResume: () => resumeMock.resume,
|
||||
}));
|
||||
vi.mock("@/features/resume/stylesheet/store", () => ({
|
||||
useStylesheetStore: (selector: (state: typeof resumeMock.stylesheet) => unknown) => selector(resumeMock.stylesheet),
|
||||
}));
|
||||
|
||||
const { ExportSectionBuilder } = await import("./export");
|
||||
|
||||
@@ -61,15 +51,12 @@ beforeAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
|
||||
resumeMock.stylesheet = {
|
||||
resumeId: "r1",
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname {" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||
revision: 42,
|
||||
renderDataVersion: 7,
|
||||
};
|
||||
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -115,7 +102,7 @@ describe("ExportSectionBuilder", () => {
|
||||
expect(filename).toBe("My Resume.md");
|
||||
});
|
||||
|
||||
it("downloads canonical stylesheet content in JSON without concurrency metadata", async () => {
|
||||
it("downloads the current stylesheet source in JSON", async () => {
|
||||
renderExport();
|
||||
openDialog();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
|
||||
@@ -127,13 +114,7 @@ describe("ExportSectionBuilder", () => {
|
||||
expect((blob as Blob).type).toBe("application/json");
|
||||
expect(filename).toBe("My Resume.json");
|
||||
const exported = JSON.parse(await (blob as Blob).text());
|
||||
expect(exported.metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: resumeMock.stylesheet.source,
|
||||
applied: resumeMock.stylesheet.applied,
|
||||
});
|
||||
expect(JSON.stringify(exported)).not.toContain("revision");
|
||||
expect(JSON.stringify(exported)).not.toContain("renderDataVersion");
|
||||
expect(exported.metadata.stylesheet).toEqual(resumeMock.resume?.data.metadata.stylesheet);
|
||||
});
|
||||
|
||||
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
|
||||
@@ -155,12 +136,7 @@ describe("ExportSectionBuilder", () => {
|
||||
await Promise.resolve();
|
||||
|
||||
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
|
||||
expect(createResumePdfBlob).toHaveBeenCalledWith(defaultResumeData, undefined, undefined, {
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
applied: resumeMock.stylesheet.applied,
|
||||
},
|
||||
});
|
||||
expect(createResumePdfBlob).toHaveBeenCalledWith(resumeMock.resume?.data, undefined, undefined);
|
||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { BuilderLayout } from "./-store/sidebar";
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useMediaQuery } from "usehooks-ts";
|
||||
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
|
||||
import { initializeStylesheetStore, useStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||
import { orpc } from "@/libs/orpc/client";
|
||||
import { createNoindexFollowMeta } from "@/libs/seo";
|
||||
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
|
||||
@@ -21,9 +20,6 @@ export const Route = createFileRoute("/builder/$resumeId")({
|
||||
const [layout, resume] = await Promise.all([
|
||||
getBuilderLayout(),
|
||||
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
|
||||
context.queryClient.ensureQueryData(
|
||||
orpc.resume.stylesheet.getState.queryOptions({ input: { id: params.resumeId } }),
|
||||
),
|
||||
]);
|
||||
|
||||
return { layout, name: resume.name };
|
||||
@@ -40,17 +36,11 @@ function RouteComponent() {
|
||||
|
||||
const { resumeId } = Route.useParams();
|
||||
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
|
||||
const { data: stylesheet } = useSuspenseQuery(
|
||||
orpc.resume.stylesheet.getState.queryOptions({ input: { id: resumeId } }),
|
||||
);
|
||||
const initializeResumeStore = useResumeStore((state) => state.initialize);
|
||||
const mergeResumeMetadata = useResumeStore((state) => state.mergeResumeMetadata);
|
||||
const isReady = useResumeStore((state) => state.isReady);
|
||||
const initializedResumeId = useResumeStore((state) => state.resumeId);
|
||||
const isInitialized = isReady && initializedResumeId === resumeId;
|
||||
const isStylesheetInitialized = useStylesheetStore((state) => state.resumeId === resumeId);
|
||||
const stylesheetInitialization = useRef({ resume, stylesheet });
|
||||
stylesheetInitialization.current = { resume, stylesheet };
|
||||
|
||||
useResumeCleanup();
|
||||
useBuilderResumeUpdateSubscription();
|
||||
@@ -60,16 +50,6 @@ function RouteComponent() {
|
||||
initializeResumeStore(resume);
|
||||
}, [initializeResumeStore, isInitialized, resume]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInitialized) return;
|
||||
const initial = stylesheetInitialization.current;
|
||||
return initializeStylesheetStore({
|
||||
resumeId,
|
||||
initial: initial.stylesheet,
|
||||
resumeData: initial.resume.data,
|
||||
});
|
||||
}, [isInitialized, resumeId]);
|
||||
|
||||
useEffect(() => {
|
||||
mergeResumeMetadata(resume);
|
||||
}, [
|
||||
@@ -85,7 +65,7 @@ function RouteComponent() {
|
||||
resume,
|
||||
]);
|
||||
|
||||
if (!isInitialized || !isStylesheetInitialized) return null;
|
||||
if (!isInitialized) return null;
|
||||
|
||||
return <BuilderLayoutShell initialLayout={initialLayout} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user