From d0267c3cf7691e9c2ed51e2d5071b9fd6004fcc1 Mon Sep 17 00:00:00 2001 From: Leon Zandman Date: Sat, 26 Sep 2026 13:25:43 +0200 Subject: [PATCH] Add drag drop browser tests (#2795) --- src/web/waiters/InputWaiter.mjs | 5 +- tests/browser/04_dragdrop.js | 775 ++++++++++++++++++++++++++++++++ tests/browser/browserUtils.js | 677 +++++++++++++++++++++++++++- 3 files changed, 1455 insertions(+), 2 deletions(-) create mode 100644 tests/browser/04_dragdrop.js diff --git a/src/web/waiters/InputWaiter.mjs b/src/web/waiters/InputWaiter.mjs index ab9b5b74..4aa6bd27 100644 --- a/src/web/waiters/InputWaiter.mjs +++ b/src/web/waiters/InputWaiter.mjs @@ -1062,7 +1062,10 @@ class InputWaiter { for (let i = 0; i < dataTransferItemList.length; i++) { // Note webkitGetAsEntry a non-standard feature and may change // Usage is necessary for handling directories - queue.push(dataTransferItemList[i].webkitGetAsEntry()); + const entry = dataTransferItemList[i].webkitGetAsEntry(); + // Items that are not files have no entry. Dragging an image out of another browser + // window, for instance, brings text/html and text/uri-list items along with the file. + if (entry) queue.push(entry); } while (queue.length > 0) { const entry = queue.shift(); diff --git a/tests/browser/04_dragdrop.js b/tests/browser/04_dragdrop.js new file mode 100644 index 00000000..6e96db69 --- /dev/null +++ b/tests/browser/04_dragdrop.js @@ -0,0 +1,775 @@ +/** + * Tests for the drag and drop surfaces: adding, reordering and removing operations, favourites, + * text argument drops, file drops onto the input, and the pane splitters. + * + * Three techniques are used, and each test says which one it is: + * + * A - a genuine native HTML5 drag, driven through the Chrome DevTools Protocol. Chromium will not + * synthesise native drag events from WebDriver mouse input, so `Input.setInterceptDrags` plus + * `Input.dispatchDragEvent` is the only way to reach the real path, dataTransfer included. + * Chrome only: there is no CDP in safaridriver or geckodriver, so these tests skip themselves + * when it is unavailable. + * B - SortableJS's pointer-event fallback mode plus the WebDriver Actions API. Real pointer input + * and real Sortable logic, but through the fallback code path and with no dataTransfer. + * C - synthetic DragEvents carrying a hand-built DataTransfer. Proves the app's handlers behave, + * not that the browser will ever call them. The only option for file drops, since WebDriver + * cannot perform an OS-level file drop at all. + * + * @author n1474335 [n1474335@gmail.com] + * @copyright Crown Copyright 2025 + * @license Apache-2.0 + */ + +const utils = require("./browserUtils.js"); + +/** + * Set in `before`. Technique A tests return early when CDP is not reachable. + */ +let nativeDragSupported = false; + +/** + * Logs and reports that a Technique A test cannot run in this browser. + * + * @param {string} name - The test name + * @returns {boolean} Always true, so callers can `if (skipNative(...)) return;` + */ +function skipNative(name) { + console.log(`SKIPPED "${name}": native drag and drop needs the Chrome DevTools Protocol, ` + + "which this driver does not provide. See the Technique notes at the top of this file."); + return true; +} + +/** + * Clears the recipe and waits for it to empty. + * + * @param {Browser} browser - Nightwatch client + */ +function clearRecipe(browser) { + browser + .useCss() + .waitForElementNotVisible("#snackbar-container", 10000) + .click("#clr-recipe") + .waitForElementNotPresent("#rec-list li.operation", 2000); +} + +/** + * Puts an operation into the search results, which is itself a Sortable seed list, so that a single + * predictable stub can be dragged from a known position. + * + * @param {Browser} browser - Nightwatch client + * @param {string} opName - The operation to search for + */ +function searchFor(browser, opName) { + browser + .useCss() + .clearValue("#search") + .setValue("#search", opName) + .waitForElementVisible("#search-results li.operation", 2000); + browser.expect.element("#search-results li.operation").text.to.contain(opName); +} + +module.exports = { + before: browser => { + browser + .resizeWindow(1280, 800) + .url(browser.launchUrl) + .waitForElementNotPresent("#preloader", 10000); + + utils.cdpAvailable(browser, available => { + nativeDragSupported = available; + }); + }, + + afterEach: browser => { + // A Technique A test that fails between nativeDragStart and nativeDrop never reaches the + // drop. Without this, drag interception and the setData patch would stay on for later tests. + utils.nativeDragCancel(browser); + }, + + /** + * T1 (Technique A). The core "get an operation into the recipe" interaction. + */ + "Drag an operation from the list into the recipe": browser => { + if (!nativeDragSupported && skipNative("Drag an operation from the list into the recipe")) return; + + // setInput clears the recipe, so the input has to be in place before the drag + utils.setInput(browser, "hello", false); + searchFor(browser, "To Base64"); + + utils.nativeDragAndDrop(browser, "#search-results li.operation", "#rec-list", {x: 190, y: 30}); + + // Sortable's setData wrote the operation name into the real dataTransfer + utils.expectDragPayload(browser, "To Base64"); + + browser.expect.elements("#rec-list li.operation").count.to.equal(1); + utils.expectRecipeOrder(browser, ["To Base64"]); + + // pull: "clone" means the stub is still in the list it came from + browser.expect.element("#search-results li.operation").to.be.present; + + utils.dragInProgress(browser, false); + utils.removeIntent(browser, false); + + // The operation is a real, bakeable one, not just a list item that got moved + utils.bake(browser); + utils.expectOutput(browser, "aGVsbG8="); + }, + + /** + * T2 (Technique A). Guards the popover teardown in createSortableSeedList's onStart: the dragged + * stub has its popover disposed, and opSortEnd is responsible for putting one back on whichever + * element stayed behind in the operations list. + */ + "Dragging an operation into the recipe does not break its popover": browser => { + if (!nativeDragSupported && skipNative("Dragging an operation into the recipe does not break its popover")) return; + + clearRecipe(browser); + searchFor(browser, "To Base64"); + + utils.nativeDragAndDrop(browser, "#search-results li.operation", "#rec-list", {x: 190, y: 30}); + + browser.execute(function() { + return document.querySelector("#search-results li.operation").getAttribute("data-toggle"); + }, [], function({value}) { + browser.expect(value).to.equal("popover"); + }); + + browser + .moveToElement("#search-results li.operation", 10, 10) + .waitForElementVisible(".popover-body", 2000) + .moveToElement("#search", 10, 10) + .waitForElementNotPresent(".popover-body", 2000); + + clearRecipe(browser); + }, + + /** + * T3 (Technique A). Reordering has to change the bake result and the URL hash, the latter + * proving that onSort dispatched a statechange. + */ + "Reorder operations in the recipe": browser => { + if (!nativeDragSupported && skipNative("Reorder operations in the recipe")) return; + + clearRecipe(browser); + utils.setInput(browser, "abc", false); + browser + .urlHash("recipe=To_Hex('Space',0)To_Base64('A-Za-z0-9%2B/%3D')") + .waitForElementPresent("#rec-list li.operation:nth-of-type(2)", 3000) + .pause(200); + + utils.expectRecipeOrder(browser, ["To Hex", "To Base64"]); + utils.bake(browser); + // "abc" -> To Hex -> "61 62 63" -> To Base64 + utils.expectOutput(browser, "NjEgNjIgNjM="); + + // Drag the second operation over the top edge of the first. The .arg filter means a drag + // started on an argument is not a Sortable drag at all, so the title is the handle. + utils.nativeDragAndDrop( + browser, + "#rec-list li.operation:nth-of-type(2) .op-title", + "#rec-list li.operation:nth-of-type(1) .op-title", + {x: 20, y: 2} + ); + + utils.expectRecipeOrder(browser, ["To Base64", "To Hex"]); + + utils.bake(browser); + // "abc" -> To Base64 -> "YWJj" -> To Hex + utils.expectOutput(browser, "59 57 4a 6a"); + + browser.execute(function() { + return decodeURIComponent(window.location.hash); + }, [], function({value}) { + browser.expect(value).to.contain("recipe=To_Base64('A-Za-z0-9+/=')To_Hex('Space',0)"); + }); + + clearRecipe(browser); + }, + + /** + * T4 (Technique A). Dragging an operation off the recipe list removes it. removeIntent is set by + * the dragleave on #rec-list and acted on in onEnd. + */ + "Drag an operation out of the recipe to remove it": browser => { + if (!nativeDragSupported && skipNative("Drag an operation out of the recipe to remove it")) return; + + clearRecipe(browser); + utils.setInput(browser, "abc", false); + browser + .urlHash("recipe=To_Base64('A-Za-z0-9%2B/%3D')") + .waitForElementPresent("#rec-list li.operation", 3000) + .pause(200); + + utils.nativeDragStart(browser, "#rec-list li.operation .op-title", {x: 20, y: 10}); + utils.dragInProgress(browser, true); + + // Leave the recipe list entirely + utils.nativeDragOver(browser, "#operations", {x: 120, y: 500}); + utils.removeIntent(browser, true); + // dragleave also resets the progress indicator + browser.execute(function() { + return window.app.progress; + }, [], function({value}) { + browser.expect(value).to.equal(0); + }); + + utils.nativeDrop(browser); + browser.pause(200); + + browser.waitForElementNotPresent("#rec-list li.operation", 2000); + utils.dragInProgress(browser, false); + browser.execute(function() { + return window.app.progress; + }, [], function({value}) { + browser.expect(value).to.equal(0); + }); + + utils.bake(browser); + utils.expectOutput(browser, "abc"); + }, + + /** + * T5 (Technique A). The regression case for T4's mechanism: re-entering the list has to clear + * removeIntent again, otherwise a wobbly drag would delete the operation. + */ + "Dragging out and back in does not remove the operation": browser => { + if (!nativeDragSupported && skipNative("Dragging out and back in does not remove the operation")) return; + + clearRecipe(browser); + browser + .urlHash("recipe=To_Base64('A-Za-z0-9%2B/%3D')") + .waitForElementPresent("#rec-list li.operation", 3000) + .pause(200); + + utils.nativeDragStart(browser, "#rec-list li.operation .op-title", {x: 20, y: 10}); + utils.nativeDragOver(browser, "#operations", {x: 120, y: 500}); + utils.removeIntent(browser, true); + + utils.nativeDragOver(browser, "#rec-list", {x: 190, y: 40}); + utils.removeIntent(browser, false); + + utils.nativeDrop(browser); + browser.pause(200); + + browser.expect.elements("#rec-list li.operation").count.to.equal(1); + utils.expectRecipeOrder(browser, ["To Base64"]); + utils.dragInProgress(browser, false); + + clearRecipe(browser); + }, + + /** + * T6 (Technique A). favDrop reads dataTransfer.getData("Text"), so this one genuinely needs a + * native drag - neither the Sortable fallback nor a synthetic event dispatched at the category + * would carry the payload the handler reads. + */ + "Drop an operation on the Favourites category": browser => { + if (!nativeDragSupported && skipNative("Drop an operation on the Favourites category")) return; + + clearRecipe(browser); + searchFor(browser, "To Base64"); + + utils.expectCategoryOps(browser, "#catFavourites", [ + "To Base64", "From Base64", "To Hex", "From Hex", "To Hexdump", "From Hexdump", + "URL Decode", "Regular expression", "Entropy", "Fork", "Magic" + ]); + + // Something that is not already a favourite, so addFavourite does not bail out early + searchFor(browser, "To Binary"); + + utils.nativeDragStart(browser, "#search-results li.operation", {x: 40, y: 10}); + utils.nativeDragOver(browser, "#categories a", {x: 40, y: 12}); + + browser.expect.element("#categories a").to.have.attribute("class").which.contains("favourites-hover"); + + utils.nativeDrop(browser); + browser.pause(400); + + browser.expect.element("#categories a").to.have.attribute("class").which.does.not.contain("favourites-hover"); + browser.waitForElementPresent("#catFavourites li.operation", 2000); + browser.expect.element("#catFavourites").text.to.contain("To Binary"); + + // The other two favDragover branches - the Edit button and the image inside it - only need + // the handler to be reached, so a synthetic dragover is enough. This does not prove the + // browser routes a real drag to those nodes, only that the handler walks up to the . + browser.execute(function() { + window.app.manager.recipe.dragInProgress = true; + const results = {}; + const dispatch = function(el) { + const dt = new DataTransfer(); + dt.setData("Text", "To Binary"); + el.dispatchEvent(new DragEvent("dragover", {bubbles: true, cancelable: true, dataTransfer: dt})); + }; + const cat = document.querySelector("#categories a"); + + cat.classList.remove("favourites-hover"); + dispatch(document.getElementById("edit-favourites")); + results.viaEditButton = cat.classList.contains("favourites-hover"); + + cat.classList.remove("favourites-hover"); + dispatch(document.querySelector("#edit-favourites i")); + results.viaEditButtonIcon = cat.classList.contains("favourites-hover"); + + cat.classList.remove("favourites-hover"); + window.app.manager.recipe.dragInProgress = false; + return results; + }, [], function({value}) { + browser.expect(value.viaEditButton).to.equal(true); + browser.expect(value.viaEditButtonIcon).to.equal(true); + }); + + // Favourites persist to localStorage, so put them back before the next test + browser + .click("#edit-favourites") + .waitForElementVisible("#favourites-modal", 2000) + .click("#reset-favourites") + .waitForElementNotVisible("#favourites-modal", 2000) + .pause(300); + browser.expect.element("#catFavourites").text.to.not.contain("To Binary"); + }, + + /** + * T7 (Technique C). Guards the early return at the top of favDragover. This proves the handler + * ignores a non-operation drag; it does not prove the browser would deliver one. + */ + "Favourites category ignores non-operation drags": browser => { + browser.useCss(); + utils.dragInProgress(browser, false); + + utils.syntheticDragEvent(browser, "#categories a", "dragover", {text: "some dragged text"}); + + browser.expect.element("#categories a").to.have.attribute("class") + .which.does.not.contain("favourites-hover"); + + // Control: with the flag set, the same event does add the cue, so a handler that silently + // stopped being reached would fail this test rather than pass it + browser.execute(function() { + window.app.manager.recipe.dragInProgress = true; + }); + utils.syntheticDragEvent(browser, "#categories a", "dragover", {text: "some dragged text"}); + browser.expect.element("#categories a").to.have.attribute("class").which.contains("favourites-hover"); + + browser.execute(function() { + window.app.manager.recipe.dragInProgress = false; + document.querySelector("#categories a").classList.remove("favourites-hover"); + }); + }, + + /** + * T8 (Technique B). The favourites editor has its own Sortable with its own removeIntent, and no + * dataTransfer is involved in any of it, so the fallback path is a fair test of the real logic. + */ + "Reorder and remove favourites in the editor": browser => { + browser + .useCss() + .waitForElementNotVisible("#snackbar-container", 10000) + .click("#edit-favourites") + .waitForElementVisible("#favourites-modal", 2000) + .waitForElementVisible("#edit-favourites-list li.operation", 2000) + .pause(300); + + utils.expectCategoryOps(browser, "#edit-favourites-list", [ + "To Base64delete", "From Base64delete", "To Hexdelete", "From Hexdelete", + "To Hexdumpdelete", "From Hexdumpdelete", "URL Decodedelete", "Regular expressiondelete", + "Entropydelete", "Forkdelete", "Magicdelete" + ]); + + utils.setForceFallback(browser, "#edit-favourites-list", true); + + // Drag the second entry above the first + utils.fallbackDragAndDrop( + browser, + "#edit-favourites-list li.operation:nth-of-type(2)", + "#edit-favourites-list li.operation:nth-of-type(1)", + {sourceOffset: {x: 40, y: 10}, targetOffset: {x: 40, y: 2}} + ); + browser.expect.element("#edit-favourites-list li.operation:nth-of-type(1)").text.to.contain("From Base64"); + + // Drag an entry out of the list entirely, which sets the editor's own removeIntent + browser.expect.elements("#edit-favourites-list li.operation").count.to.equal(11); + utils.fallbackDragAndDrop( + browser, + "#edit-favourites-list li.operation:nth-of-type(11)", + "#favourites-modal .modal-header", + {sourceOffset: {x: 40, y: 10}} + ); + browser.expect.elements("#edit-favourites-list li.operation").count.to.equal(10); + + // The remove icon is a Sortable filter, handled by onFilter rather than by a drag + browser + .click("#edit-favourites-list li.operation:nth-of-type(1) .remove-icon") + .pause(200); + browser.expect.elements("#edit-favourites-list li.operation").count.to.equal(9); + + browser + .click("#save-favourites") + .waitForElementNotVisible("#favourites-modal", 2000) + .pause(400); + + // From Base64 was moved to the top and then deleted with the remove icon; Magic was the + // eleventh entry and was dragged out of the list + utils.expectCategoryOps(browser, "#catFavourites", [ + "To Base64", "To Hex", "From Hex", "To Hexdump", "From Hexdump", + "URL Decode", "Regular expression", "Entropy", "Fork" + ]); + + browser + .waitForElementNotVisible("#snackbar-container", 10000) + .click("#edit-favourites") + .waitForElementVisible("#favourites-modal", 2000) + .click("#reset-favourites") + .waitForElementNotVisible("#favourites-modal", 2000) + .pause(400); + utils.expectCategoryOps(browser, "#catFavourites", [ + "To Base64", "From Base64", "To Hex", "From Hex", "To Hexdump", "From Hexdump", + "URL Decode", "Regular expression", "Entropy", "Fork", "Magic" + ]); + }, + + /** + * T9 (Technique C). File drops cannot be performed by WebDriver at all, so the DataTransfer is + * hand-built. This proves textArgDrop reads the file and fires a statechange; it does not prove + * that a real OS drag reaches the handler. + */ + "Drop a file onto a text argument": browser => { + clearRecipe(browser); + browser + .urlHash("recipe=JWT_Verify('secret')") + .waitForElementVisible("#rec-list li.operation textarea.arg", 3000) + .pause(200); + + utils.syntheticDrop(browser, "#rec-list li.operation textarea.arg", { + files: [utils.makeFile("secret.txt", "text/plain", "mysecret")], + cueSelector: "#rec-list li.operation textarea.arg" + }, observed => { + browser.expect(observed.cueAfterDragover).to.equal(true); + browser.expect(observed.cueAfterDrop).to.equal(false); + }); + + // FileReader is asynchronous, so the value arrives after the drop returns. Nightwatch's + // expect assertions poll, so this waits rather than checking once. + browser.expect.element("#rec-list li.operation textarea.arg").to.have.value.that.equals("mysecret"); + + // The reader also fires a statechange, which is observable in the URL hash once the app's + // state debounce has run + browser.pause(500); + browser.execute(function() { + return decodeURIComponent(window.location.hash); + }, [], function({value}) { + browser.expect(value).to.contain("JWT_Verify('mysecret')"); + }); + + clearRecipe(browser); + }, + + /** + * T10 (Technique C). textArgDrop takes text over a file when both are present. Same caveat as + * T9: handler behaviour only. + */ + "Text beats file on a text argument drop": browser => { + clearRecipe(browser); + browser + .urlHash("recipe=JWT_Verify('secret')") + .waitForElementVisible("#rec-list li.operation textarea.arg", 3000) + .pause(200); + + utils.syntheticDrop(browser, "#rec-list li.operation textarea.arg", { + text: "from the text", + files: [utils.makeFile("secret.txt", "text/plain", "from the file")] + }); + + browser.expect.element("#rec-list li.operation textarea.arg").to.have.value.that.equals("from the text"); + + // Give the FileReader every chance to overwrite it, then check that it did not + browser.pause(500); + browser.expect.element("#rec-list li.operation textarea.arg").to.have.value.that.equals("from the text"); + + clearRecipe(browser); + }, + + /** + * T11 (Technique C). Mirrors the file assertions in 01_io.js, but reached through the drop + * handler rather than the hidden file input. + * + * entryMode "stub" is doing real work here: Chrome returns null from webkitGetAsEntry() for + * every item of a script-built DataTransfer, including file items, so without a stand-in + * FileSystemFileEntry the FileSystemEntry branch of inputDrop cannot be reached from a test at + * all. The stub is the only thing simulated; getAllFileEntries, getFile and loadUIFiles all run + * for real. This does not prove a real OS drag produces the entries the app expects. + */ + "Drop a file onto the input": browser => { + browser.useCss().click("#clr-io").pause(300); + + utils.syntheticDrop(browser, "#input-text", { + files: [utils.sampleFile("files/TowelDay.jpeg", "image/jpeg")], + entryMode: "stub", + cueSelector: "#input-text" + }, observed => { + browser.expect(observed.cueAfterDragover).to.equal(true); + browser.expect(observed.cueAfterDrop).to.equal(false); + }); + + browser + .pause(300) + .waitForElementVisible("#input-text .cm-file-details", 5000) + .waitForElementVisible("#input-text .cm-file-details .file-details-name", 5000); + browser.expect.element("#input-text .cm-file-details .file-details-name").text.that.equals("TowelDay.jpeg"); + browser.expect.element("#input-text .cm-file-details .file-details-size").text.that.equals("61,379 bytes"); + browser.expect.element("#input-text .cm-file-details .file-details-type").text.that.equals("image/jpeg"); + browser.expect.element("#input-text .cm-file-details .file-details-loaded").text.that.equals("100%"); + + browser.click("#clr-io").pause(300); + }, + + /** + * T12 (Technique C). Two files should open two input tabs. + * + * This one uses entryMode "none", which removes webkitGetAsEntry so that inputDrop takes its + * dataTransfer.files fallback branch - the path browsers without the non-standard + * FileSystemEntry API take, which matters for the Safari and Firefox support the suite is + * heading towards. As above, it does not prove a real OS drag reaches the handler. + */ + "Drop multiple files onto the input opens multiple tabs": browser => { + browser.useCss().click("#clr-io").pause(300); + + utils.syntheticDrop(browser, "#input-text", { + files: [ + utils.sampleFile("files/TowelDay.jpeg", "image/jpeg"), + utils.sampleFile("files/Hitchhikers_Guide.jpeg", "image/jpeg") + ], + entryMode: "none" + }); + + browser + .waitForElementVisible("#input-tabs li:nth-of-type(2)", 5000) + .waitForElementVisible("#input-text .cm-file-details", 5000); + browser.expect.elements("#input-tabs li").count.to.equal(2); + + // The tabs themselves are only labelled "Tab n"; the file name for the active tab lives in + // the side panel, so step through them. The files are loaded in parallel, so the order the + // tabs end up in is not guaranteed. + // + // changeTab flips the tab highlight synchronously but then posts to the input worker, and + // the details panel is only rebuilt when that reply arrives in setFile. Waiting for the + // panel to be *visible* therefore proves nothing - the previous tab's panel is still there, + // so the wait passes immediately and the read returns the wrong file. Tag the current panel + // node instead and wait for it to be replaced, which is exactly the worker round trip. + // (The same race exists in the folder upload test at 01_io.js:643, where it is harmless + // only because that test asserts on whichever name it happens to read.) + const names = []; + for (let i = 1; i < 3; i++) { + browser.execute(function() { + const panel = document.querySelector("#input-text .cm-file-details"); + if (panel) panel.dataset.stale = "1"; + }); + + browser + .click(`#input-tabs li:nth-of-type(${i})`) + .waitForElementVisible(`#input-tabs li:nth-of-type(${i}).active-input-tab`, 3000) + .waitForElementVisible("#input-text .cm-file-details:not([data-stale]) .file-details-name", 3000) + .getText("#input-text .cm-file-details:not([data-stale]) .file-details-name", function(result) { + names.push(result.value); + }); + } + browser.perform(function() { + browser.expect(names.slice().sort().join(", ")) + .to.equal("Hitchhikers_Guide.jpeg, TowelDay.jpeg"); + }); + + browser.click("#clr-io").pause(300); + browser.waitForElementNotVisible("#input-tabs-wrapper", 3000); + }, + + /** + * T13 (Technique C). A drop can carry items that are not files: dragging an image out of + * another browser window brings text/html and text/uri-list items along with the file. + * webkitGetAsEntry() returns null for those, and getAllFileEntries used to push the null onto + * its queue and then dereference entry.isFile, throwing a TypeError and loading nothing - not + * even the valid file beside it. Regression guard for the null check in + * InputWaiter.getAllFileEntries. + */ + "Dropping a non-file DataTransfer item on the input does not throw": browser => { + browser.useCss().click("#clr-io").pause(300); + utils.recordPageErrors(browser); + + utils.syntheticDrop(browser, "#input-text", { + // A text/html item rather than text/plain, so inputDrop's "the editor handles text + // itself" early return does not fire and we reach the file handling + strings: [{type: "text/html", data: "dragged from another window"}], + files: [utils.makeFile("dropped.txt", "text/plain", "still loads")], + entryMode: "stub" + }); + + browser.pause(1500); + utils.expectNoPageErrors(browser); + browser.expect.element("#input-text .cm-file-details .file-details-name").text.that.equals("dropped.txt"); + + // Insurance: if the null check in getAllFileEntries is ever dropped, the TypeError raises + // webpack-dev-server's overlay, which would swallow the clicks of every test after this one + utils.dismissDevServerOverlay(browser); + browser.click("#clr-io").pause(300); + }, + + /** + * T14 (Technique C). The mirror of the existing text-argument guard in 00_nightwatch.js, for + * InputWaiter: an operation being dragged must not look like a file drop to the input. + */ + "Dragging an operation over the input is ignored": browser => { + browser.useCss().click("#clr-io").pause(300); + + browser.execute(function() { + window.app.manager.recipe.dragInProgress = true; + }); + utils.syntheticDragEvent(browser, "#input-text", "dragover", {}); + browser.expect.element("#input-text").to.have.attribute("class").which.does.not.contain("dropping-file"); + + // Control: with the flag cleared the same event does add the cue + browser.execute(function() { + window.app.manager.recipe.dragInProgress = false; + }); + utils.syntheticDragEvent(browser, "#input-text", "dragover", {}); + browser.expect.element("#input-text").to.have.attribute("class").which.contains("dropping-file"); + + browser.execute(function() { + document.getElementById("input-text").classList.remove("dropping-file"); + }); + }, + + /** + * T15 (Technique C). inputDragleave only clears the cue when the pointer has actually left + * #input-text, because dragleave fires constantly while moving between CodeMirror lines. The + * legacy e.fromElement property it reads is the event's relatedTarget for drag events. + */ + "Dragleave within the input editor keeps the drop cue": browser => { + browser.useCss().click("#clr-io").pause(300); + + utils.syntheticDragEvent(browser, "#input-text", "dragover", {}); + browser.expect.element("#input-text").to.have.attribute("class").which.contains("dropping-file"); + + // Moving from one node inside the editor to another must not clear it + utils.syntheticDragEvent(browser, "#input-text", "dragleave", {relatedTarget: "#input-text .cm-content"}); + browser.expect.element("#input-text").to.have.attribute("class").which.contains("dropping-file"); + + // Leaving for something outside the editor must + utils.syntheticDragEvent(browser, "#input-text", "dragleave", {relatedTarget: "#operations"}); + browser.expect.element("#input-text").to.have.attribute("class").which.does.not.contain("dropping-file"); + }, + + /** + * T16 (Actions API). Split.js works off plain mouse events, so no special technique is needed. + */ + "Pane splitters can be dragged": browser => { + browser + .useCss() + .waitForElementNotVisible("#snackbar-container", 10000) + .click("#reset-layout") + .pause(300); + + browser.perform(async function() { + /** + * Drags a Split.js gutter and reports the size of the pane before and after. + * + * @param {string} selector - CSS selector matching the gutters + * @param {number} index - Which gutter to drag + * @param {Object} by - How far to drag it, in CSS pixels + * @param {string} paneId - The pane whose size to measure + * @param {string} dimension - "width" or "height" + * @returns {Promise<{before: number, after: number}>} + */ + const dragGutter = async function(selector, index, by, paneId, dimension) { + const before = await browser.execute(function(paneId, dimension) { + return document.getElementById(paneId).getBoundingClientRect()[dimension]; + }, [paneId, dimension]); + + const gutter = await utils.pointFor(browser, selector, null, index); + await browser.driver.actions({async: true}) + .move({x: gutter.x, y: gutter.y, origin: "viewport"}) + .press() + .move({x: gutter.x + by.x / 2, y: gutter.y + by.y / 2, origin: "viewport", duration: 20}) + .move({x: gutter.x + by.x, y: gutter.y + by.y, origin: "viewport", duration: 20}) + .release() + .perform(); + + const after = await browser.execute(function(paneId, dimension) { + return document.getElementById(paneId).getBoundingClientRect()[dimension]; + }, [paneId, dimension]); + return {before: before, after: after}; + }; + + // The first horizontal gutter sits between #operations and #recipe, the second between + // #recipe and #IO + const cols = await dragGutter(".gutter.gutter-horizontal", 0, {x: 100, y: 0}, "operations", "width"); + browser.expect(cols.after).to.be.greaterThan(cols.before + 50); + + const rows = await dragGutter(".gutter.gutter-vertical", 0, {x: 0, y: -100}, "input", "height"); + browser.expect(rows.after).to.be.lessThan(rows.before - 50); + }); + + browser.click("#reset-layout").pause(300); + browser.execute(function() { + return document.getElementById("operations").getBoundingClientRect().width; + }, [], function({value}) { + // resetLayout puts the columns back to 20/30/50 of the 1280px window, less the gutters + browser.expect(value).to.be.within(240, 260); + }); + }, + + /** + * T17 (Technique C, touch events). RecipeWaiter binds touchend on #rec-list to recompute + * removeIntent from elementFromPoint, which is how removal works for touch users. Nothing else + * in the suite goes near it. Synthetic touch events prove the handler's arithmetic, not that a + * real touch drag reaches it. + */ + "Touch drag out of the recipe sets the remove intent": browser => { + clearRecipe(browser); + browser + .urlHash("recipe=To_Base64('A-Za-z0-9%2B/%3D')") + .waitForElementPresent("#rec-list li.operation", 3000) + .pause(200); + + browser.execute(function() { + const recList = document.getElementById("rec-list"), + op = recList.querySelector("li.operation"), + inside = recList.getBoundingClientRect(), + outside = document.getElementById("operations").getBoundingClientRect(); + + /** + * Dispatches a touch sequence ending at the given point. + * + * @param {number} x - clientX of the final touch + * @param {number} y - clientY of the final touch + */ + const touchTo = function(x, y) { + ["touchstart", "touchmove", "touchend"].forEach(function(type) { + const touch = new Touch({identifier: 1, target: op, clientX: x, clientY: y}); + op.dispatchEvent(new TouchEvent(type, { + bubbles: true, + cancelable: true, + changedTouches: [touch], + touches: type === "touchend" ? [] : [touch], + targetTouches: type === "touchend" ? [] : [touch] + })); + }); + }; + + window.app.manager.recipe.removeIntent = false; + touchTo(Math.round(outside.left + outside.width / 2), Math.round(outside.top + outside.height - 60)); + const afterOutside = window.app.manager.recipe.removeIntent; + + touchTo(Math.round(inside.left + inside.width / 2), Math.round(inside.top + 10)); + const afterInside = window.app.manager.recipe.removeIntent; + + window.app.manager.recipe.removeIntent = false; + return {afterOutside: afterOutside, afterInside: afterInside}; + }, [], function({value}) { + browser.expect(value.afterOutside).to.equal(true); + browser.expect(value.afterInside).to.equal(false); + }); + + clearRecipe(browser); + }, + + after: browser => { + browser.end(); + } +}; diff --git a/tests/browser/browserUtils.js b/tests/browser/browserUtils.js index 0941fe10..ef3a49ee 100644 --- a/tests/browser/browserUtils.js +++ b/tests/browser/browserUtils.js @@ -273,6 +273,660 @@ function uploadFolder(browser, foldername) { } +/** + * Bitmask of the drag operations a dispatched CDP drag will allow. + * + * Blink's DragOperation bits are Copy=1, Link=2, Generic=4, Private=8, Move=16. SortableJS sets + * `dropEffect = "move"` on every dragover it accepts, so a mask that omits bit 16 makes Chromium + * resolve the drag to "no operation" and silently swallow the drop - it sends dragleave/dragend + * instead. Allow everything. + */ +const DRAG_OPERATIONS_ALL = 31; + +/** @function + * Reports whether the Chrome DevTools Protocol is reachable from this session. + * + * Technique A (native drag via CDP) is Chrome-only: `safaridriver` and `geckodriver` expose no CDP + * endpoint. Tests that need it should check this in `before` and skip themselves rather than fail. + * + * @param {Browser} browser - Nightwatch client + * @param {function} callback - Called with `true` if CDP commands can be sent + */ +function cdpAvailable(browser, callback) { + browser.perform(async function() { + if (typeof browser.driver?.sendAndGetDevToolsCommand !== "function") { + callback(false); + return; + } + try { + await browser.driver.sendAndGetDevToolsCommand("Browser.getVersion", {}); + callback(true); + } catch (err) { + callback(false); + } + }); +} + +/** @function + * Sends a one-way Chrome DevTools Protocol command. + * + * @param {Browser} browser - Nightwatch client + * @param {string} command - CDP method name, e.g. "Input.dispatchDragEvent" + * @param {Object} params - CDP method parameters + * @returns {Promise} + */ +function cdp(browser, command, params) { + return browser.driver.sendAndGetDevToolsCommand(command, params); +} + +/** @function + * Resolves a selector to a viewport point, optionally offset from the element's top left corner. + * + * @param {Browser} browser - Nightwatch client + * @param {string} selector - CSS selector for the element + * @param {Object} [offset] - Offset in CSS pixels from the element's top left corner. Defaults to + * the centre of the element. + * @param {number} [index=0] - Which match to use, when the selector matches several elements + * @returns {Promise<{x: number, y: number}>} + */ +async function pointFor(browser, selector, offset, index) { + return await browser.execute(function(selector, offset, index) { + const el = document.querySelectorAll(selector)[index || 0]; + if (!el) throw new Error("No element matches " + selector); + const r = el.getBoundingClientRect(); + return { + x: Math.round(offset ? r.left + offset.x : r.left + r.width / 2), + y: Math.round(offset ? r.top + offset.y : r.top + r.height / 2) + }; + }, [selector, offset || null, index || 0]); +} + +/** + * Set by nativeDragStart and cleared once the drag has been torn down, so that nativeDragCancel can + * tell whether there is anything to undo. + */ +let nativeDragActive = false; + +/** @function + * Technique A, step 1. Begins a genuine native HTML5 drag on an element. + * + * Chromium will not synthesise native drag events from WebDriver mouse input, so we drive it with + * CDP instead: `Input.setInterceptDrags` hands the drag over to us, and a press plus a few moves on + * the source is enough for the renderer to fire a real `dragstart`. That means SortableJS's own + * `onStart` and `setData` run for real - we capture what `setData` wrote by patching + * `DataTransfer.prototype.setData` for the duration of the drag, which saves us needing a + * bidirectional CDP session to receive `Input.dragIntercepted`. + * + * Must be paired with nativeDrop or nativeDragCancel so that interception is turned back off. + * + * @param {Browser} browser - Nightwatch client + * @param {string} sourceSelector - CSS selector for the element to drag + * @param {Object} [offset] - Offset from the source element's top left corner + */ +function nativeDragStart(browser, sourceSelector, offset) { + browser.perform(async function() { + nativeDragActive = true; + + await browser.execute(function() { + window.__dragPayload = null; + // A drag that was never torn down leaves its wrapper in place. Saving that as the + // original would make the new wrapper call itself, so only ever save the real one. + if (!window.__origSetData) window.__origSetData = DataTransfer.prototype.setData; + DataTransfer.prototype.setData = function(format, data) { + window.__dragPayload = {format: format, data: data}; + return window.__origSetData.call(this, format, data); + }; + }); + + const from = await pointFor(browser, sourceSelector, offset); + await cdp(browser, "Input.setInterceptDrags", {enabled: true}); + + const mouse = (type, x, y, buttons) => cdp(browser, "Input.dispatchMouseEvent", { + type: type, x: x, y: y, button: "left", buttons: buttons, clickCount: 1 + }); + + await mouse("mousePressed", from.x, from.y, 1); + // Sortable ignores a single jump: it needs the pointer to actually travel + for (let i = 1; i <= 6; i++) { + await mouse("mouseMoved", from.x + i * 4, from.y + i * 3, 1); + } + + // Remember where the drag is, so the first nativeDragOver travels from the source rather + // than teleporting to the target. Anything that reacts to crossing a boundary - the recipe + // list's dragleave, for one - only fires if the drag actually passes over the edge. + await browser.execute(function(pt) { + window.__dragLast = pt; + }, [{x: from.x + 24, y: from.y + 18}]); + + const payload = await browser.execute(function() { + return window.__dragPayload; + }); + // Stash the DragData the rest of the drag will carry. This is the real payload Sortable's + // setData wrote, so drop handlers that read dataTransfer see exactly what the app produced. + await browser.execute(function(data, mask) { + window.__dragData = { + items: [{mimeType: "text/plain", data: data}], + files: [], + dragOperationsMask: mask + }; + }, [(payload && payload.data) || "", DRAG_OPERATIONS_ALL]); + }); +} + +/** @function + * Technique A, step 2. Drags the in-progress native drag over a target, in several steps. + * + * @param {Browser} browser - Nightwatch client + * @param {string} targetSelector - CSS selector for the element to drag over + * @param {Object} [offset] - Offset from the target element's top left corner + * @param {number} [steps=6] - Number of intermediate dragOver events to emit + */ +function nativeDragOver(browser, targetSelector, offset, steps=6) { + browser.perform(async function() { + const to = await pointFor(browser, targetSelector, offset); + const data = await browser.execute(function() { + return window.__dragData; + }); + const from = await browser.execute(function() { + return window.__dragLast || null; + }); + if (!from) throw new Error("nativeDragOver called without a drag in progress"); + const start = from; + + await cdp(browser, "Input.dispatchDragEvent", {type: "dragEnter", x: start.x, y: start.y, data: data}); + for (let i = 1; i <= steps; i++) { + await cdp(browser, "Input.dispatchDragEvent", { + type: "dragOver", + x: Math.round(start.x + (to.x - start.x) * i / steps), + y: Math.round(start.y + (to.y - start.y) * i / steps), + data: data + }); + } + // Chromium turns the first dragOver over a new element into a dragenter on it plus a + // dragleave on the one before, and no dragover at all. Anything listening for dragover - + // the recipe list clearing removeIntent, or Sortable accepting the drop - would never hear + // about the last step of the journey, so settle with a couple more at the same point. + for (let i = 0; i < 2; i++) { + await cdp(browser, "Input.dispatchDragEvent", {type: "dragOver", x: to.x, y: to.y, data: data}); + } + await browser.execute(function(pt) { + window.__dragLast = pt; + }, [to]); + }); +} + +/** @function + * Technique A, step 3. Drops the in-progress native drag at the current position and stops + * intercepting drags. + * + * @param {Browser} browser - Nightwatch client + * @param {string} [targetSelector] - Optional selector to drop on. Defaults to wherever the last + * nativeDragOver left the pointer. + * @param {Object} [offset] - Offset from the target element's top left corner + */ +function nativeDrop(browser, targetSelector, offset) { + browser.perform(async function() { + let to; + try { + to = targetSelector ? + await pointFor(browser, targetSelector, offset) : + await browser.execute(function() { + return window.__dragLast; + }); + const data = await browser.execute(function() { + return window.__dragData; + }); + + await cdp(browser, "Input.dispatchDragEvent", {type: "drop", x: to.x, y: to.y, data: data}); + } finally { + await endNativeDrag(browser, to); + } + }); +} + +/** @function + * Technique A. Abandons the native drag nativeDragStart began, if one is still in progress, and + * turns drag interception back off. The page sees the drag end without a drop - dragleave then + * dragend, as it would if the user pressed Escape. + * + * Input.dispatchDragEvent documents a "cancel" type, but Chrome rejects it as an unexpected event + * type. Instead this drops with no operations allowed, which Chromium resolves as it does any drop + * it cannot perform (see DRAG_OPERATIONS_ALL): no drop event, just dragleave and dragend. + * + * Safe to call at any time and any number of times: with no drag in progress it does nothing. That + * makes it fit for an afterEach hook, which is where it is needed - a test that fails between + * nativeDragStart and nativeDrop never reaches the drop, and without this the interception and the + * setData patch would outlive it into whatever runs next, a retry of the same test included. + * + * @param {Browser} browser - Nightwatch client + */ +function nativeDragCancel(browser) { + browser.perform(async function() { + if (!nativeDragActive) return; + + let at = null; + try { + at = await browser.execute(function() { + return window.__dragLast; + }); + const data = await browser.execute(function() { + return window.__dragData; + }); + // nativeDragStart may have failed before recording a position or payload + const point = at || {x: 0, y: 0}; + await cdp(browser, "Input.dispatchDragEvent", { + type: "drop", x: point.x, y: point.y, + data: Object.assign({items: []}, data, {dragOperationsMask: 0}) + }); + } catch (err) { + // Only likely if the page or the CDP session has gone. The teardown still has to run. + } finally { + await endNativeDrag(browser, at); + } + }); +} + +/** + * Shared by nativeDrop and nativeDragCancel. Releases the mouse button nativeDragStart pressed, + * turns drag interception off and restores DataTransfer.prototype.setData. + * + * Every step is attempted even if an earlier one fails, and nothing is thrown: this runs after a + * failure as often as not, and should neither mask that failure nor leave the job half done. + * __dragPayload is deliberately kept, because expectDragPayload reads it after the drop. + * + * @param {Browser} browser - Nightwatch client + * @param {{x: number, y: number}} [at] - Where to release the mouse. Defaults to the viewport origin. + * @returns {Promise} + */ +async function endNativeDrag(browser, at) { + nativeDragActive = false; + const point = at || {x: 0, y: 0}; + const attempt = async step => { + try { + await step(); + } catch (err) { + // Carry on with the next step regardless + } + }; + + await attempt(() => cdp(browser, "Input.dispatchMouseEvent", { + type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1 + })); + await attempt(() => cdp(browser, "Input.setInterceptDrags", {enabled: false})); + await attempt(() => browser.execute(function() { + if (window.__origSetData) DataTransfer.prototype.setData = window.__origSetData; + window.__origSetData = null; + window.__dragLast = null; + window.__dragData = null; + })); +} + +/** @function + * Technique A. Performs a complete native HTML5 drag and drop, exercising the browser's own drag + * implementation and the real dataTransfer contents. + * + * @param {Browser} browser - Nightwatch client + * @param {string} sourceSelector - CSS selector for the element to drag + * @param {string} targetSelector - CSS selector for the element to drop onto + * @param {Object} [targetOffset] - Offset from the target element's top left corner + * @param {Object} [sourceOffset] - Offset from the source element's top left corner + */ +function nativeDragAndDrop(browser, sourceSelector, targetSelector, targetOffset, sourceOffset) { + nativeDragStart(browser, sourceSelector, sourceOffset); + nativeDragOver(browser, targetSelector, targetOffset); + nativeDrop(browser, targetSelector, targetOffset); + browser.pause(200); +} + +/** @function + * Returns the payload SortableJS wrote into the dataTransfer during the last native drag. + * + * @param {Browser} browser - Nightwatch client + * @param {string} expected - The expected payload + */ +function expectDragPayload(browser, expected) { + browser.execute(function() { + return window.__dragPayload; + }, [], function({value}) { + browser.expect(value && value.data).to.equal(expected); + }); +} + +/** @function + * Switches a SortableJS list into its pointer-event fallback mode, for Technique B. + * + * CyberChef does not export Sortable onto `window`, but every Sortable instance stores itself on its + * element under a `Sortable` own property, which is enough to reach `option()`. + * + * @param {Browser} browser - Nightwatch client + * @param {string} selector - CSS selector for the Sortable list + * @param {boolean} enabled - Whether to force the fallback path + */ +function setForceFallback(browser, selector, enabled) { + browser.execute(function(selector, enabled) { + const el = document.querySelector(selector); + const key = Object.keys(el).find(k => k.indexOf("Sortable") === 0); + if (!key) throw new Error("No Sortable instance found on " + selector); + el[key].option("forceFallback", enabled); + return true; + }, [selector, enabled]); +} + +/** @function + * Technique B. Drags one element onto another using SortableJS's pointer-event fallback path and a + * real WebDriver pointer, driven as a single Actions chain so the button stays down throughout. + * + * This exercises Sortable's real reordering logic and the real DOM outcome, but through the fallback + * code path rather than native HTML5 drag and drop. It does not populate a `dataTransfer`, so it + * cannot cover anything that reads one. It is, however, the code path touch users get. + * + * @param {Browser} browser - Nightwatch client + * @param {string} sourceSelector - CSS selector for the element to drag + * @param {string} targetSelector - CSS selector for the element to drop onto + * @param {Object} [opts] + * @param {Object} [opts.sourceOffset] - Offset from the source element's top left corner + * @param {Object} [opts.targetOffset] - Offset from the target element's top left corner + * @param {number} [opts.steps=12] - Number of intermediate pointer moves + */ +function fallbackDragAndDrop(browser, sourceSelector, targetSelector, opts) { + const options = opts || {}; + browser.perform(async function() { + const from = await pointFor(browser, sourceSelector, options.sourceOffset); + const to = await pointFor(browser, targetSelector, options.targetOffset); + const steps = options.steps || 12; + + let chain = browser.driver.actions({async: true}) + .move({x: from.x, y: from.y, origin: "viewport"}) + .press(); + for (let i = 1; i <= steps; i++) { + chain = chain.move({ + x: Math.round(from.x + (to.x - from.x) * i / steps), + y: Math.round(from.y + (to.y - from.y) * i / steps), + origin: "viewport", + duration: 20 + }); + } + await chain.release().perform(); + }); + browser.pause(300); +} + +/** @function + * Builds a descriptor for a File to be constructed inside the page by syntheticDrop. + * + * A File object cannot cross the WebDriver boundary, so we pass its ingredients instead. Note this + * differs from the signature sketched in the plan (no `browser` argument) because the File has to be + * built inside the same `execute` block as the DataTransfer that carries it. + * + * @param {string} name - File name + * @param {string} type - MIME type + * @param {string} contents - File contents, base64 if `base64` is set + * @param {boolean} [base64=false] - Whether `contents` is base64 encoded + * @returns {Object} + */ +function makeFile(name, type, contents, base64=false) { + return {name: name, type: type, contents: contents, base64: !!base64}; +} + +/** @function + * Builds a File descriptor from a file in the tests/samples directory. + * + * @param {string} filename - Path relative to tests/samples, e.g. "files/TowelDay.jpeg" + * @param {string} type - MIME type + * @returns {Object} + */ +function sampleFile(filename, type) { + const fs = require("fs"), + path = require("path"), + buf = fs.readFileSync(path.resolve(__dirname + "/../samples/" + filename)); + return makeFile(path.basename(filename), type, buf.toString("base64"), true); +} + +/** + * The body of syntheticDrop, serialised into the page. Kept as a named function so the JSDoc above + * syntheticDrop can describe the options in one place. + * + * @param {string} selector - Element to dispatch the events on + * @param {Object} opts - See syntheticDrop + * @returns {Object} Observations made between the dragover and the drop + */ +function syntheticDropInPage(selector, opts) { + const el = document.querySelector(selector); + if (!el) throw new Error("No element matches " + selector); + + const dt = new DataTransfer(); + if (opts.text) dt.setData("Text", opts.text); + (opts.strings || []).forEach(s => dt.items.add(s.data, s.type)); + (opts.files || []).forEach(f => { + let body; + if (f.base64) { + const bin = atob(f.contents), + bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + body = bytes; + } else { + body = f.contents; + } + dt.items.add(new File([body], f.name, {type: f.type})); + }); + + // Chrome returns null from webkitGetAsEntry() for every item of a script-built DataTransfer, + // including file items, so the FileSystemEntry branch of InputWaiter.inputDrop is unreachable + // without help. entryMode says what to do about it: + // "raw" - leave it alone; what a script-built DataTransfer really does + // "stub" - return a minimal FileSystemFileEntry for file items and null for string items, + // which is what a real OS drag produces + // "none" - remove webkitGetAsEntry entirely, so the code takes its dataTransfer.files + // fallback branch (the path Firefox and Safari take) + const proto = DataTransferItem.prototype, + origDesc = Object.getOwnPropertyDescriptor(proto, "webkitGetAsEntry"); + if (opts.entryMode === "stub") { + proto.webkitGetAsEntry = function() { + if (this.kind !== "file") return null; + const file = this.getAsFile(); + return { + isFile: true, + isDirectory: false, + name: file.name, + file: function(resolve) { + resolve(file); + } + }; + }; + } else if (opts.entryMode === "none") { + delete proto.webkitGetAsEntry; + } + + const rect = el.getBoundingClientRect(), + init = { + bubbles: true, + cancelable: true, + dataTransfer: dt, + clientX: Math.round(rect.left + rect.width / 2), + clientY: Math.round(rect.top + rect.height / 2) + }; + + const observed = {}; + if (opts.dragover !== false) { + el.dispatchEvent(new DragEvent("dragover", init)); + observed.cueSelector = opts.cueSelector || null; + if (opts.cueSelector) { + const cueEl = document.querySelector(opts.cueSelector); + observed.cueAfterDragover = !!cueEl && cueEl.classList.contains("dropping-file"); + } + } + if (opts.drop !== false) { + el.dispatchEvent(new DragEvent("drop", init)); + if (opts.cueSelector) { + const cueEl = document.querySelector(opts.cueSelector); + observed.cueAfterDrop = !!cueEl && cueEl.classList.contains("dropping-file"); + } + } + + // Put the prototype back however we found it, even if the handler threw + if (opts.entryMode === "stub" || opts.entryMode === "none") { + if (origDesc) { + Object.defineProperty(proto, "webkitGetAsEntry", origDesc); + } else { + delete proto.webkitGetAsEntry; + } + } + return observed; +} + +/** @function + * Technique C. Dispatches a synthetic dragover/drop pair carrying a hand-built DataTransfer. + * + * This proves the app's handlers behave, not that the browser will ever call them. It is the only + * option for file drops - WebDriver cannot perform an OS-level file drop at all - and for asserting + * on cue classes and on the dragInProgress guards. + * + * @param {Browser} browser - Nightwatch client + * @param {string} targetSelector - Element to dispatch the events on. The listener may be on an + * ancestor; the events bubble. + * @param {Object} opts + * @param {string} [opts.text] - Text payload, set as "Text" + * @param {Array<{type: string, data: string}>} [opts.strings] - Extra string items + * @param {Array} [opts.files] - File descriptors from makeFile or sampleFile + * @param {string} [opts.entryMode="raw"] - "raw", "stub" or "none"; see syntheticDropInPage + * @param {string} [opts.cueSelector] - Element to check for the ".dropping-file" cue class + * @param {boolean} [opts.dragover=true] - Whether to dispatch the dragover + * @param {boolean} [opts.drop=true] - Whether to dispatch the drop + * @param {function} [callback] - Receives the observations made around the drop + */ +function syntheticDrop(browser, targetSelector, opts, callback) { + browser.execute(syntheticDropInPage, [targetSelector, opts || {}], function({value}) { + if (callback) callback(value); + }); +} + +/** @function + * Dispatches a single synthetic drag event, for the cases where only one half of the interaction + * matters. + * + * @param {Browser} browser - Nightwatch client + * @param {string} targetSelector - Element to dispatch the event on + * @param {string} type - Event type, e.g. "dragover", "dragleave" + * @param {Object} [opts] + * @param {string} [opts.text] - Text payload, set as "Text" + * @param {string} [opts.relatedTarget] - Selector for the event's relatedTarget, which is what + * the legacy `e.fromElement` property reads for drag events + */ +function syntheticDragEvent(browser, targetSelector, type, opts) { + browser.execute(function(selector, type, opts) { + const el = document.querySelector(selector), + dt = new DataTransfer(); + if (opts.text) dt.setData("Text", opts.text); + el.dispatchEvent(new DragEvent(type, { + bubbles: true, + cancelable: true, + dataTransfer: dt, + relatedTarget: opts.relatedTarget ? document.querySelector(opts.relatedTarget) : null + })); + }, [targetSelector, type, opts || {}]); +} + +/** @function + * Asserts the state of the recipe's dragInProgress flag. + * + * @param {Browser} browser - Nightwatch client + * @param {boolean} expected - The expected value + */ +function dragInProgress(browser, expected) { + browser.execute(function() { + return window.app.manager.recipe.dragInProgress; + }, [], function({value}) { + browser.expect(value).to.equal(expected); + }); +} + +/** @function + * Asserts the state of the recipe's removeIntent flag. + * + * @param {Browser} browser - Nightwatch client + * @param {boolean} expected - The expected value + */ +function removeIntent(browser, expected) { + browser.execute(function() { + return window.app.manager.recipe.removeIntent; + }, [], function({value}) { + browser.expect(value).to.equal(expected); + }); +} + +/** @function + * Asserts the operations currently in the recipe, in order. + * + * @param {Browser} browser - Nightwatch client + * @param {Array} opNames - The expected operation titles, top to bottom + */ +function expectRecipeOrder(browser, opNames) { + browser.execute(function() { + return Array.from(document.querySelectorAll("#rec-list li.operation .op-title"), el => el.textContent.trim()); + }, [], function({value}) { + browser.expect(value.join(" | ")).to.equal(opNames.join(" | ")); + }); +} + +/** @function + * Asserts the operations currently under a category, in order. + * + * @param {Browser} browser - Nightwatch client + * @param {string} catSelector - CSS selector for the category's op list, e.g. "#catFavourites" + * @param {Array} opNames - The expected operation names, top to bottom + */ +function expectCategoryOps(browser, catSelector, opNames) { + browser.execute(function(catSelector) { + return Array.from(document.querySelectorAll(catSelector + " li.operation"), el => el.textContent.trim()); + }, [catSelector], function({value}) { + browser.expect(value.join(" | ")).to.equal(opNames.join(" | ")); + }); +} + +/** @function + * Starts recording uncaught errors and unhandled rejections in the page. + * + * @param {Browser} browser - Nightwatch client + */ +function recordPageErrors(browser) { + browser.execute(function() { + window.__pageErrors = []; + window.addEventListener("error", e => window.__pageErrors.push(String(e.message))); + window.addEventListener("unhandledrejection", e => window.__pageErrors.push(String(e.reason))); + }); +} + +/** @function + * Asserts that nothing has been recorded by recordPageErrors. + * + * @param {Browser} browser - Nightwatch client + */ +function expectNoPageErrors(browser) { + browser.execute(function() { + return window.__pageErrors || []; + }, [], function({value}) { + browser.expect(value.join("; ")).to.equal(""); + }); +} + +/** @function + * Removes webpack-dev-server's error overlay if it is showing. + * + * `npm run testuidev` serves the app through webpack-dev-server, which throws a full-viewport iframe + * over the page as soon as anything in the app raises an uncaught error. It swallows every + * subsequent click, so a test that deliberately provokes an app error has to clear it before the + * next test runs. The overlay does not exist in the production build `npx grunt testui` serves. + * + * @param {Browser} browser - Nightwatch client + */ +function dismissDevServerOverlay(browser) { + browser.execute(function() { + const overlay = document.getElementById("webpack-dev-server-client-overlay"); + if (overlay) overlay.remove(); + }); +} + + module.exports = { clear: clear, setInput: setInput, @@ -285,5 +939,26 @@ module.exports = { expectOutput: expectOutput, expectInput: expectInput, uploadFile: uploadFile, - uploadFolder: uploadFolder + uploadFolder: uploadFolder, + cdpAvailable: cdpAvailable, + pointFor: pointFor, + nativeDragStart: nativeDragStart, + nativeDragOver: nativeDragOver, + nativeDrop: nativeDrop, + nativeDragCancel: nativeDragCancel, + nativeDragAndDrop: nativeDragAndDrop, + expectDragPayload: expectDragPayload, + setForceFallback: setForceFallback, + fallbackDragAndDrop: fallbackDragAndDrop, + makeFile: makeFile, + sampleFile: sampleFile, + syntheticDrop: syntheticDrop, + syntheticDragEvent: syntheticDragEvent, + dragInProgress: dragInProgress, + removeIntent: removeIntent, + expectRecipeOrder: expectRecipeOrder, + expectCategoryOps: expectCategoryOps, + recordPageErrors: recordPageErrors, + expectNoPageErrors: expectNoPageErrors, + dismissDevServerOverlay: dismissDevServerOverlay };