diff --git a/workspaces/frontend/src/app/hooks/__tests__/useCurrentRouteKey.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useCurrentRouteKey.spec.tsx new file mode 100644 index 00000000..ae6e5a8d --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useCurrentRouteKey.spec.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import { useCurrentRouteKey } from '~/app/hooks/useCurrentRouteKey'; +import { AppRouteKey, AppRoutePaths } from '~/app/routes'; + +describe('useCurrentRouteKey', () => { + const wrapper: React.FC> = ({ + children, + initialEntries, + }) => {children}; + + const fillParams = (pattern: string) => pattern.replace(/:([^/]+)/g, 'test'); + const cases: ReadonlyArray = ( + Object.entries(AppRoutePaths) as [AppRouteKey, string][] + ).map(([key, pattern]) => [fillParams(pattern), key]); + + it.each(cases)('matches route keys by path: %s', (path, expected) => { + const { result } = renderHook(() => useCurrentRouteKey(), { + wrapper: (props) => wrapper({ ...props, initialEntries: [path] }), + }); + expect(result.current).toBe(expected); + }); + + it('returns undefined for unknown paths', () => { + const { result } = renderHook(() => useCurrentRouteKey(), { + wrapper: (props) => wrapper({ ...props, initialEntries: ['/unknown'] }), + }); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useMount.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useMount.spec.tsx new file mode 100644 index 00000000..cdee8730 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useMount.spec.tsx @@ -0,0 +1,10 @@ +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import useMount from '~/app/hooks/useMount'; + +describe('useMount', () => { + it('invokes callback on mount', () => { + const cb = jest.fn(); + renderHook(() => useMount(cb)); + expect(cb).toHaveBeenCalledTimes(1); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useNamespaces.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useNamespaces.spec.tsx new file mode 100644 index 00000000..74c8618f --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useNamespaces.spec.tsx @@ -0,0 +1,52 @@ +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import useNamespaces from '~/app/hooks/useNamespaces'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { NotebookApis } from '~/shared/api/notebookApi'; +import { APIState } from '~/shared/api/types'; + +jest.mock('~/app/hooks/useNotebookAPI', () => ({ + useNotebookAPI: jest.fn(), +})); + +const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction; + +describe('useNamespaces', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects when API not available', async () => { + const unavailableState: APIState = { + apiAvailable: false, + api: {} as NotebookApis, + }; + mockUseNotebookAPI.mockReturnValue({ ...unavailableState, refreshAllAPI: jest.fn() }); + + const { result, waitForNextUpdate } = renderHook(() => useNamespaces()); + await waitForNextUpdate(); + + const [namespacesData, loaded, loadError] = result.current; + expect(namespacesData).toBeNull(); + expect(loaded).toBe(false); + expect(loadError).toBeDefined(); + }); + + it('returns data when API is available', async () => { + const listNamespaces = jest.fn().mockResolvedValue({ ok: true, data: [{ name: 'ns1' }] }); + const api = { namespaces: { listNamespaces } } as unknown as NotebookApis; + + const availableState: APIState = { + apiAvailable: true, + api, + }; + mockUseNotebookAPI.mockReturnValue({ ...availableState, refreshAllAPI: jest.fn() }); + + const { result, waitForNextUpdate } = renderHook(() => useNamespaces()); + await waitForNextUpdate(); + + const [namespacesData, loaded, loadError] = result.current; + expect(namespacesData).toEqual([{ name: 'ns1' }]); + expect(loaded).toBe(true); + expect(loadError).toBeUndefined(); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useNotebookAPI.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useNotebookAPI.spec.tsx new file mode 100644 index 00000000..79d84ff5 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useNotebookAPI.spec.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import { NotebookContext } from '~/app/context/NotebookContext'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; + +jest.mock('~/app/EnsureAPIAvailability', () => ({ + default: ({ children }: { children?: React.ReactNode }) => children as React.ReactElement, +})); + +describe('useNotebookAPI', () => { + it('returns api state and refresh function from context', () => { + const refreshAPIState = jest.fn(); + const api = {} as ReturnType['api']; + const wrapper: React.FC = ({ children }) => ( + + {children} + + ); + + const { result } = renderHook(() => useNotebookAPI(), { wrapper }); + + expect(result.current.apiAvailable).toBe(true); + expect(result.current.api).toBe(api); + + result.current.refreshAllAPI(); + expect(refreshAPIState).toHaveBeenCalled(); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceFormData.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceFormData.spec.tsx new file mode 100644 index 00000000..32e1bd1b --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceFormData.spec.tsx @@ -0,0 +1,78 @@ +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import useWorkspaceFormData, { EMPTY_FORM_DATA } from '~/app/hooks/useWorkspaceFormData'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { NotebookApis } from '~/shared/api/notebookApi'; +import { buildMockWorkspace, buildMockWorkspaceKind } from '~/shared/mock/mockBuilder'; + +jest.mock('~/app/hooks/useNotebookAPI', () => ({ + useNotebookAPI: jest.fn(), +})); + +const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction; + +describe('useWorkspaceFormData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns empty form data when missing namespace or name', async () => { + mockUseNotebookAPI.mockReturnValue({ + api: {} as NotebookApis, + apiAvailable: true, + refreshAllAPI: jest.fn(), + }); + const { result, waitForNextUpdate } = renderHook(() => + useWorkspaceFormData({ namespace: undefined, workspaceName: undefined }), + ); + await waitForNextUpdate(); + + const workspaceFormData = result.current[0]; + expect(workspaceFormData).toEqual(EMPTY_FORM_DATA); + }); + + it('maps workspace and kind into form data when API available', async () => { + const mockWorkspace = buildMockWorkspace({}); + const mockWorkspaceKind = buildMockWorkspaceKind({}); + const getWorkspace = jest.fn().mockResolvedValue({ + ok: true, + data: mockWorkspace, + }); + const getWorkspaceKind = jest.fn().mockResolvedValue({ ok: true, data: mockWorkspaceKind }); + + const api = { + workspaces: { getWorkspace }, + workspaceKinds: { getWorkspaceKind }, + } as unknown as NotebookApis; + + mockUseNotebookAPI.mockReturnValue({ + api, + apiAvailable: true, + refreshAllAPI: jest.fn(), + }); + + const { result, waitForNextUpdate } = renderHook(() => + useWorkspaceFormData({ namespace: 'ns', workspaceName: 'ws' }), + ); + await waitForNextUpdate(); + + const workspaceFormData = result.current[0]; + expect(workspaceFormData).toEqual({ + kind: mockWorkspaceKind, + image: { + ...mockWorkspace.podTemplate.options.imageConfig.current, + hidden: mockWorkspaceKind.hidden, + }, + podConfig: { + ...mockWorkspace.podTemplate.options.podConfig.current, + hidden: mockWorkspaceKind.hidden, + }, + properties: { + workspaceName: mockWorkspace.name, + deferUpdates: mockWorkspace.deferUpdates, + volumes: mockWorkspace.podTemplate.volumes.data, + secrets: mockWorkspace.podTemplate.volumes.secrets, + homeDirectory: mockWorkspace.podTemplate.volumes.home?.mountPath, + }, + }); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceFormLocationData.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceFormLocationData.spec.tsx new file mode 100644 index 00000000..3f7ec181 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceFormLocationData.spec.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import { useWorkspaceFormLocationData } from '~/app/hooks/useWorkspaceFormLocationData'; +import { NamespaceContextProvider } from '~/app/context/NamespaceContextProvider'; + +jest.mock('~/app/context/NamespaceContextProvider', () => { + const ReactActual = jest.requireActual('react'); + const mockNamespaceValue = { + namespaces: ['ns1', 'ns2', 'ns3'], + selectedNamespace: 'ns1', + setSelectedNamespace: jest.fn(), + lastUsedNamespace: 'ns1', + updateLastUsedNamespace: jest.fn(), + }; + const MockContext = ReactActual.createContext(mockNamespaceValue); + return { + NamespaceContextProvider: ({ children }: { children: React.ReactNode }) => ( + {children} + ), + useNamespaceContext: () => ReactActual.useContext(MockContext), + }; +}); + +describe('useWorkspaceFormLocationData', () => { + const wrapper: React.FC< + React.PropsWithChildren<{ initialEntries: (string | { pathname: string; state?: unknown })[] }> + > = ({ children, initialEntries }) => ( + + {children} + + ); + + it('returns edit mode data', () => { + const initialEntries = [ + { pathname: '/workspaces/edit', state: { namespace: 'ns2', workspaceName: 'ws' } }, + ]; + const { result } = renderHook(() => useWorkspaceFormLocationData(), { + wrapper: (props) => wrapper({ ...props, initialEntries }), + }); + expect(result.current).toEqual({ mode: 'edit', namespace: 'ns2', workspaceName: 'ws' }); + }); + + it('throws when missing workspaceName in edit mode', () => { + const initialEntries = [{ pathname: '/workspaces/edit', state: { namespace: 'ns1' } }]; + expect(() => + renderHook(() => useWorkspaceFormLocationData(), { + wrapper: (props) => wrapper({ ...props, initialEntries }), + }), + ).toThrow(); + }); + + it('returns create mode data using selected namespace when state not provided', () => { + const initialEntries = [{ pathname: '/workspaces/create' }]; + const { result } = renderHook(() => useWorkspaceFormLocationData(), { + wrapper: (props) => wrapper({ ...props, initialEntries }), + }); + expect(result.current).toEqual({ mode: 'create', namespace: 'ns1' }); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceKindByName.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceKindByName.spec.tsx new file mode 100644 index 00000000..88fd8339 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceKindByName.spec.tsx @@ -0,0 +1,69 @@ +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import useWorkspaceKindByName from '~/app/hooks/useWorkspaceKindByName'; +import { NotebookApis } from '~/shared/api/notebookApi'; +import { buildMockWorkspaceKind } from '~/shared/mock/mockBuilder'; + +jest.mock('~/app/hooks/useNotebookAPI', () => ({ + useNotebookAPI: jest.fn(), +})); + +const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction; + +describe('useWorkspaceKindByName', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects when API not available', async () => { + mockUseNotebookAPI.mockReturnValue({ + api: {} as NotebookApis, + apiAvailable: false, + refreshAllAPI: jest.fn(), + }); + + const { result, waitForNextUpdate } = renderHook(() => useWorkspaceKindByName('jupyter')); + await waitForNextUpdate(); + + const [workspaceKind, loaded, error] = result.current; + expect(workspaceKind).toBeNull(); + expect(loaded).toBe(false); + expect(error).toBeDefined(); + }); + + it('returns null when no kind provided', async () => { + mockUseNotebookAPI.mockReturnValue({ + api: {} as NotebookApis, + apiAvailable: true, + refreshAllAPI: jest.fn(), + }); + + const { result, waitForNextUpdate } = renderHook(() => useWorkspaceKindByName(undefined)); + await waitForNextUpdate(); + + const [workspaceKind, loaded, error] = result.current; + expect(workspaceKind).toBeNull(); + expect(loaded).toBe(true); + expect(error).toBeUndefined(); + }); + + it('returns kind when API is available', async () => { + const mockWorkspaceKind = buildMockWorkspaceKind({}); + const getWorkspaceKind = jest.fn().mockResolvedValue({ ok: true, data: mockWorkspaceKind }); + mockUseNotebookAPI.mockReturnValue({ + api: { workspaceKinds: { getWorkspaceKind } } as unknown as NotebookApis, + apiAvailable: true, + refreshAllAPI: jest.fn(), + }); + + const { result, waitForNextUpdate } = renderHook(() => + useWorkspaceKindByName(mockWorkspaceKind.name), + ); + await waitForNextUpdate(); + + const [workspaceKind, loaded, error] = result.current; + expect(workspaceKind).toEqual(mockWorkspaceKind); + expect(loaded).toBe(true); + expect(error).toBeUndefined(); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceKinds.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceKinds.spec.tsx new file mode 100644 index 00000000..4637bb69 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceKinds.spec.tsx @@ -0,0 +1,51 @@ +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import useWorkspaceKinds from '~/app/hooks/useWorkspaceKinds'; +import { NotebookApis } from '~/shared/api/notebookApi'; +import { buildMockWorkspaceKind } from '~/shared/mock/mockBuilder'; + +jest.mock('~/app/hooks/useNotebookAPI', () => ({ + useNotebookAPI: jest.fn(), +})); + +const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction; + +describe('useWorkspaceKinds', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects when API not available', async () => { + mockUseNotebookAPI.mockReturnValue({ + api: {} as NotebookApis, + apiAvailable: false, + refreshAllAPI: jest.fn(), + }); + + const { result, waitForNextUpdate } = renderHook(() => useWorkspaceKinds()); + await waitForNextUpdate(); + + const [workspaceKinds, loaded, error] = result.current; + expect(workspaceKinds).toEqual([]); + expect(loaded).toBe(false); + expect(error).toBeDefined(); + }); + + it('returns kinds when API is available', async () => { + const mockWorkspaceKind = buildMockWorkspaceKind({}); + const listWorkspaceKinds = jest.fn().mockResolvedValue({ ok: true, data: [mockWorkspaceKind] }); + mockUseNotebookAPI.mockReturnValue({ + api: { workspaceKinds: { listWorkspaceKinds } } as unknown as NotebookApis, + apiAvailable: true, + refreshAllAPI: jest.fn(), + }); + + const { result, waitForNextUpdate } = renderHook(() => useWorkspaceKinds()); + await waitForNextUpdate(); + + const [workspaceKinds, loaded, error] = result.current; + expect(workspaceKinds).toEqual([mockWorkspaceKind]); + expect(loaded).toBe(true); + expect(error).toBeUndefined(); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceRowActions.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceRowActions.spec.tsx new file mode 100644 index 00000000..16d9779b --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaceRowActions.spec.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import { WorkspaceActionsContext } from '~/app/context/WorkspaceActionsContext'; +import { useWorkspaceRowActions } from '~/app/hooks/useWorkspaceRowActions'; +import { WorkspacesWorkspace } from '~/generated/data-contracts'; +import { buildMockWorkspace } from '~/shared/mock/mockBuilder'; + +jest.mock('~/app/context/WorkspaceActionsContext', () => { + const ReactActual = jest.requireActual('react'); + const MockContext = ReactActual.createContext(undefined); + return { + WorkspaceActionsContext: MockContext, + useWorkspaceActionsContext: () => ReactActual.useContext(MockContext), + }; +}); + +describe('useWorkspaceRowActions', () => { + const workspace = buildMockWorkspace({ name: 'ws', namespace: 'ns' }); + + type MinimalAction = { title?: string; isSeparator?: boolean; onClick?: () => void }; + type RequestActionArgs = { workspace: WorkspacesWorkspace; onActionDone?: () => void }; + type WorkspaceActionsContextLike = { + requestViewDetailsAction: (args: RequestActionArgs) => void; + requestEditAction: (args: RequestActionArgs) => void; + requestDeleteAction: (args: RequestActionArgs) => void; + requestStartAction: (args: RequestActionArgs) => void; + requestRestartAction: (args: RequestActionArgs) => void; + requestStopAction: (args: RequestActionArgs) => void; + }; + + const contextValue: WorkspaceActionsContextLike = { + requestViewDetailsAction: jest.fn(), + requestEditAction: jest.fn(), + requestDeleteAction: jest.fn(), + requestStartAction: jest.fn(), + requestRestartAction: jest.fn(), + requestStopAction: jest.fn(), + }; + + const wrapper: React.FC = ({ children }) => ( + + {children} + + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('builds actions respecting visibility and separators', () => { + const actionsToInclude = [ + { id: 'viewDetails' as const }, + { id: 'separator' as const }, + { id: 'edit' as const, isVisible: (w: WorkspacesWorkspace) => w.name === 'ws' }, + { id: 'delete' as const, isVisible: false }, + ]; + + const { result } = renderHook(() => useWorkspaceRowActions(actionsToInclude), { wrapper }); + const actions = result.current(workspace); + + expect(actions).toHaveLength(3); + expect((actions[0] as MinimalAction).title).toBe('View Details'); + expect((actions[1] as MinimalAction).isSeparator).toBe(true); + expect((actions[2] as MinimalAction).title).toBe('Edit'); + }); + + it('triggers context requests on action click', () => { + const onActionDone = jest.fn(); + const { result } = renderHook( + () => + useWorkspaceRowActions([ + { id: 'start' }, + { id: 'stop' }, + { id: 'restart' }, + { id: 'delete', onActionDone }, + ]), + { wrapper }, + ); + + const actions = result.current(workspace); + act(() => (actions[0] as MinimalAction).onClick?.()); + act(() => (actions[1] as MinimalAction).onClick?.()); + act(() => (actions[2] as MinimalAction).onClick?.()); + act(() => (actions[3] as MinimalAction).onClick?.()); + + expect(contextValue.requestStartAction).toHaveBeenCalledWith({ workspace }); + expect(contextValue.requestStopAction).toHaveBeenCalledWith({ workspace }); + expect(contextValue.requestRestartAction).toHaveBeenCalledWith({ workspace }); + expect(contextValue.requestDeleteAction).toHaveBeenCalledWith({ workspace, onActionDone }); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/__tests__/useWorkspaces.spec.tsx b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaces.spec.tsx new file mode 100644 index 00000000..cd125d43 --- /dev/null +++ b/workspaces/frontend/src/app/hooks/__tests__/useWorkspaces.spec.tsx @@ -0,0 +1,193 @@ +import { renderHook } from '~/__tests__/unit/testUtils/hooks'; +import { useNotebookAPI } from '~/app/hooks/useNotebookAPI'; +import { useWorkspacesByKind, useWorkspacesByNamespace } from '~/app/hooks/useWorkspaces'; +import { NotebookApis } from '~/shared/api/notebookApi'; +import { + buildMockImageConfig, + buildMockOptionInfo, + buildMockPodConfig, + buildMockPodTemplate, + buildMockWorkspace, + buildMockWorkspaceKindInfo, + buildMockWorkspaceList, + buildPodTemplateOptions, +} from '~/shared/mock/mockBuilder'; + +jest.mock('~/app/hooks/useNotebookAPI', () => ({ + useNotebookAPI: jest.fn(), +})); + +const mockUseNotebookAPI = useNotebookAPI as jest.MockedFunction; + +describe('useWorkspaces', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('useWorkspacesByNamespace', () => { + it('returns error when API unavailable', async () => { + mockUseNotebookAPI.mockReturnValue({ + api: {} as NotebookApis, + apiAvailable: false, + refreshAllAPI: jest.fn(), + }); + const { result, waitForNextUpdate } = renderHook(() => useWorkspacesByNamespace('ns')); + await waitForNextUpdate(); + + const [workspaces, loaded, error] = result.current; + expect(workspaces).toEqual([]); + expect(loaded).toBe(false); + expect(error).toBeDefined(); + }); + + it('fetches workspaces by namespace', async () => { + const mockWorkspace = buildMockWorkspace({}); + const mockWorkspaces = buildMockWorkspaceList({ + count: 10, + namespace: 'ns', + kind: mockWorkspace.workspaceKind, + }); + const listWorkspacesByNamespace = jest + .fn() + .mockResolvedValue({ ok: true, data: mockWorkspaces }); + const api = { workspaces: { listWorkspacesByNamespace } } as unknown as NotebookApis; + + mockUseNotebookAPI.mockReturnValue({ + api, + apiAvailable: true, + refreshAllAPI: jest.fn(), + }); + + const { result, waitForNextUpdate } = renderHook(() => useWorkspacesByNamespace('ns')); + await waitForNextUpdate(); + + const [workspaces, loaded, error] = result.current; + expect(workspaces).toEqual(mockWorkspaces); + expect(loaded).toBe(true); + expect(error).toBeUndefined(); + }); + }); + + describe('useWorkspacesByKind', () => { + it('returns error when API unavailable', async () => { + mockUseNotebookAPI.mockReturnValue({ + api: {} as NotebookApis, + apiAvailable: false, + refreshAllAPI: jest.fn(), + }); + const { result, waitForNextUpdate } = renderHook(() => + useWorkspacesByKind({ kind: 'jupyter' }), + ); + await waitForNextUpdate(); + + const [workspaces, loaded, error] = result.current; + expect(workspaces).toEqual([]); + expect(loaded).toBe(false); + expect(error).toBeDefined(); + }); + + it('returns default state and error when kind missing', async () => { + mockUseNotebookAPI.mockReturnValue({ + api: {} as NotebookApis, + apiAvailable: true, + refreshAllAPI: jest.fn(), + }); + const { result, waitForNextUpdate } = renderHook(() => useWorkspacesByKind({ kind: '' })); + await waitForNextUpdate(); + + const [workspaces, loaded, error] = result.current; + expect(workspaces).toEqual([]); + expect(loaded).toBe(false); + expect(error).toBeDefined(); + }); + + it('filters workspaces by given criteria', async () => { + const mockWorkspace1 = buildMockWorkspace({ + name: 'workspace1', + namespace: 'ns1', + workspaceKind: buildMockWorkspaceKindInfo({ name: 'kind1' }), + podTemplate: buildMockPodTemplate({ + options: buildPodTemplateOptions({ + imageConfig: buildMockImageConfig({ + current: buildMockOptionInfo({ id: 'img1' }), + }), + podConfig: buildMockPodConfig({ + current: buildMockOptionInfo({ id: 'pod1' }), + }), + }), + }), + }); + const mockWorkspace2 = buildMockWorkspace({ + name: 'workspace2', + namespace: 'ns2', + workspaceKind: buildMockWorkspaceKindInfo({ name: 'kind1' }), + podTemplate: buildMockPodTemplate({ + options: buildPodTemplateOptions({ + imageConfig: buildMockImageConfig({ + current: buildMockOptionInfo({ id: 'img2' }), + }), + podConfig: buildMockPodConfig({ + current: buildMockOptionInfo({ id: 'pod2' }), + }), + }), + }), + }); + const mockWorkspace3 = buildMockWorkspace({ + name: 'workspace3', + namespace: 'ns1', + workspaceKind: buildMockWorkspaceKindInfo({ name: 'kind2' }), + podTemplate: buildMockPodTemplate({ + options: buildPodTemplateOptions({ + imageConfig: buildMockImageConfig({ + current: buildMockOptionInfo({ id: 'img1' }), + }), + podConfig: buildMockPodConfig({ + current: buildMockOptionInfo({ id: 'pod1' }), + }), + }), + }), + }); + const mockWorkspaces = [mockWorkspace1, mockWorkspace2, mockWorkspace3]; + + const listAllWorkspaces = jest.fn().mockResolvedValue({ ok: true, data: mockWorkspaces }); + const api = { workspaces: { listAllWorkspaces } } as unknown as NotebookApis; + mockUseNotebookAPI.mockReturnValue({ + api, + apiAvailable: true, + refreshAllAPI: jest.fn(), + }); + + const { result, waitForNextUpdate, rerender } = renderHook( + (props) => useWorkspacesByKind(props), + { + initialProps: { + kind: 'kind1', + namespace: 'ns1', + imageId: 'img1', + podConfigId: 'pod1', + }, + }, + ); + + const [workspaces, loaded, error] = result.current; + expect(workspaces).toEqual([]); + expect(loaded).toBe(false); + expect(error).toBeUndefined(); + + await waitForNextUpdate(); + + const [workspaces2, loaded2, error2] = result.current; + expect(workspaces2).toEqual([mockWorkspace1]); + expect(loaded2).toBe(true); + expect(error2).toBeUndefined(); + + rerender({ kind: 'kind2', namespace: 'ns1', imageId: 'img1', podConfigId: 'pod1' }); + await waitForNextUpdate(); + + const [workspaces3, loaded3, error3] = result.current; + expect(workspaces3).toEqual([mockWorkspace3]); + expect(loaded3).toBe(true); + expect(error3).toBeUndefined(); + }); + }); +}); diff --git a/workspaces/frontend/src/app/hooks/useWorkspaceFormData.ts b/workspaces/frontend/src/app/hooks/useWorkspaceFormData.ts index 88b9acc6..97429986 100644 --- a/workspaces/frontend/src/app/hooks/useWorkspaceFormData.ts +++ b/workspaces/frontend/src/app/hooks/useWorkspaceFormData.ts @@ -6,7 +6,7 @@ import useFetchState, { FetchStateCallbackPromise, } from '~/shared/utilities/useFetchState'; -const EMPTY_FORM_DATA: WorkspaceFormData = { +export const EMPTY_FORM_DATA: WorkspaceFormData = { kind: undefined, image: undefined, podConfig: undefined, @@ -51,14 +51,14 @@ const useWorkspaceFormData = (args: { displayName: imageConfig.displayName, description: imageConfig.description, hidden: false, - labels: [], + labels: imageConfig.labels, }, podConfig: { id: podConfig.id, displayName: podConfig.displayName, description: podConfig.description, hidden: false, - labels: [], + labels: podConfig.labels, }, properties: { workspaceName: workspace.name, diff --git a/workspaces/frontend/src/shared/mock/mockBuilder.ts b/workspaces/frontend/src/shared/mock/mockBuilder.ts index ef4434b2..bb539c0f 100644 --- a/workspaces/frontend/src/shared/mock/mockBuilder.ts +++ b/workspaces/frontend/src/shared/mock/mockBuilder.ts @@ -5,6 +5,11 @@ import { NamespacesNamespace, WorkspacekindsRedirectMessageLevel, WorkspacekindsWorkspaceKind, + WorkspacesImageConfig, + WorkspacesOptionInfo, + WorkspacesPodConfig, + WorkspacesPodTemplate, + WorkspacesPodTemplateOptions, WorkspacesWorkspace, WorkspacesWorkspaceKindInfo, WorkspacesWorkspaceState, @@ -39,6 +44,102 @@ export const buildMockWorkspaceKindInfo = ( ...workspaceKindInfo, }); +export const buildMockOptionInfo = ( + optionInfo?: Partial, +): WorkspacesOptionInfo => ({ + id: 'jupyterlab_scipy_190', + displayName: 'jupyter-scipy:v1.9.0', + description: 'JupyterLab, with SciPy Packages', + labels: [ + { + key: 'pythonVersion', + value: '3.11', + }, + { + key: 'jupyterlabVersion', + value: '1.9.0', + }, + ], + ...optionInfo, +}); + +export const buildMockImageConfig = ( + imageConfig?: Partial, +): WorkspacesImageConfig => ({ + current: buildMockOptionInfo({}), + ...imageConfig, +}); + +export const buildMockPodConfig = ( + podConfig?: Partial, +): WorkspacesPodConfig => ({ + current: { + id: 'tiny_cpu', + displayName: 'Tiny CPU', + description: 'Pod with 0.1 CPU, 128 Mb RAM', + labels: [ + { + key: 'cpu', + value: '100m', + }, + { + key: 'memory', + value: '128Mi', + }, + { + key: 'gpu', + value: '1', + }, + ], + }, + ...podConfig, +}); + +export const buildPodTemplateOptions = ( + podTemplateOptions?: Partial, +): WorkspacesPodTemplateOptions => ({ + imageConfig: buildMockImageConfig({}), + podConfig: buildMockPodConfig({}), + ...podTemplateOptions, +}); + +export const buildMockPodTemplate = ( + podTemplate?: Partial, +): WorkspacesPodTemplate => ({ + podMetadata: { + labels: { labelKey1: 'labelValue1', labelKey2: 'labelValue2' }, + annotations: { annotationKey1: 'annotationValue1', annotationKey2: 'annotationValue2' }, + }, + volumes: { + home: { + pvcName: 'Volume-Home', + mountPath: '/home', + readOnly: false, + }, + data: [ + { + pvcName: 'Volume-Data1', + mountPath: '/data', + readOnly: true, + }, + { + pvcName: 'Volume-Data2', + mountPath: '/data', + readOnly: false, + }, + ], + secrets: [ + { + defaultMode: 0o644, + mountPath: '/secrets', + secretName: 'secret-1', + }, + ], + }, + options: buildPodTemplateOptions({}), + ...podTemplate, +}); + export const buildMockWorkspace = ( workspace?: Partial, ): WorkspacesWorkspace => ({ @@ -50,71 +151,7 @@ export const buildMockWorkspace = ( pausedTime: new Date(2025, 3, 1).getTime(), state: WorkspacesWorkspaceState.WorkspaceStateRunning, stateMessage: 'Workspace is running', - podTemplate: { - podMetadata: { - labels: { labelKey1: 'labelValue1', labelKey2: 'labelValue2' }, - annotations: { annotationKey1: 'annotationValue1', annotationKey2: 'annotationValue2' }, - }, - volumes: { - home: { - pvcName: 'Volume-Home', - mountPath: '/home', - readOnly: false, - }, - data: [ - { - pvcName: 'Volume-Data1', - mountPath: '/data', - readOnly: true, - }, - { - pvcName: 'Volume-Data2', - mountPath: '/data', - readOnly: false, - }, - ], - }, - options: { - imageConfig: { - current: { - id: 'jupyterlab_scipy_190', - displayName: 'jupyter-scipy:v1.9.0', - description: 'JupyterLab, with SciPy Packages', - labels: [ - { - key: 'pythonVersion', - value: '3.11', - }, - { - key: 'jupyterlabVersion', - value: '1.9.0', - }, - ], - }, - }, - podConfig: { - current: { - id: 'tiny_cpu', - displayName: 'Tiny CPU', - description: 'Pod with 0.1 CPU, 128 Mb RAM', - labels: [ - { - key: 'cpu', - value: '100m', - }, - { - key: 'memory', - value: '128Mi', - }, - { - key: 'gpu', - value: '1', - }, - ], - }, - }, - }, - }, + podTemplate: buildMockPodTemplate({}), activity: { lastActivity: new Date(2025, 5, 1).getTime(), lastUpdate: new Date(2025, 4, 1).getTime(),