diff --git a/src/components/RuntimeConfigPanel.ts b/src/components/RuntimeConfigPanel.ts index e4991eed3..0ca15807f 100644 --- a/src/components/RuntimeConfigPanel.ts +++ b/src/components/RuntimeConfigPanel.ts @@ -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(); private validatedKeys = new Map(); private validationMessages = new Map(); @@ -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('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 = ` -
+

${alertTitle}

${availableFeatures}/${totalFeatures} ${t('modals.runtimeConfig.summary.available')}${configuredCount > 0 ? ` ยท ${configuredCount} ${t('modals.runtimeConfig.summary.secrets')}` : ''}. diff --git a/tests/helpers/runtime-config-panel-harness.mjs b/tests/helpers/runtime-config-panel-harness.mjs new file mode 100644 index 000000000..046825934 --- /dev/null +++ b/tests/helpers/runtime-config-panel-harness.mjs @@ -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}`; + } +} + +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, + }; +} diff --git a/tests/runtime-config-panel-visibility.test.mjs b/tests/runtime-config-panel-visibility.test.mjs new file mode 100644 index 000000000..dc3724d74 --- /dev/null +++ b/tests/runtime-config-panel-visibility.test.mjs @@ -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', + ); + }); +});