mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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.
This commit is contained in:
1 parent
6cb01ff975
commit
fe963a1219
9 files changed
+840
-517
No files matched your search
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user