Bug 1464461 - implement unix style syntax for console commands; r=ochameau, nchevobbe draft
authoryulia <ystartsev@mozilla.com>
Tue, 05 Jun 2018 17:27:07 +0200
changeset 809232 9759dcebb990dc07a10e9a29968ec02700944a9d
parent 809231 30a6d566972724d1523fc4a076ee022d66a9346e
child 809233 ae749263b8afb8b98d79fe5289e55b7aef5742e9
push id113593
push userbmo:ystartsev@mozilla.com
push dateThu, 21 Jun 2018 14:24:13 +0000
reviewersochameau, nchevobbe
bugs1464461
milestone62.0a1
Bug 1464461 - implement unix style syntax for console commands; r=ochameau, nchevobbe MozReview-Commit-ID: 8rQ9IQdsZkm
devtools/client/webconsole/test/mochitest/browser_jsterm_screenshot_command.js
devtools/server/actors/webconsole.js
devtools/server/actors/webconsole/commands.js
devtools/server/actors/webconsole/moz.build
devtools/server/tests/unit/test_format_command.js
devtools/server/tests/unit/xpcshell.ini
new file mode 100644
--- /dev/null
+++ b/devtools/client/webconsole/test/mochitest/browser_jsterm_screenshot_command.js
@@ -0,0 +1,219 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* global helpers, btoa, whenDelayedStartupFinished, OpenBrowserWindow */
+
+// Test that screenshot command works properly
+
+"use strict";
+
+const TEST_URI = "http://example.com/browser/devtools/client/webconsole/" +
+                 "test/mochitest/browser_cmd_screenshot.html";
+
+const FileUtils = (ChromeUtils.import("resource://gre/modules/FileUtils.jsm", {})).FileUtils;
+const browser = "?";
+
+add_task(async function() {
+  await addTab(TEST_URI);
+
+  const opened = waitForBrowserConsole();
+
+  let hud = HUDService.getBrowserConsole();
+  ok(!hud, "browser console is not open");
+  info("wait for the browser console to open with ctrl-shift-j");
+  EventUtils.synthesizeKey("j", { accelKey: true, shiftKey: true }, window);
+
+  hud = await opened;
+  ok(hud, "browser console opened");
+
+  await testFile(hud);
+  await testClipboard(hud);
+  await testFullpageClipboard(hud);
+  await testSelectorClipboard(hud);
+
+  const scrollbarSize = await createScrollbarOverflow();
+  await testClipboardScrollbar(hud, scrollbarSize);
+  await testFullpageClipboardScrollbar(hud, scrollbarSize);
+});
+
+async function testFile(hud) {
+  // Test capture to file
+  const file = FileUtils.getFile("TmpD", [ "TestScreenshotFile.png" ]);
+  const command = `:screenshot ${file.path}`;
+  hud.jsterm.execute(command);
+  await waitForMessage("Saved to TmpD/TestScreenshotFile.png");
+  // Bug 849168: screenshot command tests fail in try but not locally
+  ok(file.exists(), "Screenshot file exists");
+
+  if (file.exists()) {
+    file.remove(false);
+  }
+}
+
+async function testClipboard(hud) {
+  const command = `:screenshot --clipboard`;
+  hud.jsterm.execute(command);
+  await waitForMessage("Copied to clipboard.");
+  const imgSize1 = await getImageSizeFromClipboard();
+  await ContentTask.spawn(browser, imgSize1, function* (imgSize) {
+    Assert.equal(imgSize.width, content.innerWidth,
+                 "Image width matches window size");
+    Assert.equal(imgSize.height, content.innerHeight,
+                 "Image height matches window size");
+  });
+}
+
+async function testFullpageClipboard(hud) {
+  const command = `:screenshot --fullpage --clipboard`;
+  hud.jsterm.execute(command);
+  await waitForMessage("Copied to clipboard.");
+  const imgSize1 = await getImageSizeFromClipboard();
+  await ContentTask.spawn(browser, imgSize1, function* (imgSize) {
+    Assert.equal(imgSize.width,
+      content.innerWidth + content.scrollMaxX - content.scrollMinX,
+      "Image width matches page size");
+    Assert.equal(imgSize.height,
+      content.innerHeight + content.scrollMaxY - content.scrollMinY,
+      "Image height matches page size");
+  });
+}
+
+async function testSelectorClipboard(hud) {
+  const command = `:screenshot --selector img#testImage --clipboard`;
+  hud.jsterm.execute(command);
+  await waitForMessage("Copied to clipboard.");
+  const imgSize1 = await getImageSizeFromClipboard();
+  await ContentTask.spawn(browser, imgSize1, function* (imgSize) {
+    const img = content.document.querySelector("img#testImage");
+    Assert.equal(imgSize.width, img.clientWidth,
+       "Image width matches element size");
+    Assert.equal(imgSize.height, img.clientHeight,
+       "Image height matches element size");
+  });
+}
+
+async function createScrollbarOverflow() {
+  // Trigger scrollbars by forcing document to overflow
+  // This only affects results on OSes with scrollbars that reduce document size
+  // (non-floating scrollbars).  With default OS settings, this means Windows
+  // and Linux are affected, but Mac is not.  For Mac to exhibit this behavior,
+  // change System Preferences -> General -> Show scroll bars to Always.
+  await ContentTask.spawn(browser, {}, function* () {
+    content.document.body.classList.add("overflow");
+  });
+
+  const scrollbarSize = await ContentTask.spawn(browser, {}, function* () {
+    const winUtils = content.QueryInterface(Ci.nsIInterfaceRequestor)
+                            .getInterface(Ci.nsIDOMWindowUtils);
+    const scrollbarHeight = {};
+    const scrollbarWidth = {};
+    winUtils.getScrollbarSize(true, scrollbarWidth, scrollbarHeight);
+    return {
+      width: scrollbarWidth.value,
+      height: scrollbarHeight.value,
+    };
+  });
+
+  info(`Scrollbar size: ${scrollbarSize.width}x${scrollbarSize.height}`);
+  return scrollbarSize;
+}
+
+async function testClipboardScrollbar(hud, scrollbarSize) {
+  const command = `:screenshot --clipboard`;
+  hud.jsterm.execute(command);
+  await waitForMessage("Copied to clipboard.");
+  const imgSize1 = await getImageSizeFromClipboard();
+  imgSize1.scrollbarWidth = scrollbarSize.width;
+  imgSize1.scrollbarHeight = scrollbarSize.height;
+  await ContentTask.spawn(browser, imgSize1, function* (imgSize) {
+    Assert.equal(imgSize.width, content.innerWidth - imgSize.scrollbarWidth,
+       "Image width matches window size minus scrollbar size");
+    Assert.equal(imgSize.height, content.innerHeight - imgSize.scrollbarHeight,
+       "Image height matches window size minus scrollbar size");
+  });
+}
+
+async function testFullpageClipboardScrollbar(hud, scrollbarSize) {
+  const command = `:screenshot --fullpage --clipboard`;
+  hud.jsterm.execute(command);
+  await waitForMessage("Copied to clipboard.");
+  const imgSize1 = await getImageSizeFromClipboard();
+  imgSize1.scrollbarWidth = scrollbarSize.width;
+  imgSize1.scrollbarHeight = scrollbarSize.height;
+  await ContentTask.spawn(browser, imgSize1, function* (imgSize) {
+    Assert.equal(imgSize.width,
+      (content.innerWidth + content.scrollMaxX -
+       content.scrollMinX) - imgSize.scrollbarWidth,
+      "Image width matches page size minus scrollbar size");
+    Assert.equal(imgSize.height,
+      (content.innerHeight + content.scrollMaxY -
+       content.scrollMinY) - imgSize.scrollbarHeight,
+      "Image height matches page size minus scrollbar size");
+  });
+}
+
+async function getImageSizeFromClipboard() {
+  const clipid = Ci.nsIClipboard;
+  const clip = Cc["@mozilla.org/widget/clipboard;1"].getService(clipid);
+  const trans = Cc["@mozilla.org/widget/transferable;1"]
+                .createInstance(Ci.nsITransferable);
+  const flavor = "image/png";
+  trans.init(null);
+  trans.addDataFlavor(flavor);
+
+  clip.getData(trans, clipid.kGlobalClipboard);
+  const data = new Object();
+  const dataLength = new Object();
+  trans.getTransferData(flavor, data, dataLength);
+
+  ok(data.value, "screenshot exists");
+  ok(dataLength.value > 0, "screenshot has length");
+
+  let image = data.value;
+  let dataURI = `data:${flavor};base64,`;
+
+  // Due to the differences in how images could be stored in the clipboard the
+  // checks below are needed. The clipboard could already provide the image as
+  // byte streams, but also as pointer, or as image container. If it's not
+  // possible obtain a byte stream, the function returns `null`.
+  if (image instanceof Ci.nsISupportsInterfacePointer) {
+    image = image.data;
+  }
+
+  if (image instanceof Ci.imgIContainer) {
+    image = Cc["@mozilla.org/image/tools;1"]
+              .getService(Ci.imgITools)
+              .encodeImage(image, flavor);
+  }
+
+  if (image instanceof Ci.nsIInputStream) {
+    const binaryStream = Cc["@mozilla.org/binaryinputstream;1"]
+                         .createInstance(Ci.nsIBinaryInputStream);
+    binaryStream.setInputStream(image);
+    const rawData = binaryStream.readBytes(binaryStream.available());
+    const charCodes = Array.from(rawData, c => c.charCodeAt(0) & 0xff);
+    let encodedData = String.fromCharCode(...charCodes);
+    encodedData = btoa(encodedData);
+    dataURI = dataURI + encodedData;
+  } else {
+    throw new Error("Unable to read image data");
+  }
+
+  const img = document.createElementNS("http://www.w3.org/1999/xhtml", "img");
+
+  const loaded = new Promise(resolve => {
+    img.addEventListener("load", function() {
+      resolve();
+    }, {once: true});
+  });
+
+  img.src = dataURI;
+  document.documentElement.appendChild(img);
+  await loaded;
+  img.remove();
+
+  return {
+    width: img.width,
+    height: img.height,
+  };
+}
--- a/devtools/server/actors/webconsole.js
+++ b/devtools/server/actors/webconsole.js
@@ -22,16 +22,18 @@ loader.lazyRequireGetter(this, "NetworkM
 loader.lazyRequireGetter(this, "NetworkMonitorChild", "devtools/shared/webconsole/network-monitor", true);
 loader.lazyRequireGetter(this, "NetworkEventActor", "devtools/server/actors/network-event", true);
 loader.lazyRequireGetter(this, "ConsoleProgressListener", "devtools/shared/webconsole/network-monitor", true);
 loader.lazyRequireGetter(this, "StackTraceCollector", "devtools/shared/webconsole/network-monitor", true);
 loader.lazyRequireGetter(this, "JSPropertyProvider", "devtools/shared/webconsole/js-property-provider", true);
 loader.lazyRequireGetter(this, "Parser", "resource://devtools/shared/Parser.jsm", true);
 loader.lazyRequireGetter(this, "NetUtil", "resource://gre/modules/NetUtil.jsm", true);
 loader.lazyRequireGetter(this, "addWebConsoleCommands", "devtools/server/actors/webconsole/utils", true);
+loader.lazyRequireGetter(this, "formatCommand", "devtools/server/actors/webconsole/commands", true);
+loader.lazyRequireGetter(this, "isCommand", "devtools/server/actors/webconsole/commands", true);
 loader.lazyRequireGetter(this, "CONSOLE_WORKER_IDS", "devtools/server/actors/webconsole/utils", true);
 loader.lazyRequireGetter(this, "WebConsoleUtils", "devtools/server/actors/webconsole/utils", true);
 loader.lazyRequireGetter(this, "EnvironmentActor", "devtools/server/actors/environment", true);
 loader.lazyRequireGetter(this, "EventEmitter", "devtools/shared/event-emitter");
 
 // Overwrite implemented listeners for workers so that we don't attempt
 // to load an unsupported module.
 if (isWorker) {
@@ -1291,16 +1293,26 @@ WebConsoleActor.prototype =
   /* eslint-disable complexity */
   evalWithDebugger: function(string, options = {}) {
     const trimmedString = string.trim();
     // The help function needs to be easy to guess, so we make the () optional.
     if (trimmedString == "help" || trimmedString == "?") {
       string = "help()";
     }
 
+    const isCmd = isCommand(string);
+    // we support Unix like syntax for commands if it is preceeded by `:`
+    if (isCmd) {
+      try {
+        string = formatCommand(string);
+      } catch (e) {
+        string = `throw "${e}"`;
+      }
+    }
+
     // Add easter egg for console.mihai().
     if (trimmedString == "console.mihai()" || trimmedString == "console.mihai();") {
       string = "\"http://incompleteness.me/blog/2015/02/09/console-dot-mihai/\"";
     }
 
     // Find the Debugger.Frame of the given FrameActor.
     let frame = null, frameActor = null;
     if (options.frameActor) {
@@ -1362,39 +1374,47 @@ WebConsoleActor.prototype =
     if (options.selectedNodeActor) {
       const actor = this.conn.getActor(options.selectedNodeActor);
       if (actor) {
         helpers.selectedNode = actor.rawNode;
       }
     }
 
     // Check if the Debugger.Frame or Debugger.Object for the global include
-    // $ or $$. We will not overwrite these functions with the Web Console
+    // $ or $$ or screenshot. We will not overwrite these functions with the Web Console
     // commands.
-    let found$ = false, found$$ = false;
-    if (frame) {
-      const env = frame.environment;
-      if (env) {
-        found$ = !!env.find("$");
-        found$$ = !!env.find("$$");
+    let found$ = false, found$$ = false, foundScreenshot = false;
+    if (!isCmd) {
+      if (frame) {
+        const env = frame.environment;
+        if (env) {
+          found$ = !!env.find("$");
+          found$$ = !!env.find("$$");
+          foundScreenshot = !!env.find("screenshot");
+        }
+      } else {
+        found$ = !!dbgWindow.getOwnPropertyDescriptor("$");
+        found$$ = !!dbgWindow.getOwnPropertyDescriptor("$$");
+        foundScreenshot = !!dbgWindow.getOwnPropertyDescriptor("screenshot");
       }
-    } else {
-      found$ = !!dbgWindow.getOwnPropertyDescriptor("$");
-      found$$ = !!dbgWindow.getOwnPropertyDescriptor("$$");
     }
 
-    let $ = null, $$ = null;
+    let $ = null, $$ = null, screenshot = null;
     if (found$) {
       $ = bindings.$;
       delete bindings.$;
     }
     if (found$$) {
       $$ = bindings.$$;
       delete bindings.$$;
     }
+    if (foundScreenshot) {
+      screenshot = bindings.screenshot;
+      delete bindings.screenshot;
+    }
 
     // Ready to evaluate the string.
     helpers.evalInput = string;
 
     let evalOptions;
     if (typeof options.url == "string") {
       evalOptions = { url: options.url };
     }
@@ -1487,16 +1507,19 @@ WebConsoleActor.prototype =
     delete helpers.selectedNode;
 
     if ($) {
       bindings.$ = $;
     }
     if ($$) {
       bindings.$$ = $$;
     }
+    if (screenshot) {
+      bindings.screenshot = screenshot;
+    }
 
     if (bindings._self) {
       delete bindings._self;
     }
 
     return {
       result: result,
       helperResult: helperResult,
new file mode 100644
--- /dev/null
+++ b/devtools/server/actors/webconsole/commands.js
@@ -0,0 +1,212 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+const validCommands = ["help", "screenshot"];
+
+const COMMAND = "command";
+const KEY = "key";
+const ARG = "arg";
+
+const COMMAND_PREFIX = /^:/;
+const KEY_PREFIX = /^--/;
+
+// default value for flags
+const DEFAULT_VALUE = true;
+const COMMAND_DEFAULT_FLAG = {
+  screenshot: "filename"
+};
+
+/**
+ * When given a string that begins with `:` and a unix style string,
+ * format a JS like object.
+ * This is intended to be used by the WebConsole actor only.
+ *
+ * @param String string
+ *        A string to format that begins with `:`.
+ *
+ * @returns String formatted as `command({ ..args })`
+ */
+function formatCommand(string) {
+  if (!isCommand(string)) {
+    throw Error("formatCommand was called without `:`");
+  }
+  const tokens = string.trim().split(/\s+/).map(createToken);
+  const { command, args } = parseCommand(tokens);
+  const argsString = formatArgs(args);
+  return `${command}(${argsString})`;
+}
+
+/**
+ * collapses the array of arguments from the parsed command into
+ * a single string
+ *
+ * @param Object tree
+ *               A tree object produced by parseCommand
+ *
+ * @returns String formatted as ` { key: value, ... } ` or an empty string
+ */
+function formatArgs(args) {
+  const argsString = Object.entries(args).map(([key, value]) => {
+    if (Array.isArray(value)) {
+      return `${key}: [${value}]`;
+    }
+    // always treat these as strings
+    if (key === "filename" && !isStringChar(value[0])) {
+      return `${key}: "${value}"`;
+    }
+    return `${key}: ${value}`;
+  });
+
+  if (argsString.length) {
+    return `{ ${argsString.join(", ")} }`;
+  }
+  return "";
+}
+
+/**
+ * creates a token object depending on a string which as a prefix,
+ * either `:` for a command or `--` for a key, or nothing for an argument
+ *
+ * @param String string
+ *               A string to use as the basis for the token
+ *
+ * @returns Object Token Object, with the following shape
+ *                { type: String, value: String }
+ */
+function createToken(string) {
+  if (isCommand(string)) {
+    const value = string.replace(COMMAND_PREFIX, "");
+    if (!value || !validCommands.includes(value)) {
+      throw Error(`'${value}' is not a valid command`);
+    }
+    return { type: COMMAND, value };
+  }
+  if (isKey(string)) {
+    const value = string.replace(KEY_PREFIX, "");
+    if (!value) {
+      throw Error("invalid flag");
+    }
+    return { type: KEY, value };
+  }
+  return { type: ARG, value: string };
+}
+
+/**
+ * returns a command Tree object for a set of tokens
+ *
+ *
+ * @param Array Tokens tokens
+ *                     An array of Token objects
+ *
+ * @returns Object Tree Object, with the following shape
+ *                 { command: String, args: Array of Strings }
+ */
+function parseCommand(tokens) {
+  let command = null;
+  const args = {};
+
+  for (let i = 0; i < tokens.length; i++) {
+    const token = tokens[i];
+    if (token.type === COMMAND) {
+      if (command) {
+        // we are throwing here because two commands have been passed and it is unclear
+        // what the user's intention was
+        throw Error("Invalid command");
+      }
+      command = token.value;
+    }
+
+    if (token.type === KEY) {
+      const nextTokenIndex = i + 1;
+      const nextToken = tokens[nextTokenIndex];
+      let values = args[token.value] || DEFAULT_VALUE;
+      if (nextToken && nextToken.type === ARG) {
+        const { value, offset } = collectString(nextToken, tokens, nextTokenIndex);
+        // in order for JSON.stringify to correctly output values, they must be correctly
+        // typed
+        // As per the GCLI documentation, we can only have one value associated with a
+        // flag but multiple flags with the same name can exist and should be combined
+        // into and array.  Here we are associating only the value on the right hand
+        // side if it is of type `arg` as a single value; the second case initializes
+        // an array, and the final case pushes a value to an existing array
+        if (!values || values === DEFAULT_VALUE) {
+          values = value;
+        } else if (!values.isArray) {
+          values = [values, value];
+        } else {
+          values.push(value);
+        }
+        // skip the next token since we have already consumed it
+        i = nextTokenIndex + offset;
+      }
+      args[token.value] = values;
+    }
+
+    // Since this has only been implemented for screenshot, we can only have one default
+    // value. Eventually we may have more default values. For now, ignore multiple
+    // unflagged args
+    const defaultFlag = COMMAND_DEFAULT_FLAG[command];
+    if (token.type === ARG && !args[defaultFlag]) {
+      const { value, offset } = collectString(token, tokens, i);
+      args[defaultFlag] = value;
+      i = i + offset;
+    }
+  }
+  return { command, args };
+}
+
+const stringChars = ["\"", "'", "`"];
+function isStringChar(testChar) {
+  return stringChars.includes(testChar);
+}
+
+function checkLastChar(string, testChar) {
+  const lastChar = string[string.length - 1];
+  return lastChar === testChar;
+}
+
+function collectString(token, tokens, index) {
+  const firstChar = token.value[0];
+  const isString = isStringChar(firstChar);
+
+  // the test value is not a string, or it is a string but a complete one
+  // i.e. `"test"`, as opposed to `"foo`. In either case, this we can return early
+  if (!isString || checkLastChar(token.value, firstChar)) {
+    return { value: token.value, offset: 0 };
+  }
+
+  let value = token.value;
+  let offset = null;
+  for (let i = index + 1; i <= tokens.length; i++) {
+    if (i === tokens.length) {
+      throw Error("String does not terminate");
+    }
+
+    const nextToken = tokens[i];
+    if (nextToken.type !== ARG) {
+      throw Error(`String does not terminate before flag ${nextToken.value}`);
+    }
+
+    value = `${value} ${nextToken.value}`;
+    if (checkLastChar(nextToken.value, firstChar)) {
+      offset = i - index;
+      break;
+    }
+  }
+  return { value, offset };
+}
+
+function isCommand(string) {
+  return COMMAND_PREFIX.test(string);
+}
+
+function isKey(string) {
+  return KEY_PREFIX.test(string);
+}
+
+exports.formatCommand = formatCommand;
+exports.isCommand = isCommand;
--- a/devtools/server/actors/webconsole/moz.build
+++ b/devtools/server/actors/webconsole/moz.build
@@ -1,13 +1,14 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DevToolsModules(
+    'commands.js',
     'content-process-forward.js',
     'listeners.js',
     'screenshot.js',
     'utils.js',
     'worker-listeners.js',
 )
new file mode 100644
--- /dev/null
+++ b/devtools/server/tests/unit/test_format_command.js
@@ -0,0 +1,68 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable no-shadow, max-nested-callbacks */
+
+"use strict";
+
+const { formatCommand } = require("devtools/server/actors/webconsole/commands.js");
+
+const testcases = [
+  { input: ":help", expectedOutput: "help()" },
+  {
+    input: ":screenshot  --fullscreen",
+    expectedOutput: "screenshot({ fullscreen: true })"
+  },
+  { input: ":screenshot  ", expectedOutput: "screenshot()" },
+  {
+    input: ":screenshot --dpr 0.5 --fullpage --chrome",
+    expectedOutput: "screenshot({ dpr: 0.5, fullpage: true, chrome: true })"
+  },
+  {
+    input: ":screenshot 'filename'",
+    expectedOutput: "screenshot({ filename: 'filename' })"
+  },
+  {
+    input: ":screenshot filename",
+    expectedOutput: "screenshot({ filename: \"filename\" })"
+  },
+  {
+    input: ":screenshot --name 'filename' --name `filename` --name \"filename\"",
+    expectedOutput: "screenshot({ name: ['filename',`filename`,\"filename\"] })"
+  },
+  {
+    input: ":screenshot 'filename1' 'filename2' 'filename3'",
+    expectedOutput: "screenshot({ filename: 'filename1' })"
+  },
+  {
+    input: ":screenshot --chrome --chrome",
+    expectedOutput: "screenshot({ chrome: true })"
+  },
+  {
+    input: ":screenshot \"file name with spaces\"",
+    expectedOutput: "screenshot({ filename: \"file name with spaces\" })"
+  }
+];
+
+const edgecases = [
+  { input: ":", expectedOutput: "invalid command" },
+  { input: ":invalid", expectedOutput: "invalid command" },
+  { input: ":screenshot :help", expectedOutput: "invalid command" },
+  { input: ":screenshot --", expectedOutput: "invalid flag" },
+  { input: ": screenshot", expectedOutput: "invalid command" },
+  { input: ":screenshot \"file name", expectedOutput: "String does not terminate" },
+  {
+    input: ":screenshot \"file name --clipboard",
+    expectedOutput: "String does not terminate before flag \"clipboard\""
+  },
+  { input: "::screenshot", expectedOutput: "invalid command" }
+];
+
+function run_test() {
+  testcases.forEach(testcase => {
+    Assert.equal(formatCommand(testcase.input), testcase.expectedOutput);
+  });
+
+  edgecases.forEach(testcase => {
+    Assert.throws(() => formatCommand(testcase.input), testcase.expectedOutput);
+  });
+}
--- a/devtools/server/tests/unit/xpcshell.ini
+++ b/devtools/server/tests/unit/xpcshell.ini
@@ -81,16 +81,17 @@ skip-if = (verify && !debug && (os == 'w
 [test_frameclient-02.js]
 [test_nativewrappers.js]
 [test_nodelistactor.js]
 [test_eval-01.js]
 [test_eval-02.js]
 [test_eval-03.js]
 [test_eval-04.js]
 [test_eval-05.js]
+[test_format_command.js]
 [test_promises_actor_attach.js]
 [test_promises_actor_exist.js]
 [test_promises_actor_list_promises.js]
 skip-if = coverage # bug 1336670
 [test_promises_actor_onnewpromise.js]
 [test_promises_actor_onpromisesettled.js]
 [test_promises_client_getdependentpromises.js]
 [test_promises_object_creationtimestamp.js]