mirror of
https://github.com/gchq/CyberChef.git
synced 2026-10-12 02:13:56 +00:00
Add drag drop browser tests (#2795)
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user