Files
houseplan-card/demo/smoke_danger_confirmation.mjs
Claude 70fe860bac test(smoke): danger confirmation waits for the composite language gate (#627 r1 M1)
smoke_danger_confirmation asked for a confirmation as soon as btn.cancel was
translated. With #627 the settings namespace of de/fr settles after the main
catalog; while it is pending the host gate is warm and #417 refuses the
request — the dialog never painted and the smoke crashed intermittently.

The switch now waits until the host drops aria-busy (the gate settled), and
the settings-de/fr chunks are delayed by 300 ms so the slower-namespace order
is deterministic instead of a CI-speed coincidence. New checks name the lost
dialog directly instead of a TypeError.

Issue: #627
User-Visible: no
2026-09-24 14:51:19 +03:00

275 lines
12 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.
// #32: one House Plan confirmation surface owns destructive actions in View,
// onboarding and every editor. This smoke covers the real dialog contract,
// then exercises every migrated mutation class through both lazy runtimes.
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 320, height: 760 });
// #627 r1 M1: de/fr strings of the `settings` namespace are lazy chunks that
// settle after the main catalog. While one is pending the host language gate
// is `warm`, and #417 refuses/cancels a danger request in that state — a
// request made as soon as `btn.cancel` is translated used to be lost silently
// whenever the namespace chunk was the slower one. The delay makes that order
// deterministic instead of a CI-speed coincidence.
const manifest = JSON.parse(readFileSync('dist/houseplan-assets.json', 'utf8'));
const delayedNamespaceChunks = ['de', 'fr'].map((language) => {
const name = (manifest.lazyNamespaceLocaleFiles || []).map((path) => path.split('/').at(-1))
.find((candidate) => candidate.startsWith(`settings-${language}-`));
if (!name) throw new Error(`namespace chunk settings-${language} is absent from the manifest`);
return name;
});
let delayedNamespaceServed = 0;
for (const name of delayedNamespaceChunks) {
await page.route(`**/${name}*`, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 300));
delayedNamespaceServed++;
await route.fallback();
});
}
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
await card._ensureOnboardingRuntime();
const editor = card._editorRuntime;
const onboarding = card._onboardingRuntime;
const root = () => card.shadowRoot || card.renderRoot;
const settle = async () => {
await card.updateComplete;
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
};
const deepActive = () => {
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
};
const request = (key = 'delete-plan') => ({
key,
kind: 'destructive',
title: card._t('confirm.delete_plan_title'),
message: card._t('confirm.delete_plan_body'),
objectName: 'A deliberately very long plan filename — Grundriss für das Dachgeschoss.png',
confirmLabel: card._t('btn.delete'),
cancelLabel: card._t('btn.cancel'),
});
const visible = () => {
const component = root().querySelector('hp-confirm');
const dialog = component?.querySelector('hp-dialog');
return {
component,
dialog,
native: dialog?.shadowRoot?.querySelector('dialog'),
surface: dialog?.shadowRoot?.querySelector('.surface'),
footer: dialog?.querySelector('.danger-confirm-footer'),
buttons: [...(dialog?.querySelectorAll('.danger-confirm-footer button') || [])],
};
};
const opener = document.createElement('button');
opener.textContent = 'danger action';
root().append(opener);
opener.focus();
const cancelled = card._confirmDanger(request());
await settle();
let ui = visible();
result.sharedDialogVisible = !!ui.component && !!ui.native?.open
&& ui.dialog?.title === card._t('confirm.delete_plan_title');
result.cancelHasInitialFocus = deepActive() === ui.buttons[0]
&& ui.buttons[0]?.hasAttribute('autofocus');
result.narrowContentFits = !!ui.surface && !!ui.footer
&& ui.surface.getBoundingClientRect().left >= -1
&& ui.surface.getBoundingClientRect().right <= innerWidth + 1
&& ui.surface.scrollWidth <= ui.surface.clientWidth + 1
&& ui.footer.scrollWidth <= ui.footer.clientWidth + 1
&& ui.buttons.every((button) => button.getBoundingClientRect().right <= innerWidth + 1);
ui.buttons[0].click();
result.cancelButtonIsSafe = await cancelled === false;
await settle();
result.cancelRestoresFocus = deepActive() === opener;
const accepted = card._confirmDanger(request());
await settle();
ui = visible();
ui.buttons[1].click();
result.acceptButtonResolvesOnce = await accepted === true;
const escaped = card._confirmDanger(request('escape'));
await settle();
deepActive()?.dispatchEvent(new KeyboardEvent('keydown', {
key: 'Escape', bubbles: true, composed: true, cancelable: true,
}));
result.escapeIsSafe = await escaped === false;
const closed = card._confirmDanger(request('close'));
await settle();
visible().dialog.shadowRoot.querySelector('.close').click();
result.closeIsSafe = await closed === false;
const scrimmed = card._confirmDanger(request('scrim'));
await settle();
const native = visible().native;
native.dispatchEvent(new MouseEvent('click', { bubbles: true }));
result.scrimIsSafe = await scrimmed === false;
const first = card._confirmDanger(request('first'));
const second = card._confirmDanger(request('second'));
await settle();
visible().buttons[1].click();
result.replacementCancelsOldRequest = await first === false && await second === true;
const localeFits = async (language, expectedCancel) => {
card._config = { ...(card._config || {}), language };
card.requestUpdate();
await settle();
// #627 r1 M1: the translated main catalog is not the whole language. The
// live switch holds the previous frame (inert + aria-busy) until every
// dictionary of the loaded surface settles; only then may a caller ask.
for (let attempt = 0; attempt < 250
&& (card._t('btn.cancel') !== expectedCancel || card.hasAttribute('aria-busy')); attempt++) {
await new Promise((resolve) => setTimeout(resolve, 10));
await settle();
}
result[`${language}GateSettledBeforeAsking`] = card._t('btn.cancel') === expectedCancel
&& !card.hasAttribute('aria-busy');
const pending = card._confirmDanger(request(`locale-${language}`));
await settle();
const current = visible();
result[`${language}DialogShown`] = !!current.native?.open && !!current.buttons[0];
const fits = current.buttons[0]?.textContent.trim() === expectedCancel
&& !!current.surface && !!current.footer
&& current.surface.scrollWidth <= current.surface.clientWidth + 1
&& current.footer.scrollWidth <= current.footer.clientWidth + 1;
current.buttons[0]?.click();
result[`${language}CancelResolvesFalse`] = await pending === false;
return fits;
};
result.germanNarrowFits = await localeFits('de', 'Abbrechen');
result.frenchNarrowFits = await localeFits('fr', 'Annuler');
opener.remove();
// From here the UI promise is replaced with deterministic decisions so the
// action matrix can prove mutation ownership independently of layout.
const originalConfirm = card._confirmDanger;
let decision = false;
card._confirmDanger = async () => decision;
const baseSpace = () => ({
id: 'danger-space', title: 'Danger space', cell_cm: 5,
view_box: [0, 0, 1, 1], rooms: [], settings: {},
});
let configSaves = 0;
editor._saveConfigNow = async () => { configSaves++; };
card._serverCfg = {
spaces: [baseSpace()], settings: {},
markers: [{ id: 'danger-marker', binding: 'entity:switch.danger', space: 'danger-space' }],
};
card._devices = [];
// #404 AC5: диалог собирает сам продукт, а не фикстура.
//
// Ручная фикстура врала: у объявленного типа больше сорока обязательных
// полей, и рендер падал на первом же недостающем — сначала
// `_bindingHasHaPage(undefined).split(':')`, а после добавления binding и
// bindingMode на следующем. Дописывать поля по одному значит гоняться за
// типом; штатный путь «новый маркер» отдаёт полный объект с дефолтами
// продукта, и переопределить остаётся ровно то, что нужно этой проверке.
//
// Дефекта поведения здесь нет и не было: все 15 мест в src/, создающих
// диалог, идут этим же путём. Врал только смок — и гард этого не видел.
editor._openMarkerDialog();
card._markerDialog = {
...card._markerDialog,
devId: 'danger-marker', name: 'Danger marker',
binding: 'entity:switch.danger', bindingMode: 'ha', busy: false,
};
decision = false;
await editor._deleteMarker();
const markerCancel = card._serverCfg.markers.length === 1 && configSaves === 0;
decision = true;
await editor._deleteMarker();
result.markerCancelAccept = markerCancel && card._serverCfg.markers.length === 1
&& card._serverCfg.markers[0].id === 'danger-marker'
&& card._serverCfg.markers[0].removed === true
&& card._serverCfg.markers[0].hidden === true && configSaves === 1;
const wsCalls = [];
card.hass = {
...card.hass,
callWS: async (message) => {
wsCalls.push(message);
if (message.type === 'houseplan/config/get') return { config: card._serverCfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: card._layout, rev: 1 };
return {};
},
};
const setPlanDialog = () => {
card._spaceDialog = {
mode: 'edit', spaceId: 'danger-space', title: 'Danger space', planUrl: null,
planFile: null, source: 'draw', showBorders: true, showNames: true,
zeroWallStyle: 'dashed', displayTouched: true, hideDecor: false,
hideOpenings: false, roomColor: '#888888', roomOpacity: 1, bgColor: null,
bgMode: null, northDeg: null, sunRays: null, fillMode: 'custom', customFill: null,
glowEnabled: true, tempMin: 15, tempMax: 30, showLqi: true, cardFontScale: 1,
labelTemp: true, labelHum: true, labelLqi: true, labelLight: true, cellCm: 5,
busy: false, saved: [{ name: 'old.png', url: '/old.png', size: 1, modified: 1, used_by: [] }],
};
};
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
setPlanDialog();
decision = false;
const before = wsCalls.length;
await runtime._deleteServerPlan('old.png');
const cancelSafe = wsCalls.length === before;
decision = true;
await runtime._deleteServerPlan('old.png');
result[`${name}PlanCancelAccept`] = cancelSafe
&& wsCalls.filter((call) => call.type === 'houseplan/plans/delete').length
=== (name === 'editor' ? 1 : 2);
}
card._saveConfigDebounced.cancel();
card._persistLayout.cancel();
card._writeChain = Promise.resolve();
// #500: the delete path re-reads and adopts through the one gated entry; keep it inert here.
card._adoptAuthoritative = async () => ({ status: 'adopted', spaceChanged: false });
for (const [name, runtime] of [['editor', editor], ['onboarding', onboarding]]) {
card._space = 'another-space';
card._serverCfg = { spaces: [baseSpace()], markers: [], settings: {} };
setPlanDialog();
decision = false;
const before = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length;
await runtime._deleteSpace();
const cancelSafe = wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before;
decision = true;
await runtime._deleteSpace();
result[`${name}SpaceCancelAccept`] = cancelSafe
&& wsCalls.filter((call) => call.type === 'houseplan/space/delete').length === before + 1;
}
const services = [];
card._openingEntityAvailable = () => true;
card._openingInfo = {
id: 'door', type: 'door', rx: 0.5, ry: 0.5, len_cm: 90, lock: 'lock.danger',
};
card.hass = {
...card.hass,
states: {
...card.hass.states,
'lock.danger': { entity_id: 'lock.danger', state: 'locked', attributes: { friendly_name: 'Door' } },
},
callService: async (...args) => { services.push(args); },
};
decision = false;
await card._lockAction('lock.danger', 'unlock');
const unlockCancel = services.length === 0;
decision = true;
await card._lockAction('lock.danger', 'unlock');
result.unlockCancelAccept = unlockCancel && services.length === 1
&& services[0][0] === 'lock' && services[0][1] === 'unlock';
card._confirmDanger = originalConfirm;
return result;
});
// The delay is only proof if the delayed chunks were really on the path.
out.delayedNamespaceChunksServed = delayedNamespaceServed === delayedNamespaceChunks.length;
checkAll(out);
await finish(browser, out);