// #402: подтверждение опасного действия не принадлежит ни одной ветке // render(). Раньше `hp-confirm` рендерился только в финальной ветке цепочки // ранних return, и в онбординге («ещё нет ни одного пространства») кнопка // удаления плана была мертва: диалог не появлялся, промис не разрешался // никогда. Отдельный файл, а не дополнение smoke_danger_confirmation: // там оснастка проверяет матрицу мутаций и держит открытыми неполные // фикстуры диалогов, а здесь нужен чистый рендер. import { launch, checkAll, finish } from './serve.mjs'; // Touch-эмуляция намеренно: `docs/TOUCH-SUPPORT.md` § Safety floor запрещает // «best effort» в обходе подтверждения разрушающего действия, а сломанная // ветка пробивала этот пол одинаково на мыши и на пальце. Проверяем там, где // он обязан держаться. const { page, browser } = await launch( { width: 390, height: 760 }, 1, [], { hasTouch: true, isMobile: true }, ); // Hold the first German chunk request so the card remains in the real `warm` // language gate long enough to exercise it. Aborting would immediately select // the English fallback and could turn this into a test that never enters the // branch it claims to cover. const germanAsset = /\/houseplan-assets\/de-[^/?]+\.js(?:\?.*)?$/; let releaseGerman; let markGermanStarted; let markGermanCompleted; const germanStarted = new Promise((resolve) => { markGermanStarted = resolve; }); const germanCompleted = new Promise((resolve) => { markGermanCompleted = resolve; }); const holdGerman = async (route) => { markGermanStarted(); await new Promise((resolve) => { releaseGerman = resolve; }); await route.continue(); markGermanCompleted(); }; await page.route(germanAsset, holdGerman); const warmGate = await page.evaluate(async () => { const card = window.__card; const root = () => card.shadowRoot || card.renderRoot; const settle = async () => { await card.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }; const originalLanguage = card._config?.language; const committedBody = root().querySelector('ha-card')?.outerHTML; const openDecision = card._confirmDanger({ key: 'ready-before-warm', kind: 'destructive', title: 'Delete?', message: 'The plan and all its rooms will be deleted.', confirmLabel: 'Delete', cancelLabel: 'Cancel', }); await settle(); const openedWhileReady = !!root().querySelector('hp-confirm'); card._config = { ...card._config, language: 'de' }; card.requestUpdate(); for (let attempt = 0; attempt < 50 && !card.inert; attempt++) { await new Promise((resolve) => setTimeout(resolve, 10)); await settle(); } const entered = card.inert && card.getAttribute('aria-busy') === 'true'; const cancelledOnTransition = await Promise.race([ openDecision, new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)), ]); await settle(); const warmDecision = card._confirmDanger({ key: 'warm-language-gate', kind: 'destructive', title: 'Delete?', message: 'The plan and all its rooms will be deleted.', confirmLabel: 'Delete', cancelLabel: 'Cancel', }); // Refusal is a synchronous registration barrier, not eventual cleanup by // the next warm render. A briefly pending request has no decision surface. const warmRequestWasNeverRegistered = card._dangerConfirm === null && card._dangerConfirmController.state === null; const decision = await Promise.race([ warmDecision, new Promise((resolve) => setTimeout(() => resolve('timeout'), 100)), ]); const result = { warmLanguageGateActuallyEntered: entered, readyConfirmationOpenedBeforeWarm: openedWhileReady, readyToWarmCancelsOpenConfirmation: cancelledOnTransition === false, readyToWarmKeepsCommittedBody: root().querySelector('ha-card')?.outerHTML === committedBody, warmLanguageGateNeverRegisters: warmRequestWasNeverRegistered, warmLanguageGateRefusesImmediately: decision === false, warmLanguageGateKeepsControllerEmpty: card._dangerConfirm === null && card._dangerConfirmController.state === null, warmLanguageGateRemovesDecisionSurface: !root().querySelector('hp-confirm'), }; // Change the requested language back without waiting for render. A decision // made in this warm -> ready window must use current config/runtime state, // not the warm branch painted by the previous update. card._config = { ...card._config, language: originalLanguage }; const readyAgain = card._confirmDanger({ key: 'ready-before-render', kind: 'warning', title: 'Continue?', message: 'The language is ready again.', confirmLabel: 'Continue', cancelLabel: 'Cancel', }); result.warmToReadyAllowsBeforeRender = card._dangerConfirmController.state?.request.key === 'ready-before-render' && !card.inert; card._cancelDangerConfirm(); result.warmToReadyDecisionSettles = await readyAgain === false; await settle(); return result; }); await Promise.race([ germanStarted, new Promise((_, reject) => setTimeout(() => reject(new Error('German locale request not seen')), 1000)), ]); releaseGerman(); await Promise.race([ germanCompleted, new Promise((_, reject) => setTimeout(() => reject(new Error('German locale request did not complete')), 1000)), ]); await page.unroute(germanAsset, holdGerman); const out = await page.evaluate(async () => { const result = {}; const card = window.__card; const root = () => card.shadowRoot || card.renderRoot; const settle = async () => { await card.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }; const settleElement = async (element) => { await element.updateComplete; const shell = element.querySelector('hp-dialog'); if (shell) await shell.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }; const deepActiveElement = () => { let active = document.activeElement; while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement; return active; }; const directConfirm = async (kind, action, key) => { const element = document.createElement('hp-confirm'); element.token = Math.floor(Math.random() * 1_000_000) + 1; element.request = { key, kind, title: kind === 'destructive' ? 'Delete?' : 'Unlock?', message: kind === 'destructive' ? 'The plan and all its rooms will be deleted.' : 'House Plan will send an unlock command.', confirmLabel: kind === 'destructive' ? 'Delete' : 'Unlock', cancelLabel: 'Cancel', }; const decision = new Promise((resolve) => element.addEventListener( 'hp-confirm-decision', (event) => resolve(event.detail.accepted), { once: true }, )); document.body.append(element); await settleElement(element); const shell = element.querySelector('hp-dialog'); const native = shell?.shadowRoot?.querySelector('dialog'); const body = element.querySelector('.danger-confirm-body'); const cancel = element.querySelector('button[autofocus]'); const semantics = native?.getAttribute('role') === 'alertdialog' && native.getAttribute('aria-describedby') === body?.id && !!body?.textContent?.trim(); const focused = deepActiveElement() === cancel; const stayedNative = !!native && !shell?.shadowRoot?.querySelector('ha-dialog'); if (action === 'escape') { cancel?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, composed: true, cancelable: true, })); } else { cancel?.click(); } const accepted = await decision; element.remove(); await new Promise((resolve) => requestAnimationFrame(resolve)); return { semantics, focused, stayedNative, cancelled: accepted === false }; }; const dialogs = () => root().querySelectorAll('hp-confirm').length; const request = (key) => ({ key, kind: 'destructive', title: card._t('confirm.delete_plan_title'), message: card._t('confirm.delete_plan_body'), confirmLabel: card._t('btn.delete'), cancelLabel: card._t('btn.cancel'), }); const decide = async (accepted) => { const element = root().querySelector('hp-confirm'); element?.dispatchEvent(new CustomEvent('hp-confirm-decision', { detail: { token: card._dangerConfirm.token, accepted }, bubbles: true, composed: true, })); await settle(); }; const boundedDangerDecision = async (decision, timeoutMs = 250) => { let timeoutId; const outcome = await Promise.race([ decision.then((value) => ({ settled: true, value })), new Promise((resolve) => { timeoutId = setTimeout(() => resolve({ settled: false, value: 'timeout' }), timeoutMs); }), ]); clearTimeout(timeoutId); if (!outcome.settled) { // A missing or dead decision surface must fail the smoke quickly instead // of consuming the whole mutation shard. Cancelling also keeps later // branches independent from the timed-out controller state. card._cancelDangerConfirm(); await Promise.race([ decision, new Promise((resolve) => setTimeout(resolve, 100)), ]); await settle(); } return outcome; }; await settle(); result.standaloneStartsWithoutHaDialog = !customElements.get('ha-dialog'); const noHaDestructive = await directConfirm('destructive', 'click', 'no-ha-delete'); result.noHaDestructiveIsDescribedAlert = noHaDestructive.semantics; result.noHaDestructiveFocusesCancel = noHaDestructive.focused; result.noHaDestructiveCancelResolvesFalse = noHaDestructive.cancelled; const noHaWarning = await directConfirm('warning', 'escape', 'no-ha-unlock'); result.noHaWarningIsDescribedAlert = noHaWarning.semantics; result.noHaWarningFocusesCancel = noHaWarning.focused; result.noHaWarningEscapeResolvesFalse = noHaWarning.cancelled; // Основная ветка: ровно один диалог, а не два (риск двойного рендера при // выносе — если бы блок остался и в ветке, и в обёртке). const single = card._confirmDanger(request('single')); await settle(); result.mainBranchRendersExactlyOneConfirm = dialogs() === 1; card._cancelDangerConfirm(); const singleDecision = await boundedDangerDecision(single); result.mainBranchCancelResolvesFalse = singleDecision.settled && singleDecision.value === false; const savedCfg = card._serverCfg; const savedModel = card._model; const enterBranch = async (spaces) => { card._serverCfg = { ...savedCfg, spaces }; card._model = spaces.length ? savedModel : []; card._cfgEpoch += 1; card.requestUpdate(); await settle(); }; const leaveBranch = async () => { card._serverCfg = savedCfg; card._model = savedModel; card._cfgEpoch += 1; card.requestUpdate(); await settle(); }; // Ветка онбординга — буквальный сценарий issue: здесь живёт корзина // сохранённого плана. await enterBranch([]); result.onboardingBranchIsActuallyEntered = !root().querySelector('.stage'); const onboarding = card._confirmDanger(request('onboarding')); await settle(); result.onboardingBranchShowsConfirm = dialogs() === 1; await decide(false); const onboardingDecision = await boundedDangerDecision(onboarding); result.onboardingBranchResolves = onboardingDecision.settled && onboardingDecision.value === false; await leaveBranch(); // Открытое подтверждение переживает смену ветки: раньше оно исчезало вместе // с веткой, оставляя вызывающего с неразрешённым промисом. const survivor = card._confirmDanger(request('survivor')); await settle(); const openedInMainBranch = dialogs() === 1; await enterBranch([]); const stillOpenInOnboarding = dialogs() === 1; await decide(true); const survivorDecision = await boundedDangerDecision(survivor); result.openConfirmSurvivesBranchChange = openedInMainBranch && stillOpenInOnboarding && survivorDecision.settled && survivorDecision.value === true; await leaveBranch(); // The exact missed branch from #417: a non-empty model whose active space // cannot be resolved returns `nothing`, unlike onboarding and fixed-floor // status cards. An already open request must be cancelled during the same // update, and a new request must never enter the controller. const originalSpaceModel = card._spaceModel; const pendingAtSpaceLoss = card._confirmDanger(request('space-lost-after-open')); await settle(); card._spaceModel = () => undefined; card.requestUpdate(); await settle(); const spaceLossDecision = await boundedDangerDecision(pendingAtSpaceLoss); // The summary runtime may keep its inert lazy stylesheet in the shadow root // after render() returns `nothing`; it is not a decision or content surface. const lostSpaceContent = [...root().children] .filter((element) => !element.matches('style[data-hp-summary]')); result.lostSpaceBranchIsActuallyEntered = lostSpaceContent.length === 0; result.openConfirmCancelsWhenSpaceIsLost = spaceLossDecision.settled && spaceLossDecision.value === false && card._dangerConfirm === null && card._dangerConfirmController.state === null; const refusedWithoutSpacePromise = card._confirmDanger(request('space-already-lost')); const refusedWithoutSpaceWasNeverRegistered = card._dangerConfirm === null && card._dangerConfirmController.state === null; const refusedWithoutSpace = await boundedDangerDecision(refusedWithoutSpacePromise); result.lostSpaceRequestRefusesImmediately = refusedWithoutSpaceWasNeverRegistered && refusedWithoutSpace.settled && refusedWithoutSpace.value === false && card._dangerConfirm === null && card._dangerConfirmController.state === null && dialogs() === 0; card._spaceModel = originalSpaceModel; card.requestUpdate(); await settle(); // The two status branches called out by #402 still paint a card, so they // must keep hosting the confirmation instead of being caught by the new // lost-space guard. const originalConfig = card._config; const originalLoadOk = card._loadOk; const confirmInFixedBranch = async (loadOk, state) => { card._loadOk = loadOk; card._config = { ...originalConfig, floor: 'missing-floor-for-confirm-smoke' }; card.requestUpdate(); await settle(); const branchEntered = !!root().querySelector(`[data-fixed-floor-state="${state}"]`); const pending = card._confirmDanger(request(`fixed-${state}`)); await settle(); const shown = dialogs() === 1; await decide(false); const decision = await boundedDangerDecision(pending); return branchEntered && shown && decision.settled && decision.value === false; }; result.fixedFloorPendingStillShowsConfirm = await confirmInFixedBranch(false, 'pending'); result.fixedFloorInvalidStillShowsConfirm = await confirmInFixedBranch(true, 'invalid'); card._loadOk = originalLoadOk; card._config = originalConfig; card.requestUpdate(); await settle(); // Тап по «Отмена» — тот же путь, что и клик: на touch подтверждение обязано // спрашиваться, а не деградировать (TOUCH-SUPPORT § Safety floor). await enterBranch([]); const byTap = card._confirmDanger(request('touch')); await settle(); const tapDialog = root().querySelector('hp-confirm'); // Кнопки живут в hp-dialog внутри hp-confirm; «Отмена» — первая в футере // (она же с autofocus, см. #32 §6.1). const footer = tapDialog?.renderRoot?.querySelector('.danger-confirm-footer') || tapDialog?.shadowRoot?.querySelector('.danger-confirm-footer'); const cancelButton = footer?.querySelector('button'); const tapPoint = cancelButton?.getBoundingClientRect(); if (cancelButton && tapPoint) { for (const type of ['pointerdown', 'pointerup', 'click']) { cancelButton.dispatchEvent(new PointerEvent(type, { pointerId: 1, pointerType: 'touch', isPrimary: true, button: 0, clientX: tapPoint.left + tapPoint.width / 2, clientY: tapPoint.top + tapPoint.height / 2, bubbles: true, composed: true, cancelable: true, })); } } await settle(); const tapDecision = await boundedDangerDecision(byTap); result.touchTapOnCancelResolvesFalse = !!cancelButton && tapDecision.settled && tapDecision.value === false && dialogs() === 0; await leaveBranch(); // Соседние подтверждения остались в основной ветке и не поехали за компанию. const source = card.constructor.toString?.() || ''; result.neighbourConfirmsUntouched = card._tapConfirm === null && card._vacCalConfirm === null && typeof source === 'string'; // Неготовая карточка отказывает сразу: подвесить запрос — это и есть дефект. const savedHass = card.hass; card.hass = undefined; const refused = card._confirmDanger(request('not-ready')); card.hass = savedHass; card.requestUpdate(); await settle(); const refusedDecision = await boundedDangerDecision(refused); result.notReadyCardRefusesInsteadOfHanging = refusedDecision.settled && refusedDecision.value === false; // Match the public surface of HA's pinned ha-dialog closely enough to prove // branch selection and ARIA forwarding without depending on private shadow // DOM. Alert confirmations must still avoid it: the pinned component does // not forward its reflected `type` to the actual dialog role. class HaDialogStub extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }).innerHTML = '' + ''; } connectedCallback() { queueMicrotask(() => this.dispatchEvent(new Event('opened'))); } } customElements.define('ha-dialog', HaDialogStub); const ordinary = document.createElement('hp-dialog'); ordinary.title = 'Ordinary device editor'; ordinary.describedBy = 'ordinary-description'; ordinary.innerHTML = '

