Merge ngit external-access PR 79ca68c1 into combined UAT candidate

Preserve current maintenance/session guards, Firewall UI and existing catalogs.
Retain scoped guest access, publishing journeys and local Blossom integration.
Normalize Blossom/router memory units to supported quadlet suffixes.

Validation: 108 dashboard tests, 10 gateway policy tests, strict source catalog
check. Integrated isolated backend qualification remains required before main.
This commit is contained in:
archipelago
2026-10-08 18:59:24 -04:00
100 changed files with 7479 additions and 111 deletions
+23 -12
View File
@@ -1,5 +1,5 @@
<template>
<BaseModal :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
<BaseModal :manage-history="false" :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
<p class="text-sm text-white/60 mb-4">Choose how your assistant connects. API keys stay on this node and never enter your chat.</p>
<p v-if="loading" role="status" class="text-sm text-white/60 mb-3">Checking this node…</p>
<p v-if="error" role="alert" class="text-sm text-amber-200 mb-3">{{ error }}</p>
@@ -8,19 +8,22 @@
</div>
<form v-if="mode === 'claude' || mode === 'openai'" class="space-y-3" @submit.prevent="save">
<p class="text-xs text-white/60">{{ mode === 'openai' ? 'Use an OpenAI API key with API billing. Choose a chat model your project can access.' : 'Use your Anthropic API key. Usage is billed to that API account.' }}</p>
<p v-if="configured" class="text-sm text-white/70">A key is already configured. Leave this field empty to keep it.</p>
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
<p v-if="configured" class="text-sm text-white/70" role="status">Your {{ mode === 'claude' ? 'Claude' : 'OpenAI' }} API key from Settings is ready to use.</p>
<button v-if="configured && !replacingKey" type="button" class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="replacingKey = true">Change API key</button>
<template v-if="status && (!configured || replacingKey)">
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
</template>
<template v-if="mode === 'openai'">
<label class="block text-sm text-white/80" for="ai-connection-model">Model ID</label>
<input id="ai-connection-model" v-model="model" type="text" spellcheck="false" class="input-glass w-full" placeholder="Enter your OpenAI model ID" :disabled="saving" />
</template>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : 'Save and continue' }}</button>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : configured && !key.trim() ? (mode === 'claude' ? 'Use Claude' : 'Use OpenAI') : 'Save and continue' }}</button>
</form>
<div v-else-if="mode === 'routstr'" class="space-y-3">
<p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p>
<p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Add ecash</button>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Top up with ecash</button>
<RoutstrBudgetSection />
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button>
<p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p>
@@ -28,14 +31,17 @@
<button v-if="status?.local_ready" class="glass-button rounded-lg px-4 py-2 text-sm text-white mt-4 w-full" @click="useLocal">Use local AI</button>
<p class="text-xs text-white/40 mt-4">Your draft stays in place when you close this window.</p>
</BaseModal>
<ReceiveBitcoinModal :show="show && funding" initial-method="ecash" @close="finishFunding" @received="finishFunding" />
<ReceiveBitcoinModal :manage-history="false" :show="show && funding && !scanning" initial-method="ecash" @close="finishFunding" @received="finishFunding" @scan="scanning = true" />
<WalletScanModal :show="show && scanning" @close="finishScan" @sent="finishScan" />
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useModalHistory } from '@/composables/useModalHistory'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from './BaseModal.vue'
import ReceiveBitcoinModal from './ReceiveBitcoinModal.vue'
import WalletScanModal from './WalletScanModal.vue'
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
type Choice = 'claude' | 'openai' | 'routstr'
@@ -49,9 +55,12 @@ interface ProviderStatus {
}
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>()
const choices: { id: Choice; label: string }[] = [{ id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }, { id: 'routstr', label: 'Routstr · sats' }]
const mode = ref<Choice | null>(null)
const choices: { id: Choice; label: string }[] = [{ id: 'routstr', label: 'Routstr · sats' }, { id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }]
// Keep one browser Back entry while switching between connection, funding and scan.
useModalHistory(computed(() => props.show), close)
const mode = ref<Choice | null>('routstr')
const key = ref('')
const replacingKey = ref(false)
const model = ref('')
const status = ref<ProviderStatus | null>(null)
const balance = ref<number | null>(null)
@@ -59,6 +68,7 @@ const error = ref('')
const loading = ref(false)
const saving = ref(false)
const funding = ref(false)
const scanning = ref(false)
const configured = computed(() => mode.value === 'claude' ? status.value?.claude_configured : status.value?.openai_configured)
let refreshPromise: Promise<ProviderStatus | null> | null = null
async function refresh(): Promise<ProviderStatus | null> {
@@ -95,8 +105,8 @@ async function refreshBalance() {
try { const result = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 8000 }); balance.value = Number.isFinite(result.balance_sats) ? result.balance_sats : null }
catch { balance.value = null }
}
function choose(choice: Choice) { key.value = ''; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
function close() { key.value = ''; funding.value = false; emit('close') }
function choose(choice: Choice) { key.value = ''; replacingKey.value = false; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
function close() { key.value = ''; funding.value = false; scanning.value = false; emit('close') }
async function save() {
if ((mode.value !== 'claude' && mode.value !== 'openai') || saving.value) return
const provider = mode.value
@@ -128,8 +138,9 @@ async function continueRoutstr() {
emit('configured', 'routstr'); close()
} catch { error.value = 'Could not select Routstr. Try again.' }
}
async function finishScan() { scanning.value = false; await refreshBalance() }
async function finishFunding() { funding.value = false; await refreshBalance() }
watch(() => props.show, open => { if (open) { error.value = ''; void refresh() } else { key.value = ''; funding.value = false } })
watch(() => props.show, open => { if (open) { error.value = ''; void refresh(); if (mode.value === 'routstr') void refreshBalance() } else { key.value = ''; replacingKey.value = false; funding.value = false; scanning.value = false } })
async function syncSelection() { const state = await refresh(); if (state) emit('configured', state.settings.provider, state.settings.provider === 'openai' ? state.settings.openai_model : undefined) }
defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') })
</script>
+4 -1
View File
@@ -62,10 +62,13 @@ const props = withDefaults(defineProps<{
maxWidth?: string
zIndex?: string
contentClass?: string
/** A parent flow may own one history entry across several modal panels. */
manageHistory?: boolean
}>(), {
maxWidth: 'max-w-md',
zIndex: 'z-[3000]',
contentClass: '',
manageHistory: true,
})
const emit = defineEmits<{
@@ -109,7 +112,7 @@ useBodyScrollLock(computed(() => props.show))
// Browser/mouse/gesture Back closes the modal instead of navigating the
// router out from under it — the native-app behaviour kiosk and mobile
// browsers expect (the companion webview already provides it natively).
useModalHistory(computed(() => props.show), close)
useModalHistory(computed(() => props.show && props.manageHistory), close)
</script>
<style scoped>
+3 -1
View File
@@ -6,7 +6,7 @@
<RouterLink
v-for="(goal, idx) in goals"
:key="goal.id"
:to="`/dashboard/goals/${goal.id}`"
:to="goal.route || `/dashboard/goals/${goal.id}`"
class="goal-card glass-card p-6 block"
:class="{ 'home-card-animate': animate }"
:style="{ '--card-stagger': idx }"
@@ -85,6 +85,8 @@ function goalAppIcons(goal: GoalDefinition): { appId: string; url: string }[] {
function goalIcon(icon: string): string {
const icons: Record<string, string> = {
globe: '🌐',
website: '📝',
shop: '🏪',
payments: '⚡',
photos: '📸',
@@ -1,5 +1,5 @@
<template>
<BaseModal :show="show && !receiveSuccess" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<BaseModal :manage-history="manageHistory !== false" :show="show && !receiveSuccess" :title="t('web5.receiveBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
<!-- Method tabs -->
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
<button
@@ -111,6 +111,7 @@
<!-- Completion is deliberately its own modal, matching the Lightning
payment moment. It is not an inline status inside the receive form. -->
<BaseModal
:manage-history="manageHistory !== false"
:show="show && !!receiveSuccess"
title="Payment received"
max-width="max-w-2xl"
@@ -147,6 +148,7 @@ const lightning = useLightningRequired()
const props = defineProps<{
show: boolean
manageHistory?: boolean
initialMethod?: 'lightning' | 'onchain' | 'ecash' | 'ark'
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
note?: string
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
const props = defineProps<{ options: { id: string; name: string }[]; disabled?: boolean }>()
const selected = defineModel<string>({ required: true })
const root = ref<HTMLElement>()
const trigger = ref<HTMLButtonElement>()
const search = ref<HTMLInputElement>()
const open = ref(false)
const query = ref('')
const active = ref(0)
const uid = useId()
const filtered = computed(() => props.options.filter(option => `${option.name} ${option.id}`.toLowerCase().includes(query.value.trim().toLowerCase())))
const label = computed(() => props.options.find(option => option.id === selected.value)?.name || 'Choose an app')
watch(query, () => { active.value = 0 })
watch(() => props.disabled, value => { if (value) open.value = false })
watch(() => props.options, options => { if (selected.value && !options.some(option => option.id === selected.value)) selected.value = '' })
function toggle() { open.value = !open.value; query.value = ''; active.value = 0 }
function choose(id: string) { selected.value = id; open.value = false; trigger.value?.focus() }
function outside(event: Event) { if (!root.value?.contains(event.target as Node)) open.value = false }
async function keyboard(event: KeyboardEvent) {
if (event.key === 'Escape') { event.preventDefault(); open.value = false; trigger.value?.focus(); return }
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(event.key)) return
if (!open.value) {
if (event.key === 'Enter') return // native button click opens it
event.preventDefault(); toggle(); await nextTick(); search.value?.focus(); return
}
if (event.key === 'Enter' && event.target === search.value) { event.preventDefault(); if (filtered.value[active.value]) choose(filtered.value[active.value]!.id); return }
if (event.key === 'Enter') return
event.preventDefault()
active.value = Math.max(0, Math.min(filtered.value.length - 1, active.value + (event.key === 'ArrowDown' ? 1 : -1)))
search.value?.focus()
await nextTick()
document.getElementById(`${uid}-option-${active.value}`)?.scrollIntoView?.({ block: 'nearest' })
}
onMounted(() => { document.addEventListener('pointerdown', outside); document.addEventListener('focusin', outside) })
onBeforeUnmount(() => { document.removeEventListener('pointerdown', outside); document.removeEventListener('focusin', outside) })
</script>
<template>
<div ref="root" class="min-w-0" @keydown="keyboard">
<span :id="`${uid}-label`" class="block mb-2">Application</span>
<button ref="trigger" type="button" class="w-full flex items-center justify-between gap-3 rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-left text-sm text-white/90" :disabled="disabled" :aria-labelledby="`${uid}-label ${uid}-value`" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="`${uid}-list`" @click="toggle">
<span :id="`${uid}-value`" class="truncate">{{ label }}</span>
<svg class="w-4 h-4 shrink-0 text-white/55" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</button>
<!-- In normal flow so a walkthrough card, keyboard or WebView cannot clip it. -->
<div v-if="open" class="glass-card rounded-lg mt-2 p-2 border border-white/10 shadow-xl">
<input ref="search" v-model="query" type="search" role="combobox" aria-label="Search apps" aria-autocomplete="list" aria-expanded="true" :aria-controls="`${uid}-list`" :aria-activedescendant="filtered.length ? `${uid}-option-${active}` : undefined" autocomplete="off" class="w-full rounded-lg border border-white/15 bg-black/20 px-3 py-3 text-sm text-white mb-2" placeholder="Search apps…" />
<div :id="`${uid}-list`" role="listbox" aria-label="Supported applications" class="max-h-56 overflow-y-auto overscroll-contain">
<button v-for="(option, index) in filtered" :id="`${uid}-option-${index}`" :key="option.id" type="button" role="option" :aria-selected="selected === option.id" class="w-full flex items-center justify-between gap-3 rounded-lg px-3 py-3 text-left text-sm text-white/80 hover:bg-white/10 focus-visible:bg-white/10" :class="{ 'bg-white/10 text-white': active === index || selected === option.id }" @click="choose(option.id)">
<span class="truncate">{{ option.name }}</span><span v-if="selected === option.id" aria-hidden="true" class="text-orange-300">✓</span>
</button>
</div>
<p v-if="!filtered.length" role="status" class="px-3 py-4 text-sm text-white/60">No matching apps. Try another name.</p>
</div>
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
export interface SetupStep { id: string; title: string; description: string; complete: boolean }
const props = defineProps<{ steps: SetupStep[]; busy?: boolean; continueLabel?: string; continueDisabled?: boolean }>()
const emit = defineEmits<{ next: [step: string] }>()
const active = defineModel<string>({ required: true })
const root = ref<HTMLElement>()
const index = computed(() => Math.max(0, props.steps.findIndex(step => step.id === active.value)))
const completed = computed(() => props.steps.filter(step => step.complete).length)
watch(() => props.steps, steps => {
if (!steps.some(step => step.id === active.value)) active.value = steps.find(step => !step.complete)?.id ?? steps[0]?.id ?? ''
})
watch(active, async () => {
await nextTick()
const heading = root.value?.querySelector<HTMLButtonElement>('button[aria-expanded="true"]')
heading?.focus({ preventScroll: true })
heading?.scrollIntoView?.({ block: 'nearest', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' })
})
function move(offset: number) {
const step = props.steps[index.value + offset]
if (step) active.value = step.id
}
</script>
<template>
<div ref="root">
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-white/60">Step {{ index + 1 }} of {{ steps.length }}</span>
<span class="goal-status-badge goal-status-badge-in-progress">In progress</span>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden" role="progressbar" aria-label="Saved setup steps" :aria-valuenow="completed" :aria-valuemax="steps.length" aria-valuemin="0">
<div class="h-full rounded-full bg-orange-400 transition-all duration-500 ease-out" :style="{ width: `${(completed / steps.length) * 100}%` }" />
</div>
</div>
<div class="space-y-3">
<section v-for="(step, stepIndex) in steps" :key="step.id" class="glass-card p-0 overflow-hidden">
<div class="goal-step" :class="{ 'goal-step-completed': step.complete, 'goal-step-active': active === step.id, 'goal-step-pending': stepIndex > index && !step.complete }">
<button type="button" class="flex items-start gap-4 w-full text-left" :disabled="busy" :aria-expanded="active === step.id" :aria-controls="`setup-step-${step.id}`" @click="active = step.id">
<span class="mt-0.5 shrink-0 w-6 h-6 rounded-full flex items-center justify-center" :class="step.complete ? 'bg-green-500/20' : 'bg-white/10'">
<svg v-if="step.complete" class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
<span v-else class="text-xs text-white/40 font-medium">{{ stepIndex + 1 }}</span>
</span>
<span class="flex-1 min-w-0"><span class="block text-base font-semibold text-white/90 mb-1">{{ step.title }}</span><span class="block text-sm text-white/55 leading-relaxed">{{ step.description }}</span></span>
</button>
<div v-if="active === step.id" :id="`setup-step-${step.id}`" class="mt-5 sm:ml-10">
<fieldset :disabled="busy" class="space-y-8 min-w-0"><slot :name="step.id" /></fieldset>
<div class="flex flex-wrap items-center gap-3 mt-8 pt-6 border-t border-white/10">
<button v-if="stepIndex > 0" type="button" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="move(-1)">Back</button>
<button v-if="stepIndex < steps.length - 1" type="button" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || continueDisabled" @click="emit('next', steps[stepIndex + 1]!.id)">{{ continueLabel || (step.id === 'storage' ? 'Continue without installing' : 'Continue') }}<span aria-hidden="true">→</span></button>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.goal-step-pending { opacity: .72; }
button:focus-visible { outline: 2px solid #fb923c; outline-offset: 4px; }
@media (prefers-reduced-motion: reduce) { .transition-all { transition: none; } }
</style>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { websitePreview } from '@/services/publishing'
const props = defineProps<{
publication: { html: string; public_archive?: string | null }
archive?: { sha256: string; size: number } | null
address?: string | null
busy?: boolean
}>()
const emit = defineEmits<{ change: [enabled: boolean] }>()
const approved = ref(false)
watch(() => [props.publication, props.archive], () => { approved.value = false })
const fileAddress = computed(() => props.address && props.publication.public_archive
? `${props.address.replace(/\/$/, '')}/${props.publication.public_archive}` : null)
</script>
<template>
<details class="rounded-xl border border-white/10 p-4">
<summary class="cursor-pointer text-sm font-medium text-white/80">Share the archived website file</summary>
<div class="space-y-4 pt-4">
<p class="text-sm text-white/60">Let visitors download this exact website snapshot by its Blossom content hash. Other files and the Blossom app stay private. No upload or Nostr announcement is made.</p>
<template v-if="publication.public_archive">
<p class="text-sm">This snapshot is shared on this connection.</p>
<p v-if="fileAddress" class="font-mono text-xs break-all select-all">{{ fileAddress }}</p>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy" @click="emit('change', false)">Stop sharing this file</button>
</template>
<template v-else-if="archive">
<p class="text-sm text-white/60">The archived file must match the published preview below. Store and publish the same version before sharing.</p>
<p class="font-mono text-xs break-all">Archived SHA-256: {{ archive.sha256 }} · {{ archive.size }} bytes</p>
<iframe :srcdoc="websitePreview(publication.html)" sandbox="" referrerpolicy="no-referrer" title="Archived file publication preview" class="w-full h-64 rounded-xl bg-white" />
<label class="flex items-start gap-3 text-sm"><input v-model="approved" type="checkbox" class="mt-1" /><span>I approve public downloads of this exact snapshot on this connection. Copies may remain after I stop sharing.</span></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="busy || !approved" @click="emit('change', true)">Share this archived file</button>
</template>
<p v-else class="text-sm text-white/60">In “Create your website”, save a signed copy in local Blossom first.</p>
<p class="text-xs text-white/50">Publishing an update turns file sharing off until you review that version again.</p>
</div>
</details>
</template>
@@ -1,15 +1,49 @@
import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AIConnectionModal from '../AIConnectionModal.vue'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import WalletScanModal from '../WalletScanModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '<div />' } }))
vi.mock('../WalletScanModal.vue', () => ({ default: { props: ['show'], template: '<div />' } }))
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod', 'manageHistory'], template: '<div />' } }))
vi.mock('@/views/settings/RoutstrBudgetSection.vue', () => ({ default: { template: '<div />' } }))
const state = () => ({ schema: 1, settings: { provider: 'auto', openai_model: '' }, claude_configured: false, openai_configured: false, local_ready: false, routstr_remaining_sats: 0 })
function mountModal() { return mount(AIConnectionModal, { props: { show: false }, global: { stubs: { BaseModal: { props: ['show'], template: '<div v-if="show"><slot /></div>' } } } }) }
function button(w: ReturnType<typeof mountModal>, label: string) { return w.findAll('button').find(b => b.text() === label)! }
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) })
describe('AI connection setup', () => {
it('recognizes the existing Claude key and selects it without reading or rewriting the secret', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: { ...state(), claude_configured: true } } : {})
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
await button(w, 'Claude API').trigger('click')
expect(w.text()).toContain('Your Claude API key from Settings is ready to use.')
expect(w.find('#ai-connection-key').exists()).toBe(false)
expect(button(w, 'Use Claude').attributes('disabled')).toBeUndefined()
await w.get('form').trigger('submit'); await flushPromises()
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
expect(writes.map(r => r.params)).toEqual([{ key: 'ai_provider', value: JSON.stringify({ provider: 'claude', openai_model: '' }) }])
expect(w.emitted('configured')).toEqual([['claude', undefined]])
await button(w, 'Change API key').trigger('click')
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
await w.setProps({ show: false }); w.unmount()
})
it('opens on Routstr with a top-up action without authorizing spending', async () => {
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
expect(button(w, 'Routstr · sats').attributes('aria-pressed')).toBe('true')
expect(button(w, 'Top up with ecash').exists()).toBe(true)
expect(vi.mocked(rpcClient.call).mock.calls.every(([r]) => !['assistant.budget-set', 'system.settings.set'].includes(r.method))).toBe(true)
await button(w, 'Top up with ecash').trigger('click')
expect(w.findComponent(ReceiveBitcoinModal).props()).toMatchObject({ show: true, initialMethod: 'ecash', manageHistory: false })
w.findComponent(ReceiveBitcoinModal).vm.$emit('scan'); await flushPromises()
expect(w.findComponent(WalletScanModal).props('show')).toBe(true)
expect(w.findComponent(ReceiveBitcoinModal).props('show')).toBe(false)
w.findComponent(WalletScanModal).vm.$emit('close'); await flushPromises()
expect(w.findComponent(ReceiveBitcoinModal).props('show')).toBe(true)
w.unmount()
})
it('detects absent configuration without treating a failed status query as missing keys', async () => {
const w = mountModal()
expect(await (w.vm as any).checkNeeded()).toBe(true)
@@ -26,7 +60,7 @@ describe('AI connection setup', () => {
await w.get('form').trigger('submit'); await flushPromises()
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
expect(writes.map(r => r.params)).toEqual([{ key: 'openai_api_key', value: 'test-private-key' }, { key: 'ai_provider', value: JSON.stringify({ provider: 'openai', openai_model: 'test-chat-model' }) }])
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
expect(w.find('#ai-connection-key').exists()).toBe(false)
expect(w.emitted('configured')).toEqual([['openai', 'test-chat-model']])
expect(JSON.stringify(w.emitted())).not.toContain('test-private-key')
w.unmount()
@@ -1,9 +1,20 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createRouter, createMemoryHistory } from 'vue-router'
import BaseModal from '../BaseModal.vue'
describe('BaseModal', () => {
it('lets a parent flow own history while panels change', async () => {
const push = vi.spyOn(window.history, 'pushState')
const back = vi.spyOn(window.history, 'back')
const wrapper = mount(BaseModal, { props: { show: false, title: 'Panel', manageHistory: false } })
await wrapper.setProps({ show: true })
await wrapper.setProps({ show: false })
expect(push).not.toHaveBeenCalled()
expect(back).not.toHaveBeenCalled()
wrapper.unmount(); push.mockRestore(); back.mockRestore()
})
afterEach(() => {
document.body.style.overflow = ''
})
@@ -0,0 +1,29 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import SearchableAppSelect from '../SearchableAppSelect.vue'
const options = [{ id: 'homeassistant', name: 'Home Assistant' }, { id: 'immich', name: 'Immich' }]
describe('searchable app picker', () => {
it('renders actual touchable options, filters them and selects only an offered app', async () => {
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
await wrapper.get('button').trigger('click')
expect(wrapper.findAll('[role="option"]')).toHaveLength(2)
await wrapper.get('input').setValue('imm')
expect(wrapper.findAll('[role="option"]')).toHaveLength(1)
await wrapper.get('[role="option"]').trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([['immich']])
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
it('supports keyboard choice and escape without selecting arbitrary search text', async () => {
const wrapper = mount(SearchableAppSelect, { props: { modelValue: '', options } })
await wrapper.get('button').trigger('keydown', { key: 'ArrowDown' })
await wrapper.get('input').setValue('home')
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('update:modelValue')).toEqual([['homeassistant']])
await wrapper.get('button').trigger('click')
await wrapper.get('input').setValue('unknown')
await wrapper.get('input').trigger('keydown', { key: 'Enter' })
expect(wrapper.emitted('update:modelValue')).toHaveLength(1)
await wrapper.get('input').trigger('keydown', { key: 'Escape' })
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
})
})
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import WebsiteArchiveSharing from '../WebsiteArchiveSharing.vue'
describe('archived website sharing consent', () => {
it('requires fresh approval after the reviewed snapshot changes', async () => {
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: '<h1>Reviewed</h1>' }, archive: { sha256: 'a'.repeat(64), size: 17 } } })
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.get('iframe').attributes('sandbox')).toBe('')
expect(wrapper.get('iframe').attributes('srcdoc')).toContain('<h1>Reviewed</h1>')
await wrapper.get('input').setValue(true)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('change')).toEqual([[true]])
await wrapper.setProps({ publication: { html: '<h1>Different</h1>' } })
expect(wrapper.get('button').attributes('disabled')).toBeDefined()
expect(wrapper.emitted('change')).toHaveLength(1)
})
it('keeps removal available without new approval and exposes only the selected hash', async () => {
const hash = 'a'.repeat(64)
const wrapper = mount(WebsiteArchiveSharing, { props: { publication: { html: 'public', public_archive: hash }, address: 'https://example.com/' } })
expect(wrapper.text()).toContain('https://example.com/'+hash)
expect(wrapper.find('input').exists()).toBe(false)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('change')).toEqual([[false]])
})
})