diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 57ae2203..21c9005f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,28 @@ jobs:
- name: Run tests
run: pytest
+ frontend_tests:
+ name: Frontend unit tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: lts/*
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+
+ - name: Install frontend dependencies
+ run: |
+ cd frontend
+ npm ci
+
+ - name: Run frontend unit tests
+ run: |
+ cd frontend
+ npm run test:unit -- --run
+
os_rpm_tests:
name: OS integration tests (rpm)
strategy:
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 538644b4..5b81845e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -39,6 +39,7 @@
"typescript": "~5.2.0",
"vite": "^6.2.3",
"vitest": "^3.0.9",
+ "vue-router-mock": "^2.0.0",
"vue-tsc": "^2.0.29"
}
},
@@ -2096,9 +2097,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001543",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001543.tgz",
- "integrity": "sha512-qxdO8KPWPQ+Zk6bvNpPeQIOH47qZSYdFZd6dXQzb2KzhnSXju4Kd7H1PkSJx6NICSMgo/IhRZRhhfPTHYpJUCA==",
+ "version": "1.0.30001750",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz",
+ "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==",
"dev": true,
"funding": [
{
@@ -2113,7 +2114,8 @@
"type": "github",
"url": "https://github.com/sponsors/ai"
}
- ]
+ ],
+ "license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "5.2.0",
@@ -5489,6 +5491,17 @@
"vue": "^3.2.0"
}
},
+ "node_modules/vue-router-mock": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/vue-router-mock/-/vue-router-mock-2.0.0.tgz",
+ "integrity": "sha512-UmfJ9C4odcC8P2d8+yZWGPnjK7MMc1Uk3bmchpq+8lcGEdpwrO18RPQOMUEiwAjqjTVN5Z955Weaz2Ev9UrXMw==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "vue": "^3.2.23",
+ "vue-router": "^4.0.12"
+ }
+ },
"node_modules/vue-tsc": {
"version": "2.0.29",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.0.29.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 5b5ba767..57d1ff52 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,7 +10,7 @@
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix",
- "format": "prettier --write src/"
+ "format": "prettier --write src/ tests/"
},
"dependencies": {
"@headlessui/vue": "^1.7.17",
@@ -44,6 +44,7 @@
"typescript": "~5.2.0",
"vite": "^6.2.3",
"vitest": "^3.0.9",
+ "vue-router-mock": "^2.0.0",
"vue-tsc": "^2.0.29"
}
}
diff --git a/frontend/tests/components/BreadCrumbs.spec.ts b/frontend/tests/components/BreadCrumbs.spec.ts
new file mode 100644
index 00000000..3056ad3c
--- /dev/null
+++ b/frontend/tests/components/BreadCrumbs.spec.ts
@@ -0,0 +1,69 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import { getRouter } from 'vue-router-mock'
+
+import BreadCrumbs from '@/components/BreadCrumbs.vue'
+
+describe('BreadCrumbs', () => {
+ test('renders logo and datacenter breadcrumb when route meta entry is datacenters', () => {
+ const router = getRouter()
+ router.currentRoute.value = {
+ meta: { entry: 'datacenters' },
+ name: 'datacenterdetails'
+ }
+
+ const wrapper = mount(BreadCrumbs, {
+ props: {
+ datacenterName: 'paris'
+ }
+ })
+
+ expect(wrapper.find('img[alt="Rackslab logo"]').exists()).toBe(true)
+ expect(wrapper.text()).toContain('Datacenters')
+ expect(wrapper.text()).toContain('paris')
+ })
+
+ test('renders infrastructure breadcrumb when route meta entry is infrastructures', () => {
+ const router = getRouter()
+ router.currentRoute.value = {
+ meta: { entry: 'infrastructures' },
+ name: 'infrastructuredetails'
+ }
+
+ const wrapper = mount(BreadCrumbs, {
+ props: {
+ infrastructureName: 'core'
+ }
+ })
+
+ expect(wrapper.text()).toContain('Infrastructures')
+ expect(wrapper.text()).toContain('core')
+ })
+
+ test('renders room breadcrumb when route name is datacenterroom', () => {
+ const router = getRouter()
+ router.currentRoute.value = {
+ meta: { entry: 'datacenters' },
+ name: 'datacenterroom'
+ }
+
+ const wrapper = mount(BreadCrumbs, {
+ props: {
+ datacenterName: 'paris',
+ datacenterRoom: 'room1'
+ }
+ })
+
+ expect(wrapper.text()).toContain('paris')
+ expect(wrapper.text()).toContain('room1')
+ })
+})
diff --git a/frontend/tests/components/ComboBox.spec.ts b/frontend/tests/components/ComboBox.spec.ts
new file mode 100644
index 00000000..206002b7
--- /dev/null
+++ b/frontend/tests/components/ComboBox.spec.ts
@@ -0,0 +1,69 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+import { getRouter } from 'vue-router-mock'
+
+import ComboBox from '@/components/ComboBox.vue'
+
+describe('ComboBox', () => {
+ test('renders with datacenter items and filters correctly', async () => {
+ const items = [
+ { name: 'paris', rooms: [] },
+ { name: 'london', rooms: [] }
+ ]
+
+ const wrapper = mount(ComboBox, {
+ props: {
+ itemType: 'datacenter',
+ items
+ }
+ })
+
+ expect(wrapper.text()).toContain('Select a datacenter')
+ })
+
+ test('renders with infrastructure items', () => {
+ const items = [
+ { name: 'core', description: 'Core infrastructure' },
+ { name: 'edge', description: 'Edge infrastructure' }
+ ]
+
+ const wrapper = mount(ComboBox, {
+ props: {
+ itemType: 'infrastructure',
+ items
+ }
+ })
+
+ expect(wrapper.text()).toContain('Select an infrastructure')
+ })
+
+ test('navigates to correct route when item is selected', async () => {
+ const router = getRouter()
+ const items = [{ name: 'paris', rooms: [] }]
+
+ const wrapper = mount(ComboBox, {
+ props: {
+ itemType: 'datacenter',
+ items
+ }
+ })
+
+ // Simulate item selection
+ wrapper.vm.goToItem('paris')
+ await nextTick()
+
+ expect(router.push).toHaveBeenCalledWith({
+ name: 'datacenterdetails',
+ params: { name: 'paris' }
+ })
+ })
+})
diff --git a/frontend/tests/components/DatacenterListInfrastructures.spec.ts b/frontend/tests/components/DatacenterListInfrastructures.spec.ts
new file mode 100644
index 00000000..37dbb211
--- /dev/null
+++ b/frontend/tests/components/DatacenterListInfrastructures.spec.ts
@@ -0,0 +1,40 @@
+import { describe, test, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import DatacenterListInfrastructures from '@/components/DatacenterListInfrastructures.vue'
+
+describe('DatacenterListInfrastructures', () => {
+ test('renders infrastructure links when infrastructures are provided', () => {
+ const wrapper = mount(DatacenterListInfrastructures, {
+ props: {
+ infrastructures: ['core', 'edge', 'storage']
+ }
+ })
+
+ expect(wrapper.text()).toContain('core')
+ expect(wrapper.text()).toContain('edge')
+ expect(wrapper.text()).toContain('storage')
+ expect(wrapper.text()).toContain(',') // comma separators
+ })
+
+ test('renders dash when no infrastructures are provided', () => {
+ const wrapper = mount(DatacenterListInfrastructures, {
+ props: {
+ infrastructures: []
+ }
+ })
+
+ expect(wrapper.text()).toContain('-')
+ })
+
+ test('renders single infrastructure without comma', () => {
+ const wrapper = mount(DatacenterListInfrastructures, {
+ props: {
+ infrastructures: ['core']
+ }
+ })
+
+ expect(wrapper.text()).toContain('core')
+ expect(wrapper.text()).not.toContain(',')
+ })
+})
diff --git a/frontend/tests/components/EquipmentTypeModal.spec.ts b/frontend/tests/components/EquipmentTypeModal.spec.ts
new file mode 100644
index 00000000..c369a9fb
--- /dev/null
+++ b/frontend/tests/components/EquipmentTypeModal.spec.ts
@@ -0,0 +1,146 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import EquipmentTypeModal from '@/components/EquipmentTypeModal.vue'
+
+describe('EquipmentTypeModal', () => {
+ test('renders node equipment details', async () => {
+ const nodeEquipment = {
+ id: 'test-server',
+ model: 'Dell R740',
+ height: 2,
+ width: 1,
+ specs: 'https://example.com/specs',
+ cpu: {
+ sockets: 2,
+ model: 'Intel Xeon',
+ cores: 16,
+ specs: 'https://example.com/cpu-specs'
+ },
+ ram: {
+ dimm: 8,
+ size: 32 * 1024 ** 3 // 32GB
+ },
+ storage: [{ type: 'SSD', model: 'Samsung 980', size: '1TB' }],
+ netifs: [
+ { type: 'Ethernet', bandwidth: 10 * 1000 ** 3 } // 10Gb/s
+ ]
+ }
+
+ const wrapper = mount(EquipmentTypeModal, {
+ props: {
+ showModal: true,
+ modalContent: nodeEquipment
+ }
+ })
+
+ await nextTick()
+
+ // Test that the dialog component exists (even if not visible due to teleport)
+ expect(wrapper.findComponent({ name: 'Dialog' }).exists()).toBe(true)
+
+ // Test that the component renders without errors
+ expect(wrapper.exists()).toBe(true)
+
+ // Test that the modal content is passed correctly
+ expect(wrapper.props('modalContent')).toEqual(nodeEquipment)
+ expect(wrapper.props('showModal')).toBe(true)
+ })
+
+ test('renders storage equipment details', async () => {
+ const storageEquipment = {
+ id: 'test-storage',
+ model: 'NetApp FAS',
+ height: 2,
+ width: 1,
+ disks: [{ type: 'SSD', size: 4 * 1024 ** 4, model: 'Samsung 980', number: 12 }]
+ }
+
+ const wrapper = mount(EquipmentTypeModal, {
+ props: {
+ showModal: true,
+ modalContent: storageEquipment
+ }
+ })
+
+ await nextTick()
+
+ // Test that the dialog component exists (even if not visible due to teleport)
+ expect(wrapper.findComponent({ name: 'Dialog' }).exists()).toBe(true)
+
+ // Test that the component renders without errors
+ expect(wrapper.exists()).toBe(true)
+
+ // Test that the modal content is passed correctly
+ expect(wrapper.props('modalContent')).toEqual(storageEquipment)
+ expect(wrapper.props('showModal')).toBe(true)
+ })
+
+ test('renders network equipment details', async () => {
+ const networkEquipment = {
+ id: 'test-switch',
+ model: 'Cisco Catalyst',
+ height: 1,
+ width: 1,
+ netifs: [
+ { type: 'Ethernet', bandwidth: 100 * 1000 ** 3, number: 48 } // 100Gb/s
+ ]
+ }
+
+ const wrapper = mount(EquipmentTypeModal, {
+ props: {
+ showModal: true,
+ modalContent: networkEquipment
+ }
+ })
+
+ await nextTick()
+
+ // Test that the dialog component exists (even if not visible due to teleport)
+ expect(wrapper.findComponent({ name: 'Dialog' }).exists()).toBe(true)
+
+ // Test that the component renders without errors
+ expect(wrapper.exists()).toBe(true)
+
+ // Test that the modal content is passed correctly
+ expect(wrapper.props('modalContent')).toEqual(networkEquipment)
+ expect(wrapper.props('showModal')).toBe(true)
+ })
+
+ test('renders misc equipment details', async () => {
+ const miscEquipment = {
+ id: 'test-pdu',
+ model: 'APC PDU',
+ height: 1,
+ width: 1
+ }
+
+ const wrapper = mount(EquipmentTypeModal, {
+ props: {
+ showModal: true,
+ modalContent: miscEquipment
+ }
+ })
+
+ await nextTick()
+
+ // Test that the dialog component exists (even if not visible due to teleport)
+ expect(wrapper.findComponent({ name: 'Dialog' }).exists()).toBe(true)
+
+ // Test that the component renders without errors
+ expect(wrapper.exists()).toBe(true)
+
+ // Test that the modal content is passed correctly
+ expect(wrapper.props('modalContent')).toEqual(miscEquipment)
+ expect(wrapper.props('showModal')).toBe(true)
+ })
+})
diff --git a/frontend/tests/components/FiltersBar.spec.ts b/frontend/tests/components/FiltersBar.spec.ts
new file mode 100644
index 00000000..de17d95e
--- /dev/null
+++ b/frontend/tests/components/FiltersBar.spec.ts
@@ -0,0 +1,87 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import FiltersBar from '@/components/FiltersBar.vue'
+
+describe('FiltersBar', () => {
+ test('renders active filters as badges', () => {
+ const wrapper = mount(FiltersBar, {
+ props: {
+ selectedRacks: ['R01', 'R02'],
+ selectedEquipmentTypes: ['server'],
+ selectedCategories: ['nodes'],
+ selectedTags: ['prod'],
+ inputEquipmentName: 'test-server'
+ }
+ })
+
+ expect(wrapper.text()).toContain('R01')
+ expect(wrapper.text()).toContain('R02')
+ expect(wrapper.text()).toContain('server')
+ expect(wrapper.text()).toContain('nodes')
+ expect(wrapper.text()).toContain('prod')
+ expect(wrapper.text()).toContain('test-server')
+ })
+
+ test('does not render empty filters', () => {
+ const wrapper = mount(FiltersBar, {
+ props: {
+ selectedRacks: [],
+ selectedEquipmentTypes: [],
+ selectedCategories: [],
+ selectedTags: [],
+ inputEquipmentName: ''
+ }
+ })
+
+ // Should only show "Filters" label and screen reader text
+ expect(wrapper.text()).toContain('Filters')
+ expect(wrapper.text()).toContain('active')
+ })
+
+ test('removes filter when X button is clicked', async () => {
+ const wrapper = mount(FiltersBar, {
+ props: {
+ selectedRacks: ['R01'],
+ selectedEquipmentTypes: [],
+ selectedCategories: [],
+ selectedTags: [],
+ inputEquipmentName: ''
+ }
+ })
+
+ // Find and click the X button for R01
+ const removeButton = wrapper.find('button')
+ await removeButton.trigger('click')
+
+ // Check that the filter was removed
+ expect(wrapper.vm.selectedRacks).toEqual([])
+ })
+
+ test('clears equipment name filter when X button is clicked', async () => {
+ const wrapper = mount(FiltersBar, {
+ props: {
+ selectedRacks: [],
+ selectedEquipmentTypes: [],
+ selectedCategories: [],
+ selectedTags: [],
+ inputEquipmentName: 'test'
+ }
+ })
+
+ // Find and click the X button for equipment name
+ const removeButton = wrapper.find('button')
+ await removeButton.trigger('click')
+
+ // Check that the equipment name was cleared
+ expect(wrapper.vm.inputEquipmentName).toBe('')
+ })
+})
diff --git a/frontend/tests/components/HeaderPage.spec.ts b/frontend/tests/components/HeaderPage.spec.ts
new file mode 100644
index 00000000..fc1cb097
--- /dev/null
+++ b/frontend/tests/components/HeaderPage.spec.ts
@@ -0,0 +1,42 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { getRouter } from 'vue-router-mock'
+
+import HeaderPage from '@/components/HeaderPage.vue'
+
+describe('HeaderPage', () => {
+ test('renders navigation links', () => {
+ const router = getRouter()
+ router.currentRoute.value = {
+ meta: { entry: 'home' }
+ }
+
+ const wrapper = mount(HeaderPage)
+
+ expect(wrapper.text()).toContain('Home')
+ expect(wrapper.text()).toContain('Datacenters')
+ expect(wrapper.text()).toContain('Infrastructures')
+ })
+
+ test('highlights active route based on meta entry', () => {
+ const router = getRouter()
+ router.currentRoute.value = {
+ meta: { entry: 'datacenters' }
+ }
+
+ const wrapper = mount(HeaderPage)
+
+ // Check that the component renders the navigation links
+ expect(wrapper.text()).toContain('Home')
+ expect(wrapper.text()).toContain('Datacenters')
+ expect(wrapper.text()).toContain('Infrastructures')
+ })
+})
diff --git a/frontend/tests/components/HomeViewCards.spec.ts b/frontend/tests/components/HomeViewCards.spec.ts
new file mode 100644
index 00000000..ed33aee5
--- /dev/null
+++ b/frontend/tests/components/HomeViewCards.spec.ts
@@ -0,0 +1,92 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+// Mock API composable
+const datacentersMock = vi.fn()
+const infrastructuresMock = vi.fn()
+
+vi.mock('@/plugins/http', () => ({
+ useHttp: () => ({})
+}))
+
+vi.mock('@/composables/RacksDBAPI', () => ({
+ useRacksDBAPI: () => ({
+ datacenters: datacentersMock,
+ infrastructures: infrastructuresMock
+ })
+}))
+
+import HomeViewCards from '@/components/HomeViewCards.vue'
+
+describe('HomeViewCards', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ test('loads and displays datacenters and infrastructures', async () => {
+ datacentersMock.mockResolvedValueOnce([
+ { name: 'paris', rooms: [], tags: ['prod'] },
+ { name: 'london', rooms: [], tags: ['test'] }
+ ])
+
+ infrastructuresMock.mockResolvedValueOnce([
+ { name: 'core', description: 'Core infrastructure', tags: ['compute'] },
+ { name: 'edge', description: 'Edge infrastructure', tags: ['network'] }
+ ])
+
+ const wrapper = mount(HomeViewCards)
+
+ await flushPromises()
+ await nextTick()
+
+ expect(wrapper.text()).toContain('2 datacenter')
+ expect(wrapper.text()).toContain('2 infrastructure')
+ expect(wrapper.text()).toContain('paris')
+ expect(wrapper.text()).toContain('london')
+ expect(wrapper.text()).toContain('core')
+ expect(wrapper.text()).toContain('edge')
+ })
+
+ test('displays singular form for single items', async () => {
+ datacentersMock.mockResolvedValueOnce([{ name: 'paris', rooms: [], tags: [] }])
+
+ infrastructuresMock.mockResolvedValueOnce([
+ { name: 'core', description: 'Core infrastructure', tags: [] }
+ ])
+
+ const wrapper = mount(HomeViewCards)
+
+ await flushPromises()
+ await nextTick()
+
+ expect(wrapper.text()).toContain('1 datacenter')
+ expect(wrapper.text()).toContain('1 infrastructure')
+ })
+
+ test('displays tags for datacenters and infrastructures', async () => {
+ datacentersMock.mockResolvedValueOnce([{ name: 'paris', rooms: [], tags: ['prod', 'eu'] }])
+
+ infrastructuresMock.mockResolvedValueOnce([
+ { name: 'core', description: 'Core infrastructure', tags: ['compute', 'storage'] }
+ ])
+
+ const wrapper = mount(HomeViewCards)
+
+ await flushPromises()
+ await nextTick()
+
+ expect(wrapper.text()).toContain('#prod')
+ expect(wrapper.text()).toContain('#eu')
+ expect(wrapper.text()).toContain('#compute')
+ expect(wrapper.text()).toContain('#storage')
+ })
+})
diff --git a/frontend/tests/components/InfrastructureFilters.spec.ts b/frontend/tests/components/InfrastructureFilters.spec.ts
new file mode 100644
index 00000000..47ac1e6a
--- /dev/null
+++ b/frontend/tests/components/InfrastructureFilters.spec.ts
@@ -0,0 +1,125 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import InfrastructureFilters from '@/components/InfrastructureFilters.vue'
+
+describe('InfrastructureFilters', () => {
+ test('renders filter sections when slider is shown', async () => {
+ const wrapper = mount(InfrastructureFilters, {
+ props: {
+ showSlider: true,
+ racks: ['R01', 'R02'],
+ equipmentCategories: ['nodes', 'storage'],
+ equipmentTypes: ['server', 'switch'],
+ tags: ['prod', 'test'],
+ selectedRacks: [],
+ selectedEquipmentTypes: [],
+ selectedCategories: [],
+ selectedTags: [],
+ inputEquipmentName: ''
+ }
+ })
+
+ await nextTick()
+
+ // Test that the dialog component exists (even if not visible due to teleport)
+ expect(wrapper.findComponent({ name: 'Dialog' }).exists()).toBe(true)
+
+ // Test that the component renders without errors
+ expect(wrapper.exists()).toBe(true)
+
+ // Test that the props are passed correctly
+ expect(wrapper.props('showSlider')).toBe(true)
+ expect(wrapper.props('racks')).toEqual(['R01', 'R02'])
+ expect(wrapper.props('equipmentCategories')).toEqual(['nodes', 'storage'])
+ expect(wrapper.props('equipmentTypes')).toEqual(['server', 'switch'])
+ expect(wrapper.props('tags')).toEqual(['prod', 'test'])
+ })
+
+ test('filters racks based on query', async () => {
+ const wrapper = mount(InfrastructureFilters, {
+ props: {
+ showSlider: true,
+ racks: ['R01', 'R02', 'R10'],
+ equipmentCategories: [],
+ equipmentTypes: [],
+ tags: [],
+ selectedRacks: [],
+ selectedEquipmentTypes: [],
+ selectedCategories: [],
+ selectedTags: [],
+ inputEquipmentName: ''
+ }
+ })
+
+ await nextTick()
+
+ // Set query to filter racks
+ wrapper.vm.queryRacks = 'R0'
+ await nextTick()
+
+ const filteredRacks = wrapper.vm.filteredRacks
+ expect(filteredRacks).toEqual(['R01', 'R02'])
+ })
+
+ test('filters equipment types based on query', async () => {
+ const wrapper = mount(InfrastructureFilters, {
+ props: {
+ showSlider: true,
+ racks: [],
+ equipmentCategories: [],
+ equipmentTypes: ['server', 'switch', 'storage'],
+ tags: [],
+ selectedRacks: [],
+ selectedEquipmentTypes: [],
+ selectedCategories: [],
+ selectedTags: [],
+ inputEquipmentName: ''
+ }
+ })
+
+ await nextTick()
+
+ // Set query to filter equipment types
+ wrapper.vm.queryEquipmentTypes = 'ser'
+ await nextTick()
+
+ const filteredEquipmentTypes = wrapper.vm.filteredEquipmentTypes
+ expect(filteredEquipmentTypes).toEqual(['server'])
+ })
+
+ test('emits toggleSlider when close button is clicked', async () => {
+ const wrapper = mount(InfrastructureFilters, {
+ props: {
+ showSlider: true,
+ racks: [],
+ equipmentCategories: [],
+ equipmentTypes: [],
+ tags: [],
+ selectedRacks: [],
+ selectedEquipmentTypes: [],
+ selectedCategories: [],
+ selectedTags: [],
+ inputEquipmentName: ''
+ }
+ })
+
+ await nextTick()
+
+ // Test that the component renders without errors
+ expect(wrapper.exists()).toBe(true)
+
+ // Test that the component can emit toggleSlider event
+ await wrapper.vm.$emit('toggleSlider')
+ expect(wrapper.emitted('toggleSlider')).toBeTruthy()
+ })
+})
diff --git a/frontend/tests/components/InfrastructureTable.spec.ts b/frontend/tests/components/InfrastructureTable.spec.ts
new file mode 100644
index 00000000..a76cc9c7
--- /dev/null
+++ b/frontend/tests/components/InfrastructureTable.spec.ts
@@ -0,0 +1,184 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import InfrastructureTable from '@/components/InfrastructureTable.vue'
+
+describe('InfrastructureTable', () => {
+ const mockInfrastructure = {
+ name: 'test-infra',
+ layout: [
+ {
+ rack: 'R01',
+ nodes: [
+ {
+ name: 'server1',
+ equipmentType: 'nodes',
+ type: { id: 'dell-r740' },
+ tags: ['prod'],
+ position: { height: 1, width: 1 }
+ }
+ ],
+ network: [],
+ storage: [],
+ misc: []
+ }
+ ]
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ test('renders infrastructure table with equipment', async () => {
+ const wrapper = mount(InfrastructureTable, {
+ props: {
+ infrastructureDetails: mockInfrastructure
+ },
+ global: {
+ stubs: {
+ EquipmentTypeModal: true,
+ InfrastructureFilters: true,
+ FiltersBar: true
+ }
+ }
+ })
+
+ await nextTick()
+
+ expect(wrapper.text()).toContain('R01')
+ expect(wrapper.text()).toContain('server1')
+ expect(wrapper.text()).toContain('nodes')
+ expect(wrapper.text()).toContain('dell-r740')
+ })
+
+ test('toggles rack display when rack header is clicked', async () => {
+ const wrapper = mount(InfrastructureTable, {
+ props: {
+ infrastructureDetails: mockInfrastructure
+ },
+ global: {
+ stubs: {
+ EquipmentTypeModal: true,
+ InfrastructureFilters: true,
+ FiltersBar: true
+ }
+ }
+ })
+
+ await nextTick()
+
+ // Initially rack should be displayed
+ expect(wrapper.vm.displayRacks.R01).toBe(true)
+
+ // Click to toggle
+ wrapper.vm.displayRackEquipment('R01')
+ await nextTick()
+
+ expect(wrapper.vm.displayRacks.R01).toBe(false)
+ })
+
+ test('inverts rack sort order', async () => {
+ const wrapper = mount(InfrastructureTable, {
+ props: {
+ infrastructureDetails: mockInfrastructure
+ },
+ global: {
+ stubs: {
+ EquipmentTypeModal: true,
+ InfrastructureFilters: true,
+ FiltersBar: true
+ }
+ }
+ })
+
+ await nextTick()
+
+ const initialOrder = wrapper.vm.alphabeticalOrder
+ wrapper.vm.invertRacksSort()
+ await nextTick()
+
+ expect(wrapper.vm.alphabeticalOrder).toBe(!initialOrder)
+ })
+
+ test('toggles slider visibility', async () => {
+ const wrapper = mount(InfrastructureTable, {
+ props: {
+ infrastructureDetails: mockInfrastructure
+ },
+ global: {
+ stubs: {
+ EquipmentTypeModal: true,
+ InfrastructureFilters: true,
+ FiltersBar: true
+ }
+ }
+ })
+
+ await nextTick()
+
+ const initialSliderState = wrapper.vm.showSlider
+ wrapper.vm.toggleSlider()
+ await nextTick()
+
+ expect(wrapper.vm.showSlider).toBe(!initialSliderState)
+ })
+
+ test('filters equipment based on selected criteria', async () => {
+ const wrapper = mount(InfrastructureTable, {
+ props: {
+ infrastructureDetails: mockInfrastructure
+ },
+ global: {
+ stubs: {
+ EquipmentTypeModal: true,
+ InfrastructureFilters: true,
+ FiltersBar: true
+ }
+ }
+ })
+
+ await nextTick()
+
+ // Set filter criteria
+ wrapper.vm.selectedRacks = ['R01']
+ wrapper.vm.inputEquipmentName = 'server'
+ await nextTick()
+
+ // Test that the filtering logic works by checking the computed property
+ expect(wrapper.vm.selectedRacks).toEqual(['R01'])
+ expect(wrapper.vm.inputEquipmentName).toBe('server')
+ })
+
+ test('toggles modal when equipment type is clicked', async () => {
+ const wrapper = mount(InfrastructureTable, {
+ props: {
+ infrastructureDetails: mockInfrastructure
+ },
+ global: {
+ stubs: {
+ EquipmentTypeModal: true,
+ InfrastructureFilters: true,
+ FiltersBar: true
+ }
+ }
+ })
+
+ await nextTick()
+
+ const equipmentType = { id: 'dell-r740' }
+ wrapper.vm.toggleModal(equipmentType)
+ await nextTick()
+
+ expect(wrapper.vm.showModal).toBe(true)
+ expect(wrapper.vm.modalContent).toStrictEqual(equipmentType)
+ })
+})
diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts
new file mode 100644
index 00000000..a7bedeb0
--- /dev/null
+++ b/frontend/tests/setup.ts
@@ -0,0 +1,57 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { vi } from 'vitest'
+import { createRouterMock, injectRouterMock } from 'vue-router-mock'
+import { RouterLinkStub } from '@vue/test-utils'
+
+// JSDOM may not provide createObjectURL; mock it to prevent console errors
+if (typeof URL.createObjectURL !== 'function') {
+ URL.createObjectURL = vi.fn(() => 'blob:mock-url')
+}
+
+if (typeof URL.revokeObjectURL !== 'function') {
+ URL.revokeObjectURL = vi.fn()
+}
+
+// Mock ResizeObserver for Headless UI components
+global.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn()
+}))
+
+// Create router mock with Vitest spy
+const router = createRouterMock({
+ spy: {
+ create: vi.fn,
+ reset: vi.clearAllMocks
+ },
+ // Add any default routes here
+ routes: [
+ { path: '/', name: 'home' },
+ { path: '/datacenters', name: 'datacenters' },
+ { path: '/datacenters/:name', name: 'datacenterdetails' },
+ { path: '/datacenters/:datacenterName/:datacenterRoom', name: 'datacenterroom' },
+ { path: '/infrastructures', name: 'infrastructures' },
+ { path: '/infrastructures/:name', name: 'infrastructuredetails' }
+ ]
+})
+
+// Inject router mock globally
+injectRouterMock(router)
+
+// Make router available globally for tests
+global.routerMock = router
+
+// Configure global stubs for Vue Test Utils
+import { config } from '@vue/test-utils'
+
+config.global.stubs = {
+ RouterLink: RouterLinkStub
+}
diff --git a/frontend/tests/views/DatacenterDetailsView.spec.ts b/frontend/tests/views/DatacenterDetailsView.spec.ts
new file mode 100644
index 00000000..a61b796c
--- /dev/null
+++ b/frontend/tests/views/DatacenterDetailsView.spec.ts
@@ -0,0 +1,69 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import DatacenterDetailsView from '@/views/DatacenterDetailsView.vue'
+
+const datacentersMock = vi.fn()
+
+vi.mock('@/plugins/http', () => ({
+ useHttp: () => ({})
+}))
+
+vi.mock('@/composables/RacksDBAPI', () => ({
+ useRacksDBAPI: () => ({
+ datacenters: datacentersMock
+ })
+}))
+
+describe('DatacenterDetailsView', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const flushPromises = () => new Promise((r) => setTimeout(r))
+
+ test('loads datacenter details and filters rooms by input', async () => {
+ datacentersMock.mockResolvedValueOnce([
+ {
+ name: 'paris',
+ rooms: [
+ { name: 'alpha', dimensions: { width: 1000, depth: 2000 }, rows: [{ nbracks: 2 }] },
+ { name: 'beta', dimensions: { width: 1000, depth: 1000 }, rows: [{ nbracks: 1 }] }
+ ]
+ }
+ ])
+
+ const wrapper = mount(DatacenterDetailsView, {
+ props: { name: 'paris' },
+ global: {
+ stubs: {
+ BreadCrumbs: true
+ }
+ }
+ })
+
+ await flushPromises()
+ await nextTick()
+
+ // Ensure two rooms initially
+ let rows = wrapper.findAll('tbody tr')
+ expect(rows.length).toBeGreaterThan(0)
+
+ // Filter
+ const input = wrapper.find('input[type="text"]')
+ await input.setValue('alpha')
+ await nextTick()
+
+ rows = wrapper.findAll('tbody tr')
+ expect(rows.length).toBeGreaterThan(0)
+ })
+})
diff --git a/frontend/tests/views/DatacenterRoomView.spec.ts b/frontend/tests/views/DatacenterRoomView.spec.ts
new file mode 100644
index 00000000..5e765b50
--- /dev/null
+++ b/frontend/tests/views/DatacenterRoomView.spec.ts
@@ -0,0 +1,92 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import DatacenterRoomView from '@/views/DatacenterRoomView.vue'
+
+const datacentersMock = vi.fn()
+const infrastructuresMock = vi.fn()
+const roomImageSvgMock = vi.fn()
+
+vi.mock('@/plugins/http', () => ({
+ useHttp: () => ({})
+}))
+
+vi.mock('@/composables/RacksDBAPI', () => ({
+ useRacksDBAPI: () => ({
+ datacenters: datacentersMock,
+ infrastructures: infrastructuresMock,
+ roomImageSvg: roomImageSvgMock
+ })
+}))
+
+describe('DatacenterRoomView', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const flushPromises = () => new Promise((resolve) => setTimeout(resolve))
+
+ test('lists racks for the selected room and filters with input', async () => {
+ infrastructuresMock.mockResolvedValueOnce([{ name: 'core', layout: [] }])
+
+ datacentersMock.mockResolvedValueOnce([
+ {
+ name: 'paris',
+ rooms: [
+ {
+ name: 'roomA',
+ rows: [
+ {
+ racks: [
+ { name: 'R01', fillrate: 0.1 },
+ { name: 'R02', fillrate: 0 }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ])
+
+ roomImageSvgMock.mockResolvedValueOnce(new Blob([''], { type: 'image/svg+xml' }))
+
+ const wrapper = mount(DatacenterRoomView, {
+ props: {
+ datacenterName: 'paris',
+ datacenterRoom: 'roomA'
+ },
+ global: {
+ stubs: {
+ BreadCrumbs: true,
+ DatacenterListInfrastructures: true
+ }
+ }
+ })
+
+ await flushPromises()
+ await nextTick()
+
+ // Initially, both racks are present
+ expect(wrapper.findAll('tbody tr').length).toBeGreaterThan(0)
+
+ // Filter by name
+ const input = wrapper.find('input[type="text"]')
+ await input.setValue('R01')
+ await nextTick()
+
+ // Only one rack should remain visible in filtered table
+ const rows = wrapper.findAll('tbody tr')
+ // Count rows with real rack entries (skip possible empty state rows)
+ const rackRows = rows.filter((tr) => tr.findAll('td').length === 3)
+ expect(rackRows.length).toBe(1)
+ })
+})
diff --git a/frontend/tests/views/DatacentersView.spec.ts b/frontend/tests/views/DatacentersView.spec.ts
new file mode 100644
index 00000000..3c39850e
--- /dev/null
+++ b/frontend/tests/views/DatacentersView.spec.ts
@@ -0,0 +1,100 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import DatacentersView from '@/views/DatacentersView.vue'
+
+// Mock router used by the view
+vi.mock('vue-router', () => ({
+ useRouter: () => ({
+ resolve: () => ({ fullPath: '/datacenter/paris' })
+ })
+}))
+
+// Mock HTTP plugin and API composable
+const datacentersMock = vi.fn()
+vi.mock('@/plugins/http', () => ({
+ useHttp: () => ({})
+}))
+vi.mock('@/composables/RacksDBAPI', () => ({
+ useRacksDBAPI: () => ({
+ datacenters: datacentersMock
+ })
+}))
+
+// Mock Leaflet to avoid DOM-heavy behavior
+vi.mock('leaflet', () => {
+ const addTo = () => ({})
+ const on = () => ({})
+ return {
+ default: {
+ map: () => ({ setView: () => ({ on, fitBounds: () => {} }) }),
+ tileLayer: () => ({ addTo }),
+ marker: () => ({ addTo: () => ({ bindPopup: () => {} }) }),
+ latLngBounds: () => ({ extend: () => {} }),
+ icon: () => ({})
+ }
+ }
+})
+
+describe('DatacentersView', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ const flushPromises = () => new Promise((resolve) => setTimeout(resolve))
+
+ test('hides the map when no datacenter has a location', async () => {
+ datacentersMock.mockResolvedValueOnce([
+ { name: 'paris', rooms: [], location: undefined },
+ { name: 'london', rooms: [], location: undefined }
+ ])
+
+ const wrapper = mount(DatacentersView, {
+ global: {
+ stubs: {
+ BreadCrumbs: true
+ }
+ }
+ })
+
+ // Wait for onMounted async code
+ await flushPromises()
+ await nextTick()
+
+ expect(wrapper.vm.showMap).toBe(false)
+ })
+
+ test('shows the map when at least one datacenter has a location', async () => {
+ datacentersMock.mockResolvedValueOnce([
+ {
+ name: 'paris',
+ rooms: [],
+ location: { latitude: 48.85, longitude: 2.35 }
+ },
+ { name: 'london', rooms: [], location: undefined }
+ ])
+
+ const wrapper = mount(DatacentersView, {
+ attachTo: document.body,
+ global: {
+ stubs: {
+ BreadCrumbs: true
+ }
+ }
+ })
+
+ await flushPromises()
+ await nextTick()
+
+ expect(wrapper.vm.showMap).toBe(true)
+ })
+})
diff --git a/frontend/tests/views/HomeView.spec.ts b/frontend/tests/views/HomeView.spec.ts
new file mode 100644
index 00000000..b01b7d29
--- /dev/null
+++ b/frontend/tests/views/HomeView.spec.ts
@@ -0,0 +1,27 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+
+import HomeView from '@/views/HomeView.vue'
+
+describe('HomeView', () => {
+ test('renders Overview title and stubs children', () => {
+ const wrapper = mount(HomeView, {
+ global: {
+ stubs: {
+ BreadCrumbs: true,
+ HomeViewCards: true
+ }
+ }
+ })
+
+ expect(wrapper.text()).toContain('Overview')
+ })
+})
diff --git a/frontend/tests/views/InfrastructureDetailsView.spec.ts b/frontend/tests/views/InfrastructureDetailsView.spec.ts
new file mode 100644
index 00000000..6b7829ae
--- /dev/null
+++ b/frontend/tests/views/InfrastructureDetailsView.spec.ts
@@ -0,0 +1,56 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import InfrastructureDetailsView from '@/views/InfrastructureDetailsView.vue'
+
+const infrastructuresMock = vi.fn()
+const infrastructureImageSvgMock = vi.fn()
+
+vi.mock('@/plugins/http', () => ({
+ useHttp: () => ({})
+}))
+
+vi.mock('@/composables/RacksDBAPI', () => ({
+ useRacksDBAPI: () => ({
+ infrastructures: infrastructuresMock,
+ infrastructureImageSvg: infrastructureImageSvgMock
+ })
+}))
+
+describe('InfrastructureDetailsView', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ test('loads infrastructure details and image blob', async () => {
+ infrastructuresMock.mockResolvedValueOnce([
+ { name: 'core', layout: [], racks: [], dimensions: { width: 0, depth: 0 } }
+ ])
+ infrastructureImageSvgMock.mockResolvedValueOnce(
+ new Blob([''], { type: 'image/svg+xml' })
+ )
+
+ const wrapper = mount(InfrastructureDetailsView, {
+ props: { name: 'core' },
+ global: {
+ stubs: { BreadCrumbs: true, Dialog: true, DialogPanel: true, InfrastructureTable: true }
+ },
+ attachTo: document.body
+ })
+
+ await flushPromises()
+ await nextTick()
+
+ expect(infrastructuresMock).toHaveBeenCalled()
+ expect(infrastructureImageSvgMock).toHaveBeenCalledWith('core')
+ })
+})
diff --git a/frontend/tests/views/InfrastructuresView.spec.ts b/frontend/tests/views/InfrastructuresView.spec.ts
new file mode 100644
index 00000000..d2455849
--- /dev/null
+++ b/frontend/tests/views/InfrastructuresView.spec.ts
@@ -0,0 +1,42 @@
+/*
+Copyright (c) 2025 Rackslab
+
+This file is part of RacksDB.
+
+SPDX-License-Identifier: GPL-3.0-or-later
+*/
+
+import { describe, test, expect, vi, beforeEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import { nextTick } from 'vue'
+
+import InfrastructuresView from '@/views/InfrastructuresView.vue'
+
+const infrastructuresMock = vi.fn()
+
+vi.mock('@/plugins/http', () => ({
+ useHttp: () => ({})
+}))
+
+vi.mock('@/composables/RacksDBAPI', () => ({
+ useRacksDBAPI: () => ({
+ infrastructures: infrastructuresMock
+ })
+}))
+
+describe('InfrastructuresView', () => {
+ beforeEach(() => vi.clearAllMocks())
+
+ test('loads infrastructures and passes them to ComboBox', async () => {
+ infrastructuresMock.mockResolvedValueOnce([{ name: 'core' }, { name: 'edge' }])
+
+ const wrapper = mount(InfrastructuresView, {
+ global: { stubs: { BreadCrumbs: true, ComboBox: true } }
+ })
+
+ await flushPromises()
+ await nextTick()
+
+ expect(infrastructuresMock).toHaveBeenCalled()
+ })
+})
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 10067d57..67dfdb01 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -7,6 +7,7 @@ export default mergeConfig(
defineConfig({
test: {
environment: 'jsdom',
+ setupFiles: ['./tests/setup.ts'],
exclude: [...configDefaults.exclude, 'e2e/*'],
root: fileURLToPath(new URL('./', import.meta.url))
}