diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9fbb1856a..ec0d5131b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -75,6 +75,7 @@ "src/ui/stores/context.js", "src/ui/stores/cockpit.js", "src/ui/stores/locale.js", + "src/ui/stores/providerSettings.js", "src/ui/stores/location.js", "src/ui/stores/streetLevel.js", "src/ui/rail/RailChips.vue", @@ -1002,6 +1003,7 @@ "src/ui/stores/context.js", "src/ui/stores/cockpit.js", "src/ui/stores/locale.js", + "src/ui/stores/providerSettings.js", "src/ui/stores/location.js", "src/ui/stores/display.js" ], @@ -2980,6 +2982,7 @@ "src/ui/stores/context.js", "src/ui/stores/cockpit.js", "src/ui/stores/locale.js", + "src/ui/stores/providerSettings.js", "src/ui/stores/location.js", "src/ui/stores/display.js" ], diff --git a/src/keySetup.js b/src/keySetup.js index 550ac6e16..38f0542c5 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -1,4 +1,6 @@ import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js'; +import { useProviderSettingsStore } from './ui/stores/providerSettings.js'; +import { applicationPinia } from './ui/vueRuntime.js'; import { readStoredCloudVoiceAuthMode, writeStoredCloudVoiceAuthMode, @@ -18,6 +20,10 @@ import { * The surface self-destructs where it cannot work: a prod build (no endpoint) * or a LAN visitor (loopback-only endpoint) fails the status fetch, and both * the chip and the dialog are removed outright. + * + * ProviderSettingsView renders it: initKeySetup() keeps the requests, the + * field values and the dialog's keyboard, and publishes what is shown to the + * gev-provider-settings store. */ import { t, msg, tn, registerUnsavedWork } from './i18n/index.js'; @@ -217,156 +223,26 @@ export function stripKeylessBasemapFromHash(hash) { const TIER_DOTS = Object.freeze({ metered: '🔴', paid: '🔴', free: '🟡' }); -function updateCloudVoiceAuthControl(auth, busy = false) { - const mode = readStoredCloudVoiceAuthMode(); - const state = auth.querySelector('.key-setup-cloud-auth-state'); - const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]'); - state.textContent = - mode === 'oauth' - ? t('VOICE AUTH · CHATGPT OAUTH') - : t('VOICE AUTH · API KEY'); - toggle.textContent = - mode === 'oauth' ? t('USE API KEY') : t('USE CHATGPT OAUTH'); - toggle.title = - mode === 'oauth' - ? t('Use OPENAI_API_KEY for the next cloud voice session') - : t( - 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session', - ); - // Keep the control focusable while the delegated handler blocks repeats. - toggle.setAttribute('aria-disabled', String(busy)); -} - -/** Build one key row. All content is our own registry text, set via textContent. */ -function buildRow(documentRef, key) { - const row = documentRef.createElement('section'); - row.className = 'key-setup-row'; - row.dataset.keyId = key.id; - row.dataset.set = String(Boolean(key.set)); - if (key.managed) row.dataset.managed = key.managed; - const external = key.managed === 'external'; - - const head = documentRef.createElement('div'); - head.className = 'key-setup-row-head'; - const led = documentRef.createElement('span'); - led.className = 'key-setup-led'; - led.setAttribute('aria-hidden', 'true'); - const title = documentRef.createElement('strong'); - title.textContent = key.title; // i18n-ignore: provider product name - const tier = documentRef.createElement('span'); - tier.className = 'key-setup-tier'; - tier.textContent = TIER_DOTS[key.tier] || ''; - tier.title = - key.tier === 'metered' - ? t('Metered — a billing-enabled account') - : key.tier === 'paid' - ? t('Optional paid token — public access works without one') - : t('Free key — register, paste, done'); - head.append(led, title, tier); - if (key.clientExposed) { - const exposed = documentRef.createElement('span'); - exposed.className = 'key-setup-exposed'; - exposed.textContent = t('browser-side'); - exposed.title = t( - 'This key runs in the browser by design — restrict it at the provider (see SECURITY.md)', - ); - head.append(exposed); - } - if (external) { - // Externally supplied credentials (shell env, Keychain, a launcher) are - // facts this panel reports, never values it rewrites or deletes. - const badge = documentRef.createElement('span'); - badge.className = 'key-setup-external'; - badge.textContent = t('configured externally'); - badge.title = t( - 'Supplied by your environment, Keychain, or launcher — change it where it was set', - ); - head.append(badge); - } - const get = documentRef.createElement('a'); - get.className = 'key-setup-get'; - get.href = key.getUrl; - get.target = '_blank'; - get.rel = 'noopener noreferrer'; - get.textContent = key.set ? t('MANAGE ↗') : t('GET KEY ↗'); - head.append(get); - - const unlocks = documentRef.createElement('p'); - unlocks.className = 'key-setup-unlocks'; - unlocks.textContent = t(key.unlocks); - - row.append(head, unlocks); - if (!external) { - const fields = documentRef.createElement('div'); - fields.className = 'key-setup-fields'; - for (const envVar of key.envVars) { - const input = documentRef.createElement('input'); - // Passwords-style so a pasted key never shows on a shared or recorded - // screen — this app gets screen-recorded a lot. - input.type = 'password'; - input.autocomplete = 'off'; - input.spellcheck = false; - input.dataset.envVar = envVar; - input.setAttribute('aria-label', envVar); - input.placeholder = key.set - ? t('{envVar} saved — paste to replace', { envVar }) - : t('paste {envVar}', { envVar }); - fields.append(input); - } - if (key.managed === 'file') { - const remove = documentRef.createElement('button'); - remove.type = 'button'; - remove.className = 'key-setup-remove'; - remove.dataset.keySetupRemove = JSON.stringify(key.envVars); - remove.textContent = t('REMOVE'); - remove.title = t("Remove {title} from this app's saved keys", { - title: key.title, - }); - fields.append(remove); - } - row.append(fields); - } - if (key.id === 'openai') { - const auth = documentRef.createElement('div'); - auth.className = 'key-setup-cloud-auth'; - const state = documentRef.createElement('span'); - state.className = 'key-setup-cloud-auth-state'; - const toggle = documentRef.createElement('button'); - toggle.type = 'button'; - toggle.className = 'key-setup-cloud-auth-toggle'; - toggle.dataset.cloudVoiceAuthToggle = 'true'; - auth.append(state, toggle); - updateCloudVoiceAuthControl(auth); - row.append(auth); - } - return row; -} - -/** - * Move the existing setup action with the theme, retaining its listeners: - * Cyber puts it in the top toolbar, every other theme keeps it first in its - * markup home (the bottom-right corner row, beside the language picker). - */ -export function bindKeySetupPlacement(documentRef, chip) { - const theme = documentRef?.documentElement; - const toolbar = documentRef?.getElementById?.('top-center-actions'); - const Observer = documentRef?.defaultView?.MutationObserver; - const home = chip?.parentNode; - if (!theme || !toolbar || !Observer || !home) return () => {}; - const sync = () => { - if (theme.dataset.uiTheme === 'cyber') { - if (chip.parentNode !== toolbar) toolbar.append(chip); - } else if (chip.parentNode !== home) { - home.prepend(chip); - } +/** One key row as the view renders it, from the status payload's own text. */ +function presentRow(key) { + return { + id: key.id, + title: key.title, + set: Boolean(key.set), + managed: key.managed || null, + external: key.managed === 'external', + tierDot: TIER_DOTS[key.tier] || '', + tierTitle: + key.tier === 'metered' + ? t('Metered — a billing-enabled account') + : key.tier === 'paid' + ? t('Optional paid token — public access works without one') + : t('Free key — register, paste, done'), + clientExposed: Boolean(key.clientExposed), + getUrl: key.getUrl, + unlocks: t(key.unlocks), + envVars: [...(key.envVars || [])], }; - const observer = new Observer(sync); - observer.observe(theme, { - attributes: true, - attributeFilter: ['data-ui-theme'], - }); - sync(); - return () => observer.disconnect(); } /** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */ @@ -374,91 +250,78 @@ export async function initKeySetup({ documentRef = globalThis.document, fetchImpl, signal, + store = useProviderSettingsStore(applicationPinia()), } = {}) { - const chip = documentRef?.getElementById?.('key-setup-chip'); const root = documentRef?.getElementById?.('key-setup'); - if (!chip || !root || root.dataset.initialized === 'true') return null; - root.dataset.initialized = 'true'; + if (!root || store.controls) return null; const lifetime = new AbortController(); + const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis); let disposed = false; - let disposeControls = () => {}; - let disposePlacement = () => {}; - // A pasted key lives only in its field until Save: switching language (a - // reload) asks first while any field holds one. + // Pasted keys live only here until Save. + const fields = {}; + const state = { + available: null, + ready: false, + chip: null, + dialog: { hidden: true, visible: false }, + rows: [], + // Bumped by a save: the rows render afresh, so no field keeps a key. + generation: 0, + fields, + status: null, + // Counts messages, so the live region announces a repeated one again. + statusSeq: 0, + saving: null, + auth: { mode: readStoredCloudVoiceAuthMode(), busy: false }, + }; + const publish = () => { + if (store.controls !== controls) return; + store.publish({ + ...state, + chip: state.chip && { ...state.chip }, + dialog: { ...state.dialog }, + fields: { ...fields }, + auth: { ...state.auth }, + }); + }; + // Switching language (a reload) asks first while any field holds a key. const unregisterUnsavedWork = registerUnsavedWork(() => - [...(root.querySelectorAll?.('input[data-env-var]') || [])].some((input) => - String(input.value ?? '').trim(), - ) + Object.values(fields).some((value) => String(value ?? '').trim()) ? msg('unsaved provider keys') : null, ); const destroy = () => { if (disposed) return; disposed = true; - delete root.dataset.ready; unregisterUnsavedWork(); lifetime.abort(); signal?.removeEventListener('abort', destroy); - disposeControls(); - disposePlacement(); - chip.remove(); - root.remove(); + open = false; + keyboard.destroy(); + state.available = false; + state.ready = false; + publish(); + if (store.controls === controls) store.connect(null); }; - if (signal?.aborted) { - destroy(); - return null; - } - signal?.addEventListener('abort', destroy, { once: true }); - const doFetch = fetchImpl || globalThis.fetch?.bind(globalThis); let status = null; - try { - const response = await doFetch('/api/setup/status', { - cache: 'no-store', - signal: lifetime.signal, - }); - if (!response.ok) throw new Error(String(response.status)); - status = await response.json(); - if (disposed) return null; - } catch { - // Prod build or non-loopback visitor: the surface cannot function, so it - // does not exist. (The README covers .env for headless/self-host setups.) - destroy(); - return null; - } - - const rowsHost = root.querySelector('[data-key-setup-rows]'); - disposePlacement = bindKeySetupPlacement(documentRef, chip); - const applyButton = root.querySelector('[data-key-setup-apply]'); - const closeButton = root.querySelector('[data-key-setup-close]'); - const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip; - const statusLine = root.querySelector('[data-key-setup-status]'); - const defaultStatusText = statusLine?.textContent || ''; let busy = false; - let oauthBusy = false; let open = false; - const syncCloudVoiceAuth = () => { - for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth')) - updateCloudVoiceAuthControl(auth, oauthBusy); - }; - const render = (nextStatus) => { if (disposed) return; status = nextStatus; - chipLabel.textContent = keySetupChipLabel(status); - chip.title = t('Project keys: {status}', { - status: keySetupChipLabel(status), - }); - chip.setAttribute('aria-label', chip.title); + const label = keySetupChipLabel(status); // Fully powered is the owner's clean screen: the chip retires. The dialog // stays reachable this session (and via ?setup=1) to swap or verify keys. - chip.hidden = status.setCount >= status.total; - if (!rowsHost) return; - rowsHost.textContent = ''; - for (const key of status.keys || []) - rowsHost.append(buildRow(documentRef, key)); - syncCloudVoiceAuth(); + state.chip = { + label, + title: t('Project keys: {status}', { status: label }), + hidden: status.setCount >= status.total, + }; + state.rows = (status.keys || []).map(presentRow); + state.auth = { ...state.auth, mode: readStoredCloudVoiceAuthMode() }; + publish(); }; const visible = () => @@ -474,32 +337,48 @@ export async function initKeySetup({ }); const openDialog = () => { - if (disposed || open) return; + if (disposed || open || !state.ready) return; open = true; keyboard.activate(); - root.hidden = false; + state.dialog = { hidden: false, visible: false }; + publish(); + // The frame runs after Vue shows the dialog, so the fade starts from it. globalThis.requestAnimationFrame?.(() => { if (!open) return; - root.classList.add('visible'); - root.querySelector('input')?.focus?.({ preventScroll: true }); + state.dialog = { hidden: false, visible: true }; + publish(); + // The fields are already rendered; focus after Vue applies the class. + Promise.resolve().then(() => { + if (open) root.querySelector('input')?.focus?.({ preventScroll: true }); + }); }); }; + const hideIfClosed = () => { + if (open || disposed || state.dialog.hidden) return; + state.dialog = { hidden: true, visible: false }; + publish(); + }; + const close = () => { if (!open) return; open = false; - root.classList.remove('visible'); - const hide = () => { - if (!open) root.hidden = true; - }; - root.addEventListener('transitionend', hide, { once: true }); - globalThis.setTimeout?.(hide, 400); - if (statusLine) statusLine.textContent = defaultStatusText; + state.dialog = { hidden: false, visible: false }; + state.status = null; + publish(); + globalThis.setTimeout?.(hideIfClosed, 400); keyboard.deactivate({ restoreFocus: true }); }; const say = (text) => { - if (statusLine) statusLine.textContent = text; + state.status = text; + state.statusSeq += 1; + publish(); + }; + + const syncAuth = (busyNow = state.auth.busy) => { + state.auth = { mode: readStoredCloudVoiceAuthMode(), busy: busyNow }; + publish(); }; const storeLabel = () => @@ -515,7 +394,7 @@ export async function initKeySetup({ (key) => key.id === 'google-maps', )?.set; busy = true; - applyButton?.setAttribute('aria-disabled', 'true'); + state.saving = true; say(t('Saving…')); try { const response = await doFetch('/api/setup/keys', { @@ -530,8 +409,8 @@ export async function initKeySetup({ say(saveRefusalText(payload, response.status)); return; } - for (const input of root.querySelectorAll('input[data-env-var]')) - input.value = ''; + for (const envVar of Object.keys(fields)) delete fields[envVar]; + state.generation += 1; render(payload.status); if (googleWasUnset && payload.saved?.includes('GOOGLE_MAPS_API_KEY')) { const strip = () => { @@ -566,18 +445,15 @@ export async function initKeySetup({ ); } finally { busy = false; - applyButton?.setAttribute('aria-disabled', 'false'); + state.saving = false; + publish(); } }; - const onApply = async () => { + const apply = async () => { if (disposed || busy) return; - const inputs = [...root.querySelectorAll('input[data-env-var]')]; const updates = collectKeyUpdates( - inputs.map((input) => ({ - envVar: input.dataset.envVar, - value: input.value, - })), + Object.entries(fields).map(([envVar, value]) => ({ envVar, value })), ); if (!Object.keys(updates).length) { say(t('Paste at least one key first.')); @@ -589,125 +465,8 @@ export async function initKeySetup({ ); }; - chip.addEventListener('click', openDialog); - closeButton?.addEventListener('click', close); - applyButton?.addEventListener('click', onApply); - root.dataset.ready = 'true'; - // Remove buttons are rendered per row; delegate so re-renders stay wired. - rowsHost?.addEventListener('click', (event) => { - const authButton = event.target?.closest?.( - '[data-cloud-voice-auth-toggle]', - ); - if (authButton && !disposed) { - if (oauthBusy) return; - void (async () => { - const current = readStoredCloudVoiceAuthMode(); - if (current === 'oauth') { - writeStoredCloudVoiceAuthMode('api-key'); - syncCloudVoiceAuth(); - say(t('Cloud voice will use OPENAI_API_KEY on the next session.')); - return; - } - oauthBusy = true; - syncCloudVoiceAuth(); - say(t('Checking local ChatGPT OAuth sign-in…')); - try { - const response = await doFetch('/api/realtime/oauth-status', { - cache: 'no-store', - signal: lifetime.signal, - }); - const payload = await response.json().catch(() => ({})); - if (lifetime.signal.aborted) return; - if (!response.ok) - throw oauthCheckError( - payload.error, - t('Could not check ChatGPT sign-in.'), - ); - if (payload.available) { - writeStoredCloudVoiceAuthMode('oauth'); - syncCloudVoiceAuth(); - say( - t( - 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', - ), - ); - return; - } - - say( - payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED' - ? t('ChatGPT sign-in expired. Opening sign-in again…') - : t('Opening ChatGPT sign-in in your browser…'), - ); - const loginResponse = await doFetch('/api/realtime/oauth-login', { - method: 'POST', - cache: 'no-store', - signal: lifetime.signal, - }); - const loginPayload = await loginResponse.json().catch(() => ({})); - if (lifetime.signal.aborted) return; - if (!loginResponse.ok) { - say( - loginPayload.error || - payload.error || - t('Could not start ChatGPT sign-in on this machine.'), - ); - return; - } - if (loginPayload.available) { - writeStoredCloudVoiceAuthMode('oauth'); - syncCloudVoiceAuth(); - say( - t( - 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', - ), - ); - return; - } - - say( - t( - 'Finish ChatGPT sign-in in the browser. Waiting for it to complete…', - ), - ); - const available = await waitForChatGptOAuth({ - fetchImpl: doFetch, - signal: lifetime.signal, - }); - if (lifetime.signal.aborted) return; - if (!available) { - say( - t('ChatGPT sign-in timed out. Click {button} to try again.', { - button: t('USE CHATGPT OAUTH'), - }), - ); - return; - } - writeStoredCloudVoiceAuthMode('oauth'); - syncCloudVoiceAuth(); - say( - t( - 'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.', - ), - ); - } catch (error) { - if (lifetime.signal.aborted) return; - say(oauthCheckFailureText(error)); - } finally { - oauthBusy = false; - if (!disposed) syncCloudVoiceAuth(); - } - })(); - return; - } - const button = event.target?.closest?.('[data-key-setup-remove]'); - if (disposed || !button || busy) return; - let envVars = []; - try { - envVars = JSON.parse(button.dataset.keySetupRemove || '[]'); - } catch { - return; - } + const remove = (envVars) => { + if (disposed || busy) return; if (!Array.isArray(envVars) || !envVars.length) return; // Removal is destructive and — behind a framing defense that should already // stop it — a clickjack target. A confirm turns a single aligned click into @@ -720,8 +479,142 @@ export async function initKeySetup({ Object.fromEntries(envVars.map((name) => [name, null])), msg('Removed from {store}. Restarting — this page reloads itself.'), ); - }); + }; + const toggleAuth = async () => { + if (disposed || state.auth.busy) return; + const current = readStoredCloudVoiceAuthMode(); + if (current === 'oauth') { + writeStoredCloudVoiceAuthMode('api-key'); + syncAuth(); + say(t('Cloud voice will use OPENAI_API_KEY on the next session.')); + return; + } + syncAuth(true); + say(t('Checking local ChatGPT OAuth sign-in…')); + try { + const response = await doFetch('/api/realtime/oauth-status', { + cache: 'no-store', + signal: lifetime.signal, + }); + const payload = await response.json().catch(() => ({})); + if (lifetime.signal.aborted) return; + if (!response.ok) + throw oauthCheckError( + payload.error, + t('Could not check ChatGPT sign-in.'), + ); + if (payload.available) { + writeStoredCloudVoiceAuthMode('oauth'); + syncAuth(); + say( + t( + 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', + ), + ); + return; + } + + say( + payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED' + ? t('ChatGPT sign-in expired. Opening sign-in again…') + : t('Opening ChatGPT sign-in in your browser…'), + ); + const loginResponse = await doFetch('/api/realtime/oauth-login', { + method: 'POST', + cache: 'no-store', + signal: lifetime.signal, + }); + const loginPayload = await loginResponse.json().catch(() => ({})); + if (lifetime.signal.aborted) return; + if (!loginResponse.ok) { + say( + loginPayload.error || + payload.error || + t('Could not start ChatGPT sign-in on this machine.'), + ); + return; + } + if (loginPayload.available) { + writeStoredCloudVoiceAuthMode('oauth'); + syncAuth(); + say( + t( + 'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.', + ), + ); + return; + } + + say( + t('Finish ChatGPT sign-in in the browser. Waiting for it to complete…'), + ); + const available = await waitForChatGptOAuth({ + fetchImpl: doFetch, + signal: lifetime.signal, + }); + if (lifetime.signal.aborted) return; + if (!available) { + say( + t('ChatGPT sign-in timed out. Click {button} to try again.', { + button: t('USE CHATGPT OAUTH'), + }), + ); + return; + } + writeStoredCloudVoiceAuthMode('oauth'); + syncAuth(); + say( + t( + 'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.', + ), + ); + } catch (error) { + if (lifetime.signal.aborted) return; + say(oauthCheckFailureText(error)); + } finally { + if (!disposed) syncAuth(false); + } + }; + + const controls = { + open: openDialog, + close, + apply, + remove, + toggleAuth: () => void toggleAuth(), + setField(envVar, value) { + fields[envVar] = String(value ?? ''); + publish(); + }, + transitionEnd: hideIfClosed, + }; + store.connect(controls); + publish(); + + if (signal?.aborted) { + destroy(); + return null; + } + signal?.addEventListener('abort', destroy, { once: true }); + + try { + const response = await doFetch('/api/setup/status', { + cache: 'no-store', + signal: lifetime.signal, + }); + if (!response.ok) throw new Error(String(response.status)); + status = await response.json(); + if (disposed) return null; + } catch { + // Prod build or non-loopback visitor: the surface cannot function, so it + // does not exist. (The README covers .env for headless/self-host setups.) + destroy(); + return null; + } + + state.available = true; + state.ready = true; render(status); // Re-entry for a fully-keyed setup, demos, and support: ?setup=1 opens the @@ -736,12 +629,5 @@ export async function initKeySetup({ // An unparsable location never blocks init. } - disposeControls = () => { - open = false; - keyboard.destroy(); - chip.removeEventListener('click', openDialog); - closeButton?.removeEventListener('click', close); - applyButton?.removeEventListener('click', onApply); - }; return { open: openDialog, close, render, destroy }; } diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index 013dbbd87..0be1bb937 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -4,61 +4,14 @@ import { collectKeyUpdates, keySetupChipLabel, stripKeylessBasemapFromHash, - bindKeySetupPlacement, waitForChatGptOAuth, initKeySetup, } from './keySetup.js'; +import { createPinia } from 'pinia'; import { describeUnsavedWork } from './i18n/index.js'; +import { useProviderSettingsStore } from './ui/stores/providerSettings.js'; -test('setup placement moves the same button only in Cyber and disconnects on teardown', () => { - const home = { - prepend: (node) => { - node.parentNode = home; - }, - }; - const chip = { parentNode: home, hidden: true }; - let moves = 0; - const toolbar = { - append: (node) => { - moves++; - node.parentNode = toolbar; - }, - }; - const theme = { dataset: { uiTheme: 'tactical' } }; - let notify, - disconnected = false; - class Observer { - constructor(callback) { - notify = callback; - } - observe(target, options) { - assert.equal(target, theme); - assert.deepEqual(options.attributeFilter, ['data-ui-theme']); - } - disconnect() { - disconnected = true; - } - } - const dispose = bindKeySetupPlacement( - { - documentElement: theme, - getElementById: () => toolbar, - defaultView: { MutationObserver: Observer }, - }, - chip, - ); - assert.equal(chip.parentNode, home); - for (const variant of ['cyber', 'tactical', 'cyber', 'minimal']) { - theme.dataset.uiTheme = variant; - notify(); - assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home); - notify(); - } - assert.equal(moves, 2, 'identical theme sync does not reparent again'); - assert.equal(chip.hidden, true, 'placement cannot reveal a retired button'); - dispose(); - assert.equal(disconnected, true); -}); +const freshStore = () => useProviderSettingsStore(createPinia()); test('the chip counts what is missing, and retires the count at zero', () => { assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING'); @@ -153,52 +106,42 @@ test('the first Google key strips ONLY the keyless OSM basemap from the share ha }); test('aborting pending setup removes its surface and ignores a late response', async () => { - const { initKeySetup } = await import('./keySetup.js'); - const removed = []; - const chip = { remove: () => removed.push('chip') }; - const root = { dataset: {}, remove: () => removed.push('root') }; + const store = freshStore(); + const root = { dataset: {} }; let resolveResponse; let requestSignal; const controller = new AbortController(); const pending = initKeySetup({ - documentRef: { getElementById: (id) => id === 'key-setup-chip' ? chip : root }, + documentRef: { getElementById: (id) => (id === 'key-setup' ? root : null) }, signal: controller.signal, + store, fetchImpl: (_url, { signal }) => { requestSignal = signal; return new Promise((resolve) => { resolveResponse = resolve; }); }, }); + assert.equal(store.controls !== null, true); controller.abort(); assert.equal(requestSignal.aborted, true); - assert.deepEqual(removed, ['chip', 'root']); + assert.equal(store.view.available, false); + assert.equal(store.controls, null); resolveResponse({ ok: true, json: async () => ({ keys: [] }) }); assert.equal(await pending, null); + assert.equal(store.view.available, false); }); test('a pasted, unsaved key makes the language switch ask first until teardown', async () => { - const node = () => ({ - dataset: {}, - addEventListener() {}, - removeEventListener() {}, - setAttribute() {}, - querySelector: () => null, - remove() {}, - }); - const chip = node(); - const input = { dataset: { envVar: 'GOOGLE_MAPS_API_KEY' }, value: '' }; - const root = { - ...node(), - querySelectorAll: (selector) => - selector === 'input[data-env-var]' ? [input] : [], - }; + const store = freshStore(); + const root = { dataset: {} }; const controller = new AbortController(); const setup = await initKeySetup({ documentRef: { - getElementById: (id) => (id === 'key-setup-chip' ? chip : root), + getElementById: (id) => (id === 'key-setup' ? root : null), addEventListener() {}, removeEventListener() {}, }, signal: controller.signal, + store, fetchImpl: async () => ({ ok: true, json: async () => ({ keys: [], setCount: 0, total: 1 }), @@ -206,9 +149,9 @@ test('a pasted, unsaved key makes the language switch ask first until teardown', }); assert.ok(setup); assert.deepEqual(describeUnsavedWork(), []); - input.value = ' '; + store.controls.setField('GOOGLE_MAPS_API_KEY', ' '); assert.deepEqual(describeUnsavedWork(), []); - input.value = 'AIza-pasted'; + store.controls.setField('GOOGLE_MAPS_API_KEY', 'AIza-pasted'); assert.deepEqual(describeUnsavedWork(), ['unsaved provider keys']); // The field still holds the key, so only teardown dropping the check clears it. controller.abort(); diff --git a/src/tooling/ll2Integration.test.mjs b/src/tooling/ll2Integration.test.mjs index 30325eaab..d64e8f066 100644 --- a/src/tooling/ll2Integration.test.mjs +++ b/src/tooling/ll2Integration.test.mjs @@ -9,6 +9,8 @@ import { } from '../../server/providers/space/launch-library.js'; import { keySetupStatus } from '../keySetupCore.mjs'; import { initKeySetup } from '../keySetup.js'; +import { createPinia } from 'pinia'; +import { useProviderSettingsStore } from '../ui/stores/providerSettings.js'; test('LL2 requests from the same UTC minute share a cache key without mutating callers', () => { const first = new Date('2026-10-17T16:20:00.001Z'); @@ -57,60 +59,31 @@ test('LL2 headers identify the app and preserve optional server-only authorizati }); test('actual setup rendering distinguishes optional paid LL2 from unrelated free keys', async () => { - const created = []; - const node = () => { - const value = { - dataset: {}, - children: [], - append(...children) { - this.children.push(...children); - }, - querySelector() { - return null; - }, - querySelectorAll() { - return []; - }, - addEventListener() {}, - removeEventListener() {}, - setAttribute() {}, - remove() {}, - }; - created.push(value); - return value; - }; - const rows = node(); - const root = node(); - root.querySelector = (selector) => - selector === '[data-key-setup-rows]' ? rows : null; - const chip = node(); + const store = useProviderSettingsStore(createPinia()); const status = keySetupStatus({}); status.keys = status.keys.filter((key) => ['launch-library', 'mapillary'].includes(key.id), ); const setup = await initKeySetup({ documentRef: { - getElementById: (id) => (id === 'key-setup-chip' ? chip : root), - createElement: node, + getElementById: (id) => (id === 'key-setup' ? { dataset: {} } : null), addEventListener() {}, removeEventListener() {}, }, + store, fetchImpl: async () => ({ ok: true, json: async () => status }), }); assert.ok(setup); try { - const ll2 = rows.children.find( - (row) => row.dataset.keyId === 'launch-library', + const row = (id) => store.view.rows.find((key) => key.id === id); + const ll2 = row('launch-library'); + assert.match(ll2.tierTitle, /optional paid token/i); + assert.match(ll2.unlocks, /leave blank/i); + assert.match(ll2.unlocks, /no key/i); + assert.equal( + row('mapillary').tierTitle, + 'Free key — register, paste, done', ); - const free = rows.children.find((row) => row.dataset.keyId === 'mapillary'); - const tier = (row) => - row.children[0].children.find( - (child) => child.className === 'key-setup-tier', - ); - assert.match(tier(ll2).title, /optional paid token/i); - assert.match(ll2.children[1].textContent, /leave blank/i); - assert.match(ll2.children[1].textContent, /no key/i); - assert.equal(tier(free).title, 'Free key — register, paste, done'); const serialized = JSON.stringify(status); assert.ok(!serialized.includes('owned-fixture-token')); } finally { diff --git a/src/ui/providerSettings.test.mjs b/src/ui/providerSettings.test.mjs new file mode 100644 index 000000000..117a63390 --- /dev/null +++ b/src/ui/providerSettings.test.mjs @@ -0,0 +1,421 @@ +// src/ui/providerSettings.test.mjs — Provider Settings (the dev-only key +// setup) renders what src/keySetup.js presents, and its controls reach the +// owner, over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import { initKeySetup } from '../keySetup.js'; +import { CLOUD_VOICE_AUTH_STORAGE_KEY } from '../voice/cloudVoiceAuth.js'; +import { useProviderSettingsStore } from './stores/providerSettings.js'; +import ProviderSettingsView from './views/ProviderSettingsView.vue'; + +const STATUS = Object.freeze({ + total: 3, + setCount: 1, + store: 'dotenv', + keys: [ + { + id: 'google-maps', + title: 'Google Maps', + tier: 'metered', + set: false, + managed: null, + clientExposed: true, + getUrl: 'https://example.test/google', + unlocks: 'Photorealistic 3D tiles', + envVars: ['GOOGLE_MAPS_API_KEY'], + }, + { + id: 'openai', + title: 'OpenAI', + tier: 'paid', + set: true, + managed: 'file', + getUrl: 'https://example.test/openai', + unlocks: 'Cloud voice', + envVars: ['OPENAI_API_KEY'], + }, + { + id: 'firms', + title: 'NASA FIRMS', + tier: 'free', + set: true, + managed: 'external', + getUrl: 'https://example.test/firms', + unlocks: 'Fire detections', + envVars: ['FIRMS_MAP_KEY'], + }, + ], +}); + +const wait = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const frame = () => + new Promise((resolve) => requestAnimationFrame(() => resolve())); + +async function fixture(t, { status = STATUS, respond = null } = {}) { + document.body.replaceChildren(); + delete document.documentElement.dataset.uiTheme; + const toolbar = document.body.appendChild(document.createElement('div')); + toolbar.id = 'top-center-actions'; + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp( + defineComponent(() => () => h(ProviderSettingsView)), + ).use(pinia); + app.mount(host); + const store = useProviderSettingsStore(pinia); + const requests = []; + const fetchImpl = async (url, init = {}) => { + requests.push({ url, body: init.body ? JSON.parse(init.body) : null }); + if (url === '/api/setup/status') { + if (!status) return { ok: false, status: 404, json: async () => ({}) }; + return { ok: true, json: async () => status }; + } + return respond(url, init); + }; + const controller = new AbortController(); + const setup = await initKeySetup({ + documentRef: document, + fetchImpl, + signal: controller.signal, + store, + }); + await nextTick(); + t.after(() => { + controller.abort(); + app.unmount(); + }); + const $ = (selector) => document.querySelector(selector); + const text = (selector) => $(selector)?.textContent.trim(); + return { $, text, store, setup, requests, toolbar }; +} + +test('an unavailable setup endpoint removes the chip and the dialog', async (t) => { + const { $ } = await fixture(t, { status: null }); + assert.equal($('#key-setup-chip'), null); + assert.equal($('#key-setup'), null); + assert.ok($('#corner-actions')); +}); + +test('the chip counts what is missing and the rows render from the status', async (t) => { + const { $, text } = await fixture(t); + const chip = $('#key-setup-chip'); + assert.equal(chip.hidden, false); + assert.equal( + text('[data-key-setup-chip-label]'), + 'POWER UP · 2 KEYS WAITING', + ); + assert.equal(chip.title, 'Project keys: POWER UP · 2 KEYS WAITING'); + assert.equal(chip.getAttribute('aria-label'), chip.title); + assert.equal($('#key-setup').dataset.ready, 'true'); + assert.equal($('#key-setup').hidden, true); + + const google = $('[data-key-id="google-maps"]'); + assert.equal(google.dataset.set, 'false'); + assert.equal(google.hasAttribute('data-managed'), false); + assert.equal(google.querySelector('strong').textContent, 'Google Maps'); + assert.equal(google.querySelector('.key-setup-tier').textContent, '🔴'); + assert.equal( + google.querySelector('.key-setup-tier').title, + 'Metered — a billing-enabled account', + ); + assert.equal( + google.querySelector('.key-setup-exposed').textContent, + 'browser-side', + ); + assert.equal(google.querySelector('.key-setup-get').textContent, 'GET KEY ↗'); + assert.equal( + google.querySelector('.key-setup-get').getAttribute('rel'), + 'noopener noreferrer', + ); + const field = google.querySelector( + 'input[data-env-var="GOOGLE_MAPS_API_KEY"]', + ); + assert.equal(field.type, 'password'); + assert.equal(field.placeholder, 'paste GOOGLE_MAPS_API_KEY'); + assert.equal(google.querySelector('.key-setup-remove'), null); + assert.equal(google.querySelector('.key-setup-cloud-auth'), null); + + const openai = $('[data-key-id="openai"]'); + assert.equal(openai.dataset.managed, 'file'); + assert.equal(openai.querySelector('.key-setup-get').textContent, 'MANAGE ↗'); + assert.equal( + openai.querySelector('input').placeholder, + 'OPENAI_API_KEY saved — paste to replace', + ); + const remove = openai.querySelector('.key-setup-remove'); + assert.equal(remove.textContent, 'REMOVE'); + assert.equal(remove.title, "Remove OpenAI from this app's saved keys"); + assert.equal(remove.dataset.keySetupRemove, '["OPENAI_API_KEY"]'); + assert.equal( + text('[data-key-id="openai"] .key-setup-cloud-auth-state'), + 'VOICE AUTH · API KEY', + ); + + const firms = $('[data-key-id="firms"]'); + assert.equal( + firms.querySelector('.key-setup-external').textContent, + 'configured externally', + ); + assert.equal(firms.querySelector('.key-setup-fields'), null); +}); + +test('the chip opens the dialog onto its first field; closing fades it and restores the status line', async (t) => { + const { $, text } = await fixture(t); + const chip = $('#key-setup-chip'); + chip.focus(); + chip.click(); + await nextTick(); + const dialog = $('#key-setup'); + assert.equal(dialog.hidden, false); + assert.equal(dialog.classList.contains('visible'), false); + await frame(); + await wait(); + assert.equal(dialog.classList.contains('visible'), true); + assert.equal(document.activeElement?.dataset.envVar, 'GOOGLE_MAPS_API_KEY'); + + $('[data-key-setup-apply]').click(); + await nextTick(); + assert.equal( + text('[data-key-setup-status]'), + 'Paste at least one key first.', + ); + + $('[data-key-setup-close]').click(); + await nextTick(); + assert.equal(dialog.classList.contains('visible'), false); + assert.equal(dialog.hidden, false); + assert.equal( + text('[data-key-setup-status]'), + 'The Google Maps key buys the photorealistic planet — everything else stacks on top.', + ); + dialog.dispatchEvent(new Event('transitionend', { bubbles: true })); + await nextTick(); + assert.equal(dialog.hidden, true); + assert.equal(document.activeElement === chip, true); +}); + +test('saving posts the pasted keys, clears the fields and shows the result', async (t) => { + let finish; + const { $, text, requests } = await fixture(t, { + respond: () => + new Promise((resolve) => { + finish = resolve; + }), + }); + const field = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]'); + field.value = ' AIza-test '; + field.dispatchEvent(new Event('input', { bubbles: true })); + const apply = $('[data-key-setup-apply]'); + assert.equal(apply.hasAttribute('aria-disabled'), false); + apply.click(); + await nextTick(); + assert.equal(apply.getAttribute('aria-disabled'), 'true'); + assert.equal(text('[data-key-setup-status]'), 'Saving…'); + assert.deepEqual(requests.at(-1), { + url: '/api/setup/keys', + body: { GOOGLE_MAPS_API_KEY: 'AIza-test' }, + }); + finish({ + ok: true, + json: async () => ({ + ok: true, + saved: ['GOOGLE_MAPS_API_KEY'], + status: { + ...STATUS, + setCount: 3, + keys: STATUS.keys.map((key) => ({ ...key, set: true })), + }, + }), + }); + await wait(); + await nextTick(); + assert.equal(apply.getAttribute('aria-disabled'), 'false'); + assert.equal( + text('[data-key-setup-status]'), + 'Saved to your local .env. Restarting — this page reloads itself.', + ); + assert.equal($('input[data-env-var="GOOGLE_MAPS_API_KEY"]').value, ''); + assert.equal( + $('input[data-env-var="GOOGLE_MAPS_API_KEY"]').placeholder, + 'GOOGLE_MAPS_API_KEY saved — paste to replace', + ); + // Fully powered retires the chip. + assert.equal($('#key-setup-chip').hidden, true); +}); + +test('removing a saved key asks first and posts nulls', async (t) => { + const { $, requests } = await fixture(t, { + respond: async () => ({ + ok: true, + json: async () => ({ ok: true, saved: [], status: STATUS }), + }), + }); + const original = globalThis.confirm; + const asked = []; + let answer = false; + globalThis.confirm = (message) => { + asked.push(message); + return answer; + }; + t.after(() => { + globalThis.confirm = original; + }); + $('.key-setup-remove').click(); + assert.deepEqual(asked, ['Remove this key from your saved configuration?']); + assert.equal(requests.length, 1); + answer = true; + $('.key-setup-remove').click(); + await wait(); + assert.deepEqual(requests.at(-1), { + url: '/api/setup/keys', + body: { OPENAI_API_KEY: null }, + }); +}); + +test('the cloud voice toggle switches back to the API key and says so', async (t) => { + localStorage.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, 'oauth'); + t.after(() => localStorage.removeItem(CLOUD_VOICE_AUTH_STORAGE_KEY)); + const { $, text } = await fixture(t); + const toggle = $('[data-cloud-voice-auth-toggle]'); + assert.equal( + text('.key-setup-cloud-auth-state'), + 'VOICE AUTH · CHATGPT OAUTH', + ); + assert.equal(toggle.textContent, 'USE API KEY'); + toggle.click(); + await nextTick(); + assert.equal(localStorage.getItem(CLOUD_VOICE_AUTH_STORAGE_KEY), 'api-key'); + assert.equal(text('.key-setup-cloud-auth-state'), 'VOICE AUTH · API KEY'); + assert.equal(toggle.textContent, 'USE CHATGPT OAUTH'); + assert.equal(toggle.getAttribute('aria-disabled'), 'false'); + assert.equal( + text('[data-key-setup-status]'), + 'Cloud voice will use OPENAI_API_KEY on the next session.', + ); +}); + +test('while the OAuth check runs, the toggle is busy and repeat presses are ignored', async (t) => { + let finish; + const calls = []; + const { $, text } = await fixture(t, { + respond: (url) => { + calls.push(url); + return new Promise((resolve) => { + finish = resolve; + }); + }, + }); + const toggle = $('[data-cloud-voice-auth-toggle]'); + toggle.click(); + await nextTick(); + assert.equal(toggle.getAttribute('aria-disabled'), 'true'); + assert.equal( + text('[data-key-setup-status]'), + 'Checking local ChatGPT OAuth sign-in…', + ); + toggle.click(); + assert.deepEqual(calls, ['/api/realtime/oauth-status']); + finish({ ok: true, json: async () => ({ available: true }) }); + await wait(); + await nextTick(); + assert.equal(toggle.getAttribute('aria-disabled'), 'false'); + assert.equal( + text('.key-setup-cloud-auth-state'), + 'VOICE AUTH · CHATGPT OAUTH', + ); + localStorage.removeItem(CLOUD_VOICE_AUTH_STORAGE_KEY); +}); + +test('Cyber moves the chip into the top toolbar and other themes return it to the corner', async (t) => { + const { $, toolbar } = await fixture(t); + const corner = $('#corner-actions'); + assert.equal(corner.firstElementChild?.id, 'key-setup-chip'); + document.documentElement.dataset.uiTheme = 'cyber'; + await wait(); + await nextTick(); + assert.equal(toolbar.lastElementChild?.id, 'key-setup-chip'); + assert.equal(corner.querySelector('#key-setup-chip'), null); + document.documentElement.dataset.uiTheme = 'tactical'; + await wait(); + await nextTick(); + assert.equal(corner.firstElementChild?.id, 'key-setup-chip'); + assert.equal(toolbar.querySelector('#key-setup-chip'), null); + delete document.documentElement.dataset.uiTheme; +}); + +test('a field focused while a save completes does not keep the saved key', async (t) => { + let finish; + const { $ } = await fixture(t, { + respond: () => + new Promise((resolve) => { + finish = resolve; + }), + }); + const field = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]'); + field.value = 'AIza-test'; + field.dispatchEvent(new Event('input', { bubbles: true })); + $('[data-key-setup-apply]').click(); + await nextTick(); + field.focus(); + finish({ + ok: true, + json: async () => ({ ok: true, saved: [], status: STATUS }), + }); + await wait(); + await nextTick(); + const fresh = $('input[data-env-var="GOOGLE_MAPS_API_KEY"]'); + assert.equal(fresh.value, ''); + assert.equal(fresh.isConnected, true); +}); + +test('a repeated status message is written again for the live region', async (t) => { + const { $ } = await fixture(t); + const line = $('[data-key-setup-status]'); + const writes = []; + const observer = new MutationObserver((records) => + writes.push(records.length), + ); + observer.observe(line, { + childList: true, + characterData: true, + subtree: true, + }); + t.after(() => observer.disconnect()); + $('[data-key-setup-apply]').click(); + await nextTick(); + await wait(); + const first = writes.length; + assert.ok(first > 0); + $('[data-key-setup-apply]').click(); + await nextTick(); + await wait(); + assert.ok(writes.length > first); + assert.equal(line.textContent, 'Paste at least one key first.'); +}); + +test('the chip does nothing until the setup endpoint has answered', async (t) => { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp(ProviderSettingsView).use(pinia); + app.mount(host); + const store = useProviderSettingsStore(pinia); + const controller = new AbortController(); + t.after(() => { + controller.abort(); + app.unmount(); + }); + void initKeySetup({ + documentRef: document, + fetchImpl: () => new Promise(() => {}), + signal: controller.signal, + store, + }); + await nextTick(); + store.controls.open(); + await nextTick(); + assert.equal(document.getElementById('key-setup').hidden, true); +}); diff --git a/src/ui/stores/providerSettings.js b/src/ui/stores/providerSettings.js new file mode 100644 index 000000000..7679e0bc5 --- /dev/null +++ b/src/ui/stores/providerSettings.js @@ -0,0 +1,31 @@ +import { defineStore } from 'pinia'; +import { markRaw, shallowRef } from 'vue'; + +/** + * Provider Settings (the dev-only key setup) as its view renders it. The + * setup owner (src/keySetup.js) presents the POWER UP chip, the dialog, its + * key rows, the field values and the status line, and publishes them here; + * the view sends clicks and typing to the connected owner. + */ +export const useProviderSettingsStore = defineStore( + 'gev-provider-settings', + () => { + /** The presented setup; null until the owner first publishes. */ + const view = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + view.value = next && markRaw(next); + } + + /** The setup owner the controls call, or null while none is wired. */ + const controls = shallowRef(null); + + /** @param {?object} next The owner, or null to disconnect. */ + function connect(next) { + controls.value = next && markRaw(next); + } + + return { view, publish, controls, connect }; + }, +); diff --git a/src/ui/views/ProviderSettingsView.vue b/src/ui/views/ProviderSettingsView.vue index 1b05cc6e3..dc575e877 100644 --- a/src/ui/views/ProviderSettingsView.vue +++ b/src/ui/views/ProviderSettingsView.vue @@ -1,40 +1,112 @@ diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 028536fee..791f4f9c5 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -9,6 +9,8 @@ import { resolveDeicticArgs } from './deixis.js'; export { readLayerLifecycleSummary } from './layerSummary.js'; import { defaultGeospatial } from '../search/defaults.js'; import { msg } from '../i18n/index.js'; +import { useProviderSettingsStore } from '../ui/stores/providerSettings.js'; +import { applicationPinia } from '../ui/vueRuntime.js'; import * as Cesium from 'cesium'; import { CITY_POIS } from '../cityPois.js'; import { greatCircleKm } from '../geoDistance.js'; @@ -772,10 +774,9 @@ export function createGevActionRunner({ cancelled: true, }; const doc = globalThis.document; - const panel = doc?.getElementById?.('key-setup'); - const button = open - ? doc?.getElementById?.('key-setup-chip') - : panel?.querySelector?.('[data-key-setup-close]'); + // The setup owner (src/keySetup.js) publishes the dialog and takes + // its open and close requests. + const settings = useProviderSettingsStore(applicationPinia()); const concealed = open && [ @@ -784,11 +785,7 @@ export function createGevActionRunner({ 'cockpit-mode', 'scene-playback-mode', ].some((mode) => doc?.body?.classList?.contains(mode)); - if ( - concealed || - panel?.dataset?.ready !== 'true' || - typeof button?.click !== 'function' - ) { + if (concealed || settings.view?.ready !== true || !settings.controls) { const error = concealed ? 'Provider Settings is hidden in the current view. Leave that view before opening it.' : 'Provider Settings is unavailable in this runtime.'; @@ -802,11 +799,11 @@ export function createGevActionRunner({ say: error, }; } - button.click(); - // Opening unhides immediately; closing removes visible before its fade ends. - const applied = open - ? !panel.hidden - : !panel.classList.contains('visible'); + if (open) settings.controls.open(); + else settings.controls.close(); + // Opening unhides at once; closing drops visible before its fade ends. + const dialog = settings.view.dialog; + const applied = open ? !dialog.hidden : !dialog.visible; const say = applied ? `Provider Settings is ${open ? 'open' : 'closed'}.` : 'Provider Settings did not change.'; @@ -815,7 +812,7 @@ export function createGevActionRunner({ action: name, panelId, applied, - open: applied ? open : !panel.hidden, + open: applied ? open : !dialog.hidden, say, ...(!applied ? { error: say } : {}), }; diff --git a/src/voice/panel/host.test.mjs b/src/voice/panel/host.test.mjs index ce8d6e50d..b08871a19 100644 --- a/src/voice/panel/host.test.mjs +++ b/src/voice/panel/host.test.mjs @@ -681,19 +681,12 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o f.run('set_panel_open', { panelId: 'provider-settings', open }, options); assert.equal((await request(true)).unavailable, true); const doc = globalThis.document; - const chip = doc.createElement('button'); - chip.id = 'key-setup-chip'; + // The setup owner needs only its dialog element; the view is not mounted, + // so the published dialog state stands in for it. const panel = doc.createElement('aside'); panel.id = 'key-setup'; - panel.hidden = true; - const close = doc.createElement('button'); - panel.appendChild(close); - doc.body.append(chip, panel); - const query = panel.querySelector.bind(panel); - panel.querySelector = (selector) => - selector === '[data-key-setup-close]' ? close : query(selector); - chip.click = () => chip.fire('click'); - close.click = () => close.fire('click'); + doc.body.append(panel); + const dialog = () => settings.view?.dialog; let frames = 0; const previousFrame = globalThis.requestAnimationFrame; globalThis.requestAnimationFrame = (callback) => { @@ -705,6 +698,10 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o else globalThis.requestAnimationFrame = previousFrame; }); const { initKeySetup } = await import('../../keySetup.js'); + const { useProviderSettingsStore } = + await import('../../ui/stores/providerSettings.js'); + const { applicationPinia } = await import('../../ui/vueRuntime.js'); + const settings = useProviderSettingsStore(applicationPinia()); let finishStatus; const pending = initKeySetup({ documentRef: doc, @@ -727,7 +724,7 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o doc.body.classList.add('cockpit-mode'); assert.equal((await request(true)).unavailable, true); assert.equal( - panel.hidden, + dialog().hidden, true, 'a concealed view cannot schedule a later surprise reveal', ); @@ -737,16 +734,16 @@ test('Provider Settings uses its ready dialog handlers and refuses unavailable o await assert.rejects(() => request(true, { signal: abort.signal }), { name: 'AbortError', }); - assert.equal(panel.hidden, true); + assert.equal(dialog().hidden, true); assert.equal((await request(true)).ok, true); - assert.equal(panel.hidden, false); - assert.equal(panel.classList.contains('visible'), true); + assert.equal(dialog().hidden, false); + assert.equal(dialog().visible, true); assert.equal((await request(true)).ok, true); assert.equal(frames, 1, 'repeated open reuses the dialog'); assert.equal((await request(false)).ok, true); - assert.equal(panel.classList.contains('visible'), false); + assert.equal(dialog().visible, false); t.mock.timers.tick(400); - assert.equal(panel.hidden, true); + assert.equal(dialog().hidden, true); assert.equal((await request(false)).ok, true); assert.equal( f.state.visual.length,