feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
@@ -26,6 +26,53 @@ Run tests:
|
||||
|
||||
`APP_URL=http://localhost:3000 PORT=3000 DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres FLAG_DISABLE_SIGNUPS=false FLAG_DISABLE_EMAIL_AUTH=false FLAG_DISABLE_API_RATE_LIMIT=true LOCAL_STORAGE_PATH=/workspace/data/e2e pnpm test:e2e`
|
||||
|
||||
## Semantic CSS flag matrix
|
||||
|
||||
Run the ordinary suite with both Semantic CSS rollout flags disabled:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
pnpm exec playwright test --grep-invert "@semantic-css"
|
||||
```
|
||||
|
||||
Run opt-in conversion, editing, conflict, last-valid, and visual acceptance. With authoring enabled, the Playwright
|
||||
configuration automatically uses one worker so deterministic heavy browser preflight and visual checks do not compete
|
||||
for the fixed production five-second deadline:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
pnpm exec playwright test \
|
||||
tests/e2e/specs/semantic-css/legacy-conversion.spec.ts \
|
||||
tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts \
|
||||
tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts \
|
||||
tests/e2e/specs/semantic-css/revision-conflict.spec.ts \
|
||||
tests/e2e/specs/semantic-css/template-visual.spec.ts
|
||||
```
|
||||
|
||||
Verify the default-on state for newly created resumes:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=true \
|
||||
pnpm exec playwright test tests/e2e/specs/semantic-css/default-mode.spec.ts
|
||||
```
|
||||
|
||||
Verify dormant authoring and persisted semantic rendering:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=false FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
pnpm exec playwright test \
|
||||
tests/e2e/specs/semantic-css/dormant-mode.spec.ts \
|
||||
tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts
|
||||
```
|
||||
|
||||
Linux/Chromium visual baselines are updated intentionally with:
|
||||
|
||||
```bash
|
||||
FLAG_SEMANTIC_CSS_AUTHORING=true FLAG_SEMANTIC_CSS_DEFAULT=false \
|
||||
pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts \
|
||||
--project=chromium --update-snapshots
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
- Email/password auth smoke.
|
||||
@@ -33,5 +80,7 @@ Run tests:
|
||||
- Builder basics edit and autosave persistence.
|
||||
- JSON export/import.
|
||||
- Public sharing for anonymous visitors.
|
||||
- Semantic CSS rollout states, legacy conversion, last-valid recovery, portability, revision conflicts, and all-template
|
||||
visual regression.
|
||||
|
||||
PDF, DOCX, OAuth, passkeys, 2FA, password reset, and AI flows are intentionally outside the initial PR gate.
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { E2EAccount } from "./data";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const legacyParityRules = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(process.cwd(), "packages/pdf/src/semantic/__fixtures__/legacy/custom-section-type.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as unknown[];
|
||||
|
||||
function getDatabaseUrl() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is required for E2E cleanup.");
|
||||
@@ -17,3 +26,167 @@ export async function deleteE2EUser(account: E2EAccount) {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
hidePicture?: boolean;
|
||||
basicsName?: string;
|
||||
},
|
||||
) {
|
||||
const pool = new Pool({ connectionString: getDatabaseUrl() });
|
||||
|
||||
try {
|
||||
const result = await pool.query<{ data: Record<string, unknown> }>('select data from "resume" where id = $1', [
|
||||
resumeId,
|
||||
]);
|
||||
const data = result.rows[0]?.data;
|
||||
if (!data) throw new Error(`Resume ${resumeId} was not found.`);
|
||||
|
||||
if (update.stylesheet) {
|
||||
const metadata = data.metadata as Record<string, unknown>;
|
||||
metadata.stylesheet = update.stylesheet;
|
||||
}
|
||||
if (update.experienceItemId) {
|
||||
const sections = data.sections as Record<string, { items: Array<Record<string, unknown>> }>;
|
||||
const experience = sections.experience;
|
||||
if (!experience?.items[0]) throw new Error("The semantic CSS fixture requires an experience item.");
|
||||
const item = experience.items[1] ?? structuredClone(experience.items[0]);
|
||||
item.id = update.experienceItemId;
|
||||
if (!experience.items[1]) experience.items.push(item);
|
||||
}
|
||||
if (update.portableLayout) {
|
||||
const sections = data.sections as Record<string, { items: Array<Record<string, unknown>> }>;
|
||||
const experience = sections.experience;
|
||||
const education = sections.education;
|
||||
const projects = sections.projects;
|
||||
const profiles = sections.profiles;
|
||||
const skills = sections.skills;
|
||||
if (
|
||||
!experience?.items[0] ||
|
||||
!experience.items[1] ||
|
||||
!education?.items[0] ||
|
||||
!projects?.items[0] ||
|
||||
!profiles?.items[0] ||
|
||||
!skills?.items[0]
|
||||
) {
|
||||
throw new Error("The portable semantic CSS fixture requires standard sample sections.");
|
||||
}
|
||||
|
||||
experience.items = experience.items.slice(0, 2);
|
||||
experience.items[0].description = "<ul><li><p>Portable rich text marker one.</p></li></ul>";
|
||||
experience.items[1].description = "<ul><li><p>Portable rich text marker two.</p></li></ul>";
|
||||
education.items = education.items.slice(0, 1);
|
||||
education.items[0].description = Array.from(
|
||||
{ length: update.portableLayout === "pagination-stress" ? 30 : 12 },
|
||||
(_, index) =>
|
||||
`<p>Education pagination spacer ${index + 1}: deterministic content places the next section near the page boundary.</p>`,
|
||||
).join("");
|
||||
projects.items = projects.items.slice(0, 1);
|
||||
projects.items[0].description = Array.from(
|
||||
{ length: 5 },
|
||||
(_, index) =>
|
||||
`<p>Project pagination marker ${index + 1}: this section fits on a fresh page and must stay together.</p>`,
|
||||
).join("");
|
||||
profiles.items = profiles.items.slice(0, 1);
|
||||
skills.items = skills.items.slice(0, 1);
|
||||
|
||||
const metadata = data.metadata as {
|
||||
layout: { pages: Array<{ fullWidth: boolean; main: string[]; sidebar: string[] }> };
|
||||
};
|
||||
metadata.layout.pages = [
|
||||
{
|
||||
fullWidth: false,
|
||||
main: ["experience"],
|
||||
sidebar: ["profiles", "skills"],
|
||||
},
|
||||
{
|
||||
fullWidth: true,
|
||||
main: ["education", "projects"],
|
||||
sidebar: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
if (update.legacyStyleRule) {
|
||||
const metadata = data.metadata as Record<string, unknown>;
|
||||
metadata.styleRules = structuredClone(legacyParityRules);
|
||||
}
|
||||
if (update.hidePicture) {
|
||||
const picture = data.picture as Record<string, unknown>;
|
||||
picture.hidden = true;
|
||||
picture.url = "";
|
||||
}
|
||||
if (update.basicsName) {
|
||||
const basics = data.basics as Record<string, unknown>;
|
||||
basics.name = update.basicsName;
|
||||
}
|
||||
|
||||
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,
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export async function readSemanticCssFixture(resumeId: string) {
|
||||
const pool = new Pool({ connectionString: getDatabaseUrl() });
|
||||
|
||||
try {
|
||||
const result = await pool.query<{
|
||||
data: {
|
||||
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],
|
||||
);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ACTIVE_PREVIEW_PAGE_SELECTOR, activePreviewPageSelector, readPreviewPageDataUrl } from "./preview";
|
||||
|
||||
describe("preview helpers", () => {
|
||||
afterEach(() => {
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
it("reads the active preview while an exiting layer remains mounted", () => {
|
||||
document.body.innerHTML = `
|
||||
<div aria-hidden="true">
|
||||
<canvas aria-label="Resume page 1 of 1"></canvas>
|
||||
</div>
|
||||
<div aria-hidden="false">
|
||||
<canvas aria-label="Resume page 1 of 1"></canvas>
|
||||
</div>
|
||||
`;
|
||||
const [exiting, active] = document.querySelectorAll<HTMLCanvasElement>("canvas");
|
||||
if (!exiting || !active) throw new Error("Expected both preview layers.");
|
||||
exiting.toDataURL = () => "exiting";
|
||||
active.toDataURL = () => "active";
|
||||
|
||||
expect(readPreviewPageDataUrl(ACTIVE_PREVIEW_PAGE_SELECTOR)).toBe("active");
|
||||
});
|
||||
|
||||
it("accepts a requested active template with the same bitmap as the previous layer", () => {
|
||||
document.body.innerHTML = `
|
||||
<div aria-hidden="true" data-resume-preview-template="gengar">
|
||||
<canvas aria-label="Resume page 1 of 1"></canvas>
|
||||
</div>
|
||||
<div aria-hidden="false" data-resume-preview-template="glalie">
|
||||
<canvas aria-label="Resume page 1 of 1"></canvas>
|
||||
</div>
|
||||
`;
|
||||
for (const canvas of document.querySelectorAll<HTMLCanvasElement>("canvas")) {
|
||||
canvas.toDataURL = () => "same-bitmap";
|
||||
}
|
||||
|
||||
expect(readPreviewPageDataUrl(activePreviewPageSelector("glalie"))).toBe("same-bitmap");
|
||||
});
|
||||
|
||||
it("does not accept an unrelated active template", () => {
|
||||
document.body.innerHTML = `
|
||||
<div aria-hidden="false" data-resume-preview-template="gengar">
|
||||
<canvas aria-label="Resume page 1 of 1"></canvas>
|
||||
</div>
|
||||
<div aria-hidden="true" data-resume-preview-template="glalie">
|
||||
<canvas aria-label="Resume page 1 of 1"></canvas>
|
||||
</div>
|
||||
`;
|
||||
|
||||
expect(() => readPreviewPageDataUrl(activePreviewPageSelector("glalie"))).toThrow(
|
||||
"Expected an active first-page canvas.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export const ACTIVE_PREVIEW_PAGE_SELECTOR = '[aria-hidden="false"] canvas[aria-label^="Resume page 1 of"]';
|
||||
|
||||
export const activePreviewPageSelector = (template: string) =>
|
||||
`[aria-hidden="false"][data-resume-preview-template="${template}"] canvas[aria-label^="Resume page 1 of"]`;
|
||||
|
||||
export function readPreviewPageDataUrl(selector: string) {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>(selector);
|
||||
if (!canvas) throw new Error("Expected an active first-page canvas.");
|
||||
return canvas.toDataURL();
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { activePreviewPageSelector } from "./preview";
|
||||
import { switchTemplate } from "./semantic-css";
|
||||
|
||||
vi.mock("@playwright/test", () => {
|
||||
const expect = Object.assign(() => ({ toBeVisible: async () => {} }), {
|
||||
poll: (read: () => Promise<unknown>) => ({
|
||||
toBe: async () => {
|
||||
await read();
|
||||
await read();
|
||||
},
|
||||
}),
|
||||
});
|
||||
return { expect };
|
||||
});
|
||||
|
||||
vi.mock("./resume", () => ({
|
||||
createSampleResumeFromDashboard: vi.fn(),
|
||||
openSidebarSection: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("switchTemplate", () => {
|
||||
it("waits for the requested active preview when the template is already selected", async () => {
|
||||
let selectedPreview = "";
|
||||
const locator = {
|
||||
filter: () => locator,
|
||||
first: () => locator,
|
||||
};
|
||||
const section = {
|
||||
getByRole: () => ({ isVisible: async () => true }),
|
||||
};
|
||||
const page = {
|
||||
evaluate: async () => "same-bitmap",
|
||||
getByRole: (role: string) => (role === "region" ? section : locator),
|
||||
locator: (selector: string) => {
|
||||
selectedPreview = selector;
|
||||
return locator;
|
||||
},
|
||||
} as unknown as Page;
|
||||
|
||||
await switchTemplate(page, "Glalie");
|
||||
|
||||
expect(selectedPreview).toBe(activePreviewPageSelector("glalie"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import type { Download, Locator, Page, TestInfo } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { updateSemanticCssFixture } from "./db";
|
||||
import { ACTIVE_PREVIEW_PAGE_SELECTOR, activePreviewPageSelector, readPreviewPageDataUrl } from "./preview";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "./resume";
|
||||
|
||||
const EMPTY_SEMANTIC_STYLESHEET = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\n",
|
||||
} as const;
|
||||
|
||||
type SemanticStylesheetSeed = {
|
||||
mode: "semantic";
|
||||
source: { languageVersion: number; text: string };
|
||||
applied: { languageVersion: number; text: string };
|
||||
};
|
||||
|
||||
export const PORTABLE_STYLESHEET = `@version 1;
|
||||
|
||||
:root {
|
||||
--accent: var(--resume-primary-color);
|
||||
}
|
||||
|
||||
header > name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
section:is([type="experience"], [type="education"]) > section-heading {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
section[id="projects"] > section-items > item {
|
||||
padding: 6pt;
|
||||
}
|
||||
|
||||
section[id="experience"] item[id="experience-item-2"] field[name="period"] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
rich-text list-item > list-item-content {
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
region[placement="sidebar"] section {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
section[type="projects"] {
|
||||
break-inside: avoid;
|
||||
-resume-min-presence-ahead: 24pt;
|
||||
}
|
||||
|
||||
@media (max-width: 600pt) {
|
||||
region[placement="sidebar"] section-heading {
|
||||
font-size: 9pt;
|
||||
}
|
||||
}
|
||||
|
||||
resume[template="azurill"] template-part[name="timeline-dot"] {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
`;
|
||||
|
||||
export const resumeIdFromPage = (page: Page) => {
|
||||
const resumeId = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!resumeId) throw new Error(`Expected a builder URL, received ${page.url()}.`);
|
||||
return resumeId;
|
||||
};
|
||||
|
||||
export async function createSemanticCssResume(page: Page, testInfo: TestInfo) {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
const resumeId = resumeIdFromPage(page);
|
||||
await openSemanticCssEditor(page);
|
||||
return resumeId;
|
||||
}
|
||||
|
||||
export async function seedSemanticCssResume(
|
||||
page: Page,
|
||||
resumeId: string,
|
||||
{
|
||||
basicsName,
|
||||
portableLayout,
|
||||
experienceItemId,
|
||||
hidePicture = false,
|
||||
stylesheet = {
|
||||
mode: "semantic",
|
||||
source: EMPTY_SEMANTIC_STYLESHEET,
|
||||
applied: EMPTY_SEMANTIC_STYLESHEET,
|
||||
},
|
||||
}: {
|
||||
basicsName?: string;
|
||||
portableLayout?: "balanced" | "pagination-stress";
|
||||
experienceItemId?: string;
|
||||
hidePicture?: boolean;
|
||||
stylesheet?: SemanticStylesheetSeed;
|
||||
} = {},
|
||||
) {
|
||||
await updateSemanticCssFixture(resumeId, {
|
||||
basicsName,
|
||||
portableLayout,
|
||||
experienceItemId,
|
||||
hidePicture,
|
||||
stylesheet,
|
||||
});
|
||||
await page.reload();
|
||||
await openSemanticCssEditor(page);
|
||||
await waitForStylesheetStatus(page, "Applied");
|
||||
}
|
||||
|
||||
export async function openSemanticCssEditor(page: Page) {
|
||||
await openSidebarSection(page, "Custom Styles");
|
||||
await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toBeVisible();
|
||||
}
|
||||
|
||||
export async function replaceStylesheet(page: Page, source: string) {
|
||||
const editor = page.getByRole("textbox", { name: "Semantic CSS stylesheet" });
|
||||
await editor.fill(source);
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(source);
|
||||
}
|
||||
|
||||
export function readStylesheetSource(page: Page) {
|
||||
const editor = page.getByRole("textbox", { name: "Semantic CSS stylesheet" });
|
||||
return editor.evaluate((element) =>
|
||||
Array.from(element.querySelectorAll(".cm-line"), (line) => line.textContent ?? "").join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function waitForStylesheetStatus(page: Page, status: string) {
|
||||
await expect(page.getByText(status, { exact: true }).filter({ visible: true }).last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
async function firstPreviewPage(page: Page, selector = ACTIVE_PREVIEW_PAGE_SELECTOR): Promise<Locator> {
|
||||
const canvas = page.locator(selector).filter({ visible: true }).first();
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export async function waitForStablePreview(page: Page, selector = ACTIVE_PREVIEW_PAGE_SELECTOR): Promise<Locator> {
|
||||
const canvas = await firstPreviewPage(page, selector);
|
||||
let previous: string | undefined;
|
||||
let stableSamples = 0;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await page.evaluate(readPreviewPageDataUrl, selector);
|
||||
stableSamples = previous === current ? stableSamples + 1 : 0;
|
||||
previous = current;
|
||||
return stableSamples >= 1;
|
||||
},
|
||||
{ timeout: 15_000, intervals: [250] },
|
||||
)
|
||||
.toBe(true);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export async function switchTemplate(page: Page, template: string) {
|
||||
await openSidebarSection(page, "Template");
|
||||
const section = page.getByRole("region", { name: "Toggle Template section" });
|
||||
const targetPreview = activePreviewPageSelector(template.toLowerCase());
|
||||
if (!(await section.getByRole("heading", { name: template, exact: true }).isVisible())) {
|
||||
await section.getByRole("button").first().click();
|
||||
const gallery = page.getByRole("dialog", { name: "Template Gallery" });
|
||||
await expect(gallery).toBeVisible();
|
||||
const save = page.waitForResponse((response) => {
|
||||
const body = response.request().postData() ?? "";
|
||||
return response.url().includes("/api/rpc") && response.ok() && body.includes(template.toLowerCase());
|
||||
});
|
||||
await gallery.getByRole("img", { name: template, exact: true }).click();
|
||||
await save;
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createSemanticCssResume, readStylesheetSource } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css starts new resumes in semantic mode when default-on is enabled", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
await createSemanticCssResume(page, testInfo);
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { readSemanticCssFixture, updateSemanticCssFixture } from "../../fixtures/db";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../../fixtures/resume";
|
||||
import { resumeIdFromPage } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css keeps the legacy editor available while both flags are off", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
await openSidebarSection(page, "Custom Styles");
|
||||
|
||||
await expect(page.getByLabel("Target Scope")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("@semantic-css preserves a persisted stylesheet through an old-client resume update", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
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 page.reload();
|
||||
|
||||
await openSidebarSection(page, "Basics");
|
||||
await page.getByLabel("Headline").fill("Preserves semantic stylesheet");
|
||||
await expect
|
||||
.poll(() => readSemanticCssFixture(resumeId))
|
||||
.toMatchObject({
|
||||
headline: "Preserves semantic stylesheet",
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source,
|
||||
applied: source,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { updateSemanticCssFixture } from "../../fixtures/db";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../../fixtures/resume";
|
||||
import { resumeIdFromPage, waitForStablePreview } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css keeps persisted semantic rendering active when authoring is off", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
const beforeCanvas = await waitForStablePreview(page);
|
||||
const before = await beforeCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL());
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: #2563eb; font-size: 30pt; }\n" };
|
||||
await updateSemanticCssFixture(resumeIdFromPage(page), {
|
||||
stylesheet: { mode: "semantic", source, applied: source },
|
||||
});
|
||||
await page.reload();
|
||||
|
||||
const afterCanvas = await waitForStablePreview(page);
|
||||
await expect
|
||||
.poll(() => afterCanvas.evaluate((element) => (element as HTMLCanvasElement).toDataURL()))
|
||||
.not.toBe(before);
|
||||
await waitForStablePreview(page);
|
||||
await openSidebarSection(page, "Custom Styles");
|
||||
await expect(page.getByText("Semantic styles remain active", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("This instance does not currently allow Semantic CSS editing.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByLabel("Target Scope")).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { updateSemanticCssFixture } from "../../fixtures/db";
|
||||
import { activateStylesheet, createSemanticCssResume, openSemanticCssEditor } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
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();
|
||||
await openSemanticCssEditor(page);
|
||||
|
||||
await expect(page.getByText("Converted stylesheet draft", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toContainText("@version 1;");
|
||||
await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toContainText(
|
||||
'section[type="experience"]',
|
||||
);
|
||||
await expect(page.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toContainText("padding-left: 7pt;");
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { readSemanticCssFixture } from "../../fixtures/db";
|
||||
import {
|
||||
createSemanticCssResume,
|
||||
openSemanticCssEditor,
|
||||
PORTABLE_STYLESHEET,
|
||||
replaceStylesheet,
|
||||
seedSemanticCssResume,
|
||||
switchTemplate,
|
||||
waitForStablePreview,
|
||||
} from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test.setTimeout(180_000);
|
||||
|
||||
const PORTABLE_MARKERS = {
|
||||
exactItemField: [161, 193, 129, 255],
|
||||
exactSection: [254, 215, 102, 255],
|
||||
groupSelector: [42, 183, 202, 255],
|
||||
header: [254, 74, 73, 255],
|
||||
mediaQuery: [110, 231, 183, 255],
|
||||
pagination: [212, 165, 165, 255],
|
||||
placement: [247, 140, 107, 255],
|
||||
richText: [179, 136, 235, 255],
|
||||
systemVariable: [0, 132, 209, 255],
|
||||
templatePart: [255, 0, 255, 255],
|
||||
} as const;
|
||||
|
||||
const PORTABLE_ACCEPTANCE_STYLESHEET = `${PORTABLE_STYLESHEET}
|
||||
|
||||
page {
|
||||
background-color: var(--accent);
|
||||
}
|
||||
|
||||
header {
|
||||
background-color: rgb(254, 74, 73);
|
||||
}
|
||||
|
||||
section:is([type="experience"], [type="education"]) > section-heading {
|
||||
background-color: rgb(42, 183, 202);
|
||||
}
|
||||
|
||||
section[id="projects"] > section-items > item {
|
||||
background-color: rgb(254, 215, 102);
|
||||
}
|
||||
|
||||
section[id="experience"] item[id="experience-item-2"] field[name="period"] {
|
||||
color: rgb(161, 193, 129);
|
||||
}
|
||||
|
||||
rich-text list-item > list-item-content {
|
||||
color: rgb(179, 136, 235);
|
||||
}
|
||||
|
||||
region[placement="sidebar"] section {
|
||||
background-color: rgb(247, 140, 107);
|
||||
}
|
||||
|
||||
section[type="projects"] {
|
||||
background-color: rgb(212, 165, 165);
|
||||
padding: 12pt;
|
||||
}
|
||||
|
||||
@media (max-width: 600pt) {
|
||||
region[placement="sidebar"] section-heading {
|
||||
background-color: rgb(110, 231, 183);
|
||||
}
|
||||
}
|
||||
|
||||
resume[template="azurill"] template-part[name="timeline-dot"] {
|
||||
background-color: rgb(255, 0, 255);
|
||||
}
|
||||
`;
|
||||
|
||||
type PortableMarker = keyof typeof PORTABLE_MARKERS;
|
||||
|
||||
const BREAK_INSIDE_DIRECTIVE = "\tbreak-inside: avoid;\n";
|
||||
const MIN_PRESENCE_AHEAD_DIRECTIVE = "\t-resume-min-presence-ahead: 24pt;\n";
|
||||
const WITHOUT_PAGINATION_DIRECTIVES = PORTABLE_ACCEPTANCE_STYLESHEET.replace(BREAK_INSIDE_DIRECTIVE, "").replace(
|
||||
MIN_PRESENCE_AHEAD_DIRECTIVE,
|
||||
"",
|
||||
);
|
||||
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",
|
||||
"exactSection",
|
||||
"groupSelector",
|
||||
"header",
|
||||
"mediaQuery",
|
||||
"pagination",
|
||||
"placement",
|
||||
"richText",
|
||||
] as const satisfies readonly PortableMarker[];
|
||||
|
||||
async function countPortableMarkersByPage(page: Parameters<typeof waitForStablePreview>[0]) {
|
||||
await waitForStablePreview(page);
|
||||
return page.locator('canvas[aria-label^="Resume page "]').evaluateAll((elements, markers) => {
|
||||
const markerEntries = Object.entries(markers) as Array<[PortableMarker, readonly number[]]>;
|
||||
const markerByColor = new Map(
|
||||
markerEntries.map(([name, color]) => [
|
||||
(((color[0] << 24) | (color[1] << 16) | (color[2] << 8) | color[3]) >>> 0).toString(),
|
||||
name,
|
||||
]),
|
||||
);
|
||||
|
||||
return elements
|
||||
.filter((element) => !element.closest('[aria-hidden="true"]'))
|
||||
.map((element) => {
|
||||
const canvas = element as HTMLCanvasElement;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Expected a 2D preview canvas.");
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const counts = Object.fromEntries(markerEntries.map(([name]) => [name, 0])) as Record<PortableMarker, number>;
|
||||
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
const key =
|
||||
((pixels[index] << 24) | (pixels[index + 1] << 16) | (pixels[index + 2] << 8) | pixels[index + 3]) >>> 0;
|
||||
const marker = markerByColor.get(key.toString());
|
||||
if (marker) counts[marker] += 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
});
|
||||
}, PORTABLE_MARKERS);
|
||||
}
|
||||
|
||||
async function waitForPortableApplied(page: Parameters<typeof waitForStablePreview>[0]) {
|
||||
await expect(
|
||||
page
|
||||
.getByText(/^Applied(?: with warnings)?$/)
|
||||
.filter({ visible: true })
|
||||
.last(),
|
||||
).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function applyPortableStylesheet(
|
||||
page: Parameters<typeof waitForStablePreview>[0],
|
||||
resumeId: string,
|
||||
source: string,
|
||||
) {
|
||||
await openSemanticCssEditor(page);
|
||||
await replaceStylesheet(page, source);
|
||||
await expect
|
||||
.poll(async () => (await readSemanticCssFixture(resumeId)).stylesheet?.applied.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;
|
||||
}
|
||||
|
||||
test("@semantic-css applies every portable selector behavior across Onyx, Azurill, and Ditto", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
await seedSemanticCssResume(page, resumeId, {
|
||||
portableLayout: "balanced",
|
||||
experienceItemId: "experience-item-2",
|
||||
hidePicture: true,
|
||||
});
|
||||
await applyPortableStylesheet(page, resumeId, PORTABLE_ACCEPTANCE_STYLESHEET);
|
||||
await page.reload();
|
||||
await openSemanticCssEditor(page);
|
||||
await waitForPortableApplied(page);
|
||||
|
||||
for (const template of ["Onyx", "Azurill", "Ditto"]) {
|
||||
await switchTemplate(page, template);
|
||||
const pages = await countPortableMarkersByPage(page);
|
||||
console.info(`PORTABLE_MARKER_EVIDENCE ${template} ${JSON.stringify(pages)}`);
|
||||
for (const marker of GENERIC_PORTABLE_MARKERS) {
|
||||
expect(
|
||||
pages.reduce((total, page) => total + page[marker], 0),
|
||||
`${template} should render the ${marker} sentinel`,
|
||||
).toBeGreaterThan(20);
|
||||
}
|
||||
expect(
|
||||
pages.reduce((total, page) => total + page.systemVariable, 0),
|
||||
`${template} should resolve the system variable sentinel`,
|
||||
).toBeGreaterThan(10_000);
|
||||
if (template === "Azurill") {
|
||||
expect(
|
||||
pages.reduce((total, page) => total + page.templatePart, 0),
|
||||
"Azurill should render the template-part sentinel",
|
||||
).toBeGreaterThan(5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("@semantic-css pagination directives keep a portable project section together", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
await seedSemanticCssResume(page, resumeId, {
|
||||
portableLayout: "pagination-stress",
|
||||
experienceItemId: "experience-item-2",
|
||||
hidePicture: true,
|
||||
});
|
||||
await switchTemplate(page, "Onyx");
|
||||
|
||||
await applyPortableStylesheet(page, resumeId, WITHOUT_PAGINATION_DIRECTIVES);
|
||||
const withoutBreakInside = await countPortableMarkersByPage(page);
|
||||
expect(withoutBreakInside.reduce((total, page) => total + page.pagination, 0)).toBeGreaterThan(20);
|
||||
const withoutBreakInsideDistribution = withoutBreakInside.map((page) => page.pagination);
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ACTIVE_PREVIEW_PAGE_SELECTOR, readPreviewPageDataUrl } from "../../fixtures/preview";
|
||||
import {
|
||||
createSemanticCssResume,
|
||||
PORTABLE_STYLESHEET,
|
||||
seedSemanticCssResume,
|
||||
switchTemplate,
|
||||
} from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
const templates = [
|
||||
"Azurill",
|
||||
"Bronzor",
|
||||
"Chikorita",
|
||||
"Ditgar",
|
||||
"Ditto",
|
||||
"Gengar",
|
||||
"Glalie",
|
||||
"Kakuna",
|
||||
"Lapras",
|
||||
"Leafish",
|
||||
"Meowth",
|
||||
"Onyx",
|
||||
"Pikachu",
|
||||
"Rhyhorn",
|
||||
"Scizor",
|
||||
] as const;
|
||||
|
||||
test.setTimeout(240_000);
|
||||
|
||||
async function previewClip(page: Parameters<typeof createSemanticCssResume>[0]) {
|
||||
let previous: string | undefined;
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await page.evaluate(readPreviewPageDataUrl, ACTIVE_PREVIEW_PAGE_SELECTOR);
|
||||
const stable = current === previous;
|
||||
previous = current;
|
||||
return stable;
|
||||
},
|
||||
{ intervals: [250], timeout: 15_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
const dimensions = await page.evaluate((selector) => {
|
||||
const source = document.querySelector<HTMLCanvasElement>(selector);
|
||||
if (!source) throw new Error("Expected an active first-page canvas.");
|
||||
const box = source.getBoundingClientRect();
|
||||
const width = Math.ceil(box.width);
|
||||
const height = Math.ceil(box.height);
|
||||
document.querySelector("[data-semantic-css-visual-page]")?.remove();
|
||||
const surface = document.createElement("div");
|
||||
surface.dataset.semanticCssVisualPage = "";
|
||||
Object.assign(surface.style, {
|
||||
background: "white",
|
||||
height: "632px",
|
||||
left: "0",
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
width: "447px",
|
||||
zIndex: "2147483647",
|
||||
});
|
||||
const snapshot = document.createElement("canvas");
|
||||
snapshot.width = source.width;
|
||||
snapshot.height = source.height;
|
||||
snapshot.style.width = "447px";
|
||||
snapshot.style.height = "632px";
|
||||
const context = snapshot.getContext("2d");
|
||||
if (!context) throw new Error("Expected a 2D canvas context for the visual snapshot.");
|
||||
context.drawImage(source, 0, 0);
|
||||
surface.append(snapshot);
|
||||
document.body.append(surface);
|
||||
return { width, height };
|
||||
}, ACTIVE_PREVIEW_PAGE_SELECTOR);
|
||||
const { width, height } = dimensions;
|
||||
expect({ width, height }).toEqual({ width: 447, height: 632 });
|
||||
const surface = page.locator("[data-semantic-css-visual-page]");
|
||||
const surfaceBox = await surface.boundingBox();
|
||||
if (!surfaceBox) throw new Error("Expected a visible page-only visual snapshot surface.");
|
||||
const clip = {
|
||||
x: Math.floor(surfaceBox.x),
|
||||
y: Math.floor(surfaceBox.y),
|
||||
width: Math.ceil(surfaceBox.x + surfaceBox.width) - Math.floor(surfaceBox.x),
|
||||
height: Math.ceil(surfaceBox.y + surfaceBox.height) - Math.floor(surfaceBox.y),
|
||||
};
|
||||
expect({ width: clip.width, height: clip.height }).toEqual({ width: 447, height: 632 });
|
||||
return clip;
|
||||
}
|
||||
|
||||
test("@semantic-css renders deterministic first-page previews for all templates", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
const portable = { languageVersion: 1, text: PORTABLE_STYLESHEET };
|
||||
await seedSemanticCssResume(page, resumeId, {
|
||||
basicsName: "Semantic CSS Acceptance",
|
||||
experienceItemId: "experience-item-2",
|
||||
hidePicture: true,
|
||||
stylesheet: { mode: "semantic", source: portable, applied: portable },
|
||||
});
|
||||
|
||||
for (const template of templates) {
|
||||
await switchTemplate(page, template);
|
||||
await expect(page).toHaveScreenshot(`${template.toLowerCase()}-first-page.png`, {
|
||||
animations: "disabled",
|
||||
caret: "hide",
|
||||
clip: await previewClip(page),
|
||||
maxDiffPixelRatio: 0,
|
||||
});
|
||||
await page.locator("[data-semantic-css-visual-page]").evaluate((element) => element.remove());
|
||||
}
|
||||
});
|
||||
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 60 KiB |