feat(cv): qualify templates and reliable local import processing
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { previewDocumentHtml } from '../src/cvPreview';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const output = path.resolve(process.env.LETTER_LIBRARY_OUTPUT || '../.artifacts/pre-phase10-ux/matching-letters-01');
|
||||
const sha256 = (value: string | Buffer) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
test('[PRE-P10 B] nine matching letter designs retain short EN and long NB physical documents', async ({ page, context, browser }, info) => {
|
||||
test.setTimeout(600_000);
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
||||
});
|
||||
expect((await page.request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200);
|
||||
const me = await (await page.request.get(`${api}/auth/me`)).json();
|
||||
const headers = { 'X-CSRF-TOKEN': (await page.request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
|
||||
const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json();
|
||||
const themes = await (await page.request.get(`${api}/cv/themes`)).json();
|
||||
expect(themes).toHaveLength(9);
|
||||
const companyResponse = await page.request.post(`${api}/companies`, { headers, data: { name: 'Fictional Fjord Systems AS', source: 'User' } });
|
||||
expect(companyResponse.status()).toBe(201);
|
||||
const company = await companyResponse.json();
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
const fixtures = [];
|
||||
for (const language of ['en', 'nb'] as const) {
|
||||
const nb = language === 'nb';
|
||||
const paragraphs = Array.from({ length: nb ? 20 : 3 }, (_, index) => {
|
||||
const sentence = nb ? 'Jeg dokumenterte pålitelige arbeidsflyter, tilgjengelige grensesnitt og tydelig ansvar i et oppdiktet prosjekt. '
|
||||
: 'I documented reliable workflows, accessible interfaces and clear ownership in a fictional project. ';
|
||||
return `B${String(index + 1).padStart(2, '0')} ${sentence.repeat(nb ? 4 : 1)}`.trim();
|
||||
});
|
||||
paragraphs.push(nb ? 'R99 Med vennlig hilsen, Åse Ødegård-Sørensen. Dette er kun syntetiske eksempler.'
|
||||
: 'R99 Kind regards, Åse Ødegård-Sørensen. These are synthetic examples only.');
|
||||
const text = paragraphs.join('\n\n');
|
||||
for (const theme of themes) {
|
||||
const fixture = `${nb ? 'long-nb' : 'short-en'}-${theme.id}`;
|
||||
const directory = path.join(output, fixture);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const documentSettings = { schemaVersion: 1, designVersion: 2, language: nb ? 'nb-NO' : 'en', themeId: theme.id,
|
||||
senderName: 'R01 Åse Ødegård-Sørensen', senderTitle: nb ? 'P01 Plattformingeniør' : 'P01 Platform engineer',
|
||||
senderEmail: 'fictional.ase@example.test', senderLocation: 'Tromsø, Norge',
|
||||
recipient: 'R03 Fictional hiring team\nFictional Fjord Systems AS\nTromsø, Norge',
|
||||
subject: nb ? 'R02 Søknad om plattformingeniør' : 'R02 Application for platform engineer', date: '2026-10-27', pageSize: 'a4' };
|
||||
const created = await page.request.post(`${api}/jobapplications`, { headers, data: { companyId: company.id, jobTitle: fixture, status: 'Saved', description: 'Synthetic matching-letter physical proof.' } });
|
||||
expect(created.status(), fixture).toBe(201);
|
||||
const job = await created.json();
|
||||
const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`;
|
||||
const original = await (await page.request.get(letterUrl)).json();
|
||||
const savedResponse = await page.request.put(letterUrl, { headers, data: { text, documentSettings, source: 'manual', expectedVersion: original.revision } });
|
||||
expect(savedResponse.status(), fixture).toBe(200);
|
||||
const saved = await (await page.request.get(letterUrl)).json();
|
||||
expect(saved.text).toBe(text);
|
||||
expect(saved.documentSettings).toMatchObject({ designVersion: 2, themeId: theme.id, accentColor: theme.accent, bodyFont: theme.bodyFont, headingFont: theme.headingFont });
|
||||
const previewResponse = await page.request.post(`${letterUrl}/preview`, { headers, data: { text: saved.text, documentSettings: saved.documentSettings } });
|
||||
expect(previewResponse.status(), fixture).toBe(200);
|
||||
const rendered = await previewResponse.json();
|
||||
expect(rendered.html).toContain(`letter-theme-${theme.id}`);
|
||||
expect(rendered.html).toContain('data:font/ttf;base64,');
|
||||
expect(rendered.html).toContain(theme.accent);
|
||||
await fs.writeFile(path.join(directory, 'render.html'), rendered.html);
|
||||
const expectedMarkers = [documentSettings.senderName, documentSettings.senderTitle, documentSettings.recipient, documentSettings.subject, text].join('\n').match(/\b[SEBPR]\d{2}\b/g)!;
|
||||
await page.setViewportSize({ width: 794, height: 1123 });
|
||||
await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4));
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true');
|
||||
const measured = await page.evaluate(() => {
|
||||
const data = document.documentElement.dataset;
|
||||
const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight);
|
||||
const tokens = [];
|
||||
const walker = document.createTreeWalker(document.querySelector('.page')!, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (['SCRIPT', 'STYLE'].includes(node.parentElement?.tagName || '')) continue;
|
||||
for (const match of (node.textContent || '').matchAll(/\b[SEBPR]\d{2}\b/g)) {
|
||||
const range = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
|
||||
const rect = range.getClientRects()[0], style = getComputedStyle(node.parentElement!);
|
||||
const index = Math.floor(rect.x / width);
|
||||
tokens.push({ token: match[0], x: rect.x - index * width, y: rect.y + index * height, page: index + 1, fontSize: style.fontSize, color: style.color });
|
||||
}
|
||||
}
|
||||
const header = getComputedStyle(document.querySelector('header')!);
|
||||
const heading = getComputedStyle(document.querySelector('h1')!);
|
||||
const contact = getComputedStyle(document.querySelector('.contact')!);
|
||||
return { width, height, contentHeight: Number(data.cvPreviewPages) * height, pages: Number(data.cvPreviewPages),
|
||||
effectiveMargin: getComputedStyle(document.documentElement).getPropertyValue('--cv-page-margin'), layoutDurationMs: Number(data.cvPreviewDurationMs), tokens,
|
||||
text: document.querySelector('.page')!.textContent, bodyFont: getComputedStyle(document.body).fontFamily,
|
||||
headingFont: heading.fontFamily, headingColor: heading.color, headingSize: heading.fontSize,
|
||||
headerBackground: header.backgroundColor, headerAlign: header.textAlign, headerBorder: header.borderBottomWidth,
|
||||
headerGridColumn: header.gridColumn, headerBefore: getComputedStyle(document.querySelector('header')!, '::before').content,
|
||||
pageDisplay: getComputedStyle(document.querySelector('.page')!).display, contactFont: contact.fontFamily };
|
||||
});
|
||||
expect(measured.tokens.map(token => token.token).sort(), `${fixture} complete preview markers`).toEqual([...expectedMarkers].sort());
|
||||
for (const paragraph of paragraphs) expect(measured.text, `${fixture} full body paragraph`).toContain(paragraph);
|
||||
expect(measured.text).toContain(documentSettings.recipient);
|
||||
if (theme.id === 'modern') expect(measured.headerBackground).not.toBe('rgba(0, 0, 0, 0)');
|
||||
if (theme.id === 'minimal') expect(measured.headerBorder).toBe('0px');
|
||||
if (theme.id === 'executive') expect(measured.headerAlign).toBe('center');
|
||||
if (theme.id === 'elegant') expect(measured.headerBefore).toContain(nb ? 'Søknadsbrev' : 'Cover letter');
|
||||
if (theme.id === 'code') expect(measured.contactFont).toContain('Roboto Mono');
|
||||
if (['technical', 'nordic', 'creative'].includes(theme.id)) expect(measured.pageDisplay).toBe('grid');
|
||||
const exported = await page.request.post(`${letterUrl}/export-pdf?expectedVersion=${saved.revision}`, { headers });
|
||||
expect(exported.status(), fixture).toBe(200);
|
||||
const bytes = await exported.body();
|
||||
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
|
||||
await fs.writeFile(path.join(directory, 'export.pdf'), bytes);
|
||||
for (let index = 0; index < measured.pages; index++) {
|
||||
await page.setContent(previewDocumentHtml(rendered.html, measured.height, index));
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
await page.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) });
|
||||
}
|
||||
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
|
||||
fixtures.push({ fixture, settings: saved.documentSettings, expectedMarkers, expectedParagraphs: paragraphs,
|
||||
expectedRecipient: documentSettings.recipient, expectedSender: documentSettings.senderName, expectedSubject: documentSettings.subject,
|
||||
previewPages: measured.pages, measured, savedRevision: saved.revision, htmlSha256: sha256(rendered.html), pdfSha256: sha256(bytes) });
|
||||
await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), fixtures }, null, 2));
|
||||
}
|
||||
}
|
||||
expect(fixtures).toHaveLength(18);
|
||||
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore);
|
||||
await info.attach('matching-letter-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' });
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { emptyStructuredCv } from '../src/profileCv';
|
||||
|
||||
// Synthetic comparison corpus: each theme receives identical facts and marker membership.
|
||||
export const templateFixtures = [
|
||||
{ key: 'short-en', language: 'en', jobs: 1, bullets: 2, skills: 5 },
|
||||
{ key: 'medium-nb', language: 'nb', jobs: 4, bullets: 3, skills: 12 },
|
||||
{ key: 'long-en', language: 'en', jobs: 10, bullets: 4, skills: 25 },
|
||||
{ key: 'dense-nb', language: 'nb', jobs: 12, bullets: 5, skills: 60 },
|
||||
] as const;
|
||||
|
||||
export function templateProfile(sample: typeof templateFixtures[number]) {
|
||||
const nb = sample.language === 'nb';
|
||||
const profile = {
|
||||
...emptyStructuredCv(),
|
||||
contact: { ...emptyStructuredCv().contact,
|
||||
fullName: 'S01 Åse Ødegård-Sørensen LangtEtternavn',
|
||||
headline: nb ? 'S02 Senior plattformingeniør og fagansvarlig' : 'S02 Senior platform engineer and technical lead',
|
||||
email: 'ase.odegard-sorensen.long-account-identifier@example.test', location: nb ? 'Tromsø, Norge' : 'Tromsø, Norway' },
|
||||
summary: [nb ? 'S03 Bygger pålitelige tjenester med tydelig dokumentasjon, tilgjengelighet og målbare resultater.' : 'S03 Builds reliable services with clear documentation, accessibility and measurable results.'],
|
||||
jobs: Array.from({ length: sample.jobs }, (_, index) => ({
|
||||
id: `comparison-job-${index + 1}`,
|
||||
title: `E${String(index + 1).padStart(2, '0')} ${nb ? 'Senior utvikler og teknisk rådgiver' : 'Senior software engineer and technical adviser'}`,
|
||||
company: `${nb ? 'Eksempel Teknologi' : 'Example Technology'} ${index + 1}`,
|
||||
startDate: `${2025 - index}-01`, endDate: index ? `${2026 - index}-01` : undefined, isCurrent: index === 0,
|
||||
bullets: Array.from({ length: sample.bullets }, (_, detail) => {
|
||||
const marker = `B${String(index * sample.bullets + detail + 1).padStart(2, '0')}`;
|
||||
return nb ? `${marker} Forbedret stabile arbeidsflyter gjennom samarbeid, grundig testing og tilgjengelige grensesnitt for norske brukere. Dokumenterte ansvar, beslutninger og målbare resultater uten å miste historiske opplysninger.`
|
||||
: `${marker} Improved reliable workflows through collaboration, thorough testing and accessible interfaces. Documented ownership, engineering decisions and measurable outcomes while preserving historical information.`;
|
||||
}),
|
||||
})),
|
||||
skills: Array.from({ length: sample.skills }, (_, index) => `P${String(index + 1).padStart(2, '0')} ${['C# og .NET', 'React og TypeScript', 'Tilgjengelighet', 'Distribuerte systemer', 'PostgreSQL', 'Docker'][index % 6]}`),
|
||||
// Short intentionally omits education; all fixtures omit optional phone/website/projects.
|
||||
education: sample.key === 'short-en' ? [] : [{ id: 'comparison-education', qualification: 'R01 Master i informatikk', institution: 'Universitetet i Tromsø', startDate: '2014', endDate: '2019' }],
|
||||
languages: [{ name: 'R02 Norsk bokmål', level: 'Morsmål' }, { name: 'R03 English', level: 'Fluent' }],
|
||||
interests: ['R04 Friluftsliv og åpen kildekode'],
|
||||
};
|
||||
const markers = JSON.stringify(profile).match(/\b[SEBPR]\d{2}\b/g)!;
|
||||
if (new Set(markers).size !== markers.length) throw new Error(`Duplicate fixture markers: ${sample.key}`);
|
||||
return { profile, markers };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { templateFixtures, templateProfile } from './pre-p10-template-fixtures';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const baseline = path.resolve('../.artifacts/pre-phase10-ux/templates-legacy');
|
||||
const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-revised-01');
|
||||
const hash = (text: string) => createHash('sha256').update(text).digest('hex');
|
||||
// The existing avatar endpoint now normalizes image pixels with Skia. Retain font data
|
||||
// verbatim; exclude only embedded image payload bytes when comparing document markup.
|
||||
const normalizeAvatar = (html: string) => html.replace(/data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/=]+/g, 'data:image/normalized-avatar');
|
||||
|
||||
test('[PRE-P10 B] existing design-one markup, CSS and source markers survive design-two additions', async ({ request }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
expect((await request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200);
|
||||
const me = await (await request.get(`${api}/auth/me`)).json();
|
||||
const headers = { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
|
||||
const avatar = await fs.readFile(path.join(baseline, 'synthetic-nonhuman-color-sample.png'));
|
||||
expect((await request.post(`${api}/auth/avatar`, { headers, multipart: { file: { name: 'synthetic-nonhuman.png', mimeType: 'image/png', buffer: avatar } } })).status()).toBe(200);
|
||||
const themes = await (await request.get(`${api}/cv/themes`)).json();
|
||||
const results = [];
|
||||
await fs.mkdir(path.join(output, 'legacy-current'), { recursive: true });
|
||||
for (const sample of templateFixtures) {
|
||||
const { profile } = templateProfile(sample);
|
||||
const source = await (await request.get(`${api}/career/profile`)).json();
|
||||
expect((await request.put(`${api}/career/profile`, { headers, data: { expectedVersion: source.revision, profile } })).status()).toBe(200);
|
||||
for (const theme of themes) {
|
||||
const fixture = `${sample.key}-${theme.id}`;
|
||||
const created = await request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: { themeId: theme.id, language: sample.language, showPhoto: true, showIcons: true } } });
|
||||
expect(created.status(), fixture).toBe(200);
|
||||
const variant = await created.json();
|
||||
const response = await request.get(`${api}/cv/variants/${variant.id}/preview`);
|
||||
expect(response.status(), fixture).toBe(200);
|
||||
const current = (await response.json()).html as string;
|
||||
const original = await fs.readFile(path.join(baseline, fixture, 'render.html'), 'utf8');
|
||||
const style = (html: string) => html.match(/<style>([\s\S]*?)<\/style>/)![1];
|
||||
const body = (html: string) => html.match(/<body>([\s\S]*?)<\/body>/)![1];
|
||||
const originalMarkers = body(original).match(/\b[SEBPR]\d{2}\b/g);
|
||||
const currentMarkers = body(current).match(/\b[SEBPR]\d{2}\b/g);
|
||||
const measured = { fixture, rawOriginalHash: hash(original), rawCurrentHash: hash(current),
|
||||
normalizedOriginalHash: hash(normalizeAvatar(original)), normalizedCurrentHash: hash(normalizeAvatar(current)),
|
||||
originalCssHash: hash(style(original)), currentCssHash: hash(style(current)),
|
||||
originalBodyHash: hash(normalizeAvatar(body(original))), currentBodyHash: hash(normalizeAvatar(body(current))),
|
||||
markersEqual: JSON.stringify(currentMarkers) === JSON.stringify(originalMarkers) };
|
||||
results.push(measured);
|
||||
await fs.writeFile(path.join(output, 'legacy-current', `${fixture}.html`), current);
|
||||
await fs.writeFile(path.join(output, 'legacy-preservation.json'), JSON.stringify({
|
||||
normalization: 'Only data:image/{png,jpeg,webp};base64 payload bytes excluded because the upload pipeline now reencodes the synthetic avatar with Skia. Font resources, CSS and all other HTML bytes remain exact.', fixtures: results }, null, 2));
|
||||
expect.soft(measured.normalizedCurrentHash, `${fixture} normalized full HTML`).toBe(measured.normalizedOriginalHash);
|
||||
expect.soft(measured.currentCssHash, `${fixture} exact CSS including embedded licensed fonts`).toBe(measured.originalCssHash);
|
||||
expect.soft(measured.currentBodyHash, `${fixture} body excluding avatar bytes`).toBe(measured.originalBodyHash);
|
||||
expect.soft(measured.markersEqual, `${fixture} marker order`).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(results).toHaveLength(36);
|
||||
await info.attach('legacy-preservation', { path: path.join(output, 'legacy-preservation.json'), contentType: 'application/json' });
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { previewDocumentHtml } from '../src/cvPreview';
|
||||
import { templateFixtures, templateProfile } from './pre-p10-template-fixtures';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const output = path.resolve(process.env.CV_TEMPLATE_OUTPUT || '../.artifacts/pre-phase10-ux/templates-legacy');
|
||||
const designVersion = process.env.PRE_P10_DESIGN_VERSION ? Number(process.env.PRE_P10_DESIGN_VERSION) : undefined;
|
||||
const hash = (value: string | Buffer) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
test('[PRE-P10 B] equivalent synthetic histories retain preview and native PDF evidence across all nine themes', async ({ page, context, browser }, info) => {
|
||||
test.setTimeout(600_000);
|
||||
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true');
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
||||
});
|
||||
expect((await page.request.post(`${api}/auth/login`, { data: { email: 'e2e@example.test', password: 'E2ePassword123!' } })).status()).toBe(200);
|
||||
const me = await (await page.request.get(`${api}/auth/me`)).json();
|
||||
const headers = { 'X-CSRF-TOKEN': (await page.request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string };
|
||||
const themes = await (await page.request.get(`${api}/cv/themes`)).json();
|
||||
expect(themes.map((theme: { id: string }) => theme.id).sort()).toEqual(['ats-classic', 'code', 'creative', 'elegant', 'executive', 'minimal', 'modern', 'nordic', 'technical']);
|
||||
await fs.mkdir(output, { recursive: true });
|
||||
await page.setContent('<canvas width="512" height="512"></canvas>');
|
||||
const image = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('canvas')!;
|
||||
const drawing = canvas.getContext('2d')!;
|
||||
drawing.fillStyle = '#cbd5e1'; drawing.fillRect(0, 0, 512, 512);
|
||||
drawing.fillStyle = '#0f766e'; drawing.fillRect(32, 32, 448, 224);
|
||||
drawing.fillStyle = '#f59e0b'; drawing.fillRect(128, 288, 256, 160);
|
||||
return canvas.toDataURL('image/png').split(',')[1];
|
||||
});
|
||||
const avatar = Buffer.from(image, 'base64');
|
||||
await fs.writeFile(path.join(output, 'synthetic-nonhuman-color-sample.png'), avatar);
|
||||
expect((await page.request.post(`${api}/auth/avatar`, { headers, multipart: { file: { name: 'synthetic-nonhuman.png', mimeType: 'image/png', buffer: avatar } } })).status()).toBe(200);
|
||||
const results: unknown[] = [];
|
||||
for (const sample of templateFixtures) {
|
||||
const { profile, markers } = templateProfile(sample);
|
||||
const current = await (await page.request.get(`${api}/career/profile`)).json();
|
||||
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: current.revision, profile } })).status()).toBe(200);
|
||||
const source = await (await page.request.get(`${api}/career/profile`)).json();
|
||||
for (const theme of themes) {
|
||||
const fixture = `${sample.key}-${theme.id}`;
|
||||
const directory = path.join(output, fixture);
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
const started = performance.now();
|
||||
const created = await page.request.post(`${api}/cv/variants`, { headers, data: { name: fixture, settings: {
|
||||
themeId: theme.id, language: sample.language, showPhoto: true, showIcons: true,
|
||||
...(designVersion === undefined ? {} : { designVersion }),
|
||||
} } });
|
||||
expect(created.status(), fixture).toBe(200);
|
||||
const variant = await created.json();
|
||||
const renderedResponse = await page.request.get(`${api}/cv/variants/${variant.id}/preview`);
|
||||
expect(renderedResponse.status(), fixture).toBe(200);
|
||||
const rendered = await renderedResponse.json();
|
||||
await fs.writeFile(path.join(directory, 'render.html'), rendered.html);
|
||||
await page.setViewportSize({ width: 794, height: 1123 });
|
||||
await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4));
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady), { message: fixture }).toBe('true');
|
||||
const measured = await page.evaluate(() => {
|
||||
const data = document.documentElement.dataset;
|
||||
const width = Number(data.cvPreviewWidth), height = Number(data.cvPreviewHeight);
|
||||
const tokens: { token: string; x: number; y: number; page: number; fontSize: string; color: string }[] = [];
|
||||
const nameLines: { text: string; page: number; x: number; y: number; width: number; height: number }[] = [];
|
||||
const walker = document.createTreeWalker(document.querySelector('.page')!, NodeFilter.SHOW_TEXT);
|
||||
let node;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (['SCRIPT', 'STYLE'].includes(node.parentElement?.tagName || '')) continue;
|
||||
for (const match of (node.textContent || '').matchAll(/\b[SEBPR]\d{2}\b/g)) {
|
||||
const range = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
|
||||
const rect = range.getClientRects()[0], style = getComputedStyle(node.parentElement!);
|
||||
const index = Math.floor(rect.x / width);
|
||||
tokens.push({ token: match[0], x: rect.x - index * width, y: rect.y + index * height, page: index + 1, fontSize: style.fontSize, color: style.color });
|
||||
}
|
||||
if (node.parentElement?.closest('.name')) {
|
||||
for (const match of (node.textContent || '').matchAll(/\S+/g)) {
|
||||
const range = document.createRange(); range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
|
||||
for (const rect of range.getClientRects()) nameLines.push({ text: match[0], page: Math.floor(rect.x / width) + 1, x: rect.x % width, y: rect.y, width: rect.width, height: rect.height });
|
||||
}
|
||||
}
|
||||
}
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const sidebarStyle = sidebar ? getComputedStyle(sidebar) : null;
|
||||
return { width, contentHeight: Number(data.cvPreviewPages) * height, usableHeight: Number(data.cvPreviewUsableHeight), layoutDurationMs: Number(data.cvPreviewDurationMs),
|
||||
effectiveMargin: getComputedStyle(document.documentElement).getPropertyValue('--cv-page-margin'), tokens, nameLines,
|
||||
nameLineCount: new Set(nameLines.map(rect => `${rect.page}:${rect.y.toFixed(1)}`)).size,
|
||||
splitNameWords: nameLines.filter((rect, index) => nameLines.findIndex(item => item.text === rect.text) !== index).map(rect => rect.text),
|
||||
sidebar: sidebarStyle ? { background: sidebarStyle.backgroundColor, color: sidebarStyle.color, padding: sidebarStyle.padding, width: sidebar?.getBoundingClientRect().width } : null,
|
||||
photo: !!document.querySelector('.photo img'), previewPages: Number(data.cvPreviewPages), overflow: data.cvPreviewOverflow, userAgent: navigator.userAgent };
|
||||
});
|
||||
expect(measured.tokens.map(token => token.token).sort(), `${fixture} preview marker completeness`).toEqual([...markers].sort());
|
||||
expect(measured.previewPages, fixture).toBeGreaterThan(0);
|
||||
if (designVersion === 2) expect(measured.splitNameWords.filter(word => !word.includes('-')), `${fixture} unhyphenated name words`).toEqual([]);
|
||||
const exported = await page.request.post(`${api}/cv/variants/${variant.id}/export-pdf?expectedVersion=${variant.version}`, { headers });
|
||||
expect(exported.status(), fixture).toBe(200);
|
||||
const bytes = await exported.body();
|
||||
expect(bytes.subarray(0, 5).toString()).toBe('%PDF-');
|
||||
await fs.writeFile(path.join(directory, 'export.pdf'), bytes);
|
||||
for (let index = 0; index < measured.previewPages; index++) {
|
||||
await page.setContent(previewDocumentHtml(rendered.html, 297 * 96 / 25.4, index));
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe('true');
|
||||
await page.screenshot({ path: path.join(directory, `preview-${index + 1}.png`) });
|
||||
}
|
||||
expect((await (await page.request.get(`${api}/cv/variants/${variant.id}`)).json()).version).toBe(variant.version);
|
||||
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(source);
|
||||
results.push({ fixture, settings: variant.settings, expectedMarkers: markers, profileSha256: hash(JSON.stringify(profile)), htmlSha256: hash(rendered.html), pdfSha256: hash(bytes),
|
||||
durationMs: performance.now() - started, previewPages: measured.previewPages, measured });
|
||||
await fs.writeFile(path.join(output, 'measurements.json'), JSON.stringify({ browserVersion: browser.version(), designVersion: designVersion ?? 'legacy-absent', syntheticPhoto: 'Colored rectangles only; no human portrait', fixtures: results }, null, 2));
|
||||
}
|
||||
}
|
||||
expect(results).toHaveLength(36);
|
||||
await info.attach('template-library-measurements', { path: path.join(output, 'measurements.json'), contentType: 'application/json' });
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import { emptyStructuredCv } from '../src/profileCv';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
const themeNames = {
|
||||
modern: 'cvTemplateModernName', minimal: 'cvTemplateMinimalName', executive: 'cvTemplateExecutiveName',
|
||||
technical: 'cvTemplateTechnicalName', 'ats-classic': 'cvTemplateAtsClassicName', nordic: 'cvTemplateNordicName',
|
||||
elegant: 'cvTemplateElegantName', creative: 'cvTemplateCreativeName', code: 'cvTemplateCodeName',
|
||||
} as const;
|
||||
|
||||
async function login(request: APIRequestContext, email: string) {
|
||||
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await request.get(`${api}/auth/me`)).json();
|
||||
return { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
|
||||
'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
|
||||
async function owner(page: Page, admin: APIRequestContext, language: 'en' | 'nb') {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `template-selection-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: ['Premium'] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
const initial = await (await page.request.get(`${api}/career/profile`)).json();
|
||||
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact, fullName: 'Owner Canonical Marker', headline: 'Canonical headline marker', email: 'synthetic@example.test' },
|
||||
summary: ['Canonical summary marker'], skills: ['TypeScript', 'Documented evidence'] };
|
||||
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: initial.revision, profile } })).status()).toBe(200);
|
||||
expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(language => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', 'light'); }, language);
|
||||
await page.setViewportSize({ width: language === 'nb' ? 390 : 1440, height: 1000 });
|
||||
const source = await (await page.request.get(`${api}/career/profile`)).json();
|
||||
return { headers, source };
|
||||
}
|
||||
|
||||
async function createCv(page: Page, headers: Record<string, string>, showPhoto = false) {
|
||||
const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Template selection CV', settings: {
|
||||
themeId: 'modern', sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'summary' }], headline: 'Authored CV headline marker', showPhoto,
|
||||
} } });
|
||||
expect(response.status()).toBe(200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol) ? route.continue() : route.abort();
|
||||
});
|
||||
});
|
||||
|
||||
for (const language of ['en', 'nb'] as const) {
|
||||
test(`[PRE-P10 B] ${language} actual nine-theme gallery and candidate preview remain read-only until Apply`, async ({ page, playwright }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const admin = await playwright.request.newContext();
|
||||
let fixture: Awaited<ReturnType<typeof owner>>;
|
||||
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
|
||||
const cv = await createCv(page, fixture.headers);
|
||||
const cvUrl = `${api}/cv/variants/${cv.id}`;
|
||||
const read = async () => (await page.request.get(cvUrl)).json();
|
||||
const requests: { path: string; status: number }[] = [];
|
||||
let writes = 0;
|
||||
page.on('response', response => { if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(response.url()).pathname)) requests.push({ path: new URL(response.url()).pathname, status: response.status() }); });
|
||||
page.on('request', request => { if (request.url() === cvUrl && request.method() === 'PUT') writes++; });
|
||||
await page.goto(`/career/builder/${cv.id}`);
|
||||
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
|
||||
await page.getByRole('button', { name: t.cvEditorAllTemplates, exact: true }).click();
|
||||
for (const [id, key] of Object.entries(themeNames)) {
|
||||
const card = page.getByRole('button', { name: t[key], exact: true });
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await expect(card).toHaveAttribute('aria-disabled', 'false');
|
||||
const frame = card.frameLocator('iframe');
|
||||
await expect(frame.locator('.name')).toHaveText('Alex Morgan');
|
||||
await expect(frame.locator('body')).not.toContainText('Owner Canonical Marker');
|
||||
await expect.poll(() => requests.some(request => request.path === `/api/cv/themes/${id}/preview` && request.status === 200)).toBe(true);
|
||||
}
|
||||
const candidate = page.getByRole('button', { name: t.cvTemplateTechnicalName, exact: true });
|
||||
await candidate.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan');
|
||||
await expect(dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true })).toBeEnabled();
|
||||
expect(await read()).toEqual(cv);
|
||||
expect(writes).toBe(0);
|
||||
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
|
||||
expect(await read()).toEqual(cv);
|
||||
await candidate.click();
|
||||
await expect(dialog.frameLocator('iframe').locator('.name')).toHaveText('Alex Morgan');
|
||||
const applied = page.waitForResponse(response => response.url() === cvUrl && response.request().method() === 'PUT');
|
||||
await dialog.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click();
|
||||
expect((await applied).status()).toBe(200);
|
||||
await expect.poll(async () => (await read()).settings.themeId).toBe('technical');
|
||||
const saved = await read();
|
||||
expect(saved.settings).toMatchObject({ designVersion: 2, themeId: 'technical', sectionsExplicit: true, includeProfileContact: true,
|
||||
sections: [{ key: 'summary' }], headline: 'Authored CV headline marker' });
|
||||
expect(saved.version).toBe(cv.version + 1);
|
||||
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
|
||||
const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`);
|
||||
await expect(live.locator('.name')).toHaveText('Owner Canonical Marker');
|
||||
await expect(live.locator('body')).toContainText('Authored CV headline marker');
|
||||
await expect.poll(() => live.locator('body').evaluate(node => getComputedStyle(node, '::before').backgroundColor)).toBe('rgb(15, 76, 92)');
|
||||
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await info.attach('actual-template-selection', { body: JSON.stringify({ language, samples: requests, savedSettings: saved.settings, writes }), contentType: 'application/json' });
|
||||
await page.screenshot({ path: info.outputPath('template-selection.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test(`[PRE-P10 B] ${language} actual letter template Apply previews current authored text and preserves saved source`, async ({ page, playwright }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const admin = await playwright.request.newContext();
|
||||
let fixture: Awaited<ReturnType<typeof owner>>;
|
||||
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
|
||||
const companyResponse = await page.request.post(`${api}/companies`, { headers: fixture.headers, data: { name: 'Letter Selection Synthetic Company', source: 'User' } });
|
||||
expect(companyResponse.status()).toBe(201);
|
||||
const company = await companyResponse.json();
|
||||
const jobResponse = await page.request.post(`${api}/jobapplications`, { headers: fixture.headers, data: { companyId: company.id, jobTitle: 'Letter template fixture', status: 'Saved', description: 'Synthetic controlled fixture' } });
|
||||
expect(jobResponse.status()).toBe(201);
|
||||
const job = await jobResponse.json();
|
||||
const letterUrl = `${api}/jobapplications/${job.id}/cover-letter`;
|
||||
const original = await (await page.request.get(letterUrl)).json();
|
||||
const documentSettings = { schemaVersion: 1, senderName: 'Letter sender marker', senderTitle: 'Reviewed title', senderEmail: 'letter@example.test', senderPhone: '+47 12345678', senderLocation: 'Oslo',
|
||||
recipient: 'Preserved recipient marker', subject: 'Preserved subject marker', date: '2026-10-27', themeId: 'modern', accentColor: '#2563eb', bodyFont: 'Arial, Helvetica, sans-serif',
|
||||
baseFontSizePt: 10, lineHeight: 1.42, pageMarginMm: 16, pageSize: 'letter', language: language === 'nb' ? 'nb-NO' : 'en' };
|
||||
expect((await page.request.put(letterUrl, { headers: fixture.headers, data: { text: 'Saved letter body marker', source: 'manual', expectedVersion: original.revision, documentSettings } })).status()).toBe(200);
|
||||
const saved = await (await page.request.get(letterUrl)).json();
|
||||
let writes = 0;
|
||||
const cvSamples: string[] = [];
|
||||
page.on('request', request => {
|
||||
if (request.method() === 'PUT' && request.url() === letterUrl) writes++;
|
||||
if (/\/cv\/themes\/[^/]+\/preview$/.test(new URL(request.url()).pathname)) cvSamples.push(request.url());
|
||||
});
|
||||
await page.goto(`/jobs/${job.id}?section=application&sub=letter#materials`);
|
||||
const body = page.getByRole('textbox', { name: t.jobDetailsCoverLetter, exact: true });
|
||||
await expect(body).toHaveValue('Saved letter body marker');
|
||||
const draft = 'Current authored letter marker — Åse Østby';
|
||||
await body.fill(draft);
|
||||
await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled();
|
||||
await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click();
|
||||
await expect(page.getByRole('option')).toHaveCount(9);
|
||||
await page.getByRole('option', { name: 'Nordic', exact: true }).click();
|
||||
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
|
||||
await expect(body).toHaveValue(draft);
|
||||
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
|
||||
await page.getByRole('button', { name: t.letterChooseTemplate, exact: true }).click();
|
||||
await expect(dialog.getByRole('button', { name: t.letterTemplateApply, exact: true })).toBeEnabled();
|
||||
await dialog.getByRole('combobox', { name: new RegExp(`^${t.letterTemplate}(?: |$)`) }).click();
|
||||
await page.getByRole('option', { name: 'Nordic', exact: true }).click();
|
||||
const previewResponse = page.waitForResponse(response => response.url() === `${letterUrl}/preview` && response.request().method() === 'POST'
|
||||
&& response.request().postDataJSON()?.documentSettings?.themeId === 'nordic');
|
||||
await dialog.getByRole('button', { name: t.letterTemplateApply, exact: true }).click();
|
||||
await expect(body).toHaveValue(draft);
|
||||
if (language === 'nb') await page.getByRole('tab', { name: t.assetsLetterPreview, exact: true }).click();
|
||||
const preview = await previewResponse;
|
||||
expect(preview.status()).toBe(200);
|
||||
const submitted = preview.request().postDataJSON();
|
||||
expect(submitted.text).toBe(draft);
|
||||
expect(submitted.documentSettings).toMatchObject({ designVersion: 2, themeId: 'nordic', pageSize: 'letter', language: documentSettings.language,
|
||||
senderName: documentSettings.senderName, recipient: documentSettings.recipient, date: documentSettings.date, subject: documentSettings.subject });
|
||||
const frame = page.frameLocator(`iframe[title="${t.letterDocumentPreview}"]`);
|
||||
await expect(frame.locator('.letter-theme-nordic')).toBeVisible();
|
||||
await expect(frame.locator('body')).toContainText(draft);
|
||||
await expect(frame.locator('body')).toContainText(documentSettings.recipient);
|
||||
expect(await (await page.request.get(letterUrl)).json()).toEqual(saved);
|
||||
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
|
||||
expect(writes).toBe(0);
|
||||
expect(cvSamples).toEqual([]);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await info.attach('actual-letter-template-preview', { body: JSON.stringify({ language, submitted, unchangedSavedRevision: saved.revision, writes, cvSamples }), contentType: 'application/json' });
|
||||
await page.screenshot({ path: info.outputPath('letter-template-selection.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test(`[PRE-P10 B] ${language} keyboard crop uses existing avatar storage and CV photo remains optional`, async ({ page, playwright }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const admin = await playwright.request.newContext();
|
||||
let fixture: Awaited<ReturnType<typeof owner>>;
|
||||
try { fixture = await owner(page, admin, language); } finally { await admin.dispose(); }
|
||||
const cv = await createCv(page, fixture.headers, true);
|
||||
await page.goto(`/career/builder/${cv.id}`);
|
||||
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
|
||||
await page.getByRole('link', { name: t.cvEditorManagePhoto, exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/career\?section=profile$/);
|
||||
await expect(page.getByRole('button', { name: t.profileChangeImage, exact: true })).toBeEnabled();
|
||||
const png = await page.evaluate(() => {
|
||||
const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 384;
|
||||
const draw = canvas.getContext('2d')!;
|
||||
draw.fillStyle = '#0f766e'; draw.fillRect(0, 0, 256, 384);
|
||||
draw.fillStyle = '#f59e0b'; draw.fillRect(256, 0, 256, 384);
|
||||
return canvas.toDataURL('image/png').split(',')[1];
|
||||
});
|
||||
await page.locator('input[type=file]').nth(1).setInputFiles({ name: 'synthetic-nonhuman-color-blocks.png', mimeType: 'image/png', buffer: Buffer.from(png, 'base64') });
|
||||
const crop = page.getByRole('dialog');
|
||||
await expect(crop.getByAltText(t.cropDialogPreviewAlt, { exact: true })).toBeVisible();
|
||||
const zoom = crop.getByRole('slider', { name: t.cropDialogZoom, exact: true });
|
||||
await zoom.focus(); await zoom.press('End');
|
||||
await expect(zoom).toHaveAttribute('aria-valuenow', '3');
|
||||
const horizontal = crop.getByRole('slider', { name: t.cropDialogHorizontal, exact: true });
|
||||
const vertical = crop.getByRole('slider', { name: t.cropDialogVertical, exact: true });
|
||||
for (const slider of [horizontal, vertical]) {
|
||||
await expect(slider).toBeEnabled();
|
||||
await slider.focus(); await slider.press('Home');
|
||||
await expect.poll(async () => Number(await slider.getAttribute('aria-valuenow'))).toBeLessThan(0);
|
||||
await slider.press('End');
|
||||
await expect(slider).toHaveAttribute('aria-valuenow', '0');
|
||||
}
|
||||
const uploadResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/avatar' && response.request().method() === 'POST');
|
||||
await crop.getByRole('button', { name: t.cropDialogSave, exact: true }).press('Enter');
|
||||
expect((await uploadResponse).status()).toBe(200);
|
||||
await expect(crop).not.toBeVisible();
|
||||
const avatar = (await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl;
|
||||
expect(avatar).toBeTruthy();
|
||||
await page.goto(`/career/builder/${cv.id}`);
|
||||
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
|
||||
const live = page.frameLocator(`iframe[title="${t.cvEditorPreviewPage.replace('{page}', '1')}"]`);
|
||||
await expect(live.locator('.photo img')).toBeVisible();
|
||||
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorEdit, exact: true }).click();
|
||||
await page.getByRole('tab', { name: t.cvEditorCustomize, exact: true }).click();
|
||||
await page.getByRole('switch', { name: t.cvEditorShowPhoto, exact: true }).uncheck();
|
||||
await expect.poll(async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.showPhoto).toBe(false);
|
||||
if (language === 'nb') await page.getByRole('button', { name: t.cvEditorPreview, exact: true }).click();
|
||||
await expect(live.locator('.photo img')).toHaveCount(0);
|
||||
expect((await (await page.request.get(`${api}/auth/me`)).json()).avatarImageDataUrl).toBe(avatar);
|
||||
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(fixture.source);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await page.screenshot({ path: info.outputPath('optional-photo-after-keyboard-crop.png'), fullPage: true });
|
||||
await info.attach('keyboard-crop', { body: JSON.stringify({ language, syntheticNonhumanFixture: true, keyboardZoom: 3, keyboardAxes: ['horizontal', 'vertical'], optionalCvPhoto: false, avatarRetained: true }), contentType: 'application/json' });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user