Skip to content

Commit a73c96f

Browse files
committed
CONSOLE-5183: Add unit tests and e2e scenarios for persistent terminal sessions
Add comprehensive test coverage for the detach-to-Cloud-Shell feature: Unit tests (Jest): - Action creators: addDetachedSession, removeDetachedSession, clearDetachedSessions - Reducer: session add, duplicate rejection, MAX_DETACHED_SESSIONS limit, remove, clear - Selectors: getDetachedSessions with populated and empty state - detached-ws-registry: store/take/has one-shot semantics, cleanupDetachedResource with NamespaceModel and PodModel, error handling, no-op for undefined - MultiTabbedTerminal: detached tabs render, total tab count, cleanup on close - Fix pre-existing MultiTabbedTerminal test failures by adding useFlag mock E2E tests (Cypress + Cucumber): - Gherkin scenarios for detach workflow, session persistence across navigation, session limit enforcement, tab close, and drawer close cleanup - Step definitions and page object for detach button and drawer verification How to run tests: cd frontend && yarn jest packages/webterminal-plugin/src/redux/actions/__tests__/cloud-shell-actions.spec.ts cd frontend && yarn jest packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-reducer.spec.ts cd frontend && yarn jest packages/webterminal-plugin/src/redux/reducers/__tests__/cloud-shell-selectors.spec.ts cd frontend && yarn jest public/module/__tests__/detached-ws-registry.spec.ts cd frontend && yarn jest packages/webterminal-plugin/src/components/cloud-shell/__tests__/MultiTabbedTerminal.spec.tsx
1 parent 6f93b10 commit a73c96f

File tree

9 files changed

+460
-5
lines changed

9 files changed

