import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AppsMatrix } from './AppsMatrix'
import { Apps } from './Apps'
import { SlotProvider } from '../../app/ChromeSlot'
import { HubMode } from '../../ui/ClusterNodeSelect'
import * as api from '../../api/apps'
import type { AppsMatrixData, InstalledApp } from '../../api/apps'
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
/* mdns has Blocker v1.0 with an update; ndns has v2.0; Wild IP is only on mdns. ndns has no admin token. */
const DATA: AppsMatrixData = {
servers: [
{ id: 'mdns', ok: true, writable: true },
{ id: 'ndns', ok: true, writable: false },
],
apps: [
{
name: 'Blocker',
description: '',
configs: 'same',
versions: 'different',
servers: {
mdns: { installed: true, version: '1.0', updateAvailable: true, updateVersion: '2.0', configStatus: 'read' },
ndns: { installed: true, version: '2.0', configStatus: 'read' },
},
},
{
name: 'Wild IP',
description: '',
configs: 'single',
versions: 'single',
servers: {
mdns: { installed: true, version: '5.1', configStatus: 'read' },
ndns: { installed: false, configStatus: 'none' },
},
},
],
}
const ok = (results: api.TargetResult[]) => ({ kind: 'ok', data: { response: { results } } }) as never
function mockReads(data: AppsMatrixData = DATA, store = [{ name: 'Blocker', version: '2.0' }]) {
const matrix = vi.spyOn(api, 'getAppsMatrix').mockResolvedValue({ kind: 'ok', data: { response: data } } as never)
vi.spyOn(api, 'listStoreApps').mockResolvedValue({ kind: 'ok', data: { response: { storeApps: store } } } as never)
return matrix
}
async function openMenu(app: string) {
await userEvent.click(await screen.findByRole('button', { name: `Actions: ${app}` }))
}
describe('matrix actions', () => {
it('has no menu for a reader and one per row for a writer', async () => {
mockReads()
const { unmount } = render()
await screen.findByText('Blocker')
expect(screen.queryByRole('button', { name: /^Actions:/ })).not.toBeInTheDocument()
unmount()
render()
expect(await screen.findByRole('button', { name: 'Actions: Blocker' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Actions: Wild IP' })).toBeInTheDocument()
})
it('enables only the actions that make sense for the row', async () => {
mockReads()
render()
await openMenu('Blocker')
expect(screen.getByRole('menuitem', { name: 'Update on all' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Install on missing servers' })).toBeDisabled()
expect(screen.getByRole('menuitem', { name: 'Align versions' })).toBeEnabled()
expect(screen.getByRole('menuitem', { name: 'Uninstall from…' })).toBeEnabled()
})
it('offers Align versions only when the store has the newest installed version', async () => {
mockReads(DATA, [{ name: 'Blocker', version: '1.5' }])
render()
await openMenu('Blocker')
await waitFor(() => expect(api.listStoreApps).toHaveBeenCalled())
expect(screen.getByRole('button', { name: 'Align versions' })).toBeDisabled()
})
it('offers Install on missing servers for an app that is not everywhere', async () => {
mockReads()
render()
await openMenu('Wild IP')
expect(screen.getByRole('menuitem', { name: 'Install on missing servers' })).toBeEnabled()
expect(screen.getByRole('button', { name: 'Update on all' })).toBeDisabled()
})
it('shows the picker with a server the hub cannot write to disabled, with the reason', async () => {
mockReads()
render()
await openMenu('Wild IP')
await userEvent.click(screen.getByRole('menuitem', { name: 'Install on missing servers' }))
const dialog = await screen.findByRole('dialog', { name: 'Install Wild IP' })
// mdns already has it, ndns has no admin token: neither can be chosen.
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).toBeDisabled()
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
expect(within(dialog).getByText('Already installed')).toBeInTheDocument()
expect(within(dialog).getByText('No admin token configured on the hub')).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Install' })).toBeDisabled()
})
it('runs the change on the ticked servers, shows each result and refreshes', async () => {
const matrix = mockReads()
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true, message: 'Updated from the store.' }]))
render()
await openMenu('Blocker')
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
const dialog = await screen.findByRole('dialog', { name: 'Update Blocker' })
expect(within(dialog).getByRole('checkbox', { name: 'mdns' })).toBeChecked()
await userEvent.click(within(dialog).getByRole('button', { name: 'Update' }))
expect(run).toHaveBeenCalledWith('t', 'update-store', 'Blocker', ['mdns'])
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
expect(within(results).getByText('Updated from the store.')).toBeInTheDocument()
expect(within(results).getByText('Done')).toBeInTheDocument()
expect(within(results).queryByRole('button', { name: 'Retry failed' })).not.toBeInTheDocument()
const before = matrix.mock.calls.length
await userEvent.click(within(results).getAllByRole('button', { name: 'Close' }).at(-1) as HTMLElement)
await waitFor(() => expect(matrix.mock.calls.length).toBeGreaterThan(before))
})
it('retries only the servers that failed', async () => {
const wide: AppsMatrixData = {
servers: [
{ id: 'mdns', ok: true, writable: true },
{ id: 'ndns', ok: true, writable: true },
],
apps: [{ ...DATA.apps[0], servers: { ...DATA.apps[0].servers, ndns: { installed: true, version: '1.0', updateAvailable: true, configStatus: 'read' } } }],
}
mockReads(wide)
const run = vi
.spyOn(api, 'runHubApps')
.mockResolvedValueOnce(ok([{ server: 'mdns', ok: true, message: 'Updated from the store.' }, { server: 'ndns', ok: false, message: 'Disk full.' }]))
.mockResolvedValueOnce(ok([{ server: 'ndns', ok: true, message: 'Updated from the store.' }]))
render()
await openMenu('Blocker')
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
const dialog = await screen.findByRole('dialog', { name: 'Update Blocker' })
await userEvent.click(within(dialog).getByRole('button', { name: 'Update' }))
expect(run).toHaveBeenLastCalledWith('t', 'update-store', 'Blocker', ['mdns', 'ndns'])
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
expect(within(results).getByText('Disk full.')).toBeInTheDocument()
await userEvent.click(within(results).getByRole('button', { name: 'Retry failed' }))
expect(run).toHaveBeenLastCalledWith('t', 'update-store', 'Blocker', ['ndns'])
await waitFor(() => expect(within(screen.getByRole('dialog')).queryByText('Disk full.')).not.toBeInTheDocument())
expect(within(screen.getByRole('dialog')).getAllByText('Done')).toHaveLength(2)
})
it('says the config is saved before an uninstall and starts with nothing ticked', async () => {
mockReads()
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
render()
await openMenu('Wild IP')
await userEvent.click(screen.getByRole('menuitem', { name: 'Uninstall from…' }))
const dialog = await screen.findByRole('dialog', { name: 'Uninstall Wild IP' })
expect(within(dialog).getByText(/saved to the hub's history/)).toBeInTheDocument()
expect(within(dialog).getByRole('button', { name: 'Uninstall' })).toBeDisabled()
await userEvent.click(within(dialog).getByRole('checkbox', { name: 'mdns' }))
await userEvent.click(within(dialog).getByRole('button', { name: 'Uninstall' }))
expect(run).toHaveBeenCalledWith('t', 'uninstall', 'Wild IP', ['mdns'])
})
it('shows a failed call as a failure with Retry', async () => {
mockReads()
const run = vi
.spyOn(api, 'runHubApps')
.mockResolvedValueOnce({ kind: 'error', message: 'Your role does not allow this.' } as never)
.mockResolvedValueOnce(ok([{ server: 'mdns', ok: true }]))
render()
await openMenu('Blocker')
await userEvent.click(screen.getByRole('menuitem', { name: 'Update on all' }))
await userEvent.click(within(await screen.findByRole('dialog', { name: 'Update Blocker' })).getByRole('button', { name: 'Update' }))
const results = await screen.findByRole('dialog', { name: 'Update Blocker: results' })
expect(within(results).getByText('Your role does not allow this.')).toBeInTheDocument()
await userEvent.click(within(results).getByRole('button', { name: 'Retry' }))
await waitFor(() => expect(run).toHaveBeenCalledTimes(2))
expect(await within(screen.getByRole('dialog')).findByText('Done')).toBeInTheDocument()
})
})
describe('Apps behind a hub, for writers', () => {
const INSTALLED: InstalledApp = { name: 'Split Horizon', description: 'd', version: '9.0', updateAvailable: true, updateVersion: '9.1', updateUrl: 'https://x/s.zip', dnsApps: [] }
const NODES = [{ name: 'mdns', type: 'Primary' }, { name: 'ndns', type: 'Secondary' }]
function mount(props: { canWrite: boolean; writable: string[] }) {
return render(
{(ref) => (
<>
>
)}
,
)
}
it('shows App Store and Install from file on All servers to writers only', async () => {
mockReads()
const { unmount } = mount({ canWrite: false, writable: ['mdns'] })
await screen.findByText('Blocker')
expect(screen.queryByRole('button', { name: 'App Store' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Install from file' })).not.toBeInTheDocument()
unmount()
mount({ canWrite: true, writable: ['mdns'] })
expect(await screen.findByRole('button', { name: 'App Store' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
})
it('opens the hub store and sends an install through the picker', async () => {
mockReads()
vi.spyOn(api, 'listStoreApps').mockResolvedValue({
kind: 'ok',
data: { response: { storeApps: [{ name: 'Failover', description: 'Backup', version: '8.0', url: 'u', size: '1', installed: false, installedOn: [] }] } },
} as never)
const run = vi.spyOn(api, 'runHubApps').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
mount({ canWrite: true, writable: ['mdns'] })
await userEvent.click(await screen.findByRole('button', { name: 'App Store' }))
expect(await screen.findByText('Failover')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Install…' }))
const dialog = await screen.findByRole('dialog', { name: 'Install Failover' })
expect(within(dialog).getByRole('checkbox', { name: 'ndns' })).toBeDisabled()
await userEvent.click(within(dialog).getByRole('button', { name: 'Install' }))
expect(run).toHaveBeenCalledWith('t', 'install-store', 'Failover', ['mdns'])
})
it('uploads a zip to the chosen servers', async () => {
mockReads()
const upload = vi.spyOn(api, 'uploadHubApp').mockResolvedValue(ok([{ server: 'mdns', ok: true }]))
mount({ canWrite: true, writable: ['mdns'] })
await userEvent.click(await screen.findByRole('button', { name: 'Install from file' }))
const form = await screen.findByRole('dialog', { name: 'Install App' })
await userEvent.type(within(form).getByLabelText('App Name'), 'Mine')
const file = new File([new Uint8Array([1, 2, 3])], 'Mine.zip', { type: 'application/zip' })
await userEvent.upload(within(form).getByLabelText('App Zip File'), file)
await userEvent.click(within(form).getByRole('button', { name: 'Choose servers' }))
const picker = await screen.findByRole('dialog', { name: 'Install Mine from file' })
await userEvent.click(within(picker).getByRole('button', { name: 'Install' }))
expect(upload).toHaveBeenCalledWith('t', 'install', 'Mine', file, ['mdns'])
})
it('brings the single-server actions back for a writer, without Config, and sends them to that node', async () => {
mockReads()
localStorage.setItem('hubAppsNode', 'mdns')
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
const store = vi.spyOn(api, 'downloadAndUpdate').mockResolvedValue({ kind: 'ok', data: { response: {} } } as never)
mount({ canWrite: true, writable: ['mdns'] })
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Update' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'App Store' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Install from file' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /Actions: Split Horizon/ })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Config' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: 'Store Update' }))
expect(store).toHaveBeenCalledWith('t', 'Split Horizon', 'https://x/s.zip', 'mdns')
})
it('keeps a viewer, and a server without an admin token, read-only', async () => {
mockReads()
vi.spyOn(api, 'listApps').mockResolvedValue({ kind: 'ok', data: { response: { apps: [INSTALLED] } } } as never)
localStorage.setItem('hubAppsNode', 'ndns')
const { unmount } = mount({ canWrite: true, writable: ['mdns'] })
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
for (const name of ['Update', 'Store Update', 'App Store', 'Install from file']) {
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
}
unmount()
localStorage.setItem('hubAppsNode', 'mdns')
mount({ canWrite: false, writable: ['mdns'] })
expect(await screen.findByText('Split Horizon')).toBeInTheDocument()
for (const name of ['Update', 'Store Update', 'App Store', 'Install from file']) {
expect(screen.queryByRole('button', { name })).not.toBeInTheDocument()
}
})
})