Files
houseplan-card/demo/smoke_pdf_export.mjs
2026-09-07 21:05:57 +03:00

327 lines
15 KiB
JavaScript

// #53: the administrator PDF surface is lazy, modal and downloads one
// parseable A4 sheet without involving the editor runtime.
import { readFileSync } from 'node:fs';
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
import { launchColdView, checkAll, finish } from './serve.mjs';
import { fixtureWallKey } from './fixtures/wall-key.mjs';
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
const runtimePath = manifest.lazyPdfFiles?.find((path) => /pdf-export-[^/]+\.js$/.test(path));
if (!runtimePath) throw new Error('PDF export chunk is absent from the bundle manifest');
const runtimeName = runtimePath.split('/').at(-1);
const runtimePattern = `**/${runtimeName}*`;
const NARROW_VIEWPORT = { width: 320, height: 760 };
const steppedPoly = [
[0.1, 0.1], [0.7, 0.1], [0.7, 0.3], [0.8, 0.3],
[0.8, 0.6], [0.7, 0.6], [0.7, 0.9], [0.1, 0.9],
];
const steppedSegments = steppedPoly.map((a, index) => ({
id: `pdf-step-wall-${index}`, a, b: steppedPoly[(index + 1) % steppedPoly.length], cm: 15,
}));
const steppedSpace = {
id: 'pdf-stepped-exterior', title: 'Stepped exterior', cell_cm: 5, view_box: [0, 0, 2, 2],
rooms: [{ id: 'pdf-step-room', name: '', area: null, poly: steppedPoly,
wall_ids: steppedSegments.map((wall) => wall.id) }],
walls: steppedSegments.map((wall) => ({
key: fixtureWallKey(wall.a, wall.b), a: wall.a, b: wall.b, cm: wall.cm,
})),
wall_segments: steppedSegments,
openings: [], partitions: [], wall_columns: [], decor: [], settings: {},
};
const clickPrinter = (page) => page.locator('houseplan-card').evaluate((card) => {
const root = card.shadowRoot || card.renderRoot;
[...root.querySelectorAll('button')]
.find((button) => button.getAttribute('aria-label') === card._t('title.export_pdf'))?.click();
});
const { page, browser } = await launchColdView(NARROW_VIEWPORT);
// The generic demo fixture deliberately has a fixed 780 px host so desktop
// geometry smokes do not inherit the viewport width. This smoke owns the phone
// contract, therefore make only its harness host behave like a real dashboard
// column before attributing document overflow to the PDF dialog.
await page.locator('#host').evaluate((host) => {
host.style.width = '100%';
host.style.maxWidth = '100%';
host.style.boxSizing = 'border-box';
host.style.margin = '0';
});
const requests = [];
page.on('request', (request) => requests.push(new URL(request.url()).pathname));
const initialResources = await page.evaluate(() => performance.getEntriesByType('resource')
.map((entry) => new URL(entry.name).pathname));
await clickPrinter(page);
await page.waitForFunction(() => window.__card.renderRoot.querySelector('hp-pdf-dialog'));
const dialog = await page.evaluate(async () => {
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const card = window.__card;
const pdf = card.renderRoot.querySelector('hp-pdf-dialog');
const root = pdf.shadowRoot || pdf.renderRoot;
const shell = root.querySelector('hp-dialog');
await shell?.updateComplete;
const native = shell?.shadowRoot?.querySelector('dialog');
const parent = pdf.parentNode;
const surface = shell?.shadowRoot?.querySelector('.surface');
const centred = () => {
const rect = surface?.getBoundingClientRect();
return !!rect
&& Math.abs((rect.left + rect.right) / 2 - innerWidth / 2) <= 1
&& Math.abs((rect.top + rect.bottom) / 2 - innerHeight / 2) <= 1;
};
const shellBeforeReconnect = native || shell?.shadowRoot?.querySelector('ha-dialog');
pdf.remove();
parent?.append(pdf);
await pdf.updateComplete;
await shell?.updateComplete;
await frame();
await frame();
const shellAfterReconnect = native || shell?.shadowRoot?.querySelector('ha-dialog');
const reconnectKeepsModalCentered = shellBeforeReconnect === shellAfterReconnect
&& (native ? native.open && native.matches(':modal') : !!shellAfterReconnect)
&& centred();
const inputs = [...root.querySelectorAll('input[type="checkbox"]')];
const defaults = {
dimensions: inputs[0]?.checked,
decor: inputs[1]?.checked,
names: inputs[2]?.checked,
backdrop: inputs[3]?.checked,
};
inputs[2]?.click(); // remember Names = false after Save
inputs[3]?.click(); // the no-asset path keeps this smoke self-contained
await pdf.updateComplete;
return {
count: inputs.length,
expectedCount: card._spaceModel().bg ? 4 : 3,
defaults,
modal: native ? native.open && native.matches(':modal') : true,
reconnectKeepsModalCentered,
editorStillAbsent: !card._editorRuntime,
};
});
const downloadPromise = page.waitForEvent('download');
await page.locator('houseplan-card').evaluate(() => {
const pdf = window.__card.renderRoot.querySelector('hp-pdf-dialog');
(pdf.shadowRoot || pdf.renderRoot).querySelector('button.primary')?.click();
});
const download = await downloadPromise;
const path = await download.path();
if (!path) throw new Error('PDF download has no temporary file');
const bytes = new Uint8Array(readFileSync(path));
const downloadedByteLength = bytes.byteLength;
const task = getDocument({ data: bytes, useWorkerFetch: false, isEvalSupported: false });
const document = await task.promise;
const pdfPage = await document.getPage(1);
const viewport = pdfPage.getViewport({ scale: 1 });
const text = (await pdfPage.getTextContent()).items.map((item) => item.str).join(' ');
const rasterLimitError = await page.evaluate(() => {
try {
window.__card._pdfRuntime.assertRasterBudget([25 * 1024 * 1024, 1]);
return '';
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
});
const firstPagePdfRequests = requests.filter((requestPath) => requestPath.endsWith(`/${runtimeName}`)).length;
// #484: exercise the complete browser dialog/download/writer path with the
// stepped exterior that used to lose its right-hand chain. Unit scene tests
// protect placement internals; this smoke proves that the shipped lazy chunk
// still exports the six reconstructable axis-aligned dimensions.
await page.evaluate((rawSpace) => {
const card = window.__card;
card._pdfDialog = false;
card._serverCfg = { model_version: 10, spaces: [rawSpace], markers: [], settings: {} };
card._space = rawSpace.id;
card._layout = {};
card.requestUpdate();
}, steppedSpace);
await page.waitForFunction(() => window.__card?._spaceModel?.()?.id === 'pdf-stepped-exterior');
await clickPrinter(page);
await page.waitForFunction(() => window.__card.renderRoot.querySelector('hp-pdf-dialog'));
const steppedDownloadPromise = page.waitForEvent('download');
await page.locator('houseplan-card').evaluate(() => {
const pdf = window.__card.renderRoot.querySelector('hp-pdf-dialog');
(pdf.shadowRoot || pdf.renderRoot).querySelector('button.primary')?.click();
});
const steppedDownload = await steppedDownloadPromise;
const steppedPath = await steppedDownload.path();
if (!steppedPath) throw new Error('stepped PDF download has no temporary file');
const steppedTask = getDocument({
data: new Uint8Array(readFileSync(steppedPath)), useWorkerFetch: false, isEvalSupported: false,
});
const steppedDocument = await steppedTask.promise;
const steppedTextItems = (await (await steppedDocument.getPage(1)).getTextContent()).items
.map((item) => String(item.str || '').trim()).filter(Boolean);
const steppedDimensionLabels = steppedTextItems.filter((value) =>
/^\d+(?:[.,]\d+)?\s*(?:m|cm|ft|in)$/i.test(value));
const steppedExpectedLabels = ['1.20 m', '2.40 m', '3.60 m', '3.75 m', '7.35 m', '9.75 m'];
const steppedChainComplete = steppedDocument.numPages === 1
&& steppedExpectedLabels.every((value) => steppedDimensionLabels.includes(value));
await steppedTask.destroy();
// The contract is persistence across a real page load, not merely reopening
// the same element instance.
await page.reload();
await page.waitForFunction(() => window.__card?.renderRoot);
await page.locator('#host').evaluate((host) => {
host.style.width = '100%';
host.style.maxWidth = '100%';
host.style.boxSizing = 'border-box';
host.style.margin = '0';
});
await clickPrinter(page);
await page.waitForFunction(() => window.__card.renderRoot.querySelector('hp-pdf-dialog'));
const rememberedNames = await page.evaluate(() => {
const pdf = window.__card.renderRoot.querySelector('hp-pdf-dialog');
const inputs = (pdf.shadowRoot || pdf.renderRoot).querySelectorAll('input[type="checkbox"]');
return inputs[2]?.checked === false;
});
const narrowLocales = await page.evaluate(async () => {
const card = window.__card;
const expected = {
en: { cancel: 'Cancel', save: 'Save' },
ru: { cancel: 'Отмена', save: 'Сохранить' },
de: { cancel: 'Abbrechen', save: 'Speichern' },
fr: { cancel: 'Annuler', save: 'Enregistrer' },
};
const settle = async () => {
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const closeDialog = async () => {
card._pdfDialog = false;
card.requestUpdate();
await settle();
};
const loadLanguage = async (language) => {
card._config = { ...(card._config || {}), language };
card.requestUpdate();
for (let attempt = 0; attempt < 100
&& card._t('btn.cancel') !== expected[language].cancel; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
await settle();
}
};
const metrics = [];
for (const language of Object.keys(expected)) {
await closeDialog();
await loadLanguage(language);
card._pdfDialog = true;
card.requestUpdate();
await settle();
const pdf = card.renderRoot.querySelector('hp-pdf-dialog');
await pdf?.updateComplete;
const root = pdf?.shadowRoot || pdf?.renderRoot;
const shell = root?.querySelector('hp-dialog');
await shell?.updateComplete;
await settle();
const surface = shell?.shadowRoot?.querySelector('.surface');
const body = root?.querySelector('.body');
const footer = root?.querySelector('.row');
const buttons = [...(footer?.querySelectorAll('button') || [])];
const surfaceRect = surface?.getBoundingClientRect();
const footerRect = footer?.getBoundingClientRect();
const footerStyle = footer ? getComputedStyle(footer) : null;
const innerLeft = footerRect && footerStyle
? footerRect.left + parseFloat(footerStyle.paddingLeft) : 0;
const innerRight = footerRect && footerStyle
? footerRect.right - parseFloat(footerStyle.paddingRight) : 0;
const buttonRects = buttons.map((button) => button.getBoundingClientRect());
const firstBeforeSecond = buttons.length === 2
&& !!(buttons[0].compareDocumentPosition(buttons[1]) & Node.DOCUMENT_POSITION_FOLLOWING);
const metric = {
language,
copy: buttons.map((button) => button.textContent.trim()),
localized: buttons[0]?.textContent.trim() === expected[language].cancel
&& buttons[1]?.textContent.trim() === expected[language].save,
documentWidths: {
viewport: innerWidth,
rootClient: document.documentElement.clientWidth,
rootScroll: document.documentElement.scrollWidth,
bodyClient: document.body.clientWidth,
bodyScroll: document.body.scrollWidth,
},
documentFits: document.documentElement.scrollWidth
<= document.documentElement.clientWidth + 1
&& document.body.scrollWidth <= document.body.clientWidth + 1,
surfaceFits: !!surface && !!surfaceRect
&& surfaceRect.left >= -1 && surfaceRect.right <= innerWidth + 1
&& surface.scrollWidth <= surface.clientWidth + 1,
bodyFits: !!body && body.scrollWidth <= body.clientWidth + 1,
footerFits: !!footer && footer.scrollWidth <= footer.clientWidth + 1,
buttonsContained: !!footerRect && buttonRects.length === 2
&& buttonRects.every((rect) => rect.left >= innerLeft - 1
&& rect.right <= innerRight + 1
&& rect.top >= footerRect.top - 1 && rect.bottom <= footerRect.bottom + 1),
targetsAtLeast44: buttonRects.length === 2
&& buttonRects.every((rect) => rect.width >= 44 && rect.height >= 44),
stacked: buttonRects.length === 2 && buttonRects[1].top >= buttonRects[0].bottom - 1,
domAndFocusOrderPreserved: firstBeforeSecond
&& buttons.every((button) => button.tabIndex === 0),
cancelCloses: false,
};
buttons[0]?.click();
await settle();
metric.cancelCloses = !card.renderRoot.querySelector('hp-pdf-dialog');
metrics.push(metric);
}
await closeDialog();
return metrics;
});
const everyNarrowLocale = (key) => narrowLocales.every((metric) => metric[key] === true);
const out = {
pdfChunkAbsentBeforeIntent: !initialResources.some((path) => path.endsWith(`/${runtimeName}`)),
onePdfChunkRequest: firstPagePdfRequests === 1,
dialogHasConditionalOptions: dialog.count === dialog.expectedCount,
defaultsAndTouchModalWork: dialog.defaults.dimensions && dialog.defaults.names
&& !dialog.defaults.decor && (dialog.expectedCount === 3 || dialog.defaults.backdrop)
&& dialog.modal,
dialogRecoversAfterReconnect: dialog.reconnectKeepsModalCentered,
printerDoesNotLoadEditor: dialog.editorStillAbsent,
downloadNameIsStable: /^houseplan-.+-\d{4}-\d{2}-\d{2}\.pdf$/.test(download.suggestedFilename()),
oneA4Page: document.numPages === 1 && (
(Math.abs(viewport.width - 595.28) < 0.1 && Math.abs(viewport.height - 841.89) < 0.1)
|| (Math.abs(viewport.width - 841.89) < 0.1 && Math.abs(viewport.height - 595.28) < 0.1)
),
exportedTextIsExtractable: /Scale|Maßstab|Масштаб|Échelle/.test(text),
optionsPersistAcrossReload: rememberedNames,
narrowDialogLocalizesAllLocales: everyNarrowLocale('localized'),
narrowDialogHasNoHorizontalOverflow: everyNarrowLocale('documentFits')
&& everyNarrowLocale('surfaceFits') && everyNarrowLocale('bodyFits')
&& everyNarrowLocale('footerFits'),
narrowDialogButtonsAreContainedTargets: everyNarrowLocale('buttonsContained')
&& everyNarrowLocale('targetsAtLeast44'),
narrowDialogFooterStacksWithoutReordering: everyNarrowLocale('stacked')
&& everyNarrowLocale('domAndFocusOrderPreserved') && everyNarrowLocale('cancelCloses'),
narrowViewportExportStillWorks: downloadedByteLength > 0 && document.numPages === 1,
rasterLimitRejectsBeforePdfWrite: rasterLimitError === 'pdf.too_large',
steppedExteriorKeepsCompleteDimensionChain: steppedChainComplete,
};
await task.destroy();
const failed = await launchColdView();
let failedRequests = 0;
await failed.page.route(runtimePattern, async (route) => {
failedRequests += 1;
await route.abort('failed');
});
await clickPrinter(failed.page);
await failed.page.waitForFunction(() => window.__card._pdfRuntimeLoader.state === 'idle'
&& window.__card._toast);
out.failedChunkRetriesOnceAndKeepsView = failedRequests === 2
&& await failed.page.evaluate(() => !window.__card._pdfRuntime
&& !window.__card.renderRoot.querySelector('hp-pdf-dialog')
&& window.__card._mode === 'view');
await failed.browser.close();
checkAll(out);
await finish(browser, { ...out, narrowLocales });