Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ describe('AccountBreadcrumb.vue', () => {
}
})

console.log(wrapper.html())
expect(wrapper.text()).toBe('∅')
})

Expand Down
91 changes: 29 additions & 62 deletions frontend/tests/components/resources/ResourcesCanvas.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import { nextTick } from 'vue'
import { describe, test, beforeEach, vi, expect } from 'vitest'
import { describe, test, beforeEach, afterEach, vi, expect } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { useRuntimeStore } from '@/stores/runtime'
import ResourcesCanvas from '@/components/resources/ResourcesCanvas.vue'
import { APIServerError } from '@/composables/HTTPErrors'
import { init_plugins } from '../../lib/common'
import nodes from '../../assets/nodes.json'
import requestsStatus from '../../assets/status.json'
import LoadingSpinner from '@/components/LoadingSpinner.vue'

import fs from 'fs'
import path from 'path'

const mockRESTAPI = {
postRaw: vi.fn()
// Mock the GatewayAPI to avoid exercising undici multipart parsing here.
// The multipart parsing is covered in GatewayAPI.spec; this spec focuses on UI.
const mockGatewayAPI = {
infrastructureImagePng: vi.fn()
}

vi.mock('@/composables/RESTAPI', () => ({
useRESTAPI: () => mockRESTAPI
}))
vi.mock('@/composables/GatewayAPI', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/GatewayAPI')>()
return {
...actual,
useGatewayAPI: () => mockGatewayAPI
}
})

const mockCoordinates = { node1: [0, 0, 10, 10] }
const mockImage = new Blob([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], { type: 'image/png' })
const mockBitmap = { width: 10, height: 10 }

