From fe963a1219fb555c5aa4317bc7f1143d2e4ea9b2 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 18:22:20 -0700 Subject: [PATCH] feat(ui): render Provider Settings from a store initKeySetup keeps the setup requests, the ChatGPT OAuth flow, the pasted field values and the dialog's keyboard, and publishes the POWER UP chip, the dialog, its key rows and the status line to the gev-provider-settings store. ProviderSettingsView renders them and sends clicks and typing to the connected owner; in Cyber the chip is teleported into the top toolbar. A successful save renders the rows afresh so no field keeps a key, and the status line announces a repeated message again. The voice action opens and closes the dialog through the same controls. --- scripts/package-boundaries.json | 3 + src/keySetup.js | 602 +++++++++++--------------- src/keySetup.test.mjs | 91 +--- src/tooling/ll2Integration.test.mjs | 53 +-- src/ui/providerSettings.test.mjs | 421 ++++++++++++++++++ src/ui/stores/providerSettings.js | 31 ++ src/ui/views/ProviderSettingsView.vue | 98 ++++- src/voice/gevActions.js | 27 +- src/voice/panel/host.test.mjs | 31 +- 9 files changed, 840 insertions(+), 517 deletions(-) create mode 100644 src/ui/providerSettings.test.mjs create mode 100644 src/ui/stores/providerSettings.js 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,