diff --git a/mock/stats-stub/grid.js b/mock/stats-stub/grid.js
new file mode 100644
index 00000000..274ea521
--- /dev/null
+++ b/mock/stats-stub/grid.js
@@ -0,0 +1,143 @@
+// What the dashboard asks of a box to draw its home page without one: the
+// virtual machine module (in the shapes of CasaOS-VirtManagement's codegen
+// HostOK, VMListOK and VMOK), the app grid (WebAppGridItem) and the few core
+// calls the shell reads without a guard. A pure `handleGrid(request, state)`:
+//
+// request: { method, path, body } -> { status, body }, or null when the path is not one of these
+// state: newGridState() what was put in custom storage
+
+const GIB = 1073741824
+
+const HOST = {
+ libvirt: 'available',
+ libvirt_version: '9.0.0',
+ kvm: true,
+ uefi: true,
+ cpus: 8,
+ memory_mib: 16384,
+ network: 'casaos',
+ network_gateway: '10.8.0.1',
+ network_subnet: '10.8.0.0/24',
+ bridges: [],
+ links: ['eth0'],
+ storage_path: '/DATA/VM',
+ storage_free_bytes: 500 * GIB,
+}
+
+function aVm({ id, name, title, state, os, ip }) {
+ return {
+ id,
+ name,
+ title,
+ state,
+ os,
+ managed: true,
+ console: true,
+ vcpus: 2,
+ memory_mib: 2048,
+ firmware: 'uefi',
+ pending_restart: false,
+ network: { mode: 'casaos', name: 'casaos', casaos: true, fixed: true, ip, mac: '52:54:00:12:34:56' },
+ disks: [{ target: 'vda', path: `/DATA/VM/${name}.qcow2`, managed: true, boot: true, capacity_bytes: 32 * GIB, allocation_bytes: 4 * GIB }],
+ snapshots: [],
+ ports: [],
+ usb: [],
+ cdrom: '',
+ }
+}
+
+const VMS = [
+ aVm({ id: '11111111-aaaa-4aaa-8aaa-111111111111', name: '1a2b3c4d', title: 'Debian', state: 'running', os: 'debian', ip: '10.8.0.10' }),
+ aVm({ id: '22222222-bbbb-4bbb-8bbb-222222222222', name: '5e6f7a8b', title: 'Windows 11', state: 'shut-off', os: 'windows', ip: '10.8.0.11' }),
+ aVm({ id: '33333333-cccc-4ccc-8ccc-333333333333', name: '9c0d1e2f', title: '
', state: 'paused', os: '', ip: '10.8.0.12' }),
+]
+
+function anApp(name, title, port, image) {
+ return {
+ name,
+ title: { en_us: title },
+ icon: '',
+ status: 'running',
+ app_type: 'v2app',
+ author_type: 'by_casaos',
+ compose_project: name,
+ store_app_id: name,
+ scheme: 'http',
+ hostname: '',
+ port,
+ index: '/',
+ image,
+ is_uncontrolled: false,
+ update_available: false,
+ }
+}
+
+const APPS = [
+ anApp('jellyfin', 'Jellyfin', '8096', 'linuxserver/jellyfin:latest'),
+ anApp('nextcloud', 'Nextcloud', '10081', 'nextcloud:latest'),
+ anApp('immich', 'Immich', '2283', 'ghcr.io/immich-app/immich-server:release'),
+ anApp('plex', 'Plex', '32400', 'plexinc/pms-docker:latest'),
+]
+
+const UTILIZATION = {
+ cpu: { percent: 12, num: 4, temperature: 45, model: 'stub', power: { timestamp: '0', value: '0' } },
+ mem: { total: 8 * GIB, available: 5 * GIB, used: 3 * GIB, free: 5 * GIB, usedPercent: 37 },
+ sys_disk: { avail: 200 * GIB, health: true, size: 256 * GIB, used: 56 * GIB },
+ sys_usb: [],
+ net: [{ name: 'eth0', bytesSent: 0, bytesRecv: 0, packetsSent: 0, packetsRecv: 0, time: 0 }],
+}
+
+// the core's envelope
+const core = data => ({ status: 200, body: { success: 200, message: 'ok', data } })
+
+const CORE_GETS = {
+ '/v1/users/status': () => core({ initialized: true, key: '' }),
+ '/v1/users/current': () => core({ id: 1, username: 'casaos', nickname: 'casaos', role: 'admin', avatar: '', description: '' }),
+ '/v1/sys/version': () => core({ current_version: '0.4.15', need_update: false, version: { version: '0.4.15', change_log: '' } }),
+ '/v1/sys/hardware': () => core({ drive_model: 'stub', arch: 'amd64' }),
+ '/v1/sys/utilization': () => core(UTILIZATION),
+ '/v1/sys/telemetry': () => core({ enabled: false }),
+ '/v1/gateway/port': () => core('80'),
+ '/v1/usb/usb-auto-mount': () => core('False'),
+}
+
+const CUSTOM = /^\/v1\/users\/current\/custom\/([^/]+)$/
+const ONE_VM = /^\/v2\/virt_management\/vms\/([^/]+)$/
+
+export function newGridState() {
+ return { custom: new Map() }
+}
+
+export function handleGrid({ method = 'GET', path = '', body = null } = {}, state = newGridState()) {
+ const verb = method.toUpperCase()
+ const custom = path.match(CUSTOM)
+ if (custom) {
+ const key = decodeURIComponent(custom[1])
+ if (verb === 'POST') {
+ state.custom.set(key, body)
+ return core(body)
+ }
+ // what was never put is an empty string, as the core answers
+ return verb === 'GET' ? core(state.custom.has(key) ? state.custom.get(key) : '') : null
+ }
+ if (verb !== 'GET') {
+ return null
+ }
+ if (CORE_GETS[path]) {
+ return CORE_GETS[path]()
+ }
+ switch (path) {
+ case '/v2/virt_management/host':
+ return { status: 200, body: { data: HOST } }
+ case '/v2/virt_management/vms':
+ return { status: 200, body: { data: VMS } }
+ case '/v2/app_management/web/appgrid':
+ return { status: 200, body: { data: APPS } }
+ }
+ const one = path.match(ONE_VM)
+ if (one) {
+ const vm = VMS.find(candidate => candidate.id === decodeURIComponent(one[1]))
+ return vm ? { status: 200, body: { data: vm } } : { status: 404, body: { message: 'vm not found' } }
+ }
+ return null
+}
diff --git a/mock/stats-stub/grid.spec.js b/mock/stats-stub/grid.spec.js
new file mode 100644
index 00000000..31beb75e
--- /dev/null
+++ b/mock/stats-stub/grid.spec.js
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest'
+import { handleGrid, newGridState } from './grid.js'
+
+// What the dashboard asks of a box to draw its home page, its app grid and the
+// virtual machine tiles, in the shapes the services answer with.
+const ask = (method, path, body = null, state = newGridState()) => handleGrid({ method, path, body }, state)
+
+describe('the stub of the virtual machine module', () => {
+ it('answers the probe with a host that has libvirt', () => {
+ const { status, body } = ask('GET', '/v2/virt_management/host')
+
+ expect(status).toBe(200)
+ expect(body.data).toMatchObject({ libvirt: 'available', kvm: true, network: 'casaos', storage_path: '/DATA/VM' })
+ })
+
+ it('lists three VMs, running, shut off and paused, one with a hostile title', () => {
+ const { status, body } = ask('GET', '/v2/virt_management/vms')
+
+ expect(status).toBe(200)
+ expect(body.data.map(vm => vm.state)).toEqual(['running', 'shut-off', 'paused'])
+ expect(body.data.map(vm => vm.title)).toContain('
')
+ for (const vm of body.data) {
+ expect(vm.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
+ expect(vm).toMatchObject({ name: expect.stringMatching(/^[0-9a-f]{8}$/), managed: true, console: true })
+ }
+ })
+
+ it('answers one VM by its id, and 404 for one it does not have', () => {
+ const [first] = ask('GET', '/v2/virt_management/vms').body.data
+
+ expect(ask('GET', `/v2/virt_management/vms/${first.id}`).body.data).toEqual(first)
+ expect(ask('GET', '/v2/virt_management/vms/00000000-0000-0000-0000-000000000000')).toEqual({ status: 404, body: { message: 'vm not found' } })
+ })
+})
+
+describe('the stub of the app grid', () => {
+ it('lists four compose apps in the grid shape', () => {
+ const { status, body } = ask('GET', '/v2/app_management/web/appgrid')
+
+ expect(status).toBe(200)
+ expect(body.data.map(app => app.title.en_us)).toEqual(['Jellyfin', 'Nextcloud', 'Immich', 'Plex'])
+ for (const app of body.data) {
+ expect(app).toMatchObject({ app_type: 'v2app', status: 'running', name: expect.any(String), hostname: '', port: expect.any(String) })
+ }
+ })
+})
+
+describe('the stub of the shell', () => {
+ it('says the box is set up and who is signed in, in the core envelope', () => {
+ expect(ask('GET', '/v1/users/status').body).toEqual({ success: 200, message: 'ok', data: { initialized: true, key: '' } })
+ expect(ask('GET', '/v1/users/current').body.data).toMatchObject({ username: 'casaos' })
+ })
+
+ it('keeps what is put in custom storage, and answers an empty string for what was never put', () => {
+ const state = newGridState()
+
+ expect(ask('GET', '/v1/users/current/custom/app_order', null, state).body.data).toBe('')
+ ask('POST', '/v1/users/current/custom/app_order', { data: ['Files', 'Plex'] }, state)
+
+ expect(ask('GET', '/v1/users/current/custom/app_order', null, state).body.data).toEqual({ data: ['Files', 'Plex'] })
+ })
+
+ it('answers what the top bar and the widgets read', () => {
+ expect(ask('GET', '/v1/sys/version').body.data).toMatchObject({ need_update: false })
+ expect(ask('GET', '/v1/sys/hardware').body.data).toMatchObject({ arch: 'amd64' })
+ expect(ask('GET', '/v1/sys/utilization').body.data).toMatchObject({ cpu: { num: 4 }, mem: { total: expect.any(Number) } })
+ expect(ask('GET', '/v1/sys/telemetry').body.data).toEqual({ enabled: false })
+ expect(ask('GET', '/v1/gateway/port').body.data).toBe('80')
+ expect(ask('GET', '/v1/usb/usb-auto-mount').body.data).toBe('False')
+ })
+
+ it('leaves every other path to the caller', () => {
+ expect(ask('GET', '/v1/sys/nothing-here')).toBeNull()
+ expect(ask('DELETE', '/v2/virt_management/vms')).toBeNull()
+ expect(ask('GET', '/v2/virt_management/stats')).toBeNull()
+ })
+})
diff --git a/mock/stats-stub/server.mjs b/mock/stats-stub/server.mjs
index 5e9cb4ad..6d7cf866 100644
--- a/mock/stats-stub/server.mjs
+++ b/mock/stats-stub/server.mjs
@@ -4,13 +4,15 @@
//
// Answers /v1/sys/stats, /v2/app_management/stats and /v2/virt_management/stats from handlers.js, with series that end
// at the current time (a live chart moves). With --upstream, every other request (and the WebSocket upgrade of the
-// message bus) goes to that box; without it, other paths answer 404.
+// message bus) goes to that box. Without it, grid.js answers what the home page asks of a box (the virtual machine
+// module, the app grid, the shell's core calls), and every other path answers 404.
//
// GET /__stub/alerts?on=1 puts 2 active alerts on one container (on=0 takes them off)
import { Buffer } from 'node:buffer'
import { createServer, request } from 'node:http'
import { connect } from 'node:net'
import process from 'node:process'
+import { handleGrid, newGridState } from './grid.js'
import { handle } from './handlers.js'
const args = process.argv.slice(2)
@@ -29,6 +31,7 @@ if (!['real', 'bare'].includes(envelope) || !Number.isInteger(port)) {
const [upstreamHost, upstreamPort = '80'] = upstream.split(':')
const options = { envelope, alerts: false, down: [], rules: new Map() }
+const grid = newGridState()
const STATS = /^\/v[12]\/(?:sys|app_management|virt_management)\/stats(?:\/|$)/
function send(res, status, body) {
@@ -51,8 +54,9 @@ const server = createServer((req, res) => {
options.alerts = url.searchParams.get('on') === '1'
return send(res, 200, { alerts: options.alerts })
}
- if (!STATS.test(url.pathname)) {
- return upstream ? proxy(req, res) : send(res, 404, { message: 'not_found' })
+ const stats = STATS.test(url.pathname)
+ if (!stats && upstream) {
+ return proxy(req, res)
}
const chunks = []
req.on('data', chunk => chunks.push(chunk))
@@ -63,7 +67,8 @@ const server = createServer((req, res) => {
} catch {
return send(res, 400, { message: 'bad_rules: rules' })
}
- const { status, body: answer } = handle({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), body }, options)
+ const request = { method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams), body }
+ const { status, body: answer } = stats ? handle(request, options) : (handleGrid(request, grid) || { status: 404, body: { message: 'not_found' } })
send(res, status, answer)
})
})
diff --git a/src/assets/lang/en_US.json b/src/assets/lang/en_US.json
index 2e5cf83c..660e261b 100644
--- a/src/assets/lang/en_US.json
+++ b/src/assets/lang/en_US.json
@@ -2508,5 +2508,7 @@
"{seconds} s": "{seconds} s",
"Exited with an error": "Exited with an error",
"The settings are applied but could not be kept for good.": "The settings are applied but could not be kept for good.",
- "System app": "System app"
+ "System app": "System app",
+ "Console of {name}": "Console of {name}",
+ "{name}, {state}: open in Virtual Machines": "{name}, {state}: open in Virtual Machines"
}
diff --git a/src/assets/lang/fr_FR.json b/src/assets/lang/fr_FR.json
index 0939950d..d7db425b 100644
--- a/src/assets/lang/fr_FR.json
+++ b/src/assets/lang/fr_FR.json
@@ -2508,5 +2508,7 @@
"{seconds} s": "{seconds} s",
"Exited with an error": "Arrêté avec une erreur",
"The settings are applied but could not be kept for good.": "Les réglages sont appliqués, mais n'ont pas pu être conservés durablement.",
- "System app": "Application système"
+ "System app": "Application système",
+ "Console of {name}": "Console de {name}",
+ "{name}, {state}: open in Virtual Machines": "{name}, {state} : ouvrir dans Machines virtuelles"
}
diff --git a/src/components/Apps/AppSection.spec.js b/src/components/Apps/AppSection.spec.js
index 3b74ef38..ed9263d3 100644
--- a/src/components/Apps/AppSection.spec.js
+++ b/src/components/Apps/AppSection.spec.js
@@ -1,6 +1,6 @@
// @vitest-environment happy-dom
-import { describe, expect, it, vi } from 'vitest'
-import { forgetVirtualMachines, hasVirtualMachines } from '@/components/VirtualMachines/entry'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { forgetVirtualMachines, hasVirtualMachines, listVirtualMachines } from '@/components/VirtualMachines/entry'
import AppSection from '@/components/Apps/AppSection.vue'
import GitAppModal from '@/components/Apps/GitAppModal.vue'
@@ -15,6 +15,7 @@ vi.mock('@/components/VirtualMachines/entry', async importOriginal => ({
...await importOriginal(),
hasVirtualMachines: vi.fn(),
forgetVirtualMachines: vi.fn(),
+ listVirtualMachines: vi.fn(),
vmApplication: () => ({ name: 'Virtual Machines' }),
}))
@@ -229,3 +230,286 @@ describe('app section built-in cards', () => {
expect(vm.appList.find(app => app.name === 'Statistics')).toMatchObject({ app_type: 'system', status: 'running' })
})
})
+
+// One tile per VM, right after the Virtual Machines card, by title. They are not
+// apps: they never reach the saved order, whatever is dragged where.
+describe('app section VM tiles', () => {
+ const aVm = (id, title = id, state = 'running') => ({ id, title, state })
+ function section(names, vms = []) {
+ const vm = {
+ appList: names.map(name => ({ name })),
+ vms,
+ $api: { users: { setCustomStorage: vi.fn() } },
+ $buefy: { toast: { open: vi.fn() } },
+ }
+ Object.defineProperty(vm, 'gridList', {
+ get: () => AppSection.computed.gridList.get.call(vm),
+ set: list => AppSection.computed.gridList.set.call(vm, list),
+ })
+ vm.pollVms = AppSection.methods.pollVms.bind(vm)
+ vm.saveSortData = AppSection.methods.saveSortData.bind(vm)
+ return vm
+ }
+ const shown = vm => vm.gridList.map(item => item.vmTile ? `tile:${item.vm.title}` : item.name)
+ const hide = (hidden) => {
+ Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden })
+ }
+
+ afterEach(() => {
+ vi.useRealTimers()
+ hide(false)
+ listVirtualMachines.mockReset()
+ })
+
+ it('(1, 4) shows a tile per VM right after the Virtual Machines card', () => {
+ const vm = section(['App Store', 'Virtual Machines', 'Files'], [aVm('a', 'Alpha'), aVm('b', 'Beta')])
+
+ expect(shown(vm)).toEqual(['App Store', 'Virtual Machines', 'tile:Alpha', 'tile:Beta', 'Files'])
+ })
+
+ it('(3) shows no tile and asks for no VM when the probe says there is no module', async () => {
+ const vm = section(['App Store', 'Files'], [aVm('a')])
+
+ await vm.pollVms()
+
+ expect(listVirtualMachines).not.toHaveBeenCalled()
+ expect(shown(vm)).toEqual(['App Store', 'Files'])
+ })
+
+ it('(3) shows no tile and no toast when the list cannot be read', async () => {
+ listVirtualMachines.mockRejectedValue(new Error('502'))
+ const vm = section(['Virtual Machines'], [aVm('a')])
+
+ for (let i = 0; i < 3; i++) {
+ await vm.pollVms()
+ clearTimeout(vm.vmPollTimer)
+ }
+
+ expect(shown(vm)).toEqual(['Virtual Machines'])
+ expect(vm.$buefy.toast.open).not.toHaveBeenCalled()
+ })
+
+ it('(4) keeps the order payload to the apps, whatever was dragged where', () => {
+ const vm = section(['App Store', 'Virtual Machines', 'Files', 'jellyfin'], [aVm('a'), aVm('b')])
+ const [store, vms, a, b, files, jellyfin] = vm.gridList
+
+ // jellyfin dropped between the tiles, and a tile dragged to the front
+ vm.gridList = [b, store, vms, a, jellyfin, files]
+ vm.saveSortData()
+
+ expect(vm.$api.users.setCustomStorage).toHaveBeenCalledWith('app_order', { data: ['App Store', 'Virtual Machines', 'jellyfin', 'Files'] })
+ // the tiles go back after their card
+ expect(shown(vm)).toEqual(['App Store', 'Virtual Machines', 'tile:a', 'tile:b', 'jellyfin', 'Files'])
+ })
+
+ it('(5) follows the states, and a VM that is deleted goes', async () => {
+ vi.useFakeTimers()
+ const vm = section(['Virtual Machines'])
+ listVirtualMachines.mockResolvedValueOnce([aVm('a'), aVm('b')]).mockResolvedValueOnce([aVm('a', 'a', 'shut-off')])
+
+ await vm.pollVms()
+ expect(shown(vm)).toEqual(['Virtual Machines', 'tile:a', 'tile:b'])
+ await vi.advanceTimersByTimeAsync(5000)
+
+ expect(vm.vms).toEqual([aVm('a', 'a', 'shut-off')])
+ AppSection.beforeUnmount.call({ ...vm, $EventBus: { $off: vi.fn() } })
+ })
+
+ it('(3) reads the VMs as soon as the card joins the grid, and drops them with it', () => {
+ const pollVms = vi.fn()
+
+ AppSection.watch.hasVmCard.call({ pollVms })
+
+ expect(pollVms).toHaveBeenCalledTimes(1)
+ expect(AppSection.computed.hasVmCard.call({ appList: [{ name: 'Virtual Machines' }] })).toBe(true)
+ expect(AppSection.computed.hasVmCard.call({ appList: [{ name: 'Files' }] })).toBe(false)
+ })
+
+ it('(5) asks once at a time', async () => {
+ let answer
+ listVirtualMachines.mockReturnValue(new Promise((resolve) => {
+ answer = resolve
+ }))
+ const vm = section(['Virtual Machines'])
+
+ const first = vm.pollVms()
+ vm.pollVms()
+ answer([])
+ await first
+
+ expect(listVirtualMachines).toHaveBeenCalledTimes(1)
+ clearTimeout(vm.vmPollTimer)
+ })
+
+ it('(5) does not ask while the tab is hidden, and asks again once it is shown', async () => {
+ vi.useFakeTimers()
+ listVirtualMachines.mockResolvedValue([])
+ const vm = section(['Virtual Machines'])
+ hide(true)
+
+ await vm.pollVms()
+ await vi.advanceTimersByTimeAsync(30000)
+ expect(listVirtualMachines).not.toHaveBeenCalled()
+
+ hide(false)
+ await AppSection.methods.onVisibility.call(vm)
+ expect(listVirtualMachines).toHaveBeenCalledTimes(1)
+ await vi.advanceTimersByTimeAsync(5000)
+ expect(listVirtualMachines).toHaveBeenCalledTimes(2)
+
+ // hidden again: the timer is dropped
+ hide(true)
+ AppSection.methods.onVisibility.call(vm)
+ await vi.advanceTimersByTimeAsync(30000)
+ expect(listVirtualMachines).toHaveBeenCalledTimes(2)
+ })
+
+ it('(5) stops for good when the grid goes, timer and listener with it', async () => {
+ vi.useFakeTimers()
+ listVirtualMachines.mockResolvedValue([])
+ const vm = section(['Virtual Machines'])
+ vm.onVisibility = AppSection.methods.onVisibility.bind(vm)
+ const removed = vi.spyOn(document, 'removeEventListener')
+ await vm.pollVms()
+
+ AppSection.beforeUnmount.call(Object.assign(vm, { $EventBus: { $off: vi.fn() } }))
+ await vi.advanceTimersByTimeAsync(30000)
+
+ expect(listVirtualMachines).toHaveBeenCalledTimes(1)
+ expect(removed).toHaveBeenCalledWith('visibilitychange', vm.onVisibility)
+ expect(vi.getTimerCount()).toBe(0)
+ removed.mockRestore()
+ })
+})
+
+// The tiles are not drop targets: an app is dropped among the apps only, so it
+// never lands where the tiles are redrawn over it.
+describe('app section VM tiles out of the drag', () => {
+ const tileItem = { name: 'vm-tile:a', vmTile: true, vm: { id: 'a' } }
+ const move = (vm, element, willInsertAfter = false) =>
+ AppSection.methods.checkMove.call(vm, { relatedContext: { element }, willInsertAfter })
+
+ it('(1) starts no drag on a tile, and lets its click through', () => {
+ const options = AppSection.computed.dragOptions.call({})
+
+ expect(options.filter).toBe('.vm-tile-item')
+ expect(options.preventOnFilter).toBe(false)
+ })
+
+ it('(1) rejects a move over a tile', () => {
+ expect(move({ vms: [{ id: 'a' }] }, tileItem)).toBe(false)
+ expect(move({ vms: [{ id: 'a' }] }, tileItem, true)).toBe(false)
+ })
+
+ it('(1) rejects a drop right after the Virtual Machines card while tiles follow it', () => {
+ const card = { name: 'Virtual Machines' }
+
+ expect(move({ vms: [{ id: 'a' }] }, card, true)).toBe(false)
+ expect(move({ vms: [{ id: 'a' }] }, card, false)).toBe(true)
+ expect(move({ vms: [] }, card, true)).toBe(true)
+ })
+
+ it('(1) lets an app move among the apps', () => {
+ expect(move({ vms: [{ id: 'a' }] }, { name: 'Files' })).toBe(true)
+ expect(move({ vms: [{ id: 'a' }] }, { name: 'Files' }, true)).toBe(true)
+ })
+})
+
+describe('app section VM tiles riding out a failed poll', () => {
+ const aVm = (id, state = 'running') => ({ id, title: id, state })
+ function section(vms = []) {
+ const vm = { appList: [{ name: 'Virtual Machines' }], vms }
+ vm.pollVms = AppSection.methods.pollVms.bind(vm)
+ return vm
+ }
+ afterEach(() => {
+ vi.useRealTimers()
+ listVirtualMachines.mockReset()
+ })
+ const poll = async (vm) => {
+ await vm.pollVms()
+ clearTimeout(vm.vmPollTimer)
+ }
+
+ it('(2) keeps the last list through one and two failed polls, and drops it on the third', async () => {
+ listVirtualMachines.mockRejectedValue(new Error('502'))
+ const vm = section([aVm('a')])
+
+ await poll(vm)
+ expect(vm.vms).toEqual([aVm('a')])
+ await poll(vm)
+ expect(vm.vms).toEqual([aVm('a')])
+ await poll(vm)
+ expect(vm.vms).toEqual([])
+ })
+
+ it('(2) counts again from nothing once a poll works', async () => {
+ const vm = section([aVm('a')])
+ listVirtualMachines.mockRejectedValueOnce(new Error('502')).mockRejectedValueOnce(new Error('502'))
+ .mockResolvedValueOnce([aVm('b')])
+ .mockRejectedValueOnce(new Error('502')).mockRejectedValueOnce(new Error('502'))
+
+ for (let i = 0; i < 5; i++) {
+ await poll(vm)
+ }
+
+ expect(vm.vms).toEqual([aVm('b')])
+ })
+
+ it('(2) drops the list at once when the module is not there', async () => {
+ listVirtualMachines.mockRejectedValue({ response: { status: 404 } })
+ const vm = section([aVm('a')])
+
+ await poll(vm)
+
+ expect(vm.vms).toEqual([])
+ })
+
+ it('(2) drops the list at once when the card goes', async () => {
+ const vm = section([aVm('a')])
+ vm.appList = []
+
+ await poll(vm)
+
+ expect(vm.vms).toEqual([])
+ })
+
+ it('(4) leaves the list as it is when nothing changed', async () => {
+ const vms = [aVm('a')]
+ listVirtualMachines.mockResolvedValue([aVm('a')])
+ const vm = section(vms)
+
+ await poll(vm)
+
+ expect(vm.vms).toBe(vms)
+ })
+
+ it('(4) gives up on a read that hangs after 15 s, counts it as failed and polls again', async () => {
+ vi.useFakeTimers()
+ listVirtualMachines.mockReturnValueOnce(new Promise(() => {})).mockResolvedValue([aVm('b')])
+ const vm = section([aVm('a')])
+
+ vm.pollVms()
+ await vi.advanceTimersByTimeAsync(15000)
+ expect(vm.vms).toEqual([aVm('a')])
+ expect(vm.vmPolling).toBe(false)
+ expect(vm.vmFailures).toBe(1)
+
+ await vi.advanceTimersByTimeAsync(5000)
+ expect(listVirtualMachines).toHaveBeenCalledTimes(2)
+ expect(vm.vms).toEqual([aVm('b')])
+ clearTimeout(vm.vmPollTimer)
+ })
+
+ it('(5) declares its polling state when the grid is made', () => {
+ const vm = { getList: vi.fn(), isMobile: () => false, $EventBus: { $on: vi.fn() }, pollVms: vi.fn(), onVisibility: vi.fn() }
+ const added = vi.spyOn(document, 'addEventListener')
+
+ AppSection.created.call(vm)
+ clearInterval(vm.ListRefreshTimer)
+
+ expect(vm).toMatchObject({ vmPollStopped: false, vmPolling: false, vmPollTimer: null, vmFailures: 0 })
+ expect(added).toHaveBeenCalledWith('visibilitychange', vm.onVisibility)
+ added.mockRestore()
+ })
+})
diff --git a/src/components/Apps/AppSection.vue b/src/components/Apps/AppSection.vue
index 0b04db03..7002457c 100644
--- a/src/components/Apps/AppSection.vue
+++ b/src/components/Apps/AppSection.vue
@@ -40,17 +40,20 @@
their own grid while loading. -->