mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-22 14:22:16 +10:00
refactor(stylesheet): move Semantic CSS to the browser (#3329)
This commit is contained in:
+4
-2
@@ -28,7 +28,8 @@ Run tests:
|
||||
|
||||
## Semantic CSS
|
||||
|
||||
Run opt-in conversion, editing, conflict, last-valid, default-mode, and visual acceptance:
|
||||
Run the six Semantic CSS scenarios: default mode, legacy conversion, source-only autosave preservation, portable
|
||||
selector rendering, break-inside pagination, and all-template visual regression:
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test tests/e2e/specs/semantic-css
|
||||
@@ -48,6 +49,7 @@ pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts \
|
||||
- Builder basics edit and autosave persistence.
|
||||
- JSON export/import.
|
||||
- Public sharing for anonymous visitors.
|
||||
- Semantic CSS legacy conversion, last-valid recovery, portability, revision conflicts, and all-template visual regression.
|
||||
- Semantic CSS default mode, legacy conversion, source-only autosave preservation, portable selector rendering,
|
||||
break-inside pagination, and all-template visual regression.
|
||||
|
||||
PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate.
|
||||
|
||||
@@ -30,14 +30,12 @@ export async function deleteE2EUser(account: E2EAccount) {
|
||||
type SemanticStylesheetSeed = {
|
||||
mode: "legacy" | "semantic";
|
||||
source: { languageVersion: number; text: string };
|
||||
applied: { languageVersion: number; text: string };
|
||||
};
|
||||
|
||||
export async function updateSemanticCssFixture(
|
||||
resumeId: string,
|
||||
update: {
|
||||
stylesheet?: SemanticStylesheetSeed;
|
||||
bumpRevision?: boolean;
|
||||
portableLayout?: "balanced" | "pagination-stress";
|
||||
experienceItemId?: string;
|
||||
legacyStyleRule?: boolean;
|
||||
@@ -136,23 +134,9 @@ export async function updateSemanticCssFixture(
|
||||
await pool.query(
|
||||
`update "resume"
|
||||
set data = $2,
|
||||
stylesheet_revision = stylesheet_revision + $3,
|
||||
render_data_version = render_data_version + $4,
|
||||
updated_at = now()
|
||||
where id = $1`,
|
||||
[
|
||||
resumeId,
|
||||
data,
|
||||
update.bumpRevision || update.stylesheet || update.legacyStyleRule ? 1 : 0,
|
||||
update.stylesheet ||
|
||||
update.portableLayout ||
|
||||
update.experienceItemId ||
|
||||
update.legacyStyleRule ||
|
||||
update.hidePicture ||
|
||||
update.basicsName
|
||||
? 1
|
||||
: 0,
|
||||
],
|
||||
[resumeId, data],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
@@ -168,24 +152,12 @@ export async function readSemanticCssFixture(resumeId: string) {
|
||||
basics?: { headline?: string };
|
||||
metadata?: { stylesheet?: SemanticStylesheetSeed };
|
||||
};
|
||||
slug: string;
|
||||
stylesheet_revision: number;
|
||||
username: string;
|
||||
}>(
|
||||
`select r.data, r.slug, r.stylesheet_revision, u.username
|
||||
from "resume" r
|
||||
inner join "user" u on u.id = r.user_id
|
||||
where r.id = $1`,
|
||||
[resumeId],
|
||||
);
|
||||
}>(`select data from "resume" where id = $1`, [resumeId]);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error(`Resume ${resumeId} was not found.`);
|
||||
return {
|
||||
stylesheet: row.data.metadata?.stylesheet,
|
||||
headline: row.data.basics?.headline,
|
||||
revision: row.stylesheet_revision,
|
||||
slug: row.slug,
|
||||
username: row.username,
|
||||
};
|
||||
} finally {
|
||||
await pool.end();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Download, Locator, Page, TestInfo } from "@playwright/test";
|
||||
import type { Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { updateSemanticCssFixture } from "./db";
|
||||
import { ACTIVE_PREVIEW_PAGE_SELECTOR, activePreviewPageSelector, readPreviewPageDataUrl } from "./preview";
|
||||
@@ -12,7 +12,6 @@ const EMPTY_SEMANTIC_STYLESHEET = {
|
||||
type SemanticStylesheetSeed = {
|
||||
mode: "semantic";
|
||||
source: { languageVersion: number; text: string };
|
||||
applied: { languageVersion: number; text: string };
|
||||
};
|
||||
|
||||
export const PORTABLE_STYLESHEET = `@version 1;
|
||||
@@ -85,7 +84,6 @@ export async function seedSemanticCssResume(
|
||||
stylesheet = {
|
||||
mode: "semantic",
|
||||
source: EMPTY_SEMANTIC_STYLESHEET,
|
||||
applied: EMPTY_SEMANTIC_STYLESHEET,
|
||||
},
|
||||
}: {
|
||||
basicsName?: string;
|
||||
@@ -104,7 +102,7 @@ export async function seedSemanticCssResume(
|
||||
});
|
||||
await page.reload();
|
||||
await openSemanticCssEditor(page);
|
||||
await waitForStylesheetStatus(page, "Applied");
|
||||
await waitForStylesheetStatus(page, "Valid");
|
||||
}
|
||||
|
||||
export async function openSemanticCssEditor(page: Page) {
|
||||
@@ -125,7 +123,7 @@ export function readStylesheetSource(page: Page) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForStylesheetStatus(page: Page, status: string) {
|
||||
async function waitForStylesheetStatus(page: Page, status: string) {
|
||||
await expect(page.getByText(status, { exact: true }).filter({ visible: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
@@ -135,7 +133,7 @@ export async function activateStylesheet(page: Page) {
|
||||
const button = page.getByRole("button", { name: "Activate Semantic CSS" });
|
||||
await expect(button).toBeEnabled({ timeout: 30_000 });
|
||||
await button.click();
|
||||
await waitForStylesheetStatus(page, "Applied");
|
||||
await waitForStylesheetStatus(page, "Valid");
|
||||
}
|
||||
|
||||
async function firstPreviewPage(page: Page, selector = ACTIVE_PREVIEW_PAGE_SELECTOR): Promise<Locator> {
|
||||
@@ -180,12 +178,3 @@ export async function switchTemplate(page: Page, template: string) {
|
||||
}
|
||||
await waitForStablePreview(page, targetPreview);
|
||||
}
|
||||
|
||||
export async function downloadPdf(page: Page): Promise<Download> {
|
||||
await page.getByRole("button", { name: "Download options" }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Download" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const download = page.waitForEvent("download");
|
||||
await dialog.getByRole("button", { name: "Download PDF" }).click();
|
||||
return download;
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@ test("@semantic-css starts new resumes in semantic mode", async ({ authPage: pag
|
||||
|
||||
await expect(page.getByText("Converted stylesheet draft", { exact: true })).toHaveCount(0);
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe("@version 1;\n");
|
||||
await expect(page.getByText("Applied", { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
await expect(page.getByText("Valid", { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readSemanticCssFixture } from "../../fixtures/db";
|
||||
import {
|
||||
createSemanticCssResume,
|
||||
downloadPdf,
|
||||
replaceStylesheet,
|
||||
seedSemanticCssResume,
|
||||
waitForStablePreview,
|
||||
waitForStylesheetStatus,
|
||||
} from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.setTimeout(120_000);
|
||||
|
||||
const renderedPdfFingerprint = (pdf: Buffer) => {
|
||||
const stablePdf = pdf
|
||||
.toString("latin1")
|
||||
.replace(/D:\d{14}Z/g, "D:00000000000000Z")
|
||||
.replace(/[A-Z]{6}\+/g, "SUBSET+")
|
||||
.replace(/\/ID \[<[\da-f]+> <[\da-f]+>\]/gi, "/ID [<ID> <ID>]");
|
||||
|
||||
return createHash("sha256").update(stablePdf, "latin1").digest("hex");
|
||||
};
|
||||
|
||||
test("@semantic-css keeps preview and export on the last valid source", async ({ authPage: page }, testInfo) => {
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
await seedSemanticCssResume(page, resumeId);
|
||||
await replaceStylesheet(page, "@version 1;\nname { color: #dc2626; }\n");
|
||||
await waitForStylesheetStatus(page, "Applied");
|
||||
|
||||
const canvas = await waitForStablePreview(page);
|
||||
const validPreview = await canvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL());
|
||||
const validDownload = await downloadPdf(page);
|
||||
const validPdf = await readFile(await validDownload.path());
|
||||
expect(validPdf.subarray(0, 4).toString()).toBe("%PDF");
|
||||
|
||||
await replaceStylesheet(page, "@version 1;\nname { color: ; }\n");
|
||||
await waitForStylesheetStatus(page, "Error");
|
||||
await expect(page.getByText("Preview and export use the last valid version.", { exact: true })).toBeVisible();
|
||||
await expect.poll(() => canvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL())).toBe(validPreview);
|
||||
const invalidDownload = await downloadPdf(page);
|
||||
const invalidPdf = await readFile(await invalidDownload.path());
|
||||
expect(invalidPdf.subarray(0, 4).toString()).toBe("%PDF");
|
||||
expect(renderedPdfFingerprint(invalidPdf)).toBe(renderedPdfFingerprint(validPdf));
|
||||
await expect
|
||||
.poll(async () => (await readSemanticCssFixture(resumeId)).stylesheet)
|
||||
.toMatchObject({
|
||||
source: { text: "@version 1;\nname { color: ; }\n" },
|
||||
applied: { text: "@version 1;\nname { color: #dc2626; }\n" },
|
||||
});
|
||||
|
||||
await replaceStylesheet(page, "@version 1;\nname { color: #2563eb; }\n");
|
||||
await waitForStylesheetStatus(page, "Applied");
|
||||
await expect
|
||||
.poll(() => canvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL()))
|
||||
.not.toBe(validPreview);
|
||||
await waitForStablePreview(page);
|
||||
const fixedDownload = await downloadPdf(page);
|
||||
const fixedPdf = await readFile(await fixedDownload.path());
|
||||
expect(fixedPdf.subarray(0, 4).toString()).toBe("%PDF");
|
||||
expect(renderedPdfFingerprint(fixedPdf)).not.toBe(renderedPdfFingerprint(validPdf));
|
||||
await expect
|
||||
.poll(async () => (await readSemanticCssFixture(resumeId)).stylesheet?.applied.text)
|
||||
.toBe("@version 1;\nname { color: #2563eb; }\n");
|
||||
});
|
||||
@@ -7,13 +7,6 @@ test.setTimeout(120_000);
|
||||
test("@semantic-css converts legacy rules into an inactive draft before activation", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
const preflightWorkerErrors: string[] = [];
|
||||
page.on("console", (message) => {
|
||||
if (message.location().url.includes("preflight.worker") && message.text().includes("Buffer is not defined")) {
|
||||
preflightWorkerErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
await updateSemanticCssFixture(resumeId, { legacyStyleRule: true });
|
||||
await page.reload();
|
||||
@@ -28,8 +21,7 @@ test("@semantic-css converts legacy rules into an inactive draft before activati
|
||||
await expect(page.getByText(/^Ready to activate(?: with warnings)?$/)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await activateStylesheet(page);
|
||||
expect(preflightWorkerErrors).toEqual([]);
|
||||
await page.reload();
|
||||
await expect(page.getByText("Converted stylesheet draft", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Applied", { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
await expect(page.getByText("Valid", { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { readSemanticCssFixture } from "../../fixtures/db";
|
||||
import {
|
||||
createSemanticCssResume,
|
||||
@@ -81,7 +80,6 @@ const WITHOUT_PAGINATION_DIRECTIVES = PORTABLE_ACCEPTANCE_STYLESHEET.replace(BRE
|
||||
"",
|
||||
);
|
||||
const BREAK_INSIDE_ONLY_STYLESHEET = PORTABLE_ACCEPTANCE_STYLESHEET.replace(MIN_PRESENCE_AHEAD_DIRECTIVE, "");
|
||||
const MIN_PRESENCE_AHEAD_ONLY_STYLESHEET = PORTABLE_ACCEPTANCE_STYLESHEET.replace(BREAK_INSIDE_DIRECTIVE, "");
|
||||
|
||||
const GENERIC_PORTABLE_MARKERS = [
|
||||
"exactItemField",
|
||||
@@ -126,10 +124,10 @@ async function countPortableMarkersByPage(page: Parameters<typeof waitForStableP
|
||||
}, PORTABLE_MARKERS);
|
||||
}
|
||||
|
||||
async function waitForPortableApplied(page: Parameters<typeof waitForStablePreview>[0]) {
|
||||
async function waitForPortableValid(page: Parameters<typeof waitForStablePreview>[0]) {
|
||||
await expect(
|
||||
page
|
||||
.getByText(/^Applied(?: with warnings)?$/)
|
||||
.getByText(/^Valid(?: with warnings)?$/)
|
||||
.filter({ visible: true })
|
||||
.last(),
|
||||
).toBeVisible({
|
||||
@@ -145,23 +143,9 @@ async function applyPortableStylesheet(
|
||||
await openSemanticCssEditor(page);
|
||||
await replaceStylesheet(page, source);
|
||||
await expect
|
||||
.poll(async () => (await readSemanticCssFixture(resumeId)).stylesheet?.applied.text, { timeout: 30_000 })
|
||||
.poll(async () => (await readSemanticCssFixture(resumeId)).stylesheet?.source.text, { timeout: 30_000 })
|
||||
.toBe(source);
|
||||
await waitForPortableApplied(page);
|
||||
}
|
||||
|
||||
async function readProjectMinPresenceAhead(page: Page, resumeId: string) {
|
||||
const fixture = await readSemanticCssFixture(resumeId);
|
||||
const response = await page.request.get(
|
||||
`/api/openapi/resumes/${encodeURIComponent(fixture.username)}/${encodeURIComponent(fixture.slug)}/style-projection`,
|
||||
);
|
||||
expect(response.ok(), `the public PDF projection request should succeed (${response.status()})`).toBe(true);
|
||||
const projection = (await response.json()) as {
|
||||
nodes: Record<string, { minPresenceAhead?: number }>;
|
||||
};
|
||||
const projectNodes = Object.entries(projection.nodes).filter(([key]) => key.endsWith("/section-projects"));
|
||||
expect(projectNodes, "the public PDF projection should contain one projects section").toHaveLength(1);
|
||||
return projectNodes[0]?.[1].minPresenceAhead;
|
||||
await waitForPortableValid(page);
|
||||
}
|
||||
|
||||
test("@semantic-css applies every portable selector behavior across Onyx, Azurill, and Ditto", async ({
|
||||
@@ -176,7 +160,7 @@ test("@semantic-css applies every portable selector behavior across Onyx, Azuril
|
||||
await applyPortableStylesheet(page, resumeId, PORTABLE_ACCEPTANCE_STYLESHEET);
|
||||
await page.reload();
|
||||
await openSemanticCssEditor(page);
|
||||
await waitForPortableApplied(page);
|
||||
await waitForPortableValid(page);
|
||||
|
||||
for (const template of ["Onyx", "Azurill", "Ditto"]) {
|
||||
await switchTemplate(page, template);
|
||||
@@ -201,9 +185,7 @@ test("@semantic-css applies every portable selector behavior across Onyx, Azuril
|
||||
}
|
||||
});
|
||||
|
||||
test("@semantic-css pagination directives keep a portable project section together", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
test("@semantic-css break-inside keeps a portable project section together", async ({ authPage: page }, testInfo) => {
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
await seedSemanticCssResume(page, resumeId, {
|
||||
portableLayout: "pagination-stress",
|
||||
@@ -220,30 +202,15 @@ test("@semantic-css pagination directives keep a portable project section togeth
|
||||
await applyPortableStylesheet(page, resumeId, BREAK_INSIDE_ONLY_STYLESHEET);
|
||||
const breakInsideOnlyDistribution = (await countPortableMarkersByPage(page)).map((page) => page.pagination);
|
||||
|
||||
await applyPortableStylesheet(page, resumeId, WITHOUT_PAGINATION_DIRECTIVES);
|
||||
const withoutMinPresenceAhead = await readProjectMinPresenceAhead(page, resumeId);
|
||||
|
||||
await applyPortableStylesheet(page, resumeId, MIN_PRESENCE_AHEAD_ONLY_STYLESHEET);
|
||||
const minPresenceAheadOnly = await readProjectMinPresenceAhead(page, resumeId);
|
||||
|
||||
console.info(
|
||||
`PORTABLE_PAGINATION_EVIDENCE ${JSON.stringify({
|
||||
breakInside: {
|
||||
withoutDirective: withoutBreakInsideDistribution,
|
||||
withDirective: breakInsideOnlyDistribution,
|
||||
},
|
||||
minPresenceAhead: {
|
||||
withoutDirective: withoutMinPresenceAhead ?? null,
|
||||
withDirective: minPresenceAheadOnly,
|
||||
},
|
||||
})}`,
|
||||
);
|
||||
expect(breakInsideOnlyDistribution, "break-inside: avoid must independently change project pagination").not.toEqual(
|
||||
withoutBreakInsideDistribution,
|
||||
);
|
||||
expect(withoutMinPresenceAhead, "the no-directive control must omit minPresenceAhead").toBeUndefined();
|
||||
expect(
|
||||
minPresenceAheadOnly,
|
||||
"-resume-min-presence-ahead: 24pt must independently reach the project pagination props",
|
||||
).toBe(24);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ test("@semantic-css preserves a persisted stylesheet through an old-client resum
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
const resumeId = resumeIdFromPage(page);
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: #dc2626; }\n" };
|
||||
await updateSemanticCssFixture(resumeId, { stylesheet: { mode: "semantic", source, applied: source } });
|
||||
await updateSemanticCssFixture(resumeId, { stylesheet: { mode: "semantic", source } });
|
||||
await page.reload();
|
||||
|
||||
await openSidebarSection(page, "Basics");
|
||||
@@ -21,7 +21,6 @@ test("@semantic-css preserves a persisted stylesheet through an old-client resum
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source,
|
||||
applied: source,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { updateSemanticCssFixture } from "../../fixtures/db";
|
||||
import {
|
||||
createSemanticCssResume,
|
||||
readStylesheetSource,
|
||||
replaceStylesheet,
|
||||
seedSemanticCssResume,
|
||||
waitForStylesheetStatus,
|
||||
} from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.setTimeout(120_000);
|
||||
|
||||
test("@semantic-css rebases a stale revision without dropping the focused draft", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
await seedSemanticCssResume(page, resumeId);
|
||||
await replaceStylesheet(page, "@version 1;\nname { color: #dc2626; }\n");
|
||||
await waitForStylesheetStatus(page, "Applied");
|
||||
|
||||
await updateSemanticCssFixture(resumeId, { bumpRevision: true });
|
||||
const latest = "@version 1;\nname { color: #2563eb; }\n";
|
||||
await replaceStylesheet(page, latest);
|
||||
await waitForStylesheetStatus(page, "Applied");
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(latest);
|
||||
});
|
||||
@@ -94,7 +94,7 @@ test("@semantic-css renders deterministic first-page previews for all templates"
|
||||
basicsName: "Semantic CSS Acceptance",
|
||||
experienceItemId: "experience-item-2",
|
||||
hidePicture: true,
|
||||
stylesheet: { mode: "semantic", source: portable, applied: portable },
|
||||
stylesheet: { mode: "semantic", source: portable },
|
||||
});
|
||||
|
||||
for (const template of templates) {
|
||||
|
||||
Reference in New Issue
Block a user