diff options
Diffstat (limited to 'toolkit/components/aboutconfig')
17 files changed, 2422 insertions, 0 deletions
diff --git a/toolkit/components/aboutconfig/content/aboutconfig.css b/toolkit/components/aboutconfig/content/aboutconfig.css new file mode 100644 index 0000000000..b1ffe12a86 --- /dev/null +++ b/toolkit/components/aboutconfig/content/aboutconfig.css @@ -0,0 +1,290 @@ +/* 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/. */ + +:root { + --prefs-table-border-width: 1px; + --prefs-table-border: var(--prefs-table-border-width) solid var(--in-content-box-border-color); +} + +.hidden { + display: none; +} + +.table-shown > #show-all, +.table-shown > .config-background-wrapper { + display: none; +} + +.config-background { + background: url("chrome://global/content/aboutconfig/background.svg") no-repeat; + height: 182px; + margin: 32px auto; + width: 235px; +} + +.config-help-text { + text-align: center; +} + +.title { + background-image: url("chrome://global/skin/icons/warning.svg"); + fill: #fcd100; +} + +#toolbar { + position: sticky; + top: 0; + z-index: 1; + box-sizing: border-box; + width: 100%; + background-color: var(--in-content-page-background); + padding: 10px; + padding-bottom: 0; + min-width: 644px; + display: flex; +} + +.checkbox-container { + /* Center align and get rid of whitespace. */ + display: inline-flex; + align-items: center; + margin-inline-start: 1ch; +} + +#about-config-search { + -moz-context-properties: fill, fill-opacity; + fill: currentColor; + box-sizing: border-box; + flex-grow: 1; + background-image: url("chrome://global/skin/icons/search-glass.svg"); + background-repeat: no-repeat; + background-position: 8px center; + background-size: 16px; + /* Set horizontal margin to 0 to ensure alignment with table. */ + margin-inline: 0; + text-align: match-parent; + /* All prefs must be left-to-right. */ + direction: ltr; +} + +@media not (prefers-contrast) { + #about-config-search { + fill-opacity: 0.4; + } +} + +#about-config-search:placeholder-shown { + /* Display the placeholder in its natural directionality, + * even if the user changes the text direction manually + * (e.g. via RightCtrl+Shift). */ + direction: inherit; +} + +:root:dir(ltr) #about-config-search { + /* Be explicit about padding direction since + * `about-config-search` is forced to be LTR. */ + padding-left: 32px; +} + +:root:dir(rtl) #about-config-search { + background-position-x: right 8px; + padding-right: 32px; +} + +#show-all { + display: block; + margin: 10px auto; +} + +#prefs { + background-color: var(--in-content-box-background); + color: var(--in-content-text-color); + margin: 10px; + table-layout: fixed; + width: calc(100% - 20px); + min-width: 644px; + /* To stay consistent with about:preferences (664px - 20px margin). */ + border: var(--prefs-table-border); + border-radius: 4px; + border-spacing: 0; +} + +#prefs > tr.odd { + background-color: var(--in-content-box-background-odd); +} + +#prefs > tr:hover { + background-color: var(--in-content-item-hover); + color: var(--in-content-item-hover-text); +} + +#prefs > tr.has-user-value { + font-weight: bold; +} + +#prefs > tr.locked { + opacity: 0.4; + background-image: url("chrome://global/skin/icons/security.svg"); + background-repeat: no-repeat; + background-position: 9px center; + background-size: 16px 16px; + -moz-context-properties: fill; + fill: currentColor; +} + +#prefs > tr.locked:dir(rtl) { + background-position-x: right 9px; +} + +#prefs > tr > td, +#prefs > tr > th { + padding: 4px; + font-weight: inherit; +} + +#prefs > tr > th { + direction: ltr; + text-align: match-parent; +} + +#prefs > tr:dir(ltr) > th { + /* Be explicit about padding direction since `th` is forced to be LTR. */ + padding-left: 30px; +} + +#prefs > tr:dir(rtl) > th { + padding-right: 30px; +} + +#prefs > tr.deleted > th > span { + font-weight: bold; + color: var(--text-color-deemphasized); +} + +#prefs > tr > td.cell-edit, +#prefs > tr > td.cell-reset { + width: 40px; + padding: 0; +} + +.cell-value { + overflow-wrap: anywhere; + white-space: pre-wrap; + word-break: break-all; +} + +tr:not(.deleted) > .cell-value { + /* Always display the text in the value cell using left-to-right rules, but + align it according to the page direction. This doesn't apply to the radio + buttons shown for deleted preferences. */ + direction: ltr; + text-align: match-parent; +} + +:root:dir(ltr) tr:not(.deleted) > .cell-value > #form-edit { + /* Make the text in the form stay in the same place as before editing the pref. */ + margin-left: -8px; +} + +:root:dir(rtl) tr:not(.deleted) > .cell-value > #form-edit { + margin-right: -8px; +} + +#form-edit > label { + /* Make the radiobutton's text wrap to a new line along with + the radiobutton itself, when space is constrained. */ + display: inline-block; + margin-inline-end: 30px; +} + +#form-edit > label:last-of-type { + margin-inline-end: 0; +} + +#form-edit > label > :is(input[type="radio"], span) { + vertical-align: middle; +} + +td.cell-value > form > input[type="text"], +td.cell-value > form > input[type="number"] { + appearance: textfield; + margin: 0; + width: 100%; + box-sizing: border-box; + /* Align the text inside the input field in the same way as the table cell, + for both the left-to-right and right-to-left directions. */ + text-align: match-parent; +} + +.button-add, +.button-save, +.button-edit, +.button-toggle, +.button-delete, +.button-reset { + -moz-context-properties: fill; + background-position: center; + background-repeat: no-repeat; + background-size: 16px; + fill: currentColor; + min-width: auto; + width: 32px; +} + +.button-add { + background-image: url("chrome://global/skin/icons/plus.svg"); +} + +.button-save { + background-image: url("chrome://global/skin/icons/check.svg"); +} + +.button-edit { + background-image: url("chrome://global/skin/icons/edit.svg"); +} + +.button-toggle { + background-image: url("chrome://global/content/aboutconfig/toggle.svg"); +} + +.button-delete { + background-image: url("chrome://global/skin/icons/delete.svg"); +} + +.button-reset { + background-image: url("chrome://global/skin/icons/undo.svg"); +} + +.button-reset:dir(rtl) { + transform: scaleX(-1); +} + +/* The ::before creates a blank space between the last visible pref and the add row. */ +#prefs[has-visible-prefs] > .add > th::before, +#prefs[has-visible-prefs] > .add > td::before { + content: ""; + display: block; + position: absolute; + top: 0; + /* Make it wider by the border width so the border-inline is hidden. */ + inset-inline: calc(var(--prefs-table-border-width) * -1); + height: 12px; + background-color: var(--in-content-page-background); + /* This is visually the top border on the add row. */ + border-bottom: var(--prefs-table-border); +} + +#prefs[has-visible-prefs] > .add > th, +#prefs[has-visible-prefs] > .add > td { + /* This is the border underneath the last existing pref row. */ + border-top: var(--prefs-table-border); + padding-top: 14px; + position: relative; +} + +@media (prefers-contrast) { + #prefs > tr.deleted:hover > th > span { + color: inherit; + } +} diff --git a/toolkit/components/aboutconfig/content/aboutconfig.html b/toolkit/components/aboutconfig/content/aboutconfig.html new file mode 100644 index 0000000000..8962cdd86d --- /dev/null +++ b/toolkit/components/aboutconfig/content/aboutconfig.html @@ -0,0 +1,108 @@ +<!-- 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/. --> + +<!DOCTYPE html> +<html> + <head> + <meta + http-equiv="Content-Security-Policy" + content="default-src chrome:; object-src 'none'" + /> + <meta charset="utf-8" /> + <meta name="color-scheme" content="light dark" /> + <link + rel="stylesheet" + media="screen, projection" + type="text/css" + href="chrome://global/skin/in-content/common.css" + /> + <link + rel="stylesheet" + media="screen, projection" + type="text/css" + href="chrome://global/skin/in-content/info-pages.css" + title="infop" + /> + <link + rel="stylesheet" + type="text/css" + href="chrome://global/content/aboutconfig/aboutconfig.css" + /> + <link rel="localization" href="branding/brand.ftl" /> + <link rel="localization" href="toolkit/about/config.ftl" /> + <link rel="icon" href="chrome://global/skin/icons/settings.svg" /> + <script src="chrome://global/content/aboutconfig/aboutconfig.js"></script> + <title data-l10n-id="about-config-page-title"></title> + </head> + <body> + <div + class="container" + role="alertdialog" + aria-labelledby="warningTitle" + aria-describedby="warningDescription" + > + <div class="title"> + <h1 + id="warningTitle" + class="title-text" + data-l10n-id="about-config-intro-warning-title" + ></h1> + </div> + + <div class="description"> + <p + id="warningDescription" + data-l10n-id="about-config-intro-warning-text" + ></p> + </div> + + <div class="toggle-container-with-text"> + <input type="checkbox" id="showWarningNextTime" checked /> + <label + for="showWarningNextTime" + data-l10n-id="about-config-intro-warning-checkbox" + ></label> + </div> + + <div class="button-container"> + <button + id="warningButton" + class="primary" + data-l10n-id="about-config-intro-warning-button" + ></button> + </div> + </div> + + <template id="main"> + <div id="toolbar"> + <!-- Use a unique ID to prevent showing autocomplete results from other + browser pages with similarly named fields. --> + <input + type="text" + id="about-config-search" + data-l10n-id="about-config-search-input1" + /> + <label class="checkbox-container"> + <input type="checkbox" id="about-config-show-only-modified" /> + <span data-l10n-id="about-config-show-only-modified"></span> + </label> + </div> + + <table id="prefs"></table> + + <div class="config-background-wrapper"> + <button + id="show-all" + class="ghost-button" + data-l10n-id="about-config-show-all" + ></button> + <div class="config-background"></div> + <p + class="config-help-text" + data-l10n-id="about-config-caution-text" + ></p> + </div> + </template> + </body> +</html> diff --git a/toolkit/components/aboutconfig/content/aboutconfig.js b/toolkit/components/aboutconfig/content/aboutconfig.js new file mode 100644 index 0000000000..e11021ed64 --- /dev/null +++ b/toolkit/components/aboutconfig/content/aboutconfig.js @@ -0,0 +1,696 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { DeferredTask } = ChromeUtils.importESModule( + "resource://gre/modules/DeferredTask.sys.mjs" +); +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const SEARCH_TIMEOUT_MS = 100; +const SEARCH_AUTO_MIN_CRARACTERS = 3; + +const GETTERS_BY_PREF_TYPE = { + [Ci.nsIPrefBranch.PREF_BOOL]: "getBoolPref", + [Ci.nsIPrefBranch.PREF_INT]: "getIntPref", + [Ci.nsIPrefBranch.PREF_STRING]: "getStringPref", +}; + +const STRINGS_ADD_BY_TYPE = { + Boolean: "about-config-pref-add-type-boolean", + Number: "about-config-pref-add-type-number", + String: "about-config-pref-add-type-string", +}; + +// Fluent limits the maximum length of placeables. +const MAX_PLACEABLE_LENGTH = 2500; + +let gDefaultBranch = Services.prefs.getDefaultBranch(""); +let gFilterPrefsTask = new DeferredTask( + () => filterPrefs(), + SEARCH_TIMEOUT_MS, + 0 +); + +/** + * Maps the name of each preference in the back-end to its PrefRow object, + * separating the preferences that actually exist. This is as an optimization to + * avoid querying the preferences service each time the list is filtered. + */ +let gExistingPrefs = new Map(); +let gDeletedPrefs = new Map(); + +/** + * Also cache several values to improve the performance of common use cases. + */ +let gSortedExistingPrefs = null; +let gSearchInput = null; +let gShowOnlyModifiedCheckbox = null; +let gPrefsTable = null; + +/** + * Reference to the PrefRow currently being edited, if any. + */ +let gPrefInEdit = null; + +/** + * Lowercase substring that should be contained in the preference name. + */ +let gFilterString = null; + +/** + * RegExp that should be matched to the preference name. + */ +let gFilterPattern = null; + +/** + * True if we were requested to show all preferences. + */ +let gFilterShowAll = false; + +class PrefRow { + constructor(name, opts) { + this.name = name; + this.value = true; + this.hidden = false; + this.odd = false; + this.editing = false; + this.isAddRow = opts && opts.isAddRow; + this.refreshValue(); + } + + refreshValue() { + let prefType = Services.prefs.getPrefType(this.name); + + // If this preference has been deleted, we keep its last known value. + if (prefType == Ci.nsIPrefBranch.PREF_INVALID) { + this.hasDefaultValue = false; + this.hasUserValue = false; + this.isLocked = false; + if (gExistingPrefs.has(this.name)) { + gExistingPrefs.delete(this.name); + gSortedExistingPrefs = null; + } + gDeletedPrefs.set(this.name, this); + return; + } + + if (!gExistingPrefs.has(this.name)) { + gExistingPrefs.set(this.name, this); + gSortedExistingPrefs = null; + } + gDeletedPrefs.delete(this.name); + + try { + this.value = gDefaultBranch[GETTERS_BY_PREF_TYPE[prefType]](this.name); + this.hasDefaultValue = true; + } catch (ex) { + this.hasDefaultValue = false; + } + this.hasUserValue = Services.prefs.prefHasUserValue(this.name); + this.isLocked = Services.prefs.prefIsLocked(this.name); + + try { + if (this.hasUserValue) { + // This can throw for locked preferences without a default value. + this.value = Services.prefs[GETTERS_BY_PREF_TYPE[prefType]](this.name); + } else if (/^chrome:\/\/.+\/locale\/.+\.properties/.test(this.value)) { + // We don't know which preferences should be read using getComplexValue, + // so we use a heuristic to determine if this is a localized preference. + // This can throw if there is no value in the localized files. + this.value = Services.prefs.getComplexValue( + this.name, + Ci.nsIPrefLocalizedString + ).data; + } + } catch (ex) { + this.value = ""; + } + } + + get type() { + return this.value.constructor.name; + } + + get exists() { + return this.hasDefaultValue || this.hasUserValue; + } + + get matchesFilter() { + if (!this.matchesModifiedFilter) { + return false; + } + + return ( + gFilterShowAll || + (gFilterPattern && gFilterPattern.test(this.name)) || + (gFilterString && this.name.toLowerCase().includes(gFilterString)) + ); + } + + get matchesModifiedFilter() { + const onlyShowModified = gShowOnlyModifiedCheckbox.checked; + return !onlyShowModified || this.hasUserValue; + } + + /** + * Returns a reference to the table row element to be added to the document, + * constructing and initializing it the first time this method is called. + */ + getElement() { + if (this._element) { + return this._element; + } + + this._element = document.createElement("tr"); + this._element._pref = this; + + let nameCell = document.createElement("th"); + let nameCellSpan = document.createElement("span"); + nameCell.appendChild(nameCellSpan); + this._element.append( + nameCell, + (this.valueCell = document.createElement("td")), + (this.editCell = document.createElement("td")), + (this.resetCell = document.createElement("td")) + ); + this.editCell.appendChild( + (this.editButton = document.createElement("button")) + ); + delete this.resetButton; + + nameCell.setAttribute("scope", "row"); + this.valueCell.className = "cell-value"; + this.editCell.className = "cell-edit"; + this.resetCell.className = "cell-reset"; + + // Add <wbr> behind dots to prevent line breaking in random mid-word places. + let parts = this.name.split("."); + for (let i = 0; i < parts.length - 1; i++) { + nameCellSpan.append(parts[i] + ".", document.createElement("wbr")); + } + nameCellSpan.append(parts[parts.length - 1]); + + this.refreshElement(); + + return this._element; + } + + refreshElement() { + if (!this._element) { + // No need to update if this preference was never added to the table. + return; + } + + if (this.exists && !this.editing) { + // We need to place the text inside a "span" element to ensure that the + // text copied to the clipboard includes all whitespace. + let span = document.createElement("span"); + span.textContent = this.value; + // We additionally need to wrap this with another "span" element to convey + // the state to screen readers without affecting the visual presentation. + span.setAttribute("aria-hidden", "true"); + let outerSpan = document.createElement("span"); + if (this.type == "String" && this.value.length > MAX_PLACEABLE_LENGTH) { + // If the value is too long for localization, don't include the state. + // Since the preferences system is designed to store short values, this + // case happens very rarely, thus we keep the same DOM structure for + // consistency even though we could avoid the extra "span" element. + outerSpan.setAttribute("aria-label", this.value); + } else { + let spanL10nId = this.hasUserValue + ? "about-config-pref-accessible-value-custom" + : "about-config-pref-accessible-value-default"; + document.l10n.setAttributes(outerSpan, spanL10nId, { + value: "" + this.value, + }); + } + outerSpan.appendChild(span); + this.valueCell.textContent = ""; + this.valueCell.append(outerSpan); + if (this.type == "Boolean") { + document.l10n.setAttributes( + this.editButton, + "about-config-pref-toggle-button" + ); + this.editButton.className = "button-toggle semi-transparent"; + } else { + document.l10n.setAttributes( + this.editButton, + "about-config-pref-edit-button" + ); + this.editButton.className = "button-edit semi-transparent"; + } + this.editButton.removeAttribute("form"); + delete this.inputField; + } else { + this.valueCell.textContent = ""; + // The form is needed for the validation report to appear, but we need to + // prevent the associated button from reloading the page. + let form = document.createElement("form"); + form.addEventListener("submit", event => event.preventDefault()); + form.id = "form-edit"; + if (this.editing) { + this.inputField = document.createElement("input"); + this.inputField.value = this.value; + if (this.type == "Number") { + this.inputField.type = "number"; + this.inputField.required = true; + this.inputField.min = -2147483648; + this.inputField.max = 2147483647; + } else { + this.inputField.type = "text"; + } + form.appendChild(this.inputField); + document.l10n.setAttributes( + this.editButton, + "about-config-pref-save-button" + ); + this.editButton.className = "primary button-save semi-transparent"; + } else { + delete this.inputField; + for (let type of ["Boolean", "Number", "String"]) { + let radio = document.createElement("input"); + radio.type = "radio"; + radio.name = "type"; + radio.value = type; + radio.checked = this.type == type; + let radioSpan = document.createElement("span"); + document.l10n.setAttributes(radioSpan, STRINGS_ADD_BY_TYPE[type]); + let radioLabel = document.createElement("label"); + radioLabel.append(radio, radioSpan); + form.appendChild(radioLabel); + } + form.addEventListener("click", event => { + if (event.target.name != "type") { + return; + } + let type = event.target.value; + if (this.type != type) { + if (type == "Boolean") { + this.value = true; + } else if (type == "Number") { + this.value = 0; + } else { + this.value = ""; + } + } + }); + document.l10n.setAttributes( + this.editButton, + "about-config-pref-add-button" + ); + this.editButton.className = "button-add semi-transparent"; + } + this.valueCell.appendChild(form); + this.editButton.setAttribute("form", "form-edit"); + } + this.editButton.disabled = this.isLocked; + if (!this.isLocked && this.hasUserValue) { + if (!this.resetButton) { + this.resetButton = document.createElement("button"); + this.resetCell.appendChild(this.resetButton); + } + if (!this.hasDefaultValue) { + document.l10n.setAttributes( + this.resetButton, + "about-config-pref-delete-button" + ); + this.resetButton.className = + "button-delete ghost-button semi-transparent"; + } else { + document.l10n.setAttributes( + this.resetButton, + "about-config-pref-reset-button" + ); + this.resetButton.className = + "button-reset ghost-button semi-transparent"; + } + } else if (this.resetButton) { + this.resetButton.remove(); + delete this.resetButton; + } + + this.refreshClass(); + } + + refreshClass() { + if (!this._element) { + // No need to update if this preference was never added to the table. + return; + } + + let className; + if (this.hidden) { + className = "hidden"; + } else { + className = + (this.hasUserValue ? "has-user-value " : "") + + (this.isLocked ? "locked " : "") + + (this.exists ? "" : "deleted ") + + (this.isAddRow ? "add " : "") + + (this.odd ? "odd " : ""); + } + + if (this._lastClassName !== className) { + this._element.className = this._lastClassName = className; + } + } + + edit() { + if (gPrefInEdit) { + gPrefInEdit.endEdit(); + } + gPrefInEdit = this; + this.editing = true; + this.refreshElement(); + // The type=number input isn't selected unless it's focused first. + this.inputField.focus(); + this.inputField.select(); + } + + toggle() { + Services.prefs.setBoolPref(this.name, !this.value); + } + + editOrToggle() { + if (this.type == "Boolean") { + this.toggle(); + } else { + this.edit(); + } + } + + save() { + if (this.type == "Number") { + if (!this.inputField.reportValidity()) { + return; + } + Services.prefs.setIntPref(this.name, parseInt(this.inputField.value)); + } else { + Services.prefs.setStringPref(this.name, this.inputField.value); + } + this.refreshValue(); + this.endEdit(); + this.editButton.focus(); + } + + endEdit() { + this.editing = false; + this.refreshElement(); + gPrefInEdit = null; + } +} + +let gPrefObserverRegistered = false; +let gPrefObserver = { + observe(subject, topic, data) { + let pref = gExistingPrefs.get(data) || gDeletedPrefs.get(data); + if (pref) { + pref.refreshValue(); + if (!pref.editing) { + pref.refreshElement(); + } + return; + } + + let newPref = new PrefRow(data); + if (newPref.matchesFilter) { + document.getElementById("prefs").appendChild(newPref.getElement()); + } + }, +}; + +if (!Preferences.get("browser.aboutConfig.showWarning")) { + // When showing the filtered preferences directly, remove the warning elements + // immediately to prevent flickering, but wait to filter the preferences until + // the value of the textbox has been restored from previous sessions. + document.addEventListener("DOMContentLoaded", loadPrefs, { once: true }); + window.addEventListener( + "load", + () => { + if (document.getElementById("about-config-search").value) { + filterPrefs(); + } + }, + { once: true } + ); +} else { + document.addEventListener("DOMContentLoaded", function () { + let warningButton = document.getElementById("warningButton"); + warningButton.addEventListener("click", onWarningButtonClick); + warningButton.focus({ focusVisible: false }); + }); +} + +function onWarningButtonClick() { + Services.prefs.setBoolPref( + "browser.aboutConfig.showWarning", + document.getElementById("showWarningNextTime").checked + ); + loadPrefs(); +} + +function loadPrefs() { + [...document.styleSheets].find(s => s.title == "infop").disabled = true; + + let { content } = document.getElementById("main"); + document.body.textContent = ""; + document.body.appendChild(content); + + let search = (gSearchInput = document.getElementById("about-config-search")); + let prefs = (gPrefsTable = document.getElementById("prefs")); + let showAll = document.getElementById("show-all"); + gShowOnlyModifiedCheckbox = document.getElementById( + "about-config-show-only-modified" + ); + search.focus(); + gShowOnlyModifiedCheckbox.checked = false; + + for (let name of Services.prefs.getChildList("")) { + new PrefRow(name); + } + + search.addEventListener("keypress", event => { + if (event.key == "Escape") { + // The ESC key returns immediately to the initial empty page. + search.value = ""; + gFilterPrefsTask.disarm(); + filterPrefs(); + } else if (event.key == "Enter") { + // The Enter key filters immediately even if the search string is short. + gFilterPrefsTask.disarm(); + filterPrefs({ shortString: true }); + } + }); + + search.addEventListener("input", () => { + // We call "disarm" to restart the timer at every input. + gFilterPrefsTask.disarm(); + if (search.value.trim().length < SEARCH_AUTO_MIN_CRARACTERS) { + // Return immediately to the empty page if the search string is short. + filterPrefs(); + } else { + gFilterPrefsTask.arm(); + } + }); + + gShowOnlyModifiedCheckbox.addEventListener("change", () => { + // This checkbox: + // - Filters results to only modified prefs when search query is entered + // - Shows all modified prefs, in show all mode, and after initial checkbox click + let tableHidden = !document.body.classList.contains("table-shown"); + filterPrefs({ + showAll: + gFilterShowAll || (gShowOnlyModifiedCheckbox.checked && tableHidden), + }); + }); + + showAll.addEventListener("click", event => { + search.focus(); + search.value = ""; + gFilterPrefsTask.disarm(); + filterPrefs({ showAll: true }); + }); + + function shouldBeginEdit(event) { + if ( + event.target.localName != "button" && + event.target.localName != "input" + ) { + let row = event.target.closest("tr"); + return row && row._pref.exists; + } + return false; + } + + // Disable double/triple-click text selection since that triggers edit/toggle. + prefs.addEventListener("mousedown", event => { + if (event.detail > 1 && shouldBeginEdit(event)) { + event.preventDefault(); + } + }); + + prefs.addEventListener("click", event => { + if (event.detail == 2 && shouldBeginEdit(event)) { + event.target.closest("tr")._pref.editOrToggle(); + return; + } + + if (event.target.localName != "button") { + return; + } + + let pref = event.target.closest("tr")._pref; + let button = event.target.closest("button"); + + if (button.classList.contains("button-add")) { + pref.isAddRow = false; + Preferences.set(pref.name, pref.value); + if (pref.type == "Boolean") { + pref.refreshClass(); + } else { + pref.edit(); + } + } else if ( + button.classList.contains("button-toggle") || + button.classList.contains("button-edit") + ) { + pref.editOrToggle(); + } else if (button.classList.contains("button-save")) { + pref.save(); + } else { + // This is "button-reset" or "button-delete". + pref.editing = false; + Services.prefs.clearUserPref(pref.name); + pref.editButton.focus(); + } + }); + + window.addEventListener("keypress", event => { + if (event.target != search && event.key == "Escape" && gPrefInEdit) { + gPrefInEdit.endEdit(); + } + }); +} + +function filterPrefs(options = {}) { + if (gPrefInEdit) { + gPrefInEdit.endEdit(); + } + gDeletedPrefs.clear(); + + let searchName = gSearchInput.value.trim(); + if (searchName.length < SEARCH_AUTO_MIN_CRARACTERS && !options.shortString) { + searchName = ""; + } + + gFilterString = searchName.toLowerCase(); + gFilterShowAll = !!options.showAll; + + gFilterPattern = null; + if (gFilterString.includes("*")) { + gFilterPattern = new RegExp(gFilterString.replace(/\*+/g, ".*"), "i"); + gFilterString = ""; + } + + let showResults = gFilterString || gFilterPattern || gFilterShowAll; + document.body.classList.toggle("table-shown", showResults); + + let prefArray = []; + if (showResults) { + if (!gSortedExistingPrefs) { + gSortedExistingPrefs = [...gExistingPrefs.values()]; + gSortedExistingPrefs.sort((a, b) => a.name > b.name); + } + prefArray = gSortedExistingPrefs; + } + + // The slowest operations tend to be the addition and removal of DOM nodes, so + // this algorithm tries to reduce removals by hiding nodes instead. This + // happens frequently when the set narrows while typing preference names. We + // iterate the nodes already in the table in parallel to those we want to + // show, because the two lists are sorted and they will often match already. + let fragment = null; + let indexInArray = 0; + let elementInTable = gPrefsTable.firstElementChild; + let odd = false; + let hasVisiblePrefs = false; + while (indexInArray < prefArray.length || elementInTable) { + // For efficiency, filter the array while we are iterating. + let prefInArray = prefArray[indexInArray]; + if (prefInArray) { + if (!prefInArray.matchesFilter) { + indexInArray++; + continue; + } + prefInArray.hidden = false; + prefInArray.odd = odd; + } + + let prefInTable = elementInTable && elementInTable._pref; + if (!prefInTable) { + // We're at the end of the table, we just have to insert all the matching + // elements that remain in the array. We can use a fragment to make the + // insertions faster, which is useful during the initial filtering. + if (!fragment) { + fragment = document.createDocumentFragment(); + } + fragment.appendChild(prefInArray.getElement()); + } else if (prefInTable == prefInArray) { + // We got two matching elements, we just need to update the visibility. + elementInTable = elementInTable.nextElementSibling; + } else if (prefInArray && prefInArray.name < prefInTable.name) { + // The iteration in the table is ahead of the iteration in the array. + // Insert or move the array element, and advance the array index. + gPrefsTable.insertBefore(prefInArray.getElement(), elementInTable); + } else { + // The iteration in the array is ahead of the iteration in the table. + // Hide the element in the table, and advance to the next element. + let nextElementInTable = elementInTable.nextElementSibling; + if (!prefInTable.exists) { + // Remove rows for deleted preferences, or temporary addition rows. + elementInTable.remove(); + } else { + // Keep the element for the next filtering if the preference exists. + prefInTable.hidden = true; + prefInTable.refreshClass(); + } + elementInTable = nextElementInTable; + continue; + } + + prefInArray.refreshClass(); + odd = !odd; + indexInArray++; + hasVisiblePrefs = true; + } + + if (fragment) { + gPrefsTable.appendChild(fragment); + } + + gPrefsTable.toggleAttribute("has-visible-prefs", hasVisiblePrefs); + + if (searchName && !gExistingPrefs.has(searchName)) { + let addPrefRow = new PrefRow(searchName, { isAddRow: true }); + addPrefRow.odd = odd; + gPrefsTable.appendChild(addPrefRow.getElement()); + } + + // We only start observing preference changes after the first search is done, + // so that newly added preferences won't appear while the page is still empty. + if (!gPrefObserverRegistered) { + gPrefObserverRegistered = true; + Services.prefs.addObserver("", gPrefObserver); + window.addEventListener( + "unload", + () => { + Services.prefs.removeObserver("", gPrefObserver); + }, + { once: true } + ); + } +} diff --git a/toolkit/components/aboutconfig/content/background.svg b/toolkit/components/aboutconfig/content/background.svg new file mode 100644 index 0000000000..d03d081ee5 --- /dev/null +++ b/toolkit/components/aboutconfig/content/background.svg @@ -0,0 +1,4 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg"><linearGradient id="a"><stop offset="0" stop-color="#ccfbff"/><stop offset="1" stop-color="#c9e4ff"/></linearGradient><linearGradient id="b" gradientTransform="matrix(1 0 0 -1 0 320)" gradientUnits="userSpaceOnUse" x1="6.5576" x2="120.5576" href="#a" y1="318.3719" y2="152.8721"/><linearGradient id="c" gradientTransform="matrix(1 0 0 -1 0 320)" gradientUnits="userSpaceOnUse" x1="37.205" x2="151.205" href="#a" y1="339.4831" y2="173.9827"/><linearGradient id="d"><stop offset="0" stop-color="#00c8d7"/><stop offset="1" stop-color="#0a84ff"/></linearGradient><linearGradient id="e" gradientTransform="matrix(1 0 0 -1 0 320)" gradientUnits="userSpaceOnUse" x1="-51.3427" x2="242.3216" href="#d" y1="411.209" y2="56.9062"/><linearGradient id="f" gradientTransform="matrix(1 0 0 -1 0 320)" gradientUnits="userSpaceOnUse" x1="98.7014" x2="116.0054" href="#d" y1="238.5525" y2="212.9205"/><linearGradient id="g" gradientTransform="matrix(1 0 0 -1 0 320)" gradientUnits="userSpaceOnUse" x1="70.9844" x2="164.4848" href="#d" y1="291.4737" y2="152.9739"/><linearGradient id="h" gradientTransform="matrix(1 0 0 -1 0 320)" gradientUnits="userSpaceOnUse" x1="53.6667" x2="192.6669" href="#a" y1="337.889" y2="159.389"/><linearGradient id="i" gradientTransform="matrix(1 0 0 -1 0 320)" gradientUnits="userSpaceOnUse" x1="16.7153" x2="201.7151" href="#d" y1="374.8648" y2="156.3646"/><g fill="#eaeaee"><path d="m174.2 162.1c16.4-3.1 26.8-7.5 26.8-12.5 0-9.3-35.7-16.8-79.8-16.8s-79.8 7.5-79.8 16.8c0 7.8 25.2 14.3 59.3 16.2-1.5 1.2-2.3 2.5-2.3 3.8 0 6.5 19.1 11.7 42.8 11.7s42.8-5.3 42.8-11.7c-.1-2.8-3.7-5.4-9.8-7.5z"/><ellipse cx="203.2" cy="165.4" rx="12.3" ry="4"/><ellipse cx="78.9" cy="171.1" rx="9.5" ry="3.3"/><ellipse cx="207.9" cy="156.9" rx="4.8" ry="2"/><path d="m53.6 93.2h123.4c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1h-123.4c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1zm16.7-5.6h30.9c.3 0 .6-.3.6-.6s-.3-.6-.6-.6h-30.9c-.3 0-.6.3-.6.6s.3.6.6.6zm-38.9 10.5h13.4c.3 0 .6-.3.6-.6s-.3-.6-.6-.6h-13.4c-.3 0-.6.3-.6.6s.3.6.6.6zm22.3-1.1c-.3 0-.6.3-.6.6s.3.6.6.6h3.3c.3 0 .6-.3.6-.6s-.3-.6-.6-.6zm123.1.5c0 .3.3.6.6.6h3.3c.3 0 .6-.3.6-.6s-.3-.5-.6-.5h-3.3c-.3 0-.6.2-.6.5zm8.4.6h1.1c.3 0 .6-.3.6-.6s-.3-.6-.6-.6h-1.1c-.3 0-.6.3-.6.6s.3.6.6.6zm-122.6-1.1h-1.1c-.3 0-.6.3-.6.6s.3.6.6.6h1.1c.3 0 .6-.3.6-.6 0-.4-.3-.6-.6-.6zm133.7 1.1h13.4c.3 0 .6-.3.6-.6s-.3-.5-.6-.5h-13.4c-.3 0-.6.3-.6.6s.3.5.6.5zm-27.3-.6c0-.3-.3-.6-.6-.6h-13.4c-.3 0-.6.3-.6.6s.3.6.6.6h13.4c.4 0 .6-.3.6-.6zm52.5 29.2h-56.7c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h56.7c.6 0 1.1-.5 1.1-1.1s-.5-1.1-1.1-1.1zm-162 0h-50.6c-.6 0-1.1.5-1.1 1.1s.5 1.1 1.1 1.1h50.7c.6 0 1.1-.5 1.1-1.1s-.6-1.1-1.2-1.1z"/></g><path d="m163.4 155.3-12.3-103.2c-.3-2.1-2-3.7-4.2-3.7h-62c-2.4 0-4.1 1.8-4.6 4l-19.3 92.3c-.3 1.2.1 2.5.9 3.5s2 1.5 3.3 1.5h27.1l.8 6.5c.3 2.1 2 3.7 4.2 3.7h62c1.2 0 2.3-.5 3.1-1.4s1.1-2 1-3.2z" fill="#fff"/><path d="m94.7 144.1-10-83.1-17.3 83.1z" fill="url(#b)"/><path d="m86.9 54.2 12 100.2h59l-11.9-100.2z" fill="url(#c)"/><path d="m161.1 155.7-12.3-103.2c-.1-.8-.7-1.3-1.5-1.3h-62c-.9 0-1.6.5-1.8 1.3l-19.4 92.9c-.1.4 0 .9.3 1.3.3.3.7.6 1.2.6h29.4l1.1 8.9c.1.8.7 1.3 1.5 1.3h62c.4 0 .8-.2 1.1-.5.3-.5.5-.9.4-1.3zm-79.3-80.4 8 68.9h-22.4c0-.1 14.4-68.9 14.4-68.9zm17.1 79.1-12-100.2h59l12 100.2z" fill="url(#e)"/><path d="m91 57.7 11.3 92.5h51.9l-11.3-92.5z" fill="#f9f9fa"/><path d="m108.3 91.1c-2.2-.1-4 1.6-4.2 3.8-.1 2.3 1.5 4.2 3.6 4.4.8.1 1.5-.1 2.1-.5.4-1.5 1-3.6 1.6-5.6-.6-1.2-1.8-2-3.1-2.1z" fill="url(#f)"/><path d="m143 105.5-11.9-5.2c-.5-.2-1.2-.2-1.6.2l-5.8 4.8-.1-.1-6.8-6.2 10.4-10.3c.6-.6.7-1.7.1-2.4s-1.6-.7-2.3-.1l-10.1 10c1.2-4.1 3-10.4 4.3-14.8.3-.9-.2-1.8-1.1-2.1-.8-.3-1.7.2-2 1.1 0 0-.7 2.5-1.7 5.6-.4 1.2-.7 2.5-1.1 3.9-2.9 9.9-2.8 10.1-2.8 10.6 0 .3.2.6.3.8l.1.1c.2.3.4.6.6.8l8.2 7.5.2.2.1.1 5.2 4.8-5.5 9.9-1.4 2.6c-.4.8-.2 1.8.6 2.3.3.2.6.3.9.2.5 0 1-.3 1.3-.8l7.6-13.7c.4-.7.3-1.6-.3-2.1l-4.3-4.1.1-.1 1.7-1.4 4.7-3.9 7.8 3.4 3.2 1.4c.2.1.5.1.7.1.6 0 1.1-.4 1.4-1 .5-.8.2-1.8-.7-2.1z" fill="url(#g)"/><path d="m139.2 30h-27.2c-2.4-4.4-8.4-14-15.9-15.4-10-1.9-12 8-12 8s-6.6-17.2-23.4-15c-18.5 2.5-10.2 21.7-10 22.4h-28.3c-.7 0-1.3.6-1.3 1.3s.6 1.3 1.3 1.3h116.8c.7 0 1.3-.6 1.3-1.3s-.6-1.3-1.3-1.3z" fill="#fff"/><path d="m138.4 27.6h-7.3c-.4 0-.7-.3-.7-.7s.3-.7.7-.7h7.3c.4 0 .7.3.7.7s-.4.7-.7.7zm-19.1 0h-1.3c-.4 0-.7-.3-.7-.7s.3-.7.7-.7h1.3c.4 0 .7.3.7.7-.1.4-.4.7-.7.7zm-68.1-.8h-2.1c-.4 0-.7-.3-.7-.7s.3-.7.7-.7h1.1c-.1-.2-.2-.5-.3-.8s.1-.7.4-.8.7.1.8.4c.3 1 .6 1.6.6 1.6.1.2.1.4 0 .6 0 .3-.2.4-.5.4zm-12.6 0h-15.7c-.4 0-.7-.3-.7-.7s.3-.7.7-.7h15.7c.4 0 .7.3.7.7s-.3.7-.7.7zm74.6 0c-.2 0-.5-.1-.6-.3-.4-.8-1.1-1.9-2-3.3-.2-.3-.1-.7.2-.9s.7-.1.9.2c.9 1.4 1.7 2.6 2.1 3.4.2.3.1.7-.3.9-.1-.1-.2 0-.3 0zm-63.8-6.8c-.3 0-.6-.2-.6-.6-.1-.4-.1-.9-.1-1.3s.2-.7.6-.7.7.2.7.6.1.9.1 1.3c0 .3-.3.7-.7.7zm34.1-2.9c-.2 0-.5-.1-.6-.3-.2-.3-.4-.7-.6-1.1-.2-.3-.1-.7.2-.9s.7-.1.9.2c.2.4.5.8.7 1.2.2.3.1.7-.3.9-.1 0-.2 0-.3 0zm20.8-1.6c-.2 0-.3-.1-.4-.2-2.5-2.1-5-3.5-7.4-3.9-2.4-.5-4.5-.2-6.3.7-.3.2-.7 0-.9-.3s0-.7.3-.9c2-1 4.4-1.3 7.1-.8s5.4 1.9 8.1 4.2c.3.2.3.6.1.9-.2.2-.4.3-.6.3zm-24.5-3.7c-.2 0-.4-.1-.5-.2-.9-1-1.8-1.9-2.7-2.7-.3-.2-.3-.6-.1-.9s.6-.3.9-.1c1 .8 2 1.8 2.9 2.8.2.3.2.7-.1.9-.1.1-.3.2-.4.2zm-27-3.9c-.2 0-.4-.1-.5-.2-.3-.3-.3-.7 0-.9 2.1-1.9 5.1-3.1 8.8-3.6 2.3-.3 4.5-.3 6.6.1.4.1.6.4.5.8s-.4.6-.8.5c-2-.3-4.1-.4-6.2-.1-3.5.5-6.2 1.6-8.1 3.3 0 .1-.2.1-.3.1z" fill="#eaeaee"/><path d="m233.4 63.7h-15.4c-1.3-2.5-4.7-7.8-8.9-8.6-5.6-1.1-6.7 4.5-6.7 4.5s-3.7-9.6-13.1-8.4c-10.5 1.4-5.6 12.5-5.6 12.5h-15.8.5-.2c-.7 0-1.3.6-1.3 1.3s.6 1.3 1.3 1.3h65.2c.7 0 1.3-.6 1.3-1.3s-.6-1.3-1.3-1.3z" fill="#fff"/><path d="m184.2 61.9h-15.8c-.4 0-.7-.3-.7-.7s.3-.7.7-.7h15.8c.4 0 .7.3.7.7s-.3.7-.7.7zm48.6-.2h-.6c-.4 0-.7-.3-.7-.7s.3-.7.7-.7h.6c.4 0 .7.3.7.7s-.3.7-.7.7zm-5.8 0h-4c-.4 0-.7-.3-.7-.7s.3-.7.7-.7h3.9c.4 0 .7.3.7.7s-.3.7-.6.7zm-24.2-3.9c-.3 0-.5-.2-.6-.4s-.1-.4.2-1.1c.6-1.8 2.3-4.5 5.8-4.5.5 0 1 0 1.5.1 2 .4 3.9 1.5 5.9 3.4.3.3.3.7 0 .9-.3.3-.7.3-.9 0-1.8-1.7-3.5-2.7-5.2-3-.4-.1-.8-.1-1.2-.1-3.8 0-4.6 3.9-4.7 4.1-.2.4-.5.6-.8.6zm-18.3-5.9c-.1 0-.3-.1-.4-.2-.3-.2-.3-.6-.1-.9.9-1 2.1-1.8 3.6-2.3.3-.1.7.1.8.4s-.1.7-.4.8c-1.3.4-2.3 1-3 1.9-.1.2-.3.3-.5.3zm9.8-2.2c-.1 0-.1 0-.2 0-.4-.1-.8-.2-1.2-.3s-.6-.4-.6-.7.4-.6.7-.6c.5.1.9.2 1.4.3.3.1.6.5.5.8 0 .3-.3.5-.6.5z" fill="#eaeaee"/><path d="m128.8 68.2h-21c-1.7 0-3-1.3-3-3 0-1.6 1.3-3 3-3h21c1.6 0 3 1.3 3 3 0 1.6-1.3 3-3 3z" fill="url(#h)"/><path d="m128.8 68.8h-21c-2 0-3.6-1.6-3.6-3.6s1.6-3.6 3.6-3.6h21c2 0 3.6 1.6 3.6 3.6 0 1.9-1.6 3.6-3.6 3.6zm-21-6c-1.3 0-2.4 1.1-2.4 2.4s1.1 2.4 2.4 2.4h21c1.3 0 2.4-1.1 2.4-2.4s-1.1-2.4-2.4-2.4z" fill="url(#i)"/></svg> diff --git a/toolkit/components/aboutconfig/content/toggle.svg b/toolkit/components/aboutconfig/content/toggle.svg new file mode 100644 index 0000000000..73f45b079e --- /dev/null +++ b/toolkit/components/aboutconfig/content/toggle.svg @@ -0,0 +1,7 @@ +<!-- 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/. --> +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="context-fill"> + <path d="M15 9H.5c-.39.39-.39 1.61 0 2l4.793 4.707a1 1 0 0 0 1.414-1.414L3.414 11H15a1 1 0 0 0 0-2zM1 6.988h14.5c.39-.39.39-1.61 0-2L10.707.28a1 1 0 0 0-1.414 1.414l3.293 3.293H1a1 1 0 0 0 0 2z"/> +</svg> + diff --git a/toolkit/components/aboutconfig/jar.mn b/toolkit/components/aboutconfig/jar.mn new file mode 100644 index 0000000000..400db0e1dc --- /dev/null +++ b/toolkit/components/aboutconfig/jar.mn @@ -0,0 +1,11 @@ +#filter substitution +# 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/. + +toolkit.jar: + content/global/aboutconfig/aboutconfig.css (content/aboutconfig.css) + content/global/aboutconfig/aboutconfig.html (content/aboutconfig.html) + content/global/aboutconfig/aboutconfig.js (content/aboutconfig.js) + content/global/aboutconfig/background.svg (content/background.svg) + content/global/aboutconfig/toggle.svg (content/toggle.svg) diff --git a/toolkit/components/aboutconfig/moz.build b/toolkit/components/aboutconfig/moz.build new file mode 100644 index 0000000000..cfeb1a5e87 --- /dev/null +++ b/toolkit/components/aboutconfig/moz.build @@ -0,0 +1,9 @@ +# -*- 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/browser.toml"] + +JAR_MANIFESTS += ["jar.mn"] diff --git a/toolkit/components/aboutconfig/test/browser/browser.toml b/toolkit/components/aboutconfig/test/browser/browser.toml new file mode 100644 index 0000000000..acccf1f8f0 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser.toml @@ -0,0 +1,27 @@ +[DEFAULT] +skip-if = [ + "debug", # Bug 1507747 + "asan", # Bug 1520398 + "tsan" +] +support-files = ["head.js"] + +["browser_accessibility.js"] + +["browser_basic.js"] + +["browser_clipboard.js"] +fail-if = ["a11y_checks"] # Bug 1854447 th, td.cell-value, #prefs may not be focusable; #show-all may be unlabeled + +["browser_edit.js"] +fail-if = ["a11y_checks"] # Bug 1854447 span, th, td.cell-value may not be focusable; #show-all, input, button-add/delete/reset ghost-buttons may not be labeled +skip-if = ["os == 'linux' && ccov"] # Bug 1613515, the test consistently times out on Linux coverage builds. + +["browser_locked.js"] + +["browser_observe.js"] +skip-if = ["os == 'linux' && ccov"] # Bug 1614978, the test consistently times out on Linux coverage builds. + +["browser_search.js"] + +["browser_warning.js"] diff --git a/toolkit/components/aboutconfig/test/browser/browser_accessibility.js b/toolkit/components/aboutconfig/test/browser/browser_accessibility.js new file mode 100644 index 0000000000..9310e40186 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser_accessibility.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const MAX_PLACEABLE_LENGTH = 2500; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["test.aboutconfig.added", "=".repeat(MAX_PLACEABLE_LENGTH)], + ["test.aboutconfig.long", "=".repeat(MAX_PLACEABLE_LENGTH + 1)], + ], + }); +}); + +add_task(async function test_accessible_value() { + await AboutConfigTest.withNewTab(async function () { + for (let [name, expectHasUserValue] of [ + [PREF_BOOLEAN_DEFAULT_TRUE, false], + [PREF_BOOLEAN_USERVALUE_TRUE, true], + ["test.aboutconfig.added", true], + ]) { + let span = this.getRow(name).valueCell.querySelector("span"); + let expectedL10nId = expectHasUserValue + ? "about-config-pref-accessible-value-custom" + : "about-config-pref-accessible-value-default"; + Assert.equal(span.getAttribute("data-l10n-id"), expectedL10nId); + } + + // If the value is too long for localization, the state is not included. + let span = this.getRow("test.aboutconfig.long").valueCell.querySelector( + "span" + ); + Assert.ok(!span.hasAttribute("data-l10n-id")); + Assert.equal( + span.getAttribute("aria-label"), + Preferences.get("test.aboutconfig.long") + ); + }); +}); diff --git a/toolkit/components/aboutconfig/test/browser/browser_basic.js b/toolkit/components/aboutconfig/test/browser/browser_basic.js new file mode 100644 index 0000000000..014a98df97 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser_basic.js @@ -0,0 +1,52 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "test.aboutconfig.userValueLikeLocalized", + "chrome://test/locale/testing.properties", + ], + ], + }); +}); + +add_task(async function test_load_title() { + await AboutConfigTest.withNewTab(async function () { + Assert.equal(this.document.title, "Advanced Preferences"); + }); +}); + +add_task(async function test_load_settings() { + await AboutConfigTest.withNewTab(async function () { + // Test if page contains elements. + Assert.equal(this.getRow(PREF_NUMBER_DEFAULT_ZERO).value, 0); + Assert.equal(this.getRow(PREF_STRING_DEFAULT_EMPTY).value, ""); + + // Test if the modified state is displayed for the right prefs. + Assert.ok( + !this.getRow(PREF_BOOLEAN_DEFAULT_TRUE).hasClass("has-user-value") + ); + Assert.ok( + this.getRow(PREF_BOOLEAN_USERVALUE_TRUE).hasClass("has-user-value") + ); + + // Test to see if values are localized, sampling from different files. If + // any of these are removed or their value changes, just update the value + // here or point to a different preference in the same file. + Assert.equal(this.getRow("font.language.group").value, "x-western"); + Assert.equal(this.getRow("intl.ellipsis").value, "\u2026"); + + // Test to see if user created value is not empty string when it matches + // /^chrome:\/\/.+\/locale\/.+\.properties/. + Assert.equal( + this.getRow("test.aboutconfig.userValueLikeLocalized").value, + "chrome://test/locale/testing.properties" + ); + + // Test to see if empty string when value matches + // /^chrome:\/\/.+\/locale\/.+\.properties/ and an exception is thrown. + Assert.equal(this.getRow(PREF_STRING_LOCALIZED_MISSING).value, ""); + }); +}); diff --git a/toolkit/components/aboutconfig/test/browser/browser_clipboard.js b/toolkit/components/aboutconfig/test/browser/browser_clipboard.js new file mode 100644 index 0000000000..bcaa2c0328 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser_clipboard.js @@ -0,0 +1,141 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["test.aboutconfig.copy.false", false], + ["test.aboutconfig.copy.number", 10], + ["test.aboutconfig.copy.spaces.1", " "], + ["test.aboutconfig.copy.spaces.2", " "], + ["test.aboutconfig.copy.spaces.3", " "], + ["test.aboutconfig.copy.string", "010.5"], + ], + }); +}); + +add_task(async function test_copy() { + await AboutConfigTest.withNewTab(async function () { + for (let [name, expectedString] of [ + [PREF_BOOLEAN_DEFAULT_TRUE, "true"], + [PREF_BOOLEAN_USERVALUE_TRUE, "true"], + [PREF_STRING_DEFAULT_EMPTY, ""], + ["test.aboutconfig.copy.false", "false"], + ["test.aboutconfig.copy.number", "10"], + ["test.aboutconfig.copy.spaces.1", " "], + ["test.aboutconfig.copy.spaces.2", " "], + ["test.aboutconfig.copy.spaces.3", " "], + ["test.aboutconfig.copy.string", "010.5"], + ]) { + // Limit the number of preferences shown so all the rows are visible. + this.search(name); + let row = this.getRow(name); + + let selectText = async target => { + let { width, height } = target.getBoundingClientRect(); + EventUtils.synthesizeMouse( + target, + 1, + 1, + { type: "mousedown" }, + this.browser.contentWindow + ); + EventUtils.synthesizeMouse( + target, + width - 1, + height - 1, + { type: "mousemove" }, + this.browser.contentWindow + ); + EventUtils.synthesizeMouse( + target, + width - 1, + height - 1, + { type: "mouseup" }, + this.browser.contentWindow + ); + }; + + // Drag across the name cell. + await selectText(row.nameCell); + Assert.ok(row.nameCell.contains(this.window.getSelection().anchorNode)); + await SimpleTest.promiseClipboardChange(name, async () => { + await BrowserTestUtils.synthesizeKey( + "c", + { accelKey: true }, + this.browser + ); + }); + + // Drag across the value cell. + await selectText(row.valueCell); + let selection = this.window.getSelection(); + Assert.ok(row.valueCell.contains(selection.anchorNode)); + + if (expectedString !== "") { + // Non-empty values should have a selection. + Assert.ok(!selection.isCollapsed); + await SimpleTest.promiseClipboardChange(expectedString, async () => { + await BrowserTestUtils.synthesizeKey( + "c", + { accelKey: true }, + this.browser + ); + }); + } else { + // Nothing is selected for an empty value. + Assert.equal(selection.toString(), ""); + } + } + }); +}); + +add_task(async function test_copy_multiple() { + await AboutConfigTest.withNewTab(async function () { + // Lines are separated by a single LF character on all platforms. + let expectedString = + "test.aboutconfig.copy.false\tfalse\t\n" + + "test.aboutconfig.copy.number\t10\t\n" + + "test.aboutconfig.copy.spaces.1\t \t\n" + + "test.aboutconfig.copy.spaces.2\t \t\n" + + "test.aboutconfig.copy.spaces.3\t \t\n" + + "test.aboutconfig.copy.string\t010.5"; + + this.search("test.aboutconfig.copy."); + let startRow = this.getRow("test.aboutconfig.copy.false"); + let endRow = this.getRow("test.aboutconfig.copy.string"); + let { width, height } = endRow.valueCell.getBoundingClientRect(); + + // Drag from the top left of the first row to the bottom right of the last. + EventUtils.synthesizeMouse( + startRow.nameCell, + 1, + 1, + { type: "mousedown" }, + this.browser.contentWindow + ); + + EventUtils.synthesizeMouse( + endRow.valueCell, + width - 1, + height - 1, + { type: "mousemove" }, + this.browser.contentWindow + ); + EventUtils.synthesizeMouse( + endRow.valueCell, + width - 1, + height - 1, + { type: "mouseup" }, + this.browser.contentWindow + ); + + await SimpleTest.promiseClipboardChange(expectedString, async () => { + await BrowserTestUtils.synthesizeKey( + "c", + { accelKey: true }, + this.browser + ); + }); + }); +}); diff --git a/toolkit/components/aboutconfig/test/browser/browser_edit.js b/toolkit/components/aboutconfig/test/browser/browser_edit.js new file mode 100644 index 0000000000..9d10fb1e75 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser_edit.js @@ -0,0 +1,430 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const PREF_MODIFY_PREFIX = "test.aboutconfig.modify"; +const PREF_MODIFY_BOOLEAN = "test.aboutconfig.modify.boolean"; +const PREF_MODIFY_NUMBER = "test.aboutconfig.modify.number"; +const PREF_MODIFY_STRING = "test.aboutconfig.modify.string"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_MODIFY_BOOLEAN, true], + [PREF_MODIFY_NUMBER, 1337], + [ + PREF_MODIFY_STRING, + "the answer to the life the universe and everything", + ], + ], + }); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_BOOLEAN_DEFAULT_TRUE); + Services.prefs.clearUserPref(PREF_NUMBER_DEFAULT_ZERO); + Services.prefs.clearUserPref(PREF_STRING_DEFAULT_EMPTY); + }); +}); + +add_task(async function test_add_user_pref() { + Assert.equal( + Services.prefs.getPrefType(PREF_NEW), + Ci.nsIPrefBranch.PREF_INVALID + ); + + await AboutConfigTest.withNewTab(async function () { + // The row for a new preference appears when searching for its name. + Assert.ok(!this.getRow(PREF_NEW)); + + for (let [radioIndex, expectedValue, expectedEditingMode] of [ + [0, true, false], + [1, 0, true], + [2, "", true], + ]) { + this.search(PREF_NEW); + let row = this.getRow(PREF_NEW); + Assert.ok(row.hasClass("deleted")); + Assert.ok(row.hasClass("add")); + + // Adding the preference should set the default for the data type. + row.element.querySelectorAll("input")[radioIndex].click(); + row.editColumnButton.click(); + Assert.ok(!row.hasClass("deleted")); + Assert.ok(!row.hasClass("add")); + Assert.ok(Preferences.get(PREF_NEW) === expectedValue); + + // Number and String preferences should be in edit mode. + Assert.equal(!!row.valueInput, expectedEditingMode); + + // Repeat the search to verify that the preference remains. + this.search(PREF_NEW); + row = this.getRow(PREF_NEW); + Assert.ok(!row.hasClass("deleted")); + Assert.ok(!row.hasClass("add")); + Assert.ok(!row.valueInput); + + // Reset the preference, then continue by adding a different type. + row.resetColumnButton.click(); + Assert.equal( + Services.prefs.getPrefType(PREF_NEW), + Ci.nsIPrefBranch.PREF_INVALID + ); + } + }); +}); + +add_task(async function test_delete_user_pref() { + for (let [radioIndex, testValue] of [ + [0, false], + [1, -1], + [2, "value"], + ]) { + Preferences.set(PREF_NEW, testValue); + await AboutConfigTest.withNewTab(async function () { + // Deleting the preference should keep the row. + let row = this.getRow(PREF_NEW); + row.resetColumnButton.click(); + Assert.ok(row.hasClass("deleted")); + Assert.equal( + Services.prefs.getPrefType(PREF_NEW), + Ci.nsIPrefBranch.PREF_INVALID + ); + + // Re-adding the preference should keep the same value. + Assert.ok(row.element.querySelectorAll("input")[radioIndex].checked); + row.editColumnButton.click(); + Assert.ok(!row.hasClass("deleted")); + Assert.ok(Preferences.get(PREF_NEW) === testValue); + + // Filtering again after deleting should remove the row. + row.resetColumnButton.click(); + this.showAll(); + Assert.ok(!this.getRow(PREF_NEW)); + }); + } +}); + +add_task(async function test_click_type_label_multiple_forms() { + // This test displays the row to add a preference while other preferences are + // also displayed, and tries to select the type of the new preference by + // clicking the label next to the radio button. This should work even if the + // user has deleted a different preference, and multiple forms are displayed. + const PREF_TO_DELETE = "test.aboutconfig.modify.boolean"; + const PREF_NEW_WHILE_DELETED = "test.aboutconfig.modify."; + + await AboutConfigTest.withNewTab(async function () { + this.search(PREF_NEW_WHILE_DELETED); + + // This preference will remain deleted during the test. + let existingRow = this.getRow(PREF_TO_DELETE); + existingRow.resetColumnButton.click(); + + let newRow = this.getRow(PREF_NEW_WHILE_DELETED); + + for (let [radioIndex, expectedValue] of [ + [0, true], + [1, 0], + [2, ""], + ]) { + let radioLabels = newRow.element.querySelectorAll("label > span"); + await this.document.l10n.translateElements(radioLabels); + + // Even if this is the second form on the page, the click should select + // the radio button next to the label, not the one on the first form. + EventUtils.synthesizeMouseAtCenter( + radioLabels[radioIndex], + {}, + this.browser.contentWindow + ); + + // Adding the preference should set the default for the data type. + newRow.editColumnButton.click(); + Assert.ok(Preferences.get(PREF_NEW_WHILE_DELETED) === expectedValue); + + // Reset the preference, then continue by adding a different type. + newRow.resetColumnButton.click(); + } + + // Re-adding the deleted preference should restore the value. + existingRow.editColumnButton.click(); + Assert.ok(Preferences.get(PREF_TO_DELETE) === true); + }); +}); + +add_task(async function test_reset_user_pref() { + await SpecialPowers.pushPrefEnv({ + set: [ + [PREF_BOOLEAN_DEFAULT_TRUE, false], + [PREF_STRING_LOCALIZED_MISSING, "user-value"], + ], + }); + + await AboutConfigTest.withNewTab(async function () { + // Click reset. + let row = this.getRow(PREF_BOOLEAN_DEFAULT_TRUE); + row.resetColumnButton.click(); + // Check new layout and reset. + Assert.ok(!row.hasClass("has-user-value")); + Assert.ok(!row.resetColumnButton); + Assert.ok(!Services.prefs.prefHasUserValue(PREF_BOOLEAN_DEFAULT_TRUE)); + Assert.equal(this.getRow(PREF_BOOLEAN_DEFAULT_TRUE).value, "true"); + + // Filter again to test the preference cache. + this.showAll(); + row = this.getRow(PREF_BOOLEAN_DEFAULT_TRUE); + Assert.ok(!row.hasClass("has-user-value")); + Assert.ok(!row.resetColumnButton); + Assert.equal(this.getRow(PREF_BOOLEAN_DEFAULT_TRUE).value, "true"); + + // Clicking reset on a localized preference without a corresponding value. + row = this.getRow(PREF_STRING_LOCALIZED_MISSING); + Assert.equal(row.value, "user-value"); + row.resetColumnButton.click(); + // Check new layout and reset. + Assert.ok(!row.hasClass("has-user-value")); + Assert.ok(!row.resetColumnButton); + Assert.ok(!Services.prefs.prefHasUserValue(PREF_STRING_LOCALIZED_MISSING)); + Assert.equal(this.getRow(PREF_STRING_LOCALIZED_MISSING).value, ""); + }); +}); + +add_task(async function test_modify() { + await AboutConfigTest.withNewTab(async function () { + // Test toggle for boolean prefs. + for (let nameOfBoolPref of [ + PREF_MODIFY_BOOLEAN, + PREF_BOOLEAN_DEFAULT_TRUE, + ]) { + let row = this.getRow(nameOfBoolPref); + // Do this a two times to reset the pref. + for (let i = 0; i < 2; i++) { + row.editColumnButton.click(); + // Check new layout and saving in backend. + Assert.equal( + this.getRow(nameOfBoolPref).value, + "" + Preferences.get(nameOfBoolPref) + ); + let prefHasUserValue = Services.prefs.prefHasUserValue(nameOfBoolPref); + Assert.equal(row.hasClass("has-user-value"), prefHasUserValue); + Assert.equal(!!row.resetColumnButton, prefHasUserValue); + } + } + + // Test abort of edit by starting with string and continuing with editing Int pref. + let row = this.getRow(PREF_MODIFY_STRING); + row.editColumnButton.click(); + row.valueInput.value = "test"; + let intRow = this.getRow(PREF_MODIFY_NUMBER); + intRow.editColumnButton.click(); + Assert.equal(intRow.valueInput.value, Preferences.get(PREF_MODIFY_NUMBER)); + Assert.ok(!row.valueInput); + Assert.equal(row.value, Preferences.get(PREF_MODIFY_STRING)); + + // Test validation of integer values. + for (let invalidValue of [ + "", + " ", + "a", + "1.5", + "-2147483649", + "2147483648", + ]) { + intRow.valueInput.value = invalidValue; + intRow.editColumnButton.click(); + // We should still be in edit mode. + Assert.ok(intRow.valueInput); + } + + // Test correct saving and DOM-update. + for (let [prefName, willDelete] of [ + [PREF_MODIFY_STRING, true], + [PREF_MODIFY_NUMBER, true], + [PREF_NUMBER_DEFAULT_ZERO, false], + [PREF_STRING_DEFAULT_EMPTY, false], + ]) { + row = this.getRow(prefName); + // Activate edit and check displaying. + row.editColumnButton.click(); + Assert.equal(row.valueInput.value, Preferences.get(prefName)); + row.valueInput.value = "42"; + // Save and check saving. + row.editColumnButton.click(); + Assert.equal(Preferences.get(prefName), "42"); + Assert.equal(row.value, "42"); + Assert.ok(row.hasClass("has-user-value")); + // Reset or delete the preference while editing. + row.editColumnButton.click(); + Assert.equal(row.valueInput.value, Preferences.get(prefName)); + row.resetColumnButton.click(); + Assert.ok(!row.hasClass("has-user-value")); + Assert.equal(row.hasClass("deleted"), willDelete); + } + }); + + // This test would have opened the invalid form popup, so just close it so as not to + // affect later tests. + let invalidFormPopup = window.document.getElementById("invalid-form-popup"); + invalidFormPopup.hidePopup(); + await BrowserTestUtils.waitForCondition(() => { + return invalidFormPopup.state == "closed"; + }, "form validation popup closed"); +}); + +add_task(async function test_edit_field_selected() { + let prefsToCheck = [ + [PREF_MODIFY_STRING, "A string", "A new string"], + [PREF_MODIFY_NUMBER, "100", "500"], + ]; + await AboutConfigTest.withNewTab(async function () { + for (let [prefName, startValue, endValue] of prefsToCheck) { + Preferences.set(prefName, startValue); + let row = this.getRow(prefName); + + Assert.equal(row.value, startValue); + row.editColumnButton.click(); + Assert.equal(row.valueInput.value, startValue); + + EventUtils.sendString(endValue, this.window); + + row.editColumnButton.click(); + Assert.equal(row.value, endValue); + Assert.equal(Preferences.get(prefName), endValue); + } + }); +}); + +add_task(async function test_escape_cancels_edit() { + await AboutConfigTest.withNewTab(async function () { + let row = this.getRow(PREF_MODIFY_STRING); + Preferences.set(PREF_MODIFY_STRING, "Edit me, maybe"); + + for (let blurInput of [false, true]) { + Assert.ok(!row.valueInput); + row.editColumnButton.click(); + + Assert.ok(row.valueInput); + + Assert.equal(row.valueInput.value, "Edit me, maybe"); + row.valueInput.value = "Edited"; + + // Test both cases of the input being focused and not being focused. + if (blurInput) { + row.valueInput.blur(); + Assert.notEqual(this.document.activeElement, row.valueInput); + } else { + Assert.equal(this.document.activeElement, row.valueInput); + } + + EventUtils.synthesizeKey("KEY_Escape", {}, this.window); + + Assert.ok(!row.valueInput); + Assert.equal(row.value, "Edit me, maybe"); + Assert.equal(row.value, Preferences.get(PREF_MODIFY_STRING)); + } + }); +}); + +add_task(async function test_double_click_modify() { + Preferences.set(PREF_MODIFY_BOOLEAN, true); + Preferences.set(PREF_MODIFY_NUMBER, 10); + Preferences.set(PREF_MODIFY_STRING, "Hello!"); + + await AboutConfigTest.withNewTab(async function () { + this.search(PREF_MODIFY_PREFIX); + + let click = (target, opts) => + EventUtils.synthesizeMouseAtCenter(target, opts, this.window); + let doubleClick = target => { + // Trigger two mouse events to simulate the first then second click. + click(target, { clickCount: 1 }); + click(target, { clickCount: 2 }); + }; + let tripleClick = target => { + // Trigger all 3 mouse events to simulate the three mouse events we'd see. + click(target, { clickCount: 1 }); + click(target, { clickCount: 2 }); + click(target, { clickCount: 3 }); + }; + + // Check double-click to edit a boolean. + let boolRow = this.getRow(PREF_MODIFY_BOOLEAN); + Assert.equal(boolRow.value, "true"); + doubleClick(boolRow.valueCell); + Assert.equal(boolRow.value, "false"); + doubleClick(boolRow.nameCell); + Assert.equal(boolRow.value, "true"); + + // Check double-click to edit a number. + let intRow = this.getRow(PREF_MODIFY_NUMBER); + Assert.equal(intRow.value, 10); + doubleClick(intRow.valueCell); + Assert.equal(this.document.activeElement, intRow.valueInput); + EventUtils.sendString("75"); + EventUtils.synthesizeKey("KEY_Enter"); + Assert.equal(intRow.value, 75); + + // Check double-click focuses input when already editing. + Assert.equal(intRow.value, 75); + doubleClick(intRow.nameCell); + Assert.equal(this.document.activeElement, intRow.valueInput); + intRow.valueInput.blur(); + Assert.notEqual(this.document.activeElement, intRow.valueInput); + doubleClick(intRow.nameCell); + Assert.equal(this.document.activeElement, intRow.valueInput); + EventUtils.sendString("20"); + EventUtils.synthesizeKey("KEY_Enter"); + Assert.equal(intRow.value, 20); + + // Check double-click to edit a string. + let stringRow = this.getRow(PREF_MODIFY_STRING); + Assert.equal(stringRow.value, "Hello!"); + doubleClick(stringRow.valueCell); + Assert.equal( + this.document.activeElement, + stringRow.valueInput, + "The input is focused" + ); + EventUtils.sendString("New String!"); + EventUtils.synthesizeKey("KEY_Enter"); + Assert.equal(stringRow.value, "New String!"); + + // Check triple-click also edits the pref and selects the text inside. + tripleClick(stringRow.nameCell); + Assert.equal( + this.document.activeElement, + stringRow.valueInput, + "The input is focused" + ); + + // Check double-click inside input selects a word. + let newString = "Another string..."; + EventUtils.sendString(newString); + Assert.equal(this.window.getSelection().toString(), ""); + let stringInput = stringRow.valueInput; + doubleClick(stringInput); + let selectionLength = stringInput.selectionEnd - stringInput.selectionStart; + Assert.greater(selectionLength, 0); + Assert.less(selectionLength, newString.length); + EventUtils.synthesizeKey("KEY_Enter"); + Assert.equal(stringRow.value, newString); + + // Check that double/triple-click on the add row selects text as usual. + let addRow = this.getRow(PREF_MODIFY_PREFIX); + Assert.ok(addRow.hasClass("deleted")); + doubleClick(addRow.nameCell); + Assert.ok(PREF_MODIFY_PREFIX.includes(this.window.getSelection())); + tripleClick(addRow.nameCell); + Assert.equal(this.window.getSelection().toString(), PREF_MODIFY_PREFIX); + // Make sure the localized text is set in the value cell. + let labels = Array.from(addRow.valueCell.querySelectorAll("label > span")); + await this.document.l10n.translateElements(labels); + Assert.ok(labels.every(label => !!label.textContent)); + // Double-click the first input label text. + doubleClick(labels[0]); + Assert.equal(this.window.getSelection().toString(), labels[0].textContent); + tripleClick(addRow.valueCell.querySelector("label > span")); + Assert.equal( + this.window.getSelection().toString(), + labels.map(l => l.textContent).join("") + ); + }); +}); diff --git a/toolkit/components/aboutconfig/test/browser/browser_locked.js b/toolkit/components/aboutconfig/test/browser/browser_locked.js new file mode 100644 index 0000000000..6b06f22218 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser_locked.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const PREF_STRING_NO_DEFAULT = "test.aboutconfig.a"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_STRING_NO_DEFAULT, "some value"]], + }); +}); + +add_task(async function test_locked() { + registerCleanupFunction(() => { + Services.prefs.unlockPref(PREF_STRING_DEFAULT_NOTEMPTY); + Services.prefs.unlockPref(PREF_BOOLEAN_DEFAULT_TRUE); + Services.prefs.unlockPref(PREF_STRING_NO_DEFAULT); + }); + + Services.prefs.lockPref(PREF_STRING_DEFAULT_NOTEMPTY); + Services.prefs.lockPref(PREF_BOOLEAN_DEFAULT_TRUE); + Services.prefs.lockPref(PREF_STRING_NO_DEFAULT); + + await AboutConfigTest.withNewTab(async function () { + // Test locked default string pref. + let lockedPref = this.getRow(PREF_STRING_DEFAULT_NOTEMPTY); + Assert.ok(lockedPref.hasClass("locked")); + Assert.equal(lockedPref.value, PREF_STRING_DEFAULT_NOTEMPTY_VALUE); + Assert.ok(lockedPref.editColumnButton.classList.contains("button-edit")); + Assert.ok(lockedPref.editColumnButton.disabled); + + // Test locked default boolean pref. + lockedPref = this.getRow(PREF_BOOLEAN_DEFAULT_TRUE); + Assert.ok(lockedPref.hasClass("locked")); + Assert.equal(lockedPref.value, "true"); + Assert.ok(lockedPref.editColumnButton.classList.contains("button-toggle")); + Assert.ok(lockedPref.editColumnButton.disabled); + + // Test locked user added pref. + lockedPref = this.getRow(PREF_STRING_NO_DEFAULT); + Assert.ok(lockedPref.hasClass("locked")); + Assert.equal(lockedPref.value, ""); + Assert.ok(lockedPref.editColumnButton.classList.contains("button-edit")); + Assert.ok(lockedPref.editColumnButton.disabled); + + // Test pref not locked. + let unlockedPref = this.getRow(PREF_BOOLEAN_USERVALUE_TRUE); + Assert.ok(!unlockedPref.hasClass("locked")); + Assert.equal(unlockedPref.value, "true"); + Assert.ok( + unlockedPref.editColumnButton.classList.contains("button-toggle") + ); + Assert.ok(!unlockedPref.editColumnButton.disabled); + }); +}); diff --git a/toolkit/components/aboutconfig/test/browser/browser_observe.js b/toolkit/components/aboutconfig/test/browser/browser_observe.js new file mode 100644 index 0000000000..1f1ab5d217 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser_observe.js @@ -0,0 +1,163 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["test.aboutconfig.modify.boolean", true], + ["test.aboutconfig.modify.number", 1337], + [ + "test.aboutconfig.modify.string", + "the answer to the life the universe and everything", + ], + ], + }); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_BOOLEAN_DEFAULT_TRUE); + Services.prefs.clearUserPref(PREF_NUMBER_DEFAULT_ZERO); + Services.prefs.clearUserPref(PREF_STRING_DEFAULT_EMPTY); + }); +}); + +add_task(async function test_observe_add_user_pref_before_search() { + Assert.equal( + Services.prefs.getPrefType(PREF_NEW), + Ci.nsIPrefBranch.PREF_INVALID + ); + + await AboutConfigTest.withNewTab( + async function () { + this.bypassWarningButton.click(); + + // No results are shown after the warning page is dismissed or bypassed, + // and newly added preferences should not be displayed. + Preferences.set(PREF_NEW, true); + Assert.ok(!this.prefsTable.firstElementChild); + Preferences.reset(PREF_NEW); + }, + { dontBypassWarning: true } + ); +}); + +add_task(async function test_observe_add_user_pref() { + Assert.equal( + Services.prefs.getPrefType(PREF_NEW), + Ci.nsIPrefBranch.PREF_INVALID + ); + + await AboutConfigTest.withNewTab(async function () { + for (let value of [false, true, "", "value", 0, -10]) { + // A row should be added when a new preference is added. + Assert.ok(!this.getRow(PREF_NEW)); + Preferences.set(PREF_NEW, value); + let row = this.getRow(PREF_NEW); + Assert.equal(row.value, "" + value); + + // The row should stay when the preference is removed. + Preferences.reset(PREF_NEW); + Assert.ok(row.hasClass("deleted")); + + // Re-adding the preference from the interface should restore its value. + row.editColumnButton.click(); + if (value.constructor.name != "Boolean") { + row.editColumnButton.click(); + } + Assert.equal(row.value, "" + value); + Assert.ok(Preferences.get(PREF_NEW) === value); + + // Filtering again after deleting should remove the row. + Preferences.reset(PREF_NEW); + this.showAll(); + Assert.ok(!this.getRow(PREF_NEW)); + + // Searching for the preference name should give the ability to add it. + Preferences.reset(PREF_NEW); + this.search(PREF_NEW); + row = this.getRow(PREF_NEW); + Assert.ok(row.hasClass("deleted")); + + // The row for adding should be reused if the new preference is added. + Preferences.set(PREF_NEW, value); + Assert.equal(row.value, "" + value); + + // If a new preference does not match the filter it is not displayed. + Preferences.reset(PREF_NEW); + this.search(PREF_NEW + ".extra"); + Assert.ok(!this.getRow(PREF_NEW)); + Preferences.set(PREF_NEW, value); + Assert.ok(!this.getRow(PREF_NEW)); + + // Resetting the filter should display the new preference. + this.showAll(); + Assert.equal(this.getRow(PREF_NEW).value, "" + value); + + // Reset the preference, then continue by adding a different value. + Preferences.reset(PREF_NEW); + this.showAll(); + } + }); +}); + +add_task(async function test_observe_delete_user_pref() { + for (let value of [true, "value", -10]) { + Preferences.set(PREF_NEW, value); + await AboutConfigTest.withNewTab(async function () { + // Deleting the preference should keep the row. + let row = this.getRow(PREF_NEW); + Preferences.reset(PREF_NEW); + Assert.ok(row.hasClass("deleted")); + + // Filtering again should remove the row. + this.showAll(); + Assert.ok(!this.getRow(PREF_NEW)); + }); + } +}); + +add_task(async function test_observe_reset_user_pref() { + await SpecialPowers.pushPrefEnv({ + set: [[PREF_BOOLEAN_DEFAULT_TRUE, false]], + }); + + await AboutConfigTest.withNewTab(async function () { + let row = this.getRow(PREF_BOOLEAN_DEFAULT_TRUE); + Preferences.reset(PREF_BOOLEAN_DEFAULT_TRUE); + Assert.ok(!row.hasClass("has-user-value")); + Assert.equal(row.value, "true"); + }); +}); + +add_task(async function test_observe_modify() { + await AboutConfigTest.withNewTab(async function () { + for (let [name, value] of [ + ["test.aboutconfig.modify.boolean", false], + ["test.aboutconfig.modify.number", -10], + ["test.aboutconfig.modify.string", "value"], + [PREF_BOOLEAN_DEFAULT_TRUE, false], + [PREF_NUMBER_DEFAULT_ZERO, 1], + [PREF_STRING_DEFAULT_EMPTY, "string"], + ]) { + let row = this.getRow(name); + Assert.notEqual(row.value, "" + value); + Preferences.set(name, value); + Assert.equal(row.value, "" + value); + + if (value.constructor.name == "Boolean") { + continue; + } + + // Changing the value or removing while editing should not take effect. + row.editColumnButton.click(); + row.valueInput.value = "42"; + Preferences.reset(name); + Assert.equal(row.element, this.getRow(name).element); + Assert.equal(row.valueInput.value, "42"); + + // Saving should store the value even if the preference was modified. + row.editColumnButton.click(); + Assert.equal(row.value, "42"); + Assert.equal(Preferences.get(name), "42"); + } + }); +}); diff --git a/toolkit/components/aboutconfig/test/browser/browser_search.js b/toolkit/components/aboutconfig/test/browser/browser_search.js new file mode 100644 index 0000000000..89a0c0c866 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser_search.js @@ -0,0 +1,177 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + ["test.aboutconfig.a", "test value 1"], + ["test.aboutconfig.ab", "test value 2"], + ["test.aboutconfig.bc", "test value 3"], + ], + }); +}); + +add_task(async function test_search() { + await AboutConfigTest.withNewTab(async function () { + await this.document.l10n.translateFragment(this.document.documentElement); + let prefArray = Services.prefs.getChildList(""); + + // The total number of preferences may change at any time because of + // operations running in the background, so we only test approximately. + // The change in count would be because of one or two added preferences, + // but we tolerate a difference of up to 50 preferences just to be safe. + // We want thousands of prefs instead of a few dozen that are filtered. + Assert.greater(this.rows.length, prefArray.length - 50); + + // Filter a subset of preferences. The "browser.download." branch is + // chosen because it is very unlikely that its preferences would be + // modified by other code during the execution of this test. + this.search("Wser.down "); + + let filteredPrefArray = prefArray.filter(pref => + pref.includes("wser.down") + ); + // Adding +1 to the list since button does not match an exact + // preference name then a row is added for the user to add a + // new button preference if desired + Assert.equal(this.rows.length, filteredPrefArray.length + 1); + + // Show all preferences again after filtering. + this.showAll(); + Assert.equal(this.searchInput.value, ""); + + // The total number of preferences may change at any time because of + // operations running in the background, so we only test approximately. + // The change in count would be because of one or two added preferences, + // but we tolerate a difference of up to 50 preferences just to be safe. + // We want thousands of prefs instead of a few dozen that are filtered. + Assert.greater(this.rows.length, prefArray.length - 50); + + // Check if "Only show modified" feature works. + EventUtils.sendMouseEvent({ type: "click" }, this.showOnlyModifiedCheckbox); + Assert.ok(this.rows.every(r => r.hasClass("has-user-value"))); + + // Uncheck checkbox + EventUtils.sendMouseEvent({ type: "click" }, this.showOnlyModifiedCheckbox); + Assert.ok(!this.rows.every(r => r.hasClass("has-user-value"))); + + // Pressing ESC while showing all preferences returns to the initial page. + EventUtils.sendKey("escape"); + Assert.equal(this.rows.length, 0); + + // Test invalid search returns no preferences. + // Expecting 1 row to be returned since it offers the ability to add. + this.search("aJunkValueasdf"); + Assert.equal(this.rows.length, 1); + // The has-visible-prefs attribute is used to style the border of the add row. + Assert.ok(!this.prefsTable.hasAttribute("has-visible-prefs")); + let addRow = this.getRow("aJunkValueasdf"); + Assert.equal(getComputedStyle(addRow.valueCell)["border-top-width"], "0px"); + + // Pressing ESC clears the field and returns to the initial page. + EventUtils.sendKey("escape"); + Assert.equal(this.searchInput.value, ""); + Assert.equal(this.rows.length, 0); + + // Two preferences match this filter, and one of those matches exactly. + this.search("test.aboutconfig.a"); + Assert.equal(this.rows.length, 2); + + // When searching case insensitively, there is an additional row to add a + // new preference with the same name but a different case. + this.search("TEST.aboutconfig.a"); + Assert.equal(this.rows.length, 3); + // The has-visible-prefs attribute is used to style the border of the add row. + Assert.ok(this.prefsTable.hasAttribute("has-visible-prefs")); + addRow = this.getRow("TEST.aboutconfig.a"); + Assert.equal(getComputedStyle(addRow.valueCell)["border-top-width"], "1px"); + + // Entering an empty string returns to the initial page. + this.search(""); + Assert.equal(this.rows.length, 0); + Assert.ok(!this.prefsTable.hasAttribute("has-visible-prefs")); + }); +}); + +add_task(async function test_search_wildcard() { + await AboutConfigTest.withNewTab(async function () { + const extra = 1; // "Add" row + + // A trailing wildcard + this.search("test.about*"); + Assert.equal(this.rows.length, 3 + extra); + + // A wildcard in middle + this.search("test.about*a"); + Assert.equal(this.rows.length, 2 + extra); + this.search("test.about*ab"); + Assert.equal(this.rows.length, 1 + extra); + this.search("test.aboutcon*fig"); + Assert.equal(this.rows.length, 3 + extra); + + // Multiple wildcards in middle + this.search("test.about*fig*ab"); + Assert.equal(this.rows.length, 1 + extra); + this.search("test.about*config*ab"); + Assert.equal(this.rows.length, 1 + extra); + }); +}); + +add_task(async function test_search_delayed() { + await AboutConfigTest.withNewTab(async function () { + // Start with the initial empty page. + this.search(""); + + // We need to wait more than the search typing timeout to make sure that + // nothing happens when entering a short string. + EventUtils.synthesizeKey("t"); + EventUtils.synthesizeKey("e"); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 500)); + Assert.equal(this.rows.length, 0); + + // Pressing Enter will force a search to occur anyways. + EventUtils.sendKey("return"); + Assert.greater(this.rows.length, 0); + + // Prepare the table and the search field for the next test. + this.search("test.aboutconfig.a"); + Assert.equal(this.rows.length, 2); + + // The table is updated in a single microtask, so we don't need to wait for + // specific mutations, we can just continue when any of the children or + // their "hidden" attributes are updated. + let prefsTableChanged = new Promise(resolve => { + let observer = new MutationObserver(() => { + observer.disconnect(); + resolve(); + }); + observer.observe(this.prefsTable, { childList: true }); + for (let element of this.prefsTable.children) { + observer.observe(element, { attributes: true }); + } + }); + + // Add a character and test that the table is not updated immediately. + EventUtils.synthesizeKey("b"); + Assert.equal(this.rows.length, 2); + + // The table will eventually be updated after a delay. + await prefsTableChanged; + Assert.equal(this.rows.length, 1); + }); +}); + +add_task(async function test_search_add_row_color() { + await AboutConfigTest.withNewTab(async function () { + // When the row is the only one displayed, it doesn't have the "odd" class. + this.search("test.aboutconfig.add"); + Assert.equal(this.rows.length, 1); + Assert.ok(!this.getRow("test.aboutconfig.add").hasClass("odd")); + + // When displayed with one other preference, the "odd" class is present. + this.search("test.aboutconfig.b"); + Assert.equal(this.rows.length, 2); + Assert.ok(this.getRow("test.aboutconfig.b").hasClass("odd")); + }); +}); diff --git a/toolkit/components/aboutconfig/test/browser/browser_warning.js b/toolkit/components/aboutconfig/test/browser/browser_warning.js new file mode 100644 index 0000000000..d95e8f49ea --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/browser_warning.js @@ -0,0 +1,41 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.aboutConfig.showWarning", true]], + }); +}); + +add_task(async function test_showWarningNextTime() { + for (let test of [ + { expectWarningPage: true, disableShowWarningNextTime: false }, + { expectWarningPage: true, disableShowWarningNextTime: true }, + { expectWarningPage: false }, + ]) { + await AboutConfigTest.withNewTab( + async function () { + if (test.expectWarningPage) { + this.assertWarningPage(true); + Assert.ok( + this.document.getElementById("showWarningNextTime").checked + ); + if (test.disableShowWarningNextTime) { + this.document.getElementById("showWarningNextTime").click(); + } + this.bypassWarningButton.click(); + } + + // No results are shown after the warning page is dismissed or bypassed. + this.assertWarningPage(false); + Assert.ok(!this.prefsTable.firstElementChild); + Assert.equal(this.document.activeElement, this.searchInput); + + // The show all button should be present and show all results immediately. + this.showAll(); + Assert.ok(this.prefsTable.firstElementChild); + }, + { dontBypassWarning: true } + ); + } +}); diff --git a/toolkit/components/aboutconfig/test/browser/head.js b/toolkit/components/aboutconfig/test/browser/head.js new file mode 100644 index 0000000000..511fc07a37 --- /dev/null +++ b/toolkit/components/aboutconfig/test/browser/head.js @@ -0,0 +1,173 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +// List of default preferences that can be used for tests, chosen because they +// have little or no side-effects when they are modified for a brief time. If +// any of these preferences are removed or their default state changes, just +// update the constant to point to a different preference with the same default. +const PREF_BOOLEAN_DEFAULT_TRUE = "accessibility.typeaheadfind.manual"; +const PREF_BOOLEAN_USERVALUE_TRUE = "browser.dom.window.dump.enabled"; +const PREF_NUMBER_DEFAULT_ZERO = "accessibility.typeaheadfind.casesensitive"; +const PREF_STRING_DEFAULT_EMPTY = "browser.helperApps.neverAsk.openFile"; +const PREF_STRING_DEFAULT_NOTEMPTY = "accessibility.typeaheadfind.soundURL"; +const PREF_STRING_DEFAULT_NOTEMPTY_VALUE = "beep"; +const PREF_STRING_LOCALIZED_MISSING = "intl.menuitems.alwaysappendaccesskeys"; + +// Other preference names used in tests. +const PREF_NEW = "test.aboutconfig.new"; + +// These tests can be slow to execute because they show all the preferences +// several times, and each time can require a second on some virtual machines. +requestLongerTimeout(2); + +class AboutConfigRowTest { + constructor(element) { + this.element = element; + } + + querySelector(selector) { + return this.element.querySelector(selector); + } + + get nameCell() { + return this.querySelector("th"); + } + + get name() { + return this.nameCell.textContent; + } + + get valueCell() { + return this.querySelector("td.cell-value"); + } + + get value() { + return this.valueCell.textContent; + } + + /** + * Text input field when the row is in edit mode. + */ + get valueInput() { + return this.valueCell.querySelector("input"); + } + + /** + * This is normally "edit" or "toggle" based on the preference type, "save" + * when the row is in edit mode, or "add" when the preference does not exist. + */ + get editColumnButton() { + return this.querySelector("td.cell-edit > button"); + } + + /** + * This can be "reset" or "delete" based on whether a default exists. + */ + get resetColumnButton() { + return this.querySelector("td:last-child > button"); + } + + hasClass(className) { + return this.element.classList.contains(className); + } +} + +class AboutConfigTest { + static withNewTab(testFn, options = {}) { + return BrowserTestUtils.withNewTab( + { + gBrowser, + url: "chrome://global/content/aboutconfig/aboutconfig.html", + }, + async browser => { + let scope = new this(browser); + await scope.setupNewTab(options); + await testFn.call(scope); + } + ); + } + + constructor(browser) { + this.browser = browser; + this.document = browser.contentDocument; + this.window = browser.contentWindow; + } + + async setupNewTab(options) { + await this.document.l10n.ready; + if (!options.dontBypassWarning) { + this.bypassWarningButton.click(); + this.showAll(); + } + } + + get showWarningNextTimeInput() { + return this.document.getElementById("showWarningNextTime"); + } + + get bypassWarningButton() { + return this.document.getElementById("warningButton"); + } + + get searchInput() { + return this.document.getElementById("about-config-search"); + } + + get showOnlyModifiedCheckbox() { + return this.document.getElementById("about-config-show-only-modified"); + } + + get prefsTable() { + return this.document.getElementById("prefs"); + } + + /** + * Array of AboutConfigRowTest objects, one for each row in the main table. + */ + get rows() { + let elements = this.prefsTable.querySelectorAll("tr:not(.hidden)"); + return Array.from(elements, element => new AboutConfigRowTest(element)); + } + + /** + * Returns the AboutConfigRowTest object for the row in the main table which + * corresponds to the given preference name, or undefined if none is present. + */ + getRow(name) { + return this.rows.find(row => row.name == name); + } + + /** + * Shows all preferences using the dedicated button. + */ + showAll() { + this.search(""); + this.document.getElementById("show-all").click(); + } + + /** + * Performs a new search using the dedicated textbox. This also makes sure + * that the list of preferences displayed is up to date. + */ + search(value) { + this.searchInput.value = value; + this.searchInput.focus(); + EventUtils.sendKey("return"); + } + + /** + * Checks whether or not the initial warning page is displayed. + */ + assertWarningPage(expected) { + Assert.equal(!!this.showWarningNextTimeInput, expected); + Assert.equal(!!this.bypassWarningButton, expected); + Assert.equal(!this.searchInput, expected); + Assert.equal(!this.prefsTable, expected); + } +} |