diff --git a/test/webdriverio/test/clipboard_test.ts b/test/webdriverio/test/clipboard_test.ts new file mode 100644 index 00000000..04a5d381 --- /dev/null +++ b/test/webdriverio/test/clipboard_test.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as chai from 'chai'; +import * as Blockly from 'blockly'; +import { + testSetup, + testFileLocations, + PAUSE_TIME, + getBlockElementById, + getSelectedBlockId, + clickBlock, + ElementWithId, +} from './test_setup.js'; +import { + ClickOptions, + Key, + KeyAction, + PointerAction, + WheelAction, +} from 'webdriverio'; + +suite('Clipboard test', function () { + // Setting timeout to unlimited as these tests take longer time to run + this.timeout(0); + + // Clear the workspace and load start blocks + setup(async function () { + this.browser = await testSetup(testFileLocations.BASE); + await this.browser.pause(PAUSE_TIME); + }); + + test('Copy and paste while block selected', async function () { + const block = await getBlockElementById(this.browser, 'draw_circle_1'); + await clickBlock(this.browser, block, {button: 1} as ClickOptions); + + // Copy and paste + await this.browser.keys([Key.Ctrl, 'c']); + await this.browser.keys([Key.Ctrl, 'v']); + await this.browser.pause(PAUSE_TIME); + + const blocks = await getSameBlocks(this.browser, block); + const selectedId = await getSelectedBlockId(this.browser); + + chai.assert.equal(await blocks.length, 2); + chai.assert.equal( + selectedId, + await blocks[1].getAttribute('data-id'), + 'New copy of block should be selected and postioned after the copied one', + ); + }); + + test('Cut and paste while block selected', async function () { + const block = await getBlockElementById(this.browser, 'draw_circle_1'); + await clickBlock(this.browser, block, {button: 1} as ClickOptions); + + // Cut and paste + await this.browser.keys([Key.Ctrl, 'x']); + await block.waitForExist({reverse: true}); + await this.browser.keys([Key.Ctrl, 'v']); + await block.waitForExist(); + await this.browser.pause(PAUSE_TIME); + + const blocks = await getSameBlocks(this.browser, block); + const selectedId = await getSelectedBlockId(this.browser); + + chai.assert.equal(await blocks.length, 1); + chai.assert.equal(selectedId, await blocks[0].getAttribute('data-id')); + }); + + test('Copy and paste whilst dragging block', async function () { + const initialWsBlocks = await serializeWorkspaceBlocks(this.browser); + + // Simultaneously drag block and Ctrl+C then Ctrl+V + await performActionWhileDraggingBlock( + this.browser, + await getBlockElementById(this.browser, 'draw_circle_1'), + this.browser + .action('key') + .down(Key.Ctrl) + .down('c') + .up(Key.Ctrl) + .up('c') + .down(Key.Ctrl) + .down('v') + .up(Key.Ctrl) + .up('v'), + ); + + chai.assert.deepEqual( + initialWsBlocks, + await serializeWorkspaceBlocks(this.browser), + 'Blocks on the workspace should not have changed', + ); + }); + + test('Cut whilst dragging block', async function () { + const initialWsBlocks = await serializeWorkspaceBlocks(this.browser); + + // Simultaneously drag block and Ctrl+X + await performActionWhileDraggingBlock( + this.browser, + await getBlockElementById(this.browser, 'draw_circle_1'), + this.browser.action('key').down(Key.Ctrl).down('x').up(Key.Ctrl).up('x'), + ); + + chai.assert.deepEqual( + initialWsBlocks, + await serializeWorkspaceBlocks(this.browser), + 'Blocks on the workspace should not have changed', + ); + }); +}); + +/** + * Gets blocks that are the same as the reference block in terms of class + * they contain. + * + * @param browser The active WebdriverIO Browser object. + * @param block The reference element. + * @returns A Promise that resolves to blocks that are the same as the reference block. + */ +async function getSameBlocks( + browser: WebdriverIO.Browser, + block: ElementWithId, +) { + const elClass = await block.getAttribute('class'); + return browser.$$(`.${elClass.split(' ').join('.')}`); +} + +/** + * Perform actions whilst dragging a given block around. + * + * @param browser The active WebdriverIO Browser object. + * @param blockToDrag The block to drag around. + * @param action Action to perform whilst dragging block. + * @returns A Promise that resolves once action completes. + */ +async function performActionWhileDraggingBlock( + browser: WebdriverIO.Browser, + blockToDrag: ElementWithId, + action: KeyAction | PointerAction | WheelAction, +) { + const blockLoc = await blockToDrag.getLocation(); + const blockX = Math.round(blockLoc.x); + const blockY = Math.round(blockLoc.y); + await browser.actions([ + browser + .action('pointer') + .move(blockX, blockY) + .down() + .move(blockX + 20, blockY + 20) + .move(blockX, blockY), + action, + ]); + await browser.pause(PAUSE_TIME); +} + +/** + * Serializes workspace blocks into JSON objects. + * + * @param browser The active WebdriverIO Browser object. + * @returns A Promise that resolves to serialization of workspace blocks. + */ +async function serializeWorkspaceBlocks(browser: WebdriverIO.Browser) { + return await browser.execute(() => { + return Blockly.serialization.workspaces.save(Blockly.getMainWorkspace()); + }); +} diff --git a/test/webdriverio/test/test_setup.ts b/test/webdriverio/test/test_setup.ts index 17c54509..26b3d5d2 100644 --- a/test/webdriverio/test/test_setup.ts +++ b/test/webdriverio/test/test_setup.ts @@ -16,6 +16,7 @@ * identifiers that Selenium can use to find those elements. */ +import * as Blockly from 'blockly'; import * as webdriverio from 'webdriverio'; import * as path from 'path'; import {fileURLToPath} from 'url'; @@ -140,3 +141,95 @@ export const testFileLocations = { new URLSearchParams({renderer: 'geras', rtl: 'true'}), ), }; + +/** + * Copied from blockly browser test_setup.mjs and amended for typescript + * + * @param browser The active WebdriverIO Browser object. + * @returns A Promise that resolves to the ID of the currently selected block. + */ +export async function getSelectedBlockId(browser: WebdriverIO.Browser) { + return await browser.execute(() => { + // Note: selected is an ICopyable and I am assuming that it is a BlockSvg. + return Blockly.common.getSelected()?.id; + }); +} + +export interface ElementWithId extends WebdriverIO.Element { + id: string; +} + +/** + * Copied from blockly browser test_setup.mjs and amended for typescript + * + * @param browser The active WebdriverIO Browser object. + * @param id The ID of the Blockly block to search for. + * @returns A Promise that resolves to the root SVG element of the block with + * the given ID, as an interactable browser element. + */ +export async function getBlockElementById( + browser: WebdriverIO.Browser, + id: string, +) { + const elem = (await browser.$( + `[data-id="${id}"]`, + )) as unknown as ElementWithId; + elem['id'] = id; + return elem; +} + +/** + * Copied from blockly browser test_setup.mjs and amended for typescript + * + * Find a clickable element on the block and click it. + * We can't always use the block's SVG root because clicking will always happen + * in the middle of the block's bounds (including children) by default, which + * causes problems if it has holes (e.g. statement inputs). Instead, this tries + * to get the first text field on the block. It falls back on the block's SVG root. + * + * @param browser The active WebdriverIO Browser object. + * @param block The block to click, as an interactable element. + * @param clickOptions The options to pass to webdriverio's element.click function. + * @return A Promise that resolves when the actions are completed. + */ +export async function clickBlock( + browser: WebdriverIO.Browser, + block: ElementWithId, + clickOptions: webdriverio.ClickOptions, +) { + const findableId = 'clickTargetElement'; + // In the browser context, find the element that we want and give it a findable ID. + await browser.execute( + (blockId, newElemId) => { + const block = Blockly.getMainWorkspace().getBlockById(blockId); + if (block) { + for (const input of block.inputList) { + for (const field of input.fieldRow) { + if (field instanceof Blockly.FieldLabel) { + const fieldSvg = field.getSvgRoot(); + if (fieldSvg) { + fieldSvg.id = newElemId; + return; + } + } + } + } + } + // No label field found. Fall back to the block's SVG root. + (block as Blockly.BlockSvg).getSvgRoot().id = findableId; + }, + block.id, + findableId, + ); + + // In the test context, get the Webdriverio Element that we've identified. + const elem = await browser.$(`#${findableId}`); + + await elem.click(clickOptions); + + // In the browser context, remove the ID. + await browser.execute((elemId) => { + const clickElem = document.getElementById(elemId); + clickElem?.removeAttribute('id'); + }, findableId); +}