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 813607 8330fb4ea04b3cdf166bcd2032a6d4e37db92f74
parent 813606 2a2462d109efb79cb0aa62cde46af350cb46406c
child 813608 de0f6f715b4fcbd9acc4ddf20da8b1ffe69697e5
push id114934
push userbmo:ystartsev@mozilla.com
push dateTue, 03 Jul 2018 13:34:11 +0000
reviewersochameau, nchevobbe
bugs1464461
milestone63.0a1
Bug 1464461 - implement unix style syntax for console commands; r=ochameau, nchevobbe MozReview-Commit-ID: 8rQ9IQdsZkm
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
--- 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) {
@@ -1117,18 +1119,23 @@ WebConsoleActor.prototype =
       if (!this._webConsoleCommandsCache) {
         const helpers = {
           sandbox: Object.create(null)
         };
         addWebConsoleCommands(helpers);
         this._webConsoleCommandsCache =
           Object.getOwnPropertyNames(helpers.sandbox);
       }
+
       matches = matches.concat(this._webConsoleCommandsCache
-          .filter(n => n.startsWith(result.matchProp)));
+          .filter(n =>
+            // filter out `screenshot` command as it is inaccessible without
+            // the `:` prefix
+            n !== "screenshot" && n.startsWith(result.matchProp)
+          ));
     }
 
     return {
       from: this.actorID,
       matches: matches.sort(),
       matchProp: result.matchProp,
     };
   },
@@ -1325,16 +1332,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) {
@@ -1398,37 +1415,47 @@ WebConsoleActor.prototype =
       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
     // 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, disableScreenshot = false;
+    // do not override command functions if we are using the command key `:`
+    // before the command string
+    if (!isCmd) {
+      // if we do not have the command key as a prefix, screenshot is disabled by default
+      disableScreenshot = true;
+      if (frame) {
+        const env = frame.environment;
+        if (env) {
+          found$ = !!env.find("$");
+          found$$ = !!env.find("$$");
+        }
+      } else {
+        found$ = !!dbgWindow.getOwnPropertyDescriptor("$");
+        found$$ = !!dbgWindow.getOwnPropertyDescriptor("$$");
       }
-    } 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 (disableScreenshot) {
+      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 };
     }
@@ -1521,16 +1548,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,227 @@
+/* -*- 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) {
+  return Object.keys(args).length ?
+    JSON.stringify(args) :
+    "";
+}
+
+/**
+ * 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
+        const typedValue = getTypedValue(value);
+        if (values === DEFAULT_VALUE) {
+          values = typedValue;
+        } else if (!Array.isArray(values)) {
+          values = [values, typedValue];
+        } else {
+          values.push(typedValue);
+        }
+        // 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] = getTypedValue(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 hasUnexpectedChar(value, char, rightOffset, leftOffset) {
+  const lastPos = value.length - 1;
+  value.slice(rightOffset, lastPos - leftOffset).includes(char);
+}
+
+function collectString(token, tokens, index) {
+  const firstChar = token.value[0];
+  const isString = isStringChar(firstChar);
+  let value = token.value;
+
+  // 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(value, firstChar)) {
+    return { value, offset: 0 };
+  }
+
+  if (hasUnexpectedChar(value, firstChar, 1, 0)) {
+    throw Error(`String contains unexpected ${firstChar} character`);
+  }
+
+  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}`);
+    }
+
+    if (hasUnexpectedChar(nextToken.value, firstChar, 0, 1)) {
+      throw Error(`String contains unexpected ${firstChar} character`);
+    }
+
+    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);
+}
+
+function getTypedValue(value) {
+  if (!isNaN(value)) {
+    return Number(value);
+  }
+  if (value === "true" || value === "false") {
+    return Boolean(value);
+  }
+  if (isStringChar(value[0])) {
+    return value.slice(1, value.length - 1);
+  }
+  return value;
+}
+
+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,96 @@
+/* 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  --fullscreen true",
+    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\"})"
+  },
+  {
+    input: ":screenshot 'filename1' --name 'filename2'",
+    expectedOutput: "screenshot({\"filename\":\"filename1\",\"name\":\"filename2\"})"
+  },
+  {
+    input: ":screenshot --name 'filename1' 'filename2'",
+    expectedOutput: "screenshot({\"name\":\"filename1\",\"filename\":\"filename2\"})"
+  },
+  {
+    input: ":screenshot \"fo\\\"o bar\"",
+    expectedOutput: "screenshot({\"filename\":\"fo\\\\\\\"o bar\"})"
+  },
+  {
+    input: ":screenshot \"foo b\\\"ar\"",
+    expectedOutput: "screenshot({\"filename\":\"foo b\\\\\\\"ar\"})"
+  }
+];
+
+const edgecases = [
+  { input: ":", expectedError: "'' is not a valid command" },
+  { input: ":invalid", expectedError: "'invalid' is not a valid command" },
+  { input: ":screenshot :help", expectedError: "invalid command" },
+  { input: ":screenshot --", expectedError: "invalid flag" },
+  {
+    input: ":screenshot \"fo\"o bar",
+    expectedError: "String contains unexpected `\"` character"
+  },
+  {
+    input: ":screenshot \"foo b\"ar",
+    expectedError: "String contains unexpected `\"` character"
+  },
+  { input: ": screenshot", expectedError: "'' is not a valid command" },
+  { input: ":screenshot \"file name", expectedError: "String does not terminate" },
+  {
+    input: ":screenshot \"file name --clipboard",
+    expectedError: "String does not terminate before flag \"clipboard\""
+  },
+  { input: "::screenshot", expectedError: "':screenshot' is not a valid command" }
+];
+
+function run_test() {
+  testcases.forEach(testcase => {
+    Assert.equal(formatCommand(testcase.input), testcase.expectedOutput);
+  });
+
+  edgecases.forEach(testcase => {
+    Assert.throws(() => formatCommand(testcase.input), testcase.expectedError);
+  });
+}
--- 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]