Install & Onboarding:
- Remove DEV_MODE=true from production ISO service file (auto-created
users, skipped password setup)
- Auto-install no longer overwrites rootfs service file with bad template
- Login.vue always checks auth.isSetup — shows password creation form
on fresh install without requiring dev build flag
- Deploy image-versions.sh to /opt/archipelago/scripts/ on installed nodes
- First-boot-containers sources image-versions.sh, runs podman as
archipelago user (rootless), enables linger + podman.socket
- Correct volume ownership (100000:100000 for rootless UID mapping)
Container Security:
- FileBrowser: add --cap-add=DAC_OVERRIDE for rootless podman volume access
- FileBrowser: add --read-only, /data volume for database, proper cmd args
- First-boot script matches backend config (security hardening + health check)
CI Pipeline:
- Add vue-tsc type check + vitest run to build-iso.yml (runs every push)
- Add post-install-tests.yml workflow (workflow_dispatch, SSH to target)
- Build report: set +eo pipefail, fix rootfs path, add || true guards
- Bundle run-post-install-tests.sh into ISO
E2E Test Suite (scripts/run-post-install-tests.sh):
- Phase 1: Install verification (files, services, podman, linger, DEV_MODE check)
- Phase 2: Onboarding flow (auth.isSetup, auth.setup, login, DID, complete)
- Phase 3: Container lifecycle (install 3 apps via package.install RPC,
verify running, stop, verify stopped, restart, verify running, health)
- Phase 4: Log verification (first-boot log, diagnostics, journal errors)
- Correct package.install params: {"id", "dockerImage"}
Frontend:
- Fix backdrop-filter tab-switch bug (keep animations paused during rebuild)
- Dashboard glitch animations paused during tab-hidden
- Gamepad nav: auto-focus first container on route change
- Tab roving: Left/Right on role="tab" cycles and activates sibling tabs
- ContainerApps: data-controller-launch on running app cards
- 515 tests passing (fixed 30 broken, added 19 new keyboard nav tests)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
179 lines
5.6 KiB
TypeScript
179 lines
5.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
const mockPush = vi.fn()
|
|
|
|
vi.mock('vue-router', () => ({
|
|
useRouter: () => ({ push: mockPush }),
|
|
}))
|
|
|
|
vi.mock('@/api/rpc-client', () => ({
|
|
rpcClient: {
|
|
getReceivedMessages: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
import { useMessageToast } from '../useMessageToast'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
|
|
const mockedRpc = vi.mocked(rpcClient)
|
|
|
|
describe('useMessageToast', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.useFakeTimers()
|
|
// Reset shared singleton state
|
|
const toast = useMessageToast()
|
|
toast.stopPolling()
|
|
toast.receivedMessages.value = []
|
|
toast.lastMessageCount.value = 0
|
|
toast.loadingMessages.value = false
|
|
toast.toastMessage.value = { show: false, text: '' }
|
|
})
|
|
|
|
afterEach(() => {
|
|
const toast = useMessageToast()
|
|
toast.stopPolling()
|
|
vi.useRealTimers()
|
|
})
|
|
|
|
it('starts with empty state', () => {
|
|
const toast = useMessageToast()
|
|
expect(toast.receivedMessages.value).toEqual([])
|
|
expect(toast.lastMessageCount.value).toBe(0)
|
|
expect(toast.loadingMessages.value).toBe(false)
|
|
expect(toast.toastMessage.value.show).toBe(false)
|
|
expect(toast.unreadCount.value).toBe(0)
|
|
})
|
|
|
|
it('loadReceivedMessages fetches and stores messages', async () => {
|
|
mockedRpc.getReceivedMessages.mockResolvedValue({
|
|
messages: [
|
|
{ from_pubkey: 'abc', message: 'Hello', timestamp: '2026-01-01' },
|
|
],
|
|
})
|
|
const toast = useMessageToast()
|
|
await toast.loadReceivedMessages()
|
|
|
|
expect(toast.receivedMessages.value.length).toBe(1)
|
|
expect(toast.lastMessageCount.value).toBe(1)
|
|
expect(toast.loadingMessages.value).toBe(false)
|
|
})
|
|
|
|
it('does not show toast on initial load', async () => {
|
|
mockedRpc.getReceivedMessages.mockResolvedValue({
|
|
messages: [{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' }],
|
|
})
|
|
const toast = useMessageToast()
|
|
await toast.loadReceivedMessages()
|
|
|
|
expect(toast.toastMessage.value.show).toBe(false)
|
|
})
|
|
|
|
it('shows toast when new messages arrive after initial load', async () => {
|
|
const toast = useMessageToast()
|
|
|
|
// Initial load
|
|
mockedRpc.getReceivedMessages.mockResolvedValue({
|
|
messages: [{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' }],
|
|
})
|
|
await toast.loadReceivedMessages()
|
|
|
|
// New message arrives
|
|
mockedRpc.getReceivedMessages.mockResolvedValue({
|
|
messages: [
|
|
{ from_pubkey: 'a', message: 'First', timestamp: '2026-01-01' },
|
|
{ from_pubkey: 'b', message: 'Second', timestamp: '2026-01-02' },
|
|
],
|
|
})
|
|
await toast.loadReceivedMessages()
|
|
|
|
expect(toast.toastMessage.value.show).toBe(true)
|
|
expect(toast.toastMessage.value.text).toBe('Second')
|
|
})
|
|
|
|
it('shows count for multiple new messages', async () => {
|
|
const toast = useMessageToast()
|
|
|
|
// Initial load
|
|
mockedRpc.getReceivedMessages.mockResolvedValue({
|
|
messages: [{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' }],
|
|
})
|
|
await toast.loadReceivedMessages()
|
|
|
|
// Multiple new messages
|
|
mockedRpc.getReceivedMessages.mockResolvedValue({
|
|
messages: [
|
|
{ from_pubkey: 'a', message: 'One', timestamp: '2026-01-01' },
|
|
{ from_pubkey: 'b', message: 'Two', timestamp: '2026-01-02' },
|
|
{ from_pubkey: 'c', message: 'Three', timestamp: '2026-01-03' },
|
|
],
|
|
})
|
|
await toast.loadReceivedMessages()
|
|
|
|
expect(toast.toastMessage.value.show).toBe(true)
|
|
expect(toast.toastMessage.value.text).toBe('2 new messages')
|
|
})
|
|
|
|
it('unreadCount reflects difference', async () => {
|
|
const toast = useMessageToast()
|
|
toast.receivedMessages.value = [
|
|
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
|
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
|
]
|
|
toast.lastMessageCount.value = 1
|
|
expect(toast.unreadCount.value).toBe(1)
|
|
})
|
|
|
|
it('unreadCount is never negative', () => {
|
|
const toast = useMessageToast()
|
|
toast.receivedMessages.value = []
|
|
toast.lastMessageCount.value = 5
|
|
expect(toast.unreadCount.value).toBe(0)
|
|
})
|
|
|
|
it('markAsRead syncs lastMessageCount', () => {
|
|
const toast = useMessageToast()
|
|
toast.receivedMessages.value = [
|
|
{ from_pubkey: 'a', message: 'Hi', timestamp: '2026-01-01' },
|
|
{ from_pubkey: 'b', message: 'Hey', timestamp: '2026-01-02' },
|
|
]
|
|
toast.lastMessageCount.value = 0
|
|
toast.markAsRead()
|
|
expect(toast.lastMessageCount.value).toBe(2)
|
|
expect(toast.unreadCount.value).toBe(0)
|
|
})
|
|
|
|
it('dismissToastAndOpenMessages clears toast and navigates', () => {
|
|
const toast = useMessageToast()
|
|
toast.toastMessage.value = { show: true, text: 'New message' }
|
|
toast.dismissToastAndOpenMessages()
|
|
|
|
expect(toast.toastMessage.value.show).toBe(false)
|
|
expect(mockPush).toHaveBeenCalledWith('/dashboard/mesh')
|
|
})
|
|
|
|
it('stops polling on 401 error', async () => {
|
|
const toast = useMessageToast()
|
|
mockedRpc.getReceivedMessages.mockRejectedValue(new Error('401 Unauthorized'))
|
|
toast.startPolling()
|
|
|
|
// Wait for initial load triggered by startPolling
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
|
|
// Polling should have stopped, so advancing time should NOT call again
|
|
vi.clearAllMocks()
|
|
await vi.advanceTimersByTimeAsync(60000)
|
|
expect(mockedRpc.getReceivedMessages).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('startPolling does not create duplicate timers', () => {
|
|
const toast = useMessageToast()
|
|
mockedRpc.getReceivedMessages.mockResolvedValue({ messages: [] })
|
|
toast.startPolling()
|
|
toast.startPolling()
|
|
toast.startPolling()
|
|
// Should only have one timer — verify by stopping and checking no more calls
|
|
toast.stopPolling()
|
|
})
|
|
})
|