test: trim the E2E suite to speed up CI
Remove the Semantic CSS acceptance suite (six specs, fifteen visual baselines, and its fixtures) along with the --grep-invert that excluded it from CI. With the serial PDF preflight gone, Playwright can run four workers in CI instead of one. Also drop the slowest and most redundant specs: PDF raster direction, thumbnail resolution, import reproduction, imported tables, picture rendering, and literal whitespace, plus the basic authored-page guidance, settings profile, and resume lifecycle checks already covered elsewhere. Trim the OAuth consent matrix to allow and deny on an existing session.
@@ -26,30 +26,14 @@ 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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Linux/Chromium visual baselines are updated intentionally with:
|
||||
|
||||
```bash
|
||||
pnpm exec playwright test tests/e2e/specs/semantic-css/template-visual.spec.ts \
|
||||
--project=chromium --update-snapshots
|
||||
```
|
||||
|
||||
## Coverage
|
||||
|
||||
- Email/password auth smoke.
|
||||
- Dashboard sample resume creation.
|
||||
- Builder basics edit and autosave persistence.
|
||||
- Dashboard resume lifecycle: create, rename, duplicate, delete.
|
||||
- Builder section editing, autosave/navigation, and locking.
|
||||
- JSON export/import.
|
||||
- Public sharing for anonymous visitors.
|
||||
- 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.
|
||||
Visual regression, PDF/DOCX rasterization parity, thumbnail resolution, and import-fixture reproduction are
|
||||
intentionally outside the PR gate to keep it fast; the opt-in geometry, offline-font, and root-resume suites stay
|
||||
behind their environment flags.
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
import type { Browser, Page, Request, Response, TestInfo } from "@playwright/test";
|
||||
import type { E2EAccount } from "./data";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { Pool } from "pg";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
|
||||
export type ImportFormat = "pdf" | "reactive-resume-json" | "reactive-resume-v4-json" | "json-resume-json";
|
||||
|
||||
export type SyntheticImportFile = {
|
||||
name: string;
|
||||
mimeType: string;
|
||||
buffer: Buffer;
|
||||
declaredFormat: ImportFormat;
|
||||
expectedName?: string;
|
||||
};
|
||||
|
||||
type ImportRpcSummary = {
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
const IMPORT_RPC_PATH = "/api/rpc/resume/import";
|
||||
|
||||
function jsonFile(
|
||||
name: string,
|
||||
data: unknown,
|
||||
declaredFormat: Exclude<ImportFormat, "pdf">,
|
||||
expectedName?: string,
|
||||
): SyntheticImportFile {
|
||||
return {
|
||||
name,
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from(JSON.stringify(data)),
|
||||
declaredFormat,
|
||||
expectedName,
|
||||
};
|
||||
}
|
||||
|
||||
export function currentJsonFixture(): SyntheticImportFile {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.basics.name = "Current JSON Import Probe";
|
||||
data.picture.hidden = true;
|
||||
data.picture.url = "";
|
||||
data.metadata.typography.body.fontFamily = "Helvetica";
|
||||
data.metadata.typography.heading.fontFamily = "Helvetica";
|
||||
|
||||
return jsonFile("current-resume.json", data, "reactive-resume-json", data.basics.name);
|
||||
}
|
||||
|
||||
// Minimal synthetic shape copied from packages/import/src/reactive-resume-v4-json.test.ts.
|
||||
export function v4JsonFixture(): SyntheticImportFile {
|
||||
const section = (id: string) => ({
|
||||
name: id,
|
||||
columns: 1,
|
||||
separateLinks: false,
|
||||
visible: false,
|
||||
id,
|
||||
items: [],
|
||||
});
|
||||
const data = {
|
||||
basics: {
|
||||
name: "V4 JSON Import Probe",
|
||||
headline: "Diagnostic Engineer",
|
||||
email: "v4@example.test",
|
||||
phone: "",
|
||||
location: "Berlin",
|
||||
url: { label: "", href: "" },
|
||||
customFields: [],
|
||||
picture: {
|
||||
url: "",
|
||||
size: 80,
|
||||
aspectRatio: 1,
|
||||
borderRadius: 0,
|
||||
effects: { hidden: true, border: false, grayscale: false },
|
||||
},
|
||||
},
|
||||
sections: {
|
||||
summary: {
|
||||
name: "Summary",
|
||||
columns: 1,
|
||||
separateLinks: false,
|
||||
visible: false,
|
||||
id: "summary",
|
||||
content: "",
|
||||
},
|
||||
awards: section("awards"),
|
||||
certifications: section("certifications"),
|
||||
education: section("education"),
|
||||
experience: {
|
||||
...section("experience"),
|
||||
visible: true,
|
||||
items: [
|
||||
{
|
||||
id: "v4-experience",
|
||||
visible: true,
|
||||
company: "Synthetic Systems",
|
||||
position: "Engineer",
|
||||
location: "Berlin",
|
||||
date: "2021 - Present",
|
||||
summary: "Built deterministic import fixtures.",
|
||||
url: { label: "", href: "" },
|
||||
},
|
||||
],
|
||||
},
|
||||
volunteer: section("volunteer"),
|
||||
interests: section("interests"),
|
||||
languages: section("languages"),
|
||||
profiles: section("profiles"),
|
||||
projects: section("projects"),
|
||||
publications: section("publications"),
|
||||
references: section("references"),
|
||||
skills: section("skills"),
|
||||
},
|
||||
metadata: {
|
||||
template: "onyx",
|
||||
layout: [[["experience"], []]],
|
||||
css: { value: "", visible: false },
|
||||
page: { margin: 14, format: "a4", options: { breakLine: false, pageNumbers: false } },
|
||||
theme: { background: "#ffffff", text: "#000000", primary: "#dc2626" },
|
||||
typography: {
|
||||
font: { family: "Helvetica", subset: "latin", variants: ["regular"], size: 10 },
|
||||
lineHeight: 1.5,
|
||||
hideIcons: false,
|
||||
underlineLinks: false,
|
||||
},
|
||||
notes: "",
|
||||
},
|
||||
};
|
||||
|
||||
return jsonFile("v4-resume.json", data, "reactive-resume-v4-json", data.basics.name);
|
||||
}
|
||||
|
||||
// Minimal synthetic shape copied from packages/import/src/json-resume.test.ts.
|
||||
export function jsonResumeFixture(): SyntheticImportFile {
|
||||
const data = {
|
||||
basics: {
|
||||
name: "JSON Resume Import Probe",
|
||||
label: "Diagnostic Engineer",
|
||||
email: "json-resume@example.test",
|
||||
location: { city: "Berlin", countryCode: "DE" },
|
||||
},
|
||||
work: [
|
||||
{
|
||||
name: "Synthetic Systems",
|
||||
position: "Engineer",
|
||||
startDate: "2021-01",
|
||||
endDate: "2025-01",
|
||||
highlights: ["Built deterministic import fixtures"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return jsonFile("json-resume.json", data, "json-resume-json", data.basics.name);
|
||||
}
|
||||
|
||||
export function malformedJsonFixture(): SyntheticImportFile {
|
||||
return {
|
||||
name: "malformed.json",
|
||||
mimeType: "application/json",
|
||||
buffer: Buffer.from('{"basics":'),
|
||||
declaredFormat: "reactive-resume-json",
|
||||
};
|
||||
}
|
||||
|
||||
export function structurallyInvalidCurrentJsonFixture(): SyntheticImportFile {
|
||||
const data = structuredClone(sampleResumeData) as unknown as Record<string, unknown>;
|
||||
delete data.picture;
|
||||
|
||||
return jsonFile("missing-picture.json", data, "reactive-resume-json");
|
||||
}
|
||||
|
||||
export function withoutMimeType(file: SyntheticImportFile): SyntheticImportFile {
|
||||
return { ...file, mimeType: "" };
|
||||
}
|
||||
|
||||
export async function pdfFixture(
|
||||
browser: Browser,
|
||||
testInfo: TestInfo,
|
||||
options: { blank?: boolean; mimeType?: string } = {},
|
||||
): Promise<SyntheticImportFile> {
|
||||
const name = options.blank ? "blank-resume.pdf" : "text-resume.pdf";
|
||||
const path = testInfo.outputPath(name);
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
await page.setContent(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
@page { size: A4; margin: 18mm; }
|
||||
body { font-family: Helvetica, Arial, sans-serif; font-size: 12px; line-height: 1.45; }
|
||||
h1 { font-size: 22px; margin: 0 0 8px; }
|
||||
h2 { font-size: 15px; margin: 20px 0 8px; text-transform: uppercase; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${
|
||||
options.blank
|
||||
? ""
|
||||
: `<h1>PDF Import Probe</h1>
|
||||
<p>pdf-import@example.test · Berlin</p>
|
||||
<h2>Experience</h2>
|
||||
<p><strong>Synthetic Systems · Diagnostic Engineer</strong></p>
|
||||
<p>2021 – Present</p>
|
||||
<ul><li>Built deterministic browser PDF fixtures.</li><li>Verified offline resume import.</li></ul>`
|
||||
}
|
||||
</body>
|
||||
</html>`);
|
||||
await page.pdf({ path, format: "A4", printBackground: true });
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
mimeType: options.mimeType ?? "application/pdf",
|
||||
buffer: await readFile(path),
|
||||
declaredFormat: "pdf",
|
||||
expectedName: options.blank ? undefined : "PDF Import Probe",
|
||||
};
|
||||
}
|
||||
|
||||
export async function countUserResumes(account: E2EAccount): Promise<number> {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for import reproduction E2E.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
try {
|
||||
const result = await pool.query<{ count: string }>(
|
||||
`select count(*)::text as count
|
||||
from "resume"
|
||||
join "user" on "user".id = "resume".user_id
|
||||
where "user".email = $1`,
|
||||
[account.email],
|
||||
);
|
||||
return Number(result.rows[0]?.count ?? 0);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
export function observeImport(page: Page) {
|
||||
const pageErrors: Array<{ message: string; stack: string }> = [];
|
||||
const rpc: ImportRpcSummary[] = [];
|
||||
const requestStart = new WeakMap<Request, number>();
|
||||
|
||||
const onPageError = (error: Error) => {
|
||||
pageErrors.push({ message: error.message, stack: error.stack ?? error.message });
|
||||
};
|
||||
const onRequest = (request: Request) => {
|
||||
if (new URL(request.url()).pathname !== IMPORT_RPC_PATH) return;
|
||||
requestStart.set(request, performance.now());
|
||||
};
|
||||
const onResponse = (response: Response) => {
|
||||
const request = response.request();
|
||||
const path = new URL(response.url()).pathname;
|
||||
if (path !== IMPORT_RPC_PATH) return;
|
||||
rpc.push({
|
||||
method: request.method(),
|
||||
path,
|
||||
status: response.status(),
|
||||
durationMs: Math.round(performance.now() - (requestStart.get(request) ?? performance.now())),
|
||||
});
|
||||
};
|
||||
|
||||
page.on("pageerror", onPageError);
|
||||
page.on("request", onRequest);
|
||||
page.on("response", onResponse);
|
||||
|
||||
return {
|
||||
pageErrors,
|
||||
rpc,
|
||||
async attach(
|
||||
testInfo: TestInfo,
|
||||
details: {
|
||||
fixture: string;
|
||||
detectedFormat: ImportFormat | "";
|
||||
selectedFormat: ImportFormat;
|
||||
providerState: "none";
|
||||
uiOutcome: string;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
},
|
||||
) {
|
||||
await testInfo.attach("import-diagnostics", {
|
||||
contentType: "application/json",
|
||||
body: Buffer.from(JSON.stringify({ ...details, rpc, pageErrors }, null, 2)),
|
||||
});
|
||||
},
|
||||
dispose() {
|
||||
page.off("pageerror", onPageError);
|
||||
page.off("request", onRequest);
|
||||
page.off("response", onResponse);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { BorderPath } from "./pdf-borders";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countTableBorderGeometry } from "./pdf-borders";
|
||||
|
||||
describe("table border geometry", () => {
|
||||
it("does not count a later path after stale magenta stroke state", () => {
|
||||
const paths: BorderPath[] = [
|
||||
{ color: "#cc00cc", bounds: [0, 0, 100, 1] },
|
||||
{ color: "#cc00cc", bounds: [0, 19.65, 358.93, 20.65] },
|
||||
];
|
||||
const oldHorizontal = paths.filter(({ color, bounds: [x0, y0, x1, y1] }) => {
|
||||
const width = Math.abs((x1 ?? 0) - (x0 ?? 0));
|
||||
const height = Math.abs((y1 ?? 0) - (y0 ?? 0));
|
||||
return color === "#cc00cc" && height > 0 && height <= 1.01 && width > height;
|
||||
}).length;
|
||||
|
||||
expect(oldHorizontal).toBe(2);
|
||||
expect(countTableBorderGeometry(paths)).toEqual({ horizontal: 1, vertical: 0 });
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
export type BorderPath = {
|
||||
color: string;
|
||||
bounds: readonly number[];
|
||||
};
|
||||
|
||||
export type BorderGeometry = {
|
||||
horizontal: number;
|
||||
vertical: number;
|
||||
};
|
||||
|
||||
// The imported-table fixture is explicitly 300pt wide. Row height can change when a cell is edited,
|
||||
// so use width as stable topology while excluding unrelated paths that inherit the table's stroke color.
|
||||
const TABLE_GRID_MAX_X = 300;
|
||||
|
||||
const isInsideTableWidth = (bounds: readonly number[]) => {
|
||||
const [x0, y0, x1, y1] = bounds;
|
||||
if (![x0, y0, x1, y1].every((value) => Number.isFinite(value))) return false;
|
||||
return Math.min(x0 ?? 0, x1 ?? 0) >= 0 && Math.max(x0 ?? 0, x1 ?? 0) <= TABLE_GRID_MAX_X;
|
||||
};
|
||||
|
||||
export function countTableBorderGeometry(paths: readonly BorderPath[]): BorderGeometry {
|
||||
let horizontal = 0;
|
||||
let vertical = 0;
|
||||
|
||||
for (const path of paths) {
|
||||
if (path.color !== "#cc00cc" || !isInsideTableWidth(path.bounds)) continue;
|
||||
const [x0, y0, x1, y1] = path.bounds;
|
||||
const width = Math.abs((x1 ?? 0) - (x0 ?? 0));
|
||||
const height = Math.abs((y1 ?? 0) - (y0 ?? 0));
|
||||
if (height > 0 && height <= 1.01 && width > height) horizontal++;
|
||||
if (width > 0 && width <= 1.01 && height > width) vertical++;
|
||||
}
|
||||
|
||||
return { horizontal, vertical };
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// @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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
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";
|
||||
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 };
|
||||
};
|
||||
|
||||
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,
|
||||
},
|
||||
}: {
|
||||
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, "Valid");
|
||||
}
|
||||
|
||||
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"),
|
||||
);
|
||||
}
|
||||
|
||||
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, "Valid");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
test("explains authored pages and automatic PDF overflow in Layout", async ({ authPage: page }, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
await openSidebarSection(page, "Layout");
|
||||
|
||||
const guidance = page.getByRole("note", { name: "Authored pages and PDF overflow" });
|
||||
await expect(guidance).toBeVisible();
|
||||
await expect(guidance).toContainText("overflow pages are not saved or editable separately");
|
||||
await expect(guidance).toContainText("Move to → New Page");
|
||||
await expect(guidance).toContainText("Full Width");
|
||||
});
|
||||
@@ -1,357 +0,0 @@
|
||||
import type { Locator, Page, TestInfo } from "@playwright/test";
|
||||
import type { E2EAccount } from "../fixtures/data";
|
||||
import type { ImportFormat, SyntheticImportFile } from "../fixtures/import";
|
||||
import { parseJSONResume } from "@reactive-resume/import/json-resume";
|
||||
import { parseReactiveResumeJSON } from "@reactive-resume/import/reactive-resume-json";
|
||||
import { parseReactiveResumeV4JSON } from "@reactive-resume/import/reactive-resume-v4-json";
|
||||
import {
|
||||
countUserResumes,
|
||||
currentJsonFixture,
|
||||
jsonResumeFixture,
|
||||
malformedJsonFixture,
|
||||
observeImport,
|
||||
pdfFixture,
|
||||
structurallyInvalidCurrentJsonFixture,
|
||||
v4JsonFixture,
|
||||
withoutMimeType,
|
||||
} from "../fixtures/import";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const formatLabels: Record<ImportFormat, string> = {
|
||||
"reactive-resume-json": "Reactive Resume (JSON)",
|
||||
"reactive-resume-v4-json": "Reactive Resume v4 (JSON)",
|
||||
"json-resume-json": "JSON Resume",
|
||||
pdf: "PDF",
|
||||
};
|
||||
|
||||
async function openImportDialog(page: Page): Promise<Locator> {
|
||||
await page.goto("/dashboard/resumes");
|
||||
await page.getByText("Import an existing resume", { exact: true }).click();
|
||||
return page.getByRole("dialog", { name: "Import an existing resume" });
|
||||
}
|
||||
|
||||
async function selectImportFile(
|
||||
page: Page,
|
||||
dialog: Locator,
|
||||
file: SyntheticImportFile,
|
||||
selectedFormat = file.declaredFormat,
|
||||
): Promise<void> {
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
buffer: file.buffer,
|
||||
});
|
||||
|
||||
const combobox = dialog.getByRole("combobox");
|
||||
await expect(combobox).toContainText(formatLabels[file.declaredFormat]);
|
||||
if (selectedFormat === file.declaredFormat) return;
|
||||
|
||||
await combobox.click();
|
||||
await page.getByRole("option", { name: formatLabels[selectedFormat], exact: true }).click();
|
||||
await expect(combobox).toContainText(formatLabels[selectedFormat]);
|
||||
}
|
||||
|
||||
async function assertImportedName(page: Page, expectedName: string): Promise<void> {
|
||||
await page.waitForURL(/\/builder\/.+/);
|
||||
await page.getByTestId("left").getByRole("button", { name: "Basics", exact: true }).click();
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue(expectedName);
|
||||
}
|
||||
|
||||
async function assertErrorToast(page: Page, expected: string | RegExp, timeout = 10_000): Promise<string> {
|
||||
const toast = page.locator('[data-slot="toast"][data-type="error"]');
|
||||
await expect(toast).toBeVisible({ timeout });
|
||||
const description = toast.locator('[data-slot="toast-description"]');
|
||||
await expect(description).toContainText(expected);
|
||||
return (await description.textContent()) ?? "";
|
||||
}
|
||||
|
||||
async function attachDiagnostics(
|
||||
observation: ReturnType<typeof observeImport>,
|
||||
testInfo: TestInfo,
|
||||
details: {
|
||||
fixture: string;
|
||||
detectedFormat: ImportFormat | "";
|
||||
selectedFormat: ImportFormat;
|
||||
uiOutcome: string;
|
||||
beforeCount: number;
|
||||
afterCount: number;
|
||||
},
|
||||
): Promise<void> {
|
||||
await observation.attach(testInfo, { ...details, providerState: "none" });
|
||||
observation.dispose();
|
||||
}
|
||||
|
||||
async function importSuccessfully(
|
||||
page: Page,
|
||||
account: E2EAccount,
|
||||
testInfo: TestInfo,
|
||||
file: SyntheticImportFile,
|
||||
): Promise<void> {
|
||||
const observation = observeImport(page);
|
||||
const beforeCount = await countUserResumes(account);
|
||||
let afterCount = beforeCount;
|
||||
let uiOutcome = "test failed before UI outcome";
|
||||
|
||||
try {
|
||||
const dialog = await openImportDialog(page);
|
||||
await selectImportFile(page, dialog, file);
|
||||
await dialog.getByRole("button", { name: "Import", exact: true }).click();
|
||||
await assertImportedName(page, file.expectedName ?? "");
|
||||
await expect.poll(() => countUserResumes(account)).toBe(beforeCount + 1);
|
||||
afterCount = await countUserResumes(account);
|
||||
uiOutcome = `builder:${new URL(page.url()).pathname}`;
|
||||
|
||||
expect(observation.rpc).toEqual([
|
||||
expect.objectContaining({ method: "POST", path: "/api/rpc/resume/import", status: 200 }),
|
||||
]);
|
||||
expect(observation.pageErrors).toEqual([]);
|
||||
} finally {
|
||||
await attachDiagnostics(observation, testInfo, {
|
||||
fixture: file.name,
|
||||
detectedFormat: file.declaredFormat,
|
||||
selectedFormat: file.declaredFormat,
|
||||
uiOutcome,
|
||||
beforeCount,
|
||||
afterCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("synthetic import fixtures", () => {
|
||||
test("valid JSON fixtures parse through their declared importers", () => {
|
||||
const current = currentJsonFixture();
|
||||
const v4 = v4JsonFixture();
|
||||
const jsonResume = jsonResumeFixture();
|
||||
|
||||
expect(parseReactiveResumeJSON(current.buffer.toString()).basics.name).toBe(current.expectedName);
|
||||
expect(parseReactiveResumeV4JSON(v4.buffer.toString()).basics.name).toBe(v4.expectedName);
|
||||
expect(parseJSONResume(jsonResume.buffer.toString()).basics.name).toBe(jsonResume.expectedName);
|
||||
});
|
||||
|
||||
test("invalid JSON fixtures fail for their intended reason", () => {
|
||||
expect(() => parseReactiveResumeJSON(malformedJsonFixture().buffer.toString())).toThrow(/JSON/i);
|
||||
expect(() => parseReactiveResumeJSON(structurallyInvalidCurrentJsonFixture().buffer.toString())).toThrow(
|
||||
/picture/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
for (const fixtureFactory of [v4JsonFixture, jsonResumeFixture]) {
|
||||
const fixture = fixtureFactory();
|
||||
test(`imports ${fixture.declaredFormat} through real dialog and RPC`, async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, testInfo) => {
|
||||
await importSuccessfully(page, account, testInfo, fixtureFactory());
|
||||
});
|
||||
}
|
||||
|
||||
test("imports current JSON while close cancellation preserves pending dialog state", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, testInfo) => {
|
||||
const file = currentJsonFixture();
|
||||
const observation = observeImport(page);
|
||||
const beforeCount = await countUserResumes(account);
|
||||
let afterCount = beforeCount;
|
||||
let uiOutcome = "test failed before UI outcome";
|
||||
let releaseImport = () => {};
|
||||
const barrier = new Promise<void>((resolve) => {
|
||||
releaseImport = resolve;
|
||||
});
|
||||
let interceptedImport = () => {};
|
||||
const intercepted = new Promise<void>((resolve) => {
|
||||
interceptedImport = resolve;
|
||||
});
|
||||
|
||||
await page.route("**/api/rpc/resume/import", async (route) => {
|
||||
interceptedImport();
|
||||
await barrier;
|
||||
try {
|
||||
await route.continue();
|
||||
} catch (error) {
|
||||
if (!page.isClosed()) throw error;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const dialog = await openImportDialog(page);
|
||||
await selectImportFile(page, dialog, file);
|
||||
await dialog.getByRole("button", { name: "Import", exact: true }).click();
|
||||
await intercepted;
|
||||
await dialog.getByRole("button", { name: "Close", exact: true }).click();
|
||||
const confirmation = page.getByRole("alertdialog", { name: "Are you sure you want to close this dialog?" });
|
||||
await expect(confirmation).toBeVisible();
|
||||
await confirmation.getByRole("button", { name: "Stay", exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText(file.name, { exact: true })).toBeVisible();
|
||||
await expect(dialog.getByRole("button", { name: /Importing/ })).toBeDisabled();
|
||||
|
||||
releaseImport();
|
||||
await assertImportedName(page, file.expectedName ?? "");
|
||||
await expect.poll(() => countUserResumes(account)).toBe(beforeCount + 1);
|
||||
afterCount = await countUserResumes(account);
|
||||
uiOutcome = `close-cancelled:file-retained;builder:${new URL(page.url()).pathname}`;
|
||||
|
||||
expect(observation.rpc).toEqual([
|
||||
expect.objectContaining({ method: "POST", path: "/api/rpc/resume/import", status: 200 }),
|
||||
]);
|
||||
expect(observation.pageErrors).toEqual([]);
|
||||
} finally {
|
||||
releaseImport();
|
||||
if (!page.isClosed()) await page.unroute("**/api/rpc/resume/import");
|
||||
await attachDiagnostics(observation, testInfo, {
|
||||
fixture: file.name,
|
||||
detectedFormat: file.declaredFormat,
|
||||
selectedFormat: file.declaredFormat,
|
||||
uiOutcome,
|
||||
beforeCount,
|
||||
afterCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("imports current JSON when browser supplies no MIME metadata", async ({ authPage: page, account }, testInfo) => {
|
||||
await importSuccessfully(page, account, testInfo, withoutMimeType(currentJsonFixture()));
|
||||
});
|
||||
|
||||
test("imports offline text PDF generated by Playwright Chromium", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await importSuccessfully(page, account, testInfo, await pdfFixture(browser, testInfo));
|
||||
});
|
||||
|
||||
test("imports magic-byte PDF when browser supplies no MIME metadata", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await importSuccessfully(page, account, testInfo, await pdfFixture(browser, testInfo, { mimeType: "" }));
|
||||
});
|
||||
|
||||
test("keeps blank PDF in dialog and creates no resume", async ({ authPage: page, account, browser }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const file = await pdfFixture(browser, testInfo, { blank: true });
|
||||
const observation = observeImport(page);
|
||||
const beforeCount = await countUserResumes(account);
|
||||
let afterCount = beforeCount;
|
||||
let uiOutcome = "test failed before UI outcome";
|
||||
|
||||
try {
|
||||
const dialog = await openImportDialog(page);
|
||||
await selectImportFile(page, dialog, file);
|
||||
await dialog.getByRole("button", { name: "Import", exact: true }).click();
|
||||
await assertErrorToast(
|
||||
page,
|
||||
"This PDF has no readable text. It is likely a scan, so there is nothing to import.",
|
||||
30_000,
|
||||
);
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText(file.name, { exact: true })).toBeVisible();
|
||||
afterCount = await countUserResumes(account);
|
||||
uiOutcome = "error:no-readable-text;dialog-and-file-retained";
|
||||
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
expect(observation.rpc).toEqual([]);
|
||||
expect(observation.pageErrors).toEqual([]);
|
||||
} finally {
|
||||
await attachDiagnostics(observation, testInfo, {
|
||||
fixture: file.name,
|
||||
detectedFormat: file.declaredFormat,
|
||||
selectedFormat: file.declaredFormat,
|
||||
uiOutcome,
|
||||
beforeCount,
|
||||
afterCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const file of [malformedJsonFixture(), structurallyInvalidCurrentJsonFixture()]) {
|
||||
test(`keeps invalid fixture ${file.name} in dialog and creates no resume`, async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, testInfo) => {
|
||||
const observation = observeImport(page);
|
||||
const beforeCount = await countUserResumes(account);
|
||||
let afterCount = beforeCount;
|
||||
let uiOutcome = "test failed before UI outcome";
|
||||
|
||||
try {
|
||||
const dialog = await openImportDialog(page);
|
||||
await dialog.locator('input[type="file"]').setInputFiles({
|
||||
name: file.name,
|
||||
mimeType: file.mimeType,
|
||||
buffer: file.buffer,
|
||||
});
|
||||
const detectedFormat = file.name === "malformed.json" ? "" : file.declaredFormat;
|
||||
if (detectedFormat === "") {
|
||||
await expect(dialog.getByText("We couldn't detect the format automatically. Choose it above.")).toBeVisible();
|
||||
await dialog.getByRole("combobox").click();
|
||||
await page.getByRole("option", { name: formatLabels[file.declaredFormat], exact: true }).click();
|
||||
} else {
|
||||
await expect(dialog.getByRole("combobox")).toContainText(formatLabels[file.declaredFormat]);
|
||||
}
|
||||
await dialog.getByRole("button", { name: "Import", exact: true }).click();
|
||||
const expectedError =
|
||||
file.name === "malformed.json"
|
||||
? /Unexpected end of JSON input/
|
||||
: /The file could not be read as a valid resume/;
|
||||
await assertErrorToast(page, expectedError);
|
||||
await expect(dialog.getByText(file.name, { exact: true })).toBeVisible();
|
||||
afterCount = await countUserResumes(account);
|
||||
uiOutcome = "parse-error;dialog-and-file-retained";
|
||||
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
expect(observation.rpc).toEqual([]);
|
||||
expect(observation.pageErrors).toEqual([]);
|
||||
} finally {
|
||||
await attachDiagnostics(observation, testInfo, {
|
||||
fixture: file.name,
|
||||
detectedFormat: file.name === "malformed.json" ? "" : file.declaredFormat,
|
||||
selectedFormat: file.declaredFormat,
|
||||
uiOutcome,
|
||||
beforeCount,
|
||||
afterCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("rejects a JSON Resume deliberately selected as current JSON without creating a resume", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, testInfo) => {
|
||||
const file = jsonResumeFixture();
|
||||
const selectedFormat = "reactive-resume-json" as const;
|
||||
const observation = observeImport(page);
|
||||
const beforeCount = await countUserResumes(account);
|
||||
let afterCount = beforeCount;
|
||||
let uiOutcome = "test failed before UI outcome";
|
||||
|
||||
try {
|
||||
const dialog = await openImportDialog(page);
|
||||
await selectImportFile(page, dialog, file, selectedFormat);
|
||||
await dialog.getByRole("button", { name: "Import", exact: true }).click();
|
||||
await assertErrorToast(page, /The file could not be read as a valid resume/);
|
||||
await expect(dialog.getByText(file.name, { exact: true })).toBeVisible();
|
||||
afterCount = await countUserResumes(account);
|
||||
uiOutcome = "selected-format-parse-error;dialog-and-file-retained";
|
||||
|
||||
expect(afterCount).toBe(beforeCount);
|
||||
expect(observation.rpc).toEqual([]);
|
||||
expect(observation.pageErrors).toEqual([]);
|
||||
} finally {
|
||||
await attachDiagnostics(observation, testInfo, {
|
||||
fixture: file.name,
|
||||
detectedFormat: file.declaredFormat,
|
||||
selectedFormat,
|
||||
uiOutcome,
|
||||
beforeCount,
|
||||
afterCount,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { Pool } from "pg";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { countTableBorderGeometry } from "../fixtures/pdf-borders";
|
||||
import { ACTIVE_PREVIEW_PAGE_SELECTOR } from "../fixtures/preview";
|
||||
import { openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`);
|
||||
const tableHtml = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Alpha</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Beta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Gamma</td></tr><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Delta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Epsilon</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Zeta</td></tr></tbody></table>`;
|
||||
|
||||
const fixture = () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.basics.name = "Imported Table Probe";
|
||||
data.picture.hidden = true;
|
||||
data.summary.content = tableHtml;
|
||||
data.metadata.template = "ditgar";
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
|
||||
data.metadata.typography.body.fontFamily = "Helvetica";
|
||||
data.metadata.typography.heading.fontFamily = "Helvetica";
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: "@version 1;" } };
|
||||
return data;
|
||||
};
|
||||
|
||||
async function readImportedResume(id: string) {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for imported table E2E.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
const result = await pool.query<{
|
||||
data: { basics: { name: string }; summary: { content: string } };
|
||||
slug: string;
|
||||
updatedAt: Date;
|
||||
}>('select data, slug, updated_at as "updatedAt" from "resume" where id = $1', [id]);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error(`Resume ${id} was not found.`);
|
||||
return row;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function publishImportedResume(id: string) {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for imported table E2E.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
await pool.query('update "resume" set is_public = true where id = $1', [id]);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPdf(bytes: Uint8Array) {
|
||||
const { getDocument, OPS } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs"));
|
||||
const loading = getDocument({ data: bytes.slice(), useSystemFonts: true });
|
||||
try {
|
||||
const document = await loading.promise;
|
||||
const page = await document.getPage(1);
|
||||
const text = (await page.getTextContent()).items.flatMap((item: { str?: string }) => item.str ?? []);
|
||||
const operators = await page.getOperatorList();
|
||||
let stroke = "";
|
||||
const borderPaths = [];
|
||||
for (const [index, fn] of operators.fnArray.entries()) {
|
||||
if (fn === OPS.setStrokeRGBColor) stroke = operators.argsArray[index][0];
|
||||
if (fn !== OPS.constructPath) continue;
|
||||
const bounds = operators.argsArray[index][2] as ArrayLike<number>;
|
||||
borderPaths.push({
|
||||
color: stroke,
|
||||
bounds: [bounds[0] ?? 0, bounds[1] ?? 0, bounds[2] ?? 0, bounds[3] ?? 0],
|
||||
});
|
||||
}
|
||||
const { horizontal, vertical } = countTableBorderGeometry(borderPaths);
|
||||
return { text, horizontal, vertical };
|
||||
} finally {
|
||||
await loading.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBuilderPdf(page: Page, testInfo: TestInfo, stage: string) {
|
||||
await openSidebarSection(page, "Export");
|
||||
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download PDF", exact: true }).click();
|
||||
const download = await pending;
|
||||
const path = testInfo.outputPath(`${stage}-browser.pdf`);
|
||||
await download.saveAs(path);
|
||||
return new Uint8Array(await readFile(path));
|
||||
}
|
||||
|
||||
async function previewBorderPixels(page: Page, testInfo: TestInfo, stage: string) {
|
||||
const canvas = page.locator(ACTIVE_PREVIEW_PAGE_SELECTOR);
|
||||
await expect(canvas).toBeVisible({ timeout: 30_000 });
|
||||
await canvas.screenshot({ path: testInfo.outputPath(`${stage}-preview.png`), animations: "disabled" });
|
||||
return canvas.evaluate((element) => {
|
||||
const context = (element as HTMLCanvasElement).getContext("2d");
|
||||
if (!context) throw new Error("Missing preview canvas context.");
|
||||
const pixels = context.getImageData(0, 0, context.canvas.width, context.canvas.height).data;
|
||||
let magenta = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if ((pixels[index] ?? 0) > 180 && (pixels[index + 1] ?? 255) < 80 && (pixels[index + 2] ?? 0) > 180) magenta++;
|
||||
}
|
||||
return magenta;
|
||||
});
|
||||
}
|
||||
|
||||
test("edits and exports a synthetic imported table without losing its grid", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const importPath = testInfo.outputPath("imported-table.json");
|
||||
await writeFile(importPath, JSON.stringify(fixture()));
|
||||
|
||||
await page.goto("/dashboard/resumes");
|
||||
await page.getByText("Import an existing resume", { exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Import an existing resume" });
|
||||
await dialog.locator('input[type="file"]').setInputFiles(importPath);
|
||||
await dialog.getByRole("button", { name: "Import", exact: true }).click();
|
||||
await page.waitForURL(/\/builder\/.+/);
|
||||
const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!id) throw new Error("Missing imported resume id.");
|
||||
await publishImportedResume(id);
|
||||
let stored = await readImportedResume(id);
|
||||
const slug = stored.slug;
|
||||
|
||||
const captureStage = async (stage: string, expectedCell: string) => {
|
||||
const summaryHtml = (await readImportedResume(id)).data.summary.content;
|
||||
await writeFile(testInfo.outputPath(`${stage}-summary.html`), summaryHtml);
|
||||
expect(summaryHtml).toContain("<table");
|
||||
expect(summaryHtml).toContain(expectedCell);
|
||||
expect(await previewBorderPixels(page, testInfo, stage)).toBeGreaterThan(1_000);
|
||||
|
||||
for (const [surface, bytes] of [
|
||||
["browser", await downloadBuilderPdf(page, testInfo, stage)],
|
||||
[
|
||||
"server",
|
||||
new Uint8Array(
|
||||
await (
|
||||
await page.request.get(
|
||||
`/api/resumes/${encodeURIComponent(account.username)}/${encodeURIComponent(slug)}/pdf`,
|
||||
)
|
||||
).body(),
|
||||
),
|
||||
],
|
||||
] as const) {
|
||||
await writeFile(testInfo.outputPath(`${stage}-${surface}.pdf`), bytes);
|
||||
const pdf = await inspectPdf(bytes);
|
||||
expect(pdf.text, `${stage} ${surface}`).toEqual(
|
||||
expect.arrayContaining(["Alpha", expectedCell, "Gamma", "Delta", "Epsilon", "Zeta"]),
|
||||
);
|
||||
expect({ horizontal: pdf.horizontal, vertical: pdf.vertical }, `${stage} ${surface}`).toEqual({
|
||||
horizontal: 17,
|
||||
vertical: 12,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await openSidebarSection(page, "Summary");
|
||||
const summary = page.locator("#sidebar-summary");
|
||||
await expect(summary.locator("table tr")).toHaveCount(2);
|
||||
await expect(summary.locator("table td")).toHaveCount(6);
|
||||
expect(stored.data.summary.content).toBe(tableHtml);
|
||||
await captureStage("initial", "Beta");
|
||||
|
||||
await openSidebarSection(page, "Basics");
|
||||
const unrelatedName = "Imported Table Probe unrelated edit";
|
||||
const beforeUnrelatedEdit = stored.updatedAt;
|
||||
await page.getByLabel("Name").fill(unrelatedName);
|
||||
await expect.poll(async () => (await readImportedResume(id)).data.basics.name).toBe(unrelatedName);
|
||||
stored = await readImportedResume(id);
|
||||
expect(stored.updatedAt.getTime()).toBeGreaterThan(beforeUnrelatedEdit.getTime());
|
||||
await page.reload();
|
||||
stored = await readImportedResume(id);
|
||||
expect(stored.data.basics.name).toBe(unrelatedName);
|
||||
expect(stored.data.summary.content).toBe(tableHtml);
|
||||
await openSidebarSection(page, "Basics");
|
||||
await expect(page.getByLabel("Name")).toHaveValue(unrelatedName);
|
||||
await openSidebarSection(page, "Summary");
|
||||
await expect(summary.locator("table td")).toHaveCount(6);
|
||||
await captureStage("unrelated-edit", "Beta");
|
||||
|
||||
const beta = summary.locator("td").filter({ hasText: "Beta" });
|
||||
await beta.click();
|
||||
await page.keyboard.press("End");
|
||||
await page.keyboard.type("!");
|
||||
await expect(beta).toContainText("Beta!");
|
||||
await page.keyboard.press("Control+z");
|
||||
await expect(beta).toContainText("Beta");
|
||||
await page.keyboard.press("Control+Shift+z");
|
||||
await expect(beta).toContainText("Beta!");
|
||||
await expect(page.getByRole("status").filter({ hasText: "Saved" })).toBeVisible();
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Summary");
|
||||
await expect(summary.locator("table tr")).toHaveCount(2);
|
||||
await expect(summary.locator("table td")).toHaveCount(6);
|
||||
await expect(summary.locator("td").filter({ hasText: "Beta!" })).toBeVisible();
|
||||
stored = await readImportedResume(id);
|
||||
expect(stored.data.summary.content).toContain("<table");
|
||||
expect(stored.data.summary.content).toContain("Beta!");
|
||||
await captureStage("table-edit", "Beta!");
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { promisify } from "node:util";
|
||||
import { Pool } from "pg";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
const requireWeb = createRequire(`${process.cwd()}/apps/web/package.json`);
|
||||
const execFileAsync = promisify(execFile);
|
||||
const preservedHtml =
|
||||
'<p data-resume-whitespace="preserve"> LIT A\tB END </p><p data-resume-whitespace="preserve"> REF A B END </p><p data-resume-whitespace="preserve">CTL A B END</p><p data-resume-whitespace="preserve">\tTAB A B END</p>';
|
||||
|
||||
async function readSummary(id: string) {
|
||||
if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is required for literal-whitespace E2E.");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
const result = await pool.query<{ content: string }>(
|
||||
`select data->'summary'->>'content' as content from "resume" where id = $1`,
|
||||
[id],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new Error(`Resume ${id} was not found.`);
|
||||
return row.content;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function download(page: Page, testInfo: TestInfo, format: "PDF" | "DOCX" | "JSON") {
|
||||
await openSidebarSection(page, "Export");
|
||||
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: `Download ${format}`, exact: true }).click();
|
||||
const result = await pending;
|
||||
const path = testInfo.outputPath(`literal-whitespace.${format.toLowerCase()}`);
|
||||
await result.saveAs(path);
|
||||
return path;
|
||||
}
|
||||
|
||||
async function pdfLineMetrics(path: string) {
|
||||
const { getDocument } = await import(requireWeb.resolve("pdfjs-dist/legacy/build/pdf.mjs"));
|
||||
const loading = getDocument({ data: new Uint8Array(await readFile(path)), useSystemFonts: true });
|
||||
try {
|
||||
const document = await loading.promise;
|
||||
const lines: Array<{ marker: string; start: number; gap: number; tailWidth: number }> = [];
|
||||
for (let pageIndex = 0; pageIndex < document.numPages; pageIndex++) {
|
||||
const page = await document.getPage(pageIndex + 1);
|
||||
const items = (await page.getTextContent()).items.flatMap((item) =>
|
||||
"str" in item ? [{ text: item.str, x: item.transform[4], y: item.transform[5], width: item.width }] : [],
|
||||
);
|
||||
for (const marker of ["LIT", "REF", "CTL", "TAB"]) {
|
||||
const anchor = items.find((item) => item.text.includes(marker));
|
||||
if (!anchor) continue;
|
||||
const tail = items.find(
|
||||
(item) => item.x > anchor.x && Math.abs(item.y - anchor.y) < 0.01 && item.text.includes("B END"),
|
||||
);
|
||||
if (!tail) continue;
|
||||
const anchorEnd = anchor.x + anchor.width;
|
||||
lines.push({ marker, start: anchor.x, gap: tail.x - anchorEnd, tailWidth: tail.x + tail.width - anchorEnd });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
} finally {
|
||||
await loading.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
test("persists typed and pasted literal whitespace through JSON, PDF, and DOCX", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!id) throw new Error("Missing resume id.");
|
||||
|
||||
await openSidebarSection(page, "Summary");
|
||||
const editor = page.locator("#sidebar-summary [data-editor=true]");
|
||||
await editor.click();
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.insertText(" LIT A\tB END ");
|
||||
await page.keyboard.press("Enter");
|
||||
await editor.evaluate((element) => {
|
||||
const clipboard = new DataTransfer();
|
||||
clipboard.setData("text/plain", " REF A B END \nCTL A B END\n\tTAB A B END");
|
||||
element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }));
|
||||
});
|
||||
|
||||
await expect.poll(() => readSummary(id)).toBe(preservedHtml);
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Summary");
|
||||
const reloadedEditor = page.locator("#sidebar-summary [data-editor=true]");
|
||||
await expect(reloadedEditor).toBeVisible();
|
||||
expect(await reloadedEditor.evaluate((element) => element.textContent)).toBe(
|
||||
" LIT A\tB END REF A B END CTL A B END\tTAB A B END",
|
||||
);
|
||||
expect(
|
||||
await reloadedEditor
|
||||
.locator("p")
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).whiteSpace),
|
||||
).toBe("pre-wrap");
|
||||
expect(await readSummary(id)).toBe(preservedHtml);
|
||||
|
||||
const jsonPath = await download(page, testInfo, "JSON");
|
||||
const exported = JSON.parse(await readFile(jsonPath, "utf8")) as { summary: { content: string } };
|
||||
expect(exported.summary.content).toBe(preservedHtml);
|
||||
|
||||
const pdfPath = await download(page, testInfo, "PDF");
|
||||
const lines = await pdfLineMetrics(pdfPath);
|
||||
const literal = lines.find((line) => line.marker === "LIT");
|
||||
const reference = lines.find((line) => line.marker === "REF");
|
||||
expect(literal).toBeDefined();
|
||||
expect(reference).toBeDefined();
|
||||
if (!literal || !reference) throw new Error("Expected literal whitespace PDF lines.");
|
||||
expect(literal.start).toBeCloseTo(reference.start, 2);
|
||||
expect(literal.gap).toBeCloseTo(reference.gap, 2);
|
||||
expect(literal.tailWidth).toBeCloseTo(reference.tailWidth, 2);
|
||||
const control = lines.find((line) => line.marker === "CTL");
|
||||
const leadingTab = lines.find((line) => line.marker === "TAB");
|
||||
if (!control || !leadingTab) throw new Error("Expected unpadded and leading-tab PDF controls.");
|
||||
expect(literal.gap).toBeGreaterThan(0);
|
||||
expect(literal.start - control.start).toBeCloseTo(literal.gap / 2, 2);
|
||||
expect(leadingTab.start - control.start).toBeCloseTo(literal.gap, 2);
|
||||
await testInfo.attach("pdf-whitespace-geometry", {
|
||||
body: JSON.stringify(lines, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
const docxPath = await download(page, testInfo, "DOCX");
|
||||
const { stdout: documentXml } = await execFileAsync("unzip", ["-p", docxPath, "word/document.xml"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
expect(documentXml).toContain('xml:space="preserve"> LIT A B END </w:t>');
|
||||
expect(documentXml).toContain('xml:space="preserve"> REF A B END </w:t>');
|
||||
expect(documentXml).toContain('xml:space="preserve">CTL A B END</w:t>');
|
||||
expect(documentXml).toContain('xml:space="preserve"> TAB A B END</w:t>');
|
||||
});
|
||||
|
||||
test("displays each stored tab as four spaces with usable caret and selection", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
await openSidebarSection(page, "Summary");
|
||||
const editor = page.locator("#sidebar-summary [data-editor=true]");
|
||||
await editor.click();
|
||||
await page.keyboard.press("ControlOrMeta+a");
|
||||
await page.keyboard.press("Backspace");
|
||||
await editor.evaluate((element) => {
|
||||
const clipboard = new DataTransfer();
|
||||
clipboard.setData("text/plain", "A\tB\nAB\nABC\tD\nABCD\nA B\n\tZ\nZ\nA\t\tB");
|
||||
element.dispatchEvent(new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: clipboard }));
|
||||
});
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
const geometry = await editor.evaluate((element, direction) => {
|
||||
element.dir = direction;
|
||||
element.style.fontFamily = "Arial";
|
||||
// Measure ordinary-space advances without Arial's A/space pair kerning.
|
||||
element.style.fontKerning = "none";
|
||||
const blocks = Array.from(element.querySelectorAll("p"));
|
||||
const glyph = (index: number, offset: number) => {
|
||||
const walker = document.createTreeWalker(blocks[index], NodeFilter.SHOW_TEXT);
|
||||
let remaining = offset;
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (remaining < (node.textContent?.length ?? 0)) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, remaining);
|
||||
range.setEnd(node, remaining + 1);
|
||||
return range.getBoundingClientRect();
|
||||
}
|
||||
remaining -= node.textContent?.length ?? 0;
|
||||
}
|
||||
throw new Error("Missing glyph");
|
||||
};
|
||||
return {
|
||||
one: glyph(0, 2).x - glyph(0, 0).right,
|
||||
three: glyph(2, 4).x - glyph(2, 2).right,
|
||||
spaces: glyph(4, 5).x - glyph(4, 0).right,
|
||||
leading: (direction === "rtl" ? -1 : 1) * (glyph(5, 1).x - glyph(6, 0).x),
|
||||
two: glyph(7, 3).x - glyph(7, 0).right,
|
||||
text: blocks.map((block) => block.textContent),
|
||||
};
|
||||
}, direction);
|
||||
await testInfo.attach(`tab-geometry-${direction}`, {
|
||||
body: JSON.stringify(geometry, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
expect(geometry.spaces).toBeGreaterThan(0);
|
||||
for (const width of [geometry.one, geometry.three, geometry.leading]) expect(width).toBeCloseTo(geometry.spaces, 1);
|
||||
expect(geometry.two).toBeCloseTo(geometry.spaces * 2, 1);
|
||||
expect(geometry.text).toEqual(["A\tB", "AB", "ABC\tD", "ABCD", "A B", "\tZ", "Z", "A\t\tB"]);
|
||||
}
|
||||
const id = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!id) throw new Error("Missing resume id.");
|
||||
await expect.poll(() => readSummary(id)).toContain('data-resume-whitespace="preserve">A\tB</p>');
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Summary");
|
||||
await editor.click();
|
||||
await editor.evaluate((element) => {
|
||||
const text = element.querySelector("p")?.firstChild;
|
||||
if (!text) throw new Error("Missing caret target");
|
||||
const range = document.createRange();
|
||||
range.setStart(text, 1);
|
||||
range.collapse(true);
|
||||
getSelection()?.removeAllRanges();
|
||||
getSelection()?.addRange(range);
|
||||
});
|
||||
await page.keyboard.press("Shift+ArrowRight");
|
||||
expect(await page.evaluate(() => getSelection()?.toString())).toBe("\t");
|
||||
await page.keyboard.insertText("Q");
|
||||
await expect(editor.locator("p").first()).toHaveText("AQB");
|
||||
await page.keyboard.press("ControlOrMeta+z");
|
||||
expect(await editor.locator("p").first().textContent()).toBe("A\tB");
|
||||
});
|
||||
@@ -1,24 +1,17 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
for (const { accept, login, metadataResource } of [
|
||||
{ accept: false, login: undefined },
|
||||
{ accept: true, login: undefined },
|
||||
{ accept: true, login: "signed-out" },
|
||||
{ accept: true, login: "fresh" },
|
||||
{ accept: true, login: undefined, metadataResource: true },
|
||||
]) {
|
||||
test(`requires explicit OAuth consent before ${accept ? "allowing" : "denying"} access (${metadataResource ? "advertised resource" : (login ?? "existing session")})`, async ({
|
||||
for (const accept of [false, true]) {
|
||||
test(`requires explicit OAuth consent before ${accept ? "allowing" : "denying"} access`, async ({
|
||||
authPage: page,
|
||||
baseURL,
|
||||
account,
|
||||
}, testInfo) => {
|
||||
const origin = new URL(baseURL ?? "http://localhost:3000").origin;
|
||||
const metadata = await page.request.get("/.well-known/oauth-protected-resource");
|
||||
expect(metadata.status()).toBe(200);
|
||||
const advertisedResource = (await metadata.json()).resource;
|
||||
expect(advertisedResource).toBe(origin);
|
||||
const resource = metadataResource ? advertisedResource : `${origin}/mcp`;
|
||||
const resource = `${origin}/mcp`;
|
||||
const callback = "http://127.0.0.1:33921/callback";
|
||||
const registration = await page.request.post("/api/auth/oauth2/register", {
|
||||
headers: { origin },
|
||||
@@ -37,27 +30,17 @@ for (const { accept, login, metadataResource } of [
|
||||
resource,
|
||||
state: "browser-consent-state",
|
||||
});
|
||||
if (!metadataResource) query.append("resource", origin);
|
||||
query.append("resource", origin);
|
||||
await page.route(`${callback}**`, (route) => route.fulfill({ body: "Client callback" }));
|
||||
if (login === "fresh") query.set("prompt", "login");
|
||||
if (login === "signed-out") await page.context().clearCookies();
|
||||
await page.goto(`/api/auth/oauth2/authorize?${query}`);
|
||||
if (login) {
|
||||
await expect(page.getByRole("heading", { name: "Sign in to your account" })).toBeVisible();
|
||||
await page.getByLabel("Email Address", { exact: true }).fill(account.email);
|
||||
await page.getByLabel("Password", { exact: true }).fill(account.password);
|
||||
await page.getByRole("button", { name: "Sign in", exact: true }).click();
|
||||
}
|
||||
await expect(page.getByRole("heading", { name: "Connect an application" })).toBeVisible();
|
||||
await expect(page.getByText("Consent test client", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/reading and changing your resumes and job applications/)).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Allow access", exact: true })).toBeEnabled();
|
||||
const before = await page.request.get("/api/auth/oauth2/get-consents");
|
||||
expect(await before.json()).toEqual([]);
|
||||
expect(new URL(page.url()).searchParams.getAll("resource")).toEqual(
|
||||
metadataResource ? [origin] : [`${origin}/mcp`, origin],
|
||||
);
|
||||
if (accept && !login) {
|
||||
expect(new URL(page.url()).searchParams.getAll("resource")).toEqual([`${origin}/mcp`, origin]);
|
||||
if (accept) {
|
||||
await page.getByRole("button", { name: "Allow access", exact: true }).click({ trial: true });
|
||||
await page.screenshot({ path: testInfo.outputPath("consent-desktop.png"), animations: "disabled" });
|
||||
await page.setViewportSize({ width: 390, height: 600 });
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { Pool } from "pg";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
type MarkerBounds = {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type MarkerMetrics = Record<"red" | "green" | "blue" | "yellow", MarkerBounds | null>;
|
||||
|
||||
const requireRoot = createRequire(`${process.cwd()}/package.json`);
|
||||
|
||||
async function installPdfJsRoute(page: Page) {
|
||||
await page.route("**/__picture_pdfjs/*", async (route) => {
|
||||
const worker = new URL(route.request().url()).pathname.endsWith("worker.mjs");
|
||||
await route.fulfill({
|
||||
contentType: "text/javascript",
|
||||
path: requireRoot.resolve(`pdfjs-dist/legacy/build/${worker ? "pdf.worker.mjs" : "pdf.mjs"}`),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function markedLandscapePng(dataUrl: string) {
|
||||
return Buffer.from(dataUrl.slice(dataUrl.indexOf(",") + 1), "base64");
|
||||
}
|
||||
|
||||
function createMarkedLandscape(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 800;
|
||||
canvas.height = 600;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 800, 600);
|
||||
context.fillStyle = "#ff0000";
|
||||
context.fillRect(0, 0, 64, 600);
|
||||
context.fillStyle = "#00ff00";
|
||||
context.fillRect(736, 0, 64, 600);
|
||||
context.fillStyle = "#0000ff";
|
||||
context.fillRect(64, 0, 672, 48);
|
||||
context.fillStyle = "#ffff00";
|
||||
context.fillRect(64, 552, 672, 48);
|
||||
context.fillStyle = "#000000";
|
||||
context.fillRect(394, 48, 12, 504);
|
||||
context.fillRect(64, 294, 672, 12);
|
||||
return canvas.toDataURL("image/png");
|
||||
});
|
||||
}
|
||||
|
||||
function markerMetricsFromPdf(page: Page, bytes: Uint8Array): Promise<MarkerMetrics> {
|
||||
return page.evaluate(async (pdfBytes) => {
|
||||
const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(
|
||||
`${location.origin}/__picture_pdfjs/pdf.mjs`
|
||||
);
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__picture_pdfjs/worker.mjs`;
|
||||
const task = pdfjs.getDocument({ data: Uint8Array.from(pdfBytes) });
|
||||
try {
|
||||
const pdfPage = await task.promise.then((pdf) => pdf.getPage(1));
|
||||
const viewport = pdfPage.getViewport({ scale: 1.5 });
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.ceil(viewport.width);
|
||||
canvas.height = Math.ceil(viewport.height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
await pdfPage.render({ canvas, canvasContext: context, viewport, background: "white" }).promise;
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
const predicates = {
|
||||
red: (r: number, g: number, b: number) => r > 220 && g < 50 && b < 50,
|
||||
green: (r: number, g: number, b: number) => r < 50 && g > 220 && b < 50,
|
||||
blue: (r: number, g: number, b: number) => r < 50 && g < 50 && b > 220,
|
||||
yellow: (r: number, g: number, b: number) => r > 220 && g > 220 && b < 50,
|
||||
};
|
||||
const result = {} as Record<keyof typeof predicates, MarkerBounds | null>;
|
||||
for (const [name, predicate] of Object.entries(predicates) as Array<
|
||||
[keyof typeof predicates, (r: number, g: number, b: number) => boolean]
|
||||
>) {
|
||||
let left = Number.POSITIVE_INFINITY;
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
let top = Number.POSITIVE_INFINITY;
|
||||
let bottom = Number.NEGATIVE_INFINITY;
|
||||
let count = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if (!predicate(pixels[index] ?? 0, pixels[index + 1] ?? 0, pixels[index + 2] ?? 0)) continue;
|
||||
const x = (index / 4) % canvas.width;
|
||||
const y = Math.floor(index / 4 / canvas.width);
|
||||
left = Math.min(left, x);
|
||||
right = Math.max(right, x);
|
||||
top = Math.min(top, y);
|
||||
bottom = Math.max(bottom, y);
|
||||
count++;
|
||||
}
|
||||
result[name] = count > 0 ? { left, right, top, bottom, count } : null;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
await task.destroy();
|
||||
}
|
||||
}, Array.from(bytes));
|
||||
}
|
||||
|
||||
async function exportBuilderFile(page: Page, testInfo: TestInfo, buttonName: "Download JSON" | "Download PDF") {
|
||||
await openSidebarSection(page, "Export");
|
||||
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: buttonName, exact: true }).click();
|
||||
const download = await pending;
|
||||
const path = testInfo.outputPath(download.suggestedFilename());
|
||||
await download.saveAs(path);
|
||||
await page.keyboard.press("Escape");
|
||||
return path;
|
||||
}
|
||||
|
||||
test("uploads and persists full Contain image with browser/server PDF parity", async ({
|
||||
authPage: page,
|
||||
account,
|
||||
}, info) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1920, height: 950 });
|
||||
await installPdfJsRoute(page);
|
||||
await createSampleResumeFromDashboard(page, info);
|
||||
const resumeId = page.url().split("/").at(-1);
|
||||
if (!resumeId) throw new Error("Missing resume id");
|
||||
|
||||
await openSidebarSection(page, "Picture");
|
||||
await page.getByRole("button", { name: "Contain", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "Undo", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Cover", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "Redo", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
const dataUrl = await createMarkedLandscape(page);
|
||||
const inputBytes = markedLandscapePng(dataUrl);
|
||||
await info.attach("marked-landscape.png", { body: inputBytes, contentType: "image/png" });
|
||||
await page.locator('#sidebar-picture input[type="file"]').setInputFiles({
|
||||
name: "marked-landscape.png",
|
||||
mimeType: "image/png",
|
||||
buffer: inputBytes,
|
||||
});
|
||||
await expect(page.getByRole("dialog", { name: "Crop picture" })).toHaveCount(0);
|
||||
const pictureUrl = page.locator("#sidebar-picture input[name=url]");
|
||||
await expect(pictureUrl).toHaveValue(/\/uploads\//);
|
||||
const sidebarPreview = page.getByRole("button", { name: "Delete picture" }).locator("img");
|
||||
await expect(sidebarPreview).toBeVisible();
|
||||
await expect(sidebarPreview).toHaveCSS("object-fit", "contain");
|
||||
const storedUrl = await pictureUrl.inputValue();
|
||||
const storedRequestUrl = storedUrl.startsWith("/uploads/") ? `/api${storedUrl}` : storedUrl;
|
||||
const storedResponse = await page.request.get(storedRequestUrl);
|
||||
expect(storedResponse.ok()).toBe(true);
|
||||
expect(storedResponse.headers()["content-type"]).toMatch(/^image\//);
|
||||
const storedBytes = await storedResponse.body();
|
||||
await info.attach("stored-landscape", { body: storedBytes, contentType: storedResponse.headers()["content-type"] });
|
||||
const storedGeometry = await page.evaluate(async (url) => {
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas is unavailable");
|
||||
context.drawImage(image, 0, 0);
|
||||
const sample = (x: number, y: number) => [...context.getImageData(x, y, 1, 1).data.slice(0, 3)];
|
||||
return {
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight,
|
||||
left: sample(Math.floor(image.naturalWidth * 0.02), Math.floor(image.naturalHeight / 2)),
|
||||
right: sample(Math.floor(image.naturalWidth * 0.98), Math.floor(image.naturalHeight / 2)),
|
||||
top: sample(Math.floor(image.naturalWidth / 4), Math.floor(image.naturalHeight * 0.02)),
|
||||
bottom: sample(Math.floor(image.naturalWidth / 4), Math.floor(image.naturalHeight * 0.98)),
|
||||
};
|
||||
}, storedRequestUrl);
|
||||
expect(storedGeometry).toMatchObject({ width: 800, height: 600 });
|
||||
expect(storedGeometry.left[0]).toBeGreaterThan(220);
|
||||
expect(storedGeometry.left[1]).toBeLessThan(50);
|
||||
expect(storedGeometry.right[0]).toBeLessThan(50);
|
||||
expect(storedGeometry.right[1]).toBeGreaterThan(220);
|
||||
expect(storedGeometry.top[2]).toBeGreaterThan(220);
|
||||
expect(storedGeometry.bottom[0]).toBeGreaterThan(220);
|
||||
expect(storedGeometry.bottom[1]).toBeGreaterThan(220);
|
||||
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
let slug = "";
|
||||
try {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const result = await pool.query<{ data: { picture: { fit?: string; url?: string } }; slug: string }>(
|
||||
"select data, slug from resume where id = $1",
|
||||
[resumeId],
|
||||
);
|
||||
slug = result.rows[0]?.slug ?? "";
|
||||
return result.rows[0]?.data.picture;
|
||||
})
|
||||
.toMatchObject({ fit: "contain", url: storedUrl });
|
||||
|
||||
await page.getByRole("button", { name: "Cover", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Contain", exact: true }).click();
|
||||
expect(await pictureUrl.inputValue()).toBe(storedUrl);
|
||||
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Picture");
|
||||
await expect(page.getByRole("button", { name: "Contain", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.locator("#sidebar-picture input[name=url]")).toHaveValue(storedUrl);
|
||||
|
||||
await pool.query("update resume set is_public = true, updated_at = now() where id = $1", [resumeId]);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
const jsonPath = await exportBuilderFile(page, info, "Download JSON");
|
||||
const exported = JSON.parse(await readFile(jsonPath, "utf8")) as { picture: { fit?: string; url?: string } };
|
||||
expect(exported.picture).toMatchObject({ fit: "contain", url: storedUrl });
|
||||
|
||||
const browserPdfPath = await exportBuilderFile(page, info, "Download PDF");
|
||||
const browserPdf = new Uint8Array(await readFile(browserPdfPath));
|
||||
const serverResponse = await page.request.get(`/api/resumes/${account.username}/${slug}/pdf`);
|
||||
expect(serverResponse.ok()).toBe(true);
|
||||
const serverPdf = new Uint8Array(await serverResponse.body());
|
||||
const browserMarkers = await markerMetricsFromPdf(page, browserPdf);
|
||||
const serverMarkers = await markerMetricsFromPdf(page, serverPdf);
|
||||
for (const name of ["red", "green", "blue", "yellow"] as const) {
|
||||
expect(browserMarkers[name]?.count).toBeGreaterThan(100);
|
||||
expect(serverMarkers[name]?.count).toBeGreaterThan(100);
|
||||
expect(serverMarkers[name]).toEqual(browserMarkers[name]);
|
||||
}
|
||||
await info.attach("pdf-marker-metrics", {
|
||||
body: JSON.stringify({ browserMarkers, serverMarkers }),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { Pool } from "pg";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
type PdfCaptureWindow = Window & { resumePdfBytes?: number[] };
|
||||
|
||||
async function capturePdfBytes(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const read = Blob.prototype.arrayBuffer;
|
||||
Blob.prototype.arrayBuffer = async function () {
|
||||
const buffer = await read.call(this);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
if (String.fromCharCode(...bytes.subarray(0, 5)) === "%PDF-") {
|
||||
(window as PdfCaptureWindow).resumePdfBytes = Array.from(bytes);
|
||||
}
|
||||
return buffer;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function compareWithPdfReference(page: Page) {
|
||||
const require = createRequire(`${process.cwd()}/package.json`);
|
||||
await page.route("**/__pdf_reference/*", async (route) => {
|
||||
const worker = new URL(route.request().url()).pathname.endsWith("worker.mjs");
|
||||
await route.fulfill({
|
||||
contentType: "text/javascript",
|
||||
path: require.resolve(`pdfjs-dist/legacy/build/${worker ? "pdf.worker.mjs" : "pdf.mjs"}`),
|
||||
});
|
||||
});
|
||||
|
||||
return page.evaluate(async () => {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('[aria-hidden="false"] canvas');
|
||||
const bytes = (window as PdfCaptureWindow).resumePdfBytes;
|
||||
if (!canvas || !bytes) throw new Error("Missing rendered preview or original PDF bytes");
|
||||
const actual = canvas.getContext("2d")?.getImageData(0, 0, canvas.width, canvas.height);
|
||||
if (!actual) throw new Error("Missing preview pixels");
|
||||
const moduleUrl = `${location.origin}/__pdf_reference/pdf.mjs`;
|
||||
const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(moduleUrl);
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__pdf_reference/worker.mjs`;
|
||||
const task = pdfjs.getDocument({ data: Uint8Array.from(bytes) });
|
||||
try {
|
||||
const pdf = await task.promise;
|
||||
const pdfPage = await pdf.getPage(1);
|
||||
const reference = document.createElement("canvas");
|
||||
reference.width = canvas.width;
|
||||
reference.height = canvas.height;
|
||||
// Match inherited font rasterization styles as well as the original bitmap size.
|
||||
canvas.parentElement?.append(reference);
|
||||
const context = reference.getContext("2d");
|
||||
if (!context) throw new Error("Missing reference context");
|
||||
// PDF coordinates are physical. Reference must not inherit the interface's text direction.
|
||||
context.direction = "ltr";
|
||||
await pdfPage.render({
|
||||
canvas: reference,
|
||||
canvasContext: context,
|
||||
viewport: pdfPage.getViewport({ scale: 1 }),
|
||||
transform: [4, 0, 0, 4, 0, 0],
|
||||
annotationMode: pdfjs.AnnotationMode.DISABLE,
|
||||
background: "white",
|
||||
}).promise;
|
||||
const expected = context.getImageData(0, 0, reference.width, reference.height);
|
||||
reference.remove();
|
||||
let differentPixels = 0;
|
||||
let inkPixels = 0;
|
||||
for (let i = 0; i < actual.data.length; i += 4) {
|
||||
if (expected.data[i] !== 255 || expected.data[i + 1] !== 255 || expected.data[i + 2] !== 255) inkPixels++;
|
||||
if (
|
||||
actual.data[i] !== expected.data[i] ||
|
||||
actual.data[i + 1] !== expected.data[i + 1] ||
|
||||
actual.data[i + 2] !== expected.data[i + 2] ||
|
||||
actual.data[i + 3] !== expected.data[i + 3]
|
||||
) {
|
||||
differentPixels++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
differentPixels,
|
||||
actualPng: canvas.toDataURL(),
|
||||
referencePng: reference.toDataURL(),
|
||||
inkPixels,
|
||||
pageDirection: canvas.closest("[dir]")?.getAttribute("dir"),
|
||||
uiDirection: document.documentElement.dir,
|
||||
};
|
||||
} finally {
|
||||
await task.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const uiLanguage of ["English", "Arabic"]) {
|
||||
for (const resumeLocale of ["en-US", "ar-SA"]) {
|
||||
test(`preserves PDF glyph pixels with ${uiLanguage} UI and ${resumeLocale} resume`, async ({
|
||||
authPage: page,
|
||||
}, info) => {
|
||||
test.setTimeout(60_000);
|
||||
await page.setViewportSize({ width: 1920, height: 950 });
|
||||
await capturePdfBytes(page);
|
||||
await page.goto("/dashboard/resumes");
|
||||
await page.getByText("Create a new resume", { exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Create a new resume" });
|
||||
await dialog.getByLabel("Name", { exact: true }).fill("Preview direction fixture");
|
||||
await dialog.getByRole("button", { name: "Create", exact: true }).click();
|
||||
await page.waitForURL(/\/builder\/.+/);
|
||||
const builderUrl = page.url();
|
||||
const resumeId = builderUrl.split("/").at(-1);
|
||||
await page.goto("/dashboard/resumes");
|
||||
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||
try {
|
||||
await pool.query(
|
||||
`update resume set data = jsonb_set(jsonb_set(jsonb_set(data, '{metadata,page,locale}', $2::jsonb), '{basics,name}', '"Preview direction fixture"'::jsonb), '{basics,headline}', '"Software مهندس Engineer"'::jsonb) where id = $1`,
|
||||
[resumeId, JSON.stringify(resumeLocale)],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
await page.goto(builderUrl);
|
||||
if (uiLanguage === "Arabic") {
|
||||
await page.getByRole("button", { name: "Account menu", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Language", exact: true }).click();
|
||||
await page.getByRole("menuitemradio", { name: "Arabic", exact: true }).click();
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", "rtl");
|
||||
await page.reload();
|
||||
}
|
||||
await expect(page.locator('[aria-hidden="false"] canvas').first()).toBeVisible();
|
||||
const result = await compareWithPdfReference(page);
|
||||
const { actualPng, referencePng, ...metrics } = result;
|
||||
await writeFile(
|
||||
info.outputPath("actual.png"),
|
||||
Buffer.from(actualPng.slice(actualPng.indexOf(",") + 1), "base64"),
|
||||
);
|
||||
await writeFile(
|
||||
info.outputPath("reference.png"),
|
||||
Buffer.from(referencePng.slice(referencePng.indexOf(",") + 1), "base64"),
|
||||
);
|
||||
await info.attach("raster-comparison", { body: JSON.stringify(metrics), contentType: "application/json" });
|
||||
expect(result.inkPixels).toBeGreaterThan(1000);
|
||||
expect(result.uiDirection).toBe(uiLanguage === "Arabic" ? "rtl" : "ltr");
|
||||
expect(result.pageDirection).toBe(resumeLocale === "ar-SA" ? "rtl" : "ltr");
|
||||
expect(result.differentPixels).toBe(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
test("creates a sample resume and persists a basics edit", async ({ authPage: page }, testInfo) => {
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
|
||||
const updatedName = `E2E Edited ${Date.now()}`;
|
||||
await openSidebarSection(page, "Basics");
|
||||
const savePromise = page.waitForResponse((response) => {
|
||||
if (!response.url().includes("/api/rpc")) return false;
|
||||
if (response.request().method() !== "POST") return false;
|
||||
if (!response.ok()) return false;
|
||||
|
||||
const body = response.request().postData() ?? "";
|
||||
return body.includes(updatedName);
|
||||
});
|
||||
await page.getByLabel("Name").fill(updatedName);
|
||||
await savePromise;
|
||||
|
||||
await page.reload();
|
||||
await openSidebarSection(page, "Basics");
|
||||
await expect(page.getByLabel("Name")).toHaveValue(updatedName);
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Pool } from "pg";
|
||||
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
|
||||
import { resumeIdFromPage } from "../fixtures/semantic-css";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
type ExperienceItem = {
|
||||
@@ -70,7 +69,8 @@ test("sorts Experience once while preserving undo, persistence, later edits, and
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
await createSampleResumeFromDashboard(page, testInfo);
|
||||
const resumeId = resumeIdFromPage(page);
|
||||
const resumeId = new URL(page.url()).pathname.match(/^\/builder\/([^/]+)/)?.[1];
|
||||
if (!resumeId) throw new Error(`Expected a builder URL, received ${page.url()}.`);
|
||||
const authoredItems = [
|
||||
experienceItem("unknown-sort", "Mystery Co", "Recently"),
|
||||
experienceItem("older-sort", "Older Co", "2018 - 2020"),
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { createSemanticCssResume, readStylesheetSource, seedSemanticCssResume } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css keeps color picker edits and swatches aligned through dismissal and undo", async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
const source = "@version 1;\nsection {\n\tcolor: #f00;\n\tbackground-color: #fff;\n}";
|
||||
await seedSemanticCssResume(page, resumeId, {
|
||||
stylesheet: { mode: "semantic", source: { languageVersion: 1, text: source } },
|
||||
});
|
||||
const swatches = page.locator(".semantic-css-color-swatch");
|
||||
const trigger = page.locator("[data-semantic-css-color-picker-trigger]");
|
||||
const picker = page.getByRole("dialog").filter({ has: page.getByText("Presets", { exact: true }) });
|
||||
await expect(swatches).toHaveCount(2);
|
||||
await page.getByRole("button", { name: "Edit color #f00", exact: true }).click();
|
||||
|
||||
const first = source.replace("#f00", "#000000");
|
||||
await picker.getByRole("button", { name: "Use color rgba(0, 0, 0, 1)", exact: true }).click();
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(first);
|
||||
await expect(page.getByText("Presets", { exact: true })).toBeVisible();
|
||||
|
||||
const second = source.replace("#f00", "#e7000b");
|
||||
await picker.getByRole("button", { name: "Use color rgba(231, 0, 11, 1)", exact: true }).click();
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(second);
|
||||
await expect(swatches).toHaveCount(2);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByText("Presets", { exact: true })).toHaveCount(0);
|
||||
await expect(trigger).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Edit color #e7000b", exact: true }).click();
|
||||
await expect(page.getByText("Presets", { exact: true })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toHaveCount(0);
|
||||
|
||||
await page.getByRole("button", { name: "Undo stylesheet edit", exact: true }).click();
|
||||
// Rapid presets can coalesce into one undo step; either complete prior stylesheet is valid.
|
||||
await expect.poll(async () => [source, first].includes(await readStylesheetSource(page))).toBe(true);
|
||||
await expect(trigger).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Redo stylesheet edit", exact: true }).click();
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(second);
|
||||
await expect(swatches).toHaveCount(2);
|
||||
|
||||
await page.getByRole("button", { name: "Edit color #fff", exact: true }).click();
|
||||
await picker.getByRole("button", { name: "Use color rgba(21, 93, 252, 1)", exact: true }).click();
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(second.replace("#fff", "#155dfc"));
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(trigger).toHaveCount(0);
|
||||
await expect(swatches).toHaveCount(2);
|
||||
await expect(page.getByRole("button", { name: "Edit color #e7000b", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Edit color #155dfc", exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Open focus mode", exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Edit color #e7000b", exact: true })).toBeInViewport();
|
||||
await expect(page.getByRole("button", { name: "Edit color #155dfc", exact: true })).toBeInViewport();
|
||||
await expect(page.getByText("Saved", { exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("color-picker-after-undo.png"), animations: "disabled" });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
for (const inputColor of ["#FF000080", "red", "rgb(100% 0% 0% / 50%)"]) {
|
||||
test(`@semantic-css preserves alpha when editing ${inputColor} and reopening hex colors`, async ({
|
||||
authPage: page,
|
||||
}, testInfo) => {
|
||||
const resumeId = await createSemanticCssResume(page, testInfo);
|
||||
const source = `@version 1;\nsection { color: ${inputColor}; background-color: #fff; }`;
|
||||
await seedSemanticCssResume(page, resumeId, {
|
||||
stylesheet: { mode: "semantic", source: { languageVersion: 1, text: source } },
|
||||
});
|
||||
await page.getByRole("button", { name: `Edit color ${inputColor}`, exact: true }).click();
|
||||
const picker = page.getByRole("dialog").filter({ has: page.getByText("Presets", { exact: true }) });
|
||||
await expect(picker).toBeVisible();
|
||||
expect(await readStylesheetSource(page)).toBe(source);
|
||||
const alpha = picker.locator(".w-color-alpha:not(.w-color-hue)");
|
||||
const bounds = await alpha.boundingBox();
|
||||
if (!bounds) throw new Error("Missing alpha control");
|
||||
await alpha.click({ position: { x: 0, y: bounds.height / 2 } });
|
||||
await expect.poll(() => readStylesheetSource(page)).toBe(source.replace(inputColor, "#ff000000"));
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: "Edit color #ff000000", exact: true }).click();
|
||||
await expect(picker).toBeVisible();
|
||||
await alpha.click({ position: { x: bounds.width / 2, y: bounds.height / 2 } });
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const value = await readStylesheetSource(page);
|
||||
const match = value.match(/color: #ff0000([\da-f]{2});/);
|
||||
return match ? Number.parseInt(match[1] ?? "", 16) : -1;
|
||||
})
|
||||
.toBeGreaterThanOrEqual(127);
|
||||
const halfTransparent = await readStylesheetSource(page);
|
||||
// Browser pointer coordinates round to pixels; the midpoint can land on either adjacent alpha byte.
|
||||
expect(halfTransparent).toMatch(/color: #ff0000(?:7f|80);/);
|
||||
expect(halfTransparent).toContain("background-color: #fff;");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByRole("button", { name: /^Edit color #ff0000(?:7f|80)$/ }).click();
|
||||
await expect(picker).toBeVisible();
|
||||
expect(await readStylesheetSource(page)).toBe(halfTransparent);
|
||||
});
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createSemanticCssResume, readStylesheetSource } from "../../fixtures/semantic-css";
|
||||
import { expect, test } from "../../fixtures/test";
|
||||
|
||||
test("@semantic-css starts new resumes in semantic mode", 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("Valid", { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
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 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);
|
||||
await page.reload();
|
||||
await expect(page.getByText("Converted stylesheet draft", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Valid", { exact: true }).filter({ visible: true })).toBeVisible();
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
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 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 waitForPortableValid(page: Parameters<typeof waitForStablePreview>[0]) {
|
||||
await expect(
|
||||
page
|
||||
.getByText(/^Valid(?: 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?.source.text, { timeout: 30_000 })
|
||||
.toBe(source);
|
||||
await waitForPortableValid(page);
|
||||
}
|
||||
|
||||
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 waitForPortableValid(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 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",
|
||||
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);
|
||||
|
||||
console.info(
|
||||
`PORTABLE_PAGINATION_EVIDENCE ${JSON.stringify({
|
||||
breakInside: {
|
||||
withoutDirective: withoutBreakInsideDistribution,
|
||||
withDirective: breakInsideOnlyDistribution,
|
||||
},
|
||||
})}`,
|
||||
);
|
||||
expect(breakInsideOnlyDistribution, "break-inside: avoid must independently change project pagination").not.toEqual(
|
||||
withoutBreakInsideDistribution,
|
||||
);
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
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 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 } });
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
|
||||
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());
|
||||
}
|
||||
});
|
||||
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,15 +0,0 @@
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
test("updates the profile name from settings and persists it", async ({ authPage: page }) => {
|
||||
await page.goto("/dashboard/settings/profile");
|
||||
|
||||
const updatedName = `E2E Renamed ${Date.now()}`;
|
||||
const nameField = page.getByLabel("Name", { exact: true });
|
||||
await expect(nameField).toBeVisible();
|
||||
await nameField.fill(updatedName);
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
// The save round-trips through the API; a reload must show the new value
|
||||
await page.reload();
|
||||
await expect(page.getByLabel("Name", { exact: true })).toHaveValue(updatedName);
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createSampleResumeFromDashboard } from "../fixtures/resume";
|
||||
import { expect, test } from "../fixtures/test";
|
||||
|
||||
async function measureThumbnail(page: Page) {
|
||||
const image = page.locator('a[href^="/builder/"] [style*="background-image"]').first();
|
||||
await expect(image).toBeVisible({ timeout: 60_000 });
|
||||
return image.evaluate(async (element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const url = style.backgroundImage.slice(5, -2);
|
||||
const blob = await (await fetch(url)).blob();
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scale = Math.min(rect.width / bitmap.width, rect.height / bitmap.height);
|
||||
const result = {
|
||||
url,
|
||||
viewport: innerWidth,
|
||||
dpr: devicePixelRatio,
|
||||
cssWidth: rect.width,
|
||||
cssHeight: rect.height,
|
||||
pngWidth: bitmap.width,
|
||||
pngHeight: bitmap.height,
|
||||
coverage: 1 / (scale * devicePixelRatio),
|
||||
};
|
||||
bitmap.close();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSharpThumbnail(page: Page) {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect
|
||||
.poll(async () => (await measureThumbnail(page)).coverage, { timeout: 60_000 })
|
||||
.toBeGreaterThanOrEqual(0.99);
|
||||
return measureThumbnail(page);
|
||||
}
|
||||
|
||||
test("dashboard thumbnails cover physical pixels in fresh and cached Grid and Compact views", async ({
|
||||
authPage,
|
||||
browser,
|
||||
}, info) => {
|
||||
test.setTimeout(180_000);
|
||||
await createSampleResumeFromDashboard(authPage, info);
|
||||
const storageState = await authPage.context().storageState();
|
||||
const measurements: unknown[] = [];
|
||||
for (const deviceScaleFactor of [1, 2, 3]) {
|
||||
const context = await browser.newContext({
|
||||
baseURL: String(info.project.use.baseURL),
|
||||
storageState,
|
||||
deviceScaleFactor,
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto("/dashboard/resumes?view=grid");
|
||||
const desktop = await expectSharpThumbnail(page);
|
||||
measurements.push({ scenario: "fresh-desktop-grid", ...desktop });
|
||||
// Immediately below sm, one Grid column is 607 CSS px wide.
|
||||
await page.setViewportSize({ width: 639, height: 1000 });
|
||||
const grown = await expectSharpThumbnail(page);
|
||||
expect(grown.pngWidth).toBeGreaterThan(desktop.pngWidth);
|
||||
measurements.push({ scenario: "grown-cached-grid", ...grown });
|
||||
await page.screenshot({ path: info.outputPath(`grid-dpr${deviceScaleFactor}.png`) });
|
||||
await page.getByRole("tab", { name: "Compact", exact: true }).click();
|
||||
const compact = await expectSharpThumbnail(page);
|
||||
expect(compact.url).toBe(grown.url);
|
||||
measurements.push({ scenario: "cached-compact", ...compact });
|
||||
await page.reload();
|
||||
measurements.push({ scenario: "fresh-compact", ...(await expectSharpThumbnail(page)) });
|
||||
await page.getByRole("tab", { name: "Grid", exact: true }).click();
|
||||
measurements.push({ scenario: "compact-to-grid", ...(await expectSharpThumbnail(page)) });
|
||||
await page.reload();
|
||||
measurements.push({ scenario: "fresh-mobile-grid", ...(await expectSharpThumbnail(page)) });
|
||||
await page.setViewportSize({ width: 390, height: 1000 });
|
||||
const phone = await expectSharpThumbnail(page);
|
||||
measurements.push({ scenario: "phone-grid", ...phone });
|
||||
if (deviceScaleFactor === 1) {
|
||||
const cdp = await context.newCDPSession(page);
|
||||
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
||||
width: 390,
|
||||
height: 1000,
|
||||
deviceScaleFactor: 3,
|
||||
mobile: false,
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => devicePixelRatio)).toBe(3);
|
||||
const higherDpr = await expectSharpThumbnail(page);
|
||||
expect(higherDpr.pngWidth).toBeGreaterThan(phone.pngWidth);
|
||||
measurements.push({ scenario: "changed-dpr-grid", ...higherDpr });
|
||||
}
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await writeFile(info.outputPath("measurements.json"), JSON.stringify(measurements, null, 2));
|
||||
});
|
||||