Skip to content

Commit b3cd688

Browse files
author
Yehudit Kerido
committed
feat(ws): Notebooks 2.0 // Frontend // Fetch workspaces
Signed-off-by: Yehudit Kerido <yehudit.kerido@nokia.com>
1 parent d06762d commit b3cd688

File tree

8 files changed

+364
-196
lines changed

8 files changed

+364
-196
lines changed

workspaces/frontend/src/__tests__/cypress/cypress/support/commands/axe.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@ declare global {
55
namespace Cypress {
66
interface Chainable {
77
testA11y: (context?: Parameters<cy['checkA11y']>[0]) => void;
8+
getDataTest(dataTestSelector: string): Chainable<JQuery<HTMLElement>>;
89
}
910
}
1011
}
1112

13+
Cypress.Commands.add('getDataTest', (dataTestSelector) => {
14+
return cy.get(`[data-test="${dataTestSelector}"]`);
15+
});
16+
1217
Cypress.Commands.add('testA11y', { prevSubject: 'optional' }, (subject, context) => {
1318
const test = (c: Parameters<typeof cy.checkA11y>[0]) => {
1419
cy.window({ log: false }).then((win) => {

workspaces/frontend/src/__tests__/cypress/cypress/tests/e2e/Workspaces.cy.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,105 @@
1+
import { WorkspaceState } from '~/shared/types';
2+
import { home } from '~/__tests__/cypress/cypress/pages/home';
3+
import {
4+
mockWorkspaces,
5+
mockWorkspacesByNS,
6+
} from '~/__tests__/cypress/cypress/tests/mocked/workspace.mock';
17
import { mockNamespaces } from '~/__mocks__/mockNamespaces';
28
import { mockBFFResponse } from '~/__mocks__/utils';
39

10+
// Helper function to validate the content of a single workspace row in the table
11+
const validateWorkspaceRow = (workspace: any, index: number) => {
12+
// Validate the workspace name
13+
cy.getDataTest(`workspace-row-${index}`)
14+
.find('[data-test="workspace-name"]')
15+
.should('have.text', workspace.name);
16+
17+
// Map workspace state to the expected label
18+
const expectedLabel = WorkspaceState[workspace.status.state];
19+
20+
// Validate the state label and pod configuration
21+
cy.getDataTest(`workspace-row-${index}`)
22+
.find('[data-test="state-label"]')
23+
.should('have.text', expectedLabel);
24+
25+
cy.getDataTest(`workspace-row-${index}`)
26+
.find('[data-test="pod-config"]')
27+
.should('have.text', workspace.options.podConfig);
28+
};
29+
30+
// Test suite for workspace-related tests
31+
describe('Workspaces Tests', () => {
32+
beforeEach(() => {
33+
home.visit();
34+
cy.intercept('GET', '/api/v1/workspaces', {
35+
body: mockBFFResponse(mockWorkspaces),
36+
}).as('getWorkspaces');
37+
cy.wait('@getWorkspaces');
38+
});
39+
40+
it('should display the correct number of workspaces', () => {
41+
cy.getDataTest('workspaces-table')
42+
.find('tbody tr')
43+
.should('have.length', mockWorkspaces.length);
44+
});
45+
46+
it('should validate all workspace rows', () => {
47+
mockWorkspaces.forEach((workspace, index) => {
48+
cy.log(`Validating workspace ${index + 1}: ${workspace.name}`);
49+
validateWorkspaceRow(workspace, index);
50+
});
51+
});
52+
53+
it('should handle empty workspaces gracefully', () => {
54+
cy.intercept('GET', '/api/v1/workspaces', { statusCode: 200, body: { data: [] } });
55+
cy.visit('/');
56+
57+
cy.getDataTest('workspaces-table').find('tbody tr').should('not.exist');
58+
});
59+
});
60+
61+
// Test suite for workspace functionality by namespace
62+
describe('Workspace by namespace functionality', () => {
63+
beforeEach(() => {
64+
home.visit();
65+
66+
cy.intercept('GET', '/api/v1/namespaces', {
67+
body: mockBFFResponse(mockNamespaces),
68+
}).as('getNamespaces');
69+
70+
cy.intercept('GET', 'api/v1/workspaces', { body: mockBFFResponse(mockWorkspaces) }).as(
71+
'getWorkspaces',
72+
);
73+
74+
cy.intercept('GET', '/api/v1/workspaces/kubeflow', {
75+
body: mockBFFResponse(mockWorkspacesByNS),
76+
}).as('getKubeflowWorkspaces');
77+
78+
cy.wait('@getNamespaces');
79+
});
80+
81+
it('should update workspaces when namespace changes', () => {
82+
// Verify initial state (default namespace)
83+
cy.wait('@getWorkspaces');
84+
cy.getDataTest('workspaces-table')
85+
.find('tbody tr')
86+
.should('have.length', mockWorkspaces.length);
87+
88+
// Change namespace to "kubeflow"
89+
cy.findByTestId('namespace-toggle').click();
90+
cy.findByTestId('dropdown-item-kubeflow').click();
91+
92+
// Verify the API call is made with the new namespace
93+
cy.wait('@getKubeflowWorkspaces')
94+
.its('request.url')
95+
.should('include', '/api/v1/workspaces/kubeflow');
96+
97+
// Verify the length of workspaces list is updated
98+
cy.getDataTest('workspaces-table')
99+
.find('tbody tr')
100+
.should('have.length', mockWorkspacesByNS.length);
101+
});
102+
});
4103
describe('Workspaces Component', () => {
5104
beforeEach(() => {
6105
// Mock the namespaces API response
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { WorkspaceState } from '~/shared/types';
2+
3+
const generateMockWorkspace = (
4+
name: string,
5+
namespace: string,
6+
state: WorkspaceState,
7+
paused: boolean,
8+
imageConfig: string,
9+
podConfig: string,
10+
pvcName: string,
11+
) => {
12+
const currentTime = Date.now();
13+
const lastActivity = currentTime - Math.floor(Math.random() * 1000000); // Random last activity time
14+
const lastUpdate = currentTime - Math.floor(Math.random() * 100000); // Random last update time
15+
16+
return {
17+
name,
18+
namespace,
19+
paused,
20+
deferUpdates: !!paused,
21+
kind: 'jupyter-lab',
22+
cpu: 3,
23+
ram: 500,
24+
podTemplate: {
25+
volumes: {
26+
home: '/home',
27+
data: [
28+
{
29+
pvcName,
30+
mountPath: '/data',
31+
readOnly: paused, // Randomize based on paused state
32+
},
33+
],
34+
},
35+
},
36+
options: {
37+
imageConfig,
38+
podConfig,
39+
},
40+
status: {
41+
activity: {
42+
lastActivity,
43+
lastUpdate,
44+
},
45+
pauseTime: paused ? currentTime - Math.floor(Math.random() * 1000000) : 0,
46+
pendingRestart: !!paused,
47+
podTemplateOptions: {
48+
imageConfig: {
49+
desired: imageConfig,
50+
redirectChain: [
51+
{
52+
source: 'base-image',
53+
target: `optimized-${Math.floor(Math.random() * 100)}`,
54+
},
55+
],
56+
},
57+
},
58+
state,
59+
stateMessage:
60+
state === WorkspaceState.Running
61+
? 'Workspace is running smoothly.'
62+
: state === WorkspaceState.Paused
63+
? 'Workspace is paused.'
64+
: 'Workspace is operational.',
65+
},
66+
};
67+
};
68+
69+
const generateMockWorkspaces = (numWorkspaces: number, byNamespace = false) => {
70+
const mockWorkspaces = [];
71+
const podConfigs = ['Small CPU', 'Medium CPU', 'Large CPU'];
72+
const imageConfigs = [
73+
'jupyterlab_scipy_180',
74+
'jupyterlab_tensorflow_230',
75+
'jupyterlab_pytorch_120',
76+
];
77+
const namespaces = byNamespace ? ['kubeflow'] : ['kubeflow', 'system', 'user-example'];
78+
79+
for (let i = 1; i <= numWorkspaces; i++) {
80+
const state =
81+
i % 3 === 0
82+
? WorkspaceState.Error
83+
: i % 2 === 0
84+
? WorkspaceState.Paused
85+
: WorkspaceState.Running;
86+
const paused = state === WorkspaceState.Paused;
87+
const name = `workspace-${i}`;
88+
const namespace = namespaces[i % namespaces.length];
89+
const pvcName = `data-pvc-${i}`;
90+
const imageConfig = imageConfigs[i % imageConfigs.length];
91+
const podConfig = podConfigs[i % podConfigs.length];
92+
93+
mockWorkspaces.push(
94+
generateMockWorkspace(name, namespace, state, paused, imageConfig, podConfig, pvcName),
95+
);
96+
}
97+
98+
return mockWorkspaces;
99+
};
100+
101+
// Example usage
102+
export const mockWorkspaces = generateMockWorkspaces(5);
103+
export const mockWorkspacesByNS = generateMockWorkspaces(10, true);

workspaces/frontend/src/app/context/useNotebookAPIState.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React from 'react';
2-
import { APIState } from '~/shared/api/types';
2+
import { APIState, APIOptions } from '~/shared/api/types';
33
import { NotebookAPIs } from '~/app/types';
4-
import { getNamespaces } from '~/shared/api/notebookService';
4+
import { getNamespaces, getWorkspaces } from '~/shared/api/notebookService';
55
import useAPIState from '~/shared/api/useAPIState';
66

77
export type NotebookAPIState = APIState<NotebookAPIs>;
@@ -12,6 +12,7 @@ const useNotebookAPIState = (
1212
const createAPI = React.useCallback(
1313
(path: string) => ({
1414
getNamespaces: getNamespaces(path),
15+
getWorkspaces: (opts: APIOptions, namespace = '') => getWorkspaces(path, namespace)(opts),
1516
}),
1617
[],
1718
);
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import * as React from 'react';
2+
import { Workspace } from '~/shared/types';
3+
import useFetchState, {
4+
FetchState,
5+
FetchStateCallbackPromise,
6+
} from '~/shared/utilities/useFetchState';
7+
import { useNotebookAPI } from './useNotebookAPI';
8+
9+
const useWorkspaces = (namespace = ''): FetchState<Workspace[]> => {
10+
const { api, apiAvailable } = useNotebookAPI();
11+
12+
const call = React.useCallback<FetchStateCallbackPromise<Workspace[]>>(
13+
(opts) => {
14+
if (!apiAvailable) {
15+
return Promise.reject(new Error('API not yet available'));
16+
}
17+
return api.getWorkspaces(opts, namespace);
18+
},
19+
[api, apiAvailable, namespace],
20+
);
21+
22+
return useFetchState(call, []);
23+
};
24+
25+
export default useWorkspaces;

0 commit comments

Comments
 (0)