mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-24 23:32:19 +10:00
fix(stylesheet): skip parity on explicit activation (#3316)
This commit is contained in:
@@ -10921,46 +10921,6 @@
|
|||||||
"data"
|
"data"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"defined": {
|
|
||||||
"const": true
|
|
||||||
},
|
|
||||||
"code": {
|
|
||||||
"const": "STYLESHEET_PARITY_FAILED"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"const": 400
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"type": "string",
|
|
||||||
"default": "The converted stylesheet does not preserve legacy PDF presentation."
|
|
||||||
},
|
|
||||||
"data": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"mismatches": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"mismatches"
|
|
||||||
],
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"defined",
|
|
||||||
"code",
|
|
||||||
"status",
|
|
||||||
"message",
|
|
||||||
"data"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -202,9 +202,6 @@ export const resumeDto = {
|
|||||||
validation: z.strictObject({
|
validation: z.strictObject({
|
||||||
diagnostics: z.array(stylesheetDiagnosticSchema),
|
diagnostics: z.array(stylesheetDiagnosticSchema),
|
||||||
}),
|
}),
|
||||||
parity: z.strictObject({
|
|
||||||
mismatches: z.array(z.string()),
|
|
||||||
}),
|
|
||||||
revisionConflict: z.strictObject({
|
revisionConflict: z.strictObject({
|
||||||
state: stylesheetStateSchema,
|
state: stylesheetStateSchema,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ type SemanticCssEventName =
|
|||||||
| "semantic_css.compile"
|
| "semantic_css.compile"
|
||||||
| "semantic_css.preflight"
|
| "semantic_css.preflight"
|
||||||
| "semantic_css.convert_legacy"
|
| "semantic_css.convert_legacy"
|
||||||
| "semantic_css.parity_check"
|
|
||||||
| "semantic_css.activate";
|
| "semantic_css.activate";
|
||||||
|
|
||||||
export type SemanticCssEventInput = {
|
export type SemanticCssEventInput = {
|
||||||
|
|||||||
@@ -3,17 +3,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||||
import {
|
import { prepareImportedResumeData, validateHistoricalStylesheet } from "./stylesheet-preflight";
|
||||||
checkLegacyStylesheetParity,
|
|
||||||
prepareImportedResumeData,
|
|
||||||
validateHistoricalStylesheet,
|
|
||||||
} from "./stylesheet-preflight";
|
|
||||||
|
|
||||||
const semanticPdfMocks = vi.hoisted(() => ({
|
|
||||||
compareLegacySemanticPresentation: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@reactive-resume/pdf/semantic", () => semanticPdfMocks);
|
|
||||||
|
|
||||||
const validSource = {
|
const validSource = {
|
||||||
languageVersion: 1,
|
languageVersion: 1,
|
||||||
@@ -47,7 +37,6 @@ const createRunner = () => {
|
|||||||
describe("stylesheet persistence preparation", () => {
|
describe("stylesheet persistence preparation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
semanticPdfMocks.compareLegacySemanticPresentation.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses a valid imported source as applied only after PDF preflight", async () => {
|
it("uses a valid imported source as applied only after PDF preflight", async () => {
|
||||||
@@ -171,32 +160,4 @@ describe("stylesheet persistence preparation", () => {
|
|||||||
expect(serialized).not.toContain(validSource.text);
|
expect(serialized).not.toContain(validSource.text);
|
||||||
expect(serialized).not.toContain("import-observed");
|
expect(serialized).not.toContain("import-observed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records a sanitized parity failure metric when the comparator throws", async () => {
|
|
||||||
const privateText = "private parity failure john.doe@example.com";
|
|
||||||
const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
|
||||||
semanticPdfMocks.compareLegacySemanticPresentation.mockRejectedValueOnce(new Error(privateText));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
checkLegacyStylesheetParity({
|
|
||||||
data: resumeData(),
|
|
||||||
stylesheet: validSource,
|
|
||||||
resumeId: "parity-observed",
|
|
||||||
revision: 5,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(privateText);
|
|
||||||
|
|
||||||
expect(log.mock.calls.map(([event]) => event)).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: "semantic_css.parity_check",
|
|
||||||
durationMs: expect.any(Number),
|
|
||||||
diagnosticCodes: [],
|
|
||||||
success: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const serialized = JSON.stringify(log.mock.calls);
|
|
||||||
expect(serialized).not.toContain(privateText);
|
|
||||||
expect(serialized).not.toContain(validSource.text);
|
|
||||||
expect(serialized).not.toContain("parity-observed");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { StylesheetSnapshot } from "./stylesheet-service";
|
|||||||
import { ORPCError } from "@orpc/client";
|
import { ORPCError } from "@orpc/client";
|
||||||
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||||
import { templateSchema } from "@reactive-resume/schema/templates";
|
|
||||||
import { recordSemanticCssEvent } from "./stylesheet-observability";
|
import { recordSemanticCssEvent } from "./stylesheet-observability";
|
||||||
|
|
||||||
type PrepareImportedResumeDataInput = {
|
type PrepareImportedResumeDataInput = {
|
||||||
@@ -191,47 +190,3 @@ export async function convertLegacyStylesheet(snapshot: StylesheetSnapshot): Pro
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkLegacyStylesheetParity(input: {
|
|
||||||
data: ResumeData;
|
|
||||||
stylesheet: StylesheetSource;
|
|
||||||
resumeId: string;
|
|
||||||
revision: number;
|
|
||||||
}): Promise<{ mismatches: readonly string[] }> {
|
|
||||||
const startedAt = performance.now();
|
|
||||||
try {
|
|
||||||
const { compareLegacySemanticPresentation } = await import("@reactive-resume/pdf/semantic");
|
|
||||||
const result = await compareLegacySemanticPresentation({
|
|
||||||
data: input.data,
|
|
||||||
convertedSource: input.stylesheet,
|
|
||||||
templates: templateSchema.options,
|
|
||||||
});
|
|
||||||
recordSemanticCssEvent({
|
|
||||||
name: "semantic_css.parity_check",
|
|
||||||
resumeId: input.resumeId,
|
|
||||||
durationMs: performance.now() - startedAt,
|
|
||||||
languageVersion: input.stylesheet.languageVersion,
|
|
||||||
sourceBytes: byteCount(input.stylesheet),
|
|
||||||
template: input.data.metadata.template,
|
|
||||||
diagnosticCodes: [],
|
|
||||||
pageCount: null,
|
|
||||||
revision: input.revision,
|
|
||||||
success: result.mismatches.length === 0,
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
} catch (error) {
|
|
||||||
recordSemanticCssEvent({
|
|
||||||
name: "semantic_css.parity_check",
|
|
||||||
resumeId: input.resumeId,
|
|
||||||
durationMs: performance.now() - startedAt,
|
|
||||||
languageVersion: input.stylesheet.languageVersion,
|
|
||||||
sourceBytes: byteCount(input.stylesheet),
|
|
||||||
template: input.data.metadata.template,
|
|
||||||
diagnosticCodes: [],
|
|
||||||
pageCount: null,
|
|
||||||
revision: input.revision,
|
|
||||||
success: false,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -56,14 +56,6 @@ describe("resume stylesheet route error contract", () => {
|
|||||||
}>();
|
}>();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exposes strict parity mismatch data at runtime and in the inferred client error", () => {
|
|
||||||
const schema = dataSchema("STYLESHEET_PARITY_FAILED");
|
|
||||||
|
|
||||||
expect(schema.safeParse({ mismatches: ["onyx: page 1"] }).success).toBe(true);
|
|
||||||
expect(schema.safeParse({ mismatches: ["onyx: page 1"], source }).success).toBe(false);
|
|
||||||
expectTypeOf<ErrorData<"STYLESHEET_PARITY_FAILED">>().toEqualTypeOf<{ mismatches: string[] }>();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes strict canonical conflict state for type-safe client rebasing", () => {
|
it("exposes strict canonical conflict state for type-safe client rebasing", () => {
|
||||||
const schema = dataSchema("STYLESHEET_REVISION_CONFLICT");
|
const schema = dataSchema("STYLESHEET_REVISION_CONFLICT");
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,6 @@ type HarnessOptions = {
|
|||||||
initial?: StylesheetSnapshot;
|
initial?: StylesheetSnapshot;
|
||||||
compile?: (source: StylesheetSource) => CompileStylesheetResult;
|
compile?: (source: StylesheetSource) => CompileStylesheetResult;
|
||||||
preflight?: ((input: { data: ResumeData; stylesheet: StylesheetSource }) => Promise<PdfPreflightResult>) | undefined;
|
preflight?: ((input: { data: ResumeData; stylesheet: StylesheetSource }) => Promise<PdfPreflightResult>) | undefined;
|
||||||
parityMismatches?: readonly string[];
|
|
||||||
parity?: () => Promise<{ mismatches: readonly string[] }>;
|
|
||||||
locked?: StylesheetSnapshot;
|
locked?: StylesheetSnapshot;
|
||||||
useDefaultObserver?: boolean;
|
useDefaultObserver?: boolean;
|
||||||
};
|
};
|
||||||
@@ -108,10 +106,6 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|||||||
callOrder.push("preflight");
|
callOrder.push("preflight");
|
||||||
return Promise.resolve(successfulPreflight);
|
return Promise.resolve(successfulPreflight);
|
||||||
});
|
});
|
||||||
const parity = vi.fn(() => {
|
|
||||||
callOrder.push("parity");
|
|
||||||
return options.parity?.() ?? Promise.resolve({ mismatches: options.parityMismatches ?? [] });
|
|
||||||
});
|
|
||||||
const publish = vi.fn(() => {
|
const publish = vi.fn(() => {
|
||||||
callOrder.push("publish");
|
callOrder.push("publish");
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
@@ -134,7 +128,6 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|||||||
callOrder.push("preflight");
|
callOrder.push("preflight");
|
||||||
return preflight(input);
|
return preflight(input);
|
||||||
},
|
},
|
||||||
parity,
|
|
||||||
transaction: async (run) => {
|
transaction: async (run) => {
|
||||||
callOrder.push("begin");
|
callOrder.push("begin");
|
||||||
try {
|
try {
|
||||||
@@ -171,7 +164,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|||||||
...(options.useDefaultObserver ? {} : { observe: (event: SemanticCssEventInput) => events.push(event) }),
|
...(options.useDefaultObserver ? {} : { observe: (event: SemanticCssEventInput) => events.push(event) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
return { callOrder, compile, events, parity, persisted: () => persisted, preflight, publish, service };
|
return { callOrder, compile, events, persisted: () => persisted, preflight, publish, service };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("stylesheet service", () => {
|
describe("stylesheet service", () => {
|
||||||
@@ -262,7 +255,7 @@ describe("stylesheet service", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires parity and preflight before explicit activation", async () => {
|
it("activates an explicitly requested mode transition without checking legacy parity", async () => {
|
||||||
const initial = snapshot({ ...previousStylesheet, mode: "legacy" });
|
const initial = snapshot({ ...previousStylesheet, mode: "legacy" });
|
||||||
const harness = createHarness({ initial });
|
const harness = createHarness({ initial });
|
||||||
|
|
||||||
@@ -273,7 +266,6 @@ describe("stylesheet service", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.stylesheet).toEqual({ mode: "semantic", source: validSource, applied: validSource });
|
expect(result.stylesheet).toEqual({ mode: "semantic", source: validSource, applied: validSource });
|
||||||
expect(harness.parity).toHaveBeenCalledOnce();
|
|
||||||
expect(harness.preflight).toHaveBeenCalledOnce();
|
expect(harness.preflight).toHaveBeenCalledOnce();
|
||||||
expect(result.revision).toBe(4);
|
expect(result.revision).toBe(4);
|
||||||
expect(result.renderDataVersion).toBe(8);
|
expect(result.renderDataVersion).toBe(8);
|
||||||
@@ -342,35 +334,6 @@ describe("stylesheet service", () => {
|
|||||||
expect(serialized).not.toContain("resume-1");
|
expect(serialized).not.toContain("resume-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records a sanitized activation failure metric when parity throws", async () => {
|
|
||||||
const privateText = "private parity failure john.doe@example.com";
|
|
||||||
const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
|
||||||
const harness = createHarness({
|
|
||||||
initial: snapshot({ ...previousStylesheet, mode: "legacy" }),
|
|
||||||
parity: () => Promise.reject(new Error(privateText)),
|
|
||||||
useDefaultObserver: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
harness.service.mutate({ ...commonMutationInput, transition: "activate", source: validSource }),
|
|
||||||
).rejects.toThrow(privateText);
|
|
||||||
|
|
||||||
expect(log.mock.calls.map(([event]) => event)).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
name: "semantic_css.activate",
|
|
||||||
durationMs: expect.any(Number),
|
|
||||||
diagnosticCodes: [],
|
|
||||||
pageCount: null,
|
|
||||||
revision: 3,
|
|
||||||
success: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const serialized = JSON.stringify(log.mock.calls);
|
|
||||||
expect(serialized).not.toContain(privateText);
|
|
||||||
expect(serialized).not.toContain(validSource.text);
|
|
||||||
expect(serialized).not.toContain("resume-1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deactivates without compiling or deleting either source", async () => {
|
it("deactivates without compiling or deleting either source", async () => {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
|
|
||||||
@@ -378,7 +341,6 @@ describe("stylesheet service", () => {
|
|||||||
|
|
||||||
expect(result.stylesheet).toEqual({ ...previousStylesheet, mode: "legacy" });
|
expect(result.stylesheet).toEqual({ ...previousStylesheet, mode: "legacy" });
|
||||||
expect(harness.compile).not.toHaveBeenCalled();
|
expect(harness.compile).not.toHaveBeenCalled();
|
||||||
expect(harness.parity).not.toHaveBeenCalled();
|
|
||||||
expect(harness.preflight).not.toHaveBeenCalled();
|
expect(harness.preflight).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/styles
|
|||||||
import { publishResumeUpdated } from "./events";
|
import { publishResumeUpdated } from "./events";
|
||||||
import { parseStoredResumeData } from "./resume-data-validation";
|
import { parseStoredResumeData } from "./resume-data-validation";
|
||||||
import { recordSemanticCssEvent } from "./stylesheet-observability";
|
import { recordSemanticCssEvent } from "./stylesheet-observability";
|
||||||
import { checkLegacyStylesheetParity, convertLegacyStylesheet } from "./stylesheet-preflight";
|
import { convertLegacyStylesheet } from "./stylesheet-preflight";
|
||||||
|
|
||||||
export type StylesheetSnapshot = {
|
export type StylesheetSnapshot = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -68,12 +68,6 @@ type StylesheetServiceDependencies = {
|
|||||||
convertLegacy(snapshot: StylesheetSnapshot): StylesheetSource | Promise<StylesheetSource>;
|
convertLegacy(snapshot: StylesheetSnapshot): StylesheetSource | Promise<StylesheetSource>;
|
||||||
compile(source: StylesheetSource): CompileStylesheetResult;
|
compile(source: StylesheetSource): CompileStylesheetResult;
|
||||||
preflight?(input: { data: ResumeData; stylesheet: StylesheetSource }): Promise<PdfPreflightResult>;
|
preflight?(input: { data: ResumeData; stylesheet: StylesheetSource }): Promise<PdfPreflightResult>;
|
||||||
parity(input: {
|
|
||||||
data: ResumeData;
|
|
||||||
stylesheet: StylesheetSource;
|
|
||||||
resumeId: string;
|
|
||||||
revision: number;
|
|
||||||
}): Promise<{ mismatches: readonly string[] }>;
|
|
||||||
transaction<T>(run: (transaction: StylesheetTransaction) => Promise<T>): Promise<T>;
|
transaction<T>(run: (transaction: StylesheetTransaction) => Promise<T>): Promise<T>;
|
||||||
compare(snapshot: StylesheetSnapshot, input: StylesheetMutationCommon): boolean | Promise<boolean>;
|
compare(snapshot: StylesheetSnapshot, input: StylesheetMutationCommon): boolean | Promise<boolean>;
|
||||||
publish(snapshot: StylesheetSnapshot): Promise<void>;
|
publish(snapshot: StylesheetSnapshot): Promise<void>;
|
||||||
@@ -262,20 +256,6 @@ export function createStylesheetService(dependencies: StylesheetServiceDependenc
|
|||||||
throw validationError("The stylesheet cannot be activated because it is invalid.", compiled.diagnostics);
|
throw validationError("The stylesheet cannot be activated because it is invalid.", compiled.diagnostics);
|
||||||
}
|
}
|
||||||
|
|
||||||
const parity = await dependencies.parity({
|
|
||||||
data: snapshot.data,
|
|
||||||
stylesheet: input.source,
|
|
||||||
resumeId: snapshot.id,
|
|
||||||
revision: snapshot.stylesheetRevision,
|
|
||||||
});
|
|
||||||
if (parity.mismatches.length > 0) {
|
|
||||||
throw new ORPCError("STYLESHEET_PARITY_FAILED", {
|
|
||||||
status: 400,
|
|
||||||
message: "The converted stylesheet does not preserve the legacy PDF presentation.",
|
|
||||||
data: { mismatches: parity.mismatches },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const preflight = await runPreflight(snapshot, input.source);
|
const preflight = await runPreflight(snapshot, input.source);
|
||||||
didPreflight = true;
|
didPreflight = true;
|
||||||
activationPageCount = preflight.ok ? preflight.pageCount : null;
|
activationPageCount = preflight.ok ? preflight.pageCount : null;
|
||||||
@@ -397,7 +377,6 @@ export function createDatabaseStylesheetService(options: DatabaseStylesheetServi
|
|||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
parity: checkLegacyStylesheetParity,
|
|
||||||
transaction: (run) =>
|
transaction: (run) =>
|
||||||
database.transaction((transaction) =>
|
database.transaction((transaction) =>
|
||||||
run({
|
run({
|
||||||
|
|||||||
@@ -13,11 +13,6 @@ const errors = {
|
|||||||
status: 400,
|
status: 400,
|
||||||
data: resumeDto.stylesheet.errors.validation,
|
data: resumeDto.stylesheet.errors.validation,
|
||||||
},
|
},
|
||||||
STYLESHEET_PARITY_FAILED: {
|
|
||||||
message: "The converted stylesheet does not preserve legacy PDF presentation.",
|
|
||||||
status: 400,
|
|
||||||
data: resumeDto.stylesheet.errors.parity,
|
|
||||||
},
|
|
||||||
STYLESHEET_REVISION_CONFLICT: {
|
STYLESHEET_REVISION_CONFLICT: {
|
||||||
message: "The resume or stylesheet changed while the candidate was being validated.",
|
message: "The resume or stylesheet changed while the candidate was being validated.",
|
||||||
status: 409,
|
status: 409,
|
||||||
|
|||||||
Reference in New Issue
Block a user