From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- .../tools/mozscreenshots/controlCenter/browser.ini | 8 + .../controlCenter/browser_controlCenter.js | 16 + browser/tools/mozscreenshots/devtools/browser.ini | 7 + .../mozscreenshots/devtools/browser_devtools.js | 23 + browser/tools/mozscreenshots/head.js | 65 +++ browser/tools/mozscreenshots/moz.build | 37 ++ .../mozscreenshots/extension/Makefile.in | 12 + .../mozscreenshots/extension/Screenshot.sys.mjs | 196 +++++++ .../mozscreenshots/extension/TestRunner.sys.mjs | 650 +++++++++++++++++++++ .../mozscreenshots/mozscreenshots/extension/api.js | 26 + .../extension/configurations/AppMenu.sys.mjs | 77 +++ .../extension/configurations/Buttons.sys.mjs | 97 +++ .../extension/configurations/ControlCenter.sys.mjs | 320 ++++++++++ .../extension/configurations/CustomizeMode.sys.mjs | 72 +++ .../extension/configurations/DevTools.sys.mjs | 74 +++ .../configurations/LightweightThemes.sys.mjs | 51 ++ .../configurations/PermissionPrompts.sys.mjs | 168 ++++++ .../extension/configurations/Preferences.sys.mjs | 183 ++++++ .../extension/configurations/Tabs.sys.mjs | 219 +++++++ .../configurations/TabsInTitlebar.sys.mjs | 28 + .../extension/configurations/Toolbars.sys.mjs | 54 ++ .../extension/configurations/UIDensities.sys.mjs | 42 ++ .../extension/configurations/WindowSize.sys.mjs | 68 +++ .../mozscreenshots/extension/lib/borderify.xpi | Bin 0 -> 1611 bytes .../extension/lib/controlCenter/mixed.html | 15 + .../extension/lib/controlCenter/mixed_active.html | 14 + .../extension/lib/controlCenter/mixed_passive.html | 14 + .../extension/lib/controlCenter/password.html | 17 + .../extension/lib/controlCenter/tracking.html | 17 + .../extension/lib/mozscreenshots-script.js | 21 + .../extension/lib/mozscreenshots-style.css | 28 + .../extension/lib/mozscreenshots.html | 83 +++ .../extension/lib/permissionPrompts.html | 64 ++ .../mozscreenshots/extension/lib/robot.png | Bin 0 -> 7599 bytes .../mozscreenshots/extension/lib/robot_center.png | Bin 0 -> 2319 bytes .../extension/lib/robot_cropped_diagonal.png | Bin 0 -> 1306 bytes .../extension/lib/robot_diagonal.png | Bin 0 -> 2100 bytes .../extension/lib/robot_uncropped.png | Bin 0 -> 6596 bytes .../extension/lib/robot_upperleft.png | Bin 0 -> 1594 bytes .../mozscreenshots/extension/manifest.json | 23 + .../mozscreenshots/extension/moz.build | 58 ++ .../mozscreenshots/extension/schema.json | 1 + .../mozscreenshots/permissionPrompts/browser.ini | 10 + .../permissionPrompts/browser_permissionPrompts.js | 16 + .../tools/mozscreenshots/preferences/browser.ini | 7 + .../preferences/browser_preferences.js | 16 + browser/tools/mozscreenshots/primaryUI/browser.ini | 7 + .../mozscreenshots/primaryUI/browser_primaryUI.js | 23 + .../tools/mozscreenshots/tests/browser/browser.ini | 8 + .../tests/browser/browser_boundingbox.js | 173 ++++++ .../tests/browser/browser_screenshots.js | 21 + .../tests/browser/browser_screenshots_cropping.js | 153 +++++ .../tests/xpcshell/test_testConfigurations.js | 64 ++ .../mozscreenshots/tests/xpcshell/xpcshell.ini | 5 + 54 files changed, 3351 insertions(+) create mode 100644 browser/tools/mozscreenshots/controlCenter/browser.ini create mode 100644 browser/tools/mozscreenshots/controlCenter/browser_controlCenter.js create mode 100644 browser/tools/mozscreenshots/devtools/browser.ini create mode 100644 browser/tools/mozscreenshots/devtools/browser_devtools.js create mode 100644 browser/tools/mozscreenshots/head.js create mode 100644 browser/tools/mozscreenshots/moz.build create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/Makefile.in create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/Screenshot.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/api.js create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/AppMenu.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/Buttons.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/ControlCenter.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/CustomizeMode.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/DevTools.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/LightweightThemes.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/PermissionPrompts.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/Preferences.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/Tabs.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/TabsInTitlebar.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/Toolbars.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/UIDensities.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/configurations/WindowSize.sys.mjs create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/borderify.xpi create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/controlCenter/mixed.html create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/controlCenter/mixed_active.html create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/controlCenter/mixed_passive.html create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/controlCenter/password.html create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/controlCenter/tracking.html create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/mozscreenshots-script.js create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/mozscreenshots-style.css create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/mozscreenshots.html create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/permissionPrompts.html create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/robot.png create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/robot_center.png create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/robot_cropped_diagonal.png create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/robot_diagonal.png create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/robot_uncropped.png create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/lib/robot_upperleft.png create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/manifest.json create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/moz.build create mode 100644 browser/tools/mozscreenshots/mozscreenshots/extension/schema.json create mode 100644 browser/tools/mozscreenshots/permissionPrompts/browser.ini create mode 100644 browser/tools/mozscreenshots/permissionPrompts/browser_permissionPrompts.js create mode 100644 browser/tools/mozscreenshots/preferences/browser.ini create mode 100644 browser/tools/mozscreenshots/preferences/browser_preferences.js create mode 100644 browser/tools/mozscreenshots/primaryUI/browser.ini create mode 100644 browser/tools/mozscreenshots/primaryUI/browser_primaryUI.js create mode 100644 browser/tools/mozscreenshots/tests/browser/browser.ini create mode 100644 browser/tools/mozscreenshots/tests/browser/browser_boundingbox.js create mode 100644 browser/tools/mozscreenshots/tests/browser/browser_screenshots.js create mode 100644 browser/tools/mozscreenshots/tests/browser/browser_screenshots_cropping.js create mode 100644 browser/tools/mozscreenshots/tests/xpcshell/test_testConfigurations.js create mode 100644 browser/tools/mozscreenshots/tests/xpcshell/xpcshell.ini (limited to 'browser/tools/mozscreenshots') diff --git a/browser/tools/mozscreenshots/controlCenter/browser.ini b/browser/tools/mozscreenshots/controlCenter/browser.ini new file mode 100644 index 0000000000..d641364fa3 --- /dev/null +++ b/browser/tools/mozscreenshots/controlCenter/browser.ini @@ -0,0 +1,8 @@ +[DEFAULT] +subsuite = screenshots +support-files = + ../head.js + +[browser_controlCenter.js] +https_first_disabled = true +skip-if = os == 'mac' # macosx1014 times out, see bug 1554821 diff --git a/browser/tools/mozscreenshots/controlCenter/browser_controlCenter.js b/browser/tools/mozscreenshots/controlCenter/browser_controlCenter.js new file mode 100644 index 0000000000..e5692ddf0d --- /dev/null +++ b/browser/tools/mozscreenshots/controlCenter/browser_controlCenter.js @@ -0,0 +1,16 @@ +/* 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/. */ + +/* import-globals-from ../head.js */ + +"use strict"; + +add_task(async function capture() { + if (!shouldCapture()) { + return; + } + let sets = ["LightweightThemes", "ControlCenter"]; + + await TestRunner.start(sets, "controlCenter"); +}); diff --git a/browser/tools/mozscreenshots/devtools/browser.ini b/browser/tools/mozscreenshots/devtools/browser.ini new file mode 100644 index 0000000000..551112ef26 --- /dev/null +++ b/browser/tools/mozscreenshots/devtools/browser.ini @@ -0,0 +1,7 @@ +[DEFAULT] +subsuite = screenshots +support-files = + ../head.js + +[browser_devtools.js] +skip-if = os == 'mac' # times out on macosx1014, see 1570100 diff --git a/browser/tools/mozscreenshots/devtools/browser_devtools.js b/browser/tools/mozscreenshots/devtools/browser_devtools.js new file mode 100644 index 0000000000..7d0efe0319 --- /dev/null +++ b/browser/tools/mozscreenshots/devtools/browser_devtools.js @@ -0,0 +1,23 @@ +/* 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/. */ + +/* import-globals-from ../head.js */ + +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); +const { gDevTools } = require("devtools/client/framework/devtools"); + +add_task(async function capture() { + if (!shouldCapture()) { + return; + } + let sets = ["DevTools"]; + + await TestRunner.start(sets, "devtools"); + + await gDevTools.closeToolboxForTab(gBrowser.selectedTab); +}); diff --git a/browser/tools/mozscreenshots/head.js b/browser/tools/mozscreenshots/head.js new file mode 100644 index 0000000000..5e21ca75aa --- /dev/null +++ b/browser/tools/mozscreenshots/head.js @@ -0,0 +1,65 @@ +/* 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/. */ + +/* exported TestRunner, shouldCapture */ + +"use strict"; + +const chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService( + Ci.nsIChromeRegistry +); +const EXTENSION_DIR = + "chrome://mochitests/content/browser/browser/tools/mozscreenshots/mozscreenshots/extension/mozscreenshots/browser/"; + +let TestRunner; + +async function setup() { + // This timeout doesn't actually end the job even if it is hit - the buildbot timeout will + // handle things for us if the test actually hangs. + requestLongerTimeout(100); + + // Generate output so mozprocess knows we're still alive for the long session. + SimpleTest.requestCompleteLog(); + + info("installing extension temporarily"); + let chromeURL = Services.io.newURI(EXTENSION_DIR); + let dir = chromeRegistry + .convertChromeURL(chromeURL) + .QueryInterface(Ci.nsIFileURL).file; + await AddonManager.installTemporaryAddon(dir); + + info("Checking for mozscreenshots extension"); + + let aAddon = await AddonManager.getAddonByID("mozscreenshots@mozilla.org"); + isnot(aAddon, null, "The mozscreenshots extension should be installed"); + TestRunner = ChromeUtils.importESModule( + "resource://mozscreenshots/TestRunner.sys.mjs" + ).TestRunner; + TestRunner.initTest(this); +} + +/** + * Used by pre-defined sets of configurations to decide whether to run for a build. + * @note This is not used by browser_screenshots.js which handles when MOZSCREENSHOTS_SETS is set. + * @return {bool} whether to capture screenshots. + */ +function shouldCapture() { + if (Services.env.get("MOZSCREENSHOTS_SETS")) { + ok( + true, + "MOZSCREENSHOTS_SETS was specified so only capture what was " + + "requested (in browser_screenshots.js)" + ); + return false; + } + + if (AppConstants.MOZ_UPDATE_CHANNEL == "nightly") { + ok(true, "Screenshots aren't captured on Nightlies"); + return false; + } + + return true; +} + +add_setup(setup); diff --git a/browser/tools/mozscreenshots/moz.build b/browser/tools/mozscreenshots/moz.build new file mode 100644 index 0000000000..6408b1187b --- /dev/null +++ b/browser/tools/mozscreenshots/moz.build @@ -0,0 +1,37 @@ +# -*- 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/. + +with Files("**"): + BUG_COMPONENT = ("Firefox", "General") + +with Files("controlCenter/**"): + BUG_COMPONENT = ("Firefox", "Site Identity") + +with Files("devtools/**"): + BUG_COMPONENT = ("DevTools", "General") + +with Files("permissionPrompts/**"): + BUG_COMPONENT = ("Firefox", "Site Permissions") + +with Files("preferences/**"): + BUG_COMPONENT = ("Firefox", "Settings UI") + +BROWSER_CHROME_MANIFESTS += [ + # Each test is in it's own directory so it gets run in a clean profile with + # run-by-dir. + "controlCenter/browser.ini", + "devtools/browser.ini", + "permissionPrompts/browser.ini", + "preferences/browser.ini", + "primaryUI/browser.ini", + "tests/browser/browser.ini", +] + +TEST_DIRS += [ + "mozscreenshots/extension", +] + +XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.ini"] diff --git a/browser/tools/mozscreenshots/mozscreenshots/extension/Makefile.in b/browser/tools/mozscreenshots/mozscreenshots/extension/Makefile.in new file mode 100644 index 0000000000..aa496e27af --- /dev/null +++ b/browser/tools/mozscreenshots/mozscreenshots/extension/Makefile.in @@ -0,0 +1,12 @@ +# 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/. + +OUTPUT_DIR = $(DEPTH)/_tests/testing/mochitest/browser/browser/tools/mozscreenshots/mozscreenshots/extension +GENERATED_DIRS = $(OUTPUT_DIR) +XPI_PKGNAME = mozscreenshots@mozilla.org + +include $(topsrcdir)/config/rules.mk + +libs:: + (cd $(DIST)/xpi-stage && tar $(TAR_CREATE_FLAGS) - $(XPI_NAME)) | (cd $(OUTPUT_DIR) && tar -xf -) diff --git a/browser/tools/mozscreenshots/mozscreenshots/extension/Screenshot.sys.mjs b/browser/tools/mozscreenshots/mozscreenshots/extension/Screenshot.sys.mjs new file mode 100644 index 0000000000..3db9fe3e0c --- /dev/null +++ b/browser/tools/mozscreenshots/mozscreenshots/extension/Screenshot.sys.mjs @@ -0,0 +1,196 @@ +/* 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/. */ + +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +// Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. +// See LOG_LEVELS in Console.sys.mjs. Common examples: "All", "Info", "Warn", & +// "Error". +const PREF_LOG_LEVEL = "extensions.mozscreenshots@mozilla.org.loglevel"; +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevel: "info", + maxLogLevelPref: PREF_LOG_LEVEL, + prefix: "mozscreenshots", + }; + return new ConsoleAPI(consoleOptions); +}); + +export var Screenshot = { + _extensionPath: null, + _path: null, + _imagePrefix: "", + _imageExtension: ".png", + _screenshotFunction: null, + + init(path, extensionPath, imagePrefix = "") { + this._path = path; + + let dir = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + dir.initWithPath(this._path); + if (!dir.exists()) { + dir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + + this._extensionPath = extensionPath; + this._imagePrefix = imagePrefix; + switch (Services.appinfo.OS) { + case "WINNT": + this._screenshotFunction = this._screenshotWindows; + break; + case "Darwin": + this._screenshotFunction = this._screenshotOSX; + break; + case "Linux": + this._screenshotFunction = this._screenshotLinux; + break; + default: + throw new Error("Unsupported operating system"); + } + }, + + _buildImagePath(baseName) { + return PathUtils.join( + this._path, + this._imagePrefix + baseName + this._imageExtension + ); + }, + + // Capture the whole screen using an external application. + async captureExternal(filename) { + let imagePath = this._buildImagePath(filename); + await this._screenshotFunction(imagePath); + lazy.log.debug("saved screenshot: " + filename); + return imagePath; + }, + + // helpers + + _screenshotWindows(filename) { + return new Promise((resolve, reject) => { + let exe = Services.dirsvc.get("GreBinD", Ci.nsIFile); + exe.append("screenshot.exe"); + if (!exe.exists()) { + exe = Services.dirsvc.get("CurWorkD", Ci.nsIFile).parent; + exe.append("bin"); + exe.append("screenshot.exe"); + } + let process = Cc["@mozilla.org/process/util;1"].createInstance( + Ci.nsIProcess + ); + process.init(exe); + + let args = [filename]; + process.runAsync( + args, + args.length, + this._processObserver(resolve, reject) + ); + }); + }, + + async _screenshotOSX(filename) { + let screencapture = (windowID = null) => { + return new Promise((resolve, reject) => { + // Get the screencapture executable + let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath("/usr/sbin/screencapture"); + + let process = Cc["@mozilla.org/process/util;1"].createInstance( + Ci.nsIProcess + ); + process.init(file); + + // Run the process. + let args = ["-x", "-t", "png"]; + args.push(filename); + process.runAsync( + args, + args.length, + this._processObserver(resolve, reject) + ); + }); + }; + + function readWindowID() { + return IOUtils.readUTF8("/tmp/mozscreenshots-windowid"); + } + + let promiseWindowID = () => { + return new Promise((resolve, reject) => { + // Get the window ID of the application (assuming its front-most) + let osascript = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + osascript.initWithPath("/bin/bash"); + + let osascriptP = Cc["@mozilla.org/process/util;1"].createInstance( + Ci.nsIProcess + ); + osascriptP.init(osascript); + let osaArgs = [ + "-c", + "/usr/bin/osascript -e 'tell application (path to frontmost application as text) to set winID to id of window 1' > /tmp/mozscreenshots-windowid", + ]; + osascriptP.runAsync( + osaArgs, + osaArgs.length, + this._processObserver(resolve, reject) + ); + }); + }; + + await promiseWindowID(); + let windowID = await readWindowID(); + await screencapture(windowID); + }, + + _screenshotLinux(filename) { + return new Promise((resolve, reject) => { + let exe = Services.dirsvc.get("GreBinD", Ci.nsIFile); + exe.append("screentopng"); + if (!exe.exists()) { + exe = Services.dirsvc.get("CurWorkD", Ci.nsIFile).parent; + exe.append("bin"); + exe.append("screentopng"); + } + let process = Cc["@mozilla.org/process/util;1"].createInstance( + Ci.nsIProcess + ); + process.init(exe); + + let args = [filename]; + process.runAsync( + args, + args.length, + this._processObserver(resolve, reject) + ); + }); + }, + + _processObserver(resolve, reject) { + return { + observe(subject, topic, data) { + switch (topic) { + case "process-finished": + try { + // Wait 1s after process to resolve + setTimeout(resolve, 1000); + } catch (ex) { + reject(ex); + } + break; + default: + reject(topic); + break; + } + }, + }; + }, +}; diff --git a/browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs b/browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs new file mode 100644 index 0000000000..c0623d6e72 --- /dev/null +++ b/browser/tools/mozscreenshots/mozscreenshots/extension/TestRunner.sys.mjs @@ -0,0 +1,650 @@ +/* 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/. */ + +const APPLY_CONFIG_TIMEOUT_MS = 60 * 1000; +const HOME_PAGE = "resource://mozscreenshots/lib/mozscreenshots.html"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { setTimeout } from "resource://gre/modules/Timer.sys.mjs"; +import { Rect } from "resource://gre/modules/Geometry.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BrowserTestUtils: "resource://testing-common/BrowserTestUtils.sys.mjs", + // Screenshot.sys.mjs must be imported this way for xpcshell tests to work + Screenshot: "resource://mozscreenshots/Screenshot.sys.mjs", +}); + +export var TestRunner = { + combos: null, + completedCombos: 0, + currentComboIndex: 0, + _lastCombo: null, + _libDir: null, + croppingPadding: 0, + mochitestScope: null, + + init(extensionPath) { + this._extensionPath = extensionPath; + this.setupOS(); + }, + + /** + * Initialize the mochitest interface. This allows TestRunner to integrate + * with mochitest functions like is(...) and ok(...). This must be called + * prior to invoking any of the TestRunner functions. Note that this should + * be properly setup in head.js, so you probably don't need to call it. + */ + initTest(mochitestScope) { + this.mochitestScope = mochitestScope; + }, + + setupOS() { + switch (AppConstants.platform) { + case "macosx": { + this.disableNotificationCenter(); + break; + } + } + }, + + disableNotificationCenter() { + let killall = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + killall.initWithPath("/bin/bash"); + + let killallP = Cc["@mozilla.org/process/util;1"].createInstance( + Ci.nsIProcess + ); + killallP.init(killall); + let ncPlist = + "/System/Library/LaunchAgents/com.apple.notificationcenterui.plist"; + let killallArgs = [ + "-c", + `/bin/launchctl unload -w ${ncPlist} && ` + + "/usr/bin/killall -v NotificationCenter", + ]; + killallP.run(true, killallArgs, killallArgs.length); + }, + + /** + * Load specified sets, execute all combinations of them, and capture screenshots. + */ + async start(setNames, jobName = null) { + let subDirs = [ + "mozscreenshots", + new Date().toISOString().replace(/:/g, "-") + "_" + Services.appinfo.OS, + ]; + let screenshotPath = PathUtils.join(PathUtils.tempDir, ...subDirs); + + const MOZ_UPLOAD_DIR = Services.env.get("MOZ_UPLOAD_DIR"); + const GECKO_HEAD_REPOSITORY = Services.env.get("GECKO_HEAD_REPOSITORY"); + // We don't want to upload images (from MOZ_UPLOAD_DIR) on integration + // branches in order to reduce bandwidth/storage. + if (MOZ_UPLOAD_DIR && !GECKO_HEAD_REPOSITORY.includes("/integration/")) { + screenshotPath = MOZ_UPLOAD_DIR; + } + + this.mochitestScope.info(`Saving screenshots to: ${screenshotPath}`); + + let screenshotPrefix = Services.appinfo.appBuildID; + if (jobName) { + screenshotPrefix += "-" + jobName; + } + screenshotPrefix += "_"; + lazy.Screenshot.init(screenshotPath, this._extensionPath, screenshotPrefix); + this._libDir = this._extensionPath + .QueryInterface(Ci.nsIFileURL) + .file.clone(); + this._libDir.append("chrome"); + this._libDir.append("mozscreenshots"); + this._libDir.append("lib"); + + let sets = this.loadSets(setNames); + + this.mochitestScope.info(`${sets.length} sets: ${setNames}`); + this.combos = new LazyProduct(sets); + this.mochitestScope.info(this.combos.length + " combinations"); + + this.currentComboIndex = this.completedCombos = 0; + this._lastCombo = null; + + // Setup some prefs + Services.prefs.setCharPref( + "extensions.ui.lastCategory", + "addons://list/extension" + ); + // Don't let the caret blink since it causes false positives for image diffs + Services.prefs.setIntPref("ui.caretBlinkTime", -1); + // Disable some animations that can cause false positives, such as the + // reload/stop button spinning animation. + Services.prefs.setIntPref("ui.prefersReducedMotion", 1); + + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + + // Prevent the mouse cursor from causing hover styles or tooltips to appear. + browserWindow.windowUtils.disableNonTestMouseEvents(true); + + // When being automated through Marionette, Firefox shows a prominent indication + // in the urlbar and identity block. We don't want this to show when testing browser UI. + // Note that this doesn't prevent subsequently opened windows from showing the automation UI. + browserWindow.document + .getElementById("main-window") + .removeAttribute("remotecontrol"); + + let selectedBrowser = browserWindow.gBrowser.selectedBrowser; + lazy.BrowserTestUtils.loadURIString(selectedBrowser, HOME_PAGE); + await lazy.BrowserTestUtils.browserLoaded(selectedBrowser); + + for (let i = 0; i < this.combos.length; i++) { + this.currentComboIndex = i; + await this._performCombo(this.combos.item(this.currentComboIndex)); + } + + this.mochitestScope.info( + "Done: Completed " + + this.completedCombos + + " out of " + + this.combos.length + + " configurations." + ); + this.cleanup(); + }, + + /** + * Helper function for loadSets. This filters out the restricted configs from setName. + * This was made a helper function to facilitate xpcshell unit testing. + * @param {String} setName - set name to be filtered e.g. "Toolbars[onlyNavBar,allToolbars]" + * @return {Object} Returns an object with two values: the filtered set name and a set of + * restricted configs. + */ + filterRestrictions(setName) { + let match = /\[([^\]]+)\]$/.exec(setName); + if (!match) { + throw new Error(`Invalid restrictions in ${setName}`); + } + // Trim the restrictions from the set name. + setName = setName.slice(0, match.index); + let restrictions = match[1] + .split(",") + .reduce((set, name) => set.add(name.trim()), new Set()); + + return { trimmedSetName: setName, restrictions }; + }, + + /** + * Load sets of configurations from JSMs. + * @param {String[]} setNames - array of set names (e.g. ["Tabs", "WindowSize"]. + * @return {Object[]} Array of sets containing `name` and `configurations` properties. + */ + loadSets(setNames) { + let sets = []; + for (let setName of setNames) { + let restrictions = null; + if (setName.includes("[")) { + let filteredData = this.filterRestrictions(setName); + setName = filteredData.trimmedSetName; + restrictions = filteredData.restrictions; + } + let imported = ChromeUtils.import( + `resource://mozscreenshots/configurations/${setName}.jsm` + ); + imported[setName].init(this._libDir); + let configurationNames = Object.keys(imported[setName].configurations); + if (!configurationNames.length) { + throw new Error( + setName + " has no configurations for this environment" + ); + } + // Checks to see if nonexistent configuration have been specified + if (restrictions) { + let incorrectConfigs = [...restrictions].filter( + r => !configurationNames.includes(r) + ); + if (incorrectConfigs.length) { + throw new Error("non existent configurations: " + incorrectConfigs); + } + } + let configurations = {}; + for (let config of configurationNames) { + // Automatically set the name property of the configuration object to + // its name from the configuration object. + imported[setName].configurations[config].name = config; + // Filter restricted configurations. + if (!restrictions || restrictions.has(config)) { + configurations[config] = imported[setName].configurations[config]; + } + } + sets.push(configurations); + } + return sets; + }, + + cleanup() { + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + let gBrowser = browserWindow.gBrowser; + while (gBrowser.tabs.length > 1) { + gBrowser.removeTab(gBrowser.selectedTab, { animate: false }); + } + gBrowser.unpinTab(gBrowser.selectedTab); + lazy.BrowserTestUtils.loadURIString( + gBrowser.selectedBrowser, + "data:text/html;charset=utf-8,

