frontend: polish app launch and release experience

This commit is contained in:
archipelago
2026-06-11 00:24:40 -04:00
parent c393b96da3
commit 1a3d726eac
140 changed files with 5930 additions and 920 deletions
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import ControllerIndicator from '@/components/ControllerIndicator.vue'
import { rpcClient } from '@/api/rpc-client'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
const { t } = useI18n()
const store = useAppStore()
@@ -13,6 +14,7 @@ const serverName = computed(() => store.serverName)
const editingServerName = ref(false)
const serverNameDraft = ref('')
const serverNameInput = ref<HTMLInputElement | null>(null)
const serverNameWarning = ref('')
function startEditServerName() {
serverNameDraft.value = serverName.value
@@ -22,13 +24,17 @@ function startEditServerName() {
async function saveServerName() {
const name = serverNameDraft.value.trim()
serverNameWarning.value = ''
if (!name || name === serverName.value) {
editingServerName.value = false
return
}
try {
await rpcClient.call({ method: 'server.set-name', params: { name } })
const result = await rpcClient.call<{ hostname_error?: string | null }>({ method: 'server.set-name', params: { name } })
store.updateServerName(name)
if (result.hostname_error) {
serverNameWarning.value = `Display name saved, but hostname update failed: ${result.hostname_error}`
}
} catch (e) {
if (import.meta.env.DEV) console.error('Failed to rename server:', e)
}
@@ -38,6 +44,7 @@ async function saveServerName() {
// Version & release notes
const version = computed(() => store.serverInfo?.version || '0.0.0')
const showReleaseNotes = ref(false)
useBodyScrollLock(showReleaseNotes)
// Identity
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
@@ -148,6 +155,7 @@ init()
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<p v-if="serverNameWarning" class="mt-2 text-xs text-yellow-300">{{ serverNameWarning }}</p>
</div>
<!-- Version Card -->
+21 -5
View File
@@ -27,6 +27,7 @@ const verifyingBackupId = ref<string | null>(null)
const deletingBackupId = ref<string | null>(null)
const backupStatusMsg = ref('')
const backupStatusType = ref<'success' | 'error'>('success')
const backupLoadError = ref('')
function formatBackupSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
@@ -42,12 +43,15 @@ function showBackupStatus(msg: string, type: 'success' | 'error') {
}
async function loadBackups() {
const hadBackups = backupList.value.length > 0
loadingBackups.value = true
backupLoadError.value = ''
try {
const res = await rpcClient.call<{ backups: BackupEntry[] }>({ method: 'backup.list' })
backupList.value = res.backups || []
} catch {
backupList.value = []
} catch (e: unknown) {
backupLoadError.value = e instanceof Error ? e.message : t('settings.backupListFailed')
if (!hadBackups) backupList.value = []
} finally {
loadingBackups.value = false
}
@@ -189,6 +193,8 @@ function copyChannelBackup() {
}
loadBackups()
defineExpose({ loadBackups })
</script>
<template>
@@ -204,9 +210,19 @@ loadBackups()
{{ t('settings.createBackup') }}
</button>
</div>
<div v-if="loadingBackups" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
<div v-if="loadingBackups && backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.loadingBackups') }}</div>
<div v-else-if="backupList.length === 0" class="text-sm text-white/40 py-4 text-center">{{ t('settings.noBackups') }}</div>
<div v-else class="space-y-2">
<div v-if="loadingBackups" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing backups...
</div>
<div v-else-if="backupLoadError" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
{{ backupLoadError }}
</div>
<div v-for="b in backupList" :key="b.id" class="flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 bg-white/5 rounded-lg gap-2">
<div class="min-w-0">
<div class="text-sm text-white font-medium">{{ b.description || t('settings.systemBackup') }}</div>
@@ -235,7 +251,7 @@ loadBackups()
<!-- Create Backup Modal -->
<Teleport to="body">
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showCreateBackupModal = false">
<div v-if="showCreateBackupModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showCreateBackupModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="create-backup-title">
<h3 id="create-backup-title" class="text-lg font-semibold text-white mb-4">{{ t('settings.createEncryptedBackup') }}</h3>
<div class="space-y-3">
@@ -260,7 +276,7 @@ loadBackups()
<!-- Restore Backup Modal -->
<Teleport to="body">
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md" @click.self="showRestoreModal = false">
<div v-if="showRestoreModal" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md" @click.self="showRestoreModal = false">
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="restore-backup-title">
<h3 id="restore-backup-title" class="text-lg font-semibold text-white mb-2">{{ t('settings.restoreBackupTitle') }}</h3>
<p class="text-sm text-red-400/80 mb-4">{{ t('settings.restoreWarning') }}</p>
@@ -13,6 +13,7 @@ useModalKeyboard(changePasswordModalRef, showChangePasswordModal, closeChangePas
const changingPassword = ref(false)
const changePasswordError = ref('')
const changePasswordSuccess = ref('')
const changePasswordWarning = ref('')
const changePasswordForm = ref({
currentPassword: '',
newPassword: '',
@@ -32,6 +33,7 @@ function validatePasswordStrength(pw: string): string | null {
async function handleChangePassword() {
changePasswordError.value = ''
changePasswordSuccess.value = ''
changePasswordWarning.value = ''
const { currentPassword, newPassword, confirmPassword, alsoChangeSsh } = changePasswordForm.value
if (!currentPassword || !newPassword || !confirmPassword) {
changePasswordError.value = t('settings.passwordAllFieldsRequired')
@@ -48,16 +50,21 @@ async function handleChangePassword() {
}
changingPassword.value = true
try {
await rpcClient.changePassword({
const result = await rpcClient.changePassword({
currentPassword,
newPassword,
alsoChangeSsh,
})
changePasswordSuccess.value = t('settings.passwordUpdatedSuccess')
if (alsoChangeSsh && result.ssh_error) {
changePasswordWarning.value = `${t('settings.passwordUpdatedSshFailed')} ${result.ssh_error}`
}
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
setTimeout(() => {
closeChangePasswordModal()
}, 2000)
if (!changePasswordWarning.value) {
setTimeout(() => {
closeChangePasswordModal()
}, 2000)
}
} catch (e) {
changePasswordError.value = e instanceof Error ? e.message : t('settings.passwordChangeFailed')
} finally {
@@ -70,6 +77,7 @@ function closeChangePasswordModal() {
showChangePasswordModal.value = false
changePasswordError.value = ''
changePasswordSuccess.value = ''
changePasswordWarning.value = ''
changePasswordForm.value = { currentPassword: '', newPassword: '', confirmPassword: '', alsoChangeSsh: true }
}
</script>
@@ -93,7 +101,7 @@ function closeChangePasswordModal() {
<Teleport to="body">
<div
v-if="showChangePasswordModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeChangePasswordModal()"
>
<div ref="changePasswordModalRef" class="glass-card p-6 max-w-md w-full">
@@ -139,6 +147,7 @@ function closeChangePasswordModal() {
</label>
<p v-if="changePasswordError" class="text-sm text-red-400">{{ changePasswordError }}</p>
<p v-if="changePasswordSuccess" class="text-sm text-green-400">{{ changePasswordSuccess }}</p>
<p v-if="changePasswordWarning" class="text-sm text-yellow-300">{{ changePasswordWarning }}</p>
<div class="flex gap-3 pt-2">
<button
type="submit"
@@ -12,9 +12,19 @@
<span class="text-white/40">Auto = FIPS first, Tor on failure.</span>
</p>
<div v-if="loading" class="text-white/50 text-sm">Loading</div>
<div v-else-if="error" class="text-red-400 text-sm">{{ error }}</div>
<div v-if="loading && !prefs" class="text-white/50 text-sm">Loading</div>
<div v-else-if="error && !prefs" class="text-red-400 text-sm">{{ error }}</div>
<div v-else class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing transport preferences...
</div>
<div v-else-if="error" class="p-2 rounded-lg border border-red-400/20 bg-red-500/10 text-red-200/85 text-xs">
{{ error }}
</div>
<div
v-for="svc in services"
:key="svc.key"
@@ -77,12 +87,14 @@ const saving = ref<Service | null>(null)
const error = ref<string | null>(null)
async function load() {
const hadPrefs = prefs.value !== null
loading.value = true
error.value = null
try {
prefs.value = await rpcClient.call<Preferences>({ method: 'transport.preferences' })
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to load transport preferences'
if (!hadPrefs) prefs.value = null
} finally {
loading.value = false
}
@@ -118,4 +130,6 @@ async function setPref(service: Service, pref: Pref) {
}
onMounted(load)
defineExpose({ load })
</script>
@@ -172,7 +172,7 @@ loadTotpStatus()
<Teleport to="body">
<div
v-if="showTotpSetupModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeTotpSetup"
@keydown.escape="closeTotpSetup"
>
@@ -279,7 +279,7 @@ loadTotpStatus()
<Teleport to="body">
<div
v-if="showTotpDisableModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/10 backdrop-blur-md"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeTotpDisable"
@keydown.escape="closeTotpDisable"
>
@@ -38,6 +38,8 @@ function formatBytes(bytes: number): string {
}
onMounted(fetchVpnStatus)
defineExpose({ fetchVpnStatus })
</script>
<template>
@@ -65,29 +67,47 @@ onMounted(fetchVpnStatus)
</div>
</div>
<div v-if="loading" class="text-sm text-white/50">Loading VPN status...</div>
<div v-if="loading && !vpnStatus" class="text-sm text-white/50">Loading VPN status...</div>
<div v-else-if="vpnStatus?.connected" class="grid grid-cols-2 gap-3">
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Provider</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'wireguard' }}</div>
<div v-else-if="vpnStatus?.connected" class="space-y-3">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing VPN status...
</div>
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Peers</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.peers_connected }}</div>
</div>
<div v-if="vpnStatus.ip_address" class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">VPN Address</div>
<div class="text-sm font-mono text-white/90">{{ vpnStatus.ip_address }}</div>
</div>
<div v-if="vpnStatus.bytes_in || vpnStatus.bytes_out" class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Traffic</div>
<div class="text-sm text-white/90">{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}</div>
<div class="grid grid-cols-2 gap-3">
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Provider</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.provider || 'wireguard' }}</div>
</div>
<div class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Peers</div>
<div class="text-sm font-medium text-white/90">{{ vpnStatus.peers_connected }}</div>
</div>
<div v-if="vpnStatus.ip_address" class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">VPN Address</div>
<div class="text-sm font-mono text-white/90">{{ vpnStatus.ip_address }}</div>
</div>
<div v-if="vpnStatus.bytes_in || vpnStatus.bytes_out" class="bg-white/5 rounded-lg px-3 py-2">
<div class="text-xs text-white/50 mb-1">Traffic</div>
<div class="text-sm text-white/90">{{ formatBytes(vpnStatus.bytes_in) }} / {{ formatBytes(vpnStatus.bytes_out) }}</div>
</div>
</div>
</div>
<div v-else-if="!vpnStatus?.connected" class="text-sm text-white/50">
VPN will activate automatically when peers are discovered via Nostr relays.
<div v-else-if="vpnStatus && !vpnStatus.connected" class="space-y-2">
<div v-if="loading" class="p-2 text-center text-white/45 text-xs flex items-center justify-center gap-2">
<svg class="animate-spin h-3.5 w-3.5" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Refreshing VPN status...
</div>
<div class="text-sm text-white/50">
VPN will activate automatically when peers are discovered via Nostr relays.
</div>
</div>
<div v-if="error" class="text-sm text-red-400 mt-2">{{ error }}</div>
@@ -0,0 +1,72 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import BackupSection from '../BackupSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeBackup() {
return {
id: 'backup-one',
created_at: '2026-06-10T10:00:00Z',
size_bytes: 2048,
encrypted: true,
description: 'Before upgrade',
}
}
describe('BackupSection', () => {
it('keeps backups visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce({ backups: [makeBackup()] })
const wrapper = mount(BackupSection, {
global: {
stubs: {
Teleport: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('2.0 KB')
const pending = deferred<{ backups: [] }>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { loadBackups: () => Promise<void> }).loadBackups()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('Refreshing backups...')
expect(wrapper.text()).not.toContain('settings.loadingBackups')
expect(wrapper.text()).not.toContain('settings.noBackups')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Before upgrade')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing backups...')
expect(wrapper.text()).not.toContain('settings.noBackups')
})
})
@@ -0,0 +1,58 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import ChangePasswordSection from '../ChangePasswordSection.vue'
const { changePassword } = vi.hoisted(() => ({
changePassword: vi.fn(),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
changePassword,
},
}))
vi.mock('@/composables/useModalKeyboard', () => ({
useModalKeyboard: vi.fn(),
}))
describe('ChangePasswordSection', () => {
beforeEach(() => {
changePassword.mockReset()
})
it('shows a warning when SSH update fails after web password update succeeds', async () => {
changePassword.mockResolvedValueOnce({
success: true,
ssh_updated: false,
ssh_error: 'sudo unavailable',
})
const wrapper = mount(ChangePasswordSection, {
global: {
stubs: {
Teleport: true,
},
},
})
await wrapper.find('button').trigger('click')
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('password123')
await inputs[1]!.setValue('MyP@ssw0rd!123')
await inputs[2]!.setValue('MyP@ssw0rd!123')
await wrapper.find('form').trigger('submit.prevent')
expect(changePassword).toHaveBeenCalledWith({
currentPassword: 'password123',
newPassword: 'MyP@ssw0rd!123',
alsoChangeSsh: true,
})
expect(wrapper.text()).toContain('settings.passwordUpdatedSuccess')
expect(wrapper.text()).toContain('settings.passwordUpdatedSshFailed sudo unavailable')
expect(wrapper.text()).not.toContain('settings.passwordChangeFailed')
})
})
@@ -0,0 +1,65 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import TransportPrefsCard from '../TransportPrefsCard.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makePrefs() {
return {
federation: 'auto',
peers: 'fips',
peer_files: 'tor',
messaging: 'auto',
mesh_file_sharing: 'fips',
}
}
describe('TransportPrefsCard', () => {
it('keeps transport preferences visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce(makePrefs())
const wrapper = mount(TransportPrefsCard)
await flushPromises()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('Auto')
expect(wrapper.text()).toContain('FIPS')
expect(wrapper.text()).toContain('Tor')
const pending = deferred<ReturnType<typeof makePrefs>>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { load: () => Promise<void> }).load()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('Refreshing transport preferences...')
expect(wrapper.text()).not.toContain('Loading…')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Federation')
expect(wrapper.text()).toContain('Peer Files')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing transport preferences...')
})
})
@@ -0,0 +1,66 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import VpnStatusSection from '../VpnStatusSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
},
}))
function deferred<T>() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
function makeVpnStatus() {
return {
connected: true,
provider: 'wireguard',
interface: 'wg0',
ip_address: '10.0.0.2/32',
hostname: 'node',
peers_connected: 2,
bytes_in: 1024,
bytes_out: 2048,
}
}
describe('VpnStatusSection', () => {
it('keeps VPN status visible while refresh is pending or fails', async () => {
vi.mocked(rpcClient.call).mockResolvedValueOnce(makeVpnStatus())
const wrapper = mount(VpnStatusSection)
await flushPromises()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('10.0.0.2/32')
const pending = deferred<ReturnType<typeof makeVpnStatus>>()
vi.mocked(rpcClient.call).mockReturnValueOnce(pending.promise)
const refresh = (wrapper.vm as unknown as { fetchVpnStatus: () => Promise<void> }).fetchVpnStatus()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('Refreshing VPN status...')
expect(wrapper.text()).not.toContain('Loading VPN status...')
pending.reject(new Error('offline'))
await refresh
await flushPromises()
expect(wrapper.text()).toContain('Connected')
expect(wrapper.text()).toContain('wireguard')
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing VPN status...')
})
})