diff --git a/devtools/client/webconsole/test/browser/_jsterm.ini b/devtools/client/webconsole/test/browser/_jsterm.ini index 68a43d747a18..fe2dfa29cbc5 100644 --- a/devtools/client/webconsole/test/browser/_jsterm.ini +++ b/devtools/client/webconsole/test/browser/_jsterm.ini @@ -121,6 +121,7 @@ skip-if = (os == "win" && processor == "aarch64") # disabled on aarch64 due to 1 [browser_jsterm_screenshot_command_clipboard.js] [browser_jsterm_screenshot_command_user.js] [browser_jsterm_screenshot_command_file.js] +[browser_jsterm_screenshot_command_warnings.js] [browser_jsterm_selfxss.js] [browser_jsterm_syntax_highlight_output.js] skip-if = (os == "win" && processor == "aarch64") # disabled on aarch64 due to 1531574 diff --git a/devtools/client/webconsole/test/browser/browser_jsterm_screenshot_command_clipboard.js b/devtools/client/webconsole/test/browser/browser_jsterm_screenshot_command_clipboard.js index d9c0413399b9..f95bd03737dc 100644 --- a/devtools/client/webconsole/test/browser/browser_jsterm_screenshot_command_clipboard.js +++ b/devtools/client/webconsole/test/browser/browser_jsterm_screenshot_command_clipboard.js @@ -173,84 +173,3 @@ async function getContentSize() { info(`content size: ${contentSize.innerWidth}x${contentSize.innerHeight}`); return contentSize; } - -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 = {}; - trans.getTransferData(flavor, data); - - ok(data.value, "screenshot exists"); - - let image = data.value; - - // 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 or as image container. If it's not possible obtain a - // byte stream, the function throws. - - if (image instanceof Ci.imgIContainer) { - image = Cc["@mozilla.org/image/tools;1"] - .getService(Ci.imgITools) - .encodeImage(image, flavor); - } - - if (!(image instanceof Ci.nsIInputStream)) { - throw new Error("Unable to read image data"); - } - - const binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( - Ci.nsIBinaryInputStream - ); - binaryStream.setInputStream(image); - const available = binaryStream.available(); - const buffer = new ArrayBuffer(available); - is( - binaryStream.readArrayBuffer(available, buffer), - available, - "Read expected amount of data" - ); - - // We are going to load the image in the content page to measure its size. - // We don't want to insert the image directly in the browser's document - // (which is value of the global `document` here). Doing so might push the - // toolbox upwards, shrink the content page and fail the fullpage screenshot - // test. - return SpecialPowers.spawn(gBrowser.selectedBrowser, [buffer], async function( - _buffer - ) { - const img = content.document.createElement("img"); - const loaded = new Promise(r => { - img.addEventListener("load", r, { once: true }); - }); - - // Build a URL from the buffer passed to the ContentTask - const url = content.URL.createObjectURL( - new Blob([_buffer], { type: "image/png" }) - ); - - // Load the image - img.src = url; - content.document.documentElement.appendChild(img); - - info("Waiting for the clipboard image to load in the content page"); - await loaded; - - // Remove the image and revoke the URL. - img.remove(); - content.URL.revokeObjectURL(url); - - return { - width: img.width, - height: img.height, - }; - }); -} diff --git a/devtools/client/webconsole/test/browser/browser_jsterm_screenshot_command_warnings.js b/devtools/client/webconsole/test/browser/browser_jsterm_screenshot_command_warnings.js new file mode 100644 index 000000000000..ee29455a90f3 --- /dev/null +++ b/devtools/client/webconsole/test/browser/browser_jsterm_screenshot_command_warnings.js @@ -0,0 +1,114 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that screenshot command leads to the proper warning and error messages in the +// console when necessary. + +"use strict"; + +// We create a very big page here in order to make the :screenshot command fail on +// purpose. +const TEST_URI = `data:text/html;charset=utf8, + +
+ `; + +add_task(async function() { + await addTab(TEST_URI); + + const hud = await openConsole(); + ok(hud, "web console opened"); + + await testTruncationWarning(hud); + await testDPRWarning(hud); + await testErrorMessage(hud); +}); + +async function testTruncationWarning(hud) { + info("Check that large screenshots get cut off if necessary"); + + let onMessages = waitForMessages({ + hud, + messages: [ + { text: "Screenshot copied to clipboard." }, + { + text: + "The image was cut off to 10000×10000 as the resulting image was too large", + }, + ], + }); + // Note, we put the screenshot in the clipboard so we can easily measure the resulting + // image. We also pass --dpr 1 so we don't need to worry about different machines having + // different screen resolutions. + execute(hud, ":screenshot --clipboard --selector .big --dpr 1"); + await onMessages; + + let { width, height } = await getImageSizeFromClipboard(); + is(width, 10000, "The resulting image is 10000px wide"); + is(height, 10000, "The resulting image is 10000px high"); + + onMessages = waitForMessages({ + hud, + messages: [{ text: "Screenshot copied to clipboard." }], + }); + execute(hud, ":screenshot --clipboard --selector .small --dpr 1"); + await onMessages; + + ({ width, height } = await getImageSizeFromClipboard()); + is(width, 5, "The resulting image is 5px wide"); + is(height, 5, "The resulting image is 5px high"); +} + +async function testDPRWarning(hud) { + info("Check that fullpage screenshots are taken at dpr 1"); + + // This is only relevant on machines that actually have a dpr that's higher than 1. If + // the current test machine already has a dpr of 1, then the command won't change it and + // no warning will be displayed in the console. + const machineDPR = await getMachineDPR(); + if (machineDPR <= 1) { + info("This machine already has a dpr of 1, no need to test this"); + return; + } + + const onMessages = waitForMessages({ + hud, + messages: [ + { text: "Screenshot copied to clipboard." }, + { + text: + "The image was cut off to 10000×10000 as the resulting image was too large", + }, + { + text: + "The device pixel ratio was reduced to 1 as the resulting image was too large", + }, + ], + }); + execute(hud, ":screenshot --clipboard --fullpage"); + await onMessages; + + ok(true, "Expected messages were displayed"); +} + +async function testErrorMessage(hud) { + info("Check that when a screenshot fails, an error message is displayed"); + + await executeAndWaitForMessage( + hud, + ":screenshot --clipboard --dpr 1000", + "Error creating the image. The resulting image was probably too large." + ); +} + +function getMachineDPR() { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => content.devicePixelRatio + ); +} diff --git a/devtools/client/webconsole/test/browser/head.js b/devtools/client/webconsole/test/browser/head.js index cb6b5afb9207..235261eff3a5 100644 --- a/devtools/client/webconsole/test/browser/head.js +++ b/devtools/client/webconsole/test/browser/head.js @@ -1630,3 +1630,89 @@ async function clearOutput(hud, { keepStorage = false } = {}) { ui.clearOutput(!keepStorage); await Promise.all(promises); } + +/** + * A helper that returns the size of the image that was just put into the clipboard by the + * :screenshot command. + * @return The {width, height} dimension object. + */ +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 = {}; + trans.getTransferData(flavor, data); + + ok(data.value, "screenshot exists"); + + let image = data.value; + + // 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 or as image container. If it's not possible obtain a + // byte stream, the function throws. + + if (image instanceof Ci.imgIContainer) { + image = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .encodeImage(image, flavor); + } + + if (!(image instanceof Ci.nsIInputStream)) { + throw new Error("Unable to read image data"); + } + + const binaryStream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + binaryStream.setInputStream(image); + const available = binaryStream.available(); + const buffer = new ArrayBuffer(available); + is( + binaryStream.readArrayBuffer(available, buffer), + available, + "Read expected amount of data" + ); + + // We are going to load the image in the content page to measure its size. + // We don't want to insert the image directly in the browser's document + // (which is value of the global `document` here). Doing so might push the + // toolbox upwards, shrink the content page and fail the fullpage screenshot + // test. + return SpecialPowers.spawn(gBrowser.selectedBrowser, [buffer], async function( + _buffer + ) { + const img = content.document.createElement("img"); + const loaded = new Promise(r => { + img.addEventListener("load", r, { once: true }); + }); + + // Build a URL from the buffer passed to the ContentTask + const url = content.URL.createObjectURL( + new Blob([_buffer], { type: "image/png" }) + ); + + // Load the image + img.src = url; + content.document.documentElement.appendChild(img); + + info("Waiting for the clipboard image to load in the content page"); + await loaded; + + // Remove the image and revoke the URL. + img.remove(); + content.URL.revokeObjectURL(url); + + return { + width: img.width, + height: img.height, + }; + }); +} diff --git a/devtools/shared/locales/en-US/screenshot.properties b/devtools/shared/locales/en-US/screenshot.properties index a4e4601ed1cd..a9401c74580f 100644 --- a/devtools/shared/locales/en-US/screenshot.properties +++ b/devtools/shared/locales/en-US/screenshot.properties @@ -114,3 +114,20 @@ inspectNodeDesc=CSS selector # parameter to the 'inspect' command. Displayed when the --help flag is # passed to the `screenshot command. inspectNodeManual=A CSS selector for use with document.querySelector which identifies a single element + +# LOCALIZATION NOTE (screenshotTruncationWarning) Text displayed to user when the image +# that would be created by the screenshot is too big and needs to be truncated to avoid +# errors. +# The first parameter is the width of the final image and the second parameter is the +# height of the image. +screenshotTruncationWarning=The image was cut off to %1$S×%2$S as the resulting image was too large + +# LOCALIZATION NOTE (screenshotDPRDecreasedWarning) Text displayed to user when the +# screenshot they want to take is for the full page and the Device Pixel Ratio is +# decreased to 1, to avoid creating images that are too big which may cause errors. +screenshotDPRDecreasedWarning=The device pixel ratio was reduced to 1 as the resulting image was too large + +# LOCALIZATION NOTE (screenshotRenderingError) Text displayed to user upon +# encountering an error while rendering the screenshot. This most often happens when the +# resulting image is too large to be rendered. +screenshotRenderingError=Error creating the image. The resulting image was probably too large. diff --git a/devtools/shared/screenshot/capture.js b/devtools/shared/screenshot/capture.js index ee064d839a86..d3622565cf54 100644 --- a/devtools/shared/screenshot/capture.js +++ b/devtools/shared/screenshot/capture.js @@ -3,14 +3,22 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; -const { Cu } = require("chrome"); +const { Cu, Cc, Ci } = require("chrome"); +const Services = require("Services"); const { LocalizationHelper } = require("devtools/shared/l10n"); +loader.lazyRequireGetter(this, "getRect", "devtools/shared/layout/utils", true); + const CONTAINER_FLASHING_DURATION = 500; const STRINGS_URI = "devtools/shared/locales/screenshot.properties"; const L10N = new LocalizationHelper(STRINGS_URI); -loader.lazyRequireGetter(this, "getRect", "devtools/shared/layout/utils", true); +// These values are used to truncate the resulting image if the captured area is bigger. +// This is to avoid failing to produce a screenshot at all. +// It is recommended to keep these values in sync with the corresponding screenshots addon +// values in /browser/extensions/screenshots/build/buildSettings.js +const MAX_IMAGE_WIDTH = 10000; +const MAX_IMAGE_HEIGHT = 10000; /** * This function is called to simulate camera effects @@ -88,36 +96,66 @@ function createScreenshotDataURL(document, args) { height -= scrollbarHeight.value; } + let ratio; + if (args.fullpage) { + // Always take fullpage screenshots at dpr=1 to avoid failures. + ratio = 1; + // Warn the user if they had provided a higher dpr or have a high resolution monitor. + if ((args.dpr && args.dpr > 1) || window.devicePixelRatio > 1) { + logWarningInPage(L10N.getStr("screenshotDPRDecreasedWarning"), window); + } + } else { + ratio = args.dpr ? args.dpr : window.devicePixelRatio; + } + + // Truncate the width and height if necessary. + if (width > MAX_IMAGE_WIDTH || height > MAX_IMAGE_HEIGHT) { + width = Math.min(width, MAX_IMAGE_WIDTH); + height = Math.min(height, MAX_IMAGE_HEIGHT); + logWarningInPage( + L10N.getFormatStr("screenshotTruncationWarning", width, height), + window + ); + } + const canvas = document.createElementNS( "http://www.w3.org/1999/xhtml", "canvas" ); const ctx = canvas.getContext("2d"); - const ratio = args.dpr ? args.dpr : window.devicePixelRatio; - canvas.width = width * ratio; - canvas.height = height * ratio; - ctx.scale(ratio, ratio); - ctx.drawWindow(window, left, top, width, height, "#fff"); - const data = canvas.toDataURL("image/png", ""); + + // Even after decreasing width, height and ratio, there may still be cases where the + // hardware fails at creating the image. Let's catch this so we can at least show an + // error message to the user. + let data = null; + try { + canvas.width = width * ratio; + canvas.height = height * ratio; + ctx.scale(ratio, ratio); + ctx.drawWindow(window, left, top, width, height, "#fff"); + data = canvas.toDataURL("image/png", ""); + } catch (e) { + logErrorInPage(L10N.getStr("screenshotRenderingError"), window); + } // See comment above on bug 961832 if (args.fullpage) { window.scrollTo(currentX, currentY); } - simulateCameraFlash(document); + if (data) { + simulateCameraFlash(document); + } return Promise.resolve({ destinations: [], - data: data, - height: height, - width: width, - filename: filename, + data, + height, + width, + filename, }); } -exports.createScreenshotDataURL = createScreenshotDataURL; - /** * We may have a filename specified in args, or we might have to generate * one. @@ -150,3 +188,23 @@ function getFilename(defaultName) { ".png" ); } + +function logInPage(text, flags, window) { + const scriptError = Cc["@mozilla.org/scripterror;1"].createInstance( + Ci.nsIScriptError + ); + scriptError.initWithWindowID( + text, + null, + null, + 0, + 0, + flags, + "screenshot", + window.windowUtils.currentInnerWindowID + ); + Services.console.logMessage(scriptError); +} + +const logErrorInPage = (text, window) => logInPage(text, 0, window); +const logWarningInPage = (text, window) => logInPage(text, 1, window); diff --git a/devtools/shared/screenshot/save.js b/devtools/shared/screenshot/save.js index 6b753b92c5cd..b813db2c1d7b 100644 --- a/devtools/shared/screenshot/save.js +++ b/devtools/shared/screenshot/save.js @@ -116,6 +116,11 @@ function getFormattedHelpData() { * Response messages from processing the screenshot */ function saveScreenshot(window, args = {}, value) { + // Guard against missing image data. + if (!value.data) { + return []; + } + if (args.help) { const message = getFormattedHelpData(); // Wrap message in an array so that the return value is consistant with save @@ -225,6 +230,11 @@ function saveToClipboard(base64URI) { async function saveToFile(image) { let filename = image.filename; + // Guard against missing image data. + if (!image.data) { + return ""; + } + // Check there is a .png extension to filename if (!filename.match(/.png$/i)) { filename += ".png";