test: add unit tests for frontend hooks (#527)
Signed-off-by: Guilherme Caponetto <638737+caponetto@users.noreply.github.com>
This commit is contained in:
parent
2286b2c0d4
commit
265d5af568
|
@ -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<React.PropsWithChildren<{ initialEntries: string[] }>> = ({
|
||||||
|
children,
|
||||||
|
initialEntries,
|
||||||
|
}) => <MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>;
|
||||||
|
|
||||||
|
const fillParams = (pattern: string) => pattern.replace(/:([^/]+)/g, 'test');
|
||||||
|
const cases: ReadonlyArray<readonly [string, AppRouteKey]> = (
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<typeof useNotebookAPI>;
|
||||||
|
|
||||||
|
describe('useNamespaces', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when API not available', async () => {
|
||||||
|
const unavailableState: APIState<NotebookApis> = {
|
||||||
|
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<NotebookApis> = {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<typeof useNotebookAPI>['api'];
|
||||||
|
const wrapper: React.FC<React.PropsWithChildren> = ({ children }) => (
|
||||||
|
<NotebookContext.Provider
|
||||||
|
value={{
|
||||||
|
apiState: { apiAvailable: true, api },
|
||||||
|
refreshAPIState,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</NotebookContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useNotebookAPI(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.apiAvailable).toBe(true);
|
||||||
|
expect(result.current.api).toBe(api);
|
||||||
|
|
||||||
|
result.current.refreshAllAPI();
|
||||||
|
expect(refreshAPIState).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<typeof useNotebookAPI>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 }) => (
|
||||||
|
<MockContext.Provider value={mockNamespaceValue}>{children}</MockContext.Provider>
|
||||||
|
),
|
||||||
|
useNamespaceContext: () => ReactActual.useContext(MockContext),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useWorkspaceFormLocationData', () => {
|
||||||
|
const wrapper: React.FC<
|
||||||
|
React.PropsWithChildren<{ initialEntries: (string | { pathname: string; state?: unknown })[] }>
|
||||||
|
> = ({ children, initialEntries }) => (
|
||||||
|
<MemoryRouter initialEntries={initialEntries}>
|
||||||
|
<NamespaceContextProvider>{children}</NamespaceContextProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<typeof useNotebookAPI>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<typeof useNotebookAPI>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<React.PropsWithChildren> = ({ children }) => (
|
||||||
|
<WorkspaceActionsContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</WorkspaceActionsContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
});
|
|
@ -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<typeof useNotebookAPI>;
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -6,7 +6,7 @@ import useFetchState, {
|
||||||
FetchStateCallbackPromise,
|
FetchStateCallbackPromise,
|
||||||
} from '~/shared/utilities/useFetchState';
|
} from '~/shared/utilities/useFetchState';
|
||||||
|
|
||||||
const EMPTY_FORM_DATA: WorkspaceFormData = {
|
export const EMPTY_FORM_DATA: WorkspaceFormData = {
|
||||||
kind: undefined,
|
kind: undefined,
|
||||||
image: undefined,
|
image: undefined,
|
||||||
podConfig: undefined,
|
podConfig: undefined,
|
||||||
|
@ -51,14 +51,14 @@ const useWorkspaceFormData = (args: {
|
||||||
displayName: imageConfig.displayName,
|
displayName: imageConfig.displayName,
|
||||||
description: imageConfig.description,
|
description: imageConfig.description,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
labels: [],
|
labels: imageConfig.labels,
|
||||||
},
|
},
|
||||||
podConfig: {
|
podConfig: {
|
||||||
id: podConfig.id,
|
id: podConfig.id,
|
||||||
displayName: podConfig.displayName,
|
displayName: podConfig.displayName,
|
||||||
description: podConfig.description,
|
description: podConfig.description,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
labels: [],
|
labels: podConfig.labels,
|
||||||
},
|
},
|
||||||
properties: {
|
properties: {
|
||||||
workspaceName: workspace.name,
|
workspaceName: workspace.name,
|
||||||
|
|
|
@ -5,6 +5,11 @@ import {
|
||||||
NamespacesNamespace,
|
NamespacesNamespace,
|
||||||
WorkspacekindsRedirectMessageLevel,
|
WorkspacekindsRedirectMessageLevel,
|
||||||
WorkspacekindsWorkspaceKind,
|
WorkspacekindsWorkspaceKind,
|
||||||
|
WorkspacesImageConfig,
|
||||||
|
WorkspacesOptionInfo,
|
||||||
|
WorkspacesPodConfig,
|
||||||
|
WorkspacesPodTemplate,
|
||||||
|
WorkspacesPodTemplateOptions,
|
||||||
WorkspacesWorkspace,
|
WorkspacesWorkspace,
|
||||||
WorkspacesWorkspaceKindInfo,
|
WorkspacesWorkspaceKindInfo,
|
||||||
WorkspacesWorkspaceState,
|
WorkspacesWorkspaceState,
|
||||||
|
@ -39,6 +44,102 @@ export const buildMockWorkspaceKindInfo = (
|
||||||
...workspaceKindInfo,
|
...workspaceKindInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const buildMockOptionInfo = (
|
||||||
|
optionInfo?: Partial<WorkspacesOptionInfo>,
|
||||||
|
): 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>,
|
||||||
|
): WorkspacesImageConfig => ({
|
||||||
|
current: buildMockOptionInfo({}),
|
||||||
|
...imageConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildMockPodConfig = (
|
||||||
|
podConfig?: Partial<WorkspacesPodConfig>,
|
||||||
|
): 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>,
|
||||||
|
): WorkspacesPodTemplateOptions => ({
|
||||||
|
imageConfig: buildMockImageConfig({}),
|
||||||
|
podConfig: buildMockPodConfig({}),
|
||||||
|
...podTemplateOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buildMockPodTemplate = (
|
||||||
|
podTemplate?: Partial<WorkspacesPodTemplate>,
|
||||||
|
): 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 = (
|
export const buildMockWorkspace = (
|
||||||
workspace?: Partial<WorkspacesWorkspace>,
|
workspace?: Partial<WorkspacesWorkspace>,
|
||||||
): WorkspacesWorkspace => ({
|
): WorkspacesWorkspace => ({
|
||||||
|
@ -50,71 +151,7 @@ export const buildMockWorkspace = (
|
||||||
pausedTime: new Date(2025, 3, 1).getTime(),
|
pausedTime: new Date(2025, 3, 1).getTime(),
|
||||||
state: WorkspacesWorkspaceState.WorkspaceStateRunning,
|
state: WorkspacesWorkspaceState.WorkspaceStateRunning,
|
||||||
stateMessage: 'Workspace is running',
|
stateMessage: 'Workspace is running',
|
||||||
podTemplate: {
|
podTemplate: buildMockPodTemplate({}),
|
||||||
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',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
activity: {
|
activity: {
|
||||||
lastActivity: new Date(2025, 5, 1).getTime(),
|
lastActivity: new Date(2025, 5, 1).getTime(),
|
||||||
lastUpdate: new Date(2025, 4, 1).getTime(),
|
lastUpdate: new Date(2025, 4, 1).getTime(),
|
||||||
|
|
Loading…
Reference in New Issue