From e4066e1e2c00c5e09a9ccdd8656916b9f4594e5f Mon Sep 17 00:00:00 2001 From: John Nesky Date: Fri, 30 May 2025 22:56:21 -0700 Subject: [PATCH 1/3] chore: Added context menu item tests. --- test/webdriverio/test/actions_test.ts | 147 +++++++++++++++++++++++++- test/webdriverio/test/test_setup.ts | 48 ++++++++- 2 files changed, 192 insertions(+), 3 deletions(-) diff --git a/test/webdriverio/test/actions_test.ts b/test/webdriverio/test/actions_test.ts index 249e4d65..ab35e444 100644 --- a/test/webdriverio/test/actions_test.ts +++ b/test/webdriverio/test/actions_test.ts @@ -8,9 +8,13 @@ import * as chai from 'chai'; import {Key} from 'webdriverio'; import { contextMenuExists, + getContextMenuItemNames, moveToToolboxCategory, PAUSE_TIME, focusOnBlock, + focusWorkspace, + rightClickOnBlock, + rightClickOnFlyoutBlockType, tabNavigateToWorkspace, testFileLocations, testSetup, @@ -27,6 +31,18 @@ suite('Menus test', function () { await this.browser.pause(PAUSE_TIME); }); + test('Menu keyboard shortcut on workspace does not open menu', async function () { + await tabNavigateToWorkspace(this.browser); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys([Key.Ctrl, Key.Return]); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue( + await contextMenuExists(this.browser, 'Undo', /* reverse= */ true), + 'The menu should not be openable on the workspace', + ); + }); + test('Menu action opens menu', async function () { // Navigate to draw_circle_1. await tabNavigateToWorkspace(this.browser); @@ -41,9 +57,7 @@ suite('Menus test', function () { }); test('Menu action returns true in the toolbox', async function () { - // Navigate to draw_circle_1. await tabNavigateToWorkspace(this.browser); - await focusOnBlock(this.browser, 'draw_circle_1'); // Navigate to a toolbox category await moveToToolboxCategory(this.browser, 'Functions'); // Move to flyout. @@ -70,4 +84,133 @@ suite('Menus test', function () { 'The menu should not be openable during a move', ); }); + + test('Block menu via keyboard displays expected items', async function () { + await tabNavigateToWorkspace(this.browser); + await focusOnBlock(this.browser, 'draw_circle_1'); + await this.browser.keys([Key.Ctrl, Key.Return]); + await this.browser.pause(PAUSE_TIME); + + chai.assert.deepEqual( + await getContextMenuItemNames(this.browser), + [ + 'Duplicate', + 'Add Comment', + 'External Inputs', + 'Collapse Block', + 'Disable Block', + 'Delete 2 Blocks', + 'Move Block', + 'Edit Block contents', + 'Cut', + 'Copy', + 'Paste', + ], + 'A block context menu should display certain items', + ); + }); + + test('Block menu via mouse displays expected items', async function () { + await tabNavigateToWorkspace(this.browser); + await rightClickOnBlock(this.browser, 'draw_circle_1'); + + chai.assert.deepEqual( + await getContextMenuItemNames(this.browser), + [ + 'Duplicate', + 'Add Comment', + 'External Inputs', + 'Collapse Block', + 'Disable Block', + 'Delete 2 Blocks', + 'Cut', + 'Copy', + 'Paste', + ], + 'A block context menu should display certain items', + ); + }); + + test('Shadow block menu via keyboard displays expected items', async function () { + await tabNavigateToWorkspace(this.browser); + await focusOnBlock(this.browser, 'draw_circle_1_color'); + await this.browser.keys([Key.Ctrl, Key.Return]); + await this.browser.pause(PAUSE_TIME); + + chai.assert.deepEqual( + await getContextMenuItemNames(this.browser), + [ + 'Add Comment', + 'Collapse Block', + 'Disable Block', + 'Help', + 'Move Block', + 'Cut', + 'Copy', + 'Paste', + ], + 'A shadow block context menu should display certain items', + ); + }); + + test('Flyout block menu via keyboard displays expected items', async function () { + await tabNavigateToWorkspace(this.browser); + // Navigate to a toolbox category + await moveToToolboxCategory(this.browser, 'Functions'); + // Move to flyout. + await keyRight(this.browser); + await this.browser.keys([Key.Ctrl, Key.Return]); + await this.browser.pause(PAUSE_TIME); + + chai.assert.deepEqual( + await getContextMenuItemNames(this.browser), + ['Help', 'Move Block', 'Cut', 'Copy', 'Paste'], + 'A flyout block context menu should display certain items', + ); + }); + + test('Flyout block menu via mouse displays expected items', async function () { + await tabNavigateToWorkspace(this.browser); + // Navigate to a toolbox category + await moveToToolboxCategory(this.browser, 'Math'); + // Move to flyout. + await keyRight(this.browser); + await this.browser.pause(PAUSE_TIME); + await rightClickOnFlyoutBlockType(this.browser, 'math_number'); + await this.browser.pause(PAUSE_TIME); + + chai.assert.deepEqual( + await getContextMenuItemNames(this.browser), + ['Help', 'Cut', 'Copy', 'Paste'], + 'A flyout block context menu should display certain items', + ); + }); + + test('Escape key dismisses menu', async function () { + await tabNavigateToWorkspace(this.browser); + await focusOnBlock(this.browser, 'draw_circle_1'); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys([Key.Ctrl, Key.Return]); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys(Key.Escape); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue( + await contextMenuExists(this.browser, 'Duplicate', /* reverse= */ true), + 'The menu should be closed', + ); + }); + + test('Clicking workspace dismisses menu', async function () { + await tabNavigateToWorkspace(this.browser); + await rightClickOnBlock(this.browser, 'draw_circle_1'); + await this.browser.pause(PAUSE_TIME); + await focusWorkspace(this.browser); + await this.browser.pause(PAUSE_TIME); + + chai.assert.isTrue( + await contextMenuExists(this.browser, 'Duplicate', /* reverse= */ true), + 'The menu should be closed', + ); + }); }); diff --git a/test/webdriverio/test/test_setup.ts b/test/webdriverio/test/test_setup.ts index 154e4593..c03126ff 100644 --- a/test/webdriverio/test/test_setup.ts +++ b/test/webdriverio/test/test_setup.ts @@ -504,7 +504,7 @@ export async function isDragging( } /** - * Returns the result of the specificied action precondition. + * Returns the result of the specified action precondition. * * @param browser The active WebdriverIO Browser object. * @param action The action to check the precondition for. @@ -555,3 +555,49 @@ export async function contextMenuExists( const item = await browser.$(`div=${itemText}`); return await item.waitForExist({timeout: 200, reverse: reverse}); } + +/** + * Get a list of the text content of each displayed context menu item. + * + * Omits any keyboard shortcuts inside parentheses from all item text for + * testing consistency across platforms. + * + * @param browser The active WebdriverIO Browser object. + * @return A list of the text content of each displayed context menu item. + */ +export async function getContextMenuItemNames( + browser: WebdriverIO.Browser, +): Promise { + const items = await browser.$$(`.blocklyContextMenu .blocklyMenuItemContent`); + return await items.map(async (e) => + (await e.getText()).replace(/\s*\([^)]+\)/, ''), + ); +} + +/** + * Right-clicks on a block with the provided ID in the main workspace. + * + * @param browser The active WebdriverIO Browser object. + * @param blockId The ID of the block to right click on. + */ +export async function rightClickOnBlock( + browser: WebdriverIO.Browser, + blockId: string, +) { + const elem = await browser.$(`[data-id="${blockId}"]`); + await elem.click({button: 'right'}); +} + +/** + * Right-clicks on a block with the provided type in the flyout. + * + * @param browser The active WebdriverIO Browser object. + * @param blockType The name of the type block to right click on. + */ +export async function rightClickOnFlyoutBlockType( + browser: WebdriverIO.Browser, + blockType: string, +) { + const elem = await browser.$(`.blocklyFlyout .${blockType}`); + await elem.click({button: 'right'}); +} From 6779e2f2cc6b1d402e03562805ffcbfde574bb6e Mon Sep 17 00:00:00 2001 From: John Nesky Date: Fri, 6 Jun 2025 17:02:26 -0700 Subject: [PATCH 2/3] Maybe clicking a different block fixes the linux test faliure. --- test/webdriverio/test/actions_test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/webdriverio/test/actions_test.ts b/test/webdriverio/test/actions_test.ts index ab35e444..bdc3d1bf 100644 --- a/test/webdriverio/test/actions_test.ts +++ b/test/webdriverio/test/actions_test.ts @@ -203,7 +203,7 @@ suite('Menus test', function () { test('Clicking workspace dismisses menu', async function () { await tabNavigateToWorkspace(this.browser); - await rightClickOnBlock(this.browser, 'draw_circle_1'); + await rightClickOnBlock(this.browser, 'create_canvas_1'); await this.browser.pause(PAUSE_TIME); await focusWorkspace(this.browser); await this.browser.pause(PAUSE_TIME); From 11df8234877595bd23203b8ea2826352684bedb7 Mon Sep 17 00:00:00 2001 From: John Nesky Date: Tue, 10 Jun 2025 23:28:19 -0700 Subject: [PATCH 3/3] chore: Extend delete tests with context menu actions. --- test/webdriverio/test/delete_test.ts | 352 +++++++++++---------------- test/webdriverio/test/test_setup.ts | 54 +++- 2 files changed, 200 insertions(+), 206 deletions(-) diff --git a/test/webdriverio/test/delete_test.ts b/test/webdriverio/test/delete_test.ts index 8564fabd..e11fcb88 100644 --- a/test/webdriverio/test/delete_test.ts +++ b/test/webdriverio/test/delete_test.ts @@ -7,10 +7,13 @@ import * as chai from 'chai'; import { blockIsPresent, + clickOnAction, + doActionViaKeyboard, focusOnBlock, getCurrentFocusedBlockId, getFocusedBlockType, moveToToolboxCategory, + rightClickOnBlock, testSetup, testFileLocations, PAUSE_TIME, @@ -24,213 +27,154 @@ suite('Deleting Blocks', function () { // Setting timeout to unlimited as these tests take a longer time to run than most mocha test this.timeout(0); + const deletionStrategies = [ + { + strategyName: 'backspace', + deletionStrategy: async (browser: WebdriverIO.Browser) => + await browser.keys(Key.Backspace), + }, + { + strategyName: 'cut', + deletionStrategy: async (browser: WebdriverIO.Browser) => + await browser.keys([Key.Ctrl, 'x']), + }, + { + strategyName: 'keyboard action', + deletionStrategy: async (browser: WebdriverIO.Browser) => { + await browser.keys([Key.Ctrl, Key.Return]); + await doActionViaKeyboard(browser, 'Delete'); + }, + }, + { + strategyName: 'mouse action', + deletionStrategy: async (browser: WebdriverIO.Browser) => { + const blockIdToDelete = await getCurrentFocusedBlockId(browser); + if (blockIdToDelete) { + await rightClickOnBlock(browser, blockIdToDelete); + await clickOnAction(browser, 'Delete'); + } + }, + }, + ]; + setup(async function () { this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS); await this.browser.pause(PAUSE_TIME); }); - test('Deleting block selects parent block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); - await focusOnBlock(this.browser, 'controls_if_2'); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'controls_if_2')) - .equal(true); - - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'controls_if_2')) - .equal(false); - - chai - .expect(await getCurrentFocusedBlockId(this.browser)) - .to.include('controls_if_1'); - }); - - test('Cutting block selects parent block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); - await focusOnBlock(this.browser, 'controls_if_2'); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'controls_if_2')) - .equal(true); - - await this.browser.keys([Key.Ctrl, 'x']); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'controls_if_2')) - .equal(false); - - chai - .expect(await getCurrentFocusedBlockId(this.browser)) - .to.include('controls_if_1'); - }); - - test('Deleting block also deletes children and inputs', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); - await focusOnBlock(this.browser, 'controls_if_2'); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) - .equal(true); - chai.expect(await blockIsPresent(this.browser, 'text_print_1')).equal(true); - - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) - .equal(false); - chai - .expect(await blockIsPresent(this.browser, 'text_print_1')) - .equal(false); - }); - - test('Cutting block also removes children and inputs', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); - await focusOnBlock(this.browser, 'controls_if_2'); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) - .equal(true); - chai.expect(await blockIsPresent(this.browser, 'text_print_1')).equal(true); - - await this.browser.keys([Key.Ctrl, 'x']); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) - .equal(false); - chai - .expect(await blockIsPresent(this.browser, 'text_print_1')) - .equal(false); - }); - - test('Deleting inline input selects parent block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); - await focusOnBlock(this.browser, 'logic_boolean_1'); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) - .equal(true); - - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) - .equal(false); - - chai - .expect(await getCurrentFocusedBlockId(this.browser)) - .to.include('controls_if_2'); - }); - - test('Cutting inline input selects parent block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); - await focusOnBlock(this.browser, 'logic_boolean_1'); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) - .equal(true); - - await this.browser.keys([Key.Ctrl, 'x']); - await this.browser.pause(PAUSE_TIME); - - chai - .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) - .equal(false); - - chai - .expect(await getCurrentFocusedBlockId(this.browser)) - .to.include('controls_if_2'); - }); - - test('Deleting stranded block selects top block', async function () { - // Deleting a stranded block should result in the workspace being - // focused, which then focuses the top block. If that - // behavior ever changes, this test should be updated as well. - // We want deleting a block to focus the workspace, whatever that - // means at the time. - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); - - // The test workspace doesn't already contain a stranded block, so add one. - await moveToToolboxCategory(this.browser, 'Math'); - await this.browser.pause(PAUSE_TIME); - // Move to flyout. - await keyRight(this.browser); - // Select number block. - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); - // Confirm move. - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); - - chai.assert.equal('math_number', await getFocusedBlockType(this.browser)); - - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); - - chai.assert.equal( - await getCurrentFocusedBlockId(this.browser), - 'p5_setup_1', - ); - }); - - test('Cutting stranded block selects top block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); - - // The test workspace doesn't already contain a stranded block, so add one. - await moveToToolboxCategory(this.browser, 'Math'); - await this.browser.pause(PAUSE_TIME); - // Move to flyout. - await keyRight(this.browser); - // Select number block. - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); - // Confirm move. - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); - - chai.assert.equal('math_number', await getFocusedBlockType(this.browser)); - - await this.browser.keys([Key.Ctrl, 'x']); - await this.browser.pause(PAUSE_TIME); - - chai.assert.equal( - await getCurrentFocusedBlockId(this.browser), - 'p5_setup_1', - ); - }); - - test('Do not delete block while field editor is open', async function () { - // Open a field editor - await focusOnBlockField(this.browser, 'colour_picker_1', 'COLOUR'); - await this.browser.pause(PAUSE_TIME); - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); - - // Try to delete block while field editor is open - await this.browser.keys(Key.Backspace); - - // Block is not deleted - chai.assert.isTrue(await blockIsPresent(this.browser, 'colour_picker_1')); - }); + for (const {strategyName, deletionStrategy} of deletionStrategies) { + test(`Deleting block via ${strategyName} selects parent block`, async function () { + await tabNavigateToWorkspace(this.browser); + await this.browser.pause(PAUSE_TIME); + await focusOnBlock(this.browser, 'controls_if_2'); + await this.browser.pause(PAUSE_TIME); + + chai + .expect(await blockIsPresent(this.browser, 'controls_if_2')) + .equal(true); + + await deletionStrategy(this.browser); + await this.browser.pause(PAUSE_TIME); + + chai + .expect(await blockIsPresent(this.browser, 'controls_if_2')) + .equal(false); + + chai + .expect(await getCurrentFocusedBlockId(this.browser)) + .to.include('controls_if_1'); + }); + + test(`Deleting block via ${strategyName} also deletes children and inputs`, async function () { + await tabNavigateToWorkspace(this.browser); + await this.browser.pause(PAUSE_TIME); + await focusOnBlock(this.browser, 'controls_if_2'); + await this.browser.pause(PAUSE_TIME); + + chai + .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) + .equal(true); + chai + .expect(await blockIsPresent(this.browser, 'text_print_1')) + .equal(true); + + await deletionStrategy(this.browser); + await this.browser.pause(PAUSE_TIME); + + chai + .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) + .equal(false); + chai + .expect(await blockIsPresent(this.browser, 'text_print_1')) + .equal(false); + }); + + test(`Deleting inline input via ${strategyName} selects parent block`, async function () { + await tabNavigateToWorkspace(this.browser); + await this.browser.pause(PAUSE_TIME); + await focusOnBlock(this.browser, 'logic_boolean_1'); + await this.browser.pause(PAUSE_TIME); + + chai + .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) + .equal(true); + + await deletionStrategy(this.browser); + await this.browser.pause(PAUSE_TIME); + + chai + .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) + .equal(false); + + chai + .expect(await getCurrentFocusedBlockId(this.browser)) + .to.include('controls_if_2'); + }); + + test(`Deleting stranded block via ${strategyName} selects top block`, async function () { + // Deleting a stranded block should result in the workspace being + // focused, which then focuses the top block. If that + // behavior ever changes, this test should be updated as well. + // We want deleting a block to focus the workspace, whatever that + // means at the time. + await tabNavigateToWorkspace(this.browser); + await this.browser.pause(PAUSE_TIME); + + // The test workspace doesn't already contain a stranded block, so add one. + await moveToToolboxCategory(this.browser, 'Math'); + await this.browser.pause(PAUSE_TIME); + // Move to flyout. + await keyRight(this.browser); + // Select number block. + await this.browser.keys(Key.Enter); + await this.browser.pause(PAUSE_TIME); + // Confirm move. + await this.browser.keys(Key.Enter); + await this.browser.pause(PAUSE_TIME); + + chai.assert.equal('math_number', await getFocusedBlockType(this.browser)); + + await deletionStrategy(this.browser); + await this.browser.pause(PAUSE_TIME); + + chai.assert.equal( + await getCurrentFocusedBlockId(this.browser), + 'p5_setup_1', + ); + }); + + test(`Do not delete block via ${strategyName} while field editor is open`, async function () { + // Open a field editor + await focusOnBlockField(this.browser, 'colour_picker_1', 'COLOUR'); + await this.browser.pause(PAUSE_TIME); + await this.browser.keys(Key.Enter); + await this.browser.pause(PAUSE_TIME); + + // Try to delete block while field editor is open + await deletionStrategy(this.browser); + + // Block is not deleted + chai.assert.isTrue(await blockIsPresent(this.browser, 'colour_picker_1')); + }); + } }); diff --git a/test/webdriverio/test/test_setup.ts b/test/webdriverio/test/test_setup.ts index c03126ff..ba8a97a0 100644 --- a/test/webdriverio/test/test_setup.ts +++ b/test/webdriverio/test/test_setup.ts @@ -584,8 +584,11 @@ export async function rightClickOnBlock( browser: WebdriverIO.Browser, blockId: string, ) { - const elem = await browser.$(`[data-id="${blockId}"]`); - await elem.click({button: 'right'}); + const elem = await browser.$(`.blocklySvg [data-id="${blockId}"]`); + // Click 15 pixels in from the top left corner of the block. + const x = Math.round(15 - (await elem.getSize('width')) / 2); + const y = Math.round(15 - (await elem.getSize('height')) / 2); + await elem.click({button: 'right', x: x, y: y}); } /** @@ -601,3 +604,50 @@ export async function rightClickOnFlyoutBlockType( const elem = await browser.$(`.blocklyFlyout .${blockType}`); await elem.click({button: 'right'}); } + +/** + * Uses the keyboard to activate an action, assuming the context menu is already open. + * + * @param browser The active WebdriverIO Browser object. + * @param actionLabel Any unique substring of the action's label text. + */ +export async function doActionViaKeyboard( + browser: WebdriverIO.Browser, + actionLabel: string, +) { + const items = await browser.$$(`.blocklyContextMenu .blocklyMenuItem`); + let selectedIndex = await items.findIndex(async (e) => + (await e.getAttribute('class')).includes('blocklyMenuItemHighlight'), + ); + const targetIndex = await items.findIndex(async (e) => + (await e.getText()).includes(actionLabel), + ); + while (selectedIndex < targetIndex) { + await browser.keys(webdriverio.Key.ArrowDown); + selectedIndex++; + } + while (selectedIndex > targetIndex) { + await browser.keys(webdriverio.Key.ArrowUp); + selectedIndex--; + } + await browser.keys(webdriverio.Key.Return); +} + +/** + * Clicks on an action item with the given text, assuming the context menu is already open. + * + * @param browser The active WebdriverIO Browser object. + * @param actionLabel Any unique substring of the action's label text. + */ +export async function clickOnAction( + browser: WebdriverIO.Browser, + actionLabel: string, +) { + const items = await browser.$$(`.blocklyContextMenu .blocklyMenuItemContent`); + for (const item of items) { + if ((await item.getText()).includes(actionLabel)) { + await item.click(); + return; + } + } +}