From 2aa4a82499d4becd2284cdb482213d541b8804dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 16:29:10 +0200 Subject: Adding upstream version 86.0.1. Signed-off-by: Daniel Baumann --- devtools/client/styleeditor/StyleEditorUI.jsm | 1297 ++++++ devtools/client/styleeditor/StyleEditorUtil.jsm | 280 ++ devtools/client/styleeditor/StyleSheetEditor.jsm | 984 +++++ devtools/client/styleeditor/index.xhtml | 160 + devtools/client/styleeditor/moz.build | 18 + devtools/client/styleeditor/original-source.js | 103 + devtools/client/styleeditor/panel.js | 168 + devtools/client/styleeditor/test/.eslintrc.js | 6 + devtools/client/styleeditor/test/autocomplete.html | 23 + devtools/client/styleeditor/test/browser.ini | 128 + .../test/browser_styleeditor_add_stylesheet.js | 36 + .../browser_styleeditor_autocomplete-disabled.js | 42 + .../test/browser_styleeditor_autocomplete.js | 273 ++ .../styleeditor/test/browser_styleeditor_bom.js | 37 + ...itor_bug_1247083_inline_stylesheet_numbering.js | 104 + ...tyleeditor_bug_1405342_serviceworker_iframes.js | 27 + .../test/browser_styleeditor_bug_740541_iframes.js | 91 + .../browser_styleeditor_bug_851132_middle_click.js | 58 + .../test/browser_styleeditor_bug_870339.js | 48 + .../test/browser_styleeditor_copyurl.js | 46 + .../test/browser_styleeditor_enabled.js | 70 + .../browser_styleeditor_fetch-from-netmonitor.js | 79 + .../test/browser_styleeditor_filesave.js | 101 + .../browser_styleeditor_fission_switch_target.js | 29 + .../test/browser_styleeditor_highlight-selector.js | 45 + .../styleeditor/test/browser_styleeditor_import.js | 61 + .../test/browser_styleeditor_import_rule.js | 32 + .../styleeditor/test/browser_styleeditor_init.js | 52 + .../browser_styleeditor_inline_friendly_names.js | 101 + .../test/browser_styleeditor_loading.js | 34 + .../browser_styleeditor_loading_with_containers.js | 70 + .../test/browser_styleeditor_media_sidebar.js | 154 + .../browser_styleeditor_media_sidebar_links.js | 177 + ...browser_styleeditor_media_sidebar_sourcemaps.js | 62 + .../test/browser_styleeditor_missing_stylesheet.js | 37 + .../test/browser_styleeditor_navigate.js | 31 + .../styleeditor/test/browser_styleeditor_new.js | 96 + .../test/browser_styleeditor_nostyle.js | 29 + .../test/browser_styleeditor_opentab.js | 142 + .../styleeditor/test/browser_styleeditor_pretty.js | 82 + .../browser_styleeditor_private_perwindowpb.js | 75 + .../styleeditor/test/browser_styleeditor_reload.js | 42 + .../test/browser_styleeditor_resize_performance.js | 62 + .../styleeditor/test/browser_styleeditor_scroll.js | 96 + .../test/browser_styleeditor_selectstylesheet.js | 25 + .../test/browser_styleeditor_sourcemap_large.js | 34 + .../test/browser_styleeditor_sourcemap_watching.js | 165 + .../test/browser_styleeditor_sourcemaps.js | 152 + .../test/browser_styleeditor_sourcemaps_inline.js | 89 + .../test/browser_styleeditor_sv_keynav.js | 79 + .../test/browser_styleeditor_sv_resize.js | 53 + .../styleeditor/test/browser_styleeditor_sync.js | 74 + .../test/browser_styleeditor_syncAddProperty.js | 52 + .../test/browser_styleeditor_syncAddRule.js | 30 + .../test/browser_styleeditor_syncAlreadyOpen.js | 50 + .../test/browser_styleeditor_syncEditSelector.js | 38 + .../test/browser_styleeditor_syncIntoRuleView.js | 45 + .../test/browser_styleeditor_transition_rule.js | 51 + .../styleeditor/test/browser_styleeditor_xul.js | 23 + .../test/bug_1405342_serviceworker_iframes.html | 10 + devtools/client/styleeditor/test/doc_empty.html | 3 + .../test/doc_fetch_from_netmonitor.html | 13 + devtools/client/styleeditor/test/doc_long.css | 402 ++ .../client/styleeditor/test/doc_long_string.css | 43 + .../client/styleeditor/test/doc_short_string.css | 15 + devtools/client/styleeditor/test/doc_xulpage.xhtml | 7 + devtools/client/styleeditor/test/four.html | 25 + devtools/client/styleeditor/test/head.js | 173 + .../styleeditor/test/iframe_service_worker.js | 12 + .../test/iframe_with_service_worker.html | 33 + devtools/client/styleeditor/test/import.css | 8 + devtools/client/styleeditor/test/import.html | 11 + devtools/client/styleeditor/test/import2.css | 8 + devtools/client/styleeditor/test/inline-1.html | 19 + devtools/client/styleeditor/test/inline-2.html | 19 + devtools/client/styleeditor/test/longload.html | 29 + .../test/many-media-rules-sourcemaps/index.html | 11 + .../sourcemap/sourcemap-css/sourcemaps.css | 201 + .../sourcemap/sourcemap-css/sourcemaps.css.map | 10 + .../sourcemap/sourcemap-sass/_partial.scss | 25 + .../sourcemap/sourcemap-sass/sourcemaps.scss | 27 + .../styleeditor/test/media-rules-sourcemaps.html | 12 + devtools/client/styleeditor/test/media-rules.css | 29 + devtools/client/styleeditor/test/media-rules.html | 13 + devtools/client/styleeditor/test/media-small.css | 4 + devtools/client/styleeditor/test/media.html | 10 + devtools/client/styleeditor/test/minified.html | 15 + devtools/client/styleeditor/test/missing.html | 11 + devtools/client/styleeditor/test/nostyle.html | 5 + devtools/client/styleeditor/test/pretty.css | 2 + .../client/styleeditor/test/resources_inpage.jsi | 12 + .../client/styleeditor/test/resources_inpage1.css | 11 + .../client/styleeditor/test/resources_inpage2.css | 11 + .../styleeditor/test/selector-highlighter.html | 1 + devtools/client/styleeditor/test/simple.css | 7 + devtools/client/styleeditor/test/simple.css.gz | Bin 0 -> 166 bytes .../client/styleeditor/test/simple.css.gz^headers^ | 4 + devtools/client/styleeditor/test/simple.gz.html | 23 + devtools/client/styleeditor/test/simple.html | 24 + .../styleeditor/test/sjs_huge-css-server.sjs | 18 + .../styleeditor/test/sourcemap-css/contained.css | 4 + .../styleeditor/test/sourcemap-css/media-rules.css | 8 + .../test/sourcemap-css/media-rules.css.map | 6 + .../styleeditor/test/sourcemap-css/sourcemaps.css | 7 + .../test/sourcemap-css/sourcemaps.css.map | 6 + .../test/sourcemap-css/sourcemaps.css.map^headers^ | 2 + .../test/sourcemap-css/test-bootstrap-scss.css | 4513 ++++++++++++++++++++ .../styleeditor/test/sourcemap-css/test-stylus.css | 7 + .../test/sourcemap-sass/media-rules.scss | 11 + .../test/sourcemap-sass/sourcemaps.scss | 10 + .../test/sourcemap-sass/sourcemaps.scss^headers^ | 2 + .../test/sourcemap-styl/test-stylus.styl | 7 + .../client/styleeditor/test/sourcemaps-inline.html | 17 + .../client/styleeditor/test/sourcemaps-large.html | 11 + .../styleeditor/test/sourcemaps-watching.html | 11 + devtools/client/styleeditor/test/sourcemaps.html | 13 + devtools/client/styleeditor/test/sync.html | 20 + devtools/client/styleeditor/test/sync_with_csp.css | 10 + .../client/styleeditor/test/sync_with_csp.html | 12 + devtools/client/styleeditor/test/test_private.css | 3 + devtools/client/styleeditor/test/test_private.html | 7 + devtools/client/styleeditor/test/utf-16.css | Bin 0 -> 156 bytes 122 files changed, 12651 insertions(+) create mode 100644 devtools/client/styleeditor/StyleEditorUI.jsm create mode 100644 devtools/client/styleeditor/StyleEditorUtil.jsm create mode 100644 devtools/client/styleeditor/StyleSheetEditor.jsm create mode 100644 devtools/client/styleeditor/index.xhtml create mode 100644 devtools/client/styleeditor/moz.build create mode 100644 devtools/client/styleeditor/original-source.js create mode 100644 devtools/client/styleeditor/panel.js create mode 100644 devtools/client/styleeditor/test/.eslintrc.js create mode 100644 devtools/client/styleeditor/test/autocomplete.html create mode 100644 devtools/client/styleeditor/test/browser.ini create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_add_stylesheet.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_autocomplete-disabled.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_autocomplete.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_bom.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_bug_1247083_inline_stylesheet_numbering.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_bug_1405342_serviceworker_iframes.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_bug_740541_iframes.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_bug_851132_middle_click.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_bug_870339.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_copyurl.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_enabled.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_fetch-from-netmonitor.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_filesave.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_fission_switch_target.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_highlight-selector.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_import.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_import_rule.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_init.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_inline_friendly_names.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_loading.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_loading_with_containers.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_media_sidebar.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_media_sidebar_links.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_media_sidebar_sourcemaps.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_missing_stylesheet.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_navigate.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_new.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_nostyle.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_opentab.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_pretty.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_private_perwindowpb.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_reload.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_resize_performance.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_scroll.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_selectstylesheet.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_sourcemap_large.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_sourcemaps.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_sourcemaps_inline.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_sv_keynav.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_sv_resize.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_sync.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_syncAddProperty.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_syncAddRule.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_syncAlreadyOpen.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_syncEditSelector.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_syncIntoRuleView.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_transition_rule.js create mode 100644 devtools/client/styleeditor/test/browser_styleeditor_xul.js create mode 100644 devtools/client/styleeditor/test/bug_1405342_serviceworker_iframes.html create mode 100644 devtools/client/styleeditor/test/doc_empty.html create mode 100644 devtools/client/styleeditor/test/doc_fetch_from_netmonitor.html create mode 100644 devtools/client/styleeditor/test/doc_long.css create mode 100644 devtools/client/styleeditor/test/doc_long_string.css create mode 100644 devtools/client/styleeditor/test/doc_short_string.css create mode 100644 devtools/client/styleeditor/test/doc_xulpage.xhtml create mode 100644 devtools/client/styleeditor/test/four.html create mode 100644 devtools/client/styleeditor/test/head.js create mode 100644 devtools/client/styleeditor/test/iframe_service_worker.js create mode 100644 devtools/client/styleeditor/test/iframe_with_service_worker.html create mode 100644 devtools/client/styleeditor/test/import.css create mode 100644 devtools/client/styleeditor/test/import.html create mode 100644 devtools/client/styleeditor/test/import2.css create mode 100644 devtools/client/styleeditor/test/inline-1.html create mode 100644 devtools/client/styleeditor/test/inline-2.html create mode 100644 devtools/client/styleeditor/test/longload.html create mode 100644 devtools/client/styleeditor/test/many-media-rules-sourcemaps/index.html create mode 100644 devtools/client/styleeditor/test/many-media-rules-sourcemaps/sourcemap/sourcemap-css/sourcemaps.css create mode 100644 devtools/client/styleeditor/test/many-media-rules-sourcemaps/sourcemap/sourcemap-css/sourcemaps.css.map create mode 100644 devtools/client/styleeditor/test/many-media-rules-sourcemaps/sourcemap/sourcemap-sass/_partial.scss create mode 100644 devtools/client/styleeditor/test/many-media-rules-sourcemaps/sourcemap/sourcemap-sass/sourcemaps.scss create mode 100644 devtools/client/styleeditor/test/media-rules-sourcemaps.html create mode 100644 devtools/client/styleeditor/test/media-rules.css create mode 100644 devtools/client/styleeditor/test/media-rules.html create mode 100644 devtools/client/styleeditor/test/media-small.css create mode 100644 devtools/client/styleeditor/test/media.html create mode 100644 devtools/client/styleeditor/test/minified.html create mode 100644 devtools/client/styleeditor/test/missing.html create mode 100644 devtools/client/styleeditor/test/nostyle.html create mode 100644 devtools/client/styleeditor/test/pretty.css create mode 100644 devtools/client/styleeditor/test/resources_inpage.jsi create mode 100644 devtools/client/styleeditor/test/resources_inpage1.css create mode 100644 devtools/client/styleeditor/test/resources_inpage2.css create mode 100644 devtools/client/styleeditor/test/selector-highlighter.html create mode 100644 devtools/client/styleeditor/test/simple.css create mode 100644 devtools/client/styleeditor/test/simple.css.gz create mode 100644 devtools/client/styleeditor/test/simple.css.gz^headers^ create mode 100644 devtools/client/styleeditor/test/simple.gz.html create mode 100644 devtools/client/styleeditor/test/simple.html create mode 100644 devtools/client/styleeditor/test/sjs_huge-css-server.sjs create mode 100644 devtools/client/styleeditor/test/sourcemap-css/contained.css create mode 100644 devtools/client/styleeditor/test/sourcemap-css/media-rules.css create mode 100644 devtools/client/styleeditor/test/sourcemap-css/media-rules.css.map create mode 100644 devtools/client/styleeditor/test/sourcemap-css/sourcemaps.css create mode 100644 devtools/client/styleeditor/test/sourcemap-css/sourcemaps.css.map create mode 100644 devtools/client/styleeditor/test/sourcemap-css/sourcemaps.css.map^headers^ create mode 100644 devtools/client/styleeditor/test/sourcemap-css/test-bootstrap-scss.css create mode 100644 devtools/client/styleeditor/test/sourcemap-css/test-stylus.css create mode 100644 devtools/client/styleeditor/test/sourcemap-sass/media-rules.scss create mode 100644 devtools/client/styleeditor/test/sourcemap-sass/sourcemaps.scss create mode 100644 devtools/client/styleeditor/test/sourcemap-sass/sourcemaps.scss^headers^ create mode 100644 devtools/client/styleeditor/test/sourcemap-styl/test-stylus.styl create mode 100644 devtools/client/styleeditor/test/sourcemaps-inline.html create mode 100644 devtools/client/styleeditor/test/sourcemaps-large.html create mode 100644 devtools/client/styleeditor/test/sourcemaps-watching.html create mode 100644 devtools/client/styleeditor/test/sourcemaps.html create mode 100644 devtools/client/styleeditor/test/sync.html create mode 100644 devtools/client/styleeditor/test/sync_with_csp.css create mode 100644 devtools/client/styleeditor/test/sync_with_csp.html create mode 100644 devtools/client/styleeditor/test/test_private.css create mode 100644 devtools/client/styleeditor/test/test_private.html create mode 100644 devtools/client/styleeditor/test/utf-16.css (limited to 'devtools/client/styleeditor') diff --git a/devtools/client/styleeditor/StyleEditorUI.jsm b/devtools/client/styleeditor/StyleEditorUI.jsm new file mode 100644 index 0000000000..4092f7804b --- /dev/null +++ b/devtools/client/styleeditor/StyleEditorUI.jsm @@ -0,0 +1,1297 @@ +/* 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 EXPORTED_SYMBOLS = ["StyleEditorUI"]; + +const { loader, require } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" +); +const Services = require("Services"); +const { FileUtils } = require("resource://gre/modules/FileUtils.jsm"); +const { NetUtil } = require("resource://gre/modules/NetUtil.jsm"); +const { OS } = require("resource://gre/modules/osfile.jsm"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { + getString, + text, + wire, + showFilePicker, + optionsPopupMenu, +} = require("resource://devtools/client/styleeditor/StyleEditorUtil.jsm"); +const { + SplitView, +} = require("resource://devtools/client/shared/SplitView.jsm"); +const { + StyleSheetEditor, +} = require("resource://devtools/client/styleeditor/StyleSheetEditor.jsm"); +const { PluralForm } = require("devtools/shared/plural-form"); +const { PrefObserver } = require("devtools/client/shared/prefs"); +const { KeyCodes } = require("devtools/client/shared/keycodes"); +const { + OriginalSource, +} = require("devtools/client/styleeditor/original-source"); + +loader.lazyRequireGetter( + this, + "ResponsiveUIManager", + "devtools/client/responsive/manager" +); +loader.lazyRequireGetter( + this, + "openContentLink", + "devtools/client/shared/link", + true +); +loader.lazyRequireGetter( + this, + "copyString", + "devtools/shared/platform/clipboard", + true +); + +const LOAD_ERROR = "error-load"; +const STYLE_EDITOR_TEMPLATE = "stylesheet"; +const SELECTOR_HIGHLIGHTER_TYPE = "SelectorHighlighter"; +const PREF_MEDIA_SIDEBAR = "devtools.styleeditor.showMediaSidebar"; +const PREF_SIDEBAR_WIDTH = "devtools.styleeditor.mediaSidebarWidth"; +const PREF_NAV_WIDTH = "devtools.styleeditor.navSidebarWidth"; +const PREF_ORIG_SOURCES = "devtools.source-map.client-service.enabled"; + +const HTML_NS = "http://www.w3.org/1999/xhtml"; + +/** + * StyleEditorUI is controls and builds the UI of the Style Editor, including + * maintaining a list of editors for each stylesheet on a debuggee. + * + * Emits events: + * 'editor-added': A new editor was added to the UI + * 'editor-selected': An editor was selected + * 'error': An error occured + * + * @param {Toolbox} toolbox + * @param {Document} panelDoc + * Document of the toolbox panel to populate UI in. + * @param {CssProperties} A css properties database. + */ +function StyleEditorUI(toolbox, panelDoc, cssProperties) { + EventEmitter.decorate(this); + + this._toolbox = toolbox; + this._panelDoc = panelDoc; + this._cssProperties = cssProperties; + this._window = this._panelDoc.defaultView; + this._root = this._panelDoc.getElementById("style-editor-chrome"); + + this.editors = []; + this.selectedEditor = null; + this.savedLocations = {}; + this._seenSheets = new Map(); + + this._onOptionsButtonClick = this._onOptionsButtonClick.bind(this); + this._onOrigSourcesPrefChanged = this._onOrigSourcesPrefChanged.bind(this); + this._onMediaPrefChanged = this._onMediaPrefChanged.bind(this); + this._updateMediaList = this._updateMediaList.bind(this); + this._clear = this._clear.bind(this); + this._onError = this._onError.bind(this); + this._updateContextMenuItems = this._updateContextMenuItems.bind(this); + this._openLinkNewTab = this._openLinkNewTab.bind(this); + this._copyUrl = this._copyUrl.bind(this); + this._onTargetAvailable = this._onTargetAvailable.bind(this); + this._onResourceAvailable = this._onResourceAvailable.bind(this); + this._onResourceUpdated = this._onResourceUpdated.bind(this); + + this._prefObserver = new PrefObserver("devtools.styleeditor."); + this._prefObserver.on(PREF_MEDIA_SIDEBAR, this._onMediaPrefChanged); + this._sourceMapPrefObserver = new PrefObserver( + "devtools.source-map.client-service." + ); + this._sourceMapPrefObserver.on( + PREF_ORIG_SOURCES, + this._onOrigSourcesPrefChanged + ); +} + +StyleEditorUI.prototype = { + get cssProperties() { + return this._cssProperties; + }, + + get currentTarget() { + return this._toolbox.targetList.targetFront; + }, + + /* + * Index of selected stylesheet in document.styleSheets + */ + get selectedStyleSheetIndex() { + return this.selectedEditor + ? this.selectedEditor.styleSheet.styleSheetIndex + : -1; + }, + + /** + * Initiates the style editor ui creation, and start to track TargetList updates. + */ + async initialize() { + this.createUI(); + + await this._toolbox.targetList.watchTargets( + [this._toolbox.targetList.TYPES.FRAME], + this._onTargetAvailable + ); + + await this._toolbox.resourceWatcher.watchResources( + [this._toolbox.resourceWatcher.TYPES.DOCUMENT_EVENT], + { onAvailable: this._onResourceAvailable } + ); + + this._startLoadingStyleSheets(); + await this._toolbox.resourceWatcher.watchResources( + [this._toolbox.resourceWatcher.TYPES.STYLESHEET], + { + onAvailable: this._onResourceAvailable, + onUpdated: this._onResourceUpdated, + } + ); + await this._waitForLoadingStyleSheets(); + }, + + async initializeHighlighter(targetFront) { + const inspectorFront = await targetFront.getFront("inspector"); + this._walker = inspectorFront.walker; + + try { + this._highlighter = await inspectorFront.getHighlighterByType( + SELECTOR_HIGHLIGHTER_TYPE + ); + } catch (e) { + // The selectorHighlighter can't always be instantiated, for example + // it doesn't work with XUL windows (until bug 1094959 gets fixed); + // or the selectorHighlighter doesn't exist on the backend. + console.warn( + "The selectorHighlighter couldn't be instantiated, " + + "elements matching hovered selectors will not be highlighted" + ); + } + }, + + /** + * Build the initial UI and wire buttons with event handlers. + */ + createUI: function() { + this._view = new SplitView(this._root); + + wire(this._view.rootElement, ".style-editor-newButton", async () => { + const stylesheetsFront = await this.currentTarget.getFront("stylesheets"); + stylesheetsFront.addStyleSheet(null); + }); + + wire(this._view.rootElement, ".style-editor-importButton", () => { + this._importFromFile(this._mockImportFile || null, this._window); + }); + + wire(this._view.rootElement, "#style-editor-options", event => { + this._onOptionsButtonClick(event); + }); + + this._panelDoc.addEventListener( + "contextmenu", + () => { + this._contextMenuStyleSheet = null; + }, + true + ); + + this._optionsButton = this._panelDoc.getElementById("style-editor-options"); + + this._contextMenu = this._panelDoc.getElementById("sidebar-context"); + this._contextMenu.addEventListener( + "popupshowing", + this._updateContextMenuItems + ); + + this._openLinkNewTabItem = this._panelDoc.getElementById( + "context-openlinknewtab" + ); + this._openLinkNewTabItem.addEventListener("command", this._openLinkNewTab); + + this._copyUrlItem = this._panelDoc.getElementById("context-copyurl"); + this._copyUrlItem.addEventListener("command", this._copyUrl); + + const nav = this._panelDoc.querySelector(".splitview-controller"); + nav.setAttribute("width", Services.prefs.getIntPref(PREF_NAV_WIDTH)); + }, + + /** + * Opens the Options Popup Menu + * + * @params {number} screenX + * @params {number} screenY + * Both obtained from the event object, used to position the popup + */ + _onOptionsButtonClick({ screenX, screenY }) { + this._optionsMenu = optionsPopupMenu( + this._toggleOrigSources, + this._toggleMediaSidebar + ); + + this._optionsMenu.once("open", () => { + this._optionsButton.setAttribute("open", true); + }); + this._optionsMenu.once("close", () => { + this._optionsButton.removeAttribute("open"); + }); + + this._optionsMenu.popup(screenX, screenY, this._toolbox.doc); + }, + + /** + * Be called when changing the original sources pref. + */ + async _onOrigSourcesPrefChanged() { + this._clear(); + // When we toggle the source-map preference, we clear the panel and re-fetch the exact + // same stylesheet resources from ResourceWatcher, but `_addStyleSheet` will trigger + // or ignore the additional source-map mapping. + this._root.classList.add("loading"); + for (const resource of this._toolbox.resourceWatcher.getAllResources( + this._toolbox.resourceWatcher.TYPES.STYLESHEET + )) { + await this._handleStyleSheetResource(resource); + } + this._root.classList.remove("loading"); + + this.emit("stylesheets-refreshed"); + }, + + /** + * Remove all editors and add loading indicator. + */ + _clear: function() { + // remember selected sheet and line number for next load + if (this.selectedEditor && this.selectedEditor.sourceEditor) { + const href = this.selectedEditor.styleSheet.href; + const { line, ch } = this.selectedEditor.sourceEditor.getCursor(); + + this._styleSheetToSelect = { + stylesheet: href, + line: line, + col: ch, + }; + } + + // remember saved file locations + for (const editor of this.editors) { + if (editor.savedFile) { + const identifier = this.getStyleSheetIdentifier(editor.styleSheet); + this.savedLocations[identifier] = editor.savedFile; + } + } + + this._clearStyleSheetEditors(); + this._view.removeAll(); + + this.selectedEditor = null; + // Here the keys are style sheet actors, and the values are + // promises that resolve to the sheet's editor. See |_addStyleSheet|. + this._seenSheets = new Map(); + + this.emit("stylesheets-clear"); + }, + + /** + * Add an editor for this stylesheet. Add editors for its original sources + * instead (e.g. Sass sources), if applicable. + * + * @param {Resource} resource + * The STYLESHEET resource which is received from resource watcher. + * @return {Promise} + * A promise that resolves to the style sheet's editor when the style sheet has + * been fully loaded. If the style sheet has a source map, and source mapping + * is enabled, then the promise resolves to null. + */ + _addStyleSheet: function(resource) { + if (!this._seenSheets.has(resource)) { + const promise = (async () => { + let editor = await this._addStyleSheetEditor(resource); + + const sourceMapService = this._toolbox.sourceMapService; + + if ( + !sourceMapService || + !Services.prefs.getBoolPref(PREF_ORIG_SOURCES) + ) { + return editor; + } + + const { + href, + nodeHref, + resourceId: id, + sourceMapURL, + sourceMapBaseURL, + } = resource; + const sources = await sourceMapService.getOriginalURLs({ + id, + url: href || nodeHref, + sourceMapBaseURL, + sourceMapURL, + }); + // A single generated sheet might map to multiple original + // sheets, so make editors for each of them. + if (sources && sources.length) { + const parentEditorName = editor.friendlyName; + this._removeStyleSheetEditor(editor); + editor = null; + + for (const { id: originalId, url: originalURL } of sources) { + const original = new OriginalSource( + originalURL, + originalId, + sourceMapService + ); + + // set so the first sheet will be selected, even if it's a source + original.styleSheetIndex = resource.styleSheetIndex; + original.relatedStyleSheet = resource; + original.relatedEditorName = parentEditorName; + original.resourceId = resource.resourceId; + original.targetFront = resource.targetFront; + original.mediaRules = resource.mediaRules; + await this._addStyleSheetEditor(original); + } + } + + return editor; + })(); + this._seenSheets.set(resource, promise); + } + return this._seenSheets.get(resource); + }, + + _getInlineStyleSheetsCount() { + return this.editors.filter(editor => !editor.styleSheet.href).length; + }, + + _getNewStyleSheetsCount() { + return this.editors.filter(editor => editor.isNew).length; + }, + + /** + * Finds the index to be shown in the Style Editor for inline or + * user-created style sheets, returns undefined if not of either type. + * + * @param {StyleSheet} styleSheet + * Object representing stylesheet + * @return {(Number|undefined)} + * Optional Integer representing the index of the current stylesheet + * among all stylesheets of its type (inline or user-created) + */ + _getNextFriendlyIndex: function(styleSheet) { + if (styleSheet.href) { + return undefined; + } + + return styleSheet.isNew + ? this._getNewStyleSheetsCount() + : this._getInlineStyleSheetsCount(); + }, + + /** + * Add a new editor to the UI for a source. + * + * @param {Resource} resource + * The resource which is received from resource watcher. + * @return {Promise} that is resolved with the created StyleSheetEditor when + * the editor is fully initialized or rejected on error. + */ + async _addStyleSheetEditor(resource) { + const editor = new StyleSheetEditor( + resource, + this._window, + this._walker, + this._highlighter, + this._getNextFriendlyIndex(resource) + ); + + editor.on("property-change", this._summaryChange.bind(this, editor)); + editor.on("media-rules-changed", this._updateMediaList.bind(this, editor)); + editor.on("linked-css-file", this._summaryChange.bind(this, editor)); + editor.on("linked-css-file-error", this._summaryChange.bind(this, editor)); + editor.on("error", this._onError); + + // onMediaRulesChanged fires media-rules-changed, so call the function after + // registering the listener in order to ensure to get media-rules-changed event. + editor.onMediaRulesChanged(resource.mediaRules); + + this.editors.push(editor); + + await editor.fetchSource(); + this._sourceLoaded(editor); + + if (resource.fileName) { + this.emit("test:editor-updated", editor); + } + + return editor; + }, + + /** + * Import a style sheet from file and asynchronously create a + * new stylesheet on the debuggee for it. + * + * @param {mixed} file + * Optional nsIFile or filename string. + * If not set a file picker will be shown. + * @param {nsIWindow} parentWindow + * Optional parent window for the file picker. + */ + _importFromFile: function(file, parentWindow) { + const onFileSelected = selectedFile => { + if (!selectedFile) { + // nothing selected + return; + } + NetUtil.asyncFetch( + { + uri: NetUtil.newURI(selectedFile), + loadingNode: this._window.document, + securityFlags: + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT, + contentPolicyType: Ci.nsIContentPolicy.TYPE_OTHER, + }, + async (stream, status) => { + if (!Components.isSuccessCode(status)) { + this.emit("error", { key: LOAD_ERROR, level: "warning" }); + return; + } + const source = NetUtil.readInputStreamToString( + stream, + stream.available() + ); + stream.close(); + + const stylesheetsFront = await this.currentTarget.getFront( + "stylesheets" + ); + stylesheetsFront.addStyleSheet(source, selectedFile.path); + } + ); + }; + + showFilePicker(file, false, parentWindow, onFileSelected); + }, + + /** + * Forward any error from a stylesheet. + * + * @param {data} data + * The event data + */ + _onError: function(data) { + this.emit("error", data); + }, + + /** + * Toggle the original sources pref. + */ + _toggleOrigSources: function() { + const isEnabled = Services.prefs.getBoolPref(PREF_ORIG_SOURCES); + Services.prefs.setBoolPref(PREF_ORIG_SOURCES, !isEnabled); + }, + + /** + * Toggle the pref for showing a @media rules sidebar in each editor. + */ + _toggleMediaSidebar: function() { + const isEnabled = Services.prefs.getBoolPref(PREF_MEDIA_SIDEBAR); + Services.prefs.setBoolPref(PREF_MEDIA_SIDEBAR, !isEnabled); + }, + + /** + * Toggle the @media sidebar in each editor depending on the setting. + */ + _onMediaPrefChanged: function() { + this.editors.forEach(this._updateMediaList); + }, + + /** + * This method handles the following cases related to the context + * menu items "_openLinkNewTabItem" and "_copyUrlItem": + * + * 1) There was a stylesheet clicked on and it is external: show and + * enable the context menu item + * 2) There was a stylesheet clicked on and it is inline: show and + * disable the context menu item + * 3) There was no stylesheet clicked on (the right click happened + * below the list): hide the context menu + */ + _updateContextMenuItems: function() { + this._openLinkNewTabItem.setAttribute( + "hidden", + !this._contextMenuStyleSheet + ); + this._copyUrlItem.setAttribute("hidden", !this._contextMenuStyleSheet); + + if (this._contextMenuStyleSheet) { + this._openLinkNewTabItem.setAttribute( + "disabled", + !this._contextMenuStyleSheet.href + ); + this._copyUrlItem.setAttribute( + "disabled", + !this._contextMenuStyleSheet.href + ); + } + }, + + /** + * Open a particular stylesheet in a new tab. + */ + _openLinkNewTab: function() { + if (this._contextMenuStyleSheet) { + openContentLink(this._contextMenuStyleSheet.href); + } + }, + + /** + * Copies a stylesheet's URL. + */ + _copyUrl: function() { + if (this._contextMenuStyleSheet) { + copyString(this._contextMenuStyleSheet.href); + } + }, + + /** + * Remove a particular stylesheet editor from the UI + * + * @param {StyleSheetEditor} editor + * The editor to remove. + */ + _removeStyleSheetEditor: function(editor) { + if (editor.summary) { + this._view.removeItem(editor.summary); + } else { + const self = this; + this.on("editor-added", function onAdd(added) { + if (editor == added) { + self.off("editor-added", onAdd); + self._view.removeItem(editor.summary); + } + }); + } + + editor.destroy(); + this.editors.splice(this.editors.indexOf(editor), 1); + }, + + /** + * Clear all the editors from the UI. + */ + _clearStyleSheetEditors: function() { + for (const editor of this.editors) { + editor.destroy(); + } + this.editors = []; + }, + + /** + * Called when a StyleSheetEditor's source has been fetched. Create a + * summary UI for the editor. + * + * @param {StyleSheetEditor} editor + * Editor to create UI for. + */ + _sourceLoaded: function(editor) { + let ordinal = editor.styleSheet.styleSheetIndex; + ordinal = ordinal == -1 ? Number.MAX_SAFE_INTEGER : ordinal; + // add new sidebar item and editor to the UI + this._view.appendTemplatedItem(STYLE_EDITOR_TEMPLATE, { + data: { + editor: editor, + }, + disableAnimations: this._alwaysDisableAnimations, + ordinal: ordinal, + onCreate: (summary, details, data) => { + const createdEditor = data.editor; + createdEditor.summary = summary; + createdEditor.details = details; + + wire(summary, ".stylesheet-enabled", function onToggleDisabled(event) { + event.stopPropagation(); + event.target.blur(); + + createdEditor.toggleDisabled(); + }); + + wire(summary, ".stylesheet-name", { + events: { + keypress: event => { + if (event.keyCode == KeyCodes.DOM_VK_RETURN) { + this._view.activeSummary = summary; + } + }, + }, + }); + + wire(summary, ".stylesheet-saveButton", function onSaveButton(event) { + event.stopPropagation(); + event.target.blur(); + + createdEditor.saveToFile(createdEditor.savedFile); + }); + + this._updateSummaryForEditor(createdEditor, summary); + + summary.addEventListener("contextmenu", () => { + this._contextMenuStyleSheet = createdEditor.styleSheet; + }); + + summary.addEventListener("focus", function onSummaryFocus(event) { + if (event.target == summary) { + // autofocus the stylesheet name + summary.querySelector(".stylesheet-name").focus(); + } + }); + + const sidebar = details.querySelector(".stylesheet-sidebar"); + sidebar.setAttribute( + "width", + Services.prefs.getIntPref(PREF_SIDEBAR_WIDTH) + ); + + const splitter = details.querySelector(".devtools-side-splitter"); + splitter.addEventListener("mousemove", () => { + const sidebarWidth = sidebar.getAttribute("width"); + Services.prefs.setIntPref(PREF_SIDEBAR_WIDTH, sidebarWidth); + + // update all @media sidebars for consistency + const sidebars = [ + ...this._panelDoc.querySelectorAll(".stylesheet-sidebar"), + ]; + for (const mediaSidebar of sidebars) { + mediaSidebar.setAttribute("width", sidebarWidth); + } + }); + + // autofocus if it's a new user-created stylesheet + if (createdEditor.isNew) { + this._selectEditor(createdEditor); + } + + if (this._isEditorToSelect(createdEditor)) { + this.switchToSelectedSheet(); + } + + // If this is the first stylesheet and there is no pending request to + // select a particular style sheet, select this sheet. + if ( + !this.selectedEditor && + !this._styleSheetBoundToSelect && + createdEditor.styleSheet.styleSheetIndex == 0 + ) { + this._selectEditor(createdEditor); + } + this.emit("editor-added", createdEditor); + }, + + onShow: (summary, details, data) => { + const showEditor = data.editor; + this.selectedEditor = showEditor; + + (async function() { + if (!showEditor.sourceEditor) { + // only initialize source editor when we switch to this view + const inputElement = details.querySelector( + ".stylesheet-editor-input" + ); + await showEditor.load(inputElement, this._cssProperties); + } + + showEditor.onShow(); + + this.emit("editor-selected", showEditor); + } + .bind(this)() + .catch(console.error)); + }, + }); + }, + + /** + * Switch to the editor that has been marked to be selected. + * + * @return {Promise} + * Promise that will resolve when the editor is selected. + */ + switchToSelectedSheet: function() { + const toSelect = this._styleSheetToSelect; + + for (const editor of this.editors) { + if (this._isEditorToSelect(editor)) { + // The _styleSheetBoundToSelect will always hold the latest pending + // requested style sheet (with line and column) which is not yet + // selected by the source editor. Only after we select that particular + // editor and go the required line and column, it will become null. + this._styleSheetBoundToSelect = this._styleSheetToSelect; + this._styleSheetToSelect = null; + return this._selectEditor(editor, toSelect.line, toSelect.col); + } + } + + return Promise.resolve(); + }, + + /** + * Returns whether a given editor is the current editor to be selected. Tests + * based on href or underlying stylesheet. + * + * @param {StyleSheetEditor} editor + * The editor to test. + */ + _isEditorToSelect: function(editor) { + const toSelect = this._styleSheetToSelect; + if (!toSelect) { + return false; + } + const isHref = + toSelect.stylesheet === null || typeof toSelect.stylesheet == "string"; + + return ( + (isHref && editor.styleSheet.href == toSelect.stylesheet) || + toSelect.stylesheet == editor.styleSheet + ); + }, + + /** + * Select an editor in the UI. + * + * @param {StyleSheetEditor} editor + * Editor to switch to. + * @param {number} line + * Line number to jump to + * @param {number} col + * Column number to jump to + * @return {Promise} + * Promise that will resolve when the editor is selected and ready + * to be used. + */ + _selectEditor: function(editor, line, col) { + line = line || 0; + col = col || 0; + + const editorPromise = editor.getSourceEditor().then(() => { + editor.setCursor(line, col); + this._styleSheetBoundToSelect = null; + }); + + const summaryPromise = this.getEditorSummary(editor).then(summary => { + this._view.activeSummary = summary; + }); + + return Promise.all([editorPromise, summaryPromise]); + }, + + getEditorSummary: function(editor) { + const self = this; + + if (editor.summary) { + return Promise.resolve(editor.summary); + } + + return new Promise(resolve => { + this.on("editor-added", function onAdd(selected) { + if (selected == editor) { + self.off("editor-added", onAdd); + resolve(editor.summary); + } + }); + }); + }, + + getEditorDetails: function(editor) { + const self = this; + + if (editor.details) { + return Promise.resolve(editor.details); + } + + return new Promise(resolve => { + this.on("editor-added", function onAdd(selected) { + if (selected == editor) { + self.off("editor-added", onAdd); + resolve(editor.details); + } + }); + }); + }, + + /** + * Returns an identifier for the given style sheet. + * + * @param {StyleSheet} styleSheet + * The style sheet to be identified. + */ + getStyleSheetIdentifier: function(styleSheet) { + // Identify inline style sheets by their host page URI and index + // at the page. + return styleSheet.href + ? styleSheet.href + : "inline-" + styleSheet.styleSheetIndex + "-at-" + styleSheet.nodeHref; + }, + + /** + * Get the OriginalSource object for a given original sourceId returned from + * the sourcemap worker service. + * + * @param {string} sourceId + * The ID to search for from the sourcemap worker. + * + * @return {OriginalSource | null} + */ + getOriginalSourceSheet: function(sourceId) { + for (const editor of this.editors) { + const { styleSheet } = editor; + if (styleSheet.isOriginalSource && styleSheet.sourceId === sourceId) { + return styleSheet; + } + } + return null; + }, + + /** + * Given an URL, find a stylesheet front with that URL, if one has been + * loaded into the editor.js + * + * Do not use this unless you have no other way to get a StyleSheetFront + * multiple sheets could share the same URL, so this will give you _one_ + * of possibly many sheets with that URL. + * + * @param {string} url + * An arbitrary URL to search for. + * + * @return {StyleSheetFront|null} + */ + getStylesheetFrontForGeneratedURL: function(url) { + for (const styleSheet of this._seenSheets.keys()) { + const sheetURL = styleSheet.href || styleSheet.nodeHref; + if (!styleSheet.isOriginalSource && sheetURL === url) { + return styleSheet; + } + } + return null; + }, + + /** + * selects a stylesheet and optionally moves the cursor to a selected line + * + * @param {StyleSheetFront} [stylesheet] + * Stylesheet to select or href of stylesheet to select + * @param {Number} [line] + * Line to which the caret should be moved (zero-indexed). + * @param {Number} [col] + * Column to which the caret should be moved (zero-indexed). + * @return {Promise} + * Promise that will resolve when the editor is selected and ready + * to be used. + */ + selectStyleSheet: function(stylesheet, line, col) { + this._styleSheetToSelect = { + stylesheet: stylesheet, + line: line, + col: col, + }; + + /* Switch to the editor for this sheet, if it exists yet. + Otherwise each editor will be checked when it's created. */ + return this.switchToSelectedSheet(); + }, + + /** + * Handler for an editor's 'property-changed' event. + * Update the summary in the UI. + * + * @param {StyleSheetEditor} editor + * Editor for which a property has changed + */ + _summaryChange: function(editor) { + this._updateSummaryForEditor(editor); + }, + + /** + * Update split view summary of given StyleEditor instance. + * + * @param {StyleSheetEditor} editor + * @param {DOMElement} summary + * Optional item's summary element to update. If none, item + * corresponding to passed editor is used. + */ + _updateSummaryForEditor: function(editor, summary) { + summary = summary || editor.summary; + if (!summary) { + return; + } + + let ruleCount = editor.styleSheet.ruleCount; + if (editor.styleSheet.relatedStyleSheet) { + ruleCount = editor.styleSheet.relatedStyleSheet.ruleCount; + } + if (ruleCount === undefined) { + ruleCount = "-"; + } + + const flags = []; + if (editor.styleSheet.disabled) { + flags.push("disabled"); + } + if (editor.unsaved) { + flags.push("unsaved"); + } + if (editor.linkedCSSFileError) { + flags.push("linked-file-error"); + } + this._view.setItemClassName(summary, flags.join(" ")); + + const label = summary.querySelector(".stylesheet-name > label"); + label.setAttribute("value", editor.friendlyName); + if (editor.styleSheet.href) { + label.setAttribute("tooltiptext", editor.styleSheet.href); + } + + let linkedCSSSource = ""; + if (editor.linkedCSSFile) { + linkedCSSSource = OS.Path.basename(editor.linkedCSSFile); + } else if (editor.styleSheet.relatedEditorName) { + linkedCSSSource = editor.styleSheet.relatedEditorName; + } + text(summary, ".stylesheet-linked-file", linkedCSSSource); + text(summary, ".stylesheet-title", editor.styleSheet.title || ""); + text( + summary, + ".stylesheet-rule-count", + PluralForm.get(ruleCount, getString("ruleCount.label")).replace( + "#1", + ruleCount + ) + ); + }, + + /** + * Update the @media rules sidebar for an editor. Hide if there are no rules + * Display a list of the @media rules in the editor's associated style sheet. + * Emits a 'media-list-changed' event after updating the UI. + * + * @param {StyleSheetEditor} editor + * Editor to update @media sidebar of + */ + _updateMediaList: function(editor) { + (async function() { + const details = await this.getEditorDetails(editor); + const list = details.querySelector(".stylesheet-media-list"); + + while (list.firstChild) { + list.firstChild.remove(); + } + + const rules = editor.mediaRules; + const showSidebar = Services.prefs.getBoolPref(PREF_MEDIA_SIDEBAR); + const sidebar = details.querySelector(".stylesheet-sidebar"); + + let inSource = false; + + for (const rule of rules) { + const { line, column, parentStyleSheet } = rule; + + let location = { + line: line, + column: column, + source: editor.styleSheet.href, + styleSheet: parentStyleSheet, + }; + if (editor.styleSheet.isOriginalSource) { + const styleSheet = editor.cssSheet; + location = await editor.styleSheet.getOriginalLocation( + styleSheet, + line, + column + ); + } + + // this @media rule is from a different original source + if (location.source != editor.styleSheet.href) { + continue; + } + inSource = true; + + const div = this._panelDoc.createElementNS(HTML_NS, "div"); + div.className = "media-rule-label"; + div.addEventListener( + "click", + this._jumpToLocation.bind(this, location) + ); + + const cond = this._panelDoc.createElementNS(HTML_NS, "div"); + cond.className = "media-rule-condition"; + if (!rule.matches) { + cond.classList.add("media-condition-unmatched"); + } + if (this.currentTarget.isLocalTab) { + this._setConditionContents(cond, rule.conditionText); + } else { + cond.textContent = rule.conditionText; + } + div.appendChild(cond); + + const link = this._panelDoc.createElementNS(HTML_NS, "div"); + link.className = "media-rule-line theme-link"; + if (location.line != -1) { + link.textContent = ":" + location.line; + } + div.appendChild(link); + + list.appendChild(div); + } + + sidebar.hidden = !showSidebar || !inSource; + + this.emit("media-list-changed", editor); + } + .bind(this)() + .catch(console.error)); + }, + + /** + * Used to safely inject media query links + * + * @param {HTMLElement} element + * The element corresponding to the media sidebar condition + * @param {String} rawText + * The raw condition text to parse + */ + _setConditionContents(element, rawText) { + const minMaxPattern = /(min\-|max\-)(width|height):\s\d+(px)/gi; + + let match = minMaxPattern.exec(rawText); + let lastParsed = 0; + while (match && match.index != minMaxPattern.lastIndex) { + const matchEnd = match.index + match[0].length; + const node = this._panelDoc.createTextNode( + rawText.substring(lastParsed, match.index) + ); + element.appendChild(node); + + const link = this._panelDoc.createElementNS(HTML_NS, "a"); + link.href = "#"; + link.className = "media-responsive-mode-toggle"; + link.textContent = rawText.substring(match.index, matchEnd); + link.addEventListener("click", this._onMediaConditionClick.bind(this)); + element.appendChild(link); + + match = minMaxPattern.exec(rawText); + lastParsed = matchEnd; + } + + const node = this._panelDoc.createTextNode( + rawText.substring(lastParsed, rawText.length) + ); + element.appendChild(node); + }, + + /** + * Called when a media condition is clicked + * If a responsive mode link is clicked, it will launch it. + * + * @param {object} e + * Event object + */ + _onMediaConditionClick: function(e) { + const conditionText = e.target.textContent; + const isWidthCond = conditionText.toLowerCase().indexOf("width") > -1; + const mediaVal = parseInt(/\d+/.exec(conditionText), 10); + + const options = isWidthCond ? { width: mediaVal } : { height: mediaVal }; + this._launchResponsiveMode(options); + e.preventDefault(); + e.stopPropagation(); + }, + + /** + * Launches the responsive mode with a specific width or height. + * + * @param {object} options + * Object with width or/and height properties. + */ + async _launchResponsiveMode(options = {}) { + const tab = this.currentTarget.localTab; + const win = this.currentTarget.localTab.ownerDocument.defaultView; + + await ResponsiveUIManager.openIfNeeded(win, tab, { + trigger: "style_editor", + }); + this.emit("responsive-mode-opened"); + + ResponsiveUIManager.getResponsiveUIForTab(tab).setViewportSize(options); + }, + + /** + * Jump cursor to the editor for a stylesheet and line number for a rule. + * + * @param {object} location + * Location object with 'line', 'column', and 'source' properties. + */ + _jumpToLocation: function(location) { + const source = location.styleSheet || location.source; + this.selectStyleSheet(source, location.line - 1, location.column - 1); + }, + + _startLoadingStyleSheets() { + this._root.classList.add("loading"); + this._loadingStyleSheets = []; + }, + + async _waitForLoadingStyleSheets() { + while (this._loadingStyleSheets?.length > 0) { + const pending = this._loadingStyleSheets; + this._loadingStyleSheets = []; + await Promise.all(pending); + } + + this._loadingStyleSheets = null; + this._root.classList.remove("loading"); + }, + + async _handleStyleSheetResource(resource) { + try { + // The fileName is in resource means this stylesheet was imported from file by user. + const { fileName } = resource; + let file = fileName ? new FileUtils.File(fileName) : null; + + // recall location of saved file for this sheet after page reload + if (!file) { + const identifier = this.getStyleSheetIdentifier(resource); + const savedFile = this.savedLocations[identifier]; + if (savedFile) { + file = savedFile; + } + } + resource.file = file; + + await this._addStyleSheet(resource); + } catch (e) { + console.error(e); + this.emit("error", { key: LOAD_ERROR, level: "warning" }); + } + }, + + async _onResourceAvailable(resources) { + for (const resource of resources) { + if ( + resource.resourceType === this._toolbox.resourceWatcher.TYPES.STYLESHEET + ) { + const onStyleSheetHandled = this._handleStyleSheetResource(resource); + + if (this._loadingStyleSheets) { + // In case of reloading/navigating and panel's opening + this._loadingStyleSheets.push(onStyleSheetHandled); + } + + await onStyleSheetHandled; + continue; + } + + if (!resource.targetFront.isTopLevel) { + continue; + } + + if (resource.name === "dom-loading") { + // will-navigate doesn't work when we navigate to a new process, + // and for now, onTargetAvailable/onTargetDestroyed doesn't fire on navigation and + // only when navigating to another process. + // So we fallback on DOCUMENT_EVENTS to be notified when we navigates. When we + // navigate within the same process as well as when we navigate to a new process. + // (We would probably revisit that in bug 1632141) + this._startLoadingStyleSheets(); + this._clear(); + } else if (resource.name === "dom-complete") { + await this._waitForLoadingStyleSheets(); + } + } + }, + + async _onResourceUpdated(updates) { + for (const { resource, update } of updates) { + if ( + update.resourceType === this._toolbox.resourceWatcher.TYPES.STYLESHEET + ) { + const editor = this.editors.find( + e => e.resourceId === update.resourceId + ); + + switch (update.updateType) { + case "style-applied": { + editor.onStyleApplied(update); + break; + } + case "property-change": { + for (const [property, value] of Object.entries( + update.resourceUpdates + )) { + editor.onPropertyChange(property, value); + } + break; + } + case "media-rules-changed": + case "matches-change": { + const { mediaRules } = resource; + editor.onMediaRulesChanged(mediaRules); + break; + } + } + } + } + }, + + async _onTargetAvailable({ targetFront }) { + if (targetFront.isTopLevel) { + await this.initializeHighlighter(targetFront); + } + }, + + destroy: function() { + this._toolbox.targetList.unwatchTargets( + [this._toolbox.targetList.TYPES.FRAME], + this._onTargetAvailable + ); + + this._toolbox.resourceWatcher.unwatchResources( + [ + this._toolbox.resourceWatcher.TYPES.DOCUMENT_EVENT, + this._toolbox.resourceWatcher.TYPES.STYLESHEET, + ], + { + onAvailable: this._onResourceAvailable, + onUpdated: this._onResourceUpdated, + } + ); + + this._clearStyleSheetEditors(); + + this._seenSheets = null; + + const sidebar = this._panelDoc.querySelector(".splitview-controller"); + const sidebarWidth = sidebar.getAttribute("width"); + Services.prefs.setIntPref(PREF_NAV_WIDTH, sidebarWidth); + + this._sourceMapPrefObserver.off( + PREF_ORIG_SOURCES, + this._onOrigSourcesPrefChanged + ); + this._sourceMapPrefObserver.destroy(); + this._prefObserver.off(PREF_MEDIA_SIDEBAR, this._onMediaPrefChanged); + this._prefObserver.destroy(); + }, +}; diff --git a/devtools/client/styleeditor/StyleEditorUtil.jsm b/devtools/client/styleeditor/StyleEditorUtil.jsm new file mode 100644 index 0000000000..a3b0b08f0a --- /dev/null +++ b/devtools/client/styleeditor/StyleEditorUtil.jsm @@ -0,0 +1,280 @@ +/* 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/. */ + +/* All top-level definitions here are exports. */ +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +"use strict"; + +const EXPORTED_SYMBOLS = [ + "getString", + "assert", + "log", + "text", + "wire", + "showFilePicker", + "optionsPopupMenu", +]; + +const PROPERTIES_URL = "chrome://devtools/locale/styleeditor.properties"; + +const { loader, require } = ChromeUtils.import( + "resource://devtools/shared/Loader.jsm" +); +const Services = require("Services"); +const gStringBundle = Services.strings.createBundle(PROPERTIES_URL); + +loader.lazyRequireGetter(this, "Menu", "devtools/client/framework/menu"); +loader.lazyRequireGetter( + this, + "MenuItem", + "devtools/client/framework/menu-item" +); + +const PREF_MEDIA_SIDEBAR = "devtools.styleeditor.showMediaSidebar"; +const PREF_ORIG_SOURCES = "devtools.source-map.client-service.enabled"; + +/** + * Returns a localized string with the given key name from the string bundle. + * + * @param name + * @param ...rest + * Optional arguments to format in the string. + * @return string + */ +function getString(name) { + try { + if (arguments.length == 1) { + return gStringBundle.GetStringFromName(name); + } + const rest = Array.prototype.slice.call(arguments, 1); + return gStringBundle.formatStringFromName(name, rest); + } catch (ex) { + console.error(ex); + throw new Error( + "L10N error. '" + name + "' is missing from " + PROPERTIES_URL + ); + } +} + +/** + * Assert an expression is true or throw if false. + * + * @param expression + * @param message + * Optional message. + * @return expression + */ +function assert(expression, message) { + if (!expression) { + const msg = message ? "ASSERTION FAILURE:" + message : "ASSERTION FAILURE"; + log(msg); + throw new Error(msg); + } + return expression; +} + +/** + * Retrieve or set the text content of an element. + * + * @param DOMElement root + * The element to use for querySelector. + * @param string selector + * Selector string for the element to get/set the text content. + * @param string textContent + * Optional text to set. + * @return string + * Text content of matching element or null if there were no element + * matching selector. + */ +function text(root, selector, textContent) { + const element = root.querySelector(selector); + if (!element) { + return null; + } + + if (textContent === undefined) { + return element.textContent; + } + element.textContent = textContent; + return textContent; +} + +/** + * Iterates _own_ properties of an object. + * + * @param object + * The object to iterate. + * @param function callback(aKey, aValue) + */ +function forEach(object, callback) { + for (const key in object) { + if (object.hasOwnProperty(key)) { + callback(key, object[key]); + } + } +} + +/** + * Log a message to the console. + * + * @param ...rest + * One or multiple arguments to log. + * If multiple arguments are given, they will be joined by " " + * in the log. + */ +function log() { + console.logStringMessage(Array.prototype.slice.call(arguments).join(" ")); +} + +/** + * Wire up element(s) matching selector with attributes, event listeners, etc. + * + * @param DOMElement root + * The element to use for querySelectorAll. + * Can be null if selector is a DOMElement. + * @param string|DOMElement selectorOrElement + * Selector string or DOMElement for the element(s) to wire up. + * @param object descriptor + * An object describing how to wire matching selector, + * supported properties are "events" and "attributes" taking + * objects themselves. + * Each key of properties above represents the name of the event or + * attribute, with the value being a function used as an event handler or + * string to use as attribute value. + * If descriptor is a function, the argument is equivalent to : + * {events: {'click': descriptor}} + */ +function wire(root, selectorOrElement, descriptor) { + let matches; + if (typeof selectorOrElement == "string") { + // selector + matches = root.querySelectorAll(selectorOrElement); + if (!matches.length) { + return; + } + } else { + // element + matches = [selectorOrElement]; + } + + if (typeof descriptor == "function") { + descriptor = { events: { click: descriptor } }; + } + + for (let i = 0; i < matches.length; i++) { + const element = matches[i]; + forEach(descriptor.events, function(name, handler) { + element.addEventListener(name, handler); + }); + forEach(descriptor.attributes, element.setAttribute); + } +} + +/** + * Show file picker and return the file user selected. + * + * @param mixed file + * Optional nsIFile or string representing the filename to auto-select. + * @param boolean toSave + * If true, the user is selecting a filename to save. + * @param nsIWindow parentWindow + * Optional parent window. If null the parent window of the file picker + * will be the window of the attached input element. + * @param callback + * The callback method, which will be called passing in the selected + * file or null if the user did not pick one. + * @param AString suggestedFilename + * The suggested filename when toSave is true. + */ +function showFilePicker( + path, + toSave, + parentWindow, + callback, + suggestedFilename +) { + if (typeof path == "string") { + try { + if (Services.io.extractScheme(path) == "file") { + const uri = Services.io.newURI(path); + const file = uri.QueryInterface(Ci.nsIFileURL).file; + callback(file); + return; + } + } catch (ex) { + callback(null); + return; + } + try { + const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + file.initWithPath(path); + callback(file); + return; + } catch (ex) { + callback(null); + return; + } + } + if (path) { + // "path" is an nsIFile + callback(path); + return; + } + + const fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + const mode = toSave ? fp.modeSave : fp.modeOpen; + const key = toSave ? "saveStyleSheet" : "importStyleSheet"; + const fpCallback = function(result) { + if (result == Ci.nsIFilePicker.returnCancel) { + callback(null); + } else { + callback(fp.file); + } + }; + + if (toSave && suggestedFilename) { + fp.defaultString = suggestedFilename; + } + + fp.init(parentWindow, getString(key + ".title"), mode); + fp.appendFilter(getString(key + ".filter"), "*.css"); + fp.appendFilters(fp.filterAll); + fp.open(fpCallback); +} + +/** + * Returns a Popup Menu for the Options ("gear") Button + * @param {function} toggleOrigSources + * To toggle the original source pref + * @param {function} toggleMediaSources + * To toggle the pref to show @media side bar + * @return {object} popupMenu + * A Menu object holding the MenuItems + */ +function optionsPopupMenu(toggleOrigSources, toggleMediaSidebar) { + const popupMenu = new Menu(); + popupMenu.append( + new MenuItem({ + id: "options-origsources", + label: getString("showOriginalSources.label"), + accesskey: getString("showOriginalSources.accesskey"), + type: "checkbox", + checked: Services.prefs.getBoolPref(PREF_ORIG_SOURCES), + click: () => toggleOrigSources(), + }) + ); + popupMenu.append( + new MenuItem({ + id: "options-show-media", + label: getString("showMediaSidebar.label"), + accesskey: getString("showMediaSidebar.accesskey"), + type: "checkbox", + checked: Services.prefs.getBoolPref(PREF_MEDIA_SIDEBAR), + click: () => toggleMediaSidebar(), + }) + ); + + return popupMenu; +} diff --git a/devtools/client/styleeditor/StyleSheetEditor.jsm b/devtools/client/styleeditor/StyleSheetEditor.jsm new file mode 100644 index 0000000000..10216d5897 --- /dev/null +++ b/devtools/client/styleeditor/StyleSheetEditor.jsm @@ -0,0 +1,984 @@ +/* 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 EXPORTED_SYMBOLS = ["StyleSheetEditor"]; + +const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); +const Editor = require("devtools/client/shared/sourceeditor/editor"); +const promise = require("promise"); +const { + shortSource, + prettifyCSS, +} = require("devtools/shared/inspector/css-logic"); +const { throttle } = require("devtools/shared/throttle"); +const Services = require("Services"); +const EventEmitter = require("devtools/shared/event-emitter"); +const { FileUtils } = require("resource://gre/modules/FileUtils.jsm"); +const { NetUtil } = require("resource://gre/modules/NetUtil.jsm"); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { + getString, + showFilePicker, +} = require("resource://devtools/client/styleeditor/StyleEditorUtil.jsm"); + +const LOAD_ERROR = "error-load"; +const SAVE_ERROR = "error-save"; + +// max update frequency in ms (avoid potential typing lag and/or flicker) +// @see StyleEditor.updateStylesheet +const UPDATE_STYLESHEET_DELAY = 500; + +// Pref which decides if CSS autocompletion is enabled in Style Editor or not. +const AUTOCOMPLETION_PREF = "devtools.styleeditor.autocompletion-enabled"; + +// Pref which decides whether updates to the stylesheet use transitions +const TRANSITION_PREF = "devtools.styleeditor.transitions"; + +// How long to wait to update linked CSS file after original source was saved +// to disk. Time in ms. +const CHECK_LINKED_SHEET_DELAY = 500; + +// How many times to check for linked file changes +const MAX_CHECK_COUNT = 10; + +// How much time should the mouse be still before the selector at that position +// gets highlighted? +const SELECTOR_HIGHLIGHT_TIMEOUT = 500; + +// Minimum delay between firing two media-rules-changed events. +const EMIT_MEDIA_RULES_THROTTLING = 500; + +const STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR = "styleeditor"; + +/** + * StyleSheetEditor controls the editor linked to a particular StyleSheet + * object. + * + * Emits events: + * 'property-change': A property on the underlying stylesheet has changed + * 'source-editor-load': The source editor for this editor has been loaded + * 'error': An error has occured + * + * @param {Resource} resource + * The STYLESHEET resource which is received from resource watcher. + * @param {DOMWindow} win + * panel window for style editor + * @param {Walker} walker + * Optional walker used for selectors autocompletion + * @param {CustomHighlighterFront} highlighter + * Optional highlighter front for the SelectorHighligher used to + * highlight selectors + * @param {Number} styleSheetFriendlyIndex + * Optional Integer representing the index of the current stylesheet + * among all stylesheets of its type (inline or user-created) + */ +function StyleSheetEditor( + resource, + win, + walker, + highlighter, + styleSheetFriendlyIndex +) { + EventEmitter.decorate(this); + + this._resource = resource; + this._inputElement = null; + this.sourceEditor = null; + this._window = win; + this._isNew = this.styleSheet.isNew; + this.walker = walker; + this.highlighter = highlighter; + this.styleSheetFriendlyIndex = styleSheetFriendlyIndex; + + // True when we've called update() on the style sheet. + // @backward-compat { version 86 } Starting 86, onStyleApplied will be able to know + // if the style was applied because of a change in the StyleEditor (via the `event.cause` + // property inside the resource update). `this._isUpdating` can be dropped when 86 + // reaches release. + this._isUpdating = false; + // True when we've just set the editor text based on a style-applied + // event from the StyleSheetActor. + this._justSetText = false; + + // state to use when inputElement attaches + this._state = { + text: "", + selection: { + start: { line: 0, ch: 0 }, + end: { line: 0, ch: 0 }, + }, + }; + + this._styleSheetFilePath = null; + if ( + this.styleSheet.href && + Services.io.extractScheme(this.styleSheet.href) == "file" + ) { + this._styleSheetFilePath = this.styleSheet.href; + } + + this.onPropertyChange = this.onPropertyChange.bind(this); + this.onMediaRulesChanged = this.onMediaRulesChanged.bind(this); + this.checkLinkedFileForChanges = this.checkLinkedFileForChanges.bind(this); + this.markLinkedFileBroken = this.markLinkedFileBroken.bind(this); + this.saveToFile = this.saveToFile.bind(this); + this.updateStyleSheet = this.updateStyleSheet.bind(this); + this._updateStyleSheet = this._updateStyleSheet.bind(this); + this._onMouseMove = this._onMouseMove.bind(this); + + this._focusOnSourceEditorReady = false; + this.savedFile = this.styleSheet.file; + this.linkCSSFile(); + + this.emitMediaRulesChanged = throttle( + this.emitMediaRulesChanged, + EMIT_MEDIA_RULES_THROTTLING, + this + ); + + this.mediaRules = []; +} + +StyleSheetEditor.prototype = { + get resourceId() { + return this._resource.resourceId; + }, + + get styleSheet() { + return this._resource; + }, + + /** + * Whether there are unsaved changes in the editor + */ + get unsaved() { + return this.sourceEditor && !this.sourceEditor.isClean(); + }, + + /** + * Whether the editor is for a stylesheet created by the user + * through the style editor UI. + */ + get isNew() { + return this._isNew; + }, + + /** + * The style sheet or the generated style sheet for this source if it's an + * original source. + */ + get cssSheet() { + if (this.styleSheet.isOriginalSource) { + return this.styleSheet.relatedStyleSheet; + } + return this.styleSheet; + }, + + get savedFile() { + return this._savedFile; + }, + + set savedFile(name) { + this._savedFile = name; + + this.linkCSSFile(); + }, + + /** + * Get a user-friendly name for the style sheet. + * + * @return string + */ + get friendlyName() { + if (this.savedFile) { + return this.savedFile.leafName; + } + + if (this._isNew) { + const index = this.styleSheetFriendlyIndex + 1 || 0; + return getString("newStyleSheet", index); + } + + if (!this.styleSheet.href) { + const index = this.styleSheetFriendlyIndex + 1 || 0; + return getString("inlineStyleSheet", index); + } + + if (!this._friendlyName) { + const sheetURI = this.styleSheet.href; + this._friendlyName = shortSource({ href: sheetURI }); + try { + this._friendlyName = decodeURI(this._friendlyName); + } catch (ex) { + // Ignore. + } + } + return this._friendlyName; + }, + + /** + * Check if transitions are enabled for style changes. + * + * @return Boolean + */ + get transitionsEnabled() { + return Services.prefs.getBoolPref(TRANSITION_PREF); + }, + + /** + * If this is an original source, get the path of the CSS file it generated. + */ + linkCSSFile: function() { + if (!this.styleSheet.isOriginalSource) { + return; + } + + const relatedSheet = this.styleSheet.relatedStyleSheet; + if (!relatedSheet || !relatedSheet.href) { + return; + } + + let path; + const href = removeQuery(relatedSheet.href); + const uri = NetUtil.newURI(href); + + if (uri.scheme == "file") { + const file = uri.QueryInterface(Ci.nsIFileURL).file; + path = file.path; + } else if (this.savedFile) { + const origHref = removeQuery(this.styleSheet.href); + const origUri = NetUtil.newURI(origHref); + path = findLinkedFilePath(uri, origUri, this.savedFile); + } else { + // we can't determine path to generated file on disk + return; + } + + if (this.linkedCSSFile == path) { + return; + } + + this.linkedCSSFile = path; + + this.linkedCSSFileError = null; + + // save last file change time so we can compare when we check for changes. + OS.File.stat(path).then(info => { + this._fileModDate = info.lastModificationDate.getTime(); + }, this.markLinkedFileBroken); + + this.emit("linked-css-file"); + }, + + /** + * A helper function that fetches the source text from the style + * sheet. The text is possibly prettified using prettifyCSS. This + * also sets |this._state.text| to the new text. + * + * @return {Promise} a promise that resolves to the new text + */ + async _getSourceTextAndPrettify() { + const styleSheetsFront = await this._getStyleSheetsFront(); + + let longStr = null; + if (this.styleSheet.isOriginalSource) { + // If the stylesheet is OriginalSource, we should get the texts from SourceMapService. + // So, for now, we use OriginalSource.getText() as it is. + longStr = await this.styleSheet.getText(); + } else { + longStr = await styleSheetsFront.getText(this.resourceId); + } + + let source = await longStr.string(); + const ruleCount = this.styleSheet.ruleCount; + if (!this.styleSheet.isOriginalSource) { + const { result, mappings } = prettifyCSS(source, ruleCount); + source = result; + // Store the list of objects with mappings between CSS token positions from the + // original source to the prettified source. These will be used when requested to + // jump to a specific position within the editor. + this._mappings = mappings; + } + + this._state.text = source; + return source; + }, + + /** + * Start fetching the full text source for this editor's sheet. + * + * @return {Promise} + * A promise that'll resolve with the source text once the source + * has been loaded or reject on unexpected error. + */ + fetchSource: function() { + return this._getSourceTextAndPrettify() + .then(source => { + this.sourceLoaded = true; + return source; + }) + .catch(e => { + if (this._isDestroyed) { + console.warn( + "Could not fetch the source for " + + this.styleSheet.href + + ", the editor was destroyed" + ); + console.error(e); + } else { + console.error(e); + this.emit("error", { + key: LOAD_ERROR, + append: this.styleSheet.href, + level: "warning", + }); + throw e; + } + }); + }, + + /** + * Set the cursor at the given line and column location within the code editor. + * + * @param {Number} line + * @param {Number} column + */ + setCursor(line, column) { + const position = this.translateCursorPosition(line, column); + this.sourceEditor.setCursor({ line: position.line, ch: position.column }); + }, + + /** + * If the stylesheet was automatically prettified, there should be a list of line + * and column mappings from the original to the generated source that can be used + * to translate the cursor position to the correct location in the prettified source. + * If no mappings exist, return the original cursor position unchanged. + * + * @param {Number} line + * @param {Numer} column + * + * @return {Object} + */ + translateCursorPosition(line, column) { + if (Array.isArray(this._mappings)) { + for (const mapping of this._mappings) { + if ( + mapping.original.line === line && + mapping.original.column === column + ) { + line = mapping.generated.line; + column = mapping.generated.column; + continue; + } + } + } + + return { line, column }; + }, + + /** + * Forward property-change event from stylesheet. + * + * @param {string} event + * Event type + * @param {string} property + * Property that has changed on sheet + */ + onPropertyChange: function(property, value) { + this.emit("property-change", property, value); + }, + + /** + * Called when the stylesheet text changes. + * @param {Object} update: The stylesheet resource update packet. + */ + onStyleApplied: function(update) { + const updateIsFromSyleSheetEditor = + update?.event?.cause === STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR; + + // @backward-compat { version 86 } this._isUpdating can be removed. + // See property declaration for more information. + if (this._isUpdating || updateIsFromSyleSheetEditor) { + // We just applied an edit in the editor, so we can drop this + // notification. + // @backward-compat { version 86 } this._isUpdating can be removed. + this._isUpdating = false; + this.emit("style-applied"); + return; + } + + if (this.sourceEditor) { + this._getSourceTextAndPrettify().then(newText => { + this._justSetText = true; + const firstLine = this.sourceEditor.getFirstVisibleLine(); + const pos = this.sourceEditor.getCursor(); + this.sourceEditor.setText(newText); + this.sourceEditor.setFirstVisibleLine(firstLine); + this.sourceEditor.setCursor(pos); + this.emit("style-applied"); + }); + } + }, + + /** + * Handles changes to the list of @media rules in the stylesheet. + * Emits 'media-rules-changed' if the list has changed. + * + * @param {array} rules + * Array of MediaRuleFronts for new media rules of sheet. + */ + onMediaRulesChanged: function(rules) { + if (!rules.length && !this.mediaRules.length) { + return; + } + + this.mediaRules = rules; + this.emitMediaRulesChanged(); + }, + + /** + * Forward media-rules-changed event from stylesheet. + */ + emitMediaRulesChanged: function() { + this.emit("media-rules-changed", this.mediaRules); + }, + + /** + * Create source editor and load state into it. + * @param {DOMElement} inputElement + * Element to load source editor in + * @param {CssProperties} cssProperties + * A css properties database. + * + * @return {Promise} + * Promise that will resolve when the style editor is loaded. + */ + load: function(inputElement, cssProperties) { + if (this._isDestroyed) { + return promise.reject( + "Won't load source editor as the style sheet has " + + "already been removed from Style Editor." + ); + } + + this._inputElement = inputElement; + + const config = { + value: this._state.text, + lineNumbers: true, + mode: Editor.modes.css, + readOnly: false, + autoCloseBrackets: "{}()", + extraKeys: this._getKeyBindings(), + contextMenu: "sourceEditorContextMenu", + autocomplete: Services.prefs.getBoolPref(AUTOCOMPLETION_PREF), + autocompleteOpts: { walker: this.walker, cssProperties }, + cssProperties, + }; + const sourceEditor = (this._sourceEditor = new Editor(config)); + + sourceEditor.on("dirty-change", this.onPropertyChange); + + return sourceEditor.appendTo(inputElement).then(() => { + sourceEditor.on("saveRequested", this.saveToFile); + + if (!this.styleSheet.isOriginalSource) { + sourceEditor.on("change", this.updateStyleSheet); + } + + this.sourceEditor = sourceEditor; + + if (this._focusOnSourceEditorReady) { + this._focusOnSourceEditorReady = false; + sourceEditor.focus(); + } + + sourceEditor.setSelection( + this._state.selection.start, + this._state.selection.end + ); + + if ( + this.highlighter && + this.walker && + sourceEditor.container && + sourceEditor.container.contentWindow + ) { + sourceEditor.container.contentWindow.addEventListener( + "mousemove", + this._onMouseMove + ); + } + + // Add the commands controller for the source-editor. + sourceEditor.insertCommandsController(); + + this.emit("source-editor-load"); + }); + }, + + /** + * Get the source editor for this editor. + * + * @return {Promise} + * Promise that will resolve with the editor. + */ + getSourceEditor: function() { + const self = this; + + if (this.sourceEditor) { + return Promise.resolve(this); + } + + return new Promise(resolve => { + this.on("source-editor-load", () => { + resolve(self); + }); + }); + }, + + /** + * Focus the Style Editor input. + */ + focus: function() { + if (this.sourceEditor) { + this.sourceEditor.focus(); + } else { + this._focusOnSourceEditorReady = true; + } + }, + + /** + * Event handler for when the editor is shown. + */ + onShow: function() { + if (this.sourceEditor) { + // CodeMirror needs refresh to restore scroll position after hiding and + // showing the editor. + this.sourceEditor.refresh(); + } + this.focus(); + }, + + /** + * Toggled the disabled state of the underlying stylesheet. + */ + async toggleDisabled() { + const styleSheetsFront = await this._getStyleSheetsFront(); + styleSheetsFront.toggleDisabled(this.resourceId).catch(console.error); + }, + + /** + * Queue a throttled task to update the live style sheet. + */ + updateStyleSheet: function() { + if (this._updateTask) { + // cancel previous queued task not executed within throttle delay + this._window.clearTimeout(this._updateTask); + } + + this._updateTask = this._window.setTimeout( + this._updateStyleSheet, + UPDATE_STYLESHEET_DELAY + ); + }, + + /** + * Update live style sheet according to modifications. + */ + async _updateStyleSheet() { + if (this.styleSheet.disabled) { + // TODO: do we want to do this? + return; + } + + if (this._justSetText) { + this._justSetText = false; + return; + } + + // reset only if we actually perform an update + // (stylesheet is enabled) so that 'missed' updates + // while the stylesheet is disabled can be performed + // when it is enabled back. @see enableStylesheet + this._updateTask = null; + + if (this.sourceEditor) { + this._state.text = this.sourceEditor.getText(); + } + + // @backward-compat { version 86 } See property declaration for more information. + this._isUpdating = true; + + try { + const styleSheetsFront = await this._getStyleSheetsFront(); + await styleSheetsFront.update( + this.resourceId, + this._state.text, + this.transitionsEnabled, + STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR + ); + + // Clear any existing mappings from automatic CSS prettification + // because they were likely invalided by manually editing the stylesheet. + this._mappings = null; + } catch (e) { + console.error(e); + } + }, + + /** + * Handle mousemove events, calling _highlightSelectorAt after a delay only + * and reseting the delay everytime. + */ + _onMouseMove: function(e) { + this.highlighter.hide(); + + if (this.mouseMoveTimeout) { + this._window.clearTimeout(this.mouseMoveTimeout); + this.mouseMoveTimeout = null; + } + + this.mouseMoveTimeout = this._window.setTimeout(() => { + this._highlightSelectorAt(e.clientX, e.clientY); + }, SELECTOR_HIGHLIGHT_TIMEOUT); + }, + + /** + * Highlight nodes matching the selector found at coordinates x,y in the + * editor, if any. + * + * @param {Number} x + * @param {Number} y + */ + async _highlightSelectorAt(x, y) { + const pos = this.sourceEditor.getPositionFromCoords({ left: x, top: y }); + const info = this.sourceEditor.getInfoAt(pos); + if (!info || info.state !== "selector") { + return; + } + + const node = await this.walker.getStyleSheetOwnerNode(this.resourceId); + await this.highlighter.show(node, { + selector: info.selector, + hideInfoBar: true, + showOnly: "border", + region: "border", + }); + + this.emit("node-highlighted"); + }, + + /** + * Save the editor contents into a file and set savedFile property. + * A file picker UI will open if file is not set and editor is not headless. + * + * @param mixed file + * Optional nsIFile or string representing the filename to save in the + * background, no UI will be displayed. + * If not specified, the original style sheet URI is used. + * To implement 'Save' instead of 'Save as', you can pass + * savedFile here. + * @param function(nsIFile aFile) callback + * Optional callback called when the operation has finished. + * aFile has the nsIFile object for saved file or null if the operation + * has failed or has been canceled by the user. + * @see savedFile + */ + saveToFile: function(file, callback) { + const onFile = returnFile => { + if (!returnFile) { + if (callback) { + callback(null); + } + return; + } + + if (this.sourceEditor) { + this._state.text = this.sourceEditor.getText(); + } + + const ostream = FileUtils.openSafeFileOutputStream(returnFile); + const converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + const istream = converter.convertToInputStream(this._state.text); + + NetUtil.asyncCopy(istream, ostream, status => { + if (!Components.isSuccessCode(status)) { + if (callback) { + callback(null); + } + this.emit("error", { key: SAVE_ERROR }); + return; + } + FileUtils.closeSafeFileOutputStream(ostream); + + this.onFileSaved(returnFile); + + if (callback) { + callback(returnFile); + } + }); + }; + + let defaultName; + if (this._friendlyName) { + defaultName = OS.Path.basename(this._friendlyName); + } + showFilePicker( + file || this._styleSheetFilePath, + true, + this._window, + onFile, + defaultName + ); + }, + + /** + * Called when this source has been successfully saved to disk. + */ + onFileSaved: function(returnFile) { + this._friendlyName = null; + this.savedFile = returnFile; + + if (this.sourceEditor) { + this.sourceEditor.setClean(); + } + + this.emit("property-change"); + + // TODO: replace with file watching + this._modCheckCount = 0; + this._window.clearTimeout(this._timeout); + + if (this.linkedCSSFile && !this.linkedCSSFileError) { + this._timeout = this._window.setTimeout( + this.checkLinkedFileForChanges, + CHECK_LINKED_SHEET_DELAY + ); + } + }, + + /** + * Check to see if our linked CSS file has changed on disk, and + * if so, update the live style sheet. + */ + checkLinkedFileForChanges: function() { + OS.File.stat(this.linkedCSSFile).then(info => { + const lastChange = info.lastModificationDate.getTime(); + + if (this._fileModDate && lastChange != this._fileModDate) { + this._fileModDate = lastChange; + this._modCheckCount = 0; + + this.updateLinkedStyleSheet(); + return; + } + + if (++this._modCheckCount > MAX_CHECK_COUNT) { + this.updateLinkedStyleSheet(); + return; + } + + // try again in a bit + this._timeout = this._window.setTimeout( + this.checkLinkedFileForChanges, + CHECK_LINKED_SHEET_DELAY + ); + }, this.markLinkedFileBroken); + }, + + /** + * Notify that the linked CSS file (if this is an original source) + * doesn't exist on disk in the place we think it does. + * + * @param string error + * The error we got when trying to access the file. + */ + markLinkedFileBroken: function(error) { + this.linkedCSSFileError = error || true; + this.emit("linked-css-file-error"); + + error += + " querying " + + this.linkedCSSFile + + " original source location: " + + this.savedFile.path; + console.error(error); + }, + + /** + * For original sources (e.g. Sass files). Fetch contents of linked CSS + * file from disk and live update the stylesheet object with the contents. + */ + updateLinkedStyleSheet: function() { + OS.File.read(this.linkedCSSFile).then(async array => { + const decoder = new TextDecoder(); + const text = decoder.decode(array); + + // Ensure we don't re-fetch the text from the original source + // actor when we're notified that the style sheet changed. + // @backward-compat { version 86 } See property declaration for more information. + this._isUpdating = true; + + const styleSheetsFront = await this._getStyleSheetsFront(); + + await styleSheetsFront.update( + this.resourceId, + text, + this.transitionsEnabled, + STYLE_SHEET_UPDATE_CAUSED_BY_STYLE_EDITOR + ); + }, this.markLinkedFileBroken); + }, + + /** + * Retrieve custom key bindings objects as expected by Editor. + * Editor action names are not displayed to the user. + * + * @return {array} key binding objects for the source editor + */ + _getKeyBindings: function() { + const bindings = {}; + const keybind = Editor.accel(getString("saveStyleSheet.commandkey")); + + bindings[keybind] = () => { + this.saveToFile(this.savedFile); + }; + + bindings["Shift-" + keybind] = () => { + this.saveToFile(); + }; + + bindings.Esc = false; + + return bindings; + }, + + _getStyleSheetsFront() { + return this._resource.targetFront.getFront("stylesheets"); + }, + + /** + * Clean up for this editor. + */ + destroy: function() { + if (this._sourceEditor) { + this._sourceEditor.off("dirty-change", this.onPropertyChange); + this._sourceEditor.off("saveRequested", this.saveToFile); + this._sourceEditor.off("change", this.updateStyleSheet); + if ( + this.highlighter && + this.walker && + this._sourceEditor.container && + this._sourceEditor.container.contentWindow + ) { + this._sourceEditor.container.contentWindow.removeEventListener( + "mousemove", + this._onMouseMove + ); + } + this._sourceEditor.destroy(); + } + this._isDestroyed = true; + }, +}; + +/** + * Find a path on disk for a file given it's hosted uri, the uri of the + * original resource that generated it (e.g. Sass file), and the location of the + * local file for that source. + * + * @param {nsIURI} uri + * The uri of the resource + * @param {nsIURI} origUri + * The uri of the original source for the resource + * @param {nsIFile} file + * The local file for the resource on disk + * + * @return {string} + * The path of original file on disk + */ +function findLinkedFilePath(uri, origUri, file) { + const { origBranch, branch } = findUnsharedBranches(origUri, uri); + const project = findProjectPath(file, origBranch); + + const parts = project.concat(branch); + const path = OS.Path.join.apply(this, parts); + + return path; +} + +/** + * Find the path of a project given a file in the project and its branch + * off the root. e.g.: + * /Users/moz/proj/src/a.css" and "src/a.css" + * would yield ["Users", "moz", "proj"] + * + * @param {nsIFile} file + * file for that resource on disk + * @param {array} branch + * path parts for branch to chop off file path. + * @return {array} + * array of path parts + */ +function findProjectPath(file, branch) { + const path = OS.Path.split(file.path).components; + + for (let i = 2; i <= branch.length; i++) { + // work backwards until we find a differing directory name + if (path[path.length - i] != branch[branch.length - i]) { + return path.slice(0, path.length - i + 1); + } + } + + // if we don't find a differing directory, just chop off the branch + return path.slice(0, path.length - branch.length); +} + +/** + * Find the parts of a uri past the root it shares with another uri. e.g: + * "http://localhost/built/a.scss" and "http://localhost/src/a.css" + * would yield ["built", "a.scss"] and ["src", "a.css"] + * + * @param {nsIURI} origUri + * uri to find unshared branch of. Usually is uri for original source. + * @param {nsIURI} uri + * uri to compare against to get a shared root + * @return {object} + * object with 'branch' and 'origBranch' array of path parts for branch + */ +function findUnsharedBranches(origUri, uri) { + origUri = OS.Path.split(origUri.pathQueryRef).components; + uri = OS.Path.split(uri.pathQueryRef).components; + + for (let i = 0; i < uri.length - 1; i++) { + if (uri[i] != origUri[i]) { + return { + branch: uri.slice(i), + origBranch: origUri.slice(i), + }; + } + } + return { + branch: uri, + origBranch: origUri, + }; +} + +/** + * Remove the query string from a url. + * + * @param {string} href + * Url to remove query string from + * @return {string} + * Url without query string + */ +function removeQuery(href) { + return href.replace(/\?.*/, ""); +} diff --git a/devtools/client/styleeditor/index.xhtml b/devtools/client/styleeditor/index.xhtml new file mode 100644 index 0000000000..1cd879a4ed --- /dev/null +++ b/devtools/client/styleeditor/index.xhtml @@ -0,0 +1,160 @@ + + + + %editMenuStrings; + + %sourceEditorStrings; +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/devtools/client/styleeditor/moz.build b/devtools/client/styleeditor/moz.build new file mode 100644 index 0000000000..e02ef7a49b --- /dev/null +++ b/devtools/client/styleeditor/moz.build @@ -0,0 +1,18 @@ +# -*- 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/. + +BROWSER_CHROME_MANIFESTS += ["test/browser.ini"] + +DevToolsModules( + "original-source.js", + "panel.js", + "StyleEditorUI.jsm", + "StyleEditorUtil.jsm", + "StyleSheetEditor.jsm", +) + +with Files("**"): + BUG_COMPONENT = ("DevTools", "Style Editor") diff --git a/devtools/client/styleeditor/original-source.js b/devtools/client/styleeditor/original-source.js new file mode 100644 index 0000000000..d4deca68ff --- /dev/null +++ b/devtools/client/styleeditor/original-source.js @@ -0,0 +1,103 @@ +/* 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"; + +/** + * An object of this type represents an original source for the style + * editor. An "original" source is one that is mentioned in a source + * map. + * + * @param {String} url + * The URL of the original source. + * @param {String} sourceID + * The source ID of the original source, as used by the source + * map service. + * @param {SourceMapService} sourceMapService + * The source map service; @see Toolbox.sourceMapService + */ +function OriginalSource(url, sourceId, sourceMapService) { + this.isOriginalSource = true; + + this._url = url; + this._sourceId = sourceId; + this._sourceMapService = sourceMapService; +} + +OriginalSource.prototype = { + get sourceId() { + return this._sourceId; + }, + + /** Get the original source's URL. */ + get url() { + return this._url; + }, + + /** Get the original source's URL. */ + get href() { + return this._url; + }, + + /** + * Return a promise that will resolve to the original source's full + * text. The return result is actually an object with a single + * `string` method; this method will return the source text as a + * string. This is done because the style editor elsewhere expects + * a long string actor. + */ + getText: function() { + if (!this._sourcePromise) { + this._sourcePromise = this._sourceMapService + .getOriginalSourceText(this._sourceId) + .then(contents => { + // Make it look like a long string actor. + return { + string: () => contents.text, + }; + }); + } + return this._sourcePromise; + }, + + /** + * Given a source-mapped, generated style sheet, a line, and a + * column, return the corresponding original location in this style + * sheet. + * + * @param {StyleSheetFront} relatedSheet + * The generated style sheet's actor + * @param {Number} line + * Line number. + * @param {Number} column + * Column number. + * @return {Location} + * The original location, an object with at least + * `sourceUrl`, `source`, `styleSheet`, `line`, and `column` + * properties. + */ + getOriginalLocation: function(relatedSheet, line, column) { + const { href, nodeHref, resourceId: sourceId } = relatedSheet; + const sourceUrl = href || nodeHref; + return this._sourceMapService + .getOriginalLocation({ + sourceId, + line, + column, + sourceUrl, + }) + .then(location => { + // Add some properties for the style editor. + location.source = location.sourceUrl; + location.styleSheet = relatedSheet; + return location; + }); + }, + + // Dummy implementations, as we never emit an event. + on: function() {}, + off: function() {}, +}; + +exports.OriginalSource = OriginalSource; diff --git a/devtools/client/styleeditor/panel.js b/devtools/client/styleeditor/panel.js new file mode 100644 index 0000000000..004d68515b --- /dev/null +++ b/devtools/client/styleeditor/panel.js @@ -0,0 +1,168 @@ +/* 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"; + +var Services = require("Services"); +var { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); +var EventEmitter = require("devtools/shared/event-emitter"); + +var { + StyleEditorUI, +} = require("resource://devtools/client/styleeditor/StyleEditorUI.jsm"); +var { + getString, +} = require("resource://devtools/client/styleeditor/StyleEditorUtil.jsm"); + +var StyleEditorPanel = function StyleEditorPanel(panelWin, toolbox) { + EventEmitter.decorate(this); + + this._toolbox = toolbox; + this._panelWin = panelWin; + this._panelDoc = panelWin.document; + + this.destroy = this.destroy.bind(this); + this._showError = this._showError.bind(this); +}; + +exports.StyleEditorPanel = StyleEditorPanel; + +StyleEditorPanel.prototype = { + get panelWindow() { + return this._panelWin; + }, + + /** + * open is effectively an asynchronous constructor + */ + async open() { + // Initialize the CSS properties database. + const { cssProperties } = await this._toolbox.target.getFront( + "cssProperties" + ); + + // Initialize the UI + this.UI = new StyleEditorUI(this._toolbox, this._panelDoc, cssProperties); + this.UI.on("error", this._showError); + await this.UI.initialize(); + + this.isReady = true; + + return this; + }, + + /** + * Show an error message from the style editor in the toolbox + * notification box. + * + * @param {string} data + * The parameters to customize the error message + */ + _showError: function(data) { + if (!this._toolbox) { + // could get an async error after we've been destroyed + return; + } + + let errorMessage = getString(data.key); + if (data.append) { + errorMessage += " " + data.append; + } + + const notificationBox = this._toolbox.getNotificationBox(); + const notification = notificationBox.getNotificationWithValue( + "styleeditor-error" + ); + + let level = notificationBox.PRIORITY_CRITICAL_LOW; + if (data.level === "info") { + level = notificationBox.PRIORITY_INFO_LOW; + } else if (data.level === "warning") { + level = notificationBox.PRIORITY_WARNING_LOW; + } + + if (!notification) { + notificationBox.appendNotification( + errorMessage, + "styleeditor-error", + "", + level + ); + } + }, + + /** + * Select a stylesheet. + * + * @param {StyleSheetFront} front + * The front of stylesheet to find and select in editor. + * @param {number} line + * Line number to jump to after selecting. One-indexed + * @param {number} col + * Column number to jump to after selecting. One-indexed + * @return {Promise} + * Promise that will resolve when the editor is selected and ready + * to be used. + */ + selectStyleSheet: function(front, line, col) { + if (!this.UI) { + return null; + } + + return this.UI.selectStyleSheet(front, line - 1, col ? col - 1 : 0); + }, + + /** + * Given a location in an original file, open that file in the editor. + * + * @param {string} originalId + * The original "sourceId" returned from the sourcemap worker. + * @param {number} line + * Line number to jump to after selecting. One-indexed + * @param {number} col + * Column number to jump to after selecting. One-indexed + * @return {Promise} + * Promise that will resolve when the editor is selected and ready + * to be used. + */ + selectOriginalSheet: function(originalId, line, col) { + if (!this.UI) { + return null; + } + + const originalSheet = this.UI.getOriginalSourceSheet(originalId); + return this.UI.selectStyleSheet(originalSheet, line - 1, col ? col - 1 : 0); + }, + + getStylesheetFrontForGeneratedURL: function(url) { + if (!this.UI) { + return null; + } + + return this.UI.getStylesheetFrontForGeneratedURL(url); + }, + + /** + * Destroy the style editor. + */ + destroy: function() { + if (this._destroyed) { + return; + } + this._destroyed = true; + + this._toolbox = null; + this._panelWin = null; + this._panelDoc = null; + + this.UI.destroy(); + this.UI = null; + }, +}; + +XPCOMUtils.defineLazyGetter(StyleEditorPanel.prototype, "strings", function() { + return Services.strings.createBundle( + "chrome://devtools/locale/styleeditor.properties" + ); +}); diff --git a/devtools/client/styleeditor/test/.eslintrc.js b/devtools/client/styleeditor/test/.eslintrc.js new file mode 100644 index 0000000000..3d0bd99e1b --- /dev/null +++ b/devtools/client/styleeditor/test/.eslintrc.js @@ -0,0 +1,6 @@ +"use strict"; + +module.exports = { + // Extend from the shared list of defined globals for mochitests. + extends: "../../../.eslintrc.mochitests.js", +}; diff --git a/devtools/client/styleeditor/test/autocomplete.html b/devtools/client/styleeditor/test/autocomplete.html new file mode 100644 index 0000000000..801eb4d4b9 --- /dev/null +++ b/devtools/client/styleeditor/test/autocomplete.html @@ -0,0 +1,23 @@ + + + + testcase for autocomplete testing + + + + +
parent child
+ + diff --git a/devtools/client/styleeditor/test/browser.ini b/devtools/client/styleeditor/test/browser.ini new file mode 100644 index 0000000000..6ccefc3b1f --- /dev/null +++ b/devtools/client/styleeditor/test/browser.ini @@ -0,0 +1,128 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +support-files = + autocomplete.html + bug_1405342_serviceworker_iframes.html + four.html + head.js + iframe_with_service_worker.html + iframe_service_worker.js + import.css + import.html + import2.css + inline-1.html + inline-2.html + longload.html + many-media-rules-sourcemaps/index.html + many-media-rules-sourcemaps/sourcemap/sourcemap-css/sourcemaps.css + many-media-rules-sourcemaps/sourcemap/sourcemap-css/sourcemaps.css.map + many-media-rules-sourcemaps/sourcemap/sourcemap-sass/_partial.scss + many-media-rules-sourcemaps/sourcemap/sourcemap-sass/sourcemaps.scss + media-small.css + media.html + media-rules.html + media-rules.css + media-rules-sourcemaps.html + minified.html + missing.html + nostyle.html + pretty.css + resources_inpage.jsi + resources_inpage1.css + resources_inpage2.css + selector-highlighter.html + simple.css + simple.css.gz + simple.css.gz^headers^ + simple.gz.html + simple.html + sjs_huge-css-server.sjs + sourcemap-css/contained.css + sourcemap-css/sourcemaps.css + sourcemap-css/sourcemaps.css.map + # add nosniff header to test against Bug 1330383 + sourcemap-css/sourcemaps.css.map^headers^ + sourcemap-css/media-rules.css + sourcemap-css/media-rules.css.map + sourcemap-css/test-bootstrap-scss.css + sourcemap-css/test-stylus.css + sourcemap-sass/sourcemaps.scss + # add nosniff header to test against Bug 1330383 + sourcemap-sass/sourcemaps.scss^headers^ + sourcemap-sass/media-rules.scss + sourcemap-styl/test-stylus.styl + sourcemaps.html + sourcemaps-inline.html + sourcemaps-large.html + sourcemaps-watching.html + test_private.css + test_private.html + doc_empty.html + doc_fetch_from_netmonitor.html + doc_long_string.css + doc_long.css + doc_short_string.css + doc_xulpage.xhtml + sync.html + sync_with_csp.css + sync_with_csp.html + utf-16.css + !/devtools/client/inspector/shared/test/head.js + !/devtools/client/inspector/test/head.js + !/devtools/client/inspector/test/shared-head.js + !/devtools/client/responsive/test/browser/devices.json + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + !/devtools/client/shared/test/test-actor.js + +[browser_styleeditor_add_stylesheet.js] +[browser_styleeditor_autocomplete.js] +[browser_styleeditor_autocomplete-disabled.js] +[browser_styleeditor_bom.js] +[browser_styleeditor_bug_740541_iframes.js] +[browser_styleeditor_bug_851132_middle_click.js] +[browser_styleeditor_bug_870339.js] +[browser_styleeditor_bug_1247083_inline_stylesheet_numbering.js] +[browser_styleeditor_bug_1405342_serviceworker_iframes.js] +skip-if = !debug && (os == "win") || (os == "linux" && os_version == "18.04") #bug 1424914 +[browser_styleeditor_copyurl.js] +[browser_styleeditor_enabled.js] +[browser_styleeditor_fetch-from-netmonitor.js] +[browser_styleeditor_filesave.js] +[browser_styleeditor_fission_switch_target.js] +[browser_styleeditor_highlight-selector.js] +[browser_styleeditor_import.js] +[browser_styleeditor_import_rule.js] +[browser_styleeditor_init.js] +[browser_styleeditor_inline_friendly_names.js] +[browser_styleeditor_loading.js] +[browser_styleeditor_loading_with_containers.js] +[browser_styleeditor_media_sidebar.js] +[browser_styleeditor_media_sidebar_links.js] +[browser_styleeditor_media_sidebar_sourcemaps.js] +[browser_styleeditor_missing_stylesheet.js] +[browser_styleeditor_navigate.js] +[browser_styleeditor_new.js] +[browser_styleeditor_nostyle.js] +[browser_styleeditor_opentab.js] +[browser_styleeditor_pretty.js] +[browser_styleeditor_private_perwindowpb.js] +[browser_styleeditor_reload.js] +[browser_styleeditor_resize_performance.js] +[browser_styleeditor_scroll.js] +[browser_styleeditor_sv_keynav.js] +[browser_styleeditor_sv_resize.js] +[browser_styleeditor_selectstylesheet.js] +[browser_styleeditor_sourcemaps.js] +[browser_styleeditor_sourcemaps_inline.js] +[browser_styleeditor_sourcemap_large.js] +[browser_styleeditor_sourcemap_watching.js] +[browser_styleeditor_sync.js] +[browser_styleeditor_syncAddProperty.js] +[browser_styleeditor_syncAddRule.js] +[browser_styleeditor_syncAlreadyOpen.js] +[browser_styleeditor_syncEditSelector.js] +[browser_styleeditor_syncIntoRuleView.js] +[browser_styleeditor_transition_rule.js] +[browser_styleeditor_xul.js] diff --git a/devtools/client/styleeditor/test/browser_styleeditor_add_stylesheet.js b/devtools/client/styleeditor/test/browser_styleeditor_add_stylesheet.js new file mode 100644 index 0000000000..874dfcaf80 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_add_stylesheet.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that a newly-added style sheet shows up in the style editor. + +const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 2, "Two sheets present after load."); + + // We have to wait for the length to change, because we might still + // be seeing events from the initial open. + const added = new Promise(resolve => { + const handler = () => { + if (ui.editors.length === 3) { + ui.off("editor-added", handler); + resolve(); + } + }; + ui.on("editor-added", handler); + }); + + info("Adding a style sheet"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const document = content.document; + const style = document.createElement("style"); + style.appendChild(document.createTextNode("div { background: #f06; }")); + document.head.appendChild(style); + }); + await added; + + is(ui.editors.length, 3, "Three sheets present after new style sheet"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_autocomplete-disabled.js b/devtools/client/styleeditor/test/browser_styleeditor_autocomplete-disabled.js new file mode 100644 index 0000000000..c491f4de85 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_autocomplete-disabled.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that autocomplete can be disabled. + +const TESTCASE_URI = TEST_BASE_HTTP + "autocomplete.html"; + +// Pref which decides if CSS autocompletion is enabled in Style Editor or not. +const AUTOCOMPLETION_PREF = "devtools.styleeditor.autocompletion-enabled"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + const editor = await ui.editors[0].getSourceEditor(); + editor.sourceEditor.setOption("autocomplete", false); + + is( + editor.sourceEditor.getOption("autocomplete"), + false, + "Autocompletion option does not exist" + ); + ok( + !editor.sourceEditor.getAutocompletionPopup(), + "Autocompletion popup does not exist" + ); +}); + +add_task(async function() { + Services.prefs.setBoolPref(AUTOCOMPLETION_PREF, false); + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + const editor = await ui.editors[0].getSourceEditor(); + + is( + editor.sourceEditor.getOption("autocomplete"), + false, + "Autocompletion option does not exist" + ); +}); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref(AUTOCOMPLETION_PREF); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_autocomplete.js b/devtools/client/styleeditor/test/browser_styleeditor_autocomplete.js new file mode 100644 index 0000000000..3daf6c3351 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_autocomplete.js @@ -0,0 +1,273 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that autocompletion works as expected. + +const TESTCASE_URI = TEST_BASE_HTTP + "autocomplete.html"; +const MAX_SUGGESTIONS = 15; + +// Test cases to test that autocompletion works correctly when enabled. +// Format: +// [ +// key, +// { +// total: Number of suggestions in the popup (-1 if popup is closed), +// current: Index of selected suggestion, +// inserted: 1 to check whether the selected suggestion is inserted into the +// editor or not, +// entered: 1 if the suggestion is inserted and finalized +// } +// ] + +function getTestCases(cssProperties) { + const keywords = getCSSKeywords(cssProperties); + const getSuggestionNumberFor = suggestionNumberGetter(keywords); + + return [ + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["Ctrl+Space", { total: 1, current: 0 }], + ["VK_LEFT"], + ["VK_RIGHT"], + ["VK_DOWN"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["Ctrl+Space", { total: getSuggestionNumberFor("font"), current: 0 }], + ["VK_END"], + ["VK_RETURN"], + ["b", { total: getSuggestionNumberFor("b"), current: 0 }], + ["a", { total: getSuggestionNumberFor("ba"), current: 0 }], + [ + "VK_DOWN", + { total: getSuggestionNumberFor("ba"), current: 0, inserted: 1 }, + ], + [ + "VK_TAB", + { total: getSuggestionNumberFor("ba"), current: 1, inserted: 1 }, + ], + ["VK_RETURN", { current: 1, inserted: 1, entered: 1 }], + ["b", { total: getSuggestionNumberFor("background", "b"), current: 0 }], + ["l", { total: getSuggestionNumberFor("background", "bl"), current: 0 }], + [ + "VK_TAB", + { + total: getSuggestionNumberFor("background", "bl"), + current: 0, + inserted: 1, + }, + ], + [ + "VK_DOWN", + { + total: getSuggestionNumberFor("background", "bl"), + current: 1, + inserted: 1, + }, + ], + [ + "VK_UP", + { + total: getSuggestionNumberFor("background", "bl"), + current: 0, + inserted: 1, + }, + ], + [ + "VK_TAB", + { + total: getSuggestionNumberFor("background", "bl"), + current: 1, + inserted: 1, + }, + ], + [ + "VK_TAB", + { + total: getSuggestionNumberFor("background", "bl"), + current: 2, + inserted: 1, + }, + ], + [";"], + ["VK_RETURN"], + ["c", { total: getSuggestionNumberFor("c"), current: 0 }], + ["o", { total: getSuggestionNumberFor("co"), current: 0 }], + ["VK_RETURN", { current: 0, inserted: 1 }], + ["r", { total: getSuggestionNumberFor("color", "r"), current: 0 }], + ["VK_RETURN", { current: 0, inserted: 1 }], + [";"], + ["VK_LEFT"], + ["VK_RIGHT"], + ["VK_DOWN"], + ["VK_RETURN"], + ["b", { total: 2, current: 0 }], + ["u", { total: 1, current: 0 }], + ["VK_RETURN", { current: 0, inserted: 1 }], + ["{"], + ["VK_HOME"], + ["VK_DOWN"], + ["VK_DOWN"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["VK_RIGHT"], + ["Ctrl+Space", { total: 1, current: 0 }], + ]; +} + +add_task(async function() { + const { panel, ui } = await openStyleEditorForURL(TESTCASE_URI); + const { cssProperties } = ui; + const testCases = getTestCases(cssProperties); + + await ui.selectStyleSheet(ui.editors[1].styleSheet); + const editor = await ui.editors[1].getSourceEditor(); + + const sourceEditor = editor.sourceEditor; + const popup = sourceEditor.getAutocompletionPopup(); + + await SimpleTest.promiseFocus(panel.panelWindow); + + for (const index in testCases) { + await testState(testCases, index, sourceEditor, popup, panel.panelWindow); + await checkState(testCases, index, sourceEditor, popup); + } +}); + +function testState(testCases, index, sourceEditor, popup, panelWindow) { + let [key, details] = testCases[index]; + let entered; + if (details) { + entered = details.entered; + } + const mods = {}; + + info( + "pressing key " + + key + + " to get result: " + + JSON.stringify(testCases[index]) + + " for index " + + index + ); + + let evt = "after-suggest"; + + if (key == "Ctrl+Space") { + key = " "; + mods.ctrlKey = true; + } else if (key == "VK_RETURN" && entered) { + evt = "popup-hidden"; + } else if ( + /(left|right|return|home|end)/gi.test(key) || + (key == "VK_DOWN" && !popup.isOpen) + ) { + evt = "cursorActivity"; + } else if (key == "VK_TAB" || key == "VK_UP" || key == "VK_DOWN") { + evt = "suggestion-entered"; + } + + const ready = sourceEditor.once(evt); + EventUtils.synthesizeKey(key, mods, panelWindow); + + return ready; +} + +function checkState(testCases, index, sourceEditor, popup) { + return new Promise(resolve => { + executeSoon(() => { + let [, details] = testCases[index]; + details = details || {}; + const { total, current, inserted } = details; + + if (total != undefined) { + ok(popup.isOpen, "Popup is open for index " + index); + is( + total, + popup.itemCount, + "Correct total suggestions for index " + index + ); + is( + current, + popup.selectedIndex, + "Correct index is selected for index " + index + ); + if (inserted) { + const { text } = popup.getItemAtIndex(current); + const { line, ch } = sourceEditor.getCursor(); + const lineText = sourceEditor.getText(line); + is( + lineText.substring(ch - text.length, ch), + text, + "Current suggestion from the popup is inserted into the editor." + ); + } + } else { + ok(!popup.isOpen, "Popup is closed for index " + index); + if (inserted) { + const { text } = popup.getItemAtIndex(current); + const { line, ch } = sourceEditor.getCursor(); + const lineText = sourceEditor.getText(line); + is( + lineText.substring(ch - text.length, ch), + text, + "Current suggestion from the popup is inserted into the editor." + ); + } + } + resolve(); + }); + }); +} + +/** + * Returns a list of all property names and a map of property name vs possible + * CSS values provided by the Gecko engine. + * + * @return {Object} An object with following properties: + * - CSSProperties {Array} Array of string containing all the possible + * CSS property names. + * - CSSValues {Object|Map} A map where key is the property name and + * value is an array of string containing all the possible + * CSS values the property can have. + */ +function getCSSKeywords(cssProperties) { + const props = {}; + const propNames = cssProperties.getNames(); + propNames.forEach(prop => { + props[prop] = cssProperties.getValues(prop).sort(); + }); + return { + CSSValues: props, + CSSProperties: propNames.sort(), + }; +} + +/** + * Returns a function that returns the number of expected suggestions for the given + * property and value. If the value is not null, returns the number of values starting + * with `value`. Returns the number of properties starting with `property` otherwise. + */ +function suggestionNumberGetter({ CSSProperties, CSSValues }) { + return (property, value) => { + if (value == null) { + return CSSProperties.filter(prop => prop.startsWith(property)).slice( + 0, + MAX_SUGGESTIONS + ).length; + } + return CSSValues[property] + .filter(val => val.startsWith(value)) + .slice(0, MAX_SUGGESTIONS).length; + }; +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_bom.js b/devtools/client/styleeditor/test/browser_styleeditor_bom.js new file mode 100644 index 0000000000..4ba4ad2b45 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_bom.js @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BOM_CSS = TEST_BASE_HTTPS + "utf-16.css"; +const DOCUMENT = + "data:text/html;charset=UTF-8," + + encodeURIComponent( + [ + "", + "", + " ", + " Bug 1301854", + ' ', + " ", + " ", + " ", + "", + ].join("\n") + ); + +const CONTENTS = + "// Note that this file must be utf-16 with a " + + "BOM for the test to make sense.\n"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(DOCUMENT); + + is(ui.editors.length, 1, "correct number of editors"); + + const editor = ui.editors[0]; + await editor.getSourceEditor(); + + const text = editor.sourceEditor.getText(); + is(text, CONTENTS, "editor contains expected text"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_bug_1247083_inline_stylesheet_numbering.js b/devtools/client/styleeditor/test/browser_styleeditor_bug_1247083_inline_stylesheet_numbering.js new file mode 100644 index 0000000000..191535304b --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_bug_1247083_inline_stylesheet_numbering.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that the displayed numbering of inline and user-created stylesheets are independent of their absolute index +// See bug 1247083. + +const SIMPLE = TEST_BASE_HTTP + "simple.css"; +const LONG = TEST_BASE_HTTP + "doc_long.css"; +const DOCUMENT_WITH_LONG_SHEET = + "data:text/html;charset=UTF-8," + + encodeURIComponent( + [ + "", + "", + " ", + " Style editor numbering test page", + + // first inline stylesheet + " ", + // first external stylesheet + ' ', + // second external stylesheet + ' ', + // second inline stylesheet + " ", + + " ", + " ", + " ", + "", + ].join("\n") + ); + +add_task(async function() { + info("Test that inline stylesheets are numbered correctly"); + const { ui } = await openStyleEditorForURL(DOCUMENT_WITH_LONG_SHEET); + + is(ui.editors.length, 4, "4 editors present."); + + const firstEditor = ui.editors[0]; + is( + firstEditor.styleSheetFriendlyIndex, + 0, + "1st inline stylesheet's index is 0" + ); + + is( + firstEditor.styleSheet.styleSheetIndex, + 0, + "1st inline stylesheet is also the first stylesheet declared" + ); + + is(firstEditor.styleSheet.ruleCount, 1, "1st inline stylesheet has 1 rule"); + + const secondEditor = ui.editors[3]; + is( + secondEditor.styleSheetFriendlyIndex, + 1, + "2nd inline stylesheet's index is 1" + ); + + is( + secondEditor.styleSheet.styleSheetIndex, + 3, + "2nd inline stylesheet is the last stylesheet" + ); + + is(secondEditor.styleSheet.ruleCount, 2, "2nd inline stylesheet has 2 rules"); +}); + +add_task(async function() { + info("Test that user-created stylesheets are numbered correctly"); + const { panel, ui } = await openStyleEditorForURL(DOCUMENT_WITH_LONG_SHEET); + await createNewStyleSheet(ui, panel.panelWindow); + await createNewStyleSheet(ui, panel.panelWindow); + + is(ui.editors.length, 6, "6 editors present."); + + ok(ui.editors[4].isNew, "2nd to last editor is user-created"); + is( + ui.editors[4].styleSheetFriendlyIndex, + 0, + "2nd to last user created stylesheet's index is 0" + ); + + ok(ui.editors[5].isNew, "Last editor is user-created"); + is( + ui.editors[5].styleSheetFriendlyIndex, + 1, + "Last user created stylesheet's index is 1" + ); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_bug_1405342_serviceworker_iframes.js b/devtools/client/styleeditor/test/browser_styleeditor_bug_1405342_serviceworker_iframes.js new file mode 100644 index 0000000000..1d9a9833fc --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_bug_1405342_serviceworker_iframes.js @@ -0,0 +1,27 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that sheets inside cross origin iframes, served from a service worker +// are correctly fetched via the service worker in the stylesheet editor. + +add_task(async function() { + const TEST_URL = + "https://test1.example.com/browser/devtools/client/styleeditor/test/bug_1405342_serviceworker_iframes.html"; + const { ui } = await openStyleEditorForURL(TEST_URL); + + if (ui.editors.length != 1) { + info("Stylesheet isn't available immediately, waiting for it"); + await ui.once("editor-added"); + } + is(ui.editors.length, 1, "Got the iframe stylesheet"); + + await ui.selectStyleSheet(ui.editors[0].styleSheet); + const editor = await ui.editors[0].getSourceEditor(); + const text = editor.sourceEditor.getText(); + is( + text, + "* { color: green; }", + "stylesheet content is the one served by the service worker" + ); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_bug_740541_iframes.js b/devtools/client/styleeditor/test/browser_styleeditor_bug_740541_iframes.js new file mode 100644 index 0000000000..1330e48cb9 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_bug_740541_iframes.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that sheets inside iframes are shown in the editor. + +add_task(async function() { + function makeStylesheet(selector) { + return ( + "data:text/css;charset=UTF-8," + encodeURIComponent(selector + " { }") + ); + } + + function makeDocument(stylesheets, framedDocuments) { + stylesheets = stylesheets || []; + framedDocuments = framedDocuments || []; + return ( + "data:text/html;charset=UTF-8," + + encodeURIComponent( + Array.prototype.concat + .call( + [ + "", + "", + "", + "Bug 740541", + ], + stylesheets.map(function(sheet) { + return ( + '' + ); + }), + ["", ""], + framedDocuments.map(function(doc) { + return ''; + }), + ["", ""] + ) + .join("\n") + ) + ); + } + + const DOCUMENT_WITH_INLINE_STYLE = + "data:text/html;charset=UTF-8," + + encodeURIComponent( + [ + "", + "", + " ", + " Bug 740541", + ' ", + " ", + " ", + " ", + " ", + ].join("\n") + ); + + const FOUR = TEST_BASE_HTTP + "four.html"; + + const SIMPLE = TEST_BASE_HTTP + "simple.css"; + + const SIMPLE_DOCUMENT = TEST_BASE_HTTP + "simple.html"; + + const TESTCASE_URI = makeDocument( + [makeStylesheet(".a")], + [ + makeDocument([], [FOUR, DOCUMENT_WITH_INLINE_STYLE]), + makeDocument( + [makeStylesheet(".b"), SIMPLE], + [makeDocument([makeStylesheet(".c")], [])] + ), + makeDocument([SIMPLE], []), + SIMPLE_DOCUMENT, + ] + ); + + const EXPECTED_STYLE_SHEET_COUNT = 12; + + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is( + ui.editors.length, + EXPECTED_STYLE_SHEET_COUNT, + "Got the expected number of style sheets." + ); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_bug_851132_middle_click.js b/devtools/client/styleeditor/test/browser_styleeditor_bug_851132_middle_click.js new file mode 100644 index 0000000000..8b40da3e2b --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_bug_851132_middle_click.js @@ -0,0 +1,58 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that middle click on style sheet doesn't open index.xhtml in a new +// tab (bug 851132). + +const TESTCASE_URI = TEST_BASE_HTTP + "four.html"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + gBrowser.tabContainer.addEventListener("TabOpen", onTabAdded); + + await ui.editors[0].getSourceEditor(); + info("first editor selected"); + + info("Left-clicking on the second editor link."); + await clickOnStyleSheetLink(ui.editors[1], 0); + + info("Waiting for the second editor to be selected."); + const editor = await ui.once("editor-selected"); + + ok( + editor.sourceEditor.hasFocus(), + "Left mouse click gave second editor focus." + ); + + // middle mouse click should not open a new tab + info("Middle clicking on the third editor link."); + await clickOnStyleSheetLink(ui.editors[2], 1); +}); + +/** + * A helper that clicks on style sheet link in the sidebar. + * + * @param {StyleSheetEditor} editor + * The editor of which link should be clicked. + * @param {MouseEvent.button} button + * The button to click the link with. + */ +async function clickOnStyleSheetLink(editor, button) { + const window = editor._window; + const link = editor.summary.querySelector(".stylesheet-name"); + + info("Waiting for focus."); + await SimpleTest.promiseFocus(window); + + info("Pressing button " + button + " on style sheet name link."); + EventUtils.synthesizeMouseAtCenter(link, { button }, window); +} + +function onTabAdded() { + ok(false, "middle mouse click has opened a new tab"); +} + +registerCleanupFunction(function() { + gBrowser.tabContainer.removeEventListener("TabOpen", onTabAdded); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_bug_870339.js b/devtools/client/styleeditor/test/browser_styleeditor_bug_870339.js new file mode 100644 index 0000000000..2834ae21c5 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_bug_870339.js @@ -0,0 +1,48 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const SIMPLE = TEST_BASE_HTTP + "simple.css"; +const DOCUMENT_WITH_ONE_STYLESHEET = + "data:text/html;charset=UTF-8," + + encodeURIComponent( + [ + "", + "", + " ", + " Bug 870339", + ' ', + " ", + " ", + " ", + "", + ].join("\n") + ); + +add_task(async function() { + const { ui } = await openStyleEditorForURL(DOCUMENT_WITH_ONE_STYLESHEET); + + // Spam the _onOrigSourcesPrefChanged callback multiple times before the + // StyleEditorActor has a chance to respond to the first one. + const SPAM_COUNT = 2; + for (let i = 0; i < SPAM_COUNT; ++i) { + ui._onOrigSourcesPrefChanged(); + } + + // Wait for the StyleEditorActor to respond to each "onOrigSourcesPrefChanged" + // message. + await new Promise(resolve => { + let loadCount = 0; + ui.on("stylesheets-refreshed", function onReset() { + ++loadCount; + if (loadCount == SPAM_COUNT) { + ui.off("stylesheets-refreshed", onReset); + // No matter how large SPAM_COUNT is, the number of style + // sheets should never be more than the number of style sheets + // in the document. + is(ui.editors.length, 1, "correct style sheet count"); + resolve(); + } + }); + }); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_copyurl.js b/devtools/client/styleeditor/test/browser_styleeditor_copyurl.js new file mode 100644 index 0000000000..bb3117a178 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_copyurl.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test to check the 'Copy URL' functionality in the context menu item for stylesheets. + +const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + const onContextMenuShown = new Promise(resolve => { + ui._contextMenu.addEventListener( + "popupshown", + () => { + resolve(); + }, + { once: true } + ); + }); + + info("Right-click the first stylesheet editor."); + const editor = ui.editors[0]; + const stylesheetEl = editor.summary.querySelector(".stylesheet-name"); + await EventUtils.synthesizeMouseAtCenter( + stylesheetEl, + { button: 2, type: "contextmenu" }, + ui._window + ); + await onContextMenuShown; + + is( + ui._copyUrlItem.getAttribute("hidden"), + "false", + "Copy URL menu item is showing." + ); + + info( + "Click on Copy URL menu item and wait for the URL to be copied to the clipboard." + ); + await waitForClipboardPromise( + () => ui._copyUrlItem.click(), + ui._contextMenuStyleSheet.href + ); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_enabled.js b/devtools/client/styleeditor/test/browser_styleeditor_enabled.js new file mode 100644 index 0000000000..fe592e3e7e --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_enabled.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that style sheets can be disabled and enabled. + +// https rather than chrome to improve coverage +const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html"; + +add_task(async function() { + const { panel, ui } = await openStyleEditorForURL(TESTCASE_URI); + const editor = await ui.editors[0].getSourceEditor(); + + const summary = editor.summary; + const enabledToggle = summary.querySelector(".stylesheet-enabled"); + ok(enabledToggle, "enabled toggle button exists"); + + is( + editor.styleSheet.disabled, + false, + "first stylesheet is initially enabled" + ); + + is( + summary.classList.contains("disabled"), + false, + "first stylesheet is initially enabled, UI does not have DISABLED class" + ); + + info("Disabling the first stylesheet."); + await toggleEnabled(editor, enabledToggle, panel.panelWindow); + + is(editor.styleSheet.disabled, true, "first stylesheet is now disabled"); + is( + summary.classList.contains("disabled"), + true, + "first stylesheet is now disabled, UI has DISABLED class" + ); + + info("Enabling the first stylesheet again."); + await toggleEnabled(editor, enabledToggle, panel.panelWindow); + + is( + editor.styleSheet.disabled, + false, + "first stylesheet is now enabled again" + ); + is( + summary.classList.contains("disabled"), + false, + "first stylesheet is now enabled again, UI does not have DISABLED class" + ); +}); + +async function toggleEnabled(editor, enabledToggle, panelWindow) { + const changed = editor.once("property-change"); + + info("Waiting for focus."); + await SimpleTest.promiseFocus(panelWindow); + + info("Clicking on the toggle."); + EventUtils.synthesizeMouseAtCenter(enabledToggle, {}, panelWindow); + + info("Waiting for stylesheet to be disabled."); + let property = await changed; + while (property !== "disabled") { + info("Ignoring property-change for '" + property + "'."); + property = await editor.once("property-change"); + } +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_fetch-from-netmonitor.js b/devtools/client/styleeditor/test/browser_styleeditor_fetch-from-netmonitor.js new file mode 100644 index 0000000000..3f56e69057 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_fetch-from-netmonitor.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// A test to ensure Style Editor only issues 1 request for each stylesheet (instead of 2) +// by using the cache on the platform. + +const EMPTY_TEST_URL = TEST_BASE_HTTP + "doc_empty.html"; +const TEST_URL = TEST_BASE_HTTP + "doc_fetch_from_netmonitor.html"; + +add_task(async function() { + info("Opening netmonitor"); + // Navigate first to an empty document in order to: + // * avoid introducing a cross process navigation when calling navigateTo() + // * properly wait for request updates when calling navigateTo, while showToolbox + // won't necessarily wait for all pending requests. (If we were loading TEST_URL + // in the tab, we might have pending updates in the netmonitor which won't be + // awaited for by showToolbox) + const tab = await addTab(EMPTY_TEST_URL); + const target = await TargetFactory.forTab(tab); + const toolbox = await gDevTools.showToolbox(target, "netmonitor"); + const monitor = toolbox.getPanel("netmonitor"); + const { store, windowRequire } = monitor.panelWin; + const Actions = windowRequire("devtools/client/netmonitor/src/actions/index"); + const { getSortedRequests } = windowRequire( + "devtools/client/netmonitor/src/selectors/index" + ); + + store.dispatch(Actions.batchEnable(false)); + + info("Navigating to test page"); + await navigateTo(TEST_URL); + + info("Opening Style Editor"); + const styleeditor = await toolbox.selectTool("styleeditor"); + const ui = styleeditor.UI; + + info("Waiting for the sources to be loaded."); + await ui.editors[0].getSourceEditor(); + await ui.selectStyleSheet(ui.editors[1].styleSheet); + await ui.editors[1].getSourceEditor(); + + // Wait till there is 4 requests in Netmonitor store. + await waitUntil(() => getSortedRequests(store.getState()).length == 4); + + info("Checking Netmonitor contents."); + const shortRequests = []; + const longRequests = []; + const hugeRequests = []; + for (const item of getSortedRequests(store.getState())) { + if (item.url.endsWith("doc_short_string.css")) { + shortRequests.push(item); + } + if (item.url.endsWith("doc_long_string.css")) { + longRequests.push(item); + } + if (item.url.endsWith("sjs_huge-css-server.sjs")) { + hugeRequests.push(item); + } + } + + is( + shortRequests.length, + 1, + "Got one request for doc_short_string.css after Style Editor was loaded." + ); + is( + longRequests.length, + 1, + "Got one request for doc_long_string.css after Style Editor was loaded." + ); + + is( + hugeRequests.length, + 1, + "Got one requests for sjs_huge-css-server.sjs after Style Editor was loaded." + ); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_filesave.js b/devtools/client/styleeditor/test/browser_styleeditor_filesave.js new file mode 100644 index 0000000000..5fe62d444c --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_filesave.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that 'Save' function works. + +const TESTCASE_URI_HTML = TEST_BASE_HTTP + "simple.html"; +const TESTCASE_URI_CSS = TEST_BASE_HTTP + "simple.css"; + +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); + +add_task(async function() { + const htmlFile = await copy(TESTCASE_URI_HTML, "simple.html"); + await copy(TESTCASE_URI_CSS, "simple.css"); + const uri = Services.io.newFileURI(htmlFile); + const filePath = uri.resolve(""); + + const { ui } = await openStyleEditorForURL(filePath); + + const editor = ui.editors[0]; + await editor.getSourceEditor(); + + info("Editing the style sheet."); + let dirty = editor.sourceEditor.once("dirty-change"); + const beginCursor = { line: 0, ch: 0 }; + editor.sourceEditor.replaceText("DIRTY TEXT", beginCursor, beginCursor); + + await dirty; + + is(editor.sourceEditor.isClean(), false, "Editor is dirty."); + ok( + editor.summary.classList.contains("unsaved"), + "Star icon is present in the corresponding summary." + ); + + info("Saving the changes."); + dirty = editor.sourceEditor.once("dirty-change"); + + editor.saveToFile(null, function(file) { + ok(file, "file should get saved directly when using a file:// URI"); + }); + + await dirty; + + is(editor.sourceEditor.isClean(), true, "Editor is clean."); + ok( + !editor.summary.classList.contains("unsaved"), + "Star icon is not present in the corresponding summary." + ); +}); + +function copy(srcChromeURL, destFileName) { + return new Promise(resolve => { + const destFile = FileUtils.getFile("ProfD", [destFileName]); + write(read(srcChromeURL), destFile, resolve); + }); +} + +function read(srcChromeURL) { + const scriptableStream = Cc[ + "@mozilla.org/scriptableinputstream;1" + ].getService(Ci.nsIScriptableInputStream); + + const channel = NetUtil.newChannel({ + uri: srcChromeURL, + loadUsingSystemPrincipal: true, + }); + const input = channel.open(); + scriptableStream.init(input); + + let data = ""; + while (input.available()) { + data = data.concat(scriptableStream.read(input.available())); + } + scriptableStream.close(); + input.close(); + + return data; +} + +function write(data, file, callback) { + const converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + + converter.charset = "UTF-8"; + + const istream = converter.convertToInputStream(data); + const ostream = FileUtils.openSafeFileOutputStream(file); + + NetUtil.asyncCopy(istream, ostream, function(status) { + if (!Components.isSuccessCode(status)) { + info("Couldn't write to " + file.path); + return; + } + + callback(file); + }); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_fission_switch_target.js b/devtools/client/styleeditor/test/browser_styleeditor_fission_switch_target.js new file mode 100644 index 0000000000..8c55306d1b --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_fission_switch_target.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test switching for the top-level target. + +const PARENT_PROCESS_URI = "about:robots"; +const CONTENT_PROCESS_URI = TEST_BASE_HTTPS + "simple.html"; + +add_task(async function() { + // We use about:robots, because this page will run in the parent process. + // Navigating from about:robots to a regular content page will always trigger a target + // switch, with or without fission. + + info("Open a page that runs in the parent process"); + const { ui } = await openStyleEditorForURL(PARENT_PROCESS_URI); + await waitUntil(() => ui.editors.length === 3); + ok(true, `Three style sheets for ${PARENT_PROCESS_URI}`); + + info("Navigate to a page that runs in the child process"); + const onEditorReady = ui.editors[0].getSourceEditor(); + await navigateToAndWaitForStyleSheets(CONTENT_PROCESS_URI, ui, 2); + // We also have to wait for the toolbox to complete the target switching + // in order to avoid pending requests during test teardown. + ok(true, `Two sheets present for ${CONTENT_PROCESS_URI}`); + + info("Wait until the editor is ready"); + await onEditorReady; +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_highlight-selector.js b/devtools/client/styleeditor/test/browser_styleeditor_highlight-selector.js new file mode 100644 index 0000000000..1f6926bb56 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_highlight-selector.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that hovering over a simple selector in the style-editor requests the +// highlighting of the corresponding nodes + +add_task(async function() { + const url = TEST_BASE_HTTP + "selector-highlighter.html"; + const { ui } = await openStyleEditorForURL(url); + const editor = ui.editors[0]; + + // Mock the highlighter so we can locally assert that things happened + // correctly instead of accessing the highlighter elements + editor.highlighter = { + isShown: false, + options: null, + + show: function(node, options) { + this.isShown = true; + this.options = options; + return promise.resolve(); + }, + + hide: function() { + this.isShown = false; + }, + }; + + info("Expecting a node-highlighted event"); + const onHighlighted = editor.once("node-highlighted"); + + info("Simulate a mousemove event on the div selector"); + editor._onMouseMove({ clientX: 56, clientY: 10 }); + await onHighlighted; + + ok(editor.highlighter.isShown, "The highlighter is now shown"); + is(editor.highlighter.options.selector, "div", "The selector is correct"); + + info("Simulate a mousemove event elsewhere in the editor"); + editor._onMouseMove({ clientX: 16, clientY: 0 }); + + ok(!editor.highlighter.isShown, "The highlighter is now hidden"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_import.js b/devtools/client/styleeditor/test/browser_styleeditor_import.js new file mode 100644 index 0000000000..ab78bcfb48 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_import.js @@ -0,0 +1,61 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that the import button in the UI works. + +// http rather than chrome to improve coverage +const TESTCASE_URI = TEST_BASE_HTTP + "simple.html"; + +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); + +const FILENAME = "styleeditor-import-test.css"; +const SOURCE = "body{background:red;}"; + +add_task(async function() { + const { panel, ui } = await openStyleEditorForURL(TESTCASE_URI); + + const added = ui.once("test:editor-updated"); + importSheet(ui, panel.panelWindow); + + info("Waiting for editor to be added for the imported sheet."); + const editor = await added; + + is( + editor.savedFile.leafName, + FILENAME, + "imported stylesheet will be saved directly into the same file" + ); + is( + editor.friendlyName, + FILENAME, + "imported stylesheet has the same name as the filename" + ); +}); + +function importSheet(ui, panelWindow) { + // create file to import first + const file = FileUtils.getFile("ProfD", [FILENAME]); + const ostream = FileUtils.openSafeFileOutputStream(file); + const converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + const istream = converter.convertToInputStream(SOURCE); + NetUtil.asyncCopy(istream, ostream, function() { + FileUtils.closeSafeFileOutputStream(ostream); + + // click the import button now that the file to import is ready + ui._mockImportFile = file; + + waitForFocus(function() { + const document = panelWindow.document; + const importButton = document.querySelector(".style-editor-importButton"); + ok(importButton, "import button exists"); + + EventUtils.synthesizeMouseAtCenter(importButton, {}, panelWindow); + }, panelWindow); + }); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_import_rule.js b/devtools/client/styleeditor/test/browser_styleeditor_import_rule.js new file mode 100644 index 0000000000..240e4645d3 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_import_rule.js @@ -0,0 +1,32 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that style editor shows sheets loaded with @import rules. + +// http rather than chrome to improve coverage +const TESTCASE_URI = TEST_BASE_HTTP + "import.html"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 3, "there are 3 stylesheets after loading @imports"); + + is( + ui.editors[0].styleSheet.href, + TEST_BASE_HTTP + "simple.css", + "stylesheet 1 is simple.css" + ); + + is( + ui.editors[1].styleSheet.href, + TEST_BASE_HTTP + "import.css", + "stylesheet 2 is import.css" + ); + + is( + ui.editors[2].styleSheet.href, + TEST_BASE_HTTP + "import2.css", + "stylesheet 3 is import2.css" + ); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_init.js b/devtools/client/styleeditor/test/browser_styleeditor_init.js new file mode 100644 index 0000000000..8be6b09076 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_init.js @@ -0,0 +1,52 @@ +"use strict"; +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks that style editor contains correct stylesheets after initialization. + +const TESTCASE_URI = TEST_BASE_HTTP + "simple.html"; +const EXPECTED_SHEETS = [ + { + sheetIndex: 0, + name: /^simple.css$/, + rules: 1, + active: true, + }, + { + sheetIndex: 1, + name: /^<.*>$/, + rules: 3, + active: false, + }, +]; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 2, "The UI contains two style sheets."); + checkSheet(ui.editors[0], EXPECTED_SHEETS[0]); + checkSheet(ui.editors[1], EXPECTED_SHEETS[1]); +}); + +function checkSheet(editor, expected) { + is( + editor.styleSheet.styleSheetIndex, + expected.sheetIndex, + "Style sheet has correct index." + ); + + const summary = editor.summary; + const name = summary + .querySelector(".stylesheet-name > label") + .getAttribute("value"); + ok(expected.name.test(name), "The name '" + name + "' is correct."); + + const ruleCount = summary.querySelector(".stylesheet-rule-count").textContent; + is(parseInt(ruleCount, 10), expected.rules, "the rule count is correct"); + + is( + summary.classList.contains("splitview-active"), + expected.active, + "The active status for this sheet is correct." + ); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_inline_friendly_names.js b/devtools/client/styleeditor/test/browser_styleeditor_inline_friendly_names.js new file mode 100644 index 0000000000..ee9218bf83 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_inline_friendly_names.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that inline style sheets get correct names if they are saved to disk and +// that those names survice a reload but not navigation to another page. + +const FIRST_TEST_PAGE = TEST_BASE_HTTP + "inline-1.html"; +const SECOND_TEST_PAGE = TEST_BASE_HTTP + "inline-2.html"; +const SAVE_PATH = "test.css"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(FIRST_TEST_PAGE); + + testIndentifierGeneration(ui); + + await saveFirstInlineStyleSheet(ui); + await testFriendlyNamesAfterSave(ui); + await reloadPageAndWaitForStyleSheets(ui, 2); + await testFriendlyNamesAfterSave(ui); + await navigateToAndWaitForStyleSheets(SECOND_TEST_PAGE, ui, 2); + await testFriendlyNamesAfterNavigation(ui); +}); + +function testIndentifierGeneration(ui) { + const fakeStyleSheetFile = { + href: "http://example.com/test.css", + nodeHref: "http://example.com/", + styleSheetIndex: 1, + }; + + const fakeInlineStyleSheet = { + href: null, + nodeHref: "http://example.com/", + styleSheetIndex: 2, + }; + + is( + ui.getStyleSheetIdentifier(fakeStyleSheetFile), + "http://example.com/test.css", + "URI is the identifier of style sheet file." + ); + + is( + ui.getStyleSheetIdentifier(fakeInlineStyleSheet), + "inline-2-at-http://example.com/", + "Inline sheets are identified by their page and position in the page." + ); +} + +function saveFirstInlineStyleSheet(ui) { + return new Promise(resolve => { + const editor = ui.editors[0]; + const destFile = FileUtils.getFile("ProfD", [SAVE_PATH]); + + editor.saveToFile(destFile, function(file) { + ok(file, "File was correctly saved."); + resolve(); + }); + }); +} + +function testFriendlyNamesAfterSave(ui) { + const firstEditor = ui.editors[0]; + const secondEditor = ui.editors[1]; + + // The friendly name of first sheet should've been remembered, the second + // should not be the same (bug 969900). + is( + firstEditor.friendlyName, + SAVE_PATH, + "Friendly name is correct for the saved inline style sheet." + ); + isnot( + secondEditor.friendlyName, + SAVE_PATH, + "Friendly name for the second inline sheet isn't the same as the first." + ); + + return promise.resolve(null); +} + +function testFriendlyNamesAfterNavigation(ui) { + const firstEditor = ui.editors[0]; + const secondEditor = ui.editors[1]; + + // Inline style sheets shouldn't have the name of previously saved file as the + // page is different. + isnot( + firstEditor.friendlyName, + SAVE_PATH, + "The first editor doesn't have the save path as a friendly name." + ); + isnot( + secondEditor.friendlyName, + SAVE_PATH, + "The second editor doesn't have the save path as a friendly name." + ); + + return promise.resolve(null); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_loading.js b/devtools/client/styleeditor/test/browser_styleeditor_loading.js new file mode 100644 index 0000000000..4ad0cf59e2 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_loading.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that style editor loads correctly. + +const TESTCASE_URI = TEST_BASE_HTTP + "longload.html"; + +add_task(async function() { + // launch Style Editor right when the tab is created (before load) + // this checks that the Style Editor still launches correctly when it is + // opened *while* the page is still loading. The Style Editor should not + // signal that it is loaded until the accompanying content page is loaded. + const tabAdded = addTab(TESTCASE_URI); + const target = await TargetFactory.forTab(gBrowser.selectedTab); + const styleEditorLoaded = gDevTools.showToolbox(target, "styleeditor"); + + await Promise.all([tabAdded, styleEditorLoaded]); + + const toolbox = gDevTools.getToolbox(target); + const panel = toolbox.getPanel("styleeditor"); + const { panelWindow, UI: ui } = panel; + + ok( + !ui._root.classList.contains("loading"), + "style editor root element does not have 'loading' class name anymore" + ); + + let button = panelWindow.document.querySelector(".style-editor-newButton"); + ok(!button.hasAttribute("disabled"), "new style sheet button is enabled"); + + button = panelWindow.document.querySelector(".style-editor-importButton"); + ok(!button.hasAttribute("disabled"), "import button is enabled"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_loading_with_containers.js b/devtools/client/styleeditor/test/browser_styleeditor_loading_with_containers.js new file mode 100644 index 0000000000..93d4772a53 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_loading_with_containers.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the stylesheets can be loaded correctly with containers +// (bug 1282660). + +const TESTCASE_URI = TEST_BASE_HTTP + "simple.html"; +const EXPECTED_SHEETS = [ + { + sheetIndex: 0, + name: /^simple.css$/, + rules: 1, + active: true, + }, + { + sheetIndex: 1, + name: /^<.*>$/, + rules: 3, + active: false, + }, +]; + +add_task(async function() { + // Using the personal container. + const userContextId = 1; + const { tab } = await openTabInUserContext(TESTCASE_URI, userContextId); + const { ui } = await openStyleEditor(tab); + + is(ui.editors.length, 2, "The UI contains two style sheets."); + checkSheet(ui.editors[0], EXPECTED_SHEETS[0]); + checkSheet(ui.editors[1], EXPECTED_SHEETS[1]); +}); + +async function openTabInUserContext(uri, userContextId) { + // Open the tab in the correct userContextId. + const tab = BrowserTestUtils.addTab(gBrowser, uri, { userContextId }); + + // Select tab and make sure its browser is focused. + gBrowser.selectedTab = tab; + tab.ownerDocument.defaultView.focus(); + + const browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + return { tab, browser }; +} + +function checkSheet(editor, expected) { + is( + editor.styleSheet.styleSheetIndex, + expected.sheetIndex, + "Style sheet has correct index." + ); + + const summary = editor.summary; + const name = summary + .querySelector(".stylesheet-name > label") + .getAttribute("value"); + ok(expected.name.test(name), "The name '" + name + "' is correct."); + + const ruleCount = summary.querySelector(".stylesheet-rule-count").textContent; + is(parseInt(ruleCount, 10), expected.rules, "the rule count is correct"); + + is( + summary.classList.contains("splitview-active"), + expected.active, + "The active status for this sheet is correct." + ); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar.js b/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar.js new file mode 100644 index 0000000000..25186c7f36 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar.js @@ -0,0 +1,154 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// https rather than chrome to improve coverage +const TESTCASE_URI = TEST_BASE_HTTPS + "media-rules.html"; +const MEDIA_PREF = "devtools.styleeditor.showMediaSidebar"; + +const RESIZE_W = 300; +const RESIZE_H = 450; +const LABELS = [ + "not all", + "all", + "(max-width: 550px)", + "(min-height: 300px) and (max-height: 320px)", + "(max-width: 750px)", +]; +const LINE_NOS = [1, 7, 19, 25, 31]; +const NEW_RULE = "\n@media (max-width: 750px) { div { color: blue; } }"; + +waitForExplicitFinish(); + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 2, "correct number of editors"); + + // Test first plain css editor + const plainEditor = ui.editors[0]; + await openEditor(plainEditor); + testPlainEditor(plainEditor); + + // Test editor with @media rules + const mediaEditor = ui.editors[1]; + await openEditor(mediaEditor); + testMediaEditor(mediaEditor); + + // Test that sidebar hides when flipping pref + await testShowHide(ui, mediaEditor); + + // Test adding a rule updates the list + await testMediaRuleAdded(ui, mediaEditor); + + // Test resizing and seeing @media matching state change + const originalWidth = window.outerWidth; + const originalHeight = window.outerHeight; + + const onMatchesChange = listenForMediaChange(ui); + window.resizeTo(RESIZE_W, RESIZE_H); + await onMatchesChange; + + testMediaMatchChanged(mediaEditor); + + window.resizeTo(originalWidth, originalHeight); +}); + +function testPlainEditor(editor) { + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + is(sidebar.hidden, true, "sidebar is hidden on editor without @media"); +} + +function testMediaEditor(editor) { + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + is(sidebar.hidden, false, "sidebar is showing on editor with @media"); + + const entries = [...sidebar.querySelectorAll(".media-rule-label")]; + is(entries.length, 4, "four @media rules displayed in sidebar"); + + testRule(entries[0], LABELS[0], false, LINE_NOS[0]); + testRule(entries[1], LABELS[1], true, LINE_NOS[1]); + testRule(entries[2], LABELS[2], false, LINE_NOS[2]); + testRule(entries[3], LABELS[3], false, LINE_NOS[3]); +} + +function testMediaMatchChanged(editor) { + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + + const cond = sidebar.querySelectorAll(".media-rule-condition")[2]; + is( + cond.textContent, + "(max-width: 550px)", + "third rule condition text is correct" + ); + ok( + !cond.classList.contains("media-condition-unmatched"), + "media rule is now matched after resizing" + ); +} + +async function testShowHide(UI, editor) { + let sidebarChange = listenForMediaChange(UI); + Services.prefs.setBoolPref(MEDIA_PREF, false); + await sidebarChange; + + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + is(sidebar.hidden, true, "sidebar is hidden after flipping pref"); + + sidebarChange = listenForMediaChange(UI); + Services.prefs.clearUserPref(MEDIA_PREF); + await sidebarChange; + + is(sidebar.hidden, false, "sidebar is showing after flipping pref back"); +} + +async function testMediaRuleAdded(UI, editor) { + await editor.getSourceEditor(); + let text = editor.sourceEditor.getText(); + text += NEW_RULE; + + const listChange = listenForMediaChange(UI); + editor.sourceEditor.setText(text); + await listChange; + + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + const entries = [...sidebar.querySelectorAll(".media-rule-label")]; + is(entries.length, 5, "five @media rules after changing text"); + + testRule(entries[4], LABELS[4], false, LINE_NOS[4]); +} + +function testRule(rule, text, matches, lineno) { + const cond = rule.querySelector(".media-rule-condition"); + is(cond.textContent, text, "media label is correct for " + text); + + const matched = !cond.classList.contains("media-condition-unmatched"); + ok( + matches ? matched : !matched, + "media rule is " + (matches ? "matched" : "unmatched") + ); + + const line = rule.querySelector(".media-rule-line"); + is(line.textContent, ":" + lineno, "correct line number shown"); +} + +/* Helpers */ + +function openEditor(editor) { + getLinkFor(editor).click(); + + return editor.getSourceEditor(); +} + +function listenForMediaChange(UI) { + return new Promise(resolve => { + UI.once("media-list-changed", () => { + resolve(); + }); + }); +} + +function getLinkFor(editor) { + return editor.summary.querySelector(".stylesheet-name"); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar_links.js b/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar_links.js new file mode 100644 index 0000000000..4a907e7c6a --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar_links.js @@ -0,0 +1,177 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* Tests responsive mode links for + * @media sidebar width and height related conditions */ + +const asyncStorage = require("devtools/shared/async-storage"); +Services.prefs.setCharPref( + "devtools.devices.url", + "http://example.com/browser/devtools/client/responsive/test/browser/devices.json" +); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref("devtools.devices.url"); + asyncStorage.removeItem("devtools.devices.url_cache"); +}); + +loader.lazyRequireGetter( + this, + "ResponsiveUIManager", + "devtools/client/responsive/manager" +); + +const TESTCASE_URI = TEST_BASE_HTTPS + "media-rules.html"; +const responsiveModeToggleClass = ".media-responsive-mode-toggle"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + const editor = ui.editors[1]; + await openEditor(editor); + + const tab = gBrowser.selectedTab; + testNumberOfLinks(editor); + await testMediaLink(editor, tab, ui, 2, "width", 550); + await testMediaLink(editor, tab, ui, 3, "height", 300); + + await closeRDM(tab, ui); + doFinalChecks(editor); +}); + +function testNumberOfLinks(editor) { + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + const conditions = sidebar.querySelectorAll(".media-rule-condition"); + + info("Testing if media rules have the appropriate number of links"); + ok( + !conditions[0].querySelector(responsiveModeToggleClass), + "There should be no links in the first media rule." + ); + ok( + !conditions[1].querySelector(responsiveModeToggleClass), + "There should be no links in the second media rule." + ); + ok( + conditions[2].querySelector(responsiveModeToggleClass), + "There should be 1 responsive mode link in the media rule" + ); + is( + conditions[3].querySelectorAll(responsiveModeToggleClass).length, + 2, + "There should be 2 responsive mode links in the media rule" + ); +} + +async function testMediaLink(editor, tab, ui, itemIndex, type, value) { + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + let conditions = sidebar.querySelectorAll(".media-rule-condition"); + + const onMediaChange = once(ui, "media-list-changed"); + const onRDMOpened = once(ui, "responsive-mode-opened"); + + info("Launching responsive mode"); + conditions[itemIndex].querySelector(responsiveModeToggleClass).click(); + await onRDMOpened; + const rdmUI = ResponsiveUIManager.getResponsiveUIForTab(tab); + + await waitForResizeTo(rdmUI, type, value); + rdmUI.transitionsEnabled = false; + + info("Waiting for the @media list to update"); + await onMediaChange; + + // Ensure that the content has reflowed, which will ensure that all the + // element classes are reported correctly. + await promiseContentReflow(rdmUI); + + ok( + ResponsiveUIManager.isActiveForTab(tab), + "Responsive mode should be active." + ); + conditions = sidebar.querySelectorAll(".media-rule-condition"); + ok( + !conditions[itemIndex].classList.contains("media-condition-unmatched"), + "media rule should now be matched after responsive mode is active" + ); + + const dimension = (await getSizing(rdmUI))[type]; + is(dimension, value, `${type} should be properly set.`); +} + +async function closeRDM(tab, ui) { + info("Closing responsive mode"); + ResponsiveUIManager.toggle(window, tab); + const onMediaChange = waitForManyEvents(ui, 1000); + await once(ResponsiveUIManager, "off"); + + info("Wait for media-list-changed events to settle on StyleEditorUI"); + await onMediaChange; + ok( + !ResponsiveUIManager.isActiveForTab(tab), + "Responsive mode should no longer be active." + ); +} + +function doFinalChecks(editor) { + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + let conditions = sidebar.querySelectorAll(".media-rule-condition"); + conditions = sidebar.querySelectorAll(".media-rule-condition"); + ok( + conditions[2].classList.contains("media-condition-unmatched"), + "The width condition should now be unmatched" + ); + ok( + conditions[3].classList.contains("media-condition-unmatched"), + "The height condition should now be unmatched" + ); +} + +/* Helpers */ +function waitForResizeTo(rdmUI, type, value) { + return new Promise(resolve => { + const onResize = data => { + if (data[type] != value) { + return; + } + rdmUI.off("content-resize", onResize); + info(`Got content-resize to a ${type} of ${value}`); + resolve(); + }; + info(`Waiting for content-resize to a ${type} of ${value}`); + rdmUI.on("content-resize", onResize); + }); +} + +function promiseContentReflow(ui) { + return SpecialPowers.spawn(ui.getViewportBrowser(), [], async function() { + return new Promise(resolve => { + content.window.requestAnimationFrame(() => { + content.window.requestAnimationFrame(resolve); + }); + }); + }); +} + +async function getSizing(rdmUI) { + const browser = rdmUI.getViewportBrowser(); + const sizing = await SpecialPowers.spawn(browser, [], async function() { + return { + width: content.innerWidth, + height: content.innerHeight, + }; + }); + return sizing; +} + +function openEditor(editor) { + getLinkFor(editor).click(); + + return editor.getSourceEditor(); +} + +function getLinkFor(editor) { + return editor.summary.querySelector(".stylesheet-name"); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar_sourcemaps.js b/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar_sourcemaps.js new file mode 100644 index 0000000000..66a900784c --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_media_sidebar_sourcemaps.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// https rather than chrome to improve coverage +const TESTCASE_URI = TEST_BASE_HTTPS + "media-rules-sourcemaps.html"; +const MAP_PREF = "devtools.source-map.client-service.enabled"; + +const LABELS = [ + "screen and (max-width: 320px)", + "screen and (min-width: 1200px)", +]; +const LINE_NOS = [5, 8]; + +waitForExplicitFinish(); + +add_task(async function() { + Services.prefs.setBoolPref(MAP_PREF, true); + + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 1, "correct number of editors"); + + // Test editor with @media rules + const mediaEditor = ui.editors[0]; + await openEditor(mediaEditor); + testMediaEditor(mediaEditor); + + Services.prefs.clearUserPref(MAP_PREF); +}); + +function testMediaEditor(editor) { + const sidebar = editor.details.querySelector(".stylesheet-sidebar"); + is(sidebar.hidden, false, "sidebar is showing on editor with @media"); + + const entries = [...sidebar.querySelectorAll(".media-rule-label")]; + is(entries.length, 2, "two @media rules displayed in sidebar"); + + testRule(entries[0], LABELS[0], LINE_NOS[0]); + testRule(entries[1], LABELS[1], LINE_NOS[1]); +} + +function testRule(rule, text, lineno) { + const cond = rule.querySelector(".media-rule-condition"); + is(cond.textContent, text, "media label is correct for " + text); + + const line = rule.querySelector(".media-rule-line"); + is(line.textContent, ":" + lineno, "correct line number shown"); +} + +/* Helpers */ + +function openEditor(editor) { + getLinkFor(editor).click(); + + return editor.getSourceEditor(); +} + +function getLinkFor(editor) { + return editor.summary.querySelector(".stylesheet-name"); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_missing_stylesheet.js b/devtools/client/styleeditor/test/browser_styleeditor_missing_stylesheet.js new file mode 100644 index 0000000000..32f8a00aef --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_missing_stylesheet.js @@ -0,0 +1,37 @@ +"use strict"; +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Checks that the style editor manages to finalize its stylesheet loading phase +// even if one stylesheet is missing, and that an error message is displayed. + +const TESTCASE_URI = TEST_BASE + "missing.html"; + +add_task(async function() { + const { ui, toolbox, panel } = await openStyleEditorForURL(TESTCASE_URI); + + // Note that we're not testing for a specific number of stylesheet editors + // below because the test-page is loaded with chrome:// URL and, right now, + // that means UA stylesheets are shown. So we avoid hardcoding the number of + // stylesheets here. + await waitUntil(() => ui.editors.length); + ok(true, "The UI contains style sheets."); + + const rootEl = panel.panelWindow.document.getElementById( + "style-editor-chrome" + ); + ok(!rootEl.classList.contains("loading"), "The loading indicator is hidden"); + + const notifBox = toolbox.getNotificationBox(); + await waitUntil(() => notifBox.getCurrentNotification()); + const notif = notifBox.getCurrentNotification(); + ok(notif, "The notification box contains a message"); + ok( + notif.label.includes("Style sheet could not be loaded"), + "The error message is the correct one" + ); + ok( + notif.label.includes("missing-stylesheet.css"), + "The error message contains the missing stylesheet's URL" + ); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_navigate.js b/devtools/client/styleeditor/test/browser_styleeditor_navigate.js new file mode 100644 index 0000000000..5f867bf45d --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_navigate.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that selected sheet and cursor position is reset during navigation. + +const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html"; +const NEW_URI = TEST_BASE_HTTPS + "media.html"; + +const LINE_NO = 5; +const COL_NO = 3; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 2, "Two sheets present after load."); + + info("Selecting the second editor"); + await ui.selectStyleSheet(ui.editors[1].styleSheet, LINE_NO, COL_NO); + + await navigateToAndWaitForStyleSheets(NEW_URI, ui, 2); + + info("Waiting for source editor to be ready."); + await ui.editors[0].getSourceEditor(); + + is(ui.selectedEditor, ui.editors[0], "first editor is selected"); + + const { line, ch } = ui.selectedEditor.sourceEditor.getCursor(); + is(line, 0, "first line is selected"); + is(ch, 0, "first column is selected"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_new.js b/devtools/client/styleeditor/test/browser_styleeditor_new.js new file mode 100644 index 0000000000..2d377064d8 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_new.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that new sheets can be added and edited. + +const TESTCASE_URI = TEST_BASE_HTTP + "simple.html"; + +const TESTCASE_CSS_SOURCE = "body{background-color:red;"; + +add_task(async function() { + const { panel, ui } = await openStyleEditorForURL(TESTCASE_URI); + + const editor = await createNewStyleSheet(ui, panel.panelWindow); + await testInitialState(editor); + + const originalHref = editor.styleSheet.href; + const waitForPropertyChange = onPropertyChange(editor); + + await typeInEditor(editor, panel.panelWindow); + + await waitForPropertyChange; + + testUpdated(editor, originalHref); +}); + +function onPropertyChange(editor) { + return new Promise(resolve => { + editor.on("property-change", function onProp(property) { + // wait for text to be entered fully + const text = editor.sourceEditor.getText(); + if (property == "ruleCount" && text == TESTCASE_CSS_SOURCE + "}") { + editor.off("property-change", onProp); + resolve(); + } + }); + }); +} + +async function testInitialState(editor) { + info("Testing the initial state of the new editor"); + + let summary = editor.summary; + + ok(editor.sourceLoaded, "new editor is loaded when attached"); + ok(editor.isNew, "new editor has isNew flag"); + + if (!editor.sourceEditor.hasFocus()) { + info("Waiting for stylesheet editor to gain focus"); + await editor.sourceEditor.once("focus"); + } + ok(editor.sourceEditor.hasFocus(), "new editor has focus"); + + summary = editor.summary; + const ruleCount = summary.querySelector(".stylesheet-rule-count").textContent; + is(parseInt(ruleCount, 10), 0, "new editor initially shows 0 rules"); + + const color = await getComputedStyleProperty({ + selector: "body", + name: "background-color", + }); + is( + color, + "rgb(255, 255, 255)", + "content's background color is initially white" + ); +} + +function typeInEditor(editor, panelWindow) { + return new Promise(resolve => { + waitForFocus(function() { + for (const c of TESTCASE_CSS_SOURCE) { + EventUtils.synthesizeKey(c, {}, panelWindow); + } + ok(editor.unsaved, "new editor has unsaved flag"); + + resolve(); + }, panelWindow); + }); +} + +function testUpdated(editor, originalHref) { + info("Testing the state of the new editor after editing it"); + + is( + editor.sourceEditor.getText(), + TESTCASE_CSS_SOURCE + "}", + "rule bracket has been auto-closed" + ); + + const ruleCount = editor.summary.querySelector(".stylesheet-rule-count") + .textContent; + is(parseInt(ruleCount, 10), 1, "new editor shows 1 rule after modification"); + + is(editor.styleSheet.href, originalHref, "style sheet href did not change"); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_nostyle.js b/devtools/client/styleeditor/test/browser_styleeditor_nostyle.js new file mode 100644 index 0000000000..acb153c919 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_nostyle.js @@ -0,0 +1,29 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that 'no styles' indicator is shown if a page doesn't contain any style +// sheets. + +const TESTCASE_URI = TEST_BASE_HTTP + "nostyle.html"; + +add_task(async function() { + const { panel, ui } = await openStyleEditorForURL(TESTCASE_URI); + const { panelWindow } = panel; + + ok( + !ui._root.classList.contains("loading"), + "style editor root element does not have 'loading' class name anymore" + ); + + ok( + ui._root.querySelector(".empty.placeholder"), + "showing 'no style' indicator" + ); + + let button = panelWindow.document.querySelector(".style-editor-newButton"); + ok(!button.hasAttribute("disabled"), "new style sheet button is enabled"); + + button = panelWindow.document.querySelector(".style-editor-importButton"); + ok(!button.hasAttribute("disabled"), "import button is enabled"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_opentab.js b/devtools/client/styleeditor/test/browser_styleeditor_opentab.js new file mode 100644 index 0000000000..42651f602a --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_opentab.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// A test to check the 'Open Link in new tab' functionality in the +// context menu item for stylesheets (bug 992947). +const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + await rightClickStyleSheet(ui, ui.editors[0]); + is( + ui._openLinkNewTabItem.getAttribute("disabled"), + "false", + "The menu item is not disabled" + ); + is( + ui._openLinkNewTabItem.getAttribute("hidden"), + "false", + "The menu item is not hidden" + ); + + const url = + "https://example.com/browser/devtools/client/styleeditor/test/" + + "simple.css"; + is(ui._contextMenuStyleSheet.href, url, "Correct URL for sheet"); + + const browserWindow = Services.wm.getMostRecentWindow( + gDevTools.chromeWindowType + ); + const originalOpenWebLinkIn = browserWindow.openWebLinkIn; + const tabOpenedDefer = new Promise(resolve => { + browserWindow.openWebLinkIn = newUrl => { + // Reset the actual openWebLinkIn function before proceeding. + browserWindow.openWebLinkIn = originalOpenWebLinkIn; + + is(newUrl, url, "The correct tab has been opened"); + resolve(); + }; + }); + + ui._openLinkNewTabItem.click(); + + info(`Waiting for a tab to open - ${url}`); + await tabOpenedDefer; + + await rightClickInlineStyleSheet(ui, ui.editors[1]); + is( + ui._openLinkNewTabItem.getAttribute("disabled"), + "true", + "The menu item is disabled" + ); + is( + ui._openLinkNewTabItem.getAttribute("hidden"), + "false", + "The menu item is not hidden" + ); + + await rightClickNoStyleSheet(ui); + is( + ui._openLinkNewTabItem.getAttribute("hidden"), + "true", + "The menu item is not hidden" + ); +}); + +function onPopupShow(contextMenu) { + return new Promise(resolve => { + contextMenu.addEventListener( + "popupshown", + function() { + resolve(); + }, + { once: true } + ); + }); +} + +function onPopupHide(contextMenu) { + return new Promise(resolve => { + contextMenu.addEventListener( + "popuphidden", + function() { + resolve(); + }, + { once: true } + ); + }); +} + +function rightClickStyleSheet(ui, editor) { + return new Promise(resolve => { + onPopupShow(ui._contextMenu).then(() => { + onPopupHide(ui._contextMenu).then(() => { + resolve(); + }); + ui._contextMenu.hidePopup(); + }); + + EventUtils.synthesizeMouseAtCenter( + editor.summary.querySelector(".stylesheet-name"), + { button: 2, type: "contextmenu" }, + ui._window + ); + }); +} + +function rightClickInlineStyleSheet(ui, editor) { + return new Promise(resolve => { + onPopupShow(ui._contextMenu).then(() => { + onPopupHide(ui._contextMenu).then(() => { + resolve(); + }); + ui._contextMenu.hidePopup(); + }); + + EventUtils.synthesizeMouseAtCenter( + editor.summary.querySelector(".stylesheet-name"), + { button: 2, type: "contextmenu" }, + ui._window + ); + }); +} + +function rightClickNoStyleSheet(ui) { + return new Promise(resolve => { + onPopupShow(ui._contextMenu).then(() => { + onPopupHide(ui._contextMenu).then(() => { + resolve(); + }); + ui._contextMenu.hidePopup(); + }); + + EventUtils.synthesizeMouseAtCenter( + ui._panelDoc.querySelector("#splitview-tpl-summary-stylesheet"), + { button: 2, type: "contextmenu" }, + ui._window + ); + }); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_pretty.js b/devtools/client/styleeditor/test/browser_styleeditor_pretty.js new file mode 100644 index 0000000000..933cc7ecf1 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_pretty.js @@ -0,0 +1,82 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that minified sheets are automatically prettified but other are left +// untouched. + +const TESTCASE_URI = TEST_BASE_HTTP + "minified.html"; + +/* + body { + background:white; + } + div { + font-size:4em; + color:red + } + span { + color:green; + } +*/ +const PRETTIFIED_SOURCE = + "" + + "body {\r?\n" + + "\tbackground:white;\r?\n" + + "}\r?\n" + + "div {\r?\n" + + "\tfont-size:4em;\r?\n" + + "\tcolor:red\r?\n" + + "}\r?\n" + + "span {\r?\n" + + "\tcolor:green;\r?\n" + + "}\r?\n"; + +/* + body { background: red; } + div { + font-size: 5em; + color: red + } +*/ +const ORIGINAL_SOURCE = + "" + + "body { background: red; }\r?\n" + + "div {\r?\n" + + "font-size: 5em;\r?\n" + + "color: red\r?\n" + + "}"; + +const EXPAND_TAB = "devtools.editor.expandtab"; + +add_task(async function() { + const oldExpandTabPref = SpecialPowers.getBoolPref(EXPAND_TAB); + // The 'EXPAND_TAB' preference has to be set to false because + // the constant 'PRETTIFIED_SOURCE' uses tabs for indentation. + SpecialPowers.setBoolPref(EXPAND_TAB, false); + + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + is(ui.editors.length, 2, "Two sheets present."); + + info("Testing minified style sheet."); + let editor = await ui.editors[0].getSourceEditor(); + + const prettifiedSourceRE = new RegExp(PRETTIFIED_SOURCE); + ok( + prettifiedSourceRE.test(editor.sourceEditor.getText()), + "minified source has been prettified automatically" + ); + + info("Selecting second, non-minified style sheet."); + await ui.selectStyleSheet(ui.editors[1].styleSheet); + + editor = ui.editors[1]; + + const originalSourceRE = new RegExp(ORIGINAL_SOURCE); + ok( + originalSourceRE.test(editor.sourceEditor.getText()), + "non-minified source has been left untouched" + ); + + SpecialPowers.setBoolPref(EXPAND_TAB, oldExpandTabPref); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_private_perwindowpb.js b/devtools/client/styleeditor/test/browser_styleeditor_private_perwindowpb.js new file mode 100644 index 0000000000..00af5cd4b5 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_private_perwindowpb.js @@ -0,0 +1,75 @@ +/* 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"; + +// This test makes sure that the style editor does not store any +// content CSS files in the permanent cache when opened from PB mode. + +const TEST_URL = + "http://" + + TEST_HOST + + "/browser/devtools/client/" + + "styleeditor/test/test_private.html"; + +add_task(async function() { + info("Opening a new private window"); + const win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + info("Clearing the browser cache"); + Services.cache2.clear(); + + const { toolbox, ui } = await openStyleEditorForURL(TEST_URL, win); + + is(ui.editors.length, 1, "The style editor contains one sheet."); + const editor = ui.editors[0]; + + await editor.getSourceEditor(); + await checkDiskCacheFor(TEST_HOST); + + await toolbox.destroy(); + + const onUnload = new Promise(done => { + win.addEventListener("unload", function listener(event) { + if (event.target == win.document) { + win.removeEventListener("unload", listener); + done(); + } + }); + }); + win.close(); + await onUnload; +}); + +function checkDiskCacheFor(host) { + let foundPrivateData = false; + + return new Promise(resolve => { + Visitor.prototype = { + onCacheStorageInfo: function(num) { + info("disk storage contains " + num + " entries"); + }, + onCacheEntryInfo: function(uri) { + const urispec = uri.asciiSpec; + info(urispec); + foundPrivateData |= urispec.includes(host); + }, + onCacheEntryVisitCompleted: function() { + is(foundPrivateData, false, "web content present in disk cache"); + resolve(); + }, + }; + function Visitor() {} + + const storage = Services.cache2.diskCacheStorage( + Services.loadContextInfo.default, + false + ); + storage.asyncVisitStorage( + new Visitor(), + /* Do walk entries */ + true + ); + }); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_reload.js b/devtools/client/styleeditor/test/browser_styleeditor_reload.js new file mode 100644 index 0000000000..538a111c80 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_reload.js @@ -0,0 +1,42 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that selected sheet and cursor position persists during reload. + +const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html"; + +const LINE_NO = 5; +const COL_NO = 3; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 2, "Two sheets present after load."); + + info("Selecting the second editor"); + await ui.selectStyleSheet(ui.editors[1].styleSheet, LINE_NO, COL_NO); + const selectedStyleSheetIndex = ui.editors[1].styleSheet.styleSheetIndex; + + await reloadPageAndWaitForStyleSheets(ui, 2); + + info("Waiting for source editor to be ready."); + const newEditor = findEditor(ui, selectedStyleSheetIndex); + await newEditor.getSourceEditor(); + + is( + ui.selectedEditor, + newEditor, + "Editor of stylesheet that has styleSheetIndex we selected is selected after reload" + ); + + const { line, ch } = ui.selectedEditor.sourceEditor.getCursor(); + is(line, LINE_NO, "correct line selected"); + is(ch, COL_NO, "correct column selected"); +}); + +function findEditor(ui, styleSheetIndex) { + return ui.editors.find( + editor => editor.styleSheet.styleSheetIndex === styleSheetIndex + ); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_resize_performance.js b/devtools/client/styleeditor/test/browser_styleeditor_resize_performance.js new file mode 100644 index 0000000000..441fa0cef8 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_resize_performance.js @@ -0,0 +1,62 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * This is a performance test designed to check we are not redrawing the UI too many times + * after resizing the window, when the styleeditor displays mediaqueries which are source + * mapped. + * See Bug 1453044 for more details. + */ + +const TESTCASE_URI = TEST_BASE_HTTP + "many-media-rules-sourcemaps/index.html"; + +// Maximum delay allowed between two media-list-changed events. +const EVENTS_DELAY = 2000; + +// The window resize will still trigger several resize events which will lead to several +// UI updates. Arbitrary maximum number of events allowed to be fired for a single resize. +// This used to be > 100 events for this test case. +const MAX_EVENTS = 10; + +add_task(async function() { + const { toolbox, ui } = await openStyleEditorForURL(TESTCASE_URI); + + const win = toolbox.win.parent; + const originalWidth = win.outerWidth; + const originalHeight = win.outerHeight; + + // Ensure the window is above 500px wide for @media (min-width: 500px) + if (originalWidth < 500) { + info("Window is too small for the test, resize it to > 800px width"); + const onMediaListChanged = waitForManyEvents(ui, EVENTS_DELAY); + await resizeWindow(800, ui, win); + info("Wait for media-list-changed events to settle"); + await onMediaListChanged; + } + + info( + "Resize the window to stop matching media queries, and trigger the UI updates" + ); + const onMediaListChanged = waitForManyEvents(ui, win, EVENTS_DELAY); + await resizeWindow(400, ui, win); + const eventsCount = await onMediaListChanged; + + ok( + eventsCount < MAX_EVENTS, + `Too many events fired (expected less than ${MAX_EVENTS}, got ${eventsCount})` + ); + + win.resizeTo(originalWidth, originalHeight); +}); + +/** + * Resize the window to the provided width. + */ +async function resizeWindow(width, ui, win) { + const onResize = once(win, "resize"); + win.resizeTo(width, win.outerHeight); + info("Wait for window resize event"); + await onResize; +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_scroll.js b/devtools/client/styleeditor/test/browser_styleeditor_scroll.js new file mode 100644 index 0000000000..d4454c85a0 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_scroll.js @@ -0,0 +1,96 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that editor scrolls to correct line if it's selected with +// * selectStyleSheet (specified line) +// * click on the sidebar item (line before the editor was unselected) +// See bug 1148086. + +const SIMPLE = TEST_BASE_HTTP + "simple.css"; +const LONG = TEST_BASE_HTTP + "doc_long.css"; +const DOCUMENT_WITH_LONG_SHEET = + "data:text/html;charset=UTF-8," + + encodeURIComponent( + [ + "", + "", + " ", + " Editor scroll test page", + ' ', + ' ', + " ", + " Editor scroll test page", + "", + ].join("\n") + ); +const LINE_TO_SELECT = 201; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(DOCUMENT_WITH_LONG_SHEET); + + is(ui.editors.length, 2, "Two editors present."); + + const simpleEditor = ui.editors[0]; + const longEditor = ui.editors[1]; + + info(`Selecting doc_long.css and scrolling to line ${LINE_TO_SELECT}`); + + // We need to wait for editor-selected if we want to check the scroll + // position as scrolling occurs after selectStyleSheet resolves but before the + // event is emitted. + let selectEventPromise = waitForEditorToBeSelected(longEditor, ui); + await ui.selectStyleSheet(longEditor.styleSheet, LINE_TO_SELECT); + await selectEventPromise; + + info("Checking that the correct line is visible after initial load"); + + const { from, to } = longEditor.sourceEditor.getViewport(); + info(`Lines ${from}-${to} are visible (expected ${LINE_TO_SELECT}).`); + + ok(from <= LINE_TO_SELECT, "The editor scrolled too much."); + ok(to >= LINE_TO_SELECT, "The editor scrolled too little."); + + const initialScrollTop = longEditor.sourceEditor.getScrollInfo().top; + info(`Storing scrollTop = ${initialScrollTop} for later comparison.`); + + info("Selecting the first editor (simple.css)"); + await ui.selectStyleSheet(simpleEditor.styleSheet); + + info("Selecting doc_long.css again."); + selectEventPromise = waitForEditorToBeSelected(longEditor, ui); + + // Can't use ui.selectStyleSheet here as it will scroll the editor back to top + // and we want to check that the previous scroll position is restored. + const summary = await ui.getEditorSummary(longEditor); + ui._view.activeSummary = summary; + + info("Waiting for doc_long.css to be selected."); + await selectEventPromise; + + const scrollTop = longEditor.sourceEditor.getScrollInfo().top; + is( + scrollTop, + initialScrollTop, + "Scroll top was restored after the sheet was selected again." + ); +}); + +/** + * A helper that waits "editor-selected" event for given editor. + * + * @param {StyleSheetEditor} editor + * The editor to wait for. + * @param {StyleEditorUI} ui + * The StyleEditorUI the editor belongs to. + */ +var waitForEditorToBeSelected = async function(editor, ui) { + info(`Waiting for ${editor.friendlyName} to be selected.`); + let selected = await ui.once("editor-selected"); + while (selected != editor) { + info(`Ignored editor-selected for editor ${editor.friendlyName}.`); + selected = await ui.once("editor-selected"); + } + + info(`Got editor-selected for ${editor.friendlyName}.`); +}; diff --git a/devtools/client/styleeditor/test/browser_styleeditor_selectstylesheet.js b/devtools/client/styleeditor/test/browser_styleeditor_selectstylesheet.js new file mode 100644 index 0000000000..843d69eff4 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_selectstylesheet.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that StyleEditorUI.selectStyleSheet selects the correct sheet, line and +// column. + +const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html"; + +const LINE_NO = 5; +const COL_NO = 0; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + const editor = ui.editors[1]; + + info("Selecting style sheet #1."); + await ui.selectStyleSheet(editor.styleSheet, LINE_NO); + + is(ui.selectedEditor, ui.editors[1], "Second editor is selected."); + const { line, ch } = ui.selectedEditor.sourceEditor.getCursor(); + + is(line, LINE_NO, "correct line selected"); + is(ch, COL_NO, "correct column selected"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_large.js b/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_large.js new file mode 100644 index 0000000000..670db7533a --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_large.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Covers the case from Bug 1128747, where loading a sourcemapped +// file prevents the correct editor from being selected on load, +// and causes a second iframe to be appended when the user clicks +// editor in the list. + +const TESTCASE_URI = TEST_BASE_HTTPS + "sourcemaps-large.html"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + await openEditor(ui.editors[0]); + const iframes = ui.selectedEditor.details.querySelectorAll("iframe"); + + is(iframes.length, 1, "There is only one editor iframe"); + ok( + ui.selectedEditor.summary.classList.contains("splitview-active"), + "The editor is selected" + ); +}); + +function openEditor(editor) { + getLinkFor(editor).click(); + + return editor.getSourceEditor(); +} + +function getLinkFor(editor) { + return editor.summary.querySelector(".stylesheet-name"); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js b/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js new file mode 100644 index 0000000000..eea7563441 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_sourcemap_watching.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +const TESTCASE_URI_HTML = TEST_BASE_HTTP + "sourcemaps-watching.html"; +const TESTCASE_URI_CSS = TEST_BASE_HTTP + "sourcemap-css/sourcemaps.css"; +const TESTCASE_URI_REG_CSS = TEST_BASE_HTTP + "simple.css"; +const TESTCASE_URI_SCSS = TEST_BASE_HTTP + "sourcemap-sass/sourcemaps.scss"; +const TESTCASE_URI_MAP = TEST_BASE_HTTP + "sourcemap-css/sourcemaps.css.map"; +const TESTCASE_SCSS_NAME = "sourcemaps.scss"; + +const TRANSITIONS_PREF = "devtools.styleeditor.transitions"; + +const CSS_TEXT = "* { color: blue }"; + +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); + +add_task(async function() { + await new Promise(resolve => { + SpecialPowers.pushPrefEnv({ set: [[TRANSITIONS_PREF, false]] }, resolve); + }); + + // copy all our files over so we don't screw them up for other tests + const HTMLFile = await copy(TESTCASE_URI_HTML, ["sourcemaps.html"]); + const CSSFile = await copy(TESTCASE_URI_CSS, [ + "sourcemap-css", + "sourcemaps.css", + ]); + await copy(TESTCASE_URI_SCSS, ["sourcemap-sass", "sourcemaps.scss"]); + await copy(TESTCASE_URI_MAP, ["sourcemap-css", "sourcemaps.css.map"]); + await copy(TESTCASE_URI_REG_CSS, ["simple.css"]); + + const uri = Services.io.newFileURI(HTMLFile); + const testcaseURI = uri.resolve(""); + + const { ui } = await openStyleEditorForURL(testcaseURI); + + let editor = ui.editors[1]; + if (getStylesheetNameFor(editor) != TESTCASE_SCSS_NAME) { + editor = ui.editors[2]; + } + + is(getStylesheetNameFor(editor), TESTCASE_SCSS_NAME, "found scss editor"); + + const link = getLinkFor(editor); + link.click(); + + await editor.getSourceEditor(); + + let color = await getComputedStyleProperty({ + selector: "div", + name: "color", + }); + is(color, "rgb(255, 0, 102)", "div is red before saving file"); + + const styleApplied = editor.once("style-applied"); + + await pauseForTimeChange(); + + // Edit and save Sass in the editor. This will start off a file-watching + // process waiting for the CSS file to change. + await editSCSS(editor); + + // We can't run Sass or another compiler, so we fake it by just + // directly changing the CSS file. + await editCSSFile(CSSFile); + + info("wrote to CSS file, waiting for style-applied event"); + + await styleApplied; + + color = await getComputedStyleProperty({ selector: "div", name: "color" }); + is(color, "rgb(0, 0, 255)", "div is blue after saving file"); + + // Ensure that the editor didn't revert. Bug 1346662. + is(editor.sourceEditor.getText(), CSS_TEXT, "edits remain applied"); +}); + +function editSCSS(editor) { + return new Promise(resolve => { + editor.sourceEditor.setText(CSS_TEXT); + + editor.saveToFile(null, function(file) { + ok(file, "Scss file should be saved"); + resolve(); + }); + }); +} + +function editCSSFile(CSSFile) { + return write(CSS_TEXT, CSSFile); +} + +function pauseForTimeChange() { + return new Promise(resolve => { + // We have to wait for the system time to turn over > 1000 ms so that + // our file's last change time will show a change. This reflects what + // would happen in real life with a user manually saving the file. + setTimeout(resolve, 2000); + }); +} + +/* Helpers */ + +function getLinkFor(editor) { + return editor.summary.querySelector(".stylesheet-name"); +} + +function getStylesheetNameFor(editor) { + return editor.summary + .querySelector(".stylesheet-name > label") + .getAttribute("value"); +} + +function copy(srcChromeURL, destFilePath) { + const destFile = FileUtils.getFile("ProfD", destFilePath); + return write(read(srcChromeURL), destFile); +} + +function read(srcChromeURL) { + const scriptableStream = Cc[ + "@mozilla.org/scriptableinputstream;1" + ].getService(Ci.nsIScriptableInputStream); + + const channel = NetUtil.newChannel({ + uri: srcChromeURL, + loadUsingSystemPrincipal: true, + }); + const input = channel.open(); + scriptableStream.init(input); + + let data = ""; + while (input.available()) { + data = data.concat(scriptableStream.read(input.available())); + } + scriptableStream.close(); + input.close(); + + return data; +} + +function write(data, file) { + return new Promise(resolve => { + const converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + + converter.charset = "UTF-8"; + + const istream = converter.convertToInputStream(data); + const ostream = FileUtils.openSafeFileOutputStream(file); + + NetUtil.asyncCopy(istream, ostream, function(status) { + if (!Components.isSuccessCode(status)) { + info("Coudln't write to " + file.path); + return; + } + resolve(file); + }); + }); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sourcemaps.js b/devtools/client/styleeditor/test/browser_styleeditor_sourcemaps.js new file mode 100644 index 0000000000..02e4be5e43 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_sourcemaps.js @@ -0,0 +1,152 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// https rather than chrome to improve coverage +const TESTCASE_URI = TEST_BASE_HTTPS + "sourcemaps.html"; +const PREF = "devtools.source-map.client-service.enabled"; + +const contents = { + "sourcemaps.scss": [ + "", + "$paulrougetpink: #f06;", + "", + "div {", + " color: $paulrougetpink;", + "}", + "", + "span {", + " background-color: #EEE;", + "}", + ].join("\n"), + "contained.scss": [ + "$pink: #f06;", + "", + "#header {", + " color: $pink;", + "}", + ].join("\n"), + "sourcemaps.css": [ + "div {", + " color: #ff0066; }", + "", + "span {", + " background-color: #EEE; }", + "", + "/*# sourceMappingURL=sourcemaps.css.map */", + ].join("\n"), + "contained.css": [ + "#header {", + " color: #f06; }", + "", + "/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJma" + + "WxlIjoiIiwic291cmNlcyI6WyJzYXNzL2NvbnRhaW5lZC5zY3NzIl0sIm5hbWVzIjpbXSwi" + + "bWFwcGluZ3MiOiJBQUVBO0VBQ0UsT0FISyIsInNvdXJjZXNDb250ZW50IjpbIiRwaW5rOiA" + + "jZjA2O1xuXG4jaGVhZGVyIHtcbiAgY29sb3I6ICRwaW5rO1xufSJdfQ==*/", + ].join("\n"), + "test-stylus.styl": [ + "paulrougetpink = #f06;", + "", + "div", + " color: paulrougetpink", + "", + "span", + " background-color: #EEE", + "", + ].join("\n"), + "test-stylus.css": [ + "div {", + " color: #f06;", + "}", + "span {", + " background-color: #eee;", + "}", + "/*# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJzb" + + "3VyY2VzIjpbInRlc3Qtc3R5bHVzLnN0eWwiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFB" + + "RUE7RUFDRSxPQUFPLEtBQVA7O0FBRUY7RUFDRSxrQkFBa0IsS0FBbEIiLCJmaWxlIjoidGV" + + "zdC1zdHlsdXMuY3NzIiwic291cmNlc0NvbnRlbnQiOlsicGF1bHJvdWdldHBpbmsgPSAjZj" + + "A2O1xuXG5kaXZcbiAgY29sb3I6IHBhdWxyb3VnZXRwaW5rXG5cbnNwYW5cbiAgYmFja2dyb" + + "3VuZC1jb2xvcjogI0VFRVxuIl19 */", + ].join("\n"), +}; + +const cssNames = ["sourcemaps.css", "contained.css", "test-stylus.css"]; +const origNames = ["sourcemaps.scss", "contained.scss", "test-stylus.styl"]; + +waitForExplicitFinish(); + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is( + ui.editors.length, + 4, + "correct number of editors with source maps enabled" + ); + + // Test first plain css editor + testFirstEditor(ui.editors[0]); + + // Test Scss editors + await testEditor(ui.editors[1], origNames); + await testEditor(ui.editors[2], origNames); + await testEditor(ui.editors[3], origNames); + + // Test disabling original sources + await togglePref(ui); + + is(ui.editors.length, 4, "correct number of editors after pref toggled"); + + // Test CSS editors + await testEditor(ui.editors[1], cssNames); + await testEditor(ui.editors[2], cssNames); + await testEditor(ui.editors[3], cssNames); + + Services.prefs.clearUserPref(PREF); +}); + +function testFirstEditor(editor) { + const name = getStylesheetNameFor(editor); + is(name, "simple.css", "First style sheet display name is correct"); +} + +function testEditor(editor, possibleNames) { + const name = getStylesheetNameFor(editor); + ok(possibleNames.includes(name), name + " editor name is correct"); + + return openEditor(editor).then(() => { + const expectedText = contents[name]; + + const text = editor.sourceEditor.getText(); + + is(text, expectedText, name + " editor contains expected text"); + }); +} + +/* Helpers */ + +function togglePref(UI) { + const editorsPromise = UI.once("stylesheets-refreshed"); + const selectedPromise = UI.once("editor-selected"); + + Services.prefs.setBoolPref(PREF, false); + + return promise.all([editorsPromise, selectedPromise]); +} + +function openEditor(editor) { + getLinkFor(editor).click(); + + return editor.getSourceEditor(); +} + +function getLinkFor(editor) { + return editor.summary.querySelector(".stylesheet-name"); +} + +function getStylesheetNameFor(editor) { + return editor.summary + .querySelector(".stylesheet-name > label") + .getAttribute("value"); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sourcemaps_inline.js b/devtools/client/styleeditor/test/browser_styleeditor_sourcemaps_inline.js new file mode 100644 index 0000000000..d2216c187e --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_sourcemaps_inline.js @@ -0,0 +1,89 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// https rather than chrome to improve coverage +const TESTCASE_URI = TEST_BASE_HTTPS + "sourcemaps-inline.html"; +const PREF = "devtools.source-map.client-service.enabled"; + +const sassContent = `body { + background-color: black; + & > h1 { + color: white; + } +} +`; + +const cssContent = + `body { + background-color: black; +} +body > h1 { + color: white; +} +` + + "/*# sourceMappingURL=data:application/json;base64,ewoidmVyc2lvbiI6IDMsCiJtY" + + "XBwaW5ncyI6ICJBQUFBLElBQUs7RUFDSCxnQkFBZ0IsRUFBRSxLQUFLO0VBQ3ZCLFNBQU87SUFD" + + "TCxLQUFLLEVBQUUsS0FBSyIsCiJzb3VyY2VzIjogWyJ0ZXN0LnNjc3MiXSwKInNvdXJjZXNDb25" + + "0ZW50IjogWyJib2R5IHtcbiAgYmFja2dyb3VuZC1jb2xvcjogYmxhY2s7XG4gICYgPiBoMSB7XG" + + "4gICAgY29sb3I6IHdoaXRlO1xuICB9XG59XG4iXSwKIm5hbWVzIjogW10sCiJmaWxlIjogInRlc" + + "3QuY3NzIgp9Cg== */"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is( + ui.editors.length, + 1, + "correct number of editors with source maps enabled" + ); + + await testEditor(ui.editors[0], "test.scss", sassContent); + + // Test disabling original sources + await togglePref(ui); + + is(ui.editors.length, 1, "correct number of editors after pref toggled"); + + // Test CSS editors + await testEditor(ui.editors[0], "", cssContent); + + Services.prefs.clearUserPref(PREF); +}); + +async function testEditor(editor, expectedName, expectedText) { + const name = getStylesheetNameFor(editor); + is(expectedName, name, name + " editor name is correct"); + + await openEditor(editor); + const text = editor.sourceEditor.getText(); + is(text, expectedText, name + " editor contains expected text"); +} + +/* Helpers */ + +function togglePref(UI) { + const editorsPromise = UI.once("stylesheets-refreshed"); + const selectedPromise = UI.once("editor-selected"); + + Services.prefs.setBoolPref(PREF, false); + + return promise.all([editorsPromise, selectedPromise]); +} + +function openEditor(editor) { + getLinkFor(editor).click(); + + return editor.getSourceEditor(); +} + +function getLinkFor(editor) { + return editor.summary.querySelector(".stylesheet-name"); +} + +function getStylesheetNameFor(editor) { + return editor.summary + .querySelector(".stylesheet-name > label") + .getAttribute("value"); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sv_keynav.js b/devtools/client/styleeditor/test/browser_styleeditor_sv_keynav.js new file mode 100644 index 0000000000..53307f6266 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_sv_keynav.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that the style sheet list can be navigated with keyboard. + +const TESTCASE_URI = TEST_BASE_HTTP + "four.html"; + +add_task(async function() { + const { panel, ui } = await openStyleEditorForURL(TESTCASE_URI); + + info("Waiting for source editor to load."); + await ui.editors[0].getSourceEditor(); + + const selected = ui.once("editor-selected"); + + info("Testing keyboard navigation on the sheet list."); + testKeyboardNavigation(ui.editors[0], panel); + + info("Waiting for editor #2 to be selected due to keyboard navigation."); + await selected; + + ok(ui.editors[2].sourceEditor.hasFocus(), "Editor #2 has focus."); +}); + +function getStylesheetNameLinkFor(editor) { + return editor.summary.querySelector(".stylesheet-name"); +} + +function testKeyboardNavigation(editor, panel) { + const panelWindow = panel.panelWindow; + const ui = panel.UI; + waitForFocus(function() { + const summary = editor.summary; + EventUtils.synthesizeMouseAtCenter(summary, {}, panelWindow); + + let item = getStylesheetNameLinkFor(ui.editors[0]); + is( + panelWindow.document.activeElement, + item, + "editor 0 item is the active element" + ); + + EventUtils.synthesizeKey("VK_DOWN", {}, panelWindow); + item = getStylesheetNameLinkFor(ui.editors[1]); + is( + panelWindow.document.activeElement, + item, + "editor 1 item is the active element" + ); + + EventUtils.synthesizeKey("VK_HOME", {}, panelWindow); + item = getStylesheetNameLinkFor(ui.editors[0]); + is( + panelWindow.document.activeElement, + item, + "fist editor item is the active element" + ); + + EventUtils.synthesizeKey("VK_END", {}, panelWindow); + item = getStylesheetNameLinkFor(ui.editors[3]); + is( + panelWindow.document.activeElement, + item, + "last editor item is the active element" + ); + + EventUtils.synthesizeKey("VK_UP", {}, panelWindow); + item = getStylesheetNameLinkFor(ui.editors[2]); + is( + panelWindow.document.activeElement, + item, + "editor 2 item is the active element" + ); + + EventUtils.synthesizeKey("VK_RETURN", {}, panelWindow); + // this will attach and give focus editor 2 + }, panelWindow); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sv_resize.js b/devtools/client/styleeditor/test/browser_styleeditor_sv_resize.js new file mode 100644 index 0000000000..0ee9843072 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_sv_resize.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that resizing the source editor container doesn't move the caret. + +const TESTCASE_URI = TEST_BASE_HTTP + "simple.html"; + +const { Toolbox } = require("devtools/client/framework/toolbox"); + +add_task(async function() { + const { toolbox, ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 2, "There are 2 style sheets initially"); + + info("Changing toolbox host to a window."); + await toolbox.switchHost(Toolbox.HostType.WINDOW); + + const editor = await ui.editors[0].getSourceEditor(); + const originalSourceEditor = editor.sourceEditor; + + const hostWindow = toolbox.win.parent; + const originalWidth = hostWindow.outerWidth; + const originalHeight = hostWindow.outerHeight; + + // to check the caret is preserved + originalSourceEditor.setCursor(originalSourceEditor.getPosition(4)); + + info("Resizing window."); + hostWindow.resizeTo(120, 480); + + const sourceEditor = ui.editors[0].sourceEditor; + is( + sourceEditor, + originalSourceEditor, + "the editor still references the same Editor instance" + ); + + is( + sourceEditor.getOffset(sourceEditor.getCursor()), + 4, + "the caret position has been preserved" + ); + + info("Restoring window to original size."); + hostWindow.resizeTo(originalWidth, originalHeight); +}); + +registerCleanupFunction(() => { + // Restore the host type for other tests. + Services.prefs.clearUserPref("devtools.toolbox.host"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_sync.js b/devtools/client/styleeditor/test/browser_styleeditor_sync.js new file mode 100644 index 0000000000..36bba6a6b7 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_sync.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that changes in the style inspector are synchronized into the +// style editor. + +const TESTCASE_URI = TEST_BASE_HTTP + "sync.html"; + +const expectedText = ` + body { + border-width: 15px; + /*! color: red; */ + } + + #testid { + /*! font-size: 4em; */ + } + `; + +async function closeAndReopenToolbox() { + const target = await TargetFactory.forTab(gBrowser.selectedTab); + await gDevTools.closeToolbox(target); + const { ui: newui } = await openStyleEditor(); + return newui; +} + +add_task(async function() { + await addTab(TESTCASE_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + let ruleEditor = getRuleViewRuleEditor(view, 1); + + // Disable the "font-size" property. + let propEditor = ruleEditor.rule.textProps[0].editor; + let onModification = view.once("ruleview-changed"); + propEditor.enable.click(); + await onModification; + + // Disable the "color" property. Note that this property is in a + // rule that also contains a non-inherited property -- so this test + // is also testing that property editing works properly in this + // situation. + ruleEditor = getRuleViewRuleEditor(view, 3); + propEditor = ruleEditor.rule.textProps[1].editor; + onModification = view.once("ruleview-changed"); + propEditor.enable.click(); + await onModification; + + let { ui } = await openStyleEditor(); + + let editor = await ui.editors[0].getSourceEditor(); + let text = editor.sourceEditor.getText(); + is(text, expectedText, "style inspector changes are synced"); + + // Close and reopen the toolbox, to see that the edited text remains + // available. + ui = await closeAndReopenToolbox(); + editor = await ui.editors[0].getSourceEditor(); + text = editor.sourceEditor.getText(); + is(text, expectedText, "changes remain after close and reopen"); + + // For the time being, the actor does not update the style's owning + // node's textContent. See bug 1205380. + const textContent = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function() { + return content.document.querySelector("style").textContent; + } + ); + + isnot(textContent, expectedText, "changes not written back to style node"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_syncAddProperty.js b/devtools/client/styleeditor/test/browser_styleeditor_syncAddProperty.js new file mode 100644 index 0000000000..99df9b064c --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_syncAddProperty.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that adding a new rule is synced to the style editor. + +const TESTCASE_URI = TEST_BASE_HTTP + "sync.html"; +const TESTCASE_URI_WITH_CSP = TEST_BASE_HTTP + "sync_with_csp.html"; + +const expectedText = ` + body { + border-width: 15px; + color: red; + } + + #testid { + font-size: 4em; + /*! background-color: yellow; */ + } + `; + +add_task(async function() { + const URIs = [TESTCASE_URI, TESTCASE_URI_WITH_CSP]; + + for (const URI of URIs) { + await addTab(URI); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + info("Focusing a new property name in the rule-view on " + URI); + const ruleEditor = getRuleViewRuleEditor(view, 1); + const editor = await focusEditableField(view, ruleEditor.closeBrace); + is( + inplaceEditor(ruleEditor.newPropSpan), + editor, + "The new property editor has focus" + ); + + const input = editor.input; + input.value = "/* background-color: yellow; */"; + + info("Pressing return to commit and focus the new value field"); + const onModifications = view.once("ruleview-changed"); + EventUtils.synthesizeKey("VK_RETURN", {}, view.styleWindow); + await onModifications; + + const { ui } = await openStyleEditor(); + const sourceEditor = await ui.editors[0].getSourceEditor(); + const text = sourceEditor.sourceEditor.getText(); + is(text, expectedText, "selector edits are synced"); + } +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_syncAddRule.js b/devtools/client/styleeditor/test/browser_styleeditor_syncAddRule.js new file mode 100644 index 0000000000..8063caa8ac --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_syncAddRule.js @@ -0,0 +1,30 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that adding a new rule is synced to the style editor. + +const TESTCASE_URI = TEST_BASE_HTTP + "sync.html"; + +const expectedText = ` +#testid { +}`; + +add_task(async function() { + await addTab(TESTCASE_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const onRuleViewChanged = once(view, "ruleview-changed"); + view.addRuleButton.click(); + await onRuleViewChanged; + + const { ui } = await openStyleEditor(); + + info("Selecting the second editor"); + await ui.selectStyleSheet(ui.editors[1].styleSheet); + + const editor = ui.editors[1]; + const text = editor.sourceEditor.getText(); + is(text, expectedText, "selector edits are synced"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_syncAlreadyOpen.js b/devtools/client/styleeditor/test/browser_styleeditor_syncAlreadyOpen.js new file mode 100644 index 0000000000..b776981406 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_syncAlreadyOpen.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that changes in the style inspector are synchronized into the +// style editor. + +const TESTCASE_URI = TEST_BASE_HTTP + "sync.html"; + +const expectedText = ` + body { + border-width: 15px; + color: red; + } + + #testid { + /*! font-size: 4em; */ + } + `; + +add_task(async function() { + await addTab(TESTCASE_URI); + + const { inspector, view, toolbox } = await openRuleView(); + + // In this test, make sure the style editor is open before making + // changes in the inspector. + const { ui } = await openStyleEditor(); + const editor = await ui.editors[0].getSourceEditor(); + + const onEditorChange = new Promise(resolve => { + editor.sourceEditor.on("change", resolve); + }); + + await toolbox.getPanel("inspector"); + await selectNode("#testid", inspector); + const ruleEditor = getRuleViewRuleEditor(view, 1); + + // Disable the "font-size" property. + const propEditor = ruleEditor.rule.textProps[0].editor; + const onModification = view.once("ruleview-changed"); + propEditor.enable.click(); + await onModification; + + await openStyleEditor(); + await onEditorChange; + + const text = editor.sourceEditor.getText(); + is(text, expectedText, "style inspector changes are synced"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_syncEditSelector.js b/devtools/client/styleeditor/test/browser_styleeditor_syncEditSelector.js new file mode 100644 index 0000000000..eef5adaf54 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_syncEditSelector.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that changes in the style inspector are synchronized into the +// style editor. + +const TESTCASE_URI = TEST_BASE_HTTP + "sync.html"; + +const expectedText = ` + body { + border-width: 15px; + color: red; + } + + #testid, span { + font-size: 4em; + } + `; + +add_task(async function() { + await addTab(TESTCASE_URI); + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + const ruleEditor = getRuleViewRuleEditor(view, 1); + + let editor = await focusEditableField(view, ruleEditor.selectorText); + editor.input.value = "#testid, span"; + const onRuleViewChanged = once(view, "ruleview-changed"); + EventUtils.synthesizeKey("KEY_Enter"); + await onRuleViewChanged; + + const { ui } = await openStyleEditor(); + + editor = await ui.editors[0].getSourceEditor(); + const text = editor.sourceEditor.getText(); + is(text, expectedText, "selector edits are synced"); +}); diff --git a/devtools/client/styleeditor/test/browser_styleeditor_syncIntoRuleView.js b/devtools/client/styleeditor/test/browser_styleeditor_syncIntoRuleView.js new file mode 100644 index 0000000000..c1058af13e --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_syncIntoRuleView.js @@ -0,0 +1,45 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +// Test that changes in the style editor are synchronized into the +// style inspector. + +const TEST_URI = ` + +
Styled Node
+`; + +const TESTCASE_CSS_SOURCE = "#testid { color: chartreuse; }"; + +add_task(async function() { + await addTab("data:text/html;charset=utf-8," + encodeURIComponent(TEST_URI)); + + const { inspector, view } = await openRuleView(); + await selectNode("#testid", inspector); + + const { panel, ui } = await openStyleEditor(); + + const editor = await ui.editors[0].getSourceEditor(); + + const waitForRuleView = view.once("ruleview-refreshed"); + await typeInEditor(editor, panel.panelWindow); + await waitForRuleView; + + const value = getRuleViewPropertyValue(view, "#testid", "color"); + is(value, "chartreuse", "check that edits were synced to rule view"); +}); + +function typeInEditor(editor, panelWindow) { + return new Promise(resolve => { + waitForFocus(function() { + for (const c of TESTCASE_CSS_SOURCE) { + EventUtils.synthesizeKey(c, {}, panelWindow); + } + ok(editor.unsaved, "new editor has unsaved flag"); + + resolve(); + }, panelWindow); + }); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_transition_rule.js b/devtools/client/styleeditor/test/browser_styleeditor_transition_rule.js new file mode 100644 index 0000000000..a63d882d15 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_transition_rule.js @@ -0,0 +1,51 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TESTCASE_URI = TEST_BASE_HTTPS + "simple.html"; + +const NEW_RULE = "body { background-color: purple; }"; + +add_task(async function() { + const { ui } = await openStyleEditorForURL(TESTCASE_URI); + + is(ui.editors.length, 2, "correct number of editors"); + + const editor = ui.editors[0]; + await openEditor(editor); + + // Set text twice in a row + const styleChanges = listenForStyleChange(editor); + + editor.sourceEditor.setText(NEW_RULE); + editor.sourceEditor.setText(NEW_RULE + " "); + + await styleChanges; + + const rules = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [0], + async function(index) { + const sheet = content.document.styleSheets[index]; + return [...sheet.cssRules].map(rule => rule.cssText); + } + ); + + // Test that we removed the transition rule, but kept the rule we added + is(rules.length, 1, "only one rule in stylesheet"); + is(rules[0], NEW_RULE, "stylesheet only contains rule we added"); +}); + +/* Helpers */ + +function openEditor(editor) { + const link = editor.summary.querySelector(".stylesheet-name"); + link.click(); + + return editor.getSourceEditor(); +} + +function listenForStyleChange(editor) { + return editor.once("style-applied"); +} diff --git a/devtools/client/styleeditor/test/browser_styleeditor_xul.js b/devtools/client/styleeditor/test/browser_styleeditor_xul.js new file mode 100644 index 0000000000..92545e96b8 --- /dev/null +++ b/devtools/client/styleeditor/test/browser_styleeditor_xul.js @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Test that the style-editor initializes correctly for XUL windows. + +"use strict"; + +waitForExplicitFinish(); + +const TEST_URL = TEST_BASE + "doc_xulpage.xhtml"; + +add_task(async function() { + const tab = await addTab(TEST_URL); + const target = await TargetFactory.forTab(tab); + + const toolbox = await gDevTools.showToolbox(target, "styleeditor"); + const panel = toolbox.getCurrentPanel(); + + ok( + panel, + "The style-editor panel did initialize correctly for the XUL window" + ); +}); diff --git a/devtools/client/styleeditor/test/bug_1405342_serviceworker_iframes.html b/devtools/client/styleeditor/test/bug_1405342_serviceworker_iframes.html new file mode 100644 index 0000000000..7bcbcf875c --- /dev/null +++ b/devtools/client/styleeditor/test/bug_1405342_serviceworker_iframes.html @@ -0,0 +1,10 @@ + + + + + Bug 1405342 + + +