Done!" + ); + browserWindow.restore(); + Services.prefs.clearUserPref("ui.caretBlinkTime"); + Services.prefs.clearUserPref("ui.prefersReducedMotion"); + browserWindow.windowUtils.disableNonTestMouseEvents(false); + }, + + // helpers + + /** + * Calculate the bounding box based on CSS selector from config for cropping + * + * @param {String[]} selectors - array of CSS selectors for relevant DOM element + * @return {Geometry.sys.mjs Rect} Rect holding relevant x, y, width, height with padding + **/ + _findBoundingBox(selectors, windowType) { + if (!selectors.length) { + throw new Error("No selectors specified."); + } + + // Set window type, default "navigator:browser" + windowType = windowType || "navigator:browser"; + let browserWindow = Services.wm.getMostRecentWindow(windowType); + // Scale for high-density displays + const scale = Cc["@mozilla.org/gfx/screenmanager;1"] + .getService(Ci.nsIScreenManager) + .screenForRect( + browserWindow.screenX, + browserWindow.screenY, + 1, + 1 + ).defaultCSSScaleFactor; + + const windowLeft = browserWindow.screenX * scale; + const windowTop = browserWindow.screenY * scale; + const windowWidth = browserWindow.outerWidth * scale; + const windowHeight = browserWindow.outerHeight * scale; + + let bounds; + const rects = []; + // Grab bounding boxes and find the union + for (let selector of selectors) { + let elements; + // Check for function to find anonymous content + if (typeof selector == "function") { + elements = [selector()]; + } else { + elements = browserWindow.document.querySelectorAll(selector); + } + + if (!elements.length) { + throw new Error(`No element for '${selector}' found.`); + } + + for (let element of elements) { + // Calculate box region, convert to Rect + let elementRect = element.getBoundingClientRect(); + // ownerGlobal doesn't exist in content privileged windows. + // eslint-disable-next-line mozilla/use-ownerGlobal + let win = element.ownerDocument.defaultView; + let rect = new Rect( + (win.mozInnerScreenX + elementRect.left) * scale, + (win.mozInnerScreenY + elementRect.top) * scale, + elementRect.width * scale, + elementRect.height * scale + ); + rect.inflateFixed(this.croppingPadding * scale); + rect.left = Math.max(rect.left, windowLeft); + rect.top = Math.max(rect.top, windowTop); + rect.right = Math.min(rect.right, windowLeft + windowWidth); + rect.bottom = Math.min(rect.bottom, windowTop + windowHeight); + + if (rect.width === 0 && rect.height === 0) { + this.mochitestScope.todo( + false, + `Selector '${selector}' gave a 0x0 rect` + ); + continue; + } + + rects.push(rect); + + if (!bounds) { + bounds = rect; + } else { + bounds = bounds.union(rect); + } + } + } + + return { bounds, rects }; + }, + + _do_skip(reason, combo, config, func) { + const { todo } = reason; + if (todo) { + this.mochitestScope.todo( + false, + `Skipped configuration ` + + `[ ${combo.map(e => e.name).join(", ")} ] for failure in ` + + `${config.name}.${func}: ${todo}` + ); + } else { + this.mochitestScope.info( + `\tSkipped configuration ` + + `[ ${combo.map(e => e.name).join(", ")} ] ` + + `for "${reason}" in ${config.name}.${func}` + ); + } + }, + + async _performCombo(combo) { + let paddedComboIndex = padLeft( + this.currentComboIndex + 1, + String(this.combos.length).length + ); + this.mochitestScope.info( + `Combination ${paddedComboIndex}/${this.combos.length}: ${this._comboName( + combo + ).substring(1)}` + ); + + // Notice that this does need to be a closure, not a function, as otherwise + // "this" gets replaced and we lose access to this.mochitestScope. + const changeConfig = config => { + this.mochitestScope.info("calling " + config.name); + + let applyPromise = Promise.resolve(config.applyConfig()); + let timeoutPromise = new Promise((resolve, reject) => { + setTimeout(reject, APPLY_CONFIG_TIMEOUT_MS, "Timed out"); + }); + + this.mochitestScope.info("called " + config.name); + // Add a default timeout of 700ms to avoid conflicts when configurations + // try to apply at the same time. e.g WindowSize and TabsInTitlebar + return Promise.race([applyPromise, timeoutPromise]).then(result => { + return new Promise(resolve => { + setTimeout(() => resolve(result), 700); + }); + }); + }; + + try { + // First go through and actually apply all of the configs + for (let i = 0; i < combo.length; i++) { + let config = combo[i]; + if (!this._lastCombo || config !== this._lastCombo[i]) { + this.mochitestScope.info(`promising ${config.name}`); + const reason = await changeConfig(config); + if (reason) { + this._do_skip(reason, combo, config, "applyConfig"); + return; + } + } + } + + // Update the lastCombo since it's now been applied regardless of whether it's accepted below. + this.mochitestScope.info( + "fulfilled all applyConfig so setting lastCombo." + ); + this._lastCombo = combo; + + // Then ask configs if the current setup is valid. We can't can do this in + // the applyConfig methods of the config since it doesn't know what configs + // later in the loop will do that may invalidate the combo. + for (let i = 0; i < combo.length; i++) { + let config = combo[i]; + // A configuration can specify an optional verifyConfig method to indicate + // if the current config is valid for a screenshot. This gets called even + // if the this config was used in the lastCombo since another config may + // have invalidated it. + if (config.verifyConfig) { + this.mochitestScope.info( + `checking if the combo is valid with ${config.name}` + ); + const reason = await config.verifyConfig(); + if (reason) { + this._do_skip(reason, combo, config, "applyConfig"); + return; + } + } + } + } catch (ex) { + this.mochitestScope.ok( + false, + `Unexpected exception in [ ${combo + .map(({ name }) => name) + .join(", ")} ]: ${ex.toString()}` + ); + this.mochitestScope.info(`\t${ex}`); + if (ex.stack) { + this.mochitestScope.info(`\t${ex.stack}`); + } + return; + } + this.mochitestScope.info( + `Configured UI for [ ${combo + .map(({ name }) => name) + .join(", ")} ] successfully` + ); + + // Collect selectors from combo configs for cropping region + let windowType; + const finalSelectors = []; + for (const obj of combo) { + if (!windowType) { + windowType = obj.windowType; + } else if (windowType !== obj.windowType) { + this.mochitestScope.ok( + false, + "All configurations in the combo have a single window type" + ); + return; + } + for (const selector of obj.selectors) { + finalSelectors.push(selector); + } + } + + const { bounds, rects } = this._findBoundingBox(finalSelectors, windowType); + this.mochitestScope.ok(bounds, "A valid bounding box was found"); + if (!bounds) { + return; + } + await this._onConfigurationReady(combo, bounds, rects); + }, + + async _onConfigurationReady(combo, bounds, rects) { + let filename = + padLeft(this.currentComboIndex + 1, String(this.combos.length).length) + + this._comboName(combo); + const imagePath = await lazy.Screenshot.captureExternal(filename); + + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + await this._cropImage( + browserWindow, + PathUtils.toFileURI(imagePath), + bounds, + rects, + imagePath + ).catch(msg => { + throw new Error( + `Cropping combo [${combo.map(e => e.name).join(", ")}] failed: ${msg}` + ); + }); + this.completedCombos++; + this.mochitestScope.info("_onConfigurationReady"); + }, + + _comboName(combo) { + return combo.reduce(function (a, b) { + return a + "_" + b.name; + }, ""); + }, + + async _cropImage(window, srcPath, bounds, rects, targetPath) { + const { document, Image } = window; + const promise = new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + // Clip the cropping region to the size of the screenshot + // This is necessary mostly to deal with offscreen windows, since we + // are capturing an image of the operating system's desktop. + bounds.left = Math.max(0, bounds.left); + bounds.right = Math.min(img.naturalWidth, bounds.right); + bounds.top = Math.max(0, bounds.top); + bounds.bottom = Math.min(img.naturalHeight, bounds.bottom); + + // Create a new offscreen canvas with the width and height given by the + // size of the region we want to crop to + const canvas = document.createElementNS( + "http://www.w3.org/1999/xhtml", + "canvas" + ); + canvas.width = bounds.width; + canvas.height = bounds.height; + const ctx = canvas.getContext("2d"); + + ctx.fillStyle = "hotpink"; + ctx.fillRect(0, 0, bounds.width, bounds.height); + + for (const rect of rects) { + rect.left = Math.max(0, rect.left); + rect.right = Math.min(img.naturalWidth, rect.right); + rect.top = Math.max(0, rect.top); + rect.bottom = Math.min(img.naturalHeight, rect.bottom); + + const width = rect.width; + const height = rect.height; + + const screenX = rect.left; + const screenY = rect.top; + + const imageX = screenX - bounds.left; + const imageY = screenY - bounds.top; + ctx.drawImage( + img, + screenX, + screenY, + width, + height, + imageX, + imageY, + width, + height + ); + } + + // Converts the canvas to a binary blob, which can be saved to a png + canvas.toBlob(blob => { + // Use a filereader to convert the raw binary blob into a writable buffer + const fr = new FileReader(); + fr.onload = e => { + const buffer = new Uint8Array(e.target.result); + // Save the file and complete the promise + IOUtils.write(targetPath, buffer).then(resolve); + }; + // Do the conversion + fr.readAsArrayBuffer(blob); + }); + }; + + img.onerror = function () { + reject(`error loading image ${srcPath}`); + }; + // Load the src image for drawing + img.src = srcPath; + }); + return promise; + }, + + /** + * Finds the index of the first comma that is not enclosed within square brackets. + * @param {String} envVar - the string that needs to be searched + * @return {Integer} index of valid comma or -1 if not found. + */ + findComma(envVar) { + let nestingDepth = 0; + for (let i = 0; i < envVar.length; i++) { + if (envVar[i] === "[") { + nestingDepth += 1; + } else if (envVar[i] === "]") { + nestingDepth -= 1; + } else if (envVar[i] === "," && nestingDepth === 0) { + return i; + } + } + + return -1; + }, + + /** + * Splits the environment variable around commas not enclosed in brackets. + * @param {String} envVar - The environment variable + * @return {String[]} Array of strings containing the configurations + * e.g. ["Toolbars[onlyNavBar,allToolbars]","DevTools[jsdebugger,webconsole]","Tabs"] + */ + splitEnv(envVar) { + let result = []; + + let commaIndex = this.findComma(envVar); + while (commaIndex != -1) { + result.push(envVar.slice(0, commaIndex).trim()); + envVar = envVar.slice(commaIndex + 1); + commaIndex = this.findComma(envVar); + } + result.push(envVar.trim()); + return result; + }, +}; + +/** + * Helper to lazily compute the Cartesian product of all of the sets of configurations. + **/ +function LazyProduct(sets) { + /** + * An entry for each set with the value being: + * [the number of permutations of the sets with lower index, + * the number of items in the set at the index] + */ + this.sets = sets; + this.lookupTable = []; + let combinations = 1; + for (let i = this.sets.length - 1; i >= 0; i--) { + let set = this.sets[i]; + let setLength = Object.keys(set).length; + this.lookupTable[i] = [combinations, setLength]; + combinations *= setLength; + } +} +LazyProduct.prototype = { + get length() { + let last = this.lookupTable[0]; + if (!last) { + return 0; + } + return last[0] * last[1]; + }, + + item(n) { + // For set i, get the item from the set with the floored value of + // (n / the number of permutations of the sets already chosen from) modulo the length of set i + let result = []; + for (let i = this.sets.length - 1; i >= 0; i--) { + let priorCombinations = this.lookupTable[i][0]; + let setLength = this.lookupTable[i][1]; + let keyIndex = Math.floor(n / priorCombinations) % setLength; + let keys = Object.keys(this.sets[i]); + result[i] = this.sets[i][keys[keyIndex]]; + } + return result; + }, +}; + +function padLeft(number, width, padding = "0") { + return padding.repeat(Math.max(0, width - String(number).length)) + number; +} diff --git a/browser/tools/mozscreenshots/mozscreenshots/extension/api.js b/browser/tools/mozscreenshots/mozscreenshots/extension/api.js new file mode 100644 index 0000000000..a857567103 --- /dev/null +++ b/browser/tools/mozscreenshots/mozscreenshots/extension/api.js @@ -0,0 +1,26 @@ +/* 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"; + +/* globals ExtensionAPI */ + +XPCOMUtils.defineLazyServiceGetter( + this, + "resProto", + "@mozilla.org/network/protocol;1?name=resource", + "nsISubstitutingProtocolHandler" +); + +this.mozscreenshots = class extends ExtensionAPI { + async onStartup() { + let uri = Services.io.newURI("resources/", null, this.extension.rootURI); + resProto.setSubstitution("mozscreenshots", uri); + + const { TestRunner } = ChromeUtils.importESModule( + "resource://mozscreenshots/TestRunner.sys.mjs" + ); + TestRunner.init(this.extension.rootURI); + } +}; diff --git a/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/AppMenu.sys.mjs b/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/AppMenu.sys.mjs new file mode 100644 index 0000000000..8309eab623 --- /dev/null +++ b/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/AppMenu.sys.mjs @@ -0,0 +1,77 @@ +/* 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/. */ + +import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs"; + +export var AppMenu = { + init(libDir) {}, + + configurations: { + appMenuMainView: { + selectors: ["#appMenu-popup"], + async applyConfig() { + let browserWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + await reopenAppMenu(browserWindow); + }, + }, + + appMenuHistorySubview: { + selectors: ["#appMenu-popup"], + async applyConfig() { + let browserWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + await reopenAppMenu(browserWindow); + + browserWindow.document.getElementById("appMenu-library-button").click(); + let view = browserWindow.document.getElementById("appMenu-libraryView"); + let promiseViewShown = BrowserTestUtils.waitForEvent(view, "ViewShown"); + await promiseViewShown; + }, + + verifyConfig: verifyConfigHelper, + }, + + appMenuHelpSubview: { + selectors: ["#appMenu-popup"], + async applyConfig() { + let browserWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + await reopenAppMenu(browserWindow); + + browserWindow.document.getElementById("appMenu-help-button2").click(); + let view = browserWindow.document.getElementById("PanelUI-helpView"); + let promiseViewShown = BrowserTestUtils.waitForEvent(view, "ViewShown"); + await promiseViewShown; + }, + + verifyConfig: verifyConfigHelper, + }, + }, +}; + +async function reopenAppMenu(browserWindow) { + browserWindow.PanelUI.hide(); + let promiseViewShown = BrowserTestUtils.waitForEvent( + browserWindow.PanelUI.panel, + "ViewShown" + ); + browserWindow.PanelUI.show(); + await promiseViewShown; +} + +function verifyConfigHelper() { + if (isCustomizing()) { + return "navigator:browser has the customizing attribute"; + } + return undefined; +} + +function isCustomizing() { + let browserWindow = Services.wm.getMostRecentWindow("navigator:browser"); + if (browserWindow.document.documentElement.hasAttribute("customizing")) { + return true; + } + return false; +} diff --git a/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/Buttons.sys.mjs b/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/Buttons.sys.mjs new file mode 100644 index 0000000000..2bc12d9012 --- /dev/null +++ b/browser/tools/mozscreenshots/mozscreenshots/extension/configurations/Buttons.sys.mjs @@ -0,0 +1,97 @@ +/* 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/. */ + +import { CustomizableUI } from "resource:///modules/CustomizableUI.sys.mjs"; + +export var Buttons = { + init(libDir) { + createWidget(); + }, + + configurations: { + navBarButtons: { + selectors: ["#nav-bar"], + applyConfig: async () => { + CustomizableUI.addWidgetToArea( + "screenshot-widget", + CustomizableUI.AREA_NAVBAR + ); + }, + }, + + tabsToolbarButtons: { + selectors: ["#TabsToolbar"], + applyConfig: async () => { + CustomizableUI.addWidgetToArea( + "screenshot-widget", + CustomizableUI.AREA_TABSTRIP + ); + }, + }, + + menuPanelButtons: { + selectors: ["#widget-overflow"], + applyConfig: async () => { + CustomizableUI.addWidgetToArea( + "screenshot-widget", + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + }, + + async verifyConfig() { + let browserWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + if (browserWindow.PanelUI.panel.state == "closed") { + return "The button isn't shown when the panel isn't open."; + } + return undefined; + }, + }, + + custPaletteButtons: { + selectors: ["#customization-palette"], + applyConfig: async () => { + CustomizableUI.removeWidgetFromArea("screenshot-widget"); + }, + + async verifyConfig() { + let browserWindow = + Services.wm.getMostRecentWindow("navigator:browser"); + if ( + browserWindow.document.documentElement.getAttribute("customizing") != + "true" + ) { + return "The button isn't shown when we're not in customize mode."; + } + return undefined; + }, + }, + }, +}; + +function createWidget() { + let id = "screenshot-widget"; + let spec = { + id, + label: "My Button", + removable: true, + tooltiptext: "", + type: "button", + }; + CustomizableUI.createWidget(spec); + + // Append a