diff --git a/packages/manager/.changeset/pr-11341-tests-1732886601848.md b/packages/manager/.changeset/pr-11341-tests-1732886601848.md new file mode 100644 index 00000000000..d9eaf9b59fe --- /dev/null +++ b/packages/manager/.changeset/pr-11341-tests-1732886601848.md @@ -0,0 +1,5 @@ +--- +'@linode/manager': Tests +--- + +Cypress component tests for firewall rules drag and drop keyboard interaction ([#11341](https://github.com/linode/manager/pull/11341)) diff --git a/packages/manager/cypress/component/core/firewalls/firewall-rules-drag-and-drop.spec.tsx b/packages/manager/cypress/component/core/firewalls/firewall-rules-drag-and-drop.spec.tsx new file mode 100644 index 00000000000..71eceece862 --- /dev/null +++ b/packages/manager/cypress/component/core/firewalls/firewall-rules-drag-and-drop.spec.tsx @@ -0,0 +1,513 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import * as React from 'react'; +import { ui } from 'support/ui'; +import { componentTests } from 'support/util/components'; +import { + randomItem, + randomLabel, + randomNumber, + randomString, +} from 'support/util/random'; + +import { firewallRuleFactory } from 'src/factories'; +import { FirewallRulesLanding } from 'src/features/Firewalls/FirewallDetail/Rules/FirewallRulesLanding'; + +import type { FirewallPolicyType, FirewallRuleType } from '@linode/api-v4'; + +interface MoveFocusedElementParams { + direction: 'DOWN' | 'UP'; + times: number; +} + +const portPresetMap = { + '22': 'SSH', + '53': 'DNS', + '80': 'HTTP', + '443': 'HTTPS', + '3306': 'MySQL', +}; + +const mockInboundRules = Array.from({ length: 3 }, () => + firewallRuleFactory.build({ + action: 'ACCEPT', + description: randomString(), + label: randomLabel(), + ports: randomItem(Object.keys(portPresetMap)), + }) +); + +const mockOutboundRules = Array.from({ length: 3 }, () => + firewallRuleFactory.build({ + action: 'DROP', + description: randomString(), + label: randomLabel(), + ports: randomItem(Object.keys(portPresetMap)), + }) +); + +const inboundRule1 = mockInboundRules[0]; +const inboundRule2 = mockInboundRules[1]; +const inboundRule3 = mockInboundRules[2]; + +const outboundRule1 = mockOutboundRules[0]; +const outboundRule2 = mockOutboundRules[1]; +const outboundRule3 = mockOutboundRules[2]; + +const inboundAriaLabel = 'inbound Rules List'; +const outboundAriaLabel = 'outbound Rules List'; +const buttonText = 'Save Changes'; + +/** + * Returns the formatted label for the given firewall rule action. + * + * @param ruleAction + */ +const getRuleActionLabel = (ruleAction: FirewallPolicyType): string => { + return `${ruleAction.charAt(0).toUpperCase()}${ruleAction + .slice(1) + .toLowerCase()}`; +}; + +/** + * Move the focused element either up or down, N times. + * + * note: Cypress automatically focuses the element when you use .type() or .type(' '). + * + * @param options.direction - Direction to move the element (row) "UP" or "DOWN". + * @param options.times - Number of times to move the element. + */ +const moveFocusedElement = ({ direction, times }: MoveFocusedElementParams) => { + // `direction` is either "UP" or "DOWN" + const arrowKey = direction === 'DOWN' ? '{downarrow}' : '{uparrow}'; + + const repeatedArrowKey = arrowKey.repeat(times); + + // Focused element will receive the repeated arrow key presses + cy.focused().type(repeatedArrowKey); +}; + +/** + * Verifies that the firewall landing page correctly lists the specified inbound + * and outbound rules in the firewall table, based on the provided options. + * + * @param options.includeInbound - Boolean flag to specify whether inbound rules should be included. + * @param options.includeOutbound - Boolean flag to specify whether outbound rules should be included. + * @param options.isSmallViewport - Boolean flag to specify whether the viewport is considered small (default is false). + */ +const verifyFirewallWithRules = ({ + includeInbound, + includeOutbound, + isSmallViewport = false, +}: { + includeInbound: boolean; + includeOutbound: boolean; + isSmallViewport?: boolean; +}) => { + // Verify that the Firewall Landing page displays the "Inbound Rules" and "Outbound Rules" headers. + cy.findByText('Inbound Rules').should('be.visible'); + cy.findByText('Outbound Rules').should('be.visible'); + + const inboundRules = includeInbound ? mockInboundRules : []; + const outboundRules = includeOutbound ? mockOutboundRules : []; + + // Confirm the appropriate rules are listed with correct details. + [...inboundRules, ...outboundRules].forEach((rule) => { + cy.findByText(rule.label!) + .should('be.visible') + .closest('tr') + .within(() => { + if (isSmallViewport) { + // Column 'Protocol' is not visible for smaller screens. + cy.findByText(rule.protocol).should('not.exist'); + } else { + cy.findByText(rule.protocol).should('be.visible'); + } + + cy.findByText(rule.ports!).should('be.visible'); + cy.findByText(getRuleActionLabel(rule.action)).should('be.visible'); + }); + }); +}; + +/** + * Verifies that the rows in a table are in the expected order based on the + * provided list of rules and the specified aria-label. + * + * @param ariaLabel - The aria-label of the table (either inbound or outbound rule table). + * @param expectedOrder - The expected order of rules (Array of FirewallRuleType objects). + * + * @example + * // Verifies that the inbound rule table rows are in the expected order. + * verifyTableRowOrder('inbound Rules List', [rule1, rule2, rule3]); + */ +const verifyTableRowOrder = ( + ariaLabel: string, + expectedOrder: FirewallRuleType[] +) => { + cy.get(`[aria-label="${ariaLabel}"]`).within(() => { + cy.get('tbody tr').then((rows) => { + expectedOrder.forEach((rule, index) => { + expect(rows[index]).to.contain(rule.label); + }); + }); + }); +}; + +/** + * Test scenario for moving inbound rule rows using keyboard interactions. + * + * This test verifies that the keyboard-based drag-and-drop functionality + * works as expected for inbound rules: + * - Ensuring the `Save Changes` button is initially disabled. + * - Activating the row drag mode via `Space/Enter` key. + * - Moving the rule rows up and down with arrow keys. + * - Dropping the row and verifying the updated row order. + * - Enabling the `Save Changes` button after the operation. + */ +const testMoveInboundRuleRowsViaKeyboard = () => { + // Verify 'Save Changes' button is initially disabled. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); + + // Activate keyboard drag mode using Space/Enter key on the first row - inboundRule1. + cy.findByText(inboundRule1.label!).should('be.visible'); + cy.findByText(inboundRule1.label!).closest('tr').type(' '); + cy.findByText(inboundRule1.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `inboundRule1` down two rows. + moveFocusedElement({ direction: 'DOWN', times: 2 }); + + // Drop row with Space/Enter key. + cy.focused().type(' '); + + // Verify that "inboundRule2" is in the 1st row, + // "inboundRule3" is in the 2nd row, and "inboundRule1" is in the 3rd row. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule2, + inboundRule3, + inboundRule1, + ]); + + // Activate keyboard drag mode using Space/Enter key on the 2nd row - inboundRule3. + cy.findByText(inboundRule3.label!).should('be.visible'); + cy.findByText(inboundRule3.label!).closest('tr').type(' '); + cy.findByText(inboundRule3.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `inboundRule3` up one row. + moveFocusedElement({ direction: 'UP', times: 1 }); + + // Drop row with Space/Enter key. + cy.focused().type(' '); + + // Verify that "inboundRule3" is in the 1st row, + // "inboundRule2" is in the 2nd row, and "inboundRule1" is in the 3rd row. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule3, + inboundRule2, + inboundRule1, + ]); + + // Verify 'Save Changes' button is enabled after row is moved. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'false'); +}; + +/** + * Test scenario for canceling the inbound rule drag-and-drop operation using the `Esc` key. + * + * This test checks that when the `Esc` key is pressed during a row drag operation, + * the row returns to its original position and the `Save Changes` button remains disabled. + */ +const testDiscardInboundRuleDragViaKeyboard = () => { + // Verify 'Save Changes' button is initially disabled. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); + + // Activate keyboard drag mode using Space/Enter key on the first row - inboundRule1. + cy.findByText(inboundRule1.label!).should('be.visible'); + cy.findByText(inboundRule1.label!).closest('tr').type(' '); + cy.findByText(inboundRule1.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `inboundRule1` down two rows. + moveFocusedElement({ direction: 'DOWN', times: 2 }); + + // Cancel with Esc key. + cy.focused().type('{esc}'); + + // Ensure row remains in its original position. + verifyTableRowOrder(inboundAriaLabel, [ + inboundRule1, + inboundRule2, + inboundRule3, + ]); + + // Verify 'Save Changes' button remains disabled after discarding with Esc key. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); +}; + +/** + * Test scenario for moving outbound rule rows using keyboard interactions. + * + * This test verifies that the keyboard-based drag-and-drop functionality + * works as expected for outbound rules: + * - Ensuring the `Save Changes` button is initially disabled. + * - Activating the row drag mode via `Space/Enter` key. + * - Moving the rule rows up and down with arrow keys. + * - Dropping the row and verifying the updated row order. + * - Enabling the `Save Changes` button after the operation. + */ +const testMoveOutboundRulesViaKeyboard = () => { + // Verify 'Save Changes' button is initially disabled. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); + + // Activate keyboard drag mode using Space/Enter key on the first row - outboundRule1. + cy.findByText(outboundRule1.label!).should('be.visible'); + cy.findByText(outboundRule1.label!).closest('tr').type(' '); + cy.findByText(outboundRule1.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `outboundRule1` down two rows + moveFocusedElement({ direction: 'DOWN', times: 2 }); + + // Drop row with Space/Enter key + cy.focused().type(' '); + + // Verify that "outboundRule2" is in the 1st row, + // "outboundRule3" is in the 2nd row, and "outboundRule1" is in the 3rd row. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule2, + outboundRule3, + outboundRule1, + ]); + + // Activate keyboard drag mode using Space/Enter key on the 2nd row - outboundRule3. + cy.findByText(outboundRule3.label!).should('be.visible'); + cy.findByText(outboundRule3.label!).closest('tr').type(' '); + cy.findByText(outboundRule3.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `outboundRule3` up one row. + moveFocusedElement({ direction: 'UP', times: 1 }); + + // Drop row with Space/Enter key. + cy.focused().type(' '); + + // Verify that "outboundRule3" is in the 1st row, + // "outboundRule2" is in the 2nd row, and "outboundRule1" is in the 3rd row. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule3, + outboundRule2, + outboundRule1, + ]); + + // Verify 'Save Changes' button is enabled after row is moved. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'false'); +}; + +/** + * Test scenario for canceling the outbound rule drag-and-drop operation using the `Esc` key. + * + * This test checks that when the `Esc` key is pressed during a row drag operation, + * the row returns to its original position and the `Save Changes` button remains disabled. + */ +const testDiscardOutboundRuleDragViaKeyboard = () => { + // Verify 'Save Changes' button is initially disabled. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); + + // Activate keyboard drag mode using Space/Enter key on the first row - outboundRule1. + cy.findByText(outboundRule1.label!).should('be.visible'); + cy.findByText(outboundRule1.label!).closest('tr').type(' '); + cy.findByText(outboundRule1.label!) + .closest('tr') + .should('have.attr', 'aria-pressed', 'true'); + + // Move `outboundRule1` down two rows. + moveFocusedElement({ direction: 'DOWN', times: 2 }); + + // Cancel with Esc key. + cy.focused().type('{esc}'); + + // Ensure row remains in its original position. + verifyTableRowOrder(outboundAriaLabel, [ + outboundRule1, + outboundRule2, + outboundRule3, + ]); + + // Verify 'Save Changes' button remains disabled after discarding with Esc key. + ui.button + .findByTitle(buttonText) + .should('be.visible') + .should('have.attr', 'aria-disabled', 'true'); +}; + +componentTests('FirewallRulesTable Drag and Drop Interactions', (mount) => { + /** + * Keyboard keys used to perform interactions with rows in the Firewall Rules table: + * - Press `Space/Enter` key once to activate keyboard sensor on the selected row. + * - Use `Up/Down` arrow keys to move the row up or down. + * - Press `Space/Enter` key again to drop the focused row. + * - Press `Esc` key to discard drag and drop operation. + * + * Confirms: + * - All keyboard interactions on Firewall Rules table rows work as expected + * for both normal (no vertical scrollbar) and smaller window sizes (with vertical scrollbar). + * - `CustomKeyboardSensor` works as expected. + */ + describe('Keyboard Interactions', () => { + describe('Normal window (no vertical scrollbar)', () => { + beforeEach(() => { + cy.viewport(1536, 960); + }); + + describe('Inbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: true, + includeOutbound: false, + }); + }); + + it('should move Inbound rule rows using keyboard interaction', () => { + testMoveInboundRuleRowsViaKeyboard(); + }); + + it('should cancel the Inbound rules drag operation with Esc key', () => { + testDiscardInboundRuleDragViaKeyboard(); + }); + }); + + describe('Outbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: false, + includeOutbound: true, + }); + }); + + it('should move Outbound rule rows using keyboard interaction', () => { + testMoveOutboundRulesViaKeyboard(); + }); + + it('should cancel the Outbound rules drag operation with Esc key', () => { + testDiscardOutboundRuleDragViaKeyboard(); + }); + }); + }); + + describe('Window with vertical scrollbar', () => { + beforeEach(() => { + // Browser window with vertical scroll bar enabled (smaller screens). + cy.viewport(800, 400); + cy.window().should('have.property', 'innerWidth', 800); + cy.window().should('have.property', 'innerHeight', 400); + }); + + describe('Inbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: true, + includeOutbound: false, + isSmallViewport: true, + }); + }); + + it('should move Inbound rule rows using keyboard interaction', () => { + testMoveInboundRuleRowsViaKeyboard(); + }); + + it('should cancel the Inbound rules drag operation with Esc key', () => { + testDiscardInboundRuleDragViaKeyboard(); + }); + }); + + describe('Outbound Rules:', () => { + beforeEach(() => { + mount( + + ); + verifyFirewallWithRules({ + includeInbound: false, + includeOutbound: true, + isSmallViewport: true, + }); + }); + + it('should move Outbound rule rows using keyboard interaction', () => { + testMoveOutboundRulesViaKeyboard(); + }); + + it('should cancel the Outbound rules drag operation with Esc key', () => { + testDiscardOutboundRuleDragViaKeyboard(); + }); + }); + }); + }); +}); diff --git a/packages/manager/src/utilities/CustomKeyboardSensor.ts b/packages/manager/src/utilities/CustomKeyboardSensor.ts index e32e46ff1e7..521799c45fb 100644 --- a/packages/manager/src/utilities/CustomKeyboardSensor.ts +++ b/packages/manager/src/utilities/CustomKeyboardSensor.ts @@ -1,5 +1,8 @@ -// Customizing KeyboardSensor from dnd-kit to meet our requirements. -// - Prevent scrolling while using keyboard keys. +// Customizing `KeyboardSensor` from `dnd-kit` to meet our requirements: +// - Prevent scrolling while using keyboard keys (for both normal and smaller window sizes). +// * This resolves the issue of keystrokes not being recognized or having no effect on smaller screens. +// - Add a focus style to the draggable element while it is being dragged. +// - Clear the focus style when the draggable element is dropped. import { KeyboardCode, defaultCoordinates } from '@dnd-kit/core'; import { @@ -146,7 +149,7 @@ export class CustomKeyboardSensor implements SensorInstance { this.windowListeners.add(EventName.Resize, this.handleCancel); this.windowListeners.add(EventName.VisibilityChange, this.handleCancel); - // Add focus style when draggable element is dragging. + // Add a focus style to the draggable element while it is being dragged. const activator = this.props.activeNode.node.current; if (activator) { activator.style.outline = '1px dashed grey'; @@ -161,7 +164,7 @@ export class CustomKeyboardSensor implements SensorInstance { this.listeners.removeAll(); this.windowListeners.removeAll(); - // Clear focus style when draggable element is dropped + // Clear the focus style when the draggable element is dropped. const dropTarget = this.props.activeNode.node.current; if (dropTarget) { dropTarget.style.outline = 'none';