Ordinary dialog description

'; document.body.append(ordinary); await ordinary.updateComplete; await new Promise((resolve) => requestAnimationFrame(resolve)); const haShell = ordinary.shadowRoot.querySelector('ha-dialog'); result.ordinaryDialogUsesHaBranch = !!haShell && !ordinary.shadowRoot.querySelector('dialog'); result.ordinaryDialogForwardsHaAria = haShell?.ariaLabelledBy?.startsWith('hp-dialog-title-') && haShell?.ariaDescribedBy === 'ordinary-description'; ordinary.remove(); const haDestructive = await directConfirm('destructive', 'click', 'ha-delete'); result.haDestructiveStaysNativeAlert = haDestructive.semantics && haDestructive.stayedNative; result.haDestructiveFocusesCancel = haDestructive.focused; result.haDestructiveCancelResolvesFalse = haDestructive.cancelled; const haWarning = await directConfirm('warning', 'escape', 'ha-unlock'); result.haWarningStaysNativeAlert = haWarning.semantics && haWarning.stayedNative; result.haWarningFocusesCancel = haWarning.focused; result.haWarningEscapeResolvesFalse = haWarning.cancelled; return result; }); Object.assign(out, warmGate); await page.evaluate(async () => { const element = document.createElement('hp-confirm'); element.token = 4_060_000; element.request = { key: 'accessibility-probe', kind: 'destructive', title: 'Accessibility probe', message: 'All rooms in this plan will be permanently deleted.', confirmLabel: 'Delete', cancelLabel: 'Cancel', }; document.body.append(element); await element.updateComplete; await element.querySelector('hp-dialog')?.updateComplete; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); }); const accessibilitySnapshot = await page.getByRole('alertdialog', { name: 'Accessibility probe', }).ariaSnapshot(); out.realAccessibilityTreeIncludesConsequence = accessibilitySnapshot .includes('All rooms in this plan will be permanently deleted.'); await page.evaluate(() => document.querySelector('hp-confirm')?.remove()); checkAll(out); await finish(browser, out);