diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index c86ebec2..3b70b9a0 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -2,14 +2,16 @@ name: Deploy static content to Pages on: - # Runs on pushes targeting the default branch + # Runs on pushes targeting the default and experimentation branches. push: - branches: ['main'] + branches: + - main + - add-screen-reader-support-experimental - # Allows you to run this workflow manually from the Actions tab + # Allows the workflow to be manually run from the Actions tab. workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages. permissions: contents: read pages: write @@ -26,45 +28,77 @@ jobs: build: runs-on: ubuntu-latest steps: - - name: Checkout blockly + - name: Checkout tip-of-tree core Blockly uses: actions/checkout@v4 with: path: blockly repository: google/blockly ref: develop - - name: Checkout blockly-keyboard-experimentation + - name: Checkout add-screen-reader-support-experimental core Blockly + uses: actions/checkout@v4 + with: + path: blockly-add-screen-reader-support-experimental + repository: google/blockly + ref: add-screen-reader-support-experimental + + - name: Checkout tip-of-tree blockly-keyboard-experimentation uses: actions/checkout@v4 with: path: blockly-keyboard-experimentation + - name: Checkout add-screen-reader-support-experimental blockly-keyboard-experimentation + uses: actions/checkout@v4 + with: + path: blockly-keyboard-experimentation-add-screen-reader-support-experimental + ref: add-screen-reader-support-experimental + - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20.x - - name: Build blockly + - name: Build tip-of-tree core Blockly run: | cd blockly npm ci npm run package cd dist npm link - cd ../.. - - name: Build blockly-keyboard-experimentation + - name: Build tip-of-tree blockly-keyboard-experimentation run: | cd blockly-keyboard-experimentation npm ci npm link blockly npm run ghpages - cd .. + mkdir ../ghpages + cp -r build/* ../ghpages/ + + - name: Build add-screen-reader-support-experimental core Blockly + run: | + cd blockly/dist + npm unlink -g + cd ../../blockly-add-screen-reader-support-experimental + npm ci + npm run package + cd dist + npm link + + - name: Build add-screen-reader-support-experimental blockly-keyboard-experimentation + run: | + cd blockly-keyboard-experimentation-add-screen-reader-support-experimental + npm ci + npm link blockly + npm run ghpages + mkdir ../ghpages/screenreader + cp -r build/* ../ghpages/screenreader/ - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - # Upload build folder - path: './blockly-keyboard-experimentation/build' + # Upload configured GH pages site files. + path: './ghpages' deploy: environment: diff --git a/README.md b/README.md index 8a549eaa..54dc1aae 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,11 @@ import {KeyboardNavigation} from '@blockly/keyboard-navigation'; // Must be done before calling Blockly.inject. KeyboardNavigation.registerKeyboardNavigationStyles(); +// Register the default toolbox. Only do this once per page-load. +// Must be done before calling Blockly.inject. +// See instructions below if you don't use the default toolbox. +KeyboardNavigation.registerNavigationDeferringToolbox(); + // Inject Blockly. const workspace = Blockly.inject('blocklyDiv', { toolbox: toolboxCategories, @@ -81,7 +86,7 @@ const workspace = Blockly.inject('blocklyDiv', { const keyboardNav = new KeyboardNavigation(workspace); ``` -## Add shortcuts to page +### Add shortcuts to page In order to see the keyboard help popup when the user presses /, you need to add an empty div element to the hosting page that has the Blockly div element with the id "shortcuts". The plugin will take care of layout and formatting. @@ -93,7 +98,20 @@ In order to see the keyboard help popup when the user presses /, you need to add ... ``` -### Usage with cross-tab-copy-paste plugin +### Use with custom Toolbox implementation + +If you supply your own subclass of `Toolbox`, you need to override the `onKeyDown_` method to make it a no-op. The base class has its own keyboard navigation built-in that you need to disable. + +```js +class YourCustomToolbox extends Blockly.Toolbox { + protected override onKeyDown_(e: KeyboardEvent) { + // No-op, prevent keyboard handling by superclass in order to defer to + // global keyboard navigation. + } +} +``` + +### Use with cross-tab-copy-paste plugin This plugin adds context menu items for copying & pasting. It also adds feedback to copying & pasting as toasts that are shown to the user upon successful copy or cut. It is compatible with the `@blockly/plugin-cross-tab-copy-paste` by following these steps: diff --git a/package-lock.json b/package-lock.json index 4a68f389..4456a9fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "@blockly/keyboard-navigation", - "version": "2.0.0", + "version": "3.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockly/keyboard-navigation", - "version": "2.0.0", + "version": "3.0.1", "license": "Apache-2.0", "devDependencies": { "@blockly/dev-scripts": "^4.0.8", + "@blockly/dev-tools": "^9.0.2", "@blockly/field-colour": "^6.0.2", "@eslint/eslintrc": "^2.1.2", "@eslint/js": "^8.49.0", @@ -18,7 +19,7 @@ "@types/p5": "^1.7.6", "@typescript-eslint/eslint-plugin": "^6.7.2", "@typescript-eslint/parser": "^6.7.2", - "blockly": "^12.2.0", + "blockly": "12.3.0", "chai": "^5.2.0", "eslint": "^8.49.0", "eslint-config-google": "^0.14.0", @@ -35,7 +36,7 @@ "webdriverio": "^9.12.1" }, "peerDependencies": { - "blockly": "^12.2.0" + "blockly": "^12.3.0" } }, "node_modules/@asamuzakjp/css-color": { @@ -323,6 +324,19 @@ "node": ">=6.9.0" } }, + "node_modules/@blockly/block-test": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@blockly/block-test/-/block-test-7.0.2.tgz", + "integrity": "sha512-fwbJnMiH4EoX/CR0ZTGzSKaGfpRBn4nudquoWfvG4ekkhTjaNTldDdHvUSeyexzvwZZcT6M4I1Jtq3IoomTKEg==", + "dev": true, + "license": "Apache 2.0", + "engines": { + "node": ">=8.17.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" + } + }, "node_modules/@blockly/dev-scripts": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/@blockly/dev-scripts/-/dev-scripts-4.0.8.tgz", @@ -761,6 +775,117 @@ "node": ">=10" } }, + "node_modules/@blockly/dev-tools": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@blockly/dev-tools/-/dev-tools-9.0.2.tgz", + "integrity": "sha512-Ic/+BkqEvLRZxzNQVW/FKXx1cB042xXXPTSmNlTv2qr4oY+hN2fwBtHj3PirBWAzWgMOF8VDTj/EXL36jH1/lg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@blockly/block-test": "^7.0.2", + "@blockly/theme-dark": "^8.0.1", + "@blockly/theme-deuteranopia": "^7.0.1", + "@blockly/theme-highcontrast": "^7.0.1", + "@blockly/theme-tritanopia": "^7.0.1", + "chai": "^4.2.0", + "dat.gui": "^0.7.7", + "lodash.assign": "^4.2.0", + "lodash.merge": "^4.6.2", + "monaco-editor": "^0.20.0", + "sinon": "^9.0.2" + }, + "engines": { + "node": ">=8.0.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" + } + }, + "node_modules/@blockly/dev-tools/node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@blockly/dev-tools/node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@blockly/dev-tools/node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@blockly/dev-tools/node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@blockly/dev-tools/node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/@blockly/dev-tools/node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@blockly/dev-tools/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/@blockly/eslint-config": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@blockly/eslint-config/-/eslint-config-4.0.1.tgz", @@ -1004,6 +1129,58 @@ "blockly": "^12.0.0" } }, + "node_modules/@blockly/theme-dark": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-dark/-/theme-dark-8.0.1.tgz", + "integrity": "sha512-0Di3WIUwCVQw7jK9myUf/J+4oHLADWc8YxeF40KQgGsyulVrVnYipwtBolj+wxq2xjxIkqgvctAN3BdvM4mynA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.17.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" + } + }, + "node_modules/@blockly/theme-deuteranopia": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-deuteranopia/-/theme-deuteranopia-7.0.1.tgz", + "integrity": "sha512-V05Hk2hzQZict47LfzDdSTP+J5HlYiF7de/8LR/bsRQB/ft7UUTraqDLIivYc9gL2alsVtKzq/yFs9wi7FMAqQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.17.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" + } + }, + "node_modules/@blockly/theme-highcontrast": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-highcontrast/-/theme-highcontrast-7.0.1.tgz", + "integrity": "sha512-dMhysbXf8QtHxuhI1EY5GdZErlfEhjpCogwfzglDKSu8MF2C+5qzOQBxKmqfnEYJl6G9B2HNGw+mEaUo8oel6Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.17.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" + } + }, + "node_modules/@blockly/theme-tritanopia": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@blockly/theme-tritanopia/-/theme-tritanopia-7.0.1.tgz", + "integrity": "sha512-eLqPCmW6xvSYvyTFFE5uz0Bw806LxOmaQrCOzbUywkT41s2ITP06OP1BVQrHdkZSt5whipZYpB1RMGxYxS/Bpw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.17.0" + }, + "peerDependencies": { + "blockly": "^12.0.0" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", @@ -1530,6 +1707,45 @@ "node": ">=18" } }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", + "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-5.3.1.tgz", + "integrity": "sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.6.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", @@ -3063,10 +3279,11 @@ } }, "node_modules/blockly": { - "version": "12.2.0", - "resolved": "https://registry.npmjs.org/blockly/-/blockly-12.2.0.tgz", - "integrity": "sha512-s4QL9ogEMzc4Pxfe8Oi3Kmu6SQ0ts2thzmRYjdnMSEIVZFpBZ4OUuNKvpFICqujO0yfAo99zON8KzxAFw8hA1w==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/blockly/-/blockly-12.3.0.tgz", + "integrity": "sha512-dtxM6Dk8cm0QW4vMJTXmn7xi7a4GnQdXu28Esuuofx7DsYfq73456O5tm3ShUMDcXaFg8w3GVfgoH8I9v6gSVA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "jsdom": "26.1.0" }, @@ -3769,9 +3986,9 @@ } }, "node_modules/compression": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.0.tgz", - "integrity": "sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", "dev": true, "license": "MIT", "dependencies": { @@ -3779,7 +3996,7 @@ "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", - "on-headers": "~1.0.2", + "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" }, @@ -3995,6 +4212,13 @@ "node": ">=18" } }, + "node_modules/dat.gui": { + "version": "0.7.9", + "resolved": "https://registry.npmjs.org/dat.gui/-/dat.gui-0.7.9.tgz", + "integrity": "sha512-sCNc1OHobc+Erc1HqiswYgHdVNpSJUlk/Hz8vzOCsER7rl+oF/4+v8GXFUyCgtXpoCX6+bnmg07DedLvBLwYKQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -5658,6 +5882,16 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6812,6 +7046,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/just-extend": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.2.1.tgz", + "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6996,12 +7237,27 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.assign": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", + "integrity": "sha512-hFuH8TY+Yji7Eja3mGiuAxBqLagejScbG8GbG0j6o9vzn0YL14My+ktnqtZgFTosKymC9/44wP6s7xyuLfnClw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "dev": true }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7352,6 +7608,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/monaco-editor": { + "version": "0.20.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.20.0.tgz", + "integrity": "sha512-hkvf4EtPJRMQlPC3UbMoRs0vTAFAYdzFQ+gpMb8A+9znae1c43q8Mab9iVsgTcg/4PNiLGGn3SlDIa8uvK1FIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -7413,6 +7676,37 @@ "node": ">= 0.4.0" } }, + "node_modules/nise": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/nise/-/nise-4.1.0.tgz", + "integrity": "sha512-eQMEmGN/8arp0xsvGoQ+B1qvSkR73B1nWSCh7nOt5neMCtwcQVYQGdzQMhcNscktTsWB54xnlSQFzOAPJD8nXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.7.0", + "@sinonjs/fake-timers": "^6.0.0", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" + } + }, + "node_modules/nise/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "0.0.1" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -7554,9 +7848,9 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", "dev": true, "license": "MIT", "engines": { @@ -9017,6 +9311,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sinon": { + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-9.2.4.tgz", + "integrity": "sha512-zljcULZQsJxVra28qIAL6ow1Z9tpattkCTEJR4RBP3TGc00FcttsP5pK284Nas5WjMZU5Yzy3kAIp3B3KRf5Yg==", + "deprecated": "16.1.1", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^1.8.1", + "@sinonjs/fake-timers": "^6.0.1", + "@sinonjs/samsam": "^5.3.1", + "diff": "^4.0.2", + "nise": "^4.0.4", + "supports-color": "^7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -9813,6 +10137,16 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", diff --git a/package.json b/package.json index 26ba4d39..6d805ced 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockly/keyboard-navigation", - "version": "2.0.0", + "version": "3.0.1", "description": "A plugin for keyboard navigation.", "scripts": { "audit:fix": "blockly-scripts auditFix", @@ -51,6 +51,7 @@ ], "devDependencies": { "@blockly/dev-scripts": "^4.0.8", + "@blockly/dev-tools": "^9.0.2", "@blockly/field-colour": "^6.0.2", "@eslint/eslintrc": "^2.1.2", "@eslint/js": "^8.49.0", @@ -59,7 +60,7 @@ "@types/p5": "^1.7.6", "@typescript-eslint/eslint-plugin": "^6.7.2", "@typescript-eslint/parser": "^6.7.2", - "blockly": "^12.2.0", + "blockly": "12.3.0", "chai": "^5.2.0", "eslint": "^8.49.0", "eslint-config-google": "^0.14.0", @@ -76,7 +77,7 @@ "webdriverio": "^9.12.1" }, "peerDependencies": { - "blockly": "^12.2.0" + "blockly": "^12.3.0" }, "publishConfig": { "access": "public", diff --git a/src/actions/mover.ts b/src/actions/mover.ts index ebc9b1e4..8c94bbcc 100644 --- a/src/actions/mover.ts +++ b/src/actions/mover.ts @@ -179,6 +179,7 @@ export class Mover { utils.KeyCodes.DOWN, utils.KeyCodes.ENTER, utils.KeyCodes.ESC, + utils.KeyCodes.M, ].includes( typeof keyCode === 'number' ? keyCode diff --git a/src/constants.ts b/src/constants.ts index 4ef43f7d..9761a4cb 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -35,6 +35,7 @@ export enum SHORTCUT_NAMES { LEFT = 'left', NEXT_STACK = 'next_stack', PREVIOUS_STACK = 'previous_stack', + // Unused. INSERT = 'insert', EDIT_OR_CONFIRM = 'edit_or_confirm', DISCONNECT = 'disconnect', @@ -52,8 +53,16 @@ export enum SHORTCUT_NAMES { CREATE_WS_CURSOR = 'to_workspace', LIST_SHORTCUTS = 'list_shortcuts', CLEAN_UP = 'clean_up_workspace', + START_MOVE = 'start_move', } +export const SHORTCUT_NAMES_TO_DISPLAY_TEXT: Record = { + 'keyboard_nav_copy': Msg['Copy'] || 'Copy', + 'keyboard_nav_cut': Msg['Cut'] || 'Cut', + 'keyboard_nav_paste': Msg['Paste'] || 'Paste', + 'start_move': Msg['MOVE_BLOCK'] || 'Move', +}; + /** * Types of possible messages passed into the loggingCallback in the Navigation * class. @@ -73,7 +82,7 @@ export const SHORTCUT_CATEGORIES: Record< // Also allow undo/redo. Document the non-keyboard-nav versions of others for // better text because temporarily the name in the table is derived from // these id-like names. - Array + Array > = {}; SHORTCUT_CATEGORIES[Msg['SHORTCUTS_GENERAL']] = [ @@ -86,12 +95,12 @@ SHORTCUT_CATEGORIES[Msg['SHORTCUTS_GENERAL']] = [ ]; SHORTCUT_CATEGORIES[Msg['SHORTCUTS_EDITING']] = [ - SHORTCUT_NAMES.INSERT, 'delete', SHORTCUT_NAMES.DISCONNECT, - 'cut', - 'copy', - 'paste', + SHORTCUT_NAMES.START_MOVE, + SHORTCUT_NAMES.CUT, + SHORTCUT_NAMES.COPY, + SHORTCUT_NAMES.PASTE, SHORTCUT_NAMES.DUPLICATE, 'undo', 'redo', @@ -104,4 +113,5 @@ SHORTCUT_CATEGORIES[Msg['SHORTCUTS_CODE_NAVIGATION']] = [ SHORTCUT_NAMES.LEFT, SHORTCUT_NAMES.NEXT_STACK, SHORTCUT_NAMES.PREVIOUS_STACK, + SHORTCUT_NAMES.CREATE_WS_CURSOR, ]; diff --git a/src/index.ts b/src/index.ts index 320d97ab..164bc3ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -139,6 +139,17 @@ export class KeyboardNavigation { this.navigationController.shortcutDialog.toggle(this.workspace); } + /** + * Registers a default toolbox implementation that doesn't handle + * keydown events, since we now handle them in this plugin. If you + * use the default toolbox, call this function before calling + * `Blockly.inject`. If you use a custom toolbox, override the + * `onKeyDown_` method in your toolbox implementation to make it a no-op. + */ + static registerNavigationDeferringToolbox() { + this.registerNavigationDeferringToolbox(); + } + /** * Register CSS used by the plugin. * This is broken up into sections by purpose, with some notes about diff --git a/src/keyboard_drag_strategy.ts b/src/keyboard_drag_strategy.ts index 09b3bcc2..5a5ef209 100644 --- a/src/keyboard_drag_strategy.ts +++ b/src/keyboard_drag_strategy.ts @@ -36,6 +36,9 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { /** Where a constrained movement should start when traversing the tree. */ private searchNode: RenderedConnection | null = null; + /** List of all connections available on the workspace. */ + private allConnections: RenderedConnection[] = []; + constructor( private block: BlockSvg, public moveType: MoveType, @@ -46,6 +49,23 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { override startDrag(e?: PointerEvent) { super.startDrag(e); + + for (const topBlock of this.block.workspace.getTopBlocks(true)) { + this.allConnections.push( + ...topBlock + .getDescendants(true) + .filter((block: BlockSvg) => !block.isShadow()) + .flatMap((block: BlockSvg) => block.getConnections_(false)) + .sort((a: RenderedConnection, b: RenderedConnection) => { + let delta = a.y - b.y; + if (delta === 0) { + delta = a.x - b.x; + } + return delta; + }), + ); + } + // Set position of the dragging block, so that it doesn't pop // to the top left of the workspace. // @ts-expect-error block and startLoc are private. @@ -91,6 +111,7 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { override endDrag(e?: PointerEvent) { super.endDrag(e); + this.allConnections = []; this.block.removeIcon(MoveIcon.type); } @@ -168,31 +189,17 @@ export class KeyboardDragStrategy extends dragging.BlockDragStrategy { const connectionChecker = draggingBlock.workspace.connectionChecker; let candidateConnection: ConnectionCandidate | null = null; let potential: RenderedConnection | null = this.searchNode; - const allConnections: RenderedConnection[] = []; - for (const topBlock of draggingBlock.workspace.getTopBlocks(true)) { - allConnections.push( - ...topBlock - .getDescendants(true) - .flatMap((block: BlockSvg) => block.getConnections_(false)) - .sort((a: RenderedConnection, b: RenderedConnection) => { - let delta = a.y - b.y; - if (delta === 0) { - delta = a.x - b.x; - } - return delta; - }), - ); - } const dir = this.currentDragDirection; while (potential && !candidateConnection) { - const potentialIndex = allConnections.indexOf(potential); + const potentialIndex = this.allConnections.indexOf(potential); if (dir === Direction.Up || dir === Direction.Left) { potential = - allConnections[potentialIndex - 1] ?? - allConnections[allConnections.length - 1]; + this.allConnections[potentialIndex - 1] ?? + this.allConnections[this.allConnections.length - 1]; } else if (dir === Direction.Down || dir === Direction.Right) { - potential = allConnections[potentialIndex + 1] ?? allConnections[0]; + potential = + this.allConnections[potentialIndex + 1] ?? this.allConnections[0]; } localConns.forEach((conn: RenderedConnection) => { diff --git a/src/move_indicator.ts b/src/move_indicator.ts index e6f8e92b..caa5b09e 100644 --- a/src/move_indicator.ts +++ b/src/move_indicator.ts @@ -38,6 +38,7 @@ export class MoveIndicatorBubble {}, workspace.getBubbleCanvas(), ); + this.svgRoot.classList.add('blocklyMoveIndicatorBubble'); const rtl = workspace.RTL; Blockly.utils.dom.createSvgElement( Blockly.utils.Svg.CIRCLE, diff --git a/src/shortcut_dialog.ts b/src/shortcut_dialog.ts index e122af78..49e20e5c 100644 --- a/src/shortcut_dialog.ts +++ b/src/shortcut_dialog.ts @@ -52,19 +52,6 @@ export class ShortcutDialog { } } - /** - * Update the modifier key to the user's specific platform. - */ - updatePlatformName() { - const platform = this.getPlatform(); - const platformEl = this.outputDiv - ? this.outputDiv.querySelector('.platform') - : null; - if (platformEl) { - platformEl.textContent = platform; - } - } - toggle(workspace: Blockly.WorkspaceSvg) { clearHelpHint(workspace); this.toggleInternal(); @@ -88,6 +75,9 @@ export class ShortcutDialog { * @returns A title case version of the name. */ getReadableShortcutName(shortcutName: string) { + if (Constants.SHORTCUT_NAMES_TO_DISPLAY_TEXT[shortcutName]) { + return Constants.SHORTCUT_NAMES_TO_DISPLAY_TEXT[shortcutName]; + } return upperCaseFirst(shortcutName.replace(/_/gi, ' ')); } @@ -95,47 +85,46 @@ export class ShortcutDialog { * List all currently registered shortcuts as a table. */ createModalContent() { - let modalContents = ``; + this.outputDiv.innerHTML = modalContents; this.modalContainer = this.outputDiv.querySelector('.modal-container'); this.shortcutDialog = this.outputDiv.querySelector('.shortcut-modal'); this.closeButton = this.outputDiv.querySelector('.close-modal'); - this.updatePlatformName(); - // Can we also intercept the Esc key to dismiss. if (this.closeButton) { this.closeButton.addEventListener('click', (e) => { this.toggleInternal(); @@ -144,13 +133,25 @@ export class ShortcutDialog { } } + private getTableRowForShortcut(keyboardShortcut: string) { + const name = this.getReadableShortcutName(keyboardShortcut); + const keys = this.actionShortcutsToHTML(keyboardShortcut); + if (!name || !keys) return ''; + return ` + + ${name} + ${keys} + `; + } + private actionShortcutsToHTML(action: string) { const shortcuts = getLongActionShortcutsAsKeys(action); - return shortcuts.map((keys) => this.actionShortcutToHTML(keys)).join(' / '); + return shortcuts.map((keys) => this.keysToHTML(keys)).join(' / '); } - private actionShortcutToHTML(keys: string[]) { + private keysToHTML(keys: string[]) { const separator = navigator.platform.startsWith('Mac') ? '' : ' + '; + if (!keys || !keys.length) return ''; return [ ``, ...keys.map((key, index) => { diff --git a/test/index.html b/test/index.html index 212d0354..9eb2a639 100644 --- a/test/index.html +++ b/test/index.html @@ -108,8 +108,17 @@ - + + + +
diff --git a/test/index.ts b/test/index.ts index 4aa282a8..e785cef1 100644 --- a/test/index.ts +++ b/test/index.ts @@ -24,6 +24,7 @@ import {javascriptGenerator} from 'blockly/javascript'; // @ts-expect-error No types in js file import {load} from './loadTestBlocks'; import {runCode, registerRunCodeShortcut} from './runCode'; +import {createPlayground} from '@blockly/dev-tools'; (window as unknown as {Blockly: typeof Blockly}).Blockly = Blockly; @@ -80,7 +81,7 @@ function getOptions() { * * @returns The created workspace. */ -function createWorkspace(): Blockly.WorkspaceSvg { +async function createWorkspace(): Promise { const {scenario, renderer, toolbox} = getOptions(); const injectOptions = { @@ -96,17 +97,30 @@ function createWorkspace(): Blockly.WorkspaceSvg { KeyboardNavigation.registerKeyboardNavigationStyles(); registerFlyoutCursor(); registerNavigationDeferringToolbox(); - const workspace = Blockly.inject(blocklyDiv, injectOptions); - - Blockly.ContextMenuItems.registerCommentOptions(); - new KeyboardNavigation(workspace); registerRunCodeShortcut(); + Blockly.ContextMenuItems.registerCommentOptions(); - // Disable blocks that aren't inside the setup or draw loops. - workspace.addChangeListener(Blockly.Events.disableOrphans); - - load(workspace, scenario); - runCode(); + let navigation: KeyboardNavigation | null = null; + const workspace = ( + await createPlayground( + blocklyDiv, + (blocklyDiv, options) => { + if (navigation) { + navigation.dispose(); + } + const ws = Blockly.inject(blocklyDiv, options); + navigation = new KeyboardNavigation(ws); + + // Disable blocks that aren't inside the setup or draw loops. + ws.addChangeListener(Blockly.Events.disableOrphans); + + load(ws, scenario); + runCode(); + return ws; + }, + injectOptions, + ) + ).getWorkspace(); return workspace; } @@ -124,9 +138,9 @@ function addP5() { javascriptGenerator.addReservedWords('sketch'); } -document.addEventListener('DOMContentLoaded', () => { +document.addEventListener('DOMContentLoaded', async () => { addP5(); - createWorkspace(); + await createWorkspace(); document.getElementById('run')?.addEventListener('click', runCode); // Add Blockly to the global scope so that test code can access it to // verify state after keypresses. diff --git a/test/loadTestBlocks.js b/test/loadTestBlocks.js index 14b1269e..16f0ab81 100644 --- a/test/loadTestBlocks.js +++ b/test/loadTestBlocks.js @@ -317,12 +317,12 @@ const moreBlocks = { 'next': { 'block': { 'type': 'text_print', - 'id': 'J`*)bq?#`_Vq^X(DQF2t', + 'id': 'text_print_1', 'inputs': { 'TEXT': { 'shadow': { 'type': 'text', - 'id': '6fW_sIt1t|63j}nPE1ge', + 'id': 'text_print_shadow_text_1', 'fields': { 'TEXT': 'abc', }, @@ -570,7 +570,15 @@ const navigationTestBlocks = { }, }; -const moveTestBlocks = { +// The draw block contains a stack of statement blocks, each of which +// has a value input to which is connected a value expression block +// which itself has one or two inputs which have (non-shadow) simple +// value blocks connected. Each statement block will be selected in +// turn and then a move initiated (and then aborted). This is then +// repeated with the first level value blocks (those that are attached +// to the statement blocks). The second level value blocks are +// present to verify correct (lack of) heal behaviour. +const moveStartTestBlocks = { 'blocks': { 'languageVersion': 0, 'blocks': [ @@ -862,6 +870,227 @@ const moveTestBlocks = { }, }; +// A bunch of statement blocks. It is intended that statement blocks +// to be moved can be attached to the next connection of p5_canvas, +// and then be (constrained-)moved up, down, left and right to verify +// that they visit all the expected candidate connections. +const moveStatementTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup', + 'x': 75, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'text_print', + 'id': 'text_print', + 'disabledReasons': ['MANUALLY_DISABLED'], + 'x': 75, + 'y': 400, + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_text', + 'fields': { + 'TEXT': 'abc', + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_if', + 'id': 'controls_if', + 'extraState': { + 'elseIfCount': 1, + 'hasElse': true, + }, + 'inputs': { + 'DO0': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_math_number', + 'fields': { + 'NUM': 10, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw', + 'x': 75, + 'y': 950, + 'deletable': false, + }, + ], + }, +}; + +const moveValueTestBlocks = { + 'blocks': { + 'languageVersion': 0, + 'blocks': [ + { + 'type': 'p5_setup', + 'id': 'p5_setup', + 'x': 75, + 'y': 75, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'p5_canvas', + 'id': 'p5_canvas', + 'deletable': false, + 'movable': false, + 'fields': { + 'WIDTH': 400, + 'HEIGHT': 400, + }, + }, + }, + }, + }, + { + 'type': 'text_join', + 'id': 'join0', + 'x': 75, + 'y': 200, + }, + { + 'type': 'p5_draw', + 'id': 'p5_draw', + 'x': 75, + 'y': 300, + 'deletable': false, + 'inputs': { + 'STATEMENTS': { + 'block': { + 'type': 'text_print', + 'id': 'print1', + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'print2', + 'inputs': { + 'TEXT': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_print2', + 'fields': { + 'TEXT': 'shadow', + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'draw_emoji', + 'id': 'draw_emoji', + 'fields': { + 'emoji': '🐻', + }, + 'next': { + 'block': { + 'type': 'text_print', + 'id': 'print3', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text_join', + 'id': 'join1', + 'inline': true, + 'inputs': { + 'ADD0': { + 'shadow': { + 'type': 'text', + 'id': 'shadow_join', + 'fields': { + 'TEXT': 'inline', + }, + }, + }, + }, + }, + }, + }, + 'next': { + 'block': { + 'type': 'controls_repeat_ext', + 'id': 'controls_repeat_ext', + 'inputs': { + 'TIMES': { + 'shadow': { + 'type': 'math_number', + 'id': 'shadow_repeat', + 'fields': { + 'NUM': 1, + }, + }, + }, + 'DO': { + 'block': { + 'type': 'text_print', + 'id': 'print4', + 'inputs': { + 'TEXT': { + 'block': { + 'type': 'text_join', + 'id': 'join2', + 'inline': false, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, +}; + const comments = { 'workspaceComments': [ { @@ -977,6 +1206,12 @@ const comments = { }, }; +const emptyWorkspace = { + 'blocks': { + 'blocks': [], + }, +}; + /** * Loads saved state from local storage into the given workspace. * @param {Blockly.Workspace} workspace Blockly workspace to load into. @@ -985,17 +1220,22 @@ const comments = { export const load = function (workspace, scenarioString) { const scenarioMap = { 'blank': blankCanvas, - 'comments': comments, - 'moreBlocks': moreBlocks, - 'moveTestBlocks': moveTestBlocks, - 'navigationTestBlocks': navigationTestBlocks, - 'simpleCircle': simpleCircle, + comments, + moreBlocks, + moveStartTestBlocks, + moveStatementTestBlocks, + moveValueTestBlocks, + navigationTestBlocks, + simpleCircle, 'sun': sunnyDay, + emptyWorkspace, }; - - const data = JSON.stringify(scenarioMap[scenarioString]); // Don't emit events during loading. Blockly.Events.disable(); - Blockly.serialization.workspaces.load(JSON.parse(data), workspace, false); + Blockly.serialization.workspaces.load( + scenarioMap[scenarioString], + workspace, + false, + ); Blockly.Events.enable(); }; diff --git a/test/webdriverio/test/actions_test.ts b/test/webdriverio/test/actions_test.ts index 7f9b27e2..61d47401 100644 --- a/test/webdriverio/test/actions_test.ts +++ b/test/webdriverio/test/actions_test.ts @@ -7,140 +7,215 @@ import * as chai from 'chai'; import {Key} from 'webdriverio'; import { + clickBlock, contextMenuExists, moveToToolboxCategory, PAUSE_TIME, focusOnBlock, + focusWorkspace, + rightClickOnFlyoutBlockType, tabNavigateToWorkspace, testFileLocations, testSetup, + sendKeyAndWait, keyRight, contextMenuItems, } from './test_setup.js'; +const isDarwin = process.platform === 'darwin'; + +const blockActionsViaKeyboard = [ + {'text': 'Duplicate D'}, + {'text': 'Add Comment'}, + {'text': 'External Inputs'}, + {'text': 'Collapse Block'}, + {'text': 'Disable Block'}, + {'text': 'Delete 2 Blocks Delete'}, + {'text': 'Move Block M'}, + {'text': 'Edit Block contents Right'}, + {'text': isDarwin ? 'Cut ⌘ X' : 'Cut Ctrl + X'}, + {'text': isDarwin ? 'Copy ⌘ C' : 'Copy Ctrl + C'}, + {'disabled': true, 'text': isDarwin ? 'Paste ⌘ V' : 'Paste Ctrl + V'}, +]; + +const blockActionsViaMouse = [ + {'text': 'Duplicate D'}, + {'text': 'Add Comment'}, + {'text': 'External Inputs'}, + {'text': 'Collapse Block'}, + {'text': 'Disable Block'}, + {'text': 'Delete 2 Blocks Delete'}, + {'text': isDarwin ? 'Cut ⌘ X' : 'Cut Ctrl + X'}, + {'text': isDarwin ? 'Copy ⌘ C' : 'Copy Ctrl + C'}, + {'disabled': true, 'text': isDarwin ? 'Paste ⌘ V' : 'Paste Ctrl + V'}, +]; + +const shadowBlockActionsViaKeyboard = [ + {'text': 'Add Comment'}, + {'text': 'Collapse Block'}, + {'text': 'Disable Block'}, + {'text': 'Help'}, + {'text': 'Move Block M'}, + {'text': 'Edit Block contents Right'}, + {'disabled': true, 'text': isDarwin ? 'Cut ⌘ X' : 'Cut Ctrl + X'}, + {'text': isDarwin ? 'Copy ⌘ C' : 'Copy Ctrl + C'}, + {'disabled': true, 'text': isDarwin ? 'Paste ⌘ V' : 'Paste Ctrl + V'}, +]; + +const toolboxBlockActionsViaKeyboard = [ + {'text': 'Help'}, + {'disabled': true, 'text': 'Move Block M'}, + {'disabled': true, 'text': isDarwin ? 'Cut ⌘ X' : 'Cut Ctrl + X'}, + {'text': isDarwin ? 'Copy ⌘ C' : 'Copy Ctrl + C'}, +]; + +const flyoutBlockActionsViaMouse = [ + {'text': 'Help'}, + {'disabled': true, 'text': isDarwin ? 'Cut ⌘ X' : 'Cut Ctrl + X'}, + {'text': isDarwin ? 'Copy ⌘ C' : 'Copy Ctrl + C'}, +]; + +const workspaceActionsViaKeyboard = [ + {'disabled': true, 'text': 'Undo'}, + {'disabled': true, 'text': 'Redo'}, + {'text': 'Clean up Blocks'}, + {'text': 'Collapse Blocks'}, + {'disabled': true, 'text': 'Expand Blocks'}, + {'text': 'Delete 14 Blocks'}, + {'text': 'Add Comment'}, + {'disabled': true, 'text': isDarwin ? 'Paste ⌘ V' : 'Paste Ctrl + V'}, +]; + suite('Menus test', function () { - // Setting timeout to unlimited as these tests take longer time to run - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Clear the workspace and load start blocks + // Clear the workspace and load start blocks. setup(async function () { - this.browser = await testSetup(testFileLocations.BASE); + // This is the first test suite, which must wait for Chrome + + // chromedriver to start up, which can be slowβ€”perhaps a few + // seconds. Allow 30s just in case. + this.timeout(30000); + + this.browser = await testSetup(testFileLocations.MORE_BLOCKS); await this.browser.pause(PAUSE_TIME); }); - test('Menu on block', async function () { + test('Menu action via keyboard on block opens menu', async function () { // Navigate to draw_circle_1. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'draw_circle_1'); await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, [Key.Ctrl, Key.Return]); + + chai.assert.deepEqual( + await contextMenuItems(this.browser), + blockActionsViaKeyboard, + ); + }); + + test('Block menu via mouse displays expected items', async function () { + await tabNavigateToWorkspace(this.browser); + await clickBlock(this.browser, 'draw_circle_1', {button: 'right'}); + + chai.assert.deepEqual( + await contextMenuItems(this.browser), + blockActionsViaMouse, + ); + }); + + test('Shadow block menu via keyboard displays expected items', async function () { + await tabNavigateToWorkspace(this.browser); + await focusOnBlock(this.browser, 'text_print_1'); + await this.browser.keys(Key.ArrowRight); await this.browser.keys([Key.Ctrl, Key.Return]); await this.browser.pause(PAUSE_TIME); chai.assert.deepEqual( - process.platform === 'darwin' - ? [ - {'text': 'Duplicate D'}, - {'text': 'Add Comment'}, - {'text': 'External Inputs'}, - {'text': 'Collapse Block'}, - {'text': 'Disable Block'}, - {'text': 'Delete 2 Blocks Delete'}, - {'text': 'Move Block M'}, - {'text': 'Edit Block contents Right'}, - {'text': 'Cut ⌘ X'}, - {'text': 'Copy ⌘ C'}, - {'disabled': true, 'text': 'Paste ⌘ V'}, - ] - : [ - {'text': 'Duplicate D'}, - {'text': 'Add Comment'}, - {'text': 'External Inputs'}, - {'text': 'Collapse Block'}, - {'text': 'Disable Block'}, - {'text': 'Delete 2 Blocks Delete'}, - {'text': 'Move Block M'}, - {'text': 'Edit Block contents Right'}, - {'text': 'Cut Ctrl + X'}, - {'text': 'Copy Ctrl + C'}, - {'disabled': true, 'text': 'Paste Ctrl + V'}, - ], await contextMenuItems(this.browser), + shadowBlockActionsViaKeyboard, ); }); - test('Menu on block in the toolbox', async function () { - // Navigate to draw_circle_1. + test('Menu action on block in the toolbox', async function () { await tabNavigateToWorkspace(this.browser); - await focusOnBlock(this.browser, 'draw_circle_1'); // 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 sendKeyAndWait(this.browser, [Key.Ctrl, Key.Return]); + + chai.assert.deepEqual( + await contextMenuItems(this.browser), + toolboxBlockActionsViaKeyboard, + ); + }); + + 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( - process.platform === 'darwin' - ? [ - {'text': 'Help'}, - {'disabled': true, 'text': 'Move Block M'}, - {'disabled': true, 'text': 'Cut ⌘ X'}, - {'text': 'Copy ⌘ C'}, - ] - : [ - {'text': 'Help'}, - {'disabled': true, 'text': 'Move Block M'}, - {'disabled': true, 'text': 'Cut Ctrl + X'}, - {'text': 'Copy Ctrl + C'}, - ], await contextMenuItems(this.browser), + flyoutBlockActionsViaMouse, ); }); test('Menu on workspace', async function () { // Navigate to draw_circle_1. await tabNavigateToWorkspace(this.browser); - await this.browser.keys('w'); - await this.browser.keys([Key.Ctrl, Key.Return]); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 'w'); + await sendKeyAndWait(this.browser, [Key.Ctrl, Key.Return]); chai.assert.deepEqual( - process.platform === 'darwin' - ? [ - {'disabled': true, 'text': 'Undo'}, - {'disabled': true, 'text': 'Redo'}, - {'text': 'Clean up Blocks'}, - {'text': 'Collapse Blocks'}, - {'disabled': true, 'text': 'Expand Blocks'}, - {'text': 'Delete 4 Blocks'}, - {'text': 'Add Comment'}, - {'disabled': true, 'text': 'Paste ⌘ V'}, - ] - : [ - {'disabled': true, 'text': 'Undo'}, - {'disabled': true, 'text': 'Redo'}, - {'text': 'Clean up Blocks'}, - {'text': 'Collapse Blocks'}, - {'disabled': true, 'text': 'Expand Blocks'}, - {'text': 'Delete 4 Blocks'}, - {'text': 'Add Comment'}, - {'disabled': true, 'text': 'Paste Ctrl + V'}, - ], await contextMenuItems(this.browser), + workspaceActionsViaKeyboard, ); }); test('Menu on block during drag is not shown', async function () { // Navigate to draw_circle_1. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'draw_circle_1'); // Start moving the block - await this.browser.keys('m'); - await this.browser.keys([Key.Ctrl, Key.Return]); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 'm'); + await sendKeyAndWait(this.browser, [Key.Ctrl, Key.Return]); + chai.assert.isTrue( await contextMenuExists(this.browser, 'Collapse Block', true), 'The menu should not be openable during a move', ); }); + + 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 clickBlock(this.browser, 'draw_circle_1', {button: 'right'}); + 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/basic_test.ts b/test/webdriverio/test/basic_test.ts index abfd7dde..98912a38 100644 --- a/test/webdriverio/test/basic_test.ts +++ b/test/webdriverio/test/basic_test.ts @@ -16,6 +16,7 @@ import { testSetup, testFileLocations, PAUSE_TIME, + sendKeyAndWait, tabNavigateToWorkspace, keyLeft, keyRight, @@ -25,10 +26,10 @@ import { import {Key} from 'webdriverio'; suite('Keyboard navigation on Blocks', function () { - // Setting timeout to unlimited as these tests take a longer time to run than most mocha test - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Setup Selenium for all of the tests + // Clear the workspace and load start blocks. suiteSetup(async function () { this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS); }); @@ -53,8 +54,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Down from statement block selects next block across stacks', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'p5_canvas_1'); await this.browser.pause(PAUSE_TIME); await keyDown(this.browser); @@ -65,8 +64,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Up from statement block selects previous block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'simple_circle_1'); await this.browser.pause(PAUSE_TIME); await keyUp(this.browser); @@ -77,8 +74,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Down from parent block selects first child block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'p5_setup_1'); await this.browser.pause(PAUSE_TIME); await keyDown(this.browser); @@ -88,8 +83,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Up from child block selects parent block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'p5_canvas_1'); await this.browser.pause(PAUSE_TIME); await keyUp(this.browser); @@ -99,8 +92,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Right from block selects first field', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'p5_canvas_1'); await this.browser.pause(PAUSE_TIME); await keyRight(this.browser); @@ -113,8 +104,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Right from block selects first inline input', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'simple_circle_1'); await this.browser.pause(PAUSE_TIME); await keyRight(this.browser); @@ -126,8 +115,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Up from inline input selects statement block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'math_number_2'); await this.browser.pause(PAUSE_TIME); await keyUp(this.browser); @@ -139,8 +126,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Left from first inline input selects block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'math_number_2'); await this.browser.pause(PAUSE_TIME); await keyLeft(this.browser); @@ -152,8 +137,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Right from first inline input selects second inline input', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'math_number_2'); await this.browser.pause(PAUSE_TIME); await keyRight(this.browser); @@ -165,8 +148,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Left from second inline input selects first inline input', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'math_number_3'); await this.browser.pause(PAUSE_TIME); await keyLeft(this.browser); @@ -178,8 +159,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Right from last inline input selects next block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'colour_picker_1'); await this.browser.pause(PAUSE_TIME); await keyRight(this.browser); @@ -190,8 +169,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Down from inline input selects next block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'colour_picker_1'); await this.browser.pause(PAUSE_TIME); await keyDown(this.browser); @@ -202,8 +179,6 @@ suite('Keyboard navigation on Blocks', function () { }); test("Down from inline input selects block's child 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); await keyDown(this.browser); @@ -214,8 +189,6 @@ suite('Keyboard navigation on Blocks', function () { }); test('Right from text block selects shadow block then field', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'text_print_1'); await this.browser.pause(PAUSE_TIME); await keyRight(this.browser); @@ -236,33 +209,27 @@ suite('Keyboard navigation on Blocks', function () { }); test('Losing focus cancels move', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'text_print_1'); - await this.browser.keys('m'); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 'm'); chai.assert.isTrue(await isDragging(this.browser)); - await this.browser.keys(Key.Tab); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Tab); chai.assert.isFalse(await isDragging(this.browser)); }); }); suite('Keyboard navigation on Fields', function () { - // Setting timeout to unlimited as these tests take a longer time to run than most mocha test - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Setup Selenium for all of the tests + // Clear the workspace and load start blocks. suiteSetup(async function () { this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS); }); test('Up from first field selects block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlockField(this.browser, 'p5_canvas_1', 'WIDTH'); await this.browser.pause(PAUSE_TIME); await keyUp(this.browser); @@ -274,8 +241,6 @@ suite('Keyboard navigation on Fields', function () { }); test('Left from first field selects block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlockField(this.browser, 'p5_canvas_1', 'WIDTH'); await this.browser.pause(PAUSE_TIME); await keyLeft(this.browser); @@ -287,8 +252,6 @@ suite('Keyboard navigation on Fields', function () { }); test('Right from first field selects second field', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlockField(this.browser, 'p5_canvas_1', 'WIDTH'); await this.browser.pause(PAUSE_TIME); await keyRight(this.browser); @@ -301,8 +264,6 @@ suite('Keyboard navigation on Fields', function () { }); test('Left from second field selects first field', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlockField(this.browser, 'p5_canvas_1', 'HEIGHT'); await this.browser.pause(PAUSE_TIME); await keyLeft(this.browser); @@ -315,8 +276,6 @@ suite('Keyboard navigation on Fields', function () { }); test('Right from second field selects next block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlockField(this.browser, 'p5_canvas_1', 'HEIGHT'); await this.browser.pause(PAUSE_TIME); await keyRight(this.browser); @@ -327,8 +286,6 @@ suite('Keyboard navigation on Fields', function () { }); test('Down from field selects next block', async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlockField(this.browser, 'p5_canvas_1', 'WIDTH'); await this.browser.pause(PAUSE_TIME); await keyDown(this.browser); @@ -339,8 +296,6 @@ suite('Keyboard navigation on Fields', function () { }); test("Down from field selects block's child block", async function () { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlockField(this.browser, 'controls_repeat_1', 'TIMES'); await this.browser.pause(PAUSE_TIME); await keyDown(this.browser); @@ -354,8 +309,7 @@ suite('Keyboard navigation on Fields', function () { // Open a field editor dropdown await focusOnBlockField(this.browser, 'logic_boolean_1', 'BOOL'); await this.browser.pause(PAUSE_TIME); - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Enter); // Try to navigate to a different block await keyRight(this.browser); @@ -368,13 +322,12 @@ suite('Keyboard navigation on Fields', function () { // Open colour picker 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); + await sendKeyAndWait(this.browser, Key.Enter); // Move right to pick a new colour. await keyRight(this.browser); // Enter to choose. - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter); // Focus seems to take longer than a single pause to settle. await this.browser.waitUntil( diff --git a/test/webdriverio/test/block_comment_test.ts b/test/webdriverio/test/block_comment_test.ts index e965f3ff..08e7af9e 100644 --- a/test/webdriverio/test/block_comment_test.ts +++ b/test/webdriverio/test/block_comment_test.ts @@ -13,14 +13,15 @@ import { sendKeyAndWait, testFileLocations, keyRight, + PAUSE_TIME, } from './test_setup.js'; import {Key} from 'webdriverio'; suite('Block comment navigation', function () { - // Setting timeout to unlimited as these tests take a longer time to run than most mocha test - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Setup Selenium for all of the tests + // Clear the workspace and load start blocks. setup(async function () { this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS); await this.browser.execute(() => { diff --git a/test/webdriverio/test/clipboard_test.ts b/test/webdriverio/test/clipboard_test.ts index b9e9f42e..afe4253a 100644 --- a/test/webdriverio/test/clipboard_test.ts +++ b/test/webdriverio/test/clipboard_test.ts @@ -13,19 +13,19 @@ import { getBlockElementById, getSelectedBlockId, ElementWithId, - tabNavigateToWorkspace, focusOnBlock, focusOnBlockField, blockIsPresent, getFocusedBlockType, + sendKeyAndWait, } from './test_setup.js'; import {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); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Clear the workspace and load start blocks + // Clear the workspace and load start blocks. setup(async function () { this.browser = await testSetup(testFileLocations.BASE); await this.browser.pause(PAUSE_TIME); @@ -33,13 +33,11 @@ suite('Clipboard test', function () { test('Copy and paste while block selected', async function () { // Navigate to draw_circle_1. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'draw_circle_1'); // Copy and paste - await this.browser.keys([Key.Ctrl, 'c']); - await this.browser.keys([Key.Ctrl, 'v']); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'c']); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'v']); const block = await getBlockElementById(this.browser, 'draw_circle_1'); const blocks = await getSameBlocks(this.browser, block); @@ -54,15 +52,13 @@ suite('Clipboard test', function () { test('Cut and paste while block selected', async function () { // Navigate to draw_circle_1. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'draw_circle_1'); const block = await getBlockElementById(this.browser, 'draw_circle_1'); // Cut and paste - await this.browser.keys([Key.Ctrl, 'x']); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'x']); await block.waitForExist({reverse: true}); - await this.browser.keys([Key.Ctrl, 'v']); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'v']); const focusedType = await getFocusedBlockType(this.browser); @@ -117,11 +113,10 @@ suite('Clipboard test', function () { // Open a field editor await focusOnBlockField(this.browser, 'draw_circle_1_color', 'COLOUR'); await this.browser.pause(PAUSE_TIME); - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Enter); // Try to cut block while field editor is open - await this.browser.keys([Key.Ctrl, 'x']); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'x']); // Block is not deleted chai.assert.isTrue( diff --git a/test/webdriverio/test/delete_test.ts b/test/webdriverio/test/delete_test.ts index 8564fabd..0503b45b 100644 --- a/test/webdriverio/test/delete_test.ts +++ b/test/webdriverio/test/delete_test.ts @@ -15,23 +15,23 @@ import { testFileLocations, PAUSE_TIME, tabNavigateToWorkspace, + sendKeyAndWait, keyRight, focusOnBlockField, } from './test_setup.js'; import {Key} from 'webdriverio'; suite('Deleting Blocks', function () { - // Setting timeout to unlimited as these tests take a longer time to run than most mocha test - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); + // Clear the workspace and load start blocks. 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); @@ -39,8 +39,7 @@ suite('Deleting Blocks', function () { .expect(await blockIsPresent(this.browser, 'controls_if_2')) .equal(true); - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Backspace); chai .expect(await blockIsPresent(this.browser, 'controls_if_2')) @@ -52,8 +51,6 @@ suite('Deleting Blocks', function () { }); 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); @@ -61,8 +58,7 @@ suite('Deleting Blocks', function () { .expect(await blockIsPresent(this.browser, 'controls_if_2')) .equal(true); - await this.browser.keys([Key.Ctrl, 'x']); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'x']); chai .expect(await blockIsPresent(this.browser, 'controls_if_2')) @@ -74,8 +70,6 @@ suite('Deleting Blocks', function () { }); 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); @@ -84,8 +78,7 @@ suite('Deleting Blocks', function () { .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); + await sendKeyAndWait(this.browser, Key.Backspace); chai .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) @@ -96,8 +89,6 @@ suite('Deleting Blocks', function () { }); 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); @@ -106,8 +97,7 @@ suite('Deleting Blocks', function () { .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); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'x']); chai .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) @@ -118,8 +108,6 @@ suite('Deleting Blocks', function () { }); 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); @@ -127,8 +115,7 @@ suite('Deleting Blocks', function () { .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) .equal(true); - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Backspace); chai .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) @@ -140,8 +127,6 @@ suite('Deleting Blocks', function () { }); 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); @@ -149,8 +134,7 @@ suite('Deleting Blocks', function () { .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) .equal(true); - await this.browser.keys([Key.Ctrl, 'x']); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'x']); chai .expect(await blockIsPresent(this.browser, 'logic_boolean_1')) @@ -176,16 +160,13 @@ suite('Deleting Blocks', function () { // Move to flyout. await keyRight(this.browser); // Select number block. - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Enter); // Confirm move. - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Enter); chai.assert.equal('math_number', await getFocusedBlockType(this.browser)); - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Backspace); chai.assert.equal( await getCurrentFocusedBlockId(this.browser), @@ -203,16 +184,13 @@ suite('Deleting Blocks', function () { // Move to flyout. await keyRight(this.browser); // Select number block. - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Enter); // Confirm move. - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Enter); chai.assert.equal('math_number', await getFocusedBlockType(this.browser)); - await this.browser.keys([Key.Ctrl, 'x']); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'x']); chai.assert.equal( await getCurrentFocusedBlockId(this.browser), @@ -224,11 +202,10 @@ suite('Deleting Blocks', 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); + await sendKeyAndWait(this.browser, Key.Enter); // Try to delete block while field editor is open - await this.browser.keys(Key.Backspace); + await sendKeyAndWait(this.browser, Key.Backspace); // Block is not deleted chai.assert.isTrue(await blockIsPresent(this.browser, 'colour_picker_1')); diff --git a/test/webdriverio/test/duplicate_test.ts b/test/webdriverio/test/duplicate_test.ts index 2cef7808..8498d32b 100644 --- a/test/webdriverio/test/duplicate_test.ts +++ b/test/webdriverio/test/duplicate_test.ts @@ -14,13 +14,14 @@ import { tabNavigateToWorkspace, testFileLocations, testSetup, + sendKeyAndWait, } from './test_setup.js'; suite('Duplicate test', function () { - // Setting timeout to unlimited as these tests take longer time to run - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Clear the workspace and load start blocks + // Clear the workspace and load start blocks. setup(async function () { this.browser = await testSetup(testFileLocations.BASE); await this.browser.pause(PAUSE_TIME); @@ -28,12 +29,10 @@ suite('Duplicate test', function () { test('Duplicate block', async function () { // Navigate to draw_circle_1. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'draw_circle_1'); // Duplicate - await this.browser.keys('d'); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 'd'); // Check a different block of the same type has focus. chai.assert.notEqual( @@ -65,7 +64,7 @@ suite('Duplicate test', function () { await this.browser.pause(PAUSE_TIME); // Duplicate. - await this.browser.keys('d'); + await sendKeyAndWait(this.browser, 'd'); // Assert we have two comments with the same text. const commentTexts = await this.browser.execute(() => diff --git a/test/webdriverio/test/flyout_test.ts b/test/webdriverio/test/flyout_test.ts index b6beacf8..769a895b 100644 --- a/test/webdriverio/test/flyout_test.ts +++ b/test/webdriverio/test/flyout_test.ts @@ -14,13 +14,17 @@ import { keyDown, tabNavigateBackward, tabNavigateToWorkspace, + sendKeyAndWait, keyRight, getCurrentFocusNodeId, getCurrentFocusedBlockId, } from './test_setup.js'; suite('Toolbox and flyout test', function () { - // Clear the workspace and load start blocks + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) 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); @@ -164,7 +168,7 @@ suite('Toolbox and flyout test', function () { test('Tabbing to the workspace after selecting flyout block via workspace toolbox shortcut should close the flyout', async function () { await tabNavigateToWorkspace(this.browser); - await this.browser.keys('t'); + await sendKeyAndWait(this.browser, 't'); await keyRight(this.browser); await tabNavigateForward(this.browser); diff --git a/test/webdriverio/test/insert_test.ts b/test/webdriverio/test/insert_test.ts index 7563f65e..bf22f8f9 100644 --- a/test/webdriverio/test/insert_test.ts +++ b/test/webdriverio/test/insert_test.ts @@ -14,6 +14,7 @@ import { tabNavigateToWorkspace, testFileLocations, testSetup, + sendKeyAndWait, keyRight, getCurrentFocusedBlockId, blockIsPresent, @@ -22,10 +23,10 @@ import { } from './test_setup.js'; suite('Insert test', function () { - // Setting timeout to unlimited as these tests take longer time to run - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Clear the workspace and load start blocks + // Clear the workspace and load start blocks. setup(async function () { this.browser = await testSetup(testFileLocations.BASE); await this.browser.pause(PAUSE_TIME); @@ -33,18 +34,17 @@ suite('Insert test', function () { test('Insert and cancel with block selection', async function () { // Navigate to draw_circle_1. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'draw_circle_1'); // Insert 'if' block - await this.browser.keys('t'); + await sendKeyAndWait(this.browser, 't'); await keyRight(this.browser); - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter); chai.assert.equal('controls_if', await getFocusedBlockType(this.browser)); const ifId = await getCurrentFocusedBlockId(this.browser); chai.assert.ok(ifId); // Cancel - await this.browser.keys(Key.Escape); + await sendKeyAndWait(this.browser, Key.Escape); chai.assert.isFalse(await blockIsPresent(this.browser, ifId)); }); @@ -52,33 +52,32 @@ suite('Insert test', function () { test('Insert and cancel with workspace selection', async function () { // Navigate to workspace. await tabNavigateToWorkspace(this.browser); - await this.browser.keys('w'); + await sendKeyAndWait(this.browser, 'w'); // Insert 'if' block - await this.browser.keys('t'); + await sendKeyAndWait(this.browser, 't'); await keyRight(this.browser); - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter); chai.assert.equal('controls_if', await getFocusedBlockType(this.browser)); const ifId = await getCurrentFocusedBlockId(this.browser); chai.assert.ok(ifId); // Cancel - await this.browser.keys(Key.Escape); + await sendKeyAndWait(this.browser, Key.Escape); chai.assert.isFalse(await blockIsPresent(this.browser, ifId)); }); test('Insert C-shaped block with statement block selected', async function () { // Navigate to draw_circle_1. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'draw_circle_1'); await moveToToolboxCategory(this.browser, 'Functions'); // Move to flyout. await keyRight(this.browser); // Select Function block. - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter); // Confirm move. - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter); chai.assert.equal( 'procedures_defnoreturn', @@ -92,9 +91,9 @@ suite('Insert test', function () { // Insert 'if' block await keyRight(this.browser); // Choose. - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter); // Confirm position. - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter); // Assert inserted inside first block p5_setup not at top-level. chai.assert.equal('controls_if', await getFocusedBlockType(this.browser)); diff --git a/test/webdriverio/test/keyboard_mode_test.ts b/test/webdriverio/test/keyboard_mode_test.ts index d022c69a..8a362182 100644 --- a/test/webdriverio/test/keyboard_mode_test.ts +++ b/test/webdriverio/test/keyboard_mode_test.ts @@ -14,6 +14,7 @@ import { getBlockElementById, tabNavigateToWorkspace, clickBlock, + sendKeyAndWait, } from './test_setup.js'; import {Key} from 'webdriverio'; @@ -26,8 +27,8 @@ const isKeyboardNavigating = function (browser: WebdriverIO.Browser) { suite( 'Keyboard navigation mode set on mouse or keyboard interaction', function () { - // Setting timeout to unlimited as these tests take a longer time to run than most mocha tests - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); setup(async function () { // Reload the page between tests @@ -46,8 +47,7 @@ suite( test('T to open toolbox enables keyboard mode', async function () { await this.browser.pause(PAUSE_TIME); - await this.browser.keys('t'); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 't'); chai.assert.isTrue(await isKeyboardNavigating(this.browser)); }); @@ -55,15 +55,14 @@ suite( test('M for move mode enables keyboard mode', async function () { await focusOnBlock(this.browser, 'controls_if_2'); await this.browser.pause(PAUSE_TIME); - await this.browser.keys('m'); + await sendKeyAndWait(this.browser, 'm'); chai.assert.isTrue(await isKeyboardNavigating(this.browser)); }); test('W for workspace cursor enables keyboard mode', async function () { await this.browser.pause(PAUSE_TIME); - await this.browser.keys('w'); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 'w'); chai.assert.isTrue(await isKeyboardNavigating(this.browser)); }); @@ -71,8 +70,7 @@ suite( test('X to disconnect enables keyboard mode', async function () { await focusOnBlock(this.browser, 'controls_if_2'); await this.browser.pause(PAUSE_TIME); - await this.browser.keys('x'); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 'x'); chai.assert.isTrue(await isKeyboardNavigating(this.browser)); }); @@ -81,8 +79,7 @@ suite( // Make sure we're on a copyable block so that copy occurs await focusOnBlock(this.browser, 'controls_if_2'); await this.browser.pause(PAUSE_TIME); - await this.browser.keys([Key.Ctrl, 'c']); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'c']); chai.assert.isFalse(await isKeyboardNavigating(this.browser)); @@ -91,8 +88,7 @@ suite( }); await this.browser.pause(PAUSE_TIME); - await this.browser.keys([Key.Ctrl, 'c']); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, [Key.Ctrl, 'c']); chai.assert.isTrue(await isKeyboardNavigating(this.browser)); }); @@ -101,8 +97,7 @@ suite( // Make sure we're on a deletable block so that delete occurs await focusOnBlock(this.browser, 'controls_if_2'); await this.browser.pause(PAUSE_TIME); - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Backspace); chai.assert.isFalse(await isKeyboardNavigating(this.browser)); @@ -113,8 +108,7 @@ suite( // Focus a different deletable block await focusOnBlock(this.browser, 'controls_if_1'); await this.browser.pause(PAUSE_TIME); - await this.browser.keys(Key.Backspace); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Backspace); chai.assert.isTrue(await isKeyboardNavigating(this.browser)); }); diff --git a/test/webdriverio/test/move_test.ts b/test/webdriverio/test/move_test.ts index 3a603e26..34c38313 100644 --- a/test/webdriverio/test/move_test.ts +++ b/test/webdriverio/test/move_test.ts @@ -10,20 +10,22 @@ import {Browser, Key} from 'webdriverio'; import { PAUSE_TIME, focusOnBlock, - tabNavigateToWorkspace, + createTestUrl, testFileLocations, testSetup, sendKeyAndWait, keyDown, + contextMenuItems, } from './test_setup.js'; -suite('Move tests', function () { - // Setting timeout to unlimited as these tests take longer time to run - this.timeout(0); +suite('Move start tests', function () { + // Increase timeout to 10s for this longer test (but disable + // timeouts if when non-zero PAUSE_TIME is used to watch tests) run. + this.timeout(PAUSE_TIME ? 0 : 10000); - // Clear the workspace and load start blocks + // Clear the workspace and load start blocks. setup(async function () { - this.browser = await testSetup(testFileLocations.MOVE_TEST_BLOCKS); + this.browser = await testSetup(testFileLocations.MOVE_START_TEST_BLOCKS); await this.browser.pause(PAUSE_TIME); }); @@ -32,36 +34,40 @@ suite('Move tests', function () { // moved, with subsequent statement blocks below it in the stack // reattached to where the moving block was - i.e., that a stack // heal will occur. + // + // Also tests initating a move using the shortcut key. test('Start moving statement blocks', async function () { for (let i = 1; i < 7; i++) { // Navigate to statement_. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, `statement_${i}`); // Get information about parent connection of selected block, // and block connected to selected block's next connection. const info = await getFocusedNeighbourInfo(this.browser); - chai.assert(info.parentId, 'selected block has no parent block'); + chai.assert.exists( + info.parentId, + 'selected block should have parent block', + ); chai.assert( typeof info.parentIndex === 'number', - 'parent connection index not found', + 'parent connection index should exist and be a number', ); - chai.assert(info.nextId, 'selected block has no next block'); + chai.assert.exists(info.nextId, 'selected block should have next block'); - // Start move. - await this.browser.keys('m'); + // Start move using keyboard shortcut. + await sendKeyAndWait(this.browser, 'm'); // Check that the moving block has nothing connected it its // next/previous connections, and same thing connected to value // input. const newInfo = await getFocusedNeighbourInfo(this.browser); - chai.assert( - newInfo.parentId === null, + chai.assert.isNull( + newInfo.parentId, 'moving block should have no parent block', ); - chai.assert( - newInfo.nextId === null, + chai.assert.isNull( + newInfo.nextId, 'moving block should have no next block', ); chai.assert.strictEqual( @@ -85,43 +91,65 @@ suite('Move tests', function () { ); // Abort move. - await this.browser.keys(Key.Escape); + await sendKeyAndWait(this.browser, Key.Escape); } }); // When a move of a value block begins, it is expected that block // and all blocks connected to its inputs will be moved - i.e., that // a stack heal (really: unary operator chain heal) will NOT occur. + // + // Also tests initiating a move via the context menu. test('Start moving value blocks', async function () { for (let i = 1; i < 7; i++) { // Navigate to statement_. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, `value_${i}`); // Get information about parent connection of selected block, // and block connected to selected block's value input. const info = await getFocusedNeighbourInfo(this.browser); - chai.assert(info.parentId, 'selected block has no parent block'); + chai.assert.exists( + info.parentId, + 'selected block should have parent block', + ); chai.assert( typeof info.parentIndex === 'number', - 'parent connection index not found', + 'parent connection index should exist and be a number', + ); + chai.assert.exists( + info.valueId, + 'selected block should have child value block', + ); + + // Start move using context menu (using keyboard nav). + await sendKeyAndWait(this.browser, [Key.Ctrl, Key.Return]); + + // Find how many times to press the down arrow + const index = (await contextMenuItems(this.browser)).findIndex(({text}) => + text.includes('Move'), + ); + chai.assert.isAbove( + index, + -1, + 'expected Move to appear in context menu items', ); - chai.assert(info.valueId, 'selected block has no child value block'); + await keyDown(this.browser, index); + await sendKeyAndWait(this.browser, Key.Return); - // Start move. - await this.browser.keys('m'); + // Wait for the move icon to appear so we know we're in move mode. + await this.browser.$('.blocklyMoveIndicatorBubble').waitForExist(); // Check that the moving block has nothing connected it its // next/previous connections, and same thing connected to value // input. const newInfo = await getFocusedNeighbourInfo(this.browser); - chai.assert( - newInfo.parentId === null, + chai.assert.isNull( + newInfo.parentId, 'moving block should have no parent block', ); - chai.assert( - newInfo.nextId === null, + chai.assert.isNull( + newInfo.nextId, 'moving block should have no next block', ); chai.assert.strictEqual( @@ -144,9 +172,134 @@ suite('Move tests', function () { ); // Abort move. - await this.browser.keys(Key.Escape); + await sendKeyAndWait(this.browser, Key.Escape); } }); +}); + +suite('Statement move tests', function () { + // Increase timeout to 10s for this longer test (but disable + // timeouts if when non-zero PAUSE_TIME is used to watch tests) run. + this.timeout(PAUSE_TIME ? 0 : 10000); + + // Clear the workspace and load start blocks. + setup(async function () { + this.browser = await testSetup( + testFileLocations.MOVE_STATEMENT_TEST_BLOCKS, + ); + await this.browser.pause(PAUSE_TIME); + }); + + /** Serialized simple statement block with no statement inputs. */ + const STATEMENT_SIMPLE = { + type: 'draw_emoji', + id: 'simple_mover', + fields: {emoji: '✨'}, + }; + /** + * Expected connection candidates when moving a block with no + * inputs, after pressing right (or down) arrow n times. + */ + const EXPECTED_SIMPLE_RIGHT = [ + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location. + {id: 'text_print', index: 0, ownIndex: 1}, // Previous. + {id: 'text_print', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. + {id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input. + {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. + {id: 'controls_if', index: 1, ownIndex: 0}, // Next. + {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + ]; + /** + * Expected connection candidates when moving STATEMENT_SIMPLE after + * pressing left (or up) arrow n times. + */ + const EXPECTED_SIMPLE_LEFT = EXPECTED_SIMPLE_RIGHT.slice(0, 1).concat( + EXPECTED_SIMPLE_RIGHT.slice(1).reverse(), + ); + + suite('Constrained moves of simple statement block', function () { + setup(async function () { + await appendBlock(this.browser, STATEMENT_SIMPLE, 'p5_canvas'); + }); + test( + 'moving right', + moveTest(STATEMENT_SIMPLE.id, Key.ArrowRight, EXPECTED_SIMPLE_RIGHT), + ); + test( + 'moving left', + moveTest(STATEMENT_SIMPLE.id, Key.ArrowLeft, EXPECTED_SIMPLE_LEFT), + ); + test( + 'moving down', + moveTest(STATEMENT_SIMPLE.id, Key.ArrowDown, EXPECTED_SIMPLE_RIGHT), + ); + test( + 'moving up', + moveTest(STATEMENT_SIMPLE.id, Key.ArrowUp, EXPECTED_SIMPLE_LEFT), + ); + }); + + /** Serialized statement block with multiple statement inputs. */ + const STATEMENT_COMPLEX = { + type: 'controls_if', + id: 'complex_mover', + extraState: {hasElse: true}, + }; + /** + * Expected connection candidates when moving STATEMENT_COMPLEX, after + * pressing right (or down) arrow n times. + */ + const EXPECTED_COMPLEX_RIGHT = [ + // TODO(#702): Due to a bug in KeyboardDragStrategy, certain + // connection candidates that can be found using the mouse are not + // visited when doing a keyboard move. They appear in the list + // below, but commented out for now. They should be uncommented + // when bug is fixed. + {id: 'p5_canvas', index: 1, ownIndex: 0}, // Next; starting location again. + // {id: 'text_print', index: 0, ownIndex: 1}, // Previous to own next. + {id: 'text_print', index: 0, ownIndex: 4}, // Previous to own else input. + // {id: 'text_print', index: 0, ownIndex: 3}, // Previous to own if input. + {id: 'text_print', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 3, ownIndex: 0}, // "If" statement input. + {id: 'controls_repeat_ext', index: 3, ownIndex: 0}, // Statement input. + {id: 'controls_repeat_ext', index: 1, ownIndex: 0}, // Next. + {id: 'controls_if', index: 5, ownIndex: 0}, // "Else if" statement input. + {id: 'controls_if', index: 6, ownIndex: 0}, // "Else" statement input. + {id: 'controls_if', index: 1, ownIndex: 0}, // Next. + {id: 'p5_draw', index: 0, ownIndex: 0}, // Statement input. + ]; + /** + * Expected connection candidates when moving STATEMENT_COMPLEX after + * pressing left or up arrow n times. + */ + const EXPECTED_COMPLEX_LEFT = EXPECTED_COMPLEX_RIGHT.slice(0, 1).concat( + EXPECTED_COMPLEX_RIGHT.slice(1).reverse(), + ); + + suite('Constrained moves of stack block with statement inputs', function () { + setup(async function () { + await appendBlock(this.browser, STATEMENT_COMPLEX, 'p5_canvas'); + }); + test( + 'moving right', + moveTest(STATEMENT_COMPLEX.id, Key.ArrowRight, EXPECTED_COMPLEX_RIGHT), + ); + test( + 'moving left', + moveTest(STATEMENT_COMPLEX.id, Key.ArrowLeft, EXPECTED_COMPLEX_LEFT), + ); + test( + 'moving down', + moveTest(STATEMENT_COMPLEX.id, Key.ArrowDown, EXPECTED_COMPLEX_RIGHT), + ); + test( + 'moving up', + moveTest(STATEMENT_COMPLEX.id, Key.ArrowUp, EXPECTED_COMPLEX_LEFT), + ); + }); // When a top-level block with no previous, next or output // connections is subject to a constrained move, it should not move. @@ -157,7 +310,7 @@ suite('Move tests', function () { // block unexpectedly moving (unless workspace scale was === 1). test('Constrained move of unattachable top-level block', async function () { // Block ID of an unconnectable block. - const BLOCK = 'p5_setup_1'; + const BLOCK = 'p5_setup'; // Scale workspace. await this.browser.execute(() => { @@ -165,10 +318,9 @@ suite('Move tests', function () { }); // Navigate to unconnectable block, get initial coords and start move. - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, BLOCK); const startCoordinate = await getCoordinate(this.browser, BLOCK); - await this.browser.keys('m'); + await sendKeyAndWait(this.browser, 'm'); // Check constrained moves have no effect. await keyDown(this.browser, 5); @@ -201,20 +353,237 @@ suite('Move tests', function () { } // Abort move. - await this.browser.keys(Key.Escape); + await sendKeyAndWait(this.browser, Key.Escape); }); }); +suite(`Value expression move tests`, function () { + // Increase timeout to 10s for this longer test (but disable + // timeouts if when non-zero PAUSE_TIME is used to watch tests) run. + this.timeout(PAUSE_TIME ? 0 : 10000); + + /** Serialized simple reporter value block with no inputs. */ + const VALUE_SIMPLE = { + type: 'text', + id: 'simple_mover', + fields: {TEXT: 'simple mover'}, + }; + /** + * Expected connection candidates when moving VALUE_SIMPLE, after + * pressing ArrowRight n times. + */ + const EXPECTED_SIMPLE_RIGHT = [ + {id: 'join0', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join0', index: 2, ownIndex: 0}, // Join block ADD1 input. + {id: 'print1', index: 2, ownIndex: 0}, // Print block with no shadow. + {id: 'print2', index: 2, ownIndex: 0}, // Print block with shadow. + // Skip draw_emoji block as it has no value inputs. + {id: 'print3', index: 2, ownIndex: 0}, // Replacing join expression. + {id: 'join1', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join1', index: 2, ownIndex: 0}, // Join block ADD1 input. + // Skip controls_repeat_ext block's TIMES input as it is incompatible. + {id: 'print4', index: 2, ownIndex: 0}, // Replacing join expression. + {id: 'join2', index: 1, ownIndex: 0}, // Join block ADD0 input. + {id: 'join2', index: 2, ownIndex: 0}, // Join block ADD1 input. + // Skip input of unattached join block. + ]; + /** + * Expected connection candidates when moving BLOCK_SIMPLE, after + * pressing ArrowLeft n times. + */ + const EXPECTED_SIMPLE_LEFT = EXPECTED_SIMPLE_RIGHT.slice(0, 1).concat( + EXPECTED_SIMPLE_RIGHT.slice(1).reverse(), + ); + + /** + * Serialized row of value blocks with no free inputs; should behave + * as VALUE_SIMPLE does. + */ + const VALUE_ROW = { + type: 'text_changeCase', + id: 'row_mover', + fields: {CASE: 'TITLECASE'}, + inputs: { + TEXT: {block: VALUE_SIMPLE}, + }, + }; + // EXPECTED_ROW_RIGHT will be same as EXPECTED_SIMPLE_RIGHT (and + // same for ..._LEFT). + + /** Serialized value block with a single free (external) input. */ + const VALUE_UNARY = { + type: 'text_changeCase', + id: 'unary_mover', + fields: {CASE: 'TITLECASE'}, + }; + /** + * Expected connection candidates when moving VALUE_UNARY after + * pressing ArrowRight n times. + */ + const EXPECTED_UNARY_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([ + {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input. + ]); + /** + * Expected connection candidates when moving row consisting of + * BLOCK_UNARY on its own after pressing ArrowLEFT n times. + */ + const EXPECTED_UNARY_LEFT = EXPECTED_UNARY_RIGHT.slice(0, 1).concat( + EXPECTED_UNARY_RIGHT.slice(1).reverse(), + ); + + /** Serialized value block with a single free (external) input. */ + const VALUE_COMPLEX = { + type: 'text_join', + id: 'complex_mover', + }; + /** + * Expected connection candidates when moving VALUE_COMPLEX after + * pressing ArrowRight n times. + */ + const EXPECTED_COMPLEX_RIGHT = EXPECTED_SIMPLE_RIGHT.concat([ + // TODO(#702): Due to a bug in KeyboardDragStrategy, certain + // connection candidates that can be found using the mouse are not + // visited when doing a keyboard move. They appear in the list + // below, but commented out for now. They should be uncommented + // when bug is fixed. + {id: 'join0', index: 0, ownIndex: 2}, // Unattached block to own input. + // {id: 'join0', index: 0, ownIndex: 1}, // Unattached block to own input. + ]); + /** + * Expected connection candidates when moving row consisting of + * BLOCK_COMPLEX on its own after pressing ArrowLEFT n times. + */ + const EXPECTED_COMPLEX_LEFT = EXPECTED_COMPLEX_RIGHT.slice(0, 1).concat( + EXPECTED_COMPLEX_RIGHT.slice(1).reverse(), + ); + + for (const renderer of ['geras', 'thrasos', 'zelos']) { + // TODO(#707): These tests fail when run using zelos, so for now + // we skip entire suite. Stop skipping suite when bug is fixed. + const suiteOrSkip = renderer === 'zelos' ? suite.skip : suite; + suiteOrSkip(`using ${renderer}`, function () { + // Clear the workspace and load start blocks. + setup(async function () { + this.browser = await testSetup( + createTestUrl( + new URLSearchParams({renderer, scenario: 'moveValueTestBlocks'}), + ), + ); + await this.browser.pause(PAUSE_TIME); + }); + + suite('Constrained moves of a simple reporter block', function () { + setup(async function () { + await appendBlock(this.browser, VALUE_SIMPLE, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest(VALUE_SIMPLE.id, Key.ArrowRight, EXPECTED_SIMPLE_RIGHT), + ); + test( + 'moving left', + moveTest(VALUE_SIMPLE.id, Key.ArrowLeft, EXPECTED_SIMPLE_LEFT), + ); + }); + + suite('Constrained moves of row of value blocks', function () { + setup(async function () { + await appendBlock(this.browser, VALUE_ROW, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest(VALUE_ROW.id, Key.ArrowRight, EXPECTED_SIMPLE_RIGHT), + ); + test( + 'moving left', + moveTest(VALUE_ROW.id, Key.ArrowLeft, EXPECTED_SIMPLE_LEFT), + ); + }); + + suite('Constrained moves of unary expression block', function () { + setup(async function () { + await appendBlock(this.browser, VALUE_UNARY, 'join0', 'ADD0'); + }); + // TODO(#709): Reenable test once crash bug is fixed. + test.skip( + 'moving right', + moveTest(VALUE_UNARY.id, Key.ArrowRight, EXPECTED_UNARY_RIGHT), + ); + test( + 'moving left', + moveTest(VALUE_UNARY.id, Key.ArrowLeft, EXPECTED_UNARY_LEFT), + ); + }); + + suite('Constrained moves of a complex expression block', function () { + setup(async function () { + await appendBlock(this.browser, VALUE_COMPLEX, 'join0', 'ADD0'); + }); + test( + 'moving right', + moveTest(VALUE_COMPLEX.id, Key.ArrowRight, EXPECTED_COMPLEX_RIGHT), + ); + test( + 'moving left', + moveTest(VALUE_COMPLEX.id, Key.ArrowLeft, EXPECTED_COMPLEX_LEFT), + ); + }); + }); + } +}); + +/** + * Create a mocha test function moving a specified block in a + * particular direction, checking that it has the the expected + * connection candidate after each step, and that once the move + * finishes that the moving block is reconnected to its initial + * location. + * + * @param mover Block ID of the block to be moved. + * @param key Key to send to move one step. + * @param candidates Array of expected connection candidates. + * @returns function to pass as second argument to mocha's test function. + */ +function moveTest( + mover: string, + key: string | string[], + candidates: Array<{id: string; index: number}>, +) { + return async function (this: Mocha.Context) { + // Navigate to block to be moved and intiate move. + await focusOnBlock(this.browser, mover); + const initialInfo = await getFocusedNeighbourInfo(this.browser); + await sendKeyAndWait(this.browser, 'm'); + // Press specified key multiple times, checking connection candidates. + for (let i = 0; i < candidates.length; i++) { + const candidate = await getConnectionCandidate(this.browser); + chai.assert.deepEqual(candidate, candidates[i]); + await sendKeyAndWait(this.browser, key); + } + + // Finish move and check final location of moved block. + await sendKeyAndWait(this.browser, Key.Enter); + const finalInfo = await getFocusedNeighbourInfo(this.browser); + chai.assert.deepEqual(initialInfo, finalInfo); + }; +} + /** - * Get information about the currently-selected block's parent and + * Get information about the currently-focused block's parent and * child blocks. * * @param browser The webdriverio browser session. - * @returns A promise setting to {parentId, parentIndex, nextId, - * valueId}, being respectively the parent block ID, index of parent - * connection, next block ID, and ID of the block connected to the - * zeroth value value input, or null if the given item does not - * exist. + * @returns A promise setting to + * + * {parentId, parentIndex, nextId, valueId} + * + * where parentId, parentIndex are the ID of the parent block and + * the index of the connection on that block to which the + * currently-focused block is connected, nextId is the ID of block + * connected to the focused block's next connection, and valueID + * is the ID of a block connected to the zeroth input of the + * focused block (or, in each case, null if there is no such + * block). */ function getFocusedNeighbourInfo(browser: Browser) { return browser.execute(() => { @@ -295,3 +664,106 @@ function getCoordinate( return block.getRelativeToSurfaceXY(); }, id); } + +/** + * Get information about the connection candidate for the + * currently-moving block (if any). + * + * @param browser The webdriverio browser session. + * @returns A promise setting to either null if there is no connection + * candidate, or otherwise if there is one to + * + * {id, index, ownIndex} + * + * where id is the block ID of the neighbour, index is the index + * of the candidate connection on the neighbour, and ownIndex is + * the index of the candidate connection on the moving block. + */ +function getConnectionCandidate( + browser: Browser, +): Promise<{id: string; index: number} | null> { + return browser.execute(() => { + const focused = Blockly.getFocusManager().getFocusedNode(); + if (!focused) throw new Error('nothing focused'); + if (!(focused instanceof Blockly.BlockSvg)) { + throw new TypeError('focused node is not a BlockSvg'); + } + const block = focused; // Inferred as BlockSvg. + const dragStrategy = + block.getDragStrategy() as Blockly.dragging.BlockDragStrategy; + if (!dragStrategy) throw new Error('no drag strategy'); + // @ts-expect-error connectionCandidate is private. + const candidate = dragStrategy.connectionCandidate; + if (!candidate) return null; + const neighbourBlock = candidate.neighbour.getSourceBlock(); + if (!neighbourBlock) throw new TypeError('connection has no source block'); + const neighbourConnections = neighbourBlock.getConnections_(true); + const index = neighbourConnections.indexOf(candidate.neighbour); + const ownConnections = block.getConnections_(true); + const ownIndex = ownConnections.indexOf(candidate.local); + return {id: neighbourBlock.id, index, ownIndex}; + }); +} + +/** + * Create a new block from serialised state (parsed JSON) and + * optionally attach it to an existing block on the workspace. + * + * @param browser The WebdriverIO browser object. + * @param state The JSON definition of the new block. + * @param parentId The ID of the block to attach to. If undefined, the + * new block is not attached. + * @param inputName The name of the input on the parent block to + * attach to. If undefined, the new block is attached to the + * parent's next connection. + * @returns A promise that resolves with the new block's ID. + */ +async function appendBlock( + browser: Browser, + state: Blockly.serialization.blocks.State, + parentId?: string, + inputName?: string, +): Promise { + return await browser.execute( + (state, parentId, inputName) => { + const workspace = Blockly.getMainWorkspace(); + if (!workspace) throw new Error('workspace not found'); + + const block = Blockly.serialization.blocks.append(state, workspace); + if (!block) throw new Error('failed to create block from state'); + if (!parentId) return block.id; + + try { + const parent = workspace.getBlockById(parentId); + if (!parent) throw new Error(`parent block not found: ${parentId}`); + + let parentConnection; + let childConnection; + + if (inputName) { + parentConnection = parent.getInput(inputName)?.connection; + if (!parentConnection) { + throw new Error(`input ${inputName} not found on parent`); + } + childConnection = block.outputConnection ?? block.previousConnection; + } else { + parentConnection = parent.nextConnection; + if (!parentConnection) { + throw new Error('parent has no next connection'); + } + childConnection = block.previousConnection; + } + if (!childConnection) throw new Error('new block not compatible'); + parentConnection.connect(childConnection); + return block.id; + } catch (e) { + // If anything goes wrong during attachment, clean up the new block. + block.dispose(); + throw e; + } + }, + state, + parentId, + inputName, + ); +} diff --git a/test/webdriverio/test/mutator_test.ts b/test/webdriverio/test/mutator_test.ts index 16db298a..0d106e16 100644 --- a/test/webdriverio/test/mutator_test.ts +++ b/test/webdriverio/test/mutator_test.ts @@ -14,29 +14,26 @@ import { testSetup, testFileLocations, PAUSE_TIME, - tabNavigateToWorkspace, + sendKeyAndWait, keyRight, keyDown, } from './test_setup.js'; import {Key} from 'webdriverio'; suite('Mutator navigation', function () { - // Setting timeout to unlimited as these tests take a longer time to run than most mocha test - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Setup Selenium for all of the tests + // Clear the workspace and load start blocks. setup(async function () { this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS); this.openMutator = async () => { - await tabNavigateToWorkspace(this.browser); - await this.browser.pause(PAUSE_TIME); await focusOnBlock(this.browser, 'controls_if_1'); await this.browser.pause(PAUSE_TIME); // Navigate to the mutator icon await keyRight(this.browser); // Activate the icon - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Enter); }; }); @@ -54,8 +51,7 @@ suite('Mutator navigation', function () { test('Escape dismisses mutator', async function () { await this.openMutator(); - await this.browser.keys(Key.Escape); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Escape); // Main workspace should be the focused tree (since mutator workspace is gone) const mainWorkspaceFocused = await focusedTreeIsMainWorkspace(this.browser); @@ -75,11 +71,9 @@ suite('Mutator navigation', function () { test('Escape in the mutator flyout focuses the mutator workspace', async function () { await this.openMutator(); // Focus the flyout - await this.browser.keys('t'); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 't'); // Hit escape to return focus to the mutator workspace - await this.browser.keys(Key.Escape); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Escape); // The "if" placeholder block in the mutator should be focused const focusedBlockType = await getFocusedBlockType(this.browser); chai.assert.equal(focusedBlockType, 'controls_if_if'); @@ -87,8 +81,7 @@ suite('Mutator navigation', function () { test('T focuses the mutator flyout', async function () { await this.openMutator(); - await this.browser.keys('t'); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 't'); // The "else if" block in the mutator flyout should be focused const focusedBlockType = await getFocusedBlockType(this.browser); @@ -97,16 +90,14 @@ suite('Mutator navigation', function () { test('Blocks can be inserted from the mutator flyout', async function () { await this.openMutator(); - await this.browser.keys('t'); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, 't'); // Navigate down to the second block in the flyout await keyDown(this.browser); await this.browser.pause(PAUSE_TIME); // Hit enter to enter insert mode - await this.browser.keys(Key.Enter); - await this.browser.pause(PAUSE_TIME); + await sendKeyAndWait(this.browser, Key.Enter); // Hit enter again to lock it into place on the connection - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter); const topBlocks = await this.browser.execute(() => { const focusedTree = Blockly.getFocusManager().getFocusedTree(); diff --git a/test/webdriverio/test/scroll_test.ts b/test/webdriverio/test/scroll_test.ts index 305ca31c..ac64b6d8 100644 --- a/test/webdriverio/test/scroll_test.ts +++ b/test/webdriverio/test/scroll_test.ts @@ -8,6 +8,7 @@ import * as Blockly from 'blockly'; import * as chai from 'chai'; import {Key} from 'webdriverio'; import { + sendKeyAndWait, keyDown, keyRight, PAUSE_TIME, @@ -17,25 +18,43 @@ import { } from './test_setup.js'; suite('Scrolling into view', function () { - // Setting timeout to unlimited as these tests take longer time to run - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Clear the workspace and load start blocks - setup(async function () { + // Resize browser to provide predictable small window size for scrolling. + // + // N.B. that this is called only one per suite, not once per test. + suiteSetup(async function () { this.browser = await testSetup(testFileLocations.BASE); - // Predictable small window size for scrolling. + this.windowSize = await this.browser.getWindowSize(); await this.browser.setWindowSize(800, 600); await this.browser.pause(PAUSE_TIME); }); + // Restore original browser window size. + suiteTeardown(async function () { + await this.browser.setWindowSize( + this.windowSize.width, + this.windowSize.height, + ); + }); + + // Clear the workspace and load start blocks. + setup(async function () { + await testSetup(testFileLocations.BASE); + }); + test('Insert scrolls new block into view', async function () { + // Increase timeout to 10s for this longer test. + this.timeout(PAUSE_TIME ? 0 : 10000); + await tabNavigateToWorkspace(this.browser); // Separate the two top-level blocks by moving p5_draw_1 further down. await keyDown(this.browser, 3); - await this.browser.keys('m'); - await this.browser.keys([Key.Alt, ...new Array(25).fill(Key.ArrowDown)]); - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, 'm'); + await sendKeyAndWait(this.browser, [Key.Alt, Key.ArrowDown], 25); + await sendKeyAndWait(this.browser, Key.Enter); // Scroll back up, leaving cursor on the draw block out of the viewport. await this.browser.execute(() => { const workspace = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg; @@ -47,10 +66,9 @@ suite('Scrolling into view', function () { }); // Insert and confirm the test block which should be scrolled into view. - await this.browser.keys('t'); + await sendKeyAndWait(this.browser, 't'); await keyRight(this.browser); - await this.browser.keys(Key.Enter); - await this.browser.keys(Key.Enter); + await sendKeyAndWait(this.browser, Key.Enter, 2); // Assert new block has been scrolled into the viewport. await this.browser.pause(PAUSE_TIME); diff --git a/test/webdriverio/test/stack_navigation.ts b/test/webdriverio/test/stack_navigation.ts index 08c9942f..2699e45d 100644 --- a/test/webdriverio/test/stack_navigation.ts +++ b/test/webdriverio/test/stack_navigation.ts @@ -12,13 +12,11 @@ import { tabNavigateToWorkspace, testFileLocations, testSetup, + sendKeyAndWait, } from './test_setup.js'; suite('Stack navigation', function () { - // Setting timeout to unlimited as these tests take longer time to run - this.timeout(0); - - // Clear the workspace and load start blocks + // Clear the workspace and load start blocks. setup(async function () { this.browser = await testSetup(testFileLocations.COMMENTS); await this.browser.pause(PAUSE_TIME); @@ -30,17 +28,17 @@ suite('Stack navigation', function () { 'p5_setup_1', await getCurrentFocusedBlockId(this.browser), ); - await this.browser.keys('n'); + await sendKeyAndWait(this.browser, 'n'); chai.assert.equal( 'p5_draw_1', await getCurrentFocusedBlockId(this.browser), ); - await this.browser.keys('n'); + await sendKeyAndWait(this.browser, 'n'); chai.assert.equal( 'workspace_comment_1', await getCurrentFocusNodeId(this.browser), ); - await this.browser.keys('n'); + await sendKeyAndWait(this.browser, 'n'); // Looped around. chai.assert.equal( 'p5_setup_1', @@ -54,18 +52,18 @@ suite('Stack navigation', function () { 'p5_setup_1', await getCurrentFocusedBlockId(this.browser), ); - await this.browser.keys('b'); + await sendKeyAndWait(this.browser, 'b'); // Looped to bottom. chai.assert.equal( 'workspace_comment_1', await getCurrentFocusNodeId(this.browser), ); - await this.browser.keys('b'); + await sendKeyAndWait(this.browser, 'b'); chai.assert.equal( 'p5_draw_1', await getCurrentFocusedBlockId(this.browser), ); - await this.browser.keys('b'); + await sendKeyAndWait(this.browser, 'b'); chai.assert.equal( 'p5_setup_1', await getCurrentFocusedBlockId(this.browser), diff --git a/test/webdriverio/test/styling_test.ts b/test/webdriverio/test/styling_test.ts index efa2514b..f226a5a5 100644 --- a/test/webdriverio/test/styling_test.ts +++ b/test/webdriverio/test/styling_test.ts @@ -18,10 +18,10 @@ import { import * as chai from 'chai'; suite('Styling test', function () { - // Setting timeout to unlimited as these tests take longer time to run - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Clear the workspace and load start blocks + // Clear the workspace and load start blocks. setup(async function () { this.browser = await testSetup(testFileLocations.BASE); await this.browser.pause(PAUSE_TIME); @@ -81,7 +81,6 @@ suite('Styling test', function () { }); test('Workspace has only active tree style when move is in progress', async function () { - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'set_background_color_1'); // Moves block to drag layer which requires different selectors. await sendKeyAndWait(this.browser, 'm'); @@ -91,7 +90,6 @@ suite('Styling test', function () { }); test('Workspace has only active tree style when widget has focus', async function () { - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'create_canvas_1'); // Move to field. await keyRight(this.browser); @@ -103,7 +101,6 @@ suite('Styling test', function () { }); test('Workspace has only active tree style when dropdown has focus', async function () { - await tabNavigateToWorkspace(this.browser); await focusOnBlock(this.browser, 'set_background_color_1'); // Move to color block. await keyRight(this.browser); diff --git a/test/webdriverio/test/test_setup.ts b/test/webdriverio/test/test_setup.ts index e5d47aa7..ae93f073 100644 --- a/test/webdriverio/test/test_setup.ts +++ b/test/webdriverio/test/test_setup.ts @@ -11,9 +11,9 @@ * This file is to be used in the suiteSetup for any automated fuctional test. * * Note: In this file many functions return browser elements that can - * be clicked or otherwise interacted with through Selenium WebDriver. These + * be clicked or otherwise interacted with through WebdriverIO. These * elements are not the raw HTML and SVG elements on the page; they are - * identifiers that Selenium can use to find those elements. + * identifiers that WebdriverIO can use to find those elements. */ import * as Blockly from 'blockly'; @@ -27,16 +27,30 @@ import {fileURLToPath} from 'url'; let driver: webdriverio.Browser | null = null; /** - * The default amount of time to wait during a test. Increase this to make - * tests easier to watch; decrease it to make tests run faster. + * The default amount of time to wait during a test, in ms. Increase + * this to make tests easier to watch; decrease it to make tests run + * faster. + * + * The _test.js files in this directory are set up to disable timeouts + * automatically when PAUSE_TIME is set to a nonzero value via + * + * if (PAUSE_TIME) this.timeout(0); + * + * at the top of each suite. + * + * Tests should pass reliably even with this set to zero; use one of + * the browser.wait* functions if you need your test to wait for + * something to happen after sending input. */ -export const PAUSE_TIME = 50; +export const PAUSE_TIME = 0; /** - * Start up the test page. This should only be done once, to avoid - * constantly popping browser windows open and closed. + * Start up WebdriverIO and load the test page. This should only be + * done once, to avoid constantly popping browser windows open and + * closed. * - * @returns A Promise that resolves to a webdriverIO browser that tests can manipulate. + * @returns A Promise that resolves to a WebdriverIO browser that + * tests can manipulate. */ export async function driverSetup(): Promise { const options = { @@ -68,14 +82,14 @@ export async function driverSetup(): Promise { // https://github.com/google/blockly/issues/5345 for details. options.capabilities['goog:chromeOptions'].args.push('--disable-gpu'); } - // Use Selenium to bring up the page + // Use webdriver to bring up the page console.log('Starting webdriverio...'); driver = await webdriverio.remote(options); return driver; } /** - * End the webdriverIO session. + * End the WebdriverIO session. * * @return A Promise that resolves after the actions have been completed. */ @@ -90,7 +104,8 @@ export async function driverTeardown() { * * @param playgroundUrl The URL to open for the test, which should be * a Blockly playground with a workspace. - * @returns A Promise that resolves to a webdriverIO browser that tests can manipulate. + * @returns A Promise that resolves to a WebdriverIO browser that + * tests can manipulate. */ export async function testSetup( playgroundUrl: string, @@ -116,13 +131,11 @@ export async function testSetup( * @returns posix path */ function posixPath(target: string): string { - const result = target.split(path.sep).join(path.posix.sep); - console.log(result); - return result; + return target.split(path.sep).join(path.posix.sep); } // Relative to dist folder for TS build -const createTestUrl = (options?: URLSearchParams) => { +export const createTestUrl = (options?: URLSearchParams) => { const dirname = path.dirname(fileURLToPath(import.meta.url)); const base = new URL( `file://${posixPath(path.join(dirname, '..', '..', 'build', 'index.html'))}`, @@ -138,8 +151,14 @@ export const testFileLocations = { new URLSearchParams({scenario: 'navigationTestBlocks'}), ), // eslint-disable-next-line @typescript-eslint/naming-convention - MOVE_TEST_BLOCKS: createTestUrl( - new URLSearchParams({scenario: 'moveTestBlocks'}), + MORE_BLOCKS: createTestUrl(new URLSearchParams({scenario: 'moreBlocks'})), + // eslint-disable-next-line @typescript-eslint/naming-convention + MOVE_START_TEST_BLOCKS: createTestUrl( + new URLSearchParams({scenario: 'moveStartTestBlocks'}), + ), + // eslint-disable-next-line @typescript-eslint/naming-convention + MOVE_STATEMENT_TEST_BLOCKS: createTestUrl( + new URLSearchParams({scenario: 'moveStatementTestBlocks'}), ), COMMENTS: createTestUrl(new URLSearchParams({scenario: 'comments'})), // eslint-disable-next-line @typescript-eslint/naming-convention @@ -173,7 +192,7 @@ export async function focusWorkspace(browser: WebdriverIO.Browser) { const workspaceElement = await browser.$( '#blocklyDiv > div > svg.blocklySvg > g', ); - await workspaceElement.click(); + await workspaceElement.click({x: 100}); } /** @@ -440,8 +459,14 @@ export async function tabNavigateToWorkspace( hasToolbox = true, hasFlyout = true, ) { - // Navigate past the initial pre-injection focusable div element. - await tabNavigateForward(browser); + // Move focus to initial pre-injection focusable div element. + // + // Ideally we'd just reset focus state to the state it is in when + // the document initially loads (and then send one tab), but alas + // there's no straightforward way to do that; see + // https://stackoverflow.com/q/51518855/4969945 + await browser.execute(() => document.getElementById('focusableDiv')?.focus()); + // Navigate to workspace. if (hasToolbox) await tabNavigateForward(browser); if (hasFlyout) await tabNavigateForward(browser); await tabNavigateForward(browser); // Tab to the workspace itself. @@ -531,9 +556,17 @@ export async function sendKeyAndWait( keys: string | string[], times = 1, ) { - for (let i = 0; i < times; i++) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: Unintentional comparison error + if (PAUSE_TIME === 0) { + // Send all keys in one call if no pauses needed. + keys = Array(times).fill(keys).flat(); await browser.keys(keys); - await browser.pause(PAUSE_TIME); + } else { + for (let i = 0; i < times; i++) { + await browser.keys(keys); + await browser.pause(PAUSE_TIME); + } } } @@ -552,7 +585,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. @@ -679,7 +712,7 @@ export async function clickBlock( findableId, ); - // In the test context, get the Webdriverio Element that we've identified. + // In the test context, get the WebdriverIO Element that we've identified. const elem = await browser.$(`#${findableId}`); await elem.click(clickOptions); @@ -689,3 +722,17 @@ export async function clickBlock( document.getElementById(elemId)?.removeAttribute('id'); }, findableId); } + +/** + * 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'}); +} diff --git a/test/webdriverio/test/toast_test.ts b/test/webdriverio/test/toast_test.ts index cd4721d5..bf774491 100644 --- a/test/webdriverio/test/toast_test.ts +++ b/test/webdriverio/test/toast_test.ts @@ -9,6 +9,10 @@ import * as Blockly from 'blockly/core'; import {PAUSE_TIME, testFileLocations, testSetup} from './test_setup.js'; suite('HTML toasts', function () { + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) 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); diff --git a/test/webdriverio/test/workspace_comment_test.ts b/test/webdriverio/test/workspace_comment_test.ts index eb882e27..f97ddcde 100644 --- a/test/webdriverio/test/workspace_comment_test.ts +++ b/test/webdriverio/test/workspace_comment_test.ts @@ -19,14 +19,15 @@ import { keyDown, keyUp, contextMenuItems, + PAUSE_TIME, } from './test_setup.js'; import {Key} from 'webdriverio'; suite('Workspace comment navigation', function () { - // Setting timeout to unlimited as these tests take a longer time to run than most mocha test - this.timeout(0); + // Disable timeouts when non-zero PAUSE_TIME is used to watch tests run. + if (PAUSE_TIME) this.timeout(0); - // Setup Selenium for all of the tests + // Clear the workspace and load start blocks. setup(async function () { this.browser = await testSetup(testFileLocations.NAVIGATION_TEST_BLOCKS); [this.commentId1, this.commentId2] = await this.browser.execute(() => {