Files
houseplan-card/demo/smoke_support_feedback.mjs
T
Claude 255330b9bc Шапка на телефоне: одна строка — вкладки, масштаб и шестерёнка с меню остального (#616)
На окне не шире 480 px шапка карточки и страницы бокового меню занимает одну
строку ≤ 56 px: вкладки пространств (без шестерёнок и «+», прокрутка вбок,
текущая вкладка докручивается в видимую область), масштаб, одна шестерёнка
«Действия и настройки» и у админа слот крестика #647. Заголовок скрыт.

Меню шестерёнки (src/header-menu.ts): редакторы, настройки и добавление
пространства, общие настройки, PDF, помощь, сводная панель и объёмный вид
(alpha) — в фиксированном порядке и с теми же условиями и действиями, что у
прежних кнопок. Пункт закрывает меню; Escape возвращает фокус на шестерёнку;
касание вне меню поглощается прозрачной подложкой. На > 480 px шапка прежняя,
в киоске меню нет.

- три кнопки шапки вынесены в renderHeaderActions — шапка карточки не растёт;
- сводная панель отдаёт свои пункты методом menuItems();
- i18n: title.header_menu (en/ru/de/fr);
- потолок initial View 291 000 → 292 600 (замер 291 635), база bundleBytes;
- смок smoke_mobile_view_header, юнит header-menu, 10 мутантов; смоки
  support_feedback, gear_tabs, modes, toolbar_stable_width переведены на
  меню на ≤ 480 px; подпись вкладки в span.tabtitle без pointer-events;
- USER-GUIDE.ru, UX-MODES, TOUCH-SUPPORT, STYLING-HOOKS, data-hp-contract,
  оба changelog.

Issue: #616
User-Visible: yes
2026-09-25 14:22:36 +03:00

462 lines
21 KiB
JavaScript

import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
const PREVIEW_TEXT = '{"format":"houseplan-support-package","version":1}\n';
const PREVIEW_SHA = createHash('sha256').update(PREVIEW_TEXT).digest('hex');
const { page, browser } = await launch({ width: 1000, height: 900 });
const result = await page.evaluate(async ({ version, previewText, previewSha }) => {
const card = window.__card;
const root = () => card.renderRoot || card.shadowRoot;
const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const frame = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const until = async (predicate, label, attempts = 100) => {
for (let index = 0; index < attempts; index++) {
if (predicate()) return;
await wait(10);
}
throw new Error(`support smoke timed out: ${label}`);
};
const update = async () => {
await card.updateComplete;
await frame();
};
const setInput = async (selector, value) => {
const input = root().querySelector(selector);
if (!input) throw new Error(`support smoke input missing: ${selector}`);
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
await update();
};
const close = async () => {
await card._editorRuntime._closeSupportDialog();
await update();
};
const open = async () => {
const button = root().querySelector('.support-button');
if (!button) throw new Error('support button missing');
button.click();
await until(() => !!card._supportDialog, 'dialog open');
await update();
};
card._haIntegrationVersion = version;
card._haSupportApi = 1;
const originalCallWS = card.hass.callWS.bind(card.hass);
const calls = [];
let submitMode = 'success';
let previewToken = 'a';
let deferPreviews = false;
const pendingPreviews = [];
const previewPayload = (tokenChar = previewToken) => ({
text: previewText,
size: new TextEncoder().encode(previewText).byteLength,
expires_in: 300,
spaces: 2,
token: tokenChar.repeat(48),
sha256: previewSha,
version: 1,
format: 'houseplan-support-package',
});
card.hass = {
...card.hass,
callWS: async (message) => {
if (!String(message.type || '').startsWith('houseplan/support/')) {
return originalCallWS(message);
}
calls.push(structuredClone(message));
if (message.type === 'houseplan/support/preview') {
if (deferPreviews) {
return new Promise((resolve, reject) => pendingPreviews.push({ resolve, reject }));
}
return previewPayload();
}
if (message.type === 'houseplan/support/submit') {
if (submitMode === 'rate') throw { code: 'support_rate_limited' };
if (submitMode === 'timeout') throw { code: 'support_unavailable' };
if (submitMode === 'unknown') throw { code: 'unknown_command' };
return { report_id: 'HP-43-SMOKE' };
}
return { ok: true };
},
};
await update();
const out = {};
card._adoptConfigCapabilities({ integration_version: 'mixed-release', support_api: 1 });
const learnedCapability = card._haSupportApi === 1;
card._adoptConfigCapabilities({ integration_version: version });
out.capabilityAdoption = learnedCapability && card._haSupportApi === null;
card._adoptConfigCapabilities({ integration_version: version, support_api: 1 });
const modeResults = [];
for (const mode of ['view', 'plan', 'devices', 'decor']) {
card._setMode(mode);
await until(() => card._mode === mode && !card._modeTransitionBusy, `mode ${mode}`);
await update();
const before = {
mode: card._mode,
zoom: card._zoom,
selectedDevice: card._selId,
selectedDecor: card._decorSel,
};
const button = root().querySelector('.support-button');
const pdf = button?.previousElementSibling;
const settings = pdf?.previousElementSibling;
await open();
modeResults.push({
mode,
afterSettings: settings?.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:cog-outline',
pdfBetween: pdf?.querySelector('ha-icon')?.getAttribute('icon') === 'mdi:printer-outline',
unchanged: JSON.stringify(before) === JSON.stringify({
mode: card._mode,
zoom: card._zoom,
selectedDevice: card._selId,
selectedDecor: card._decorSel,
}),
});
await close();
}
out.modeAndOrder = modeResults.every((item) => item.afterSettings && item.pdfBetween && item.unchanged)
&& new Set(modeResults.map((item) => item.mode)).size === 4;
const originalConfig = card._config;
card._config = { ...card._config, kiosk: true };
card.requestUpdate();
await update();
const kioskButton = root().querySelector('.support-button');
out.kioskHidden = !kioskButton || getComputedStyle(kioskButton).display === 'none'
|| getComputedStyle(kioskButton.closest('.hdr')).display === 'none';
card._config = originalConfig;
card._serverCanWrite = false;
card.requestUpdate();
await update();
out.readonlyAbsent = !root().querySelector('.support-button');
card._serverCanWrite = true;
card.requestUpdate();
await update();
const supportCallsBeforeOpen = calls.length;
await open();
const dialog = root().querySelector('#support-dialog');
const supportButton = root().querySelector('.support-button');
const pdfButton = supportButton?.previousElementSibling;
const settingsButton = pdfButton?.previousElementSibling;
const touchRect = supportButton.getBoundingClientRect();
const pdfRect = pdfButton?.getBoundingClientRect();
const settingsRect = settingsButton?.getBoundingClientRect();
const message = dialog?.querySelector('#support-message');
const contact = dialog?.querySelector('#support-contact');
const guide = dialog?.querySelector('#support-docs-heading + a');
out.openIsLocal = calls.length === supportCallsBeforeOpen;
out.aboutAndEnglishGuide = !!dialog?.querySelector('.aboutver')
&& dialog.querySelectorAll('.supportlinks a.aboutlink').length === 2
&& guide?.href.endsWith('/docs/USER-GUIDE.md');
out.freshDefaults = dialog?.querySelector('#support-contact')?.value === ''
&& dialog?.querySelector('#support-message')?.value === ''
&& dialog?.querySelector('.supportattach input')?.checked === false;
out.touchTarget = touchRect.width >= 44 && touchRect.height >= 44;
out.headerActionsMatch = !!settingsRect && !!pdfRect
&& Math.abs(touchRect.width - settingsRect.width) < 0.01
&& Math.abs(touchRect.height - settingsRect.height) < 0.01
&& Math.abs(touchRect.width - pdfRect.width) < 0.01
&& Math.abs(touchRect.height - pdfRect.height) < 0.01;
out.messageSurfaceMatchesContact = !!message && !!contact
&& getComputedStyle(message).backgroundColor === getComputedStyle(contact).backgroundColor;
out.noHorizontalOverflow = dialog.scrollWidth <= dialog.clientWidth + 1
&& dialog.querySelector('.supportbody').scrollWidth
<= dialog.querySelector('.supportbody').clientWidth + 1;
await close();
card._config = { ...card._config, language: 'ru' };
await open();
out.russianGuide = root().querySelector('#support-docs-heading + a')?.href
.endsWith('/docs/USER-GUIDE.ru.md') === true;
await close();
card._config = { ...card._config, language: 'en' };
card._haIntegrationVersion = '0.0.0';
await open();
out.mixedReleaseCompatible = !!root().querySelector('#support-dialog .supportform')
&& !root().querySelector('#support-dialog .supportupdate');
await close();
card._haSupportApi = null;
await open();
out.oldBackendDegrades = !!root().querySelector('#support-dialog .supportupdate')
&& !root().querySelector('#support-dialog .supportform')
&& !!root().querySelector('#support-dialog .aboutver')
&& root().querySelectorAll('#support-dialog a.aboutlink').length >= 3;
await close();
card._haIntegrationVersion = version;
card._haSupportApi = 1;
await open();
const submitCallsBeforeValidation = calls.filter((call) => call.type === 'houseplan/support/submit').length;
await card._editorRuntime._submitSupport();
await update();
out.validation = card._supportDialog?.errorCode === 'validation.message_required'
&& root().activeElement?.id === 'support-message'
&& calls.filter((call) => call.type === 'houseplan/support/submit').length
=== submitCallsBeforeValidation;
await setInput('#support-contact', ' user@example.test ');
await setInput('#support-message', ' Exact support message. ');
root().querySelector('.supportattach input').click();
await until(() => card._supportDialog?.status === 'ready', 'preview ready');
await update();
const preview = card._supportDialog.preview;
root().querySelector('.supportpreview summary').click();
await update();
out.preview = !!root().querySelector('.supportwarning')
&& root().querySelector('.supportraw')?.value === previewText
&& preview?.text === previewText
&& preview?.sha256 === previewSha
&& root().querySelector('.supporthash code')?.textContent === previewSha;
let downloadedBlob = null;
let downloadedName = '';
const originalCreateObjectURL = URL.createObjectURL;
const originalRevokeObjectURL = URL.revokeObjectURL;
const originalAnchorClick = HTMLAnchorElement.prototype.click;
URL.createObjectURL = (blob) => { downloadedBlob = blob; return 'blob:houseplan-smoke'; };
URL.revokeObjectURL = () => {};
HTMLAnchorElement.prototype.click = function click() { downloadedName = this.download; };
card._editorRuntime._downloadSupportPreview();
const downloadedText = await downloadedBlob?.text();
URL.createObjectURL = originalCreateObjectURL;
URL.revokeObjectURL = originalRevokeObjectURL;
HTMLAnchorElement.prototype.click = originalAnchorClick;
out.downloadExact = downloadedText === previewText
&& downloadedName === `houseplan-support-${previewSha.slice(0, 12)}.json`;
submitMode = 'success';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.status === 'success', 'success submit');
await update();
const successRequest = calls.findLast((call) => call.type === 'houseplan/support/submit');
out.success = root().querySelector('#support-receipt')?.textContent.includes('HP-43-SMOKE') === true
&& root().activeElement?.id === 'support-receipt'
&& successRequest?.message === 'Exact support message.'
&& successRequest?.contact === 'user@example.test'
&& successRequest?.preview_token === 'a'.repeat(48);
await close();
await open();
out.freshAfterSuccess = card._supportDialog?.contact === ''
&& card._supportDialog?.message === '' && card._supportDialog?.attach === false;
await setInput('#support-message', 'Retry this exact message.');
const retryKey = card._supportDialog.idempotencyKey;
submitMode = 'rate';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.status === 'error', 'rate limit error');
await update();
const ratePreserved = card._supportDialog.message === 'Retry this exact message.'
&& !!root().querySelector('.supportmanual')
&& !root().querySelector('#support-receipt');
submitMode = 'timeout';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.errorCode === 'support_unavailable', 'timeout error');
submitMode = 'unknown';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.status === 'error', 'unknown command error');
submitMode = 'success';
root().querySelector('#support-dialog .supportfooter .btn.on').click();
await until(() => card._supportDialog?.status === 'success', 'retry success');
await update();
const retryRequests = calls.filter((call) => call.type === 'houseplan/support/submit'
&& call.message === 'Retry this exact message.');
out.retryAndManualRecovery = ratePreserved && retryRequests.length === 4
&& retryRequests.every((call) => call.idempotency_key === retryKey)
&& root().querySelector('#support-receipt')?.textContent.includes('HP-43-SMOKE') === true;
await close();
await open();
deferPreviews = true;
const canceledBuild = card._editorRuntime._setSupportAttachment(true);
await until(() => pendingPreviews.length === 1
&& card._supportDialog?.status === 'building', 'deferred preview building');
await update();
const disabledWhileBuilding = root().querySelector('.supportattach input')?.disabled === true;
const canceledRequest = pendingPreviews.shift();
await card._editorRuntime._setSupportAttachment(false);
await update();
const hiddenImmediately = card._supportDialog?.attach === false
&& card._supportDialog?.status === 'idle'
&& card._supportDialog?.preview === null
&& !root().querySelector('.supportpreview');
canceledRequest.resolve(previewPayload('b'));
await canceledBuild;
await update();
const discardedB = calls.filter((call) => call.type === 'houseplan/support/preview/discard'
&& call.token === 'b'.repeat(48)).length;
out.latePreviewConsentStaysRevoked = disabledWhileBuilding && hiddenImmediately
&& card._supportDialog?.attach === false
&& card._supportDialog?.status === 'idle'
&& card._supportDialog?.preview === null
&& !root().querySelector('.supportpreview')
&& discardedB === 1;
const oldBuild = card._editorRuntime._setSupportAttachment(true);
await until(() => pendingPreviews.length === 1, 'old preview queued');
const oldRequest = pendingPreviews.shift();
await card._editorRuntime._setSupportAttachment(false);
const latestBuild = card._editorRuntime._setSupportAttachment(true);
await until(() => pendingPreviews.length === 1, 'latest preview queued');
const latestRequest = pendingPreviews.shift();
latestRequest.resolve(previewPayload('c'));
await latestBuild;
oldRequest.resolve(previewPayload('d'));
await oldBuild;
await update();
out.latestPreviewGenerationWins = card._supportDialog?.attach === true
&& card._supportDialog?.status === 'ready'
&& card._supportDialog?.preview?.token === 'c'.repeat(48)
&& calls.filter((call) => call.type === 'houseplan/support/preview/discard'
&& call.token === 'd'.repeat(48)).length === 1;
const rejectedBuild = card._editorRuntime._refreshSupportPreview();
await until(() => pendingPreviews.length === 1
&& card._supportDialog?.status === 'building', 'rejected stale preview queued');
const rejectedRequest = pendingPreviews.shift();
await card._editorRuntime._setSupportAttachment(false);
rejectedRequest.reject({ code: 'support_rejected' });
await rejectedBuild;
await update();
out.stalePreviewErrorIsIgnored = card._supportDialog?.attach === false
&& card._supportDialog?.status === 'idle'
&& card._supportDialog?.errorCode === ''
&& card._supportDialog?.preview === null
&& !root().querySelector('.supportpreview');
const invalidBuild = card._editorRuntime._setSupportAttachment(true);
await until(() => pendingPreviews.length === 1, 'invalid preview queued');
pendingPreviews.shift().resolve({ ...previewPayload('0'), sha256: 'invalid' });
await invalidBuild;
await update();
const validInvalidDiscarded = calls.filter(
(call) => call.type === 'houseplan/support/preview/discard'
&& call.token === '0'.repeat(48),
).length === 1;
const malformedBuild = card._editorRuntime._refreshSupportPreview();
await until(() => pendingPreviews.length === 1, 'malformed-token preview queued');
pendingPreviews.shift().resolve({ ...previewPayload('h'), token: 'not-a-token', sha256: 'invalid' });
await malformedBuild;
await update();
const malformedNotDiscarded = calls.every(
(call) => call.type !== 'houseplan/support/preview/discard' || call.token !== 'not-a-token',
);
const runtime = card._editorRuntime;
const realSupportPatch = runtime._supportPatch.bind(runtime);
runtime._supportPatch = (candidateDraftId, patch) => patch?.status === 'ready'
? false : realSupportPatch(candidateDraftId, patch);
const unadoptedBuild = runtime._refreshSupportPreview();
await until(() => pendingPreviews.length === 1, 'unadoptable preview queued');
pendingPreviews.shift().resolve(previewPayload('1'));
await unadoptedBuild;
runtime._supportPatch = realSupportPatch;
realSupportPatch(card._supportDialog.draftId, {
status: 'error', errorCode: 'support_rejected',
});
await update();
const unadoptedDiscarded = calls.filter(
(call) => call.type === 'houseplan/support/preview/discard'
&& call.token === '1'.repeat(48),
).length === 1;
out.invalidPreviewTokenIsDiscardedExactlyOnce = validInvalidDiscarded;
out.malformedPreviewTokenIsNotEchoed = malformedNotDiscarded;
out.locallyUnadoptedPreviewTokenIsDiscardedExactlyOnce = unadoptedDiscarded;
deferPreviews = false;
await close();
await open();
const validationKey = card._supportDialog.idempotencyKey;
const submitsBeforeEmptyValidation = calls.filter(
(call) => call.type === 'houseplan/support/submit',
).length;
await card._editorRuntime._submitSupport();
await update();
out.validationDoesNotRotateKey = card._supportDialog?.idempotencyKey === validationKey
&& card._supportDialog?.submissionFingerprint === ''
&& calls.filter((call) => call.type === 'houseplan/support/submit').length
=== submitsBeforeEmptyValidation;
const failSubmit = async () => {
await card._editorRuntime._submitSupport();
await until(() => card._supportDialog?.status === 'error', 'payload submit failure');
await update();
return calls.findLast((call) => call.type === 'houseplan/support/submit');
};
submitMode = 'rate';
await setInput('#support-message', ' First payload ');
const firstPayloadRequest = await failSubmit();
await setInput('#support-message', 'First payload ');
const trimOnlyRequest = await failSubmit();
await setInput('#support-message', 'Second payload');
const changedMessageRequest = await failSubmit();
await setInput('#support-contact', 'owner@example.test');
const changedContactRequest = await failSubmit();
previewToken = 'e';
await card._editorRuntime._setSupportAttachment(true);
const attachedRequest = await failSubmit();
await card._editorRuntime._setSupportAttachment(false);
previewToken = 'f';
await card._editorRuntime._setSupportAttachment(true);
const changedPreviewRequest = await failSubmit();
out.idempotencyKeyFollowsEffectivePayload = firstPayloadRequest?.idempotency_key === validationKey
&& trimOnlyRequest?.idempotency_key === firstPayloadRequest?.idempotency_key
&& changedMessageRequest?.idempotency_key !== trimOnlyRequest?.idempotency_key
&& changedContactRequest?.idempotency_key !== changedMessageRequest?.idempotency_key
&& attachedRequest?.idempotency_key !== changedContactRequest?.idempotency_key
&& attachedRequest?.preview_token === 'e'.repeat(48)
&& changedPreviewRequest?.idempotency_key !== attachedRequest?.idempotency_key
&& changedPreviewRequest?.preview_token === 'f'.repeat(48)
&& firstPayloadRequest?.message === 'First payload'
&& firstPayloadRequest?.contact === '';
await close();
return out;
}, { version: VERSION, previewText: PREVIEW_TEXT, previewSha: PREVIEW_SHA });
const responsive = async (viewport) => {
await page.setViewportSize(viewport);
return page.evaluate(async () => {
const card = window.__card;
if (card._supportDialog) await card._editorRuntime._closeSupportDialog();
card._setMode('view');
await card.updateComplete;
// #616: at ≤ 480 px the entry point is the header menu item, not the header
// button (hidden there); its hit target is what the phone user taps.
let button = card.renderRoot.querySelector('.support-button');
let entryRect = null;
if (innerWidth <= 480) {
card.renderRoot.querySelector('[data-hp="header-menu"]')?.click();
await card.updateComplete;
button = card.renderRoot.querySelector('[data-hp="header-menu-item"][data-id="support"]');
entryRect = button?.getBoundingClientRect();
}
button?.click();
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const dialog = card.renderRoot.querySelector('#support-dialog');
const body = dialog?.querySelector('.supportbody');
const footer = dialog?.querySelector('.supportfooter');
const dialogRect = dialog?.getBoundingClientRect();
const buttonRect = entryRect ?? button?.getBoundingClientRect();
return !!dialog && !!body && !!footer && !!dialogRect && !!buttonRect
&& dialogRect.left >= -1 && dialogRect.right <= innerWidth + 1
&& dialogRect.top >= -1 && dialogRect.bottom <= innerHeight + 1
&& dialog.scrollWidth <= dialog.clientWidth + 1
&& body.scrollWidth <= body.clientWidth + 1
&& body.clientHeight > 0 && footer.getBoundingClientRect().height > 0
&& buttonRect.width >= 44 && buttonRect.height >= 44;
});
};
result.phonePortrait = await responsive({ width: 320, height: 760 });
result.phoneLandscape = await responsive({ width: 760, height: 320 });
checkAll(result);
await finish(browser, result);