Files
houseplan-card/demo/smoke_backdrop_guard.mjs
Claude 857369b18b fix(plans): загрузка плана по HTTP, предел 8 МБ проверяется до отправки (#617)
План уходил 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
2026-09-24 19:57:41 +03:00

307 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #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"
0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08, // TIFF BE, IFD0 @8
0x00, 0x01, // 1 тег
0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00, // Orientation=6
0x00, 0x00, 0x00, 0x00, // next IFD = none
]);
const rotated = new Uint8Array(2 + app1.length + (plainJpeg.length - 2));
rotated.set(plainJpeg.subarray(0, 2), 0); // SOI
rotated.set(app1, 2); // EXIF
rotated.set(plainJpeg.subarray(2), 2 + app1.length);
const rotatedDecoded = await realCIB(new Blob([rotated], { type: 'image/jpeg' }),
{ imageOrientation: 'from-image' });
out.exifFixtureRotates = rotatedDecoded.width === 4100 && rotatedDecoded.height === 8200;
rotatedDecoded.close();
let seenOrientationOption = null;
let exifHookCalls = 0;
window.createImageBitmap = (source, options) => {
decodeCalls++;
exifHookCalls++;
seenOrientationOption = options?.imageOrientation ?? null;
return realCIB(source, options);
};
await pick(new File([rotated], 'rotated.jpg', { type: 'image/jpeg' }));
await waitFor(() => !!card._backdropGuard); await card.updateComplete;
// probe читает НЕповёрнутые размеры из SOF
out.exifProbeReadsSof = card._backdropGuard?.probe?.width === 8200
&& card._backdropGuard?.probe?.height === 4100;
guardButtons().find((b) => b.textContent.includes(card._t('backdrop.use_downscaled')))?.click();
out.exifReduceApplied = await waitFor(() =>
!!card._spaceDialog?.planFile && !card._backdropGuard, 15000);
out.exifOptionPassed = seenOrientationOption === 'from-image';
const exifDims = await new Promise((resolve) => {
const image = new Image();
image.onload = () => resolve([image.naturalWidth, image.naturalHeight]);
image.onerror = () => resolve([0, 0]);
const blob = card._spaceDialog?.planFile?.blob;
image.src = blob ? URL.createObjectURL(blob) : '';
});
// уменьшенная копия ПОВЁРНУТА: портрет 2048×4096, а не пейзаж
out.exifReducedRotated = exifDims[0] === 2048 && exifDims[1] === 4096;
card._spaceDialog = { ...card._spaceDialog, planFile: null };
window.createImageBitmap = (...args) => {
decodeCalls++;
if (decodeMode === 'reject') return Promise.reject(new Error('mock decode fail'));
if (decodeMode === 'hang') return new Promise(() => {});
return realCIB(...args);
};
// ── повторный выбор после отказа работает (инпут сбрасывается всегда) ────
await pick(fileOf(pngHeader(10000, 10000), 'huge.png', 'image/png'));
out.repickReopensGuard = await waitFor(() => !!card._backdropGuard);
guardButtons().find((b) => b.textContent.includes(card._t('btn.cancel')))?.click();
await waitFor(() => !card._backdropGuard);
// ── AC5: SVG идёт мимо probe и мимо decode ───────────────────────────────
const beforeSvg = decodeCalls;
const svg = new File([`<svg xmlns="http://www.w3.org/2000/svg" width="99999" height="99999"/>`],
'plan.svg', { type: 'image/svg+xml' });
await pick(svg);
out.svgBypassesGuard = await waitFor(() =>
card._spaceDialog?.planFile?.ext === 'svg' && !card._backdropGuard);
out.svgNoDecode = decodeCalls === beforeSvg;
window.createImageBitmap = realCIB;
card._spaceDialog = null;
card._backdropGuard = null;
return out;
});
checkAll(out);
await finish(browser, out);