Selaa lähdekoodia

Added initial input and output UI tests

n1474335 2 vuotta sitten
vanhempi
commit
05160227a3
4 muutettua tiedostoa jossa 503 lisäystä ja 7 poistoa
  1. 11 4
      src/core/Utils.mjs
  2. 5 2
      src/web/utils/editorUtils.mjs
  3. 486 0
      tests/browser/io.js
  4. 1 1
      tests/browser/ops.js

+ 11 - 4
src/core/Utils.mjs

@@ -835,6 +835,11 @@ class Utils {
      * Escapes HTML tags in a string to stop them being rendered.
      * https://www.owasp.org/index.php/XSS_(Cross_Site_Scripting)_Prevention_Cheat_Sheet
      *
+     * Null bytes are a special case and are converted to a character from the Unicode
+     * Private Use Area, which CyberChef will display as a control character picture.
+     * This is done due to null bytes not being rendered or stored correctly in HTML
+     * DOM building.
+     *
      * @param {string} str
      * @returns string
      *
@@ -849,10 +854,11 @@ class Utils {
             ">": ">",
             '"': """,
             "'": "'", // ' not recommended because it's not in the HTML spec
-            "`": "`"
+            "`": "`",
+            "\u0000": "\ue000"
         };
 
-        return str ? str.replace(/[&<>"'`]/g, function (match) {
+        return str ? str.replace(/[&<>"'`\u0000]/g, function (match) {
             return HTML_CHARS[match];
         }) : str;
     }
@@ -876,10 +882,11 @@ class Utils {
             "&quot;": '"',
             "&#x27;": "'",
             "&#x2F;": "/",
-            "&#x60;": "`"
+            "&#x60;": "`",
+            "\ue000": "\u0000"
         };
 
-        return str.replace(/&#?x?[a-z0-9]{2,4};/ig, function (match) {
+        return str.replace(/(&#?x?[a-z0-9]{2,4};|\ue000)/ig, function (match) {
             return HTML_CHARS[match] || match;
         });
     }

+ 5 - 2
src/web/utils/editorUtils.mjs

@@ -25,6 +25,9 @@ const Names = {
     8232: "line separator",
     8237: "left-to-right override",
     8238: "right-to-left override",
+    8294: "left-to-right isolate",
+    8295: "right-to-left isolate",
+    8297: "pop directional isolate",
     8233: "paragraph separator",
     65279: "zero width no-break space",
     65532: "object replacement"
@@ -32,13 +35,13 @@ const Names = {
 
 // Regex for Special Characters to be replaced
 const UnicodeRegexpSupport = /x/.unicode != null ? "gu" : "g";
-const Specials = new RegExp("[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\ufeff\ufff9-\ufffc\ue000-\uf8ff]", UnicodeRegexpSupport);
+const Specials = new RegExp("[\u0000-\u0008\u000a-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc\ue000-\uf8ff]", UnicodeRegexpSupport);
 
 
 /**
  * Override for rendering special characters.
  * Should mirror the toDOM function in
- * https://github.com/codemirror/view/blob/main/src/special-chars.ts#L150
+ * https://github.com/codemirror/view/blob/main/src/special-chars.ts#L153
  * But reverts the replacement of line feeds with newline control pictures.
  *
  * @param {number} code

+ 486 - 0
tests/browser/io.js

@@ -0,0 +1,486 @@
+/**
+ * Tests for input and output of various types to ensure the editors work as expected
+ * and retain data integrity, especially when it comes to special characters.
+ *
+ * @author n1474335 [n1474335@gmail.com]
+ * @copyright Crown Copyright 2023
+ * @license Apache-2.0
+ */
+
+const SPECIAL_CHARS = [
+    "\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009\u000a\u000b\u000c\u000d\u000e\u000f",
+    "\u0010\u0011\u0012\u0013\u0014\u0015\u0016\u0017\u0018\u0019\u001a\u001b\u001c\u001d\u001e\u001f",
+    "\u007f",
+    "\u0080\u0081\u0082\u0083\u0084\u0085\u0086\u0087\u0088\u0089\u008a\u008b\u008c\u008d\u008e\u008f",
+    "\u0090\u0091\u0092\u0093\u0094\u0095\u0096\u0097\u0098\u0099\u009a\u009b\u009c\u009d\u009e\u009f",
+    "\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9\ufffa\ufffb\ufffc"
+].join("");
+
+const ALL_BYTES = [
+    "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f",
+    "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f",
+    "\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f",
+    "\x30\x31\x32\x33\x34\x35\x36\x37\x38\x39\x3a\x3b\x3c\x3d\x3e\x3f",
+    "\x40\x41\x42\x43\x44\x45\x46\x47\x48\x49\x4a\x4b\x4c\x4d\x4e\x4f",
+    "\x50\x51\x52\x53\x54\x55\x56\x57\x58\x59\x5a\x5b\x5c\x5d\x5e\x5f",
+    "\x60\x61\x62\x63\x64\x65\x66\x67\x68\x69\x6a\x6b\x6c\x6d\x6e\x6f",
+    "\x70\x71\x72\x73\x74\x75\x76\x77\x78\x79\x7a\x7b\x7c\x7d\x7e\x7f",
+    "\x80\x81\x82\x83\x84\x85\x86\x87\x88\x89\x8a\x8b\x8c\x8d\x8e\x8f",
+    "\x90\x91\x92\x93\x94\x95\x96\x97\x98\x99\x9a\x9b\x9c\x9d\x9e\x9f",
+    "\xa0\xa1\xa2\xa3\xa4\xa5\xa6\xa7\xa8\xa9\xaa\xab\xac\xad\xae\xaf",
+    "\xb0\xb1\xb2\xb3\xb4\xb5\xb6\xb7\xb8\xb9\xba\xbb\xbc\xbd\xbe\xbf",
+    "\xc0\xc1\xc2\xc3\xc4\xc5\xc6\xc7\xc8\xc9\xca\xcb\xcc\xcd\xce\xcf",
+    "\xd0\xd1\xd2\xd3\xd4\xd5\xd6\xd7\xd8\xd9\xda\xdb\xdc\xdd\xde\xdf",
+    "\xe0\xe1\xe2\xe3\xe4\xe5\xe6\xe7\xe8\xe9\xea\xeb\xec\xed\xee\xef",
+    "\xf0\xf1\xf2\xf3\xf4\xf5\xf6\xf7\xf8\xf9\xfa\xfb\xfc\xfd\xfe\xff",
+].join("");
+
+const PUA_CHARS = "\ue000\ue001\uf8fe\uf8ff";
+
+// Descriptions for named control characters
+const CONTROL_CHAR_NAMES = {
+    0: "null",
+    7: "bell",
+    8: "backspace",
+    10: "line feed",
+    11: "vertical tab",
+    13: "carriage return",
+    27: "escape",
+    8203: "zero width space",
+    8204: "zero width non-joiner",
+    8205: "zero width joiner",
+    8206: "left-to-right mark",
+    8207: "right-to-left mark",
+    8232: "line separator",
+    8237: "left-to-right override",
+    8238: "right-to-left override",
+    8294: "left-to-right isolate",
+    8295: "right-to-left isolate",
+    8297: "pop directional isolate",
+    8233: "paragraph separator",
+    65279: "zero width no-break space",
+    65532: "object replacement"
+};
+
+module.exports = {
+    before: browser => {
+        browser
+            .resizeWindow(1280, 800)
+            .url(browser.launchUrl)
+            .useCss()
+            .waitForElementNotPresent("#preloader", 10000)
+            .click("#auto-bake-label");
+    },
+
+    "CodeMirror has loaded correctly": browser => {
+        /* Editor has initialised */
+        browser
+            .useCss()
+            // Input
+            .waitForElementVisible("#input-text")
+            .waitForElementVisible("#input-text .cm-editor")
+            .waitForElementVisible("#input-text .cm-editor .cm-scroller")
+            .waitForElementVisible("#input-text .cm-editor .cm-scroller .cm-content")
+            .waitForElementVisible("#input-text .cm-editor .cm-scroller .cm-content .cm-line")
+            // Output
+            .waitForElementVisible("#output-text")
+            .waitForElementVisible("#output-text .cm-editor")
+            .waitForElementVisible("#output-text .cm-editor .cm-scroller")
+            .waitForElementVisible("#output-text .cm-editor .cm-scroller .cm-content")
+            .waitForElementVisible("#output-text .cm-editor .cm-scroller .cm-content .cm-line");
+
+        /* Status bar is showing and has correct values */
+        browser // Input
+            .waitForElementVisible("#input-text .cm-status-bar")
+            .waitForElementVisible("#input-text .cm-status-bar .stats-length-value")
+            .expect.element("#input-text .cm-status-bar .stats-length-value").text.to.equal("0");
+        browser.waitForElementVisible("#input-text .cm-status-bar .stats-lines-value")
+            .expect.element("#input-text .cm-status-bar .stats-lines-value").text.to.equal("1");
+        browser.waitForElementVisible("#input-text .cm-status-bar .chr-enc-value")
+            .expect.element("#input-text .cm-status-bar .chr-enc-value").text.to.equal("Raw Bytes");
+        browser.waitForElementVisible("#input-text .cm-status-bar .eol-value")
+            .expect.element("#input-text .cm-status-bar .eol-value").text.to.equal("LF");
+
+        browser // Output
+            .waitForElementVisible("#output-text .cm-status-bar")
+            .waitForElementVisible("#output-text .cm-status-bar .stats-length-value")
+            .expect.element("#output-text .cm-status-bar .stats-length-value").text.to.equal("0");
+        browser.waitForElementVisible("#output-text .cm-status-bar .stats-lines-value")
+            .expect.element("#output-text .cm-status-bar .stats-lines-value").text.to.equal("1");
+        browser.waitForElementVisible("#output-text .cm-status-bar .baking-time-info")
+            .expect.element("#output-text .cm-status-bar .baking-time-info").text.to.contain("ms");
+        browser.waitForElementVisible("#output-text .cm-status-bar .chr-enc-value")
+            .expect.element("#output-text .cm-status-bar .chr-enc-value").text.to.equal("Raw Bytes");
+        browser.waitForElementVisible("#output-text .cm-status-bar .eol-value")
+            .expect.element("#output-text .cm-status-bar .eol-value").text.to.equal("LF");
+    },
+
+    "Adding content": browser => {
+        /* Status bar updates correctly */
+        setInput(browser, `"You know," said Arthur, "it's at times like this, when I'm trapped in a Vogon airlock with a man from Betelgeuse, and about to die of asphyxiation in deep space that I really wish I'd listened to what my mother told me when I was young."
+"Why, what did she tell you?"
+"I don't know, I didn't listen."`);
+
+        browser.expect.element("#input-text .cm-status-bar .stats-length-value").text.to.equal("301");
+        browser.expect.element("#input-text .cm-status-bar .stats-lines-value").text.to.equal("3");
+        browser.expect.element("#input-text .cm-status-bar .chr-enc-value").text.to.equal("Raw Bytes");
+        browser.expect.element("#input-text .cm-status-bar .eol-value").text.to.equal("LF");
+
+        browser.expect.element("#output-text .cm-status-bar .stats-length-value").text.to.equal("0");
+        browser.expect.element("#output-text .cm-status-bar .stats-lines-value").text.to.equal("1");
+        browser.expect.element("#output-text .cm-status-bar .baking-time-info").text.to.contain("ms");
+        browser.expect.element("#output-text .cm-status-bar .chr-enc-value").text.to.equal("Raw Bytes");
+        browser.expect.element("#output-text .cm-status-bar .eol-value").text.to.equal("LF");
+
+        /* Output updates correctly */
+        bake(browser);
+        browser.expect.element("#output-text .cm-status-bar .stats-length-value").text.to.equal("301");
+        browser.expect.element("#output-text .cm-status-bar .stats-lines-value").text.to.equal("3");
+        browser.expect.element("#output-text .cm-status-bar .baking-time-info").text.to.contain("ms");
+        browser.expect.element("#output-text .cm-status-bar .chr-enc-value").text.to.equal("Raw Bytes");
+        browser.expect.element("#output-text .cm-status-bar .eol-value").text.to.equal("LF");
+    },
+
+    "Special content": browser => {
+        /* Special characters are rendered correctly */
+        setInput(browser, SPECIAL_CHARS, false);
+
+        // First line
+        for (let i = 0x0; i <= 0x8; i++) {
+            browser.expect.element(`#input-text .cm-line:nth-of-type(1) .cm-specialChar:nth-of-type(${i+1})`)
+                .to.have.property("title").equals(`Control character ${CONTROL_CHAR_NAMES[i] || "0x" + i.toString(16)}`);
+            browser.expect.element(`#input-text .cm-line:nth-of-type(1) .cm-specialChar:nth-of-type(${i+1})`)
+                .text.to.equal(String.fromCharCode(0x2400 + i));
+        }
+
+        // Tab \u0009
+        browser.expect.element(`#input-text .cm-line:nth-of-type(1)`).to.have.property("textContent").match(/\u0009$/);
+
+        // Line feed \u000a
+        browser.expect.element(`#input-text .cm-line:nth-of-type(1)`).to.have.property("textContent").match(/^.{10}$/);
+        browser.expect.element("#input-text .cm-status-bar .stats-lines-value").text.to.equal("2");
+
+        // Second line
+        for (let i = 0x0b; i < SPECIAL_CHARS.length; i++) {
+            const index = SPECIAL_CHARS.charCodeAt(i);
+            const name = CONTROL_CHAR_NAMES[index] || "0x" + index.toString(16);
+            const value = index >= 32 ? "\u2022" : String.fromCharCode(0x2400 + index);
+
+            browser.expect.element(`#input-text .cm-line:nth-of-type(2) .cm-specialChar:nth-of-type(${i-10})`)
+                .to.have.property("title").equals(`Control character ${name}`);
+            browser.expect.element(`#input-text .cm-line:nth-of-type(2) .cm-specialChar:nth-of-type(${i-10})`)
+                .text.to.equal(value);
+        }
+
+        /* Output renders correctly */
+        setChrEnc(browser, "output", "UTF-8");
+        bake(browser);
+
+        // First line
+        for (let i = 0x0; i <= 0x8; i++) {
+            browser.expect.element(`#output-text .cm-line:nth-of-type(1) .cm-specialChar:nth-of-type(${i+1})`)
+                .to.have.property("title").equals(`Control character ${CONTROL_CHAR_NAMES[i] || "0x" + i.toString(16)}`);
+            browser.expect.element(`#output-text .cm-line:nth-of-type(1) .cm-specialChar:nth-of-type(${i+1})`)
+                .text.to.equal(String.fromCharCode(0x2400 + i));
+        }
+
+        // Tab \u0009
+        browser.expect.element(`#output-text .cm-line:nth-of-type(1)`).to.have.property("textContent").match(/\u0009$/);
+
+        // Line feed \u000a
+        browser.expect.element(`#output-text .cm-line:nth-of-type(1)`).to.have.property("textContent").match(/^.{10}$/);
+        browser.expect.element("#output-text .cm-status-bar .stats-lines-value").text.to.equal("2");
+
+        // Second line
+        for (let i = 0x0b; i < SPECIAL_CHARS.length; i++) {
+            const index = SPECIAL_CHARS.charCodeAt(i);
+            const name = CONTROL_CHAR_NAMES[index] || "0x" + index.toString(16);
+            const value = index >= 32 ? "\u2022" : String.fromCharCode(0x2400 + index);
+
+            browser.expect.element(`#output-text .cm-content .cm-line:nth-of-type(2) .cm-specialChar:nth-of-type(${i-10})`)
+                .to.have.property("title").equals(`Control character ${name}`);
+            browser.expect.element(`#output-text .cm-content .cm-line:nth-of-type(2) .cm-specialChar:nth-of-type(${i-10})`)
+                .text.to.equal(value);
+        }
+
+        /* Bytes are rendered correctly */
+        setInput(browser, ALL_BYTES, false);
+        // Expect length to be 255, since one character is creating a newline
+        browser.expect.element(`#input-text .cm-content`).to.have.property("textContent").match(/^.{255}$/);
+        browser.expect.element("#input-text .cm-status-bar .stats-length-value").text.to.equal("256");
+        browser.expect.element("#input-text .cm-status-bar .stats-lines-value").text.to.equal("2");
+
+
+        /* PUA \ue000-\uf8ff */
+        setInput(browser, PUA_CHARS, false);
+        setChrEnc(browser, "output", "UTF-8");
+        bake(browser);
+
+        // Confirm input and output as expected
+        /*  In order to render whitespace characters as control character pictures in the output, even
+            when they are the designated line separator, CyberChef sometimes chooses to represent them
+            internally using the Unicode Private Use Area (https://en.wikipedia.org/wiki/Private_Use_Areas).
+            See `Utils.escapeWhitespace()` for an example of this.
+            Therefore, PUA characters should be rendered normally in the Input but as control character
+            pictures in the output.
+        */
+        browser.expect.element(`#input-text .cm-content`).to.have.property("textContent").match(/^\ue000\ue001\uf8fe\uf8ff$/);
+        browser.expect.element(`#output-text .cm-content`).to.have.property("textContent").match(/^\u2400\u2401\u3cfe\u3cff$/);
+
+        /* Can be copied */
+        setInput(browser, SPECIAL_CHARS, false);
+        setChrEnc(browser, "output", "UTF-8");
+        bake(browser);
+
+        // Manual copy
+        browser
+            .doubleClick("#output-text .cm-content .cm-line:nth-of-type(1) .cm-specialChar:nth-of-type(1)")
+            .waitForElementVisible("#output-text .cm-selectionBackground");
+        copy(browser);
+        paste(browser, "#search"); // Paste into search box as this won't mess with the values
+
+        // Ensure that the values are as expected
+        browser.expect.element("#search").to.have.value.that.equals("\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008");
+        browser.clearValue("#search");
+
+        // Raw copy
+        browser
+            .click("#copy-output")
+            .pause(100);
+        paste(browser, "#search"); // Paste into search box as this won't mess with the values
+
+        // Ensure that the values are as expected
+        browser.expect.element("#search").to.have.value.that.matches(/^\u0000\u0001\u0002\u0003\u0004\u0005\u0006\u0007\u0008\u0009/);
+        browser.clearValue("#search");
+    },
+
+    "HTML output": browser => {
+        /* Displays correctly */
+        loadRecipe(browser, "Entropy", ALL_BYTES);
+        bake(browser);
+
+        browser
+            .waitForElementVisible("#output-html")
+            .waitForElementVisible("#output-html #chart-area");
+
+        /* Status bar widgets are disabled */
+        browser.expect.element("#output-text .cm-status-bar .disabled .stats-length-value").to.be.visible;
+        browser.expect.element("#output-text .cm-status-bar .disabled .stats-lines-value").to.be.visible;
+        browser.expect.element("#output-text .cm-status-bar .disabled .chr-enc-value").to.be.visible;
+        browser.expect.element("#output-text .cm-status-bar .disabled .eol-value").to.be.visible;
+
+        /* Displays special chars correctly */
+        loadRecipe(browser, "To Table", ",\u0000\u0001\u0002\u0003\u0004", [",", "\\r\\n", false, "HTML"]);
+        bake(browser);
+
+        for (let i = 0x0; i <= 0x4; i++) {
+            browser.expect.element(`#output-html .cm-specialChar:nth-of-type(${i+1})`)
+                .to.have.property("title").equals(`Control character ${CONTROL_CHAR_NAMES[i] || "0x" + i.toString(16)}`);
+            browser.expect.element(`#output-html .cm-specialChar:nth-of-type(${i+1})`)
+                .text.to.equal(String.fromCharCode(0x2400 + i));
+        }
+
+        /* Can be copied */
+        // Raw copy
+        browser
+            .click("#copy-output")
+            .pause(100);
+        paste(browser, "#search"); // Paste into search box as this won't mess with the values
+
+        // Ensure that the values are as expected
+        browser.expect.element("#search").to.have.value.that.matches(/\u0000\u0001\u0002\u0003\u0004/);
+        browser.clearValue("#search");
+    },
+
+    "Highlighting": browser => {
+        /* Selecting text also selects other instances */
+        /* Selecting input text highlights in output */
+        /* Selecting output text highlights in input */
+    },
+
+    "Character encoding": browser => {
+        /* Dropup works */
+        /* Selecting changes output correctly */
+        /* Changing output to match input works as expected */
+        /* Encodings appear in the URL */
+        /* Preserved when changing tabs */
+    },
+
+    "Line endings": browser => {
+        /* Dropup works */
+        /* Selecting changes view in input */
+        /* Adding new line ending changes output correctly */
+        /* Other EOL characters are displayed correctly when not being used to end a line */
+        /* Changing in output has the correct effect */
+        /* Line endings appear in the URL */
+        /* Preserved when changing tabs */
+    },
+
+    "File inputs": browser => {
+        /* By button */
+        /* By drag and drop */
+        /* Side panel displays correct info */
+        /* Side panel can be hidden */
+    },
+
+    "Folder inputs": browser => {
+        /* By button */
+        /* By drag and drop */
+    },
+
+    "Loading from URL": browser => {
+        /* Complex deep link populates the input correctly (encoding, eol, input) */
+    },
+
+    "Replace input with output": browser => {
+        /* Input is correctly populated */
+        /* Special characters, encodings and line endings all as expected */
+    },
+
+
+    after: browser => {
+        browser.end();
+    }
+};
+
+/** @function
+ * Clears the recipe and input
+ *
+ * @param {Browser} browser - Nightwatch client
+ */
+function clear(browser) {
+    browser
+        .useCss()
+        .click("#clr-recipe")
+        .click("#clr-io")
+        .waitForElementNotPresent("#rec-list li.operation")
+        .expect.element("#input-text .cm-content").text.that.equals("");
+}
+
+/** @function
+ * Sets the input to the desired string
+ *
+ * @param {Browser} browser - Nightwatch client
+ * @param {string} input - The text to populate the input with
+ * @param {boolean} [type=true] - Whether to type the characters in by using sendKeys,
+ *      or to set the value of the editor directly (useful for special characters)
+ */
+function setInput(browser, input, type=true) {
+    clear(browser);
+    if (type) {
+        browser
+            .useCss()
+            .sendKeys("#input-text .cm-content", input)
+            .pause(100);
+    } else {
+        browser.execute(text => {
+            window.app.setInput(text);
+        }, [input]);
+    }
+}
+
+/** @function
+ * Triggers a bake
+ *
+ * @param {Browser} browser - Nightwatch client
+ */
+function bake(browser) {
+    browser
+        .click("#bake")
+        .pause(100)
+        .waitForElementPresent("#stale-indicator.hidden", 5000);
+}
+
+/** @function
+ * Sets the character encoding in the input or output
+ *
+ * @param {Browser} browser - Nightwatch client
+ * @param {string} io - Either "input" or "output"
+ * @param {string} enc - The encoding to be set
+ */
+function setChrEnc(browser, io, enc) {
+    io = `#${io}-text`;
+    browser
+        .useCss()
+        .click(io + " .chr-enc-value")
+        .waitForElementVisible(io + " .cm-status-bar-select-scroll")
+        .click("link text", enc)
+        .waitForElementNotVisible(io + " .cm-status-bar-select-scroll")
+        .expect.element(io + " .chr-enc-value").text.that.equals(enc);
+}
+
+/** @function
+ * Copies whatever is currently selected
+ *
+ * @param {Browser} browser - Nightwatch client
+ */
+function copy(browser) {
+    browser.perform(function() {
+        const actions = this.actions({async: true});
+
+        // Ctrl + Ins used as this works on Windows, Linux and Mac
+        return actions
+            .keyDown(browser.Keys.CONTROL)
+            .keyDown(browser.Keys.INSERT)
+            .keyUp(browser.Keys.INSERT)
+            .keyUp(browser.Keys.CONTROL);
+    });
+}
+
+/** @function
+ * Pastes into the target element
+ *
+ * @param {Browser} browser - Nightwatch client
+ * @param {string} el - Target element selector
+ */
+function paste(browser, el) {
+    browser
+        .click(el)
+        .perform(function() {
+            const actions = this.actions({async: true});
+
+            // Shift + Ins used as this works on Windows, Linux and Mac
+            return actions
+                .keyDown(browser.Keys.SHIFT)
+                .keyDown(browser.Keys.INSERT)
+                .keyUp(browser.Keys.INSERT)
+                .keyUp(browser.Keys.SHIFT);
+        })
+        .pause(100);
+}
+
+/** @function
+ * Loads a recipe and input
+ *
+ * @param {Browser} browser - Nightwatch client
+ * @param {string|Array<string>} opName - name of operation to be loaded, array for multiple ops
+ * @param {string} input - input text for test
+ * @param {Array<string>|Array<Array<string>>} args - arguments, nested if multiple ops
+ */
+function loadRecipe(browser, opName, input, args) {
+    let recipeConfig;
+
+    if (typeof(opName) === "string") {
+        recipeConfig = JSON.stringify([{
+            "op": opName,
+            "args": args
+        }]);
+    } else if (opName instanceof Array) {
+        recipeConfig = JSON.stringify(
+            opName.map((op, i) => {
+                return {
+                    op: op,
+                    args: args.length ? args[i] : []
+                };
+            })
+        );
+    } else {
+        throw new Error("Invalid operation type. Must be string or array of strings. Received: " + typeof(opName));
+    }
+
+    clear(browser);
+    setInput(browser, input, false);
+    browser
+        .urlHash("recipe=" + recipeConfig)
+        .waitForElementPresent("#rec-list li.operation");
+}

+ 1 - 1
tests/browser/ops.js

@@ -382,7 +382,7 @@ module.exports = {
  * @param {Browser} browser - Nightwatch client
  * @param {string|Array<string>} opName - name of operation to be tested, array for multiple ops
  * @param {string} input - input text for test
- * @param {Array<string>|Array<Array<string>>} args - aarguments, nested if multiple ops
+ * @param {Array<string>|Array<Array<string>>} args - arguments, nested if multiple ops
  */
 function bakeOp(browser, opName, input, args=[]) {
     let recipeConfig;