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]