Add drag drop browser tests (#2795)

This commit is contained in:
Leon Zandman
2026-09-26 12:25:43 +01:00
committed by GitHub
parent e517cf52dc
commit d0267c3cf7
3 changed files with 1455 additions and 2 deletions
+4 -1
View File
@@ -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();
+775
View File
@@ -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 [[email protected]]
* @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 <a>.
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: "<b>dragged from another window</b>"}],
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();
}
};
+676 -1
View File
@@ -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<timestamp>` 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<Object>} [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<string>} 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<string>} 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
};