diff --git a/frontend/tests/components/accounts/AccountBreadcrumb.spec.ts b/frontend/tests/components/accounts/AccountBreadcrumb.spec.ts index 64a1ab2fd..fc10da2f7 100644 --- a/frontend/tests/components/accounts/AccountBreadcrumb.spec.ts +++ b/frontend/tests/components/accounts/AccountBreadcrumb.spec.ts @@ -30,7 +30,6 @@ describe('AccountBreadcrumb.vue', () => { } }) - console.log(wrapper.html()) expect(wrapper.text()).toBe('∅') }) diff --git a/frontend/tests/components/resources/ResourcesCanvas.spec.ts b/frontend/tests/components/resources/ResourcesCanvas.spec.ts index 08dc24dcd..d164e1af5 100644 --- a/frontend/tests/components/resources/ResourcesCanvas.spec.ts +++ b/frontend/tests/components/resources/ResourcesCanvas.spec.ts @@ -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() + 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(() => { @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', @@ -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', diff --git a/frontend/tests/composables/GatewayAPI.spec.ts b/frontend/tests/composables/GatewayAPI.spec.ts index 2de61aa46..c7c24d803 100644 --- a/frontend/tests/composables/GatewayAPI.spec.ts +++ b/frontend/tests/composables/GatewayAPI.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect, test, beforeEach, afterEach, vi } from 'vitest' import { compareClusterJobSortOrder, jobResourcesTRES, @@ -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' @@ -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] diff --git a/frontend/tests/lib/vitest-canvas.ts b/frontend/tests/lib/vitest-canvas.ts index 2cc034f08..3f81807ac 100644 --- a/frontend/tests/lib/vitest-canvas.ts +++ b/frontend/tests/lib/vitest-canvas.ts @@ -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 diff --git a/frontend/tests/views/LoginView.spec.ts b/frontend/tests/views/LoginView.spec.ts index e08d5efe3..227e8cde9 100644 --- a/frontend/tests/views/LoginView.spec.ts +++ b/frontend/tests/views/LoginView.spec.ts @@ -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' @@ -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