fix: harden summary panel settings and writes (#493)

Issue: #493
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-09 02:53:36 +03:00
parent 0bce4b1123
commit 578cae5240
73 changed files with 2849 additions and 1380 deletions
+224
View File
@@ -0,0 +1,224 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SUMMARY_PANEL_API_VERSION } from '../test-build/summary-panel-api.js';
import { summaryLocalKey } from '../test-build/summary-panel.js';
import { LoadedSummaryPanelRuntime } from '../test-build/summary-panel-runtime-loaded.js';
const installBrowserGlobals = () => {
const previous = {
location: Object.getOwnPropertyDescriptor(globalThis, 'location'),
localStorage: Object.getOwnPropertyDescriptor(globalThis, 'localStorage'),
};
const values = new Map();
Object.defineProperty(globalThis, 'location', {
configurable: true, value: { pathname: '/dashboard/home' },
});
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
},
});
return {
values,
restore() {
for (const [key, descriptor] of Object.entries(previous)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete globalThis[key];
}
},
};
};
const hostFixture = () => {
const root = {
adoptedStyleSheets: [], firstChild: null,
querySelector: () => null,
querySelectorAll: () => [],
insertBefore: () => undefined,
};
return {
localName: 'houseplan-card', parentNode: null,
hass: { user: { id: 'alice', name: 'Alice', is_admin: true }, states: {}, config: {} },
panelHost: true, narrow: false, renderRoot: root,
ownerDocument: {
defaultView: undefined, visibilityState: 'visible',
createElement: () => ({ dataset: {}, textContent: '' }),
},
isConnected: true, updateComplete: Promise.resolve(), requestUpdate: () => undefined,
_mode: 'view', _space: '', _settings: {}, _config: {}, _model: [], _markers: [],
_serverCfg: { spaces: [], markers: [], settings: {} }, _cfgRev: 0, _cfgEpoch: 0,
_layoutRev: 0, _cfgContentFingerprint: '', _regSignature: '',
_signer: { prepareImage: async () => true },
_continuity: { hasCompleteFrame: false, state: 'steady', note: () => undefined },
_writesPending: 0, _writeChain: Promise.resolve(), _kiosk: false,
_kioskScale: { icon: 1, font: 1 }, _haSummaryPanelApi: SUMMARY_PANEL_API_VERSION,
_canManageConfiguration: true,
_haRegistry: { authoritative: true, revision: 0, devices: {}, entities: {} },
_areaToSpace: {}, _stageEl: null,
_t: (key) => key, _errText: (error) => String(error),
_confirmDanger: async () => true, _sendConfigCandidate: async () => undefined,
_getAuthoritativeConfig: async () => ({ config: { spaces: [], markers: [], settings: {} }, rev: 0 }),
_candidateBackdrop: () => '', _scheduleLoadRetry: () => undefined,
_beginContinuityCandidate: () => 0, _adoptStructuralResponses: () => ({ configChanged: false }),
_syncDecorAssets: async () => undefined, _adoptInitialSpace: () => undefined,
_maybeRebuildDevices: () => undefined, _resumePendingNavMode: () => false,
_restoreZoom: () => undefined, _reloadConfigOnly: async () => undefined,
_cacheSnapshot: () => undefined,
};
};
test('#493 same-key scale remains authoritative and identity changes discard the dialog', async () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
const key = summaryLocalKey({
userId: 'alice', path: '/dashboard/home', host: 'panel', slot: 'houseplan-card',
});
browser.values.set(key, JSON.stringify({
version: 1, show: true, icon_scale: 2, font_scale: 1.5,
}));
const runtime = new LoadedSummaryPanelRuntime(host);
assert.equal(runtime.applyLocalScaleForCurrentIdentity(), true);
assert.deepEqual(host._kioskScale, { icon: 2, font: 1.5 });
host._kioskScale = { icon: 1, font: 1 };
assert.equal(runtime.applyLocalScaleForCurrentIdentity(), true);
assert.deepEqual(host._kioskScale, { icon: 2, font: 1.5 });
runtime.connect();
await runtime.openDialog();
assert.equal(runtime.dialogOpen, true);
host.hass.user = { id: 'bob', name: 'Bob', is_admin: false };
host._canManageConfiguration = false;
runtime.updated();
assert.equal(runtime.dialogOpen, false);
assert.deepEqual(host._kioskScale, { icon: 1, font: 1 });
await runtime.openDialog();
assert.equal(runtime.dialogOpen, true);
host._kiosk = true;
runtime.updated();
assert.equal(runtime.dialogOpen, false, 'kiosk capability change discards the shared draft');
host._kiosk = false;
await runtime.openDialog();
runtime.leaveRoute();
assert.equal(runtime.dialogOpen, false, 'route leave discards the draft');
await runtime.openDialog();
runtime.disconnect();
assert.equal(runtime.dialogOpen, false);
runtime.connect();
await runtime.openDialog();
assert.equal(runtime.dialogOpen, true, 'reconnect starts a fresh dialog');
} finally {
browser.restore();
}
});
test('#493 active picker keeps stable value ownership across reorder and closes on delete', async () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
const runtime = new LoadedSummaryPanelRuntime(host);
runtime.connect();
await runtime.openDialog();
const block = runtime.dialog.draft.blocks[0];
const targetId = block.values[0].id;
const otherId = block.values[1].id;
runtime.openSource(block.id, targetId);
runtime.mutate((draft) => { draft.blocks[0].values.reverse(); });
runtime.setSource(block.id, targetId, 'entity:sensor.target');
const target = runtime.dialog.draft.blocks[0].values.find((value) => value.id === targetId);
assert.deepEqual(target.source, { type: 'entity', entity_id: 'sensor.target' });
assert.equal(runtime.dialog.draft.blocks[0].values.at(-1).id, targetId,
'the active owner must follow the stable id after reorder');
runtime.openSource(block.id, otherId);
runtime.mutate((draft) => {
draft.blocks[0].values = draft.blocks[0].values.filter((value) => value.id !== otherId);
});
assert.equal(runtime.dialog.activeSource, null);
} finally {
browser.restore();
}
});
test('#493 a late save completion cannot adopt UI state into a new user generation', async () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
let releaseWrite;
let noteSent;
const sent = new Promise((resolve) => { noteSent = resolve; });
host._sendConfigCandidate = async () => {
noteSent();
await new Promise((resolve) => { releaseWrite = resolve; });
};
const runtime = new LoadedSummaryPanelRuntime(host);
runtime.connect();
await runtime.openDialog();
runtime.dialog.draft.title = 'Old user draft';
const saving = runtime.saveDialog();
await sent;
host.hass.user = { id: 'bob', name: 'Bob', is_admin: false };
host._canManageConfiguration = false;
runtime.updated();
releaseWrite();
await saving;
assert.equal(runtime.dialogOpen, false);
assert.equal(host._serverCfg.settings.summary_panel, undefined,
'the server may accept an in-flight write, but the new UI must reload authority');
assert.equal(host._writesPending, 0);
} finally {
browser.restore();
}
});
test('#493 late reload and confirmation completions stay outside a new lifecycle', async () => {
const browser = installBrowserGlobals();
try {
const host = hostFixture();
let releaseReload;
let reloadStarted;
host._reloadConfigOnly = async () => {
reloadStarted();
await new Promise((resolve) => { releaseReload = resolve; });
};
const runtime = new LoadedSummaryPanelRuntime(host);
runtime.connect();
await runtime.openDialog();
const started = new Promise((resolve) => { reloadStarted = resolve; });
const reloading = runtime.reloadDialog();
await started;
host._canManageConfiguration = false;
runtime.updated();
releaseReload();
await reloading;
assert.equal(runtime.dialogOpen, false);
host._canManageConfiguration = true;
runtime.updated();
await runtime.openDialog();
let releaseConfirm;
let confirmationStarted;
host._confirmDanger = async () => {
confirmationStarted();
return new Promise((resolve) => { releaseConfirm = resolve; });
};
const confirming = new Promise((resolve) => { confirmationStarted = resolve; });
const deleting = runtime.deleteBlock(0);
await confirming;
host.hass.user = { id: 'bob', name: 'Bob', is_admin: false };
host._canManageConfiguration = false;
runtime.updated();
releaseConfirm(true);
await deleting;
assert.equal(runtime.dialogOpen, false);
} finally {
browser.restore();
}
});