+460
-5
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
@web-terminal
2+
Feature: Persistent Terminal Sessions (Detach to Cloud Shell)
3+
As a user, I should be able to detach pod terminals to the Cloud Shell drawer
4+
so that they persist across page navigation
5+
6+
Background:
7+
Given user has logged in as basic user
8+
And user has created or selected namespace "aut-terminal-detach"
9+
And user can see terminal icon on masthead
10+
11+
@regression
12+
Scenario: Detach pod terminal to Cloud Shell drawer: WT-02-TC01
13+
Given user is on the pod details terminal tab for a running pod
14+
When user clicks the Detach to Cloud Shell button
15+
Then user will see the Cloud Shell drawer open
16+
And user will see a detached session tab with the pod name
17+
18+
@regression
19+
Scenario: Detached session persists across navigation: WT-02-TC02
20+
Given user has a detached terminal session in the Cloud Shell drawer
21+
When user navigates to a different page
22+
Then user will still see the detached session tab in the Cloud Shell drawer
23+
24+
@regression
25+
Scenario: Close a detached session tab: WT-02-TC03
26+
Given user has a detached terminal session in the Cloud Shell drawer
27+
When user clicks the close button on the detached session tab
28+
Then the detached session tab is removed from the drawer
29+
30+
@regression
31+
Scenario: Session limit prevents more than five detached sessions: WT-02-TC04
32+
Given user has five detached terminal sessions in the Cloud Shell drawer
33+
Then the Detach to Cloud Shell button is disabled on the pod terminal
34+
35+
@regression
36+
Scenario: Close drawer clears all detached sessions: WT-02-TC05
37+
Given user has a detached terminal session in the Cloud Shell drawer
38+
When user closes the Cloud Shell drawer
39+
And user clicks on the Web Terminal icon on the Masthead
40+
Then user will not see any detached session tabs
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export const detachTerminalPO = {
2+
detachButton: 'button:contains("Detach to Cloud Shell")',
3+
detachedButton: 'button:contains("Detached")',
4+
detachedTab: '[data-test="detached-terminal-tab"]',
5+
multiTabTerminal: '[data-test="multi-tab-terminal"]',
6+
closeTabButton: '[aria-label="Close terminal tab"]',
7+
cloudShellDrawer: '.co-cloud-shell-drawer',
8+
};
9+
10+
export const detachTerminalPage = {
11+
clickDetachButton: () => {
12+
cy.get(detachTerminalPO.detachButton).should('be.visible').click();
13+
},
14+
15+
verifyDetachedTabs: (count: number) => {
16+
cy.get(detachTerminalPO.detachedTab).should('have.length', count);
17+
},
18+
19+
verifyNoDetachedTabs: () => {
20+
cy.get(detachTerminalPO.detachedTab).should('not.exist');
21+
},
22+
23+
verifyDetachButtonDisabled: () => {
24+
cy.get(detachTerminalPO.detachButton).should('be.disabled');
25+
},
26+
27+
closeDetachedTab: (index = 0) => {
28+
cy.get(detachTerminalPO.detachedTab).eq(index).find(detachTerminalPO.closeTabButton).click();
29+
},
30+
31+
verifyDrawerOpen: () => {
32+
cy.get(detachTerminalPO.cloudShellDrawer).should('be.visible');
33+
},
34+
35+
verifyDetachedTabWithPodName: (podName: string) => {
36+
cy.get(detachTerminalPO.detachedTab).contains(podName).should('be.visible');
37+
},
38+
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps';
2+
import { detachTerminalPage } from '@console/webterminal-plugin/integration-tests/support/step-definitions/pages/web-terminal/detachTerminal-page';
3+
import { webTerminalPage } from '@console/webterminal-plugin/integration-tests/support/step-definitions/pages/web-terminal/webTerminal-page';
4+
5+
Given('user is on the pod details terminal tab for a running pod', () => {
6+
const ns = Cypress.expose('NAMESPACE') || 'aut-terminal-detach';
7+
cy.exec(`oc get pods -n ${ns} -o jsonpath='{.items[0].metadata.name}'`).then((result) => {
8+
const podName = result.stdout.replace(/'/g, '');
9+
cy.visit(`/k8s/ns/${ns}/pods/${podName}/terminal`);
10+
cy.get('.co-terminal', { timeout: 30000 }).should('be.visible');
11+
});
12+
});
13+
14+
When('user clicks the Detach to Cloud Shell button', () => {
15+
detachTerminalPage.clickDetachButton();
16+
});
17+
18+
Then('user will see the Cloud Shell drawer open', () => {
19+
detachTerminalPage.verifyDrawerOpen();
20+
});
21+
22+
Then('user will see a detached session tab with the pod name', () => {
23+
detachTerminalPage.verifyDetachedTabs(1);
24+
});
25+
26+
Given('user has a detached terminal session in the Cloud Shell drawer', () => {
27+
const ns = Cypress.expose('NAMESPACE') || 'aut-terminal-detach';
28+
cy.exec(`oc get pods -n ${ns} -o jsonpath='{.items[0].metadata.name}'`).then((result) => {
29+
const podName = result.stdout.replace(/'/g, '');
30+
cy.visit(`/k8s/ns/${ns}/pods/${podName}/terminal`);
31+
cy.get('.co-terminal', { timeout: 30000 }).should('be.visible');
32+
detachTerminalPage.clickDetachButton();
33+
detachTerminalPage.verifyDetachedTabs(1);
34+
});
35+
});
36+
37+
When('user navigates to a different page', () => {
38+
cy.visit('/k8s/cluster/projects');
39+
cy.url().should('include', '/projects');
40+
});
41+
42+
Then('user will still see the detached session tab in the Cloud Shell drawer', () => {
43+
detachTerminalPage.verifyDetachedTabs(1);
44+
});
45+
46+
When('user clicks the close button on the detached session tab', () => {
47+
detachTerminalPage.closeDetachedTab(0);
48+
});
49+
50+
Then('the detached session tab is removed from the drawer', () => {
51+
detachTerminalPage.verifyNoDetachedTabs();
52+
});
53+
54+
Given('user has five detached terminal sessions in the Cloud Shell drawer', () => {
55+
const ns = Cypress.expose('NAMESPACE') || 'aut-terminal-detach';
56+
cy.exec(`oc get pods -n ${ns} -o jsonpath='{.items[*].metadata.name}'`).then((result) => {
57+
const pods = result.stdout.replace(/'/g, '').split(' ');
58+
const targetPod = pods[0];
59+
for (let i = 0; i < 5; i++) {
60+
cy.visit(`/k8s/ns/${ns}/pods/${targetPod}/terminal`);
61+
cy.get('.co-terminal', { timeout: 30000 }).should('be.visible');
62+
detachTerminalPage.clickDetachButton();
63+
}
64+
detachTerminalPage.verifyDetachedTabs(5);
65+
});
66+
});
67+
68+
Then('the Detach to Cloud Shell button is disabled on the pod terminal', () => {
69+
detachTerminalPage.verifyDetachButtonDisabled();
70+
});
71+
72+
When('user closes the Cloud Shell drawer', () => {
73+
webTerminalPage.closeCurrentTerminalSession();
74+
});
75+
76+
Then('user will not see any detached session tabs', () => {
77+
detachTerminalPage.verifyNoDetachedTabs();
78+
});

frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/CloudShellDrawer.spec.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ jest.mock('@console/webterminal-plugin/src/redux/actions/cloud-shell-dispatchers
2121

2222
jest.mock('@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors', () => ({
2323
useIsCloudShellExpanded: jest.fn(() => true),
24+
useDetachedSessions: jest.fn(() => []),
2425
}));
2526

2627
const mockUseFlag = useFlag as jest.Mock;

frontend/packages/webterminal-plugin/src/components/cloud-shell/__tests__/MultiTabbedTerminal.spec.tsx

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,33 @@ jest.mock('@console/webterminal-plugin/src/components/cloud-shell/CloudShellTerm
1313
default: () => 'Terminal content',
1414
}));
1515

16+
jest.mock('@console/webterminal-plugin/src/components/cloud-shell/DetachedPodExec', () => ({
17+
default: ({ sessionId }: { sessionId: string }) => `Detached ${sessionId}`,
18+
}));
19+
20+
jest.mock('@console/shared/src/hooks/useFlag', () => ({
21+
useFlag: () => true,
22+
}));
23+
24+
const mockCleanup = jest.fn();
25+
jest.mock('@console/internal/module/detached-ws-registry', () => ({
26+
cleanupDetachedResource: (...args: unknown[]) => mockCleanup(...args),
27+
}));
28+
29+
const mockUseDetachedSessions = jest.fn();
30+
jest.mock('@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors', () => {
31+
const actual = jest.requireActual(
32+
'@console/webterminal-plugin/src/redux/reducers/cloud-shell-selectors',
33+
);
34+
return {
35+
...actual,
36+
useDetachedSessions: () => mockUseDetachedSessions(),
37+
};
38+
});
39+
1640
const originalWindowRequestAnimationFrame = window.requestAnimationFrame;
1741
const originalWindowCancelAnimationFrame = window.cancelAnimationFrame;
1842

19-
// Helper to click an element multiple times sequentially with act()
2043
const clickMultipleTimes = async (element: HTMLElement, times: number) => {
2144
for (let i = 0; i < times; i++) {
2245
// eslint-disable-next-line no-await-in-loop
@@ -40,6 +63,11 @@ describe('MultiTabTerminal', () => {
4063
window.cancelAnimationFrame = originalWindowCancelAnimationFrame;
4164
});
4265

66+
beforeEach(() => {
67+
mockUseDetachedSessions.mockReturnValue([]);
68+
mockCleanup.mockClear();
69+
});
70+
4371
it('should initially load with only one console', async () => {
4472
let multiTabTerminalWrapper: ReturnType<typeof renderWithProviders>;
4573
await act(async () => {
@@ -103,5 +131,65 @@ describe('MultiTabTerminal', () => {
103131
expect(multiTabTerminalWrapper.getAllByText('Terminal content').length).toBe(5);
104132
});
105133

134+
describe('detached session tabs', () => {
135+
const detachedSessions = [
136+
{
137+
id: 'pod1-c1',
138+
podName: 'my-pod',
139+
namespace: 'ns-1',
140+
containerName: 'main',
141+
cleanup: { type: 'namespace' as const, name: 'openshift-debug-abc' },
142+
},
143+
{
144+
id: 'pod2-c2',
145+
podName: 'other-pod',
146+
namespace: 'ns-2',
147+
containerName: 'sidecar',
148+
},
149+
];
150+
151+
it('should render detached sessions as additional tabs', async () => {
152+
mockUseDetachedSessions.mockReturnValue(detachedSessions);
153+
154+
let wrapper: ReturnType<typeof renderWithProviders>;
155+
await act(async () => {
156+
wrapper = renderWithProviders(<MultiTabbedTerminal />);
157+
});
158+
159+
expect(wrapper.getByText('Detached pod1-c1')).toBeTruthy();
160+
expect(wrapper.getByText('Detached pod2-c2')).toBeTruthy();
161+
});
162+
163+
it('should include detached sessions in total tab count', async () => {
164+
mockUseDetachedSessions.mockReturnValue(detachedSessions);
165+
166+
let wrapper: ReturnType<typeof renderWithProviders>;
167+
await act(async () => {
168+
wrapper = renderWithProviders(<MultiTabbedTerminal />);
169+
});
170+
171+
// 1 Cloud Shell tab + 2 detached = 3 total
172+
const closeBtns = wrapper.getAllByLabelText('Close terminal tab');
173+
expect(closeBtns).toHaveLength(3);
174+
});
175+
176+
it('should call cleanupDetachedResource when closing a detached tab with cleanup metadata', async () => {
177+
mockUseDetachedSessions.mockReturnValue(detachedSessions);
178+
179+
let wrapper: ReturnType<typeof renderWithProviders>;
180+
await act(async () => {
181+
wrapper = renderWithProviders(<MultiTabbedTerminal />);
182+
});
183+
184+
const closeBtns = wrapper.getAllByLabelText('Close terminal tab');
185+
// Index 0 = Cloud Shell tab, Index 1 = first detached, Index 2 = second detached
186+
await act(async () => {
187+
fireEvent.click(closeBtns[1]);
188+
});
189+
190+
expect(mockCleanup).toHaveBeenCalledWith(detachedSessions[0].cleanup);
191+
});
192+
});
193+
106194
jest.clearAllTimers();
107195
});

frontend/packages/webterminal-plugin/src/redux/actions/__tests__/cloud-shell-actions.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { setCloudShellExpanded, setCloudShellActive, Actions } from '../cloud-shell-actions';
1+
import {
2+
setCloudShellExpanded,
3+
setCloudShellActive,
4+
addDetachedSession,
5+
removeDetachedSession,
6+
clearDetachedSessions,
7+
Actions,
8+
} from '../cloud-shell-actions';
29

310
describe('Cloud shell actions', () => {
411
it('should create expand action', () => {
@@ -38,4 +45,38 @@ describe('Cloud shell actions', () => {
3845
}),
3946
);
4047
});
48+
49+
it('should create addDetachedSession action', () => {
50+
const session = {
51+
id: 'test-pod-container-123',
52+
podName: 'test-pod',
53+
namespace: 'default',
54+
containerName: 'container',
55+
command: ['sh', '-i'],
56+
cleanup: { type: 'namespace' as const, name: 'openshift-debug-abc' },
57+
};
58+
expect(addDetachedSession(session)).toEqual(
59+
expect.objectContaining({
60+
type: Actions.AddDetachedSession,
61+
payload: session,
62+
}),
63+
);
64+
});
65+
66+
it('should create removeDetachedSession action', () => {
67+
expect(removeDetachedSession('session-1')).toEqual(
68+
expect.objectContaining({
69+
type: Actions.RemoveDetachedSession,
70+
payload: { id: 'session-1' },
71+
}),
72+
);
73+
});
74+
75+
it('should create clearDetachedSessions action', () => {
76+
expect(clearDetachedSessions()).toEqual(
77+
expect.objectContaining({
78+
type: Actions.ClearDetachedSessions,
79+
}),
80+
);
81+
});
4182
});

0 commit comments

Comments
 (0)