/* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const { TelemetryTestUtils } = ChromeUtils.importESModule( "resource://testing-common/TelemetryTestUtils.sys.mjs" ); const { UrlbarTestUtils } = ChromeUtils.importESModule( "resource://testing-common/UrlbarTestUtils.sys.mjs" ); const TEST_ROOT = getRootDirectory(gTestPath).replace( "chrome://mochitests/content", "http://example.com" ); const TEST_PAGE = TEST_ROOT + "test-page.html"; const SHORT_TEST_PAGE = TEST_ROOT + "short-test-page.html"; const LARGE_TEST_PAGE = TEST_ROOT + "large-test-page.html"; const MAX_CAPTURE_DIMENSION = 32767; const MAX_CAPTURE_AREA = 124925329; const gScreenshotUISelectors = { panelButtons: "#screenshotsPagePanel", fullPageButton: "button.full-page", visiblePageButton: "button.visible-page", copyButton: "button.#copy", }; // MouseEvents is for the mouse events on the Anonymous content const MouseEvents = { mouse: new Proxy( {}, { get: (target, name) => async function (x, y, selector = ":root") { if (name === "click") { this.down(x, y); this.up(x, y); } else { await safeSynthesizeMouseEventInContentPage(selector, x, y, { type: "mouse" + name, }); } }, } ), }; const { mouse } = MouseEvents; class ScreenshotsHelper { constructor(browser) { this.browser = browser; this.selector = gScreenshotUISelectors; } get toolbarButton() { return document.getElementById("screenshot-button"); } /** * Click the screenshots button in the toolbar */ triggerUIFromToolbar() { let button = this.toolbarButton; ok( BrowserTestUtils.is_visible(button), "The screenshot toolbar button is visible" ); button.click(); } async waitForPanel() { let panel = this.browser.ownerDocument.querySelector( "#screenshotsPagePanel" ); await BrowserTestUtils.waitForCondition(async () => { if (!panel) { panel = this.browser.ownerDocument.querySelector( "#screenshotsPagePanel" ); } return panel?.state === "open" && BrowserTestUtils.is_visible(panel); }); return panel; } async waitForOverlay() { const panel = await this.waitForPanel(); ok(BrowserTestUtils.is_visible(panel), "Panel buttons are visible"); await BrowserTestUtils.waitForCondition(async () => { let init = await this.isOverlayInitialized(); return init; }); info("Overlay is visible"); } async waitForOverlayClosed() { let panel = this.browser.ownerDocument.querySelector( "#screenshotsPagePanel" ); if (!panel) { panel = await this.waitForPanel(); } await BrowserTestUtils.waitForMutationCondition( panel, { attributes: true }, () => { return BrowserTestUtils.is_hidden(panel); } ); ok(BrowserTestUtils.is_hidden(panel), "Panel buttons are hidden"); await BrowserTestUtils.waitForCondition(async () => { let init = !(await this.isOverlayInitialized()); info("Is overlay initialized: " + !init); return init; }); info("Overlay is not visible"); } async isOverlayInitialized() { return SpecialPowers.spawn(this.browser, [], () => { let screenshotsChild = content.windowGlobalChild.getActor( "ScreenshotsComponent" ); return screenshotsChild?._overlay?._initialized; }); } async getOverlayState() { return ContentTask.spawn(this.browser, null, async () => { let screenshotsChild = content.windowGlobalChild.getActor( "ScreenshotsComponent" ); return screenshotsChild._overlay.stateHandler.getState(); }); } async waitForStateChange(newState) { await BrowserTestUtils.waitForCondition(async () => { let state = await this.getOverlayState(); return state === newState; }, `Waiting for state change to ${newState}`); } async getHoverElementRect() { return ContentTask.spawn(this.browser, null, async () => { let screenshotsChild = content.windowGlobalChild.getActor( "ScreenshotsComponent" ); return screenshotsChild._overlay.stateHandler.getHoverElementBoxRect(); }); } async waitForHoverElementRect() { return TestUtils.waitForCondition(async () => { let rect = await this.getHoverElementRect(); return rect; }); } async waitForSelectionBoxSizeChange(currentWidth) { await ContentTask.spawn( this.browser, [currentWidth], async ([currWidth]) => { let screenshotsChild = content.windowGlobalChild.getActor( "ScreenshotsComponent" ); let dimensions = screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions(); // return dimensions.boxWidth; await ContentTaskUtils.waitForCondition(() => { dimensions = screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions(); return dimensions.boxWidth !== currWidth; }, "Wait for selection box width change"); } ); } /** * This will drag an overlay starting at the given startX and startY coordinates and ending * at the given endX and endY coordinates. * * endY should be at least 70px from the bottom of window and endX should be at least * 265px from the left of the window. If these requirements are not met then the * overlay buttons (cancel, copy, download) will be positioned different from the default * and the methods to click the overlay buttons will not work unless the updated * position coordinates are supplied. * See https://searchfox.org/mozilla-central/rev/af78418c4b5f2c8721d1a06486cf4cf0b33e1e8d/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs#1789,1798 * for how the overlay buttons are positioned when the overlay rect is near the bottom or * left edge of the window. * * Note: The distance of the rect should be greater than 40 to enter in the "dragging" state. * See https://searchfox.org/mozilla-central/rev/af78418c4b5f2c8721d1a06486cf4cf0b33e1e8d/browser/components/screenshots/ScreenshotsOverlayChild.sys.mjs#809 * @param {Number} startX The starting X coordinate. The left edge of the overlay rect. * @param {Number} startY The starting Y coordinate. The top edge of the overlay rect. * @param {Number} endX The end X coordinate. The right edge of the overlay rect. * @param {Number} endY The end Y coordinate. The bottom edge of the overlay rect. */ async dragOverlay(startX, startY, endX, endY) { await this.waitForStateChange("crosshairs"); let state = await this.getOverlayState(); Assert.equal(state, "crosshairs", "The overlay is in the crosshairs state"); mouse.down(startX, startY); await this.waitForStateChange("draggingReady"); state = await this.getOverlayState(); Assert.equal( state, "draggingReady", "The overlay is in the draggingReady state" ); mouse.move(endX, endY); await this.waitForStateChange("dragging"); state = await this.getOverlayState(); Assert.equal(state, "dragging", "The overlay is in the dragging state"); mouse.up(endX, endY); await this.waitForStateChange("selected"); state = await this.getOverlayState(); Assert.equal(state, "selected", "The overlay is in the selected state"); this.endX = endX; this.endY = endY; } async scrollContentWindow(x, y) { let promise = BrowserTestUtils.waitForContentEvent(this.browser, "scroll"); await ContentTask.spawn(this.browser, [x, y], async ([xPos, yPos]) => { content.window.scroll(xPos, yPos); await ContentTaskUtils.waitForCondition(() => { return ( content.window.scrollX === xPos && content.window.scrollY === yPos ); }, `Waiting for window to scroll to ${xPos}, ${yPos}`); }); await promise; } getWindowPosition() { return ContentTask.spawn(this.browser, [], () => { return { scrollX: content.window.scrollX, scrollY: content.window.scrollY, }; }); } async waitForScrollTo(x, y) { await ContentTask.spawn(this.browser, [x, y], async ([xPos, yPos]) => { await ContentTaskUtils.waitForCondition(() => { info( `Got scrollX: ${content.window.scrollX}. scrollY: ${content.window.scrollY}` ); return ( content.window.scrollX === xPos && content.window.scrollY === yPos ); }, `Waiting for window to scroll to ${xPos}, ${yPos}`); }); } clickDownloadButton() { // Click the download button with last x and y position from dragOverlay. // The middle of the copy button is last X - 70 and last Y + 36. // Ex. 500, 500 would be 530, 536 mouse.click(this.endX - 70, this.endY + 36); } clickCopyButton(overrideX = null, overrideY = null) { // Click the copy button with last x and y position from dragOverlay. // The middle of the copy button is last X - 183 and last Y + 36. // Ex. 500, 500 would be 317, 536 if (overrideX && overrideY) { mouse.click(overrideX - 183, overrideY + 36); } else { mouse.click(this.endX - 183, this.endY + 36); } } clickCancelButton() { // Click the cancel button with last x and y position from dragOverlay. // The middle of the copy button is last X - 259 and last Y + 36. // Ex. 500, 500 would be 241, 536 mouse.click(this.endX - 259, this.endY + 36); } async clickTestPageElement() { let rect = await ContentTask.spawn(this.browser, [], async () => { let ele = content.document.getElementById("testPageElement"); return ele.getBoundingClientRect(); }); let x = Math.floor(rect.x + rect.width / 2); let y = Math.floor(rect.y + rect.height / 2); mouse.move(x, y); await this.waitForHoverElementRect(); mouse.down(x, y); await this.waitForStateChange("draggingReady"); mouse.up(x, y); await this.waitForStateChange("selected"); } async zoomBrowser(zoom) { await SpecialPowers.spawn(this.browser, [zoom], zoomLevel => { const { Layout } = ChromeUtils.importESModule( "chrome://mochitests/content/browser/accessible/tests/browser/Layout.sys.mjs" ); Layout.zoomDocument(content.document, zoomLevel); }); } /** * Gets the dialog box * @returns The dialog box */ getDialog() { let currDialogBox = this.browser.tabDialogBox; let manager = currDialogBox.getTabDialogManager(); let dialogs = manager.hasDialogs && manager.dialogs; return dialogs[0]; } assertPanelVisible() { let panel = this.browser.ownerDocument.querySelector( "#screenshotsPagePanel" ); Assert.ok( BrowserTestUtils.is_visible(panel), "Screenshots panel is visible" ); } assertPanelNotVisible() { let panel = this.browser.ownerDocument.querySelector( "#screenshotsPagePanel" ); Assert.ok( BrowserTestUtils.is_hidden(panel), "Screenshots panel is not visible" ); } /** * Copied from screenshots extension * Returns a promise that resolves when the clipboard data has changed * Otherwise rejects */ waitForRawClipboardChange() { const initialClipboardData = Date.now().toString(); SpecialPowers.clipboardCopyString(initialClipboardData); let promiseChanged = TestUtils.waitForCondition(() => { let data; try { data = getRawClipboardData("image/png"); } catch (e) { console.log("Failed to get image/png clipboard data:", e); return false; } return data && initialClipboardData !== data; }); return promiseChanged; } /** * Gets the client and scroll demensions on the window * @returns { Object } * clientHeight The visible height * clientWidth The visible width * scrollHeight The scrollable height * scrollWidth The scrollable width */ getContentDimensions() { return SpecialPowers.spawn(this.browser, [], async function () { let { innerWidth, innerHeight, scrollMaxX, scrollMaxY } = content.window; let width = innerWidth + scrollMaxX; let height = innerHeight + scrollMaxY; const scrollbarHeight = {}; const scrollbarWidth = {}; content.window.windowUtils.getScrollbarSize( false, scrollbarWidth, scrollbarHeight ); width -= scrollbarWidth.value; height -= scrollbarHeight.value; innerWidth -= scrollbarWidth.value; innerHeight -= scrollbarHeight.value; return { clientHeight: innerHeight, clientWidth: innerWidth, scrollHeight: height, scrollWidth: width, }; }); } getSelectionLayerDimensions() { return ContentTask.spawn(this.browser, null, async () => { let screenshotsChild = content.windowGlobalChild.getActor( "ScreenshotsComponent" ); Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists"); return screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions(); }); } async waitForSelectionLayerDimensionChange(oldWidth, oldHeight) { await ContentTask.spawn( this.browser, [oldWidth, oldHeight], async ([prevWidth, prevHeight]) => { let screenshotsChild = content.windowGlobalChild.getActor( "ScreenshotsComponent" ); await ContentTaskUtils.waitForCondition(() => { let dimensions = screenshotsChild._overlay.screenshotsContainer.getSelectionLayerDimensions(); info( `old height: ${prevHeight}. new height: ${dimensions.scrollHeight}.\nold width: ${prevWidth}. new width: ${dimensions.scrollWidth}` ); return ( dimensions.scrollHeight !== prevHeight && dimensions.scrollWidth !== prevWidth ); }, "Wait for selection box width change"); } ); } getSelectionBoxDimensions() { return ContentTask.spawn(this.browser, null, async () => { let screenshotsChild = content.windowGlobalChild.getActor( "ScreenshotsComponent" ); Assert.ok(screenshotsChild._overlay._initialized, "The overlay exists"); return screenshotsChild._overlay.screenshotsContainer.getSelectionLayerBoxDimensions(); }); } /** * Clicks an element on the screen * @param eleSel The selector for the element to click */ async clickUIElement(eleSel) { await SpecialPowers.spawn( this.browser, [eleSel], async function (eleSelector) { info( `in clickScreenshotsUIElement content function, eleSelector: ${eleSelector}` ); const EventUtils = ContentTaskUtils.getEventUtils(content); let ele = content.document.querySelector(eleSelector); info(`Found the thing to click: ${eleSelector}: ${!!ele}`); EventUtils.synthesizeMouseAtCenter(ele, {}); // wait a frame for the screenshots UI to finish any init await new content.Promise(res => content.requestAnimationFrame(res)); } ); } /** * Copied from screenshots extension * A helper that returns the size of the image that was just put into the clipboard by the * :screenshot command. * @return The {width, height, color} dimension and color object. */ async getImageSizeAndColorFromClipboard() { let flavor = "image/png"; let image = getRawClipboardData(flavor); ok(image, "screenshot data exists on the clipboard"); // 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 could mess all sorts of things up return SpecialPowers.spawn( this.browser, [buffer], async function (_buffer) { const img = content.document.createElement("img"); const loaded = new Promise(r => { img.addEventListener("load", r, { once: true }); }); const url = content.URL.createObjectURL( new Blob([_buffer], { type: "image/png" }) ); img.src = url; content.document.documentElement.appendChild(img); info("Waiting for the clipboard image to load in the content page"); await loaded; let canvas = content.document.createElementNS( "http://www.w3.org/1999/xhtml", "html:canvas" ); let context = canvas.getContext("2d"); canvas.width = img.width; canvas.height = img.height; context.drawImage(img, 0, 0); let topLeft = context.getImageData(0, 0, 1, 1); let topRight = context.getImageData(img.width - 1, 0, 1, 1); let bottomLeft = context.getImageData(0, img.height - 1, 1, 1); let bottomRight = context.getImageData( img.width - 1, img.height - 1, 1, 1 ); img.remove(); content.URL.revokeObjectURL(url); return { width: img.width, height: img.height, color: { topLeft: topLeft.data, topRight: topRight.data, bottomLeft: bottomLeft.data, bottomRight: bottomRight.data, }, }; } ); } } /** * Get the raw clipboard data * @param flavor Type of data to get from clipboard * @returns The data from the clipboard */ function getRawClipboardData(flavor) { const whichClipboard = Services.clipboard.kGlobalClipboard; const xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( Ci.nsITransferable ); xferable.init(null); xferable.addDataFlavor(flavor); Services.clipboard.getData(xferable, whichClipboard); let data = {}; try { // xferable.getTransferData(flavor, data); xferable.getAnyTransferData({}, data); info(JSON.stringify(data, null, 2)); } catch (e) { info(e); } data = data.value || null; return data; } /** * Synthesize a mouse event on an element, after ensuring that it is visible * in the viewport. * * @param {String} selector: The node selector to get the node target for the event. * @param {number} x * @param {number} y * @param {object} options: Options that will be passed to BrowserTestUtils.synthesizeMouse */ async function safeSynthesizeMouseEventInContentPage( selector, x, y, options = {} ) { let context = gBrowser.selectedBrowser.browsingContext; BrowserTestUtils.synthesizeMouse(selector, x, y, options, context); } add_setup(async () => { CustomizableUI.addWidgetToArea( "screenshot-button", CustomizableUI.AREA_NAVBAR ); let screenshotBtn = document.getElementById("screenshot-button"); Assert.ok(screenshotBtn, "The screenshots button was added to the nav bar"); }); function getContentDevicePixelRatio(browser) { return SpecialPowers.spawn(browser, [], async function () { return content.window.devicePixelRatio; }); } async function clearAllTelemetryEvents() { // Clear everything. info("Clearing all telemetry events"); await TestUtils.waitForCondition(() => { Services.telemetry.clearEvents(); let events = Services.telemetry.snapshotEvents( Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, true ); let content = events.content; let parent = events.parent; return (!content && !parent) || (!content.length && !parent.length); }); } async function waitForScreenshotsEventCount(count, process = "parent") { await TestUtils.waitForCondition( () => { let events = TelemetryTestUtils.getEvents( { category: "screenshots" }, { process } ); info(`Got ${events?.length} event(s)`); info(`Actual events: ${JSON.stringify(events, null, 2)}`); return events.length === count ? events : null; }, `Waiting for ${count} ${process} event(s).`, 200, 100 ); } async function assertScreenshotsEvents(expectedEvents, process = "parent") { info(`Expected events: ${JSON.stringify(expectedEvents, null, 2)}`); // Make sure we have recorded the correct number of events await waitForScreenshotsEventCount(expectedEvents.length, process); TelemetryTestUtils.assertEvents( expectedEvents, { category: "screenshots", clear: true }, { process } ); }