describe('ResourcesCanvas.vue', () => {
beforeEach(() => {
Expand All @@ -33,17 +38,15 @@ describe('ResourcesCanvas.vue', () => {
cache: true
}
]
mockGatewayAPI.infrastructureImagePng.mockResolvedValue([mockImage, mockCoordinates])
vi.stubGlobal('createImageBitmap', vi.fn().mockResolvedValue(mockBitmap))
vi.spyOn(CanvasRenderingContext2D.prototype, 'drawImage').mockImplementation(() => {})
})
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
test('display resources canvas', async () => {
const message = fs.readFileSync(
path.resolve(__dirname, '../../assets/racksdb-draw-coordinates.txt')
)
mockRESTAPI.postRaw.mockReturnValueOnce(
Promise.resolve({
headers: { 'content-type': requestsStatus['racksdb-draw-coordinates']['content-type'] },
data: message
})
)
const wrapper = mount(ResourcesCanvas, {
props: {
cluster: 'foo',
Expand Down Expand Up @@ -80,9 +83,9 @@ describe('ResourcesCanvas.vue', () => {
expect(wrapper.emitted()).toHaveProperty('imageSize')
})
test('report API server error', async () => {
mockRESTAPI.postRaw.mockImplementationOnce(() => {
throw new APIServerError(500, 'fake API server error')
})
mockGatewayAPI.infrastructureImagePng.mockRejectedValueOnce(
new APIServerError(500, 'fake API server error')
)
const wrapper = mount(ResourcesCanvas, {
props: {
cluster: 'foo',
Expand All @@ -100,9 +103,9 @@ describe('ResourcesCanvas.vue', () => {
expect(wrapper.emitted()).toHaveProperty('update:modelValue')
})
test('report other errors', async () => {
mockRESTAPI.postRaw.mockImplementationOnce(() => {
throw new Error('fake other server error')
})
mockGatewayAPI.infrastructureImagePng.mockRejectedValueOnce(
new Error('fake other server error')
)
const wrapper = mount(ResourcesCanvas, {
props: {
cluster: 'foo',
Expand All @@ -120,15 +123,6 @@ describe('ResourcesCanvas.vue', () => {
expect(wrapper.emitted()).toHaveProperty('update:modelValue')
})
test('display resources canvas in cores mode', async () => {
const message = fs.readFileSync(
path.resolve(__dirname, '../../assets/racksdb-draw-coordinates.txt')
)
mockRESTAPI.postRaw.mockReturnValueOnce(
Promise.resolve({
headers: { 'content-type': requestsStatus['racksdb-draw-coordinates']['content-type'] },
data: message
})
)
const wrapper = mount(ResourcesCanvas, {
props: {
cluster: 'foo',
Expand All @@ -153,15 +147,6 @@ describe('ResourcesCanvas.vue', () => {
expect(wrapper.props('mode')).toBe('cores')
})
test('tooltip shows cores information in cores mode', async () => {
const message = fs.readFileSync(
path.resolve(__dirname, '../../assets/racksdb-draw-coordinates.txt')
)
mockRESTAPI.postRaw.mockReturnValueOnce(
Promise.resolve({
headers: { 'content-type': requestsStatus['racksdb-draw-coordinates']['content-type'] },
data: message
})
)
const wrapper = mount(ResourcesCanvas, {
props: {
cluster: 'foo',
Expand Down Expand Up @@ -200,15 +185,6 @@ describe('ResourcesCanvas.vue', () => {
})

test('shimmer animation starts when nodes are loading', async () => {
const message = fs.readFileSync(
path.resolve(__dirname, '../../assets/racksdb-draw-coordinates.txt')
)
mockRESTAPI.postRaw.mockReturnValueOnce(
Promise.resolve({
headers: { 'content-type': requestsStatus['racksdb-draw-coordinates']['content-type'] },
data: message
})
)
const wrapper = mount(ResourcesCanvas, {
props: {
cluster: 'foo',
Expand All @@ -233,15 +209,6 @@ describe('ResourcesCanvas.vue', () => {
})

test('shimmer animation stops when nodes are loaded', async () => {
const message = fs.readFileSync(
path.resolve(__dirname, '../../assets/racksdb-draw-coordinates.txt')
)
mockRESTAPI.postRaw.mockReturnValueOnce(
Promise.resolve({
headers: { 'content-type': requestsStatus['racksdb-draw-coordinates']['content-type'] },
data: message
})
)
const wrapper = mount(ResourcesCanvas, {
props: {
cluster: 'foo',
Expand Down
82 changes: 80 additions & 2 deletions frontend/tests/composables/GatewayAPI.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, test } from 'vitest'
import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest'
import {
compareClusterJobSortOrder,
jobResourcesTRES,
Expand All @@ -9,7 +9,8 @@ import {
getNodeMainState,
getNodeAllocationState,
getNodeGPUFromGres,
getNodeGPU
getNodeGPU,
useGatewayAPI
} from '@/composables/GatewayAPI'
import jobs from '../assets/jobs.json'
import jobPending from '../assets/job-pending.json'
Expand Down Expand Up @@ -37,6 +38,83 @@ import nodeWithGpusModelMixed from '../assets/node-with-gpus-model-mixed.json'

import nodeWithoutGpu from '../assets/node-without-gpu.json'

// Stub REST API for infrastructureImagePng tests; we only care about parsing.
const mockRestAPI = {
postRaw: vi.fn()
}

vi.mock('@/composables/RESTAPI', () => ({
useRESTAPI: () => mockRestAPI
}))

// Provide minimal runtime configuration for GatewayAPI initialization.
vi.mock('@/plugins/runtimeConfiguration', () => ({
useRuntimeConfiguration: () => ({
api_server: 'http://localhost',
authentication: true,
racksdb_rows_labels: true,
racksdb_racks_labels: true,
version: 'test'
})
}))

describe('infrastructureImagePng', () => {
const originalResponse = globalThis.Response
const coordinates = { node1: [0, 0, 10, 10] }
const imageBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47])

beforeEach(() => {
// The response body is not used by our fake Response, but keep a realistic
// shape so GatewayAPI continues to call Response.formData().
mockRestAPI.postRaw.mockResolvedValue({
headers: { 'content-type': 'multipart/form-data; boundary=mock' },
data: new Uint8Array([0x00])
})

// Build a minimal FormData-like object with the parts GatewayAPI expects.
// This avoids undici multipart parsing in tests while still exercising
// the extraction and JSON parsing logic.
const image = new Blob([imageBytes], { type: 'image/png' })
const coordinatesFile = new Blob([JSON.stringify(coordinates)], {
type: 'application/json'
})
const formData = {
get: (key: string) => {
if (key === 'image') return image
if (key === 'coordinates') return coordinatesFile
return null
}
}

// Fake Response.formData() to return our synthetic parts.
globalThis.Response = class {
constructor() {}
async formData() {
return formData
}
} as typeof Response
})

afterEach(() => {
globalThis.Response = originalResponse
vi.clearAllMocks()
})

test('parses image and coordinates from multipart response', async () => {
const gateway = useGatewayAPI()
const [image, parsedCoordinates] = await gateway.infrastructureImagePng(
'cluster',
'infra',
100,
100
)

expect(image).toBeInstanceOf(Blob)
expect((image as Blob).type).toBe('image/png')
expect(parsedCoordinates).toStrictEqual(coordinates)
})
})

describe('compareClusterJobSorter', () => {
test('compare same jobs', () => {
const jobA = jobs[1]
Expand Down
12 changes: 12 additions & 0 deletions frontend/tests/lib/vitest-canvas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,21 @@

/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/ban-ts-comment */
import { vi } from 'vitest'
import { Blob, File } from 'node:buffer'
;(global as any).jest = vi

// @ts-ignore
const { default: getCanvasWindow } = await import('jest-canvas-mock/lib/window')
const canvasWindow = getCanvasWindow(window)
global['CanvasRenderingContext2D'] = canvasWindow['CanvasRenderingContext2D']

// Ensure undici's Web IDL checks use Node's File/Blob classes.
// In some Node versions (e.g. 20, 24), Response.formData() validates parts
// with webidl.is.File/USVString.
// jsdom provides its own File/Blob, which fails undici's type assertions.
// Using Node's implementations avoids undici assertion errors in tests.
// For reference, see: https://github.com/rackslab/Slurm-web/issues/651
const NodeFile = File as unknown as typeof globalThis.File
const NodeBlob = Blob as unknown as typeof globalThis.Blob
globalThis.File = NodeFile
globalThis.Blob = NodeBlob
6 changes: 4 additions & 2 deletions frontend/tests/views/LoginView.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, test, expect, beforeEach, vi } from 'vitest'
import { shallowMount, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
import LoginView from '@/views/LoginView.vue'
import { init_plugins } from '../lib/common'
import { useAuthStore } from '@/stores/auth'
Expand Down Expand Up @@ -112,11 +113,12 @@ describe('LoginView.vue', () => {
// Check not redirected on clusters list but stayed on login page.
expect(router.push).toHaveBeenCalledTimes(0)
})
test('should display info alert when redirected to login page', () => {
test('should display info alert when redirected to login page', async () => {
const wrapper = mount(LoginView, {})
const authStore = useAuthStore()
// Set returnUrl to simulate redirect from another page
authStore.returnUrl = '/clusters/foo/dashboard'
const wrapper = mount(LoginView, {})
await nextTick()
// Check InfoAlert component is present
const infoAlert = wrapper.getComponent(InfoAlert)
// Check the message content
Expand Down