mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(panels): keep desktop runtime config alert hidden when disabled (#1679)
* fix(panels): keep desktop runtime config alert hidden when disabled * refactor(panels): let render() own visibility in alert-mode show() Removes the unconditional setEffectiveVisibility(true) before render() in the show() override. For alert mode, render() already decides visibility based on config state — calling setEffectiveVisibility(true) first meant the element briefly appeared then was immediately re-hidden for fully- configured panels. Non-alert mode is unchanged. --------- Co-authored-by: Elie Habib <elie.habib@gmail.com>
This commit is contained in:
@@ -34,6 +34,7 @@ export class RuntimeConfigPanel extends Panel {
|
||||
private readonly mode: 'full' | 'alert';
|
||||
private readonly buffered: boolean;
|
||||
private readonly featureFilter?: RuntimeFeatureId[];
|
||||
private hiddenByUser = false;
|
||||
private pendingSecrets = new Map<RuntimeSecretKey, string>();
|
||||
private validatedKeys = new Map<RuntimeSecretKey, boolean>();
|
||||
private validationMessages = new Map<RuntimeSecretKey, string>();
|
||||
@@ -157,6 +158,25 @@ export class RuntimeConfigPanel extends Panel {
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
private setEffectiveVisibility(visible: boolean): void {
|
||||
if (visible) super.show();
|
||||
else super.hide();
|
||||
}
|
||||
|
||||
public override show(): void {
|
||||
this.hiddenByUser = false;
|
||||
if (this.mode === 'alert') {
|
||||
this.render();
|
||||
} else {
|
||||
this.setEffectiveVisibility(true);
|
||||
}
|
||||
}
|
||||
|
||||
public override hide(): void {
|
||||
this.hiddenByUser = true;
|
||||
this.setEffectiveVisibility(false);
|
||||
}
|
||||
|
||||
private captureUnsavedInputs(): void {
|
||||
if (!this.buffered) return;
|
||||
this.content.querySelectorAll<HTMLInputElement>('input[data-secret]').forEach((input) => {
|
||||
@@ -194,24 +214,30 @@ export class RuntimeConfigPanel extends Panel {
|
||||
const features = this.getFilteredFeatures();
|
||||
|
||||
if (desktop && this.mode === 'alert') {
|
||||
if (this.hiddenByUser) {
|
||||
this.setEffectiveVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalFeatures = RUNTIME_FEATURES.length;
|
||||
const availableFeatures = RUNTIME_FEATURES.filter((feature) => isFeatureAvailable(feature.id)).length;
|
||||
const missingFeatures = Math.max(0, totalFeatures - availableFeatures);
|
||||
const configuredCount = Object.keys(snapshot.secrets).length;
|
||||
const alertState = configuredCount > 0
|
||||
? (missingFeatures > 0 ? 'some' : 'configured')
|
||||
: 'needsKeys';
|
||||
|
||||
if (missingFeatures === 0 && configuredCount >= totalFeatures) {
|
||||
this.hide();
|
||||
this.setEffectiveVisibility(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const alertTitle = configuredCount > 0
|
||||
? (missingFeatures > 0 ? t('modals.runtimeConfig.alertTitle.some') : t('modals.runtimeConfig.alertTitle.configured'))
|
||||
: t('modals.runtimeConfig.alertTitle.needsKeys');
|
||||
const alertTitle = t(`modals.runtimeConfig.alertTitle.${alertState}`);
|
||||
const alertClass = missingFeatures > 0 ? 'warn' : 'ok';
|
||||
|
||||
this.show();
|
||||
this.setEffectiveVisibility(true);
|
||||
this.content.innerHTML = `
|
||||
<section class="runtime-alert runtime-alert-${alertClass}">
|
||||
<section class="runtime-alert runtime-alert-${alertClass}" data-alert-state="${alertState}">
|
||||
<h3>${alertTitle}</h3>
|
||||
<p>
|
||||
${availableFeatures}/${totalFeatures} ${t('modals.runtimeConfig.summary.available')}${configuredCount > 0 ? ` · ${configuredCount} ${t('modals.runtimeConfig.summary.secrets')}` : ''}.
|
||||
|
||||
642
tests/helpers/runtime-config-panel-harness.mjs
Normal file
642
tests/helpers/runtime-config-panel-harness.mjs
Normal file
@@ -0,0 +1,642 @@
|
||||
import { build } from 'esbuild';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(__dirname, '..', '..');
|
||||
const entry = resolve(root, 'src/components/RuntimeConfigPanel.ts');
|
||||
|
||||
class MiniClassList {
|
||||
constructor() {
|
||||
this.values = new Set();
|
||||
}
|
||||
|
||||
add(...tokens) {
|
||||
tokens.forEach((token) => this.values.add(token));
|
||||
}
|
||||
|
||||
remove(...tokens) {
|
||||
tokens.forEach((token) => this.values.delete(token));
|
||||
}
|
||||
|
||||
contains(token) {
|
||||
return this.values.has(token);
|
||||
}
|
||||
|
||||
toggle(token, force) {
|
||||
if (force === true) {
|
||||
this.values.add(token);
|
||||
return true;
|
||||
}
|
||||
if (force === false) {
|
||||
this.values.delete(token);
|
||||
return false;
|
||||
}
|
||||
if (this.values.has(token)) {
|
||||
this.values.delete(token);
|
||||
return false;
|
||||
}
|
||||
this.values.add(token);
|
||||
return true;
|
||||
}
|
||||
|
||||
setFromString(value) {
|
||||
this.values = new Set(String(value).split(/\s+/).filter(Boolean));
|
||||
}
|
||||
|
||||
toString() {
|
||||
return Array.from(this.values).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
class MiniNode extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.childNodes = [];
|
||||
this.parentNode = null;
|
||||
this.parentElement = null;
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
if (child instanceof MiniDocumentFragment) {
|
||||
const children = [...child.childNodes];
|
||||
children.forEach((node) => this.appendChild(node));
|
||||
return child;
|
||||
}
|
||||
if (child.parentNode) {
|
||||
child.parentNode.removeChild(child);
|
||||
}
|
||||
child.parentNode = this;
|
||||
child.parentElement = this instanceof MiniElement ? this : null;
|
||||
this.childNodes.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
const index = this.childNodes.indexOf(child);
|
||||
if (index >= 0) {
|
||||
this.childNodes.splice(index, 1);
|
||||
child.parentNode = null;
|
||||
child.parentElement = null;
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child, referenceNode) {
|
||||
if (referenceNode == null) {
|
||||
return this.appendChild(child);
|
||||
}
|
||||
if (child.parentNode) {
|
||||
child.parentNode.removeChild(child);
|
||||
}
|
||||
const index = this.childNodes.indexOf(referenceNode);
|
||||
if (index === -1) {
|
||||
return this.appendChild(child);
|
||||
}
|
||||
child.parentNode = this;
|
||||
child.parentElement = this instanceof MiniElement ? this : null;
|
||||
this.childNodes.splice(index, 0, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
get firstChild() {
|
||||
return this.childNodes[0] ?? null;
|
||||
}
|
||||
|
||||
get lastChild() {
|
||||
return this.childNodes.at(-1) ?? null;
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
return this.childNodes.map((child) => child.textContent ?? '').join('');
|
||||
}
|
||||
|
||||
set textContent(value) {
|
||||
this.childNodes = [new MiniText(value ?? '')];
|
||||
}
|
||||
}
|
||||
|
||||
class MiniText extends MiniNode {
|
||||
constructor(value) {
|
||||
super();
|
||||
this.value = String(value);
|
||||
}
|
||||
|
||||
get textContent() {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
set textContent(value) {
|
||||
this.value = String(value);
|
||||
}
|
||||
|
||||
get outerHTML() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
class MiniDocumentFragment extends MiniNode {
|
||||
get outerHTML() {
|
||||
return this.childNodes.map((child) => child.outerHTML ?? child.textContent ?? '').join('');
|
||||
}
|
||||
}
|
||||
|
||||
class MiniElement extends MiniNode {
|
||||
constructor(tagName) {
|
||||
super();
|
||||
this.tagName = tagName.toUpperCase();
|
||||
this.attributes = new Map();
|
||||
this.classList = new MiniClassList();
|
||||
this.dataset = {};
|
||||
this.style = {};
|
||||
this._innerHTML = '';
|
||||
this.id = '';
|
||||
this.title = '';
|
||||
this.disabled = false;
|
||||
}
|
||||
|
||||
get className() {
|
||||
return this.classList.toString();
|
||||
}
|
||||
|
||||
set className(value) {
|
||||
this.classList.setFromString(value);
|
||||
}
|
||||
|
||||
get innerHTML() {
|
||||
if (this._innerHTML) return this._innerHTML;
|
||||
return this.childNodes.map((child) => child.outerHTML ?? child.textContent ?? '').join('');
|
||||
}
|
||||
|
||||
set innerHTML(value) {
|
||||
this._innerHTML = String(value);
|
||||
this.childNodes = [];
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
this._innerHTML = '';
|
||||
return super.appendChild(child);
|
||||
}
|
||||
|
||||
insertBefore(child, referenceNode) {
|
||||
this._innerHTML = '';
|
||||
return super.insertBefore(child, referenceNode);
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
this._innerHTML = '';
|
||||
return super.removeChild(child);
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
const stringValue = String(value);
|
||||
this.attributes.set(name, stringValue);
|
||||
if (name === 'class') {
|
||||
this.className = stringValue;
|
||||
} else if (name === 'id') {
|
||||
this.id = stringValue;
|
||||
} else if (name.startsWith('data-')) {
|
||||
const key = name
|
||||
.slice(5)
|
||||
.split('-')
|
||||
.map((part, index) => (index === 0 ? part : `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`))
|
||||
.join('');
|
||||
this.dataset[key] = stringValue;
|
||||
}
|
||||
}
|
||||
|
||||
getAttribute(name) {
|
||||
return this.attributes.get(name) ?? null;
|
||||
}
|
||||
|
||||
hasAttribute(name) {
|
||||
return this.attributes.has(name);
|
||||
}
|
||||
|
||||
removeAttribute(name) {
|
||||
this.attributes.delete(name);
|
||||
if (name === 'class') this.className = '';
|
||||
}
|
||||
|
||||
querySelector() {
|
||||
return null;
|
||||
}
|
||||
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
|
||||
closest() {
|
||||
return null;
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (this.parentNode) {
|
||||
this.parentNode.removeChild(this);
|
||||
}
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { width: 1, height: 1, top: 0, left: 0, right: 1, bottom: 1 };
|
||||
}
|
||||
|
||||
get nextElementSibling() {
|
||||
if (!this.parentNode) return null;
|
||||
const siblings = this.parentNode.childNodes.filter((child) => child instanceof MiniElement);
|
||||
const index = siblings.indexOf(this);
|
||||
return index >= 0 ? siblings[index + 1] ?? null : null;
|
||||
}
|
||||
|
||||
get isConnected() {
|
||||
let current = this.parentNode;
|
||||
while (current) {
|
||||
if (current === globalThis.document?.body || current === globalThis.document?.documentElement) {
|
||||
return true;
|
||||
}
|
||||
current = current.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get outerHTML() {
|
||||
return `<${this.tagName.toLowerCase()}>${this.innerHTML}</${this.tagName.toLowerCase()}>`;
|
||||
}
|
||||
}
|
||||
|
||||
class MiniStorage {
|
||||
constructor() {
|
||||
this.values = new Map();
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.values.has(key) ? this.values.get(key) : null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.values.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
this.values.delete(key);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.values.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class MiniDocument extends EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.documentElement = new MiniElement('html');
|
||||
this.documentElement.clientHeight = 800;
|
||||
this.documentElement.clientWidth = 1200;
|
||||
this.body = new MiniElement('body');
|
||||
this.documentElement.appendChild(this.body);
|
||||
}
|
||||
|
||||
createElement(tagName) {
|
||||
return new MiniElement(tagName);
|
||||
}
|
||||
|
||||
createTextNode(value) {
|
||||
return new MiniText(value);
|
||||
}
|
||||
|
||||
createDocumentFragment() {
|
||||
return new MiniDocumentFragment();
|
||||
}
|
||||
}
|
||||
|
||||
function createBrowserEnvironment() {
|
||||
const document = new MiniDocument();
|
||||
const localStorage = new MiniStorage();
|
||||
const window = {
|
||||
document,
|
||||
localStorage,
|
||||
innerHeight: 800,
|
||||
innerWidth: 1200,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
open() {},
|
||||
getComputedStyle() {
|
||||
return {
|
||||
display: '',
|
||||
visibility: '',
|
||||
gridTemplateColumns: 'none',
|
||||
columnGap: '0',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
document,
|
||||
localStorage,
|
||||
window,
|
||||
requestAnimationFrame() {
|
||||
return 1;
|
||||
},
|
||||
cancelAnimationFrame() {},
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotGlobal(name) {
|
||||
return {
|
||||
exists: Object.prototype.hasOwnProperty.call(globalThis, name),
|
||||
value: globalThis[name],
|
||||
};
|
||||
}
|
||||
|
||||
function restoreGlobal(name, snapshot) {
|
||||
if (snapshot.exists) {
|
||||
globalThis[name] = snapshot.value;
|
||||
return;
|
||||
}
|
||||
delete globalThis[name];
|
||||
}
|
||||
|
||||
function createRuntimeState() {
|
||||
return {
|
||||
features: [],
|
||||
availableIds: new Set(),
|
||||
configuredCount: 0,
|
||||
listeners: new Set(),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRuntimeConfigPanel() {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'wm-runtime-config-panel-'));
|
||||
const outfile = join(tempDir, 'RuntimeConfigPanel.bundle.mjs');
|
||||
|
||||
const stubModules = new Map([
|
||||
['runtime-config-stub', `
|
||||
const state = globalThis.__wmRuntimeConfigPanelTestState;
|
||||
|
||||
export const RUNTIME_FEATURES = state.features;
|
||||
|
||||
export function getEffectiveSecrets() {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getRuntimeConfigSnapshot() {
|
||||
const secrets = Object.fromEntries(
|
||||
Array.from({ length: state.configuredCount }, (_, index) => [
|
||||
'SECRET_' + (index + 1),
|
||||
{ value: 'set', source: 'vault' },
|
||||
]),
|
||||
);
|
||||
return { featureToggles: {}, secrets };
|
||||
}
|
||||
|
||||
export function getSecretState() {
|
||||
return { present: false, valid: false, source: 'missing' };
|
||||
}
|
||||
|
||||
export function isFeatureAvailable(featureId) {
|
||||
return state.availableIds.has(featureId);
|
||||
}
|
||||
|
||||
export function isFeatureEnabled() {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setFeatureToggle() {}
|
||||
|
||||
export async function setSecretValue() {}
|
||||
|
||||
export function subscribeRuntimeConfig(listener) {
|
||||
state.listeners.add(listener);
|
||||
return () => state.listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function validateSecret() {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
export async function verifySecretWithApi() {
|
||||
return { valid: true };
|
||||
}
|
||||
`],
|
||||
['runtime-stub', `export function isDesktopRuntime() { return true; }`],
|
||||
['tauri-bridge-stub', `export async function invokeTauri() {}`],
|
||||
['i18n-stub', `export function t(key) { return key; }`],
|
||||
['dom-utils-stub', `
|
||||
function append(parent, child) {
|
||||
if (child == null || child === false) return;
|
||||
if (typeof child === 'string' || typeof child === 'number') {
|
||||
parent.appendChild(document.createTextNode(String(child)));
|
||||
return;
|
||||
}
|
||||
parent.appendChild(child);
|
||||
}
|
||||
|
||||
export function h(tag, propsOrChild, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
let allChildren = children;
|
||||
|
||||
if (
|
||||
propsOrChild != null &&
|
||||
typeof propsOrChild === 'object' &&
|
||||
!('tagName' in propsOrChild) &&
|
||||
!('textContent' in propsOrChild)
|
||||
) {
|
||||
for (const [key, value] of Object.entries(propsOrChild)) {
|
||||
if (value == null || value === false) continue;
|
||||
if (key === 'className') {
|
||||
el.className = value;
|
||||
} else if (key === 'style' && typeof value === 'object') {
|
||||
Object.assign(el.style, value);
|
||||
} else if (key === 'dataset' && typeof value === 'object') {
|
||||
Object.assign(el.dataset, value);
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
el.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else if (value === true) {
|
||||
el.setAttribute(key, '');
|
||||
} else {
|
||||
el.setAttribute(key, String(value));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
allChildren = [propsOrChild, ...children];
|
||||
}
|
||||
|
||||
allChildren.forEach((child) => append(el, child));
|
||||
return el;
|
||||
}
|
||||
|
||||
export function replaceChildren(el, ...children) {
|
||||
el.innerHTML = '';
|
||||
children.forEach((child) => append(el, child));
|
||||
}
|
||||
|
||||
export function safeHtml() {
|
||||
return document.createDocumentFragment();
|
||||
}
|
||||
`],
|
||||
['analytics-stub', `export function trackPanelResized() {} export function trackFeatureToggle() {}`],
|
||||
['ai-flow-settings-stub', `export function getAiFlowSettings() { return { badgeAnimation: false }; }`],
|
||||
['sanitize-stub', `export function escapeHtml(value) { return String(value); }`],
|
||||
['ollama-models-stub', `export async function fetchOllamaModels() { return []; }`],
|
||||
['settings-constants-stub', `
|
||||
export const SIGNUP_URLS = {};
|
||||
export const PLAINTEXT_KEYS = new Set();
|
||||
export const MASKED_SENTINEL = '***';
|
||||
`],
|
||||
]);
|
||||
|
||||
const aliasMap = new Map([
|
||||
['@/services/runtime-config', 'runtime-config-stub'],
|
||||
['../services/runtime', 'runtime-stub'],
|
||||
['@/services/runtime', 'runtime-stub'],
|
||||
['../services/tauri-bridge', 'tauri-bridge-stub'],
|
||||
['@/services/tauri-bridge', 'tauri-bridge-stub'],
|
||||
['../services/i18n', 'i18n-stub'],
|
||||
['@/services/i18n', 'i18n-stub'],
|
||||
['../utils/dom-utils', 'dom-utils-stub'],
|
||||
['@/services/analytics', 'analytics-stub'],
|
||||
['@/services/ai-flow-settings', 'ai-flow-settings-stub'],
|
||||
['@/utils/sanitize', 'sanitize-stub'],
|
||||
['@/services/ollama-models', 'ollama-models-stub'],
|
||||
['@/services/settings-constants', 'settings-constants-stub'],
|
||||
]);
|
||||
|
||||
const plugin = {
|
||||
name: 'runtime-config-panel-test-stubs',
|
||||
setup(buildApi) {
|
||||
buildApi.onResolve({ filter: /.*/ }, (args) => {
|
||||
const target = aliasMap.get(args.path);
|
||||
return target ? { path: target, namespace: 'stub' } : null;
|
||||
});
|
||||
|
||||
buildApi.onLoad({ filter: /.*/, namespace: 'stub' }, (args) => ({
|
||||
contents: stubModules.get(args.path),
|
||||
loader: 'js',
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [entry],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'browser',
|
||||
target: 'es2020',
|
||||
write: false,
|
||||
plugins: [plugin],
|
||||
});
|
||||
|
||||
writeFileSync(outfile, result.outputFiles[0].text, 'utf8');
|
||||
|
||||
const mod = await import(`${pathToFileURL(outfile).href}?t=${Date.now()}`);
|
||||
return {
|
||||
RuntimeConfigPanel: mod.RuntimeConfigPanel,
|
||||
cleanupBundle() {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRuntimeConfigPanelHarness() {
|
||||
const originalGlobals = {
|
||||
document: snapshotGlobal('document'),
|
||||
window: snapshotGlobal('window'),
|
||||
localStorage: snapshotGlobal('localStorage'),
|
||||
requestAnimationFrame: snapshotGlobal('requestAnimationFrame'),
|
||||
cancelAnimationFrame: snapshotGlobal('cancelAnimationFrame'),
|
||||
};
|
||||
const browserEnvironment = createBrowserEnvironment();
|
||||
const runtimeState = createRuntimeState();
|
||||
|
||||
globalThis.document = browserEnvironment.document;
|
||||
globalThis.window = browserEnvironment.window;
|
||||
globalThis.localStorage = browserEnvironment.localStorage;
|
||||
globalThis.requestAnimationFrame = browserEnvironment.requestAnimationFrame;
|
||||
globalThis.cancelAnimationFrame = browserEnvironment.cancelAnimationFrame;
|
||||
globalThis.__wmRuntimeConfigPanelTestState = runtimeState;
|
||||
|
||||
let RuntimeConfigPanel;
|
||||
let cleanupBundle;
|
||||
try {
|
||||
({ RuntimeConfigPanel, cleanupBundle } = await loadRuntimeConfigPanel());
|
||||
} catch (error) {
|
||||
delete globalThis.__wmRuntimeConfigPanelTestState;
|
||||
restoreGlobal('document', originalGlobals.document);
|
||||
restoreGlobal('window', originalGlobals.window);
|
||||
restoreGlobal('localStorage', originalGlobals.localStorage);
|
||||
restoreGlobal('requestAnimationFrame', originalGlobals.requestAnimationFrame);
|
||||
restoreGlobal('cancelAnimationFrame', originalGlobals.cancelAnimationFrame);
|
||||
throw error;
|
||||
}
|
||||
const activePanels = [];
|
||||
|
||||
function setRuntimeState({
|
||||
totalFeatures,
|
||||
availableFeatures,
|
||||
configuredCount,
|
||||
}) {
|
||||
runtimeState.features.splice(
|
||||
0,
|
||||
runtimeState.features.length,
|
||||
...Array.from({ length: totalFeatures }, (_, index) => ({ id: `feature-${index + 1}` })),
|
||||
);
|
||||
runtimeState.availableIds = new Set(
|
||||
runtimeState.features.slice(0, availableFeatures).map((feature) => feature.id),
|
||||
);
|
||||
runtimeState.configuredCount = configuredCount;
|
||||
}
|
||||
|
||||
function createPanel(options = { mode: 'alert' }) {
|
||||
const panel = new RuntimeConfigPanel(options);
|
||||
activePanels.push(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function emitRuntimeConfigChange() {
|
||||
for (const listener of [...runtimeState.listeners]) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function isHidden(panel) {
|
||||
return panel.getElement().classList.contains('hidden');
|
||||
}
|
||||
|
||||
function getAlertState(panel) {
|
||||
const match = panel.content.innerHTML.match(/data-alert-state="([^"]+)"/);
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
while (activePanels.length > 0) {
|
||||
activePanels.pop()?.destroy();
|
||||
}
|
||||
runtimeState.features.length = 0;
|
||||
runtimeState.availableIds = new Set();
|
||||
runtimeState.configuredCount = 0;
|
||||
runtimeState.listeners.clear();
|
||||
browserEnvironment.localStorage.clear();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
reset();
|
||||
cleanupBundle();
|
||||
delete globalThis.__wmRuntimeConfigPanelTestState;
|
||||
restoreGlobal('document', originalGlobals.document);
|
||||
restoreGlobal('window', originalGlobals.window);
|
||||
restoreGlobal('localStorage', originalGlobals.localStorage);
|
||||
restoreGlobal('requestAnimationFrame', originalGlobals.requestAnimationFrame);
|
||||
restoreGlobal('cancelAnimationFrame', originalGlobals.cancelAnimationFrame);
|
||||
}
|
||||
|
||||
return {
|
||||
createPanel,
|
||||
emitRuntimeConfigChange,
|
||||
getAlertState,
|
||||
isHidden,
|
||||
reset,
|
||||
cleanup,
|
||||
setRuntimeState,
|
||||
};
|
||||
}
|
||||
153
tests/runtime-config-panel-visibility.test.mjs
Normal file
153
tests/runtime-config-panel-visibility.test.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
import { after, afterEach, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createRuntimeConfigPanelHarness } from './helpers/runtime-config-panel-harness.mjs';
|
||||
|
||||
const harness = await createRuntimeConfigPanelHarness();
|
||||
|
||||
afterEach(() => {
|
||||
harness.reset();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
harness.cleanup();
|
||||
});
|
||||
|
||||
describe('runtime config panel visibility', () => {
|
||||
it('keeps a fully configured desktop alert hidden when panel settings replay toggle(true)', () => {
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 4,
|
||||
configuredCount: 4,
|
||||
});
|
||||
|
||||
const panel = harness.createPanel();
|
||||
|
||||
assert.equal(harness.isHidden(panel), true, 'configured alert should auto-hide on initial render');
|
||||
|
||||
panel.toggle(true);
|
||||
|
||||
assert.equal(
|
||||
harness.isHidden(panel),
|
||||
true,
|
||||
'reapplying enabled panel settings must not re-show an already configured alert',
|
||||
);
|
||||
});
|
||||
|
||||
it('rerenders the current alert state when reopening after an explicit hide', () => {
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 1,
|
||||
configuredCount: 0,
|
||||
});
|
||||
|
||||
const panel = harness.createPanel();
|
||||
panel.hide();
|
||||
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 2,
|
||||
configuredCount: 1,
|
||||
});
|
||||
|
||||
panel.toggle(true);
|
||||
|
||||
assert.equal(harness.isHidden(panel), false, 'reopening should make the panel visible again');
|
||||
assert.equal(
|
||||
harness.getAlertState(panel),
|
||||
'some',
|
||||
'reopening should recompute the partial-configuration alert state',
|
||||
);
|
||||
});
|
||||
|
||||
it('reappears when configuration becomes incomplete after auto-hiding as configured', () => {
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 4,
|
||||
configuredCount: 4,
|
||||
});
|
||||
|
||||
const panel = harness.createPanel();
|
||||
assert.equal(harness.isHidden(panel), true, 'configured alert should start hidden');
|
||||
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 2,
|
||||
configuredCount: 1,
|
||||
});
|
||||
harness.emitRuntimeConfigChange();
|
||||
|
||||
assert.equal(
|
||||
harness.isHidden(panel),
|
||||
false,
|
||||
'subscription updates should reshow the alert when a configured setup becomes incomplete',
|
||||
);
|
||||
assert.equal(
|
||||
harness.getAlertState(panel),
|
||||
'some',
|
||||
'the reshow path should expose the partial-configuration alert state',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the configured alert when all desktop features are available but setup is only partially configured', () => {
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 4,
|
||||
configuredCount: 1,
|
||||
});
|
||||
|
||||
const panel = harness.createPanel();
|
||||
|
||||
assert.equal(
|
||||
harness.isHidden(panel),
|
||||
false,
|
||||
'all-available desktop setups with only some secrets configured should stay visible',
|
||||
);
|
||||
assert.equal(
|
||||
harness.getAlertState(panel),
|
||||
'configured',
|
||||
'the visible all-available branch should use the configured alert state',
|
||||
);
|
||||
});
|
||||
|
||||
it('stays hidden when runtime-config subscriptions fire after the panel was disabled', () => {
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 1,
|
||||
configuredCount: 0,
|
||||
});
|
||||
|
||||
const panel = harness.createPanel();
|
||||
panel.hide();
|
||||
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 2,
|
||||
configuredCount: 1,
|
||||
});
|
||||
harness.emitRuntimeConfigChange();
|
||||
|
||||
assert.equal(
|
||||
harness.isHidden(panel),
|
||||
true,
|
||||
'runtime-config subscription rerenders must respect an explicit hidden panel state',
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the needsKeys alert for first-run desktop setup', () => {
|
||||
harness.setRuntimeState({
|
||||
totalFeatures: 4,
|
||||
availableFeatures: 0,
|
||||
configuredCount: 0,
|
||||
});
|
||||
|
||||
const panel = harness.createPanel();
|
||||
|
||||
assert.equal(harness.isHidden(panel), false, 'first-run setup should show the alert');
|
||||
assert.equal(
|
||||
harness.getAlertState(panel),
|
||||
'needsKeys',
|
||||
'first-run setup should use the needsKeys alert state',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user