mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
План уходил base64 в WebSocket-кадре: файл больше ~3 МиБ давал кадр больше 4 МиБ, HA закрывал сокет до обработчика, и обещанные 8 МБ были недостижимы. - бэкенд: HouseplanPlanUploadView (POST /api/houseplan/plans/upload), потоковый предел MAX_PLAN_BYTES (read_bounded), общий writer store_plan_upload для view и ws_plan_set (контракт WS без изменений); - карточка: stagePlanFile/uploadPlanFile/renderPlanBackdropGuard в backdrop-pick.ts для обоих рантаймов; PlanFilePayload хранит Blob вместо b64; SVG больше предела — тост при выборе, растр — диалог #39 только с уменьшенной копией, копия больше предела не попадает в staging, 413 называет предел; - i18n toast.plan_too_large, backdrop.over_limit_body (en/ru/de/fr); USER-GUIDE ru/en, CHANGELOG ru/en, docs/testing-notes (#617); - тесты: test/plan-upload-limit.test.mjs, tests_backend/test_plan_upload.py, test_ha_upload.py (#617), smoke_plan_upload_limit.mjs; три смока переведены с b64/plan/set на blob/fetchWithAuth; мутанты plan-upload-*; - база монолита: hostRefs +3 (общий хелпер плана вместо двух копий в рантаймах, новый тост уменьшенной копии). Issue: #617 User-Visible: yes
307 lines
17 KiB
JavaScript
307 lines
17 KiB
JavaScript
// #39: large-backdrop guard on the real bundle. The probe reads header bytes
|
||
// only, so the warning must appear with ZERO createImageBitmap calls; decode
|
||
// happens exclusively after the explicit "reduced copy" choice, and its
|
||
// failure is honest — toast, clean staging, no silent fallback.
|
||
import { launch, checkAll, finish } from './serve.mjs';
|
||
|
||
const { page, browser } = await launch();
|
||
const out = await page.evaluate(async () => {
|
||
const card = window.__card;
|
||
const out = {};
|
||
const waitFor = async (predicate, timeout = 8000) => {
|
||
const started = Date.now();
|
||
while (!predicate() && Date.now() - started < timeout) {
|
||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||
}
|
||
return !!predicate();
|
||
};
|
||
|
||
// счётчик decode-вызовов — главный свидетель «до выбора ничего тяжёлого»
|
||
const realCIB = window.createImageBitmap.bind(window);
|
||
let decodeCalls = 0;
|
||
let decodeMode = 'real';
|
||
window.createImageBitmap = (...args) => {
|
||
decodeCalls++;
|
||
if (decodeMode === 'reject') return Promise.reject(new Error('mock decode fail'));
|
||
if (decodeMode === 'hang') return new Promise(() => {});
|
||
return realCIB(...args);
|
||
};
|
||
|
||
const u32 = (v) => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255];
|
||
const asciiBytes = (t) => [...t].map((c) => c.charCodeAt(0));
|
||
const pngHeader = (w, h, colourType = 2) => Uint8Array.from([
|
||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||
...u32(13), ...asciiBytes('IHDR'), ...u32(w), ...u32(h), 8, colourType, 0, 0, 0, ...u32(0),
|
||
...u32(0), ...asciiBytes('IEND'), ...u32(0),
|
||
]);
|
||
const fileOf = (bytes, name, type) => new File([bytes], name, { type });
|
||
const pick = (file) => card._pickPlanFile({ target: { files: [file], value: 'x' } });
|
||
// диалог пространства — тоже hp-dialog: гард ищем по СВОЕМУ заголовку
|
||
const guardDialog = () => [...card.renderRoot.querySelectorAll('hp-dialog')]
|
||
.find((d) => {
|
||
const title = String(d.title || '');
|
||
return title === card._t('backdrop.large_title')
|
||
|| title === card._t('backdrop.too_large_title');
|
||
}) || null;
|
||
const dialogText = () => {
|
||
const dialog = guardDialog();
|
||
return dialog ? (dialog.textContent || '') + ' ' + (dialog.title || '') : '';
|
||
};
|
||
const guardButtons = () => [...(guardDialog()?.querySelectorAll('.row button') || [])];
|
||
|
||
// редакторский диалог пространства должен существовать как поверхность
|
||
await card._ensureEditorRuntime();
|
||
card._spaceDialog = {
|
||
mode: 'edit', spaceId: card._space, title: 'smoke', planUrl: null, planFile: null,
|
||
source: 'file', showBorders: true, showNames: true, zeroWallStyle: 'auto',
|
||
displayTouched: false, hideDecor: false, hideOpenings: false, roomColor: '#888888',
|
||
roomOpacity: 0.2, bgColor: null, bgMode: null, northDeg: null, sunRays: null,
|
||
fillMode: 'none', customFill: null, glowEnabled: true, tempMin: 18, tempMax: 28,
|
||
showLqi: false, cardFontScale: 1, labelTemp: false, labelHum: false, labelLqi: false,
|
||
labelLight: false, cellCm: 50, busy: false,
|
||
};
|
||
card.requestUpdate(); await card.updateComplete;
|
||
|
||
// ── AC1: warn по заголовку 10000×10000 без единого decode ─────────────────
|
||
await pick(fileOf(pngHeader(10000, 10000), 'huge.png', 'image/png'));
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
out.warnDialogShown = !!card._backdropGuard && dialogText().includes('10000×10000');
|
||
out.warnShowsMemory = dialogText().includes(card._t('backdrop.large_title'))
|
||
&& /\d+ (MB|МБ)/.test(dialogText());
|
||
out.noDecodeBeforeChoice = decodeCalls === 0;
|
||
|
||
// ── AC3: «Оставить оригинал» — в staging ровно байты выбранного файла ────
|
||
// #617: base64 больше нет, план едет Blob-ом по HTTP; паритет «оригинал =
|
||
// выбранные байты» проверяется побайтово по blob, а не по b64.
|
||
const originalBytes = pngHeader(10000, 10000);
|
||
const sameBytes = async (blob) => {
|
||
if (!blob) return false;
|
||
const got = new Uint8Array(await blob.arrayBuffer());
|
||
return got.length === originalBytes.length && got.every((v, i) => v === originalBytes[i]);
|
||
};
|
||
guardButtons().find((b) => b.textContent.includes(card._t('backdrop.keep_original')))?.click();
|
||
out.keepOriginalSetsPlanFile = await waitFor(() =>
|
||
!!card._spaceDialog?.planFile?.blob && !card._backdropGuard);
|
||
out.keepOriginalKeepsName = card._spaceDialog?.planFile?.name === 'huge.png';
|
||
out.blobParityWithOriginalBytes = await sameBytes(card._spaceDialog?.planFile?.blob);
|
||
card._spaceDialog = { ...card._spaceDialog, planFile: null };
|
||
|
||
// ── AC2: настоящая уменьшенная копия (опак 6200² JPEG → jpg 4096) ────────
|
||
const src = new OffscreenCanvas(6200, 6200);
|
||
const ctx = src.getContext('2d');
|
||
ctx.fillStyle = '#c81e1e'; ctx.fillRect(0, 0, 6200, 6200);
|
||
ctx.fillStyle = '#1e50c8'; ctx.fillRect(100, 100, 3000, 3000);
|
||
const bigBlob = await src.convertToBlob({ type: 'image/jpeg', quality: 0.92 });
|
||
const bigFile = new File([bigBlob], 'scan.jpg', { type: 'image/jpeg' });
|
||
await pick(bigFile);
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
out.realWarnShown = card._backdropGuard?.probe?.kind === 'warn';
|
||
const beforeReduce = decodeCalls;
|
||
guardButtons().find((b) => b.textContent.includes(card._t('backdrop.use_downscaled')))?.click();
|
||
out.reducedApplied = await waitFor(() =>
|
||
!!card._spaceDialog?.planFile && !card._backdropGuard, 15000);
|
||
out.decodeExactlyOnce = decodeCalls === beforeReduce + 1;
|
||
const reduced = card._spaceDialog?.planFile;
|
||
out.reducedExtJpg = reduced?.ext === 'jpg' && /-reduced\.jpg$/.test(reduced?.name || '');
|
||
const reducedDims = await new Promise((resolve) => {
|
||
const image = new Image();
|
||
image.onload = () => resolve([image.naturalWidth, image.naturalHeight]);
|
||
image.onerror = () => resolve([0, 0]);
|
||
image.src = reduced?.blob ? URL.createObjectURL(reduced.blob) : '';
|
||
});
|
||
out.reducedTo4096 = reducedDims[0] === 4096 && reducedDims[1] === 4096;
|
||
card._spaceDialog = { ...card._spaceDialog, planFile: null };
|
||
|
||
// ── #427: decor source >2 MiB can still choose a reduced copy ───────────
|
||
// The canonical asset limit forbids only the original. Trailing bytes make
|
||
// this valid JPEG exceed the source limit without another huge allocation;
|
||
// Chromium deliberately ignores data after JPEG EOI while decoding.
|
||
const decorBigFile = new File([
|
||
bigBlob, new Uint8Array(2 * 1024 * 1024 + 1),
|
||
], 'decor-large.jpg', { type: 'image/jpeg' });
|
||
const realUploadDecorImage = card._editorRuntime._uploadDecorImage;
|
||
let decorUpload = null;
|
||
card._editorRuntime._uploadDecorImage = async (blob, name, replace) => {
|
||
decorUpload = { blob, name, replace };
|
||
};
|
||
await card._editorRuntime._decorImageUpload({
|
||
target: { files: [decorBigFile], value: 'x' },
|
||
});
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
const decorButtons = guardButtons();
|
||
out.decorOversizeOffersReducedWithoutOriginal = decorButtons.length === 2
|
||
&& decorButtons.some((button) => button.textContent.includes(card._t('btn.cancel')))
|
||
&& decorButtons.some((button) => button.textContent.includes(card._t('backdrop.use_downscaled')))
|
||
&& !decorButtons.some((button) => button.textContent.includes(card._t('backdrop.keep_original')));
|
||
decorButtons.find((button) => button.textContent.includes(
|
||
card._t('backdrop.use_downscaled'),
|
||
))?.click();
|
||
out.decorOversizeUploadsReducedCopy = await waitFor(() => !!decorUpload
|
||
&& !card._backdropGuard, 15000)
|
||
&& decorUpload.blob !== decorBigFile
|
||
&& decorUpload.blob.size < decorBigFile.size
|
||
&& /-reduced\.jpg$/.test(decorUpload.name)
|
||
&& decorUpload.replace === false;
|
||
card._editorRuntime._uploadDecorImage = realUploadDecorImage;
|
||
|
||
await card._editorRuntime._decorImageUpload({
|
||
target: {
|
||
files: [fileOf(pngHeader(20000, 20000), 'decor-hard.png', 'image/png')],
|
||
value: 'x',
|
||
},
|
||
});
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
const decorHardButtons = guardButtons();
|
||
out.decorHardStillHasOnlyCancel = decorHardButtons.length === 1
|
||
&& decorHardButtons[0].textContent.includes(card._t('btn.cancel'));
|
||
decorHardButtons[0]?.click();
|
||
await waitFor(() => !card._backdropGuard);
|
||
|
||
// ── alpha-ветка: PNG с alpha остаётся PNG ────────────────────────────────
|
||
const alphaSrc = new OffscreenCanvas(6200, 6200);
|
||
const alphaCtx = alphaSrc.getContext('2d');
|
||
alphaCtx.fillStyle = 'rgba(200,30,30,0.5)'; alphaCtx.fillRect(0, 0, 6200, 3100);
|
||
const alphaBlob = await alphaSrc.convertToBlob({ type: 'image/png' });
|
||
await pick(new File([alphaBlob], 'plan.png', { type: 'image/png' }));
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
guardButtons().find((b) => b.textContent.includes(card._t('backdrop.use_downscaled')))?.click();
|
||
out.alphaStaysPng = await waitFor(() =>
|
||
card._spaceDialog?.planFile?.ext === 'png'
|
||
&& /-reduced\.png$/.test(card._spaceDialog?.planFile?.name || ''), 15000);
|
||
card._spaceDialog = { ...card._spaceDialog, planFile: null };
|
||
|
||
// ── AC4 фаза 1: >16384 — только «Отмена», decode не растёт ───────────────
|
||
const beforeHard = decodeCalls;
|
||
await pick(fileOf(pngHeader(20000, 20000), 'giant.png', 'image/png'));
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
out.hardDialogShown = card._backdropGuard?.probe?.kind === 'hard'
|
||
&& dialogText().includes(card._t('backdrop.too_large_title'));
|
||
out.hardHasOnlyCancel = guardButtons().length === 1
|
||
&& guardButtons()[0].textContent.includes(card._t('btn.cancel'));
|
||
guardButtons()[0]?.click();
|
||
await waitFor(() => !card._backdropGuard);
|
||
out.hardLeavesEverything = !card._spaceDialog.planFile && decodeCalls === beforeHard;
|
||
|
||
// ── AC4б фаза 2а: decode reject → тост, staging чист, оригинал не грузился ─
|
||
decodeMode = 'reject';
|
||
await pick(bigFile);
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
guardButtons().find((b) => b.textContent.includes(card._t('backdrop.use_downscaled')))?.click();
|
||
out.phase2RejectToast = await waitFor(() =>
|
||
card._toast === card._t('backdrop.downscale_failed') && !card._backdropGuard);
|
||
out.phase2RejectCleanStaging = !card._spaceDialog.planFile;
|
||
|
||
// ── AC4б фаза 2б: вечный decode + короткий таймаут ───────────────────────
|
||
decodeMode = 'hang';
|
||
window.__HP_BACKDROP_TIMEOUT_MS = 60;
|
||
card._toast = '';
|
||
await pick(bigFile);
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
guardButtons().find((b) => b.textContent.includes(card._t('backdrop.use_downscaled')))?.click();
|
||
out.phase2TimeoutToast = await waitFor(() =>
|
||
card._toast === card._t('backdrop.downscale_failed') && !card._backdropGuard, 5000);
|
||
out.phase2CleanStaging = !card._spaceDialog.planFile;
|
||
delete window.__HP_BACKDROP_TIMEOUT_MS;
|
||
decodeMode = 'real';
|
||
|
||
// ── r1-M1: dismissal во время busy не гоняет гонку с исполняющимся выбором ─
|
||
decodeMode = 'hang';
|
||
window.__HP_BACKDROP_TIMEOUT_MS = 400;
|
||
card._toast = '';
|
||
await pick(bigFile);
|
||
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
|
||
guardButtons().find((b) => b.textContent.includes(card._t('backdrop.use_downscaled')))?.click();
|
||
await waitFor(() => card._backdropGuard?.busy); await card.updateComplete;
|
||
// Escape/скрим во время busy: hp-close игнорируется, диалог остаётся
|
||
guardDialog()?.dispatchEvent(new CustomEvent('hp-close', { bubbles: true, composed: true }));
|
||
await card.updateComplete;
|
||
out.busyDismissIgnored = !!card._backdropGuard && card._backdropGuard.busy;
|
||
// а даже если гард снести силой — устаревший поток не подставит planFile
|
||
card._backdropGuard = null; card.requestUpdate(); await card.updateComplete;
|
||
await new Promise((resolve) => setTimeout(resolve, 700)); // таймаут отработал
|
||
out.staleFlowNeverApplies = !card._spaceDialog.planFile
|
||
&& card._toast !== card._t('backdrop.downscale_failed');
|
||
delete window.__HP_BACKDROP_TIMEOUT_MS;
|
||
decodeMode = 'real';
|
||
|
||
// ── AC8: EXIF-ориентация — настоящий повёрнутый JPEG (APP1 orientation 6) ─
|
||
const exifSrc = new OffscreenCanvas(8200, 4100);
|
||
const exifCtx = exifSrc.getContext('2d');
|
||
exifCtx.fillStyle = '#3a7d2c'; exifCtx.fillRect(0, 0, 8200, 4100);
|
||
const plainJpeg = new Uint8Array(await (await exifSrc.convertToBlob({
|
||
type: 'image/jpeg', quality: 0.9,
|
||
})).arrayBuffer());
|
||
// APP1/Exif с единственным тегом 0x0112 (Orientation) = 6 (поворот 90° CW)
|
||
const app1 = Uint8Array.from([
|
||
0xff, 0xe1, 0x00, 0x22, // APP1: длина 34 = 2 (сама длина) + 32 содержимого
|
||
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // "Exif |