Bug 1464461 - implement unix style syntax for console commands; r=nchevobbe,ochameau
authoryulia <ystartsev@mozilla.com>
Tue, 05 Jun 2018 17:27:07 +0200
changeset 424812 e090de5269dc
parent 424811 f1f577e0d6f4
child 424813 ea3556925d99
push id34224
push usershindli@mozilla.com
push dateTue, 03 Jul 2018 21:55:38 +0000
treeherdermozilla-central@b636a45b545e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersnchevobbe, ochameau
bugs1464461
milestone63.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1464461 - implement unix style syntax for console commands; r=nchevobbe,ochameau 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]