зеркало из https://github.com/mozilla/gecko-dev.git
Merge autoland to central, a=merge
MozReview-Commit-ID: IhZjTRz0dA5
This commit is contained in:
Коммит
5fa2384aeb
|
@ -140,6 +140,9 @@ GPATH
|
|||
^testing/talos/talos/tests/devtools/damp.manifest.develop
|
||||
^talos-venv
|
||||
^py3venv
|
||||
^testing/talos/talos/mitmproxy/mitmdump
|
||||
^testing/talos/talos/mitmproxy/mitmproxy
|
||||
^testing/talos/talos/mitmproxy/mitmweb
|
||||
|
||||
# Ignore files created when running a reftest.
|
||||
^lextab.py$
|
||||
|
|
|
@ -130,8 +130,6 @@
|
|||
el.remove();
|
||||
el = document.getElementById("errorShortDescText_harmful");
|
||||
el.remove();
|
||||
el = document.getElementById("errorLongDescText_harmful");
|
||||
el.remove();
|
||||
}
|
||||
|
||||
// Set sitename if necessary.
|
||||
|
@ -175,7 +173,7 @@
|
|||
<p id="errorShortDescText_phishing">&safeb.blocked.phishingPage.shortDesc2;</p>
|
||||
<p id="errorShortDescText_malware">&safeb.blocked.malwarePage.shortDesc;</p>
|
||||
<p id="errorShortDescText_unwanted">&safeb.blocked.unwantedPage.shortDesc;</p>
|
||||
<p id="errorShortDescText_harmful">&safeb.blocked.harmfulPage.shortDesc;</p>
|
||||
<p id="errorShortDescText_harmful">&safeb.blocked.harmfulPage.shortDesc2;</p>
|
||||
</div>
|
||||
|
||||
<!-- Long Description -->
|
||||
|
@ -183,7 +181,6 @@
|
|||
<p id="errorLongDescText_phishing">&safeb.blocked.phishingPage.longDesc2;</p>
|
||||
<p id="errorLongDescText_malware">&safeb.blocked.malwarePage.longDesc;</p>
|
||||
<p id="errorLongDescText_unwanted">&safeb.blocked.unwantedPage.longDesc;</p>
|
||||
<p id="errorLongDescText_harmful">&safeb.blocked.harmfulPage.longDesc;</p>
|
||||
</div>
|
||||
|
||||
<!-- Advisory -->
|
||||
|
|
|
@ -38,7 +38,6 @@ var CustomizationHandler = {
|
|||
|
||||
UpdateUrlbarSearchSplitterState();
|
||||
|
||||
CombinedStopReload.uninit();
|
||||
PlacesToolbarHelper.customizeStart();
|
||||
DownloadsButton.customizeStart();
|
||||
},
|
||||
|
@ -66,9 +65,6 @@ var CustomizationHandler = {
|
|||
PlacesToolbarHelper.customizeDone();
|
||||
DownloadsButton.customizeDone();
|
||||
|
||||
// The url bar splitter state is dependent on whether stop/reload
|
||||
// and the location bar are combined, so we need this ordering
|
||||
CombinedStopReload.init();
|
||||
UpdateUrlbarSearchSplitterState();
|
||||
|
||||
// Update the urlbar
|
||||
|
|
|
@ -705,7 +705,7 @@ BrowserPageActions.copyURL = {
|
|||
BrowserPageActions.panelNode.hidePopup();
|
||||
Cc["@mozilla.org/widget/clipboardhelper;1"]
|
||||
.getService(Ci.nsIClipboardHelper)
|
||||
.copyString(gBrowser.selectedBrowser.currentURI.spec);
|
||||
.copyString(gURLBar.makeURIReadable(gBrowser.selectedBrowser.currentURI).displaySpec);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -90,8 +90,7 @@ var gSync = {
|
|||
},
|
||||
|
||||
init() {
|
||||
// Bail out if we're already initialized or for hidden windows.
|
||||
if (this._initialized || window.location.href != getBrowserURL()) {
|
||||
if (this._initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -733,7 +733,8 @@ html|input.urlbar-input[textoverflow]:not([focused]) {
|
|||
toolbarpaletteitem[place=toolbar][id^=wrapper-customizableui-special-spring],
|
||||
toolbarspring {
|
||||
-moz-box-flex: 1;
|
||||
min-width: 0;
|
||||
min-width: 28px;
|
||||
max-width: 112px;
|
||||
}
|
||||
|
||||
#nav-bar toolbarpaletteitem[id^=wrapper-customizableui-special-spring],
|
||||
|
|
|
@ -1949,11 +1949,6 @@ if (AppConstants.platform == "macosx") {
|
|||
// initialize the private browsing UI
|
||||
gPrivateBrowsingUI.init();
|
||||
|
||||
// initialize the sync UI
|
||||
requestIdleCallback(() => {
|
||||
gSync.init();
|
||||
}, {timeout: 1000 * 5});
|
||||
|
||||
if (AppConstants.E10S_TESTING_ONLY) {
|
||||
gRemoteTabsUI.init();
|
||||
}
|
||||
|
@ -6726,7 +6721,7 @@ function warnAboutClosingWindow() {
|
|||
|
||||
var MailIntegration = {
|
||||
sendLinkForBrowser(aBrowser) {
|
||||
this.sendMessage(aBrowser.currentURI.displaySpec, aBrowser.contentTitle);
|
||||
this.sendMessage(gURLBar.makeURIReadable(aBrowser.currentURI).displaySpec, aBrowser.contentTitle);
|
||||
},
|
||||
|
||||
sendMessage(aBody, aSubject) {
|
||||
|
|
|
@ -921,6 +921,7 @@
|
|||
removable="true"
|
||||
oncommand="PanelUI.showSubView('appMenu-libraryView', this, null, event);"
|
||||
closemenu="none"
|
||||
cui-areatype="toolbar"
|
||||
label="&places.library.title;">
|
||||
<box class="toolbarbutton-animatable-box">
|
||||
<image class="toolbarbutton-animatable-image"/>
|
||||
|
|
|
@ -118,8 +118,9 @@ var whitelist = [
|
|||
// browser/extensions/pdfjs/content/web/viewer.js#7450
|
||||
{file: "resource://pdf.js/web/debugger.js"},
|
||||
|
||||
// Needed by Normandy
|
||||
{file: "resource://gre/modules/IndexedDB.jsm"},
|
||||
// These are used in content processes. They are actually referenced.
|
||||
{file: "resource://shield-recipe-client-content/shield-content-frame.js"},
|
||||
{file: "resource://shield-recipe-client-content/shield-content-process.js"},
|
||||
|
||||
// New L10n API that is not yet used in production
|
||||
{file: "resource://gre/modules/Localization.jsm"},
|
||||
|
|
|
@ -941,6 +941,26 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|||
]]></body>
|
||||
</method>
|
||||
|
||||
<method name="makeURIReadable">
|
||||
<parameter name="aURI"/>
|
||||
<body>
|
||||
<![CDATA[
|
||||
// Avoid copying 'about:reader?url=', and always provide the original URI:
|
||||
// Reader mode ensures we call createExposableURI itself.
|
||||
let readerStrippedURI = ReaderMode.getOriginalUrlObjectForDisplay(aURI.displaySpec);
|
||||
if (readerStrippedURI) {
|
||||
aURI = readerStrippedURI;
|
||||
} else {
|
||||
// Only copy exposable URIs
|
||||
try {
|
||||
aURI = Services.uriFixup.createExposableURI(aURI);
|
||||
} catch (ex) {}
|
||||
}
|
||||
return aURI;
|
||||
]]>
|
||||
</body>
|
||||
</method>
|
||||
|
||||
<method name="_getSelectedValueForClipboard">
|
||||
<body><![CDATA[
|
||||
// Grab the actual input field's value, not our value, which could include moz-action:
|
||||
|
@ -979,17 +999,7 @@ file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|||
return selectedVal;
|
||||
}
|
||||
|
||||
// Avoid copying 'about:reader?url=', and always provide the original URI:
|
||||
// Reader mode ensures we call createExposableURI itself.
|
||||
let readerStrippedURI = ReaderMode.getOriginalUrlObjectForDisplay(uri.displaySpec);
|
||||
if (readerStrippedURI) {
|
||||
uri = readerStrippedURI;
|
||||
} else {
|
||||
// Only copy exposable URIs
|
||||
try {
|
||||
uri = uriFixup.createExposableURI(uri);
|
||||
} catch (ex) {}
|
||||
}
|
||||
uri = this.makeURIReadable(uri);
|
||||
|
||||
// If the entire URL is selected, just use the actual loaded URI,
|
||||
// unless we want a decoded URI, or it's a data: or javascript: URI,
|
||||
|
|
|
@ -278,9 +278,15 @@ const DownloadsIndicatorView = {
|
|||
// Direct control functions
|
||||
|
||||
/**
|
||||
* Set while we are waiting for a notification to fade out.
|
||||
* Set to the type ("start" or "finish") when display of a notification is in-progress
|
||||
*/
|
||||
_notificationTimeout: null,
|
||||
_currentNotificationType: null,
|
||||
|
||||
/**
|
||||
* Set to the type ("start" or "finish") when a notification arrives while we
|
||||
* are waiting for the timeout of the previous notification
|
||||
*/
|
||||
_nextNotificationType: null,
|
||||
|
||||
/**
|
||||
* Check if the panel containing aNode is open.
|
||||
|
@ -295,8 +301,7 @@ const DownloadsIndicatorView = {
|
|||
},
|
||||
|
||||
/**
|
||||
* If the status indicator is visible in its assigned position, shows for a
|
||||
* brief time a visual notification of a relevant event, like a new download.
|
||||
* Display or enqueue a visual notification of a relevant event, like a new download.
|
||||
*
|
||||
* @param aType
|
||||
* Set to "start" for new downloads, "finish" for completed downloads.
|
||||
|
@ -310,6 +315,25 @@ const DownloadsIndicatorView = {
|
|||
return;
|
||||
}
|
||||
|
||||
// enqueue this notification while the current one is being displayed
|
||||
if (this._currentNotificationType) {
|
||||
// only queue up the notification if it is different to the current one
|
||||
if (this._currentNotificationType != aType) {
|
||||
this._nextNotificationType = aType;
|
||||
}
|
||||
} else {
|
||||
this._showNotification(aType);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If the status indicator is visible in its assigned position, shows for a
|
||||
* brief time a visual notification of a relevant event, like a new download.
|
||||
*
|
||||
* @param aType
|
||||
* Set to "start" for new downloads, "finish" for completed downloads.
|
||||
*/
|
||||
_showNotification(aType) {
|
||||
// No need to show visual notification if the panel is visible.
|
||||
if (DownloadsPanel.isPanelShowing) {
|
||||
return;
|
||||
|
@ -334,10 +358,6 @@ const DownloadsIndicatorView = {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this._notificationTimeout) {
|
||||
clearTimeout(this._notificationTimeout);
|
||||
}
|
||||
|
||||
// The notification element is positioned to show in the same location as
|
||||
// the downloads button. It's not in the downloads button itself in order to
|
||||
// be able to anchor the notification elsewhere if required, and to ensure
|
||||
|
@ -371,11 +391,24 @@ const DownloadsIndicatorView = {
|
|||
// This value is determined by the overall duration of animation in CSS.
|
||||
animationDuration = aType == "start" ? 760 : 850;
|
||||
|
||||
this._notificationTimeout = setTimeout(() => {
|
||||
notifier.setAttribute("hidden", "true");
|
||||
notifier.removeAttribute("notification");
|
||||
notifier.style.transform = "";
|
||||
anchor.removeAttribute("notification");
|
||||
this._currentNotificationType = aType;
|
||||
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
notifier.setAttribute("hidden", "true");
|
||||
notifier.removeAttribute("notification");
|
||||
notifier.style.transform = "";
|
||||
anchor.removeAttribute("notification");
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
let nextType = this._nextNotificationType;
|
||||
this._currentNotificationType = null;
|
||||
this._nextNotificationType = null;
|
||||
if (nextType) {
|
||||
this._showNotification(nextType);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, animationDuration);
|
||||
},
|
||||
|
||||
|
|
|
@ -4,16 +4,15 @@
|
|||
|
||||
/* global getDevToolsTargetForContext */
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "DevToolsShim",
|
||||
"chrome://devtools-shim/content/DevToolsShim.jsm");
|
||||
|
||||
var {
|
||||
SpreadArgs,
|
||||
} = ExtensionCommon;
|
||||
|
||||
this.devtools_inspectedWindow = class extends ExtensionAPI {
|
||||
getAPI(context) {
|
||||
const {
|
||||
WebExtensionInspectedWindowFront,
|
||||
} = require("devtools/shared/fronts/webextension-inspected-window");
|
||||
|
||||
// Lazily retrieve and store an inspectedWindow actor front per child context.
|
||||
let waitForInspectedWindowFront;
|
||||
async function getInspectedWindowFront() {
|
||||
|
@ -22,7 +21,7 @@ this.devtools_inspectedWindow = class extends ExtensionAPI {
|
|||
// because the first time that the target has been cloned, it is not ready to be used to create
|
||||
// the front instance until it is connected to the remote debugger successfully).
|
||||
const clonedTarget = await getDevToolsTargetForContext(context);
|
||||
return new WebExtensionInspectedWindowFront(clonedTarget.client, clonedTarget.form);
|
||||
return DevToolsShim.createWebExtensionInspectedWindowFront(clonedTarget);
|
||||
}
|
||||
|
||||
function getToolboxOptions() {
|
||||
|
|
|
@ -53,9 +53,8 @@ global.getDevToolsTargetForContext = async (context) => {
|
|||
throw new Error("Unexpected target type: only local tabs are currently supported.");
|
||||
}
|
||||
|
||||
const {TabTarget} = require("devtools/client/framework/target");
|
||||
|
||||
context.devToolsTarget = new TabTarget(context.devToolsToolbox.target.tab);
|
||||
const tab = context.devToolsToolbox.target.tab;
|
||||
context.devToolsTarget = DevToolsShim.getTargetForTab(tab);
|
||||
await context.devToolsTarget.makeRemote();
|
||||
|
||||
return context.devToolsTarget;
|
||||
|
|
|
@ -239,6 +239,11 @@ var gSearchResultsPane = {
|
|||
let rootPreferencesChildren = document
|
||||
.querySelectorAll("#mainPrefPane > *:not([data-hidden-from-search])");
|
||||
|
||||
// Show all second level headers in search result
|
||||
for (let element of document.querySelectorAll("caption.search-header")) {
|
||||
element.hidden = false;
|
||||
}
|
||||
|
||||
if (subQuery) {
|
||||
// Since the previous query is a subset of the current query,
|
||||
// there is no need to check elements that is hidden already.
|
||||
|
@ -318,6 +323,11 @@ var gSearchResultsPane = {
|
|||
document.getElementById("sorry-message").textContent = "";
|
||||
// Going back to General when cleared
|
||||
gotoPref("paneGeneral");
|
||||
|
||||
// Hide some special second level headers in normal view
|
||||
for (let element of document.querySelectorAll("caption.search-header")) {
|
||||
element.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent("PreferencesSearchCompleted", { detail: query }));
|
||||
|
|
|
@ -752,6 +752,8 @@
|
|||
|
||||
<!-- Update -->
|
||||
<groupbox id="updateApp" data-category="paneGeneral" hidden="true">
|
||||
<caption class="search-header" hidden="true"><label>&updateApplication.label;</label></caption>
|
||||
|
||||
<label>&updateApplicationDescription.label;</label>
|
||||
<hbox align="start">
|
||||
<vbox flex="1">
|
||||
|
@ -904,6 +906,8 @@
|
|||
|
||||
<!-- Performance -->
|
||||
<groupbox id="performanceGroup" data-category="paneGeneral" hidden="true">
|
||||
<caption class="search-header" hidden="true"><label>&performance.label;</label></caption>
|
||||
|
||||
<hbox align="center">
|
||||
<checkbox id="useRecommendedPerformanceSettings"
|
||||
label="&useRecommendedPerformanceSettings2.label;"
|
||||
|
@ -946,6 +950,8 @@
|
|||
|
||||
<!-- Browsing -->
|
||||
<groupbox id="browsingGroup" data-category="paneGeneral" hidden="true">
|
||||
<caption class="search-header" hidden="true"><label>&browsing.label;</label></caption>
|
||||
|
||||
<checkbox id="useAutoScroll"
|
||||
label="&useAutoScroll.label;"
|
||||
accesskey="&useAutoScroll.accesskey;"
|
||||
|
@ -981,6 +987,8 @@
|
|||
|
||||
<!-- Network Proxy-->
|
||||
<groupbox id="connectionGroup" data-category="paneGeneral" hidden="true">
|
||||
<caption class="search-header" hidden="true"><label>&networkProxy.label;</label></caption>
|
||||
|
||||
<hbox align="center">
|
||||
<description flex="1" control="connectionSettings">&connectionDesc.label;</description>
|
||||
<!-- Please don't remove the wrapping hbox/vbox/box for these elements. It's used to properly compute the search tooltip position. -->
|
||||
|
|
|
@ -427,7 +427,7 @@
|
|||
<vbox flex="1">
|
||||
<description flex="1">
|
||||
<label id="totalSiteDataSize"></label>
|
||||
<label id="siteDataLearnMoreLink" class="learnMore text-link" value="&siteDataLearnMoreLink.label;"></label>
|
||||
<label id="siteDataLearnMoreLink" class="learnMore text-link">&siteDataLearnMoreLink.label;</label>
|
||||
</description>
|
||||
</vbox>
|
||||
<vbox align="end">
|
||||
|
@ -536,6 +536,8 @@
|
|||
|
||||
<!-- Permissions -->
|
||||
<groupbox id="permissionsGroup" data-category="panePrivacy" hidden="true">
|
||||
<caption class="search-header" hidden="true"><label>&permissions.label;</label></caption>
|
||||
|
||||
<grid>
|
||||
<columns>
|
||||
<column flex="1"/>
|
||||
|
@ -677,11 +679,12 @@
|
|||
<!-- Firefox Data Collection and Use -->
|
||||
#ifdef MOZ_DATA_REPORTING
|
||||
<groupbox id="dataCollectionGroup" data-category="panePrivacy" data-subcategory="reports" hidden="true">
|
||||
<description>
|
||||
&dataCollectionDesc.label;<label id="dataCollectionPrivacyNotice" class="learnMore text-link">&dataCollectionPrivacyNotice.label;</label>
|
||||
</description>
|
||||
<caption class="search-header" hidden="true"><label>&dataCollection.label;</label></caption>
|
||||
|
||||
<vbox>
|
||||
<description>
|
||||
&dataCollectionDesc.label;<label id="dataCollectionPrivacyNotice" class="learnMore text-link">&dataCollectionPrivacyNotice.label;</label>
|
||||
</description>
|
||||
<description flex="1">
|
||||
<checkbox id="submitHealthReportBox" label="&enableHealthReport1.label;"
|
||||
accesskey="&enableHealthReport1.accesskey;"/>
|
||||
|
|
|
@ -302,7 +302,7 @@ var gSyncPane = {
|
|||
document.getElementById("fxaChangeDeviceName").disabled = !syncReady;
|
||||
|
||||
// Clear the profile image (if any) of the previously logged in account.
|
||||
document.getElementById("fxaProfileImage").style.removeProperty("list-style-image");
|
||||
document.querySelector("#fxaLoginVerified > .fxaProfileImage").style.removeProperty("list-style-image");
|
||||
|
||||
// If the account is verified the next promise in the chain will
|
||||
// fetch profile data.
|
||||
|
@ -331,7 +331,7 @@ var gSyncPane = {
|
|||
}
|
||||
if (data.avatar) {
|
||||
let bgImage = "url(\"" + data.avatar + "\")";
|
||||
let profileImageElement = document.getElementById("fxaProfileImage");
|
||||
let profileImageElement = document.querySelector("#fxaLoginVerified > .fxaProfileImage");
|
||||
profileImageElement.style.listStyleImage = bgImage;
|
||||
|
||||
let img = new Image();
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
</hbox>
|
||||
<hbox id="fxaNoLoginStatus" align="center" flex="1">
|
||||
<vbox>
|
||||
<image id="fxaProfileImage"/>
|
||||
<image class="fxaProfileImage"/>
|
||||
</vbox>
|
||||
<vbox flex="1">
|
||||
<hbox align="center" flex="1">
|
||||
|
@ -96,7 +96,7 @@
|
|||
|
||||
<!-- logged in and verified and all is good -->
|
||||
<hbox id="fxaLoginVerified" align="center" flex="1">
|
||||
<image id="fxaProfileImage" class="actionable"
|
||||
<image class="fxaProfileImage actionable"
|
||||
role="button"
|
||||
onclick="gSyncPane.openChangeProfileImage(event);"
|
||||
onkeypress="gSyncPane.openChangeProfileImage(event);"
|
||||
|
@ -123,11 +123,11 @@
|
|||
<!-- logged in to an unverified account -->
|
||||
<hbox id="fxaLoginUnverified">
|
||||
<vbox>
|
||||
<image id="fxaProfileImage"/>
|
||||
<image class="fxaProfileImage"/>
|
||||
</vbox>
|
||||
<vbox flex="1">
|
||||
<hbox>
|
||||
<vbox><image id="fxaLoginRejectedWarning"/></vbox>
|
||||
<vbox><image class="fxaLoginRejectedWarning"/></vbox>
|
||||
<description flex="1">
|
||||
&signedInUnverified.beforename.label;
|
||||
<label id="fxaEmailAddress2"/>
|
||||
|
@ -144,11 +144,11 @@
|
|||
<!-- logged in locally but server rejected credentials -->
|
||||
<hbox id="fxaLoginRejected">
|
||||
<vbox>
|
||||
<image id="fxaProfileImage"/>
|
||||
<image class="fxaProfileImage"/>
|
||||
</vbox>
|
||||
<vbox flex="1">
|
||||
<hbox>
|
||||
<vbox><image id="fxaLoginRejectedWarning"/></vbox>
|
||||
<vbox><image class="fxaLoginRejectedWarning"/></vbox>
|
||||
<description flex="1">
|
||||
&signedInLoginFailure.beforename.label;
|
||||
<label id="fxaEmailAddress3"/>
|
||||
|
|
|
@ -103,7 +103,6 @@ if (typeof Mozilla == "undefined") {
|
|||
* <li>appMenu
|
||||
* <li>backForward
|
||||
* <li>bookmarks
|
||||
* <li>bookmark-star-button
|
||||
* <li>controlCenter-trackingUnblock
|
||||
* <li>controlCenter-trackingBlock
|
||||
* <li>customize
|
||||
|
@ -113,10 +112,10 @@ if (typeof Mozilla == "undefined") {
|
|||
* <li>home
|
||||
* <li>library
|
||||
* <li>pageActionButton
|
||||
* <li>pageAction-panel-bookmark
|
||||
* <li>pageAction-panel-copyURL
|
||||
* <li>pageAction-panel-emailLink
|
||||
* <li>pageAction-panel-sendToDevice
|
||||
* <li>pageAction-bookmark
|
||||
* <li>pageAction-copyURL
|
||||
* <li>pageAction-emailLink
|
||||
* <li>pageAction-sendToDevice
|
||||
* <li>pocket
|
||||
* <li>privateWindow
|
||||
* <li>quit
|
||||
|
|
|
@ -115,7 +115,13 @@ this.UITour = {
|
|||
// to automatically open the appMenu when annotating this target.
|
||||
widgetName: "appMenu-fxa-label",
|
||||
}],
|
||||
["addons", {query: "#appMenu-addons-button"}],
|
||||
["addons", {
|
||||
query: (aDocument) => {
|
||||
// select toolbar icon if exist, fallback to appMenu item
|
||||
let node = aDocument.getElementById("add-ons-button");
|
||||
return node ? node : aDocument.getElementById("appMenu-addons-button");
|
||||
},
|
||||
}],
|
||||
["appMenu", {
|
||||
addTargetListener: (aDocument, aCallback) => {
|
||||
let panelPopup = aDocument.defaultView.PanelUI.panel;
|
||||
|
@ -149,10 +155,24 @@ this.UITour = {
|
|||
}],
|
||||
["help", {query: "#appMenu-help-button"}],
|
||||
["home", {query: "#home-button"}],
|
||||
["library", {query: "#appMenu-library-button"}],
|
||||
["library", {
|
||||
query: (aDocument) => {
|
||||
// select toolbar icon if exist, fallback to appMenu item
|
||||
let node = aDocument.getElementById("library-button");
|
||||
return node ? node : aDocument.getElementById("appMenu-library-button");
|
||||
},
|
||||
}],
|
||||
["pocket", {
|
||||
allowAdd: true,
|
||||
query: "#pocket-button",
|
||||
query: (aDocument) => {
|
||||
// The pocket's urlbar page action button is pre-defined in the DOM.
|
||||
// It would be hidden if toggled off from the urlbar.
|
||||
let node = aDocument.getElementById("pocket-button-box");
|
||||
if (node && node.hidden == false) {
|
||||
return node;
|
||||
}
|
||||
return aDocument.getElementById("pageAction-panel-pocket");
|
||||
},
|
||||
}],
|
||||
["privateWindow", {query: "#appMenu-private-window-button"}],
|
||||
["quit", {query: "#appMenu-quit-button"}],
|
||||
|
@ -209,20 +229,34 @@ this.UITour = {
|
|||
["pageActionButton", {
|
||||
query: "#pageActionButton"
|
||||
}],
|
||||
["pageAction-panel-bookmark", {
|
||||
query: "#pageAction-panel-bookmark"
|
||||
["pageAction-bookmark", {
|
||||
query: (aDocument) => {
|
||||
// The bookmark's urlbar page action button is pre-defined in the DOM.
|
||||
// It would be hidden if toggled off from the urlbar.
|
||||
let node = aDocument.getElementById("star-button-box");
|
||||
if (node && node.hidden == false) {
|
||||
return node;
|
||||
}
|
||||
return aDocument.getElementById("pageAction-panel-bookmark");
|
||||
},
|
||||
}],
|
||||
["pageAction-panel-copyURL", {
|
||||
query: "#pageAction-panel-copyURL"
|
||||
["pageAction-copyURL", {
|
||||
query: (aDocument) => {
|
||||
return aDocument.getElementById("pageAction-urlbar-copyURL") ||
|
||||
aDocument.getElementById("pageAction-panel-copyURL");
|
||||
},
|
||||
}],
|
||||
["pageAction-panel-emailLink", {
|
||||
query: "#pageAction-panel-emailLink"
|
||||
["pageAction-emailLink", {
|
||||
query: (aDocument) => {
|
||||
return aDocument.getElementById("pageAction-urlbar-emailLink") ||
|
||||
aDocument.getElementById("pageAction-panel-emailLink");
|
||||
},
|
||||
}],
|
||||
["pageAction-panel-sendToDevice", {
|
||||
query: "#pageAction-panel-sendToDevice"
|
||||
}],
|
||||
["bookmark-star-button", {
|
||||
query: "#star-button"
|
||||
["pageAction-sendToDevice", {
|
||||
query: (aDocument) => {
|
||||
return aDocument.getElementById("pageAction-urlbar-sendToDevice") ||
|
||||
aDocument.getElementById("pageAction-panel-sendToDevice");
|
||||
},
|
||||
}]
|
||||
]),
|
||||
|
||||
|
|
|
@ -18,14 +18,14 @@ add_UITour_task(async function test_highligh_between_pageActionButtonOnUrlbar_an
|
|||
is(pageActionPanel.state, "closed", "Shouldn't open the page action panel while highlighting the pageActionButton");
|
||||
is(getShowHighlightTargetName(), "pageActionButton", "Should highlight the pageActionButton");
|
||||
|
||||
// Test switching the highlight to the bookmark button on the page action panel
|
||||
// Test switching the highlight to the copyURL button on the page action panel
|
||||
let panelShownPromise = promisePanelElementShown(window, pageActionPanel);
|
||||
highlightVisiblePromise = elementVisiblePromise(highlight, "Should show highlight");
|
||||
gContentAPI.showHighlight("pageAction-panel-bookmark");
|
||||
gContentAPI.showHighlight("pageAction-copyURL");
|
||||
await highlightVisiblePromise;
|
||||
await panelShownPromise;
|
||||
is(pageActionPanel.state, "open", "Should open the page action panel for highlighting the pageAction-panel-bookmark");
|
||||
is(getShowHighlightTargetName(), "pageAction-panel-bookmark", "Should highlight the pageAction-panel-bookmark");
|
||||
is(pageActionPanel.state, "open", "Should open the page action panel for highlighting the pageAction-copyURL");
|
||||
is(getShowHighlightTargetName(), "pageAction-copyURL", "Should highlight the pageAction-copyURL");
|
||||
|
||||
// Test hiding highlight
|
||||
let panelHiddenPromise = promisePanelElementHidden(window, pageActionPanel);
|
||||
|
@ -57,13 +57,13 @@ add_UITour_task(async function test_highligh_between_buttonOnAppMenu_and_buttonO
|
|||
let appMenuHiddenPromise = promisePanelElementHidden(window, appMenu);
|
||||
let pageActionPanelShownPromise = promisePanelElementShown(window, pageActionPanel);
|
||||
highlightVisiblePromise = elementVisiblePromise(highlight, "Should show highlight");
|
||||
gContentAPI.showHighlight("pageAction-panel-copyURL");
|
||||
gContentAPI.showHighlight("pageAction-copyURL");
|
||||
await appMenuHiddenPromise;
|
||||
await pageActionPanelShownPromise;
|
||||
await highlightVisiblePromise;
|
||||
is(appMenu.state, "closed", "Should close the app menu after no more highlight for the addons button");
|
||||
is(pageActionPanel.state, "open", "Should open the page action panel to highlight the copyURL button");
|
||||
is(getShowHighlightTargetName(), "pageAction-panel-copyURL", "Should highlight the copyURL button on the page action panel");
|
||||
is(getShowHighlightTargetName(), "pageAction-copyURL", "Should highlight the copyURL button on the page action panel");
|
||||
|
||||
// Test hiding highlight
|
||||
let pageActionPanelHiddenPromise = promisePanelElementHidden(window, pageActionPanel);
|
||||
|
@ -85,12 +85,12 @@ add_UITour_task(async function test_showInfo_between_buttonOnPageActionPanel_and
|
|||
// Test showing info tooltip on the emailLink button on the page action panel
|
||||
let pageActionPanelShownPromise = promisePanelElementShown(window, pageActionPanel);
|
||||
let tooltipVisiblePromise = elementVisiblePromise(tooltip, "Should show info tooltip");
|
||||
await showInfoPromise("pageAction-panel-emailLink", "title", "text");
|
||||
await showInfoPromise("pageAction-emailLink", "title", "text");
|
||||
await pageActionPanelShownPromise;
|
||||
await tooltipVisiblePromise;
|
||||
is(appMenu.state, "closed", "Shouldn't open the app menu");
|
||||
is(pageActionPanel.state, "open", "Should open the page action panel to show info on the copyURL button");
|
||||
is(getShowInfoTargetName(), "pageAction-panel-emailLink", "Should show info tooltip on the emailLink button on the page action panel");
|
||||
is(getShowInfoTargetName(), "pageAction-emailLink", "Should show info tooltip on the emailLink button on the page action panel");
|
||||
|
||||
// Test switching info tooltip to the customize button on the app menu
|
||||
let appMenuShownPromise = promisePanelElementShown(window, appMenu);
|
||||
|
@ -126,12 +126,12 @@ add_UITour_task(async function test_highlight_buttonOnPageActionPanel_and_showIn
|
|||
// Test highlighting the sendToDevice button on the page action panel
|
||||
let pageActionPanelShownPromise = promisePanelElementShown(window, pageActionPanel);
|
||||
let highlightVisiblePromise = elementVisiblePromise(highlight, "Should show highlight");
|
||||
gContentAPI.showHighlight("pageAction-panel-sendToDevice");
|
||||
gContentAPI.showHighlight("pageAction-sendToDevice");
|
||||
await pageActionPanelShownPromise;
|
||||
await highlightVisiblePromise;
|
||||
is(appMenu.state, "closed", "Shouldn't open the app menu");
|
||||
is(pageActionPanel.state, "open", "Should open the page action panel to highlight the sendToDevice button");
|
||||
is(getShowHighlightTargetName(), "pageAction-panel-sendToDevice", "Should highlight the sendToDevice button on the page action panel");
|
||||
is(getShowHighlightTargetName(), "pageAction-sendToDevice", "Should highlight the sendToDevice button on the page action panel");
|
||||
|
||||
// Test showing info tooltip on the privateWindow button on the app menu
|
||||
let appMenuShownPromise = promisePanelElementShown(window, appMenu);
|
||||
|
@ -180,14 +180,14 @@ add_UITour_task(async function test_showInfo_buttonOnAppMenu_and_highlight_butto
|
|||
let highlightVisiblePromise = elementVisiblePromise(highlight, "Should show highlight");
|
||||
let appMenuHiddenPromise = promisePanelElementHidden(window, appMenu);
|
||||
let tooltipHiddenPromise = elementHiddenPromise(tooltip, "Should hide info");
|
||||
gContentAPI.showHighlight("pageAction-panel-sendToDevice");
|
||||
gContentAPI.showHighlight("pageAction-sendToDevice");
|
||||
await pageActionPanelShownPromise;
|
||||
await highlightVisiblePromise;
|
||||
await appMenuHiddenPromise;
|
||||
await tooltipHiddenPromise;
|
||||
is(appMenu.state, "closed", "Should close the app menu");
|
||||
is(pageActionPanel.state, "open", "Should open the page action panel to highlight the sendToDevice button");
|
||||
is(getShowHighlightTargetName(), "pageAction-panel-sendToDevice", "Should highlight the sendToDevice button on the page action panel");
|
||||
is(getShowHighlightTargetName(), "pageAction-sendToDevice", "Should highlight the sendToDevice button on the page action panel");
|
||||
|
||||
// Test hiding highlight
|
||||
let pageActionPanelHiddenPromise = promisePanelElementHidden(window, pageActionPanel);
|
||||
|
@ -215,12 +215,12 @@ add_UITour_task(async function test_show_appMenu_and_highligh_buttonOnPageAction
|
|||
// Test highlighting the sendToDevice button on the page action panel
|
||||
let pageActionPanelShownPromise = promisePanelElementShown(window, pageActionPanel);
|
||||
let highlightVisiblePromise = elementVisiblePromise(highlight, "Should show highlight");
|
||||
gContentAPI.showHighlight("pageAction-panel-sendToDevice");
|
||||
gContentAPI.showHighlight("pageAction-sendToDevice");
|
||||
await pageActionPanelShownPromise;
|
||||
await highlightVisiblePromise;
|
||||
is(appMenu.state, "open", "Shouldn't close the app menu because it is opened explictly by api user.");
|
||||
is(pageActionPanel.state, "open", "Should open the page action panel to highlight the sendToDevice button");
|
||||
is(getShowHighlightTargetName(), "pageAction-panel-sendToDevice", "Should highlight the sendToDevice button on the page action panel");
|
||||
is(getShowHighlightTargetName(), "pageAction-sendToDevice", "Should highlight the sendToDevice button on the page action panel");
|
||||
|
||||
// Test hiding the app menu wouldn't affect the highlight on the page action panel
|
||||
let appMenuHiddenPromise = promisePanelElementHidden(window, appMenu);
|
||||
|
@ -229,7 +229,7 @@ add_UITour_task(async function test_show_appMenu_and_highligh_buttonOnPageAction
|
|||
is_element_visible(highlight, "Highlight should still be visible");
|
||||
is(appMenu.state, "closed", "Should close the app menu");
|
||||
is(pageActionPanel.state, "open", "Shouldn't close the page action panel");
|
||||
is(getShowHighlightTargetName(), "pageAction-panel-sendToDevice", "Should still highlight the sendToDevice button on the page action panel");
|
||||
is(getShowHighlightTargetName(), "pageAction-sendToDevice", "Should still highlight the sendToDevice button on the page action panel");
|
||||
|
||||
// Test hiding highlight
|
||||
let pageActionPanelHiddenPromise = promisePanelElementHidden(window, pageActionPanel);
|
||||
|
|
|
@ -6,7 +6,41 @@ var gContentWindow;
|
|||
|
||||
add_task(setup_UITourTest);
|
||||
|
||||
add_UITour_task(async function test_highlight_library_icon_in_toolbar() {
|
||||
let highlight = document.getElementById("UITourHighlight");
|
||||
is_element_hidden(highlight, "Highlight should initially be hidden");
|
||||
|
||||
// Test highlighting the library button
|
||||
let highlightVisiblePromise = elementVisiblePromise(highlight, "Should show highlight");
|
||||
gContentAPI.showHighlight("library");
|
||||
await highlightVisiblePromise;
|
||||
UITour.getTarget(window, "library").then((target) => {
|
||||
is("library-button", target.node.id, "Should highlight the right target");
|
||||
});
|
||||
});
|
||||
|
||||
add_UITour_task(async function test_highlight_addons_icon_in_toolbar() {
|
||||
CustomizableUI.addWidgetToArea("add-ons-button", CustomizableUI.AREA_NAVBAR, 0);
|
||||
ok(!UITour.availableTargetsCache.has(window),
|
||||
"Targets should be evicted from cache after widget change");
|
||||
let highlight = document.getElementById("UITourHighlight");
|
||||
is_element_hidden(highlight, "Highlight should initially be hidden");
|
||||
|
||||
// Test highlighting the addons button on toolbar
|
||||
let highlightVisiblePromise = elementVisiblePromise(highlight, "Should show highlight");
|
||||
gContentAPI.showHighlight("addons");
|
||||
await highlightVisiblePromise;
|
||||
UITour.getTarget(window, "addons").then((target) => {
|
||||
is("add-ons-button", target.node.id, "Should highlight the right target");
|
||||
CustomizableUI.removeWidgetFromArea("add-ons-button");
|
||||
});
|
||||
});
|
||||
|
||||
add_UITour_task(async function test_highlight_library_and_show_library_subview() {
|
||||
CustomizableUI.removeWidgetFromArea("library-button");
|
||||
|
||||
ok(!UITour.availableTargetsCache.has(window),
|
||||
"Targets should be evicted from cache after widget change");
|
||||
let highlight = document.getElementById("UITourHighlight");
|
||||
is_element_hidden(highlight, "Highlight should initially be hidden");
|
||||
|
||||
|
@ -37,5 +71,6 @@ add_UITour_task(async function test_highlight_library_and_show_library_subview()
|
|||
gContentAPI.hideMenu("appMenu");
|
||||
await appMenuHiddenPromise;
|
||||
is(appMenu.state, "closed", "Should close the app menu");
|
||||
CustomizableUI.addWidgetToArea("library", CustomizableUI.AREA_NAVBAR, 0);
|
||||
});
|
||||
|
||||
|
|
|
@ -15,17 +15,16 @@ function getExpectedTargets() {
|
|||
"addons",
|
||||
"appMenu",
|
||||
"backForward",
|
||||
"bookmark-star-button",
|
||||
"customize",
|
||||
"devtools",
|
||||
"help",
|
||||
"home",
|
||||
"library",
|
||||
"pageActionButton",
|
||||
"pageAction-panel-bookmark",
|
||||
"pageAction-panel-copyURL",
|
||||
"pageAction-panel-emailLink",
|
||||
"pageAction-panel-sendToDevice",
|
||||
"pageAction-bookmark",
|
||||
"pageAction-copyURL",
|
||||
"pageAction-emailLink",
|
||||
"pageAction-sendToDevice",
|
||||
...(hasPocket ? ["pocket"] : []),
|
||||
"privateWindow",
|
||||
...(hasQuit ? ["quit"] : []),
|
||||
|
@ -43,7 +42,6 @@ add_UITour_task(async function test_availableTargets() {
|
|||
let data = await getConfigurationPromise("availableTargets");
|
||||
let expecteds = getExpectedTargets();
|
||||
ok_targets(data, expecteds);
|
||||
|
||||
ok(UITour.availableTargetsCache.has(window),
|
||||
"Targets should now be cached");
|
||||
});
|
||||
|
@ -77,6 +75,44 @@ add_UITour_task(async function test_availableTargets_exceptionFromGetTarget() {
|
|||
CustomizableUI.reset();
|
||||
});
|
||||
|
||||
add_UITour_task(async function test_availableTargets_removeUrlbarPageActionsAll() {
|
||||
pageActionsHelper.setActionsUrlbarState(false);
|
||||
UITour.clearAvailableTargetsCache();
|
||||
let data = await getConfigurationPromise("availableTargets");
|
||||
let expecteds = getExpectedTargets();
|
||||
ok_targets(data, expecteds);
|
||||
let expectedActions = [
|
||||
[ "pocket", "pageAction-panel-pocket" ],
|
||||
[ "pageAction-bookmark", "pageAction-panel-bookmark" ],
|
||||
[ "pageAction-copyURL", "pageAction-panel-copyURL" ],
|
||||
[ "pageAction-emailLink", "pageAction-panel-emailLink" ],
|
||||
[ "pageAction-sendToDevice", "pageAction-panel-sendToDevice" ],
|
||||
];
|
||||
for (let [ targetName, expectedNodeId ] of expectedActions) {
|
||||
await assertTargetNode(targetName, expectedNodeId);
|
||||
}
|
||||
pageActionsHelper.restoreActionsUrlbarState();
|
||||
});
|
||||
|
||||
add_UITour_task(async function test_availableTargets_addUrlbarPageActionsAll() {
|
||||
pageActionsHelper.setActionsUrlbarState(true);
|
||||
UITour.clearAvailableTargetsCache();
|
||||
let data = await getConfigurationPromise("availableTargets");
|
||||
let expecteds = getExpectedTargets();
|
||||
ok_targets(data, expecteds);
|
||||
let expectedActions = [
|
||||
[ "pocket", "pocket-button-box" ],
|
||||
[ "pageAction-bookmark", "star-button-box" ],
|
||||
[ "pageAction-copyURL", "pageAction-urlbar-copyURL" ],
|
||||
[ "pageAction-emailLink", "pageAction-urlbar-emailLink" ],
|
||||
[ "pageAction-sendToDevice", "pageAction-urlbar-sendToDevice" ],
|
||||
];
|
||||
for (let [ targetName, expectedNodeId ] of expectedActions) {
|
||||
await assertTargetNode(targetName, expectedNodeId);
|
||||
}
|
||||
pageActionsHelper.restoreActionsUrlbarState();
|
||||
});
|
||||
|
||||
function ok_targets(actualData, expectedTargets) {
|
||||
// Depending on how soon after page load this is called, the selected tab icon
|
||||
// may or may not be showing the loading throbber. Check for its presence and
|
||||
|
@ -92,3 +128,28 @@ function ok_targets(actualData, expectedTargets) {
|
|||
is(actualData.targets.sort().toString(), expectedTargets.sort().toString(),
|
||||
"Targets should be as expected");
|
||||
}
|
||||
|
||||
async function assertTargetNode(targetName, expectedNodeId) {
|
||||
let target = await UITour.getTarget(window, targetName);
|
||||
is(target.node.id, expectedNodeId, "UITour should get the right target node");
|
||||
}
|
||||
|
||||
var pageActionsHelper = {
|
||||
setActionsUrlbarState(inUrlbar) {
|
||||
this._originalStates = [];
|
||||
PageActions._actionsByID.forEach(action => {
|
||||
this._originalStates.push([ action, action.shownInUrlbar ]);
|
||||
action.shownInUrlbar = inUrlbar;
|
||||
});
|
||||
},
|
||||
|
||||
restoreActionsUrlbarState() {
|
||||
if (!this._originalStates) {
|
||||
return;
|
||||
}
|
||||
for (let [ action, originalState] of this._originalStates) {
|
||||
action.shownInUrlbar = originalState;
|
||||
}
|
||||
this._originalStates = null;
|
||||
}
|
||||
};
|
||||
|
|
|
@ -66,6 +66,8 @@ for (const type of [
|
|||
"TELEMETRY_PERFORMANCE_EVENT",
|
||||
"TELEMETRY_UNDESIRED_EVENT",
|
||||
"TELEMETRY_USER_EVENT",
|
||||
"TOP_SITES_EDIT_CLOSE",
|
||||
"TOP_SITES_EDIT_OPEN",
|
||||
"TOP_SITES_PIN",
|
||||
"TOP_SITES_UNPIN",
|
||||
"TOP_SITES_UPDATED",
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
|
||||
const {actionTypes: at} = Components.utils.import("resource://activity-stream/common/Actions.jsm", {});
|
||||
|
||||
const TOP_SITES_SHOWMORE_LENGTH = 12;
|
||||
|
||||
const INITIAL_STATE = {
|
||||
App: {
|
||||
// Have we received real data from the app yet?
|
||||
|
@ -12,7 +14,7 @@ const INITIAL_STATE = {
|
|||
// The locale of the browser
|
||||
locale: "",
|
||||
// Localized strings with defaults
|
||||
strings: {},
|
||||
strings: null,
|
||||
// The version of the system-addon
|
||||
version: null
|
||||
},
|
||||
|
@ -67,7 +69,6 @@ function insertPinned(links, pinned) {
|
|||
newLinks = newLinks.map(link => {
|
||||
if (link && link.isPinned) {
|
||||
delete link.isPinned;
|
||||
delete link.pinTitle;
|
||||
delete link.pinIndex;
|
||||
}
|
||||
return link;
|
||||
|
@ -76,7 +77,7 @@ function insertPinned(links, pinned) {
|
|||
// Then insert them in their specified location
|
||||
pinned.forEach((val, index) => {
|
||||
if (!val) { return; }
|
||||
let link = Object.assign({}, val, {isPinned: true, pinIndex: index, pinTitle: val.title});
|
||||
let link = Object.assign({}, val, {isPinned: true, pinIndex: index});
|
||||
if (index > newLinks.length) {
|
||||
newLinks[index] = link;
|
||||
} else {
|
||||
|
@ -139,7 +140,7 @@ function TopSites(prevState = INITIAL_STATE.TopSites, action) {
|
|||
return Object.assign({}, prevState, {rows: newRows});
|
||||
case at.PINNED_SITES_UPDATED:
|
||||
pinned = action.data;
|
||||
newRows = insertPinned(prevState.rows, pinned);
|
||||
newRows = insertPinned(prevState.rows, pinned).slice(0, TOP_SITES_SHOWMORE_LENGTH);
|
||||
return Object.assign({}, prevState, {rows: newRows});
|
||||
default:
|
||||
return prevState;
|
||||
|
@ -254,8 +255,9 @@ function Snippets(prevState = INITIAL_STATE.Snippets, action) {
|
|||
}
|
||||
|
||||
this.INITIAL_STATE = INITIAL_STATE;
|
||||
this.TOP_SITES_SHOWMORE_LENGTH = TOP_SITES_SHOWMORE_LENGTH;
|
||||
|
||||
this.reducers = {TopSites, App, Snippets, Prefs, Dialog, Sections};
|
||||
this.insertPinned = insertPinned;
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned"];
|
||||
this.EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned", "TOP_SITES_SHOWMORE_LENGTH"];
|
||||
|
|
|
@ -103,7 +103,7 @@ const globalImportContext = typeof Window === "undefined" ? BACKGROUND_PROCESS :
|
|||
// UNINIT: "UNINIT"
|
||||
// }
|
||||
const actionTypes = {};
|
||||
for (const type of ["BLOCK_URL", "BOOKMARK_URL", "DELETE_BOOKMARK_BY_ID", "DELETE_HISTORY_URL", "DELETE_HISTORY_URL_CONFIRM", "DIALOG_CANCEL", "DIALOG_OPEN", "FEED_INIT", "INIT", "LOCALE_UPDATED", "MIGRATION_CANCEL", "MIGRATION_START", "NEW_TAB_INIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_UNLOAD", "OPEN_LINK", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "PINNED_SITES_UPDATED", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_CHANGED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "SAVE_SESSION_PERF_DATA", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_REGISTER", "SECTION_ROWS_UPDATE", "SET_PREF", "SNIPPETS_DATA", "SNIPPETS_RESET", "SYSTEM_TICK", "TELEMETRY_IMPRESSION_STATS", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_PIN", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "UNINIT"]) {
|
||||
for (const type of ["BLOCK_URL", "BOOKMARK_URL", "DELETE_BOOKMARK_BY_ID", "DELETE_HISTORY_URL", "DELETE_HISTORY_URL_CONFIRM", "DIALOG_CANCEL", "DIALOG_OPEN", "FEED_INIT", "INIT", "LOCALE_UPDATED", "MIGRATION_CANCEL", "MIGRATION_START", "NEW_TAB_INIT", "NEW_TAB_INITIAL_STATE", "NEW_TAB_LOAD", "NEW_TAB_UNLOAD", "OPEN_LINK", "OPEN_NEW_WINDOW", "OPEN_PRIVATE_WINDOW", "PINNED_SITES_UPDATED", "PLACES_BOOKMARK_ADDED", "PLACES_BOOKMARK_CHANGED", "PLACES_BOOKMARK_REMOVED", "PLACES_HISTORY_CLEARED", "PLACES_LINK_BLOCKED", "PLACES_LINK_DELETED", "PREFS_INITIAL_VALUES", "PREF_CHANGED", "SAVE_SESSION_PERF_DATA", "SAVE_TO_POCKET", "SCREENSHOT_UPDATED", "SECTION_DEREGISTER", "SECTION_REGISTER", "SECTION_ROWS_UPDATE", "SET_PREF", "SNIPPETS_DATA", "SNIPPETS_RESET", "SYSTEM_TICK", "TELEMETRY_IMPRESSION_STATS", "TELEMETRY_PERFORMANCE_EVENT", "TELEMETRY_UNDESIRED_EVENT", "TELEMETRY_USER_EVENT", "TOP_SITES_EDIT_CLOSE", "TOP_SITES_EDIT_OPEN", "TOP_SITES_PIN", "TOP_SITES_UNPIN", "TOP_SITES_UPDATED", "UNINIT"]) {
|
||||
actionTypes[type] = type;
|
||||
}
|
||||
|
||||
|
@ -331,7 +331,7 @@ var _require2 = __webpack_require__(1);
|
|||
const ac = _require2.actionCreators;
|
||||
|
||||
const linkMenuOptions = __webpack_require__(23);
|
||||
const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow"];
|
||||
const DEFAULT_SITE_MENU_OPTIONS = ["CheckPinTopSite", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"];
|
||||
|
||||
class LinkMenu extends React.Component {
|
||||
getOptions() {
|
||||
|
@ -579,8 +579,11 @@ function addLocaleDataForReactIntl(_ref) {
|
|||
|
||||
class Base extends React.Component {
|
||||
componentDidMount() {
|
||||
// Also wait for the preloaded page to show, so the tab's title updates
|
||||
addEventListener("visibilitychange", () => this.updateTitle(this.props.App), { once: true });
|
||||
// Also wait for the preloaded page to show, so the tab's title and favicon updates
|
||||
addEventListener("visibilitychange", () => {
|
||||
this.updateTitle(this.props.App);
|
||||
document.getElementById("favicon").href += "#";
|
||||
}, { once: true });
|
||||
}
|
||||
componentWillUpdate(_ref2) {
|
||||
let App = _ref2.App;
|
||||
|
@ -595,7 +598,9 @@ class Base extends React.Component {
|
|||
updateTitle(_ref3) {
|
||||
let strings = _ref3.strings;
|
||||
|
||||
document.title = strings.newtab_page_title;
|
||||
if (strings) {
|
||||
document.title = strings.newtab_page_title;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
@ -606,7 +611,7 @@ class Base extends React.Component {
|
|||
initialized = _props$App.initialized;
|
||||
|
||||
const prefs = props.Prefs.values;
|
||||
if (!initialized) {
|
||||
if (!initialized || !strings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -1127,6 +1132,8 @@ var _require = __webpack_require__(1);
|
|||
const at = _require.actionTypes;
|
||||
|
||||
|
||||
const TOP_SITES_SHOWMORE_LENGTH = 12;
|
||||
|
||||
const INITIAL_STATE = {
|
||||
App: {
|
||||
// Have we received real data from the app yet?
|
||||
|
@ -1134,7 +1141,7 @@ const INITIAL_STATE = {
|
|||
// The locale of the browser
|
||||
locale: "",
|
||||
// Localized strings with defaults
|
||||
strings: {},
|
||||
strings: null,
|
||||
// The version of the system-addon
|
||||
version: null
|
||||
},
|
||||
|
@ -1196,7 +1203,6 @@ function insertPinned(links, pinned) {
|
|||
newLinks = newLinks.map(link => {
|
||||
if (link && link.isPinned) {
|
||||
delete link.isPinned;
|
||||
delete link.pinTitle;
|
||||
delete link.pinIndex;
|
||||
}
|
||||
return link;
|
||||
|
@ -1207,7 +1213,7 @@ function insertPinned(links, pinned) {
|
|||
if (!val) {
|
||||
return;
|
||||
}
|
||||
let link = Object.assign({}, val, { isPinned: true, pinIndex: index, pinTitle: val.title });
|
||||
let link = Object.assign({}, val, { isPinned: true, pinIndex: index });
|
||||
if (index > newLinks.length) {
|
||||
newLinks[index] = link;
|
||||
} else {
|
||||
|
@ -1277,7 +1283,7 @@ function TopSites() {
|
|||
return Object.assign({}, prevState, { rows: newRows });
|
||||
case at.PINNED_SITES_UPDATED:
|
||||
pinned = action.data;
|
||||
newRows = insertPinned(prevState.rows, pinned);
|
||||
newRows = insertPinned(prevState.rows, pinned).slice(0, TOP_SITES_SHOWMORE_LENGTH);
|
||||
return Object.assign({}, prevState, { rows: newRows });
|
||||
default:
|
||||
return prevState;
|
||||
|
@ -1410,7 +1416,8 @@ var reducers = { TopSites, App, Snippets, Prefs, Dialog, Sections };
|
|||
module.exports = {
|
||||
reducers,
|
||||
INITIAL_STATE,
|
||||
insertPinned
|
||||
insertPinned,
|
||||
TOP_SITES_SHOWMORE_LENGTH
|
||||
};
|
||||
|
||||
/***/ }),
|
||||
|
@ -1501,9 +1508,11 @@ class Card extends React.Component {
|
|||
eventSource = _props.eventSource;
|
||||
|
||||
const isContextMenuOpen = this.state.showContextMenu && this.state.activeCard === index;
|
||||
var _cardContextTypes$lin = cardContextTypes[link.type];
|
||||
const icon = _cardContextTypes$lin.icon,
|
||||
intlID = _cardContextTypes$lin.intlID;
|
||||
|
||||
var _ref = link.type ? cardContextTypes[link.type] : {};
|
||||
|
||||
const icon = _ref.icon,
|
||||
intlID = _ref.intlID;
|
||||
|
||||
|
||||
return React.createElement(
|
||||
|
@ -1518,15 +1527,15 @@ class Card extends React.Component {
|
|||
link.image && React.createElement("div", { className: "card-preview-image", style: { backgroundImage: `url(${link.image})` } }),
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "card-details" },
|
||||
React.createElement(
|
||||
{ className: `card-details${link.image ? "" : " no-image"}` },
|
||||
link.hostname && React.createElement(
|
||||
"div",
|
||||
{ className: "card-host-name" },
|
||||
link.hostname
|
||||
),
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: `card-text${link.image ? "" : " full-height"}` },
|
||||
{ className: `card-text${link.image ? "" : " no-image"}${link.hostname ? "" : " no-host-name"}${icon ? "" : " no-context"}` },
|
||||
React.createElement(
|
||||
"h4",
|
||||
{ className: "card-title", dir: "auto" },
|
||||
|
@ -1538,7 +1547,7 @@ class Card extends React.Component {
|
|||
link.description
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
icon && React.createElement(
|
||||
"div",
|
||||
{ className: "card-context" },
|
||||
React.createElement("span", { className: `card-context-icon icon icon-${icon}` }),
|
||||
|
@ -2268,10 +2277,11 @@ class Section extends React.Component {
|
|||
infoOption = _props.infoOption,
|
||||
emptyState = _props.emptyState,
|
||||
dispatch = _props.dispatch,
|
||||
maxCards = _props.maxCards,
|
||||
maxRows = _props.maxRows,
|
||||
contextMenuOptions = _props.contextMenuOptions,
|
||||
intl = _props.intl;
|
||||
|
||||
const maxCards = 3 * maxRows;
|
||||
const initialized = rows && rows.length > 0;
|
||||
const shouldShowTopics = id === "TopStories" && this.props.topics && this.props.read_more_endpoint;
|
||||
|
||||
|
@ -2296,8 +2306,8 @@ class Section extends React.Component {
|
|||
React.createElement(
|
||||
"h3",
|
||||
{ className: "section-title" },
|
||||
React.createElement("span", { className: `icon icon-small-spacer icon-${icon}` }),
|
||||
React.createElement(FormattedMessage, title)
|
||||
icon && icon.startsWith("moz-extension://") ? React.createElement("span", { className: "icon icon-small-spacer", style: { "background-image": `url('${icon}')` } }) : React.createElement("span", { className: `icon icon-small-spacer icon-${icon || "webextension"}` }),
|
||||
this.getFormattedMessage(title)
|
||||
),
|
||||
infoOption && React.createElement(
|
||||
"span",
|
||||
|
@ -2314,17 +2324,17 @@ class Section extends React.Component {
|
|||
infoOption.header && React.createElement(
|
||||
"div",
|
||||
{ className: "info-option-header", role: "heading" },
|
||||
React.createElement(FormattedMessage, infoOption.header)
|
||||
this.getFormattedMessage(infoOption.header)
|
||||
),
|
||||
infoOption.body && React.createElement(
|
||||
"p",
|
||||
{ className: "info-option-body" },
|
||||
React.createElement(FormattedMessage, infoOption.body)
|
||||
this.getFormattedMessage(infoOption.body)
|
||||
),
|
||||
infoOption.link && React.createElement(
|
||||
"a",
|
||||
{ href: infoOption.link.href, target: "_blank", rel: "noopener noreferrer", className: "info-option-link" },
|
||||
React.createElement(FormattedMessage, infoOption.link)
|
||||
this.getFormattedMessage(infoOption.link.title || infoOption.link)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -2344,7 +2354,7 @@ class Section extends React.Component {
|
|||
React.createElement(
|
||||
"p",
|
||||
{ className: "empty-state-message" },
|
||||
React.createElement(FormattedMessage, emptyState.message)
|
||||
this.getFormattedMessage(emptyState.message)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
@ -2389,7 +2399,8 @@ const connect = _require.connect;
|
|||
|
||||
var _require2 = __webpack_require__(2);
|
||||
|
||||
const FormattedMessage = _require2.FormattedMessage;
|
||||
const FormattedMessage = _require2.FormattedMessage,
|
||||
injectIntl = _require2.injectIntl;
|
||||
|
||||
const LinkMenu = __webpack_require__(4);
|
||||
|
||||
|
@ -2412,6 +2423,8 @@ class TopSite extends React.Component {
|
|||
this.onLinkClick = this.onLinkClick.bind(this);
|
||||
this.onMenuButtonClick = this.onMenuButtonClick.bind(this);
|
||||
this.onMenuUpdate = this.onMenuUpdate.bind(this);
|
||||
this.onDismissButtonClick = this.onDismissButtonClick.bind(this);
|
||||
this.onPinButtonClick = this.onPinButtonClick.bind(this);
|
||||
}
|
||||
toggleContextMenu(event, index) {
|
||||
this.setState({
|
||||
|
@ -2419,13 +2432,21 @@ class TopSite extends React.Component {
|
|||
showContextMenu: true
|
||||
});
|
||||
}
|
||||
onLinkClick() {
|
||||
userEvent(event) {
|
||||
this.props.dispatch(ac.UserEvent({
|
||||
event: "CLICK",
|
||||
event,
|
||||
source: TOP_SITES_SOURCE,
|
||||
action_position: this.props.index
|
||||
}));
|
||||
}
|
||||
onLinkClick(ev) {
|
||||
if (this.props.editMode) {
|
||||
// Ignore clicks if we are in the edit modal.
|
||||
ev.preventDefault();
|
||||
return;
|
||||
}
|
||||
this.userEvent("CLICK");
|
||||
}
|
||||
onMenuButtonClick(event) {
|
||||
event.preventDefault();
|
||||
this.toggleContextMenu(event, this.props.index);
|
||||
|
@ -2433,14 +2454,49 @@ class TopSite extends React.Component {
|
|||
onMenuUpdate(showContextMenu) {
|
||||
this.setState({ showContextMenu });
|
||||
}
|
||||
render() {
|
||||
onDismissButtonClick() {
|
||||
const link = this.props.link;
|
||||
|
||||
if (link.isPinned) {
|
||||
this.props.dispatch(ac.SendToMain({
|
||||
type: at.TOP_SITES_UNPIN,
|
||||
data: { site: { url: link.url } }
|
||||
}));
|
||||
}
|
||||
this.props.dispatch(ac.SendToMain({
|
||||
type: at.BLOCK_URL,
|
||||
data: link.url
|
||||
}));
|
||||
this.userEvent("BLOCK");
|
||||
}
|
||||
onPinButtonClick() {
|
||||
var _props = this.props;
|
||||
const link = _props.link,
|
||||
index = _props.index,
|
||||
dispatch = _props.dispatch;
|
||||
index = _props.index;
|
||||
|
||||
if (link.isPinned) {
|
||||
this.props.dispatch(ac.SendToMain({
|
||||
type: at.TOP_SITES_UNPIN,
|
||||
data: { site: { url: link.url } }
|
||||
}));
|
||||
this.userEvent("UNPIN");
|
||||
} else {
|
||||
this.props.dispatch(ac.SendToMain({
|
||||
type: at.TOP_SITES_PIN,
|
||||
data: { site: { url: link.url }, index }
|
||||
}));
|
||||
this.userEvent("PIN");
|
||||
}
|
||||
}
|
||||
render() {
|
||||
var _props2 = this.props;
|
||||
const link = _props2.link,
|
||||
index = _props2.index,
|
||||
dispatch = _props2.dispatch,
|
||||
editMode = _props2.editMode;
|
||||
|
||||
const isContextMenuOpen = this.state.showContextMenu && this.state.activeTile === index;
|
||||
const title = link.pinTitle || link.hostname;
|
||||
const title = link.hostname;
|
||||
const topSiteOuterClassName = `top-site-outer${isContextMenuOpen ? " active" : ""}`;
|
||||
const tippyTopIcon = link.tippyTopIcon;
|
||||
|
||||
|
@ -2483,27 +2539,45 @@ class TopSite extends React.Component {
|
|||
)
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
"button",
|
||||
{ className: "context-menu-button", onClick: this.onMenuButtonClick },
|
||||
!editMode && React.createElement(
|
||||
"div",
|
||||
null,
|
||||
React.createElement(
|
||||
"span",
|
||||
{ className: "sr-only" },
|
||||
`Open context menu for ${title}`
|
||||
)
|
||||
"button",
|
||||
{ className: "context-menu-button", onClick: this.onMenuButtonClick },
|
||||
React.createElement(
|
||||
"span",
|
||||
{ className: "sr-only" },
|
||||
`Open context menu for ${title}`
|
||||
)
|
||||
),
|
||||
React.createElement(LinkMenu, {
|
||||
dispatch: dispatch,
|
||||
index: index,
|
||||
onUpdate: this.onMenuUpdate,
|
||||
options: TOP_SITES_CONTEXT_MENU_OPTIONS,
|
||||
site: link,
|
||||
source: TOP_SITES_SOURCE,
|
||||
visible: isContextMenuOpen })
|
||||
),
|
||||
React.createElement(LinkMenu, {
|
||||
dispatch: dispatch,
|
||||
index: index,
|
||||
onUpdate: this.onMenuUpdate,
|
||||
options: TOP_SITES_CONTEXT_MENU_OPTIONS,
|
||||
site: link,
|
||||
source: TOP_SITES_SOURCE,
|
||||
visible: isContextMenuOpen })
|
||||
editMode && React.createElement(
|
||||
"div",
|
||||
{ className: "edit-menu" },
|
||||
React.createElement("button", {
|
||||
className: `icon icon-${link.isPinned ? "unpin" : "pin"}`,
|
||||
title: this.props.intl.formatMessage({ id: `edit_topsites_${link.isPinned ? "unpin" : "pin"}_button` }),
|
||||
onClick: this.onPinButtonClick }),
|
||||
React.createElement("button", {
|
||||
className: "icon icon-dismiss",
|
||||
title: this.props.intl.formatMessage({ id: "edit_topsites_dismiss_button" }),
|
||||
onClick: this.onDismissButtonClick })
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
TopSite.defaultProps = { editMode: false };
|
||||
|
||||
/**
|
||||
* A proxy class that uses double requestAnimationFrame from
|
||||
* componentDidMount to dispatch a SAVE_SESSION_PERF_DATA to the main procsess
|
||||
|
@ -2613,7 +2687,7 @@ class TopSitesPerfTimer extends React.Component {
|
|||
|
||||
const TopSites = props => React.createElement(
|
||||
"section",
|
||||
null,
|
||||
{ className: "top-sites" },
|
||||
React.createElement(
|
||||
"h3",
|
||||
{ className: "section-title" },
|
||||
|
@ -2627,14 +2701,92 @@ const TopSites = props => React.createElement(
|
|||
key: link.guid || link.url,
|
||||
dispatch: props.dispatch,
|
||||
link: link,
|
||||
index: index }))
|
||||
)
|
||||
index: index,
|
||||
intl: props.intl }))
|
||||
),
|
||||
React.createElement(TopSitesEditIntl, props)
|
||||
);
|
||||
|
||||
class TopSitesEdit extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { showEditModal: false };
|
||||
this.onEditButtonClick = this.onEditButtonClick.bind(this);
|
||||
}
|
||||
onEditButtonClick() {
|
||||
this.setState({ showEditModal: !this.state.showEditModal });
|
||||
const event = this.state.showEditModal ? "TOP_SITES_EDIT_OPEN" : "TOP_SITES_EDIT_CLOSE";
|
||||
this.props.dispatch(ac.UserEvent({
|
||||
source: TOP_SITES_SOURCE,
|
||||
event
|
||||
}));
|
||||
}
|
||||
render() {
|
||||
return React.createElement(
|
||||
"div",
|
||||
{ className: "edit-topsites-wrapper" },
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "edit-topsites-button" },
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
className: "edit",
|
||||
title: this.props.intl.formatMessage({ id: "edit_topsites_button_label" }),
|
||||
onClick: this.onEditButtonClick },
|
||||
React.createElement(FormattedMessage, { id: "edit_topsites_button_text" })
|
||||
)
|
||||
),
|
||||
this.state.showEditModal && React.createElement(
|
||||
"div",
|
||||
{ className: "edit-topsites" },
|
||||
React.createElement("div", { className: "modal-overlay" }),
|
||||
React.createElement(
|
||||
"div",
|
||||
{ className: "modal" },
|
||||
React.createElement(
|
||||
"section",
|
||||
{ className: "edit-topsites-inner-wrapper" },
|
||||
React.createElement(
|
||||
"h3",
|
||||
{ className: "section-title" },
|
||||
React.createElement("span", { className: `icon icon-small-spacer icon-topsites` }),
|
||||
React.createElement(FormattedMessage, { id: "header_top_sites" })
|
||||
),
|
||||
React.createElement(
|
||||
"ul",
|
||||
{ className: "top-sites-list" },
|
||||
this.props.TopSites.rows.map((link, index) => link && React.createElement(TopSite, {
|
||||
key: link.guid || link.url,
|
||||
dispatch: this.props.dispatch,
|
||||
link: link,
|
||||
index: index,
|
||||
intl: this.props.intl,
|
||||
editMode: true }))
|
||||
)
|
||||
),
|
||||
React.createElement(
|
||||
"section",
|
||||
{ className: "actions" },
|
||||
React.createElement(
|
||||
"button",
|
||||
{ className: "done", onClick: this.onEditButtonClick },
|
||||
React.createElement(FormattedMessage, { id: "edit_topsites_done_button" })
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const TopSitesEditIntl = injectIntl(TopSitesEdit);
|
||||
|
||||
module.exports = connect(state => ({ TopSites: state.TopSites }))(TopSitesPerfTimer);
|
||||
module.exports._unconnected = TopSitesPerfTimer;
|
||||
module.exports.TopSite = TopSite;
|
||||
module.exports.TopSites = TopSites;
|
||||
module.exports.TopSitesEdit = TopSitesEdit;
|
||||
|
||||
/***/ }),
|
||||
/* 22 */
|
||||
|
@ -2790,7 +2942,7 @@ module.exports = {
|
|||
icon: "pin",
|
||||
action: ac.SendToMain({
|
||||
type: at.TOP_SITES_PIN,
|
||||
data: { site: { url: site.url, title: site.hostname }, index }
|
||||
data: { site: { url: site.url }, index }
|
||||
}),
|
||||
userEvent: "PIN"
|
||||
}),
|
||||
|
|
|
@ -70,6 +70,8 @@ input {
|
|||
width: 12px; }
|
||||
.icon.icon-check {
|
||||
background-image: url("chrome://browser/skin/check.svg"); }
|
||||
.icon.icon-webextension {
|
||||
background-image: url("assets/glyph-webextension-16.svg"); }
|
||||
|
||||
html,
|
||||
body,
|
||||
|
@ -169,14 +171,13 @@ main {
|
|||
main {
|
||||
width: 736px; } }
|
||||
main section {
|
||||
margin-bottom: 40px; }
|
||||
margin-bottom: 32px; }
|
||||
|
||||
.section-title {
|
||||
color: #6E707E;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
margin: 0 0 18px; }
|
||||
text-transform: uppercase; }
|
||||
.section-title span {
|
||||
vertical-align: middle; }
|
||||
|
||||
|
@ -309,10 +310,10 @@ main {
|
|||
background-size: 96px;
|
||||
background-repeat: no-repeat; }
|
||||
.top-sites-list .top-site-outer .title {
|
||||
font: message-box;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
width: 96px;
|
||||
position: relative; }
|
||||
.top-sites-list .top-site-outer .title .icon {
|
||||
|
@ -326,6 +327,68 @@ main {
|
|||
white-space: nowrap; }
|
||||
.top-sites-list .top-site-outer .title.pinned span {
|
||||
padding: 0 13px; }
|
||||
.top-sites-list .top-site-outer .edit-menu {
|
||||
background: #FFF;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 12.5px;
|
||||
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.1);
|
||||
height: 25px;
|
||||
position: absolute;
|
||||
offset-inline-end: -12.5px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
top: -12.5px;
|
||||
transform: scale(0.25);
|
||||
transition-property: transform, opacity;
|
||||
transition-duration: 200ms;
|
||||
z-index: 1000; }
|
||||
.top-sites-list .top-site-outer .edit-menu:focus, .top-sites-list .top-site-outer .edit-menu:active {
|
||||
transform: scale(1);
|
||||
opacity: 1; }
|
||||
.top-sites-list .top-site-outer .edit-menu button {
|
||||
border: 0;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.2);
|
||||
background-color: #FFF;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
width: 25px; }
|
||||
.top-sites-list .top-site-outer .edit-menu button:hover {
|
||||
background-color: #FBFBFB; }
|
||||
.top-sites-list .top-site-outer .edit-menu button:last-child:dir(ltr) {
|
||||
border-right: 0; }
|
||||
.top-sites-list .top-site-outer .edit-menu button:first-child:dir(rtl) {
|
||||
border-right: 0; }
|
||||
.top-sites-list .top-site-outer:hover .edit-menu, .top-sites-list .top-site-outer:focus .edit-menu, .top-sites-list .top-site-outer.active .edit-menu {
|
||||
transform: scale(1);
|
||||
opacity: 1; }
|
||||
|
||||
.top-sites {
|
||||
position: relative; }
|
||||
|
||||
.edit-topsites-wrapper .edit-topsites-button {
|
||||
position: absolute;
|
||||
offset-inline-end: 0;
|
||||
top: -2px; }
|
||||
.edit-topsites-wrapper .edit-topsites-button button {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: #A0A0A0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0; }
|
||||
.edit-topsites-wrapper .edit-topsites-button button:focus {
|
||||
background: #EBEBEB;
|
||||
border-bottom: dotted 1px #A0A0A0; }
|
||||
|
||||
.edit-topsites-wrapper .modal {
|
||||
offset-inline-start: -31px;
|
||||
position: absolute;
|
||||
top: -29px;
|
||||
width: calc(100% + 62px); }
|
||||
|
||||
.edit-topsites-wrapper .edit-topsites-inner-wrapper {
|
||||
margin: 0;
|
||||
padding: 15px 30px; }
|
||||
|
||||
.sections-list .section-top-bar {
|
||||
position: relative;
|
||||
|
@ -334,7 +397,8 @@ main {
|
|||
.sections-list .section-top-bar .section-title {
|
||||
float: left; }
|
||||
.sections-list .section-top-bar .section-info-option {
|
||||
float: right; }
|
||||
float: right;
|
||||
margin-top: 14px; }
|
||||
.sections-list .section-top-bar .info-option-icon {
|
||||
background-image: url("assets/glyph-info-option-12.svg");
|
||||
background-size: 12px 12px;
|
||||
|
@ -433,7 +497,7 @@ main {
|
|||
.topic {
|
||||
font-size: 12px;
|
||||
color: #BFC0C7;
|
||||
margin-top: 16px;
|
||||
margin-top: 12px;
|
||||
line-height: 1.6; }
|
||||
@media (min-width: 800px) {
|
||||
.topic {
|
||||
|
@ -470,8 +534,7 @@ main {
|
|||
margin-left: 5px;
|
||||
background-image: url("assets/topic-show-more-12.svg");
|
||||
background-repeat: no-repeat;
|
||||
vertical-align: middle;
|
||||
background-position-y: 1px; }
|
||||
vertical-align: middle; }
|
||||
|
||||
.search-wrapper {
|
||||
cursor: default;
|
||||
|
@ -738,7 +801,6 @@ main {
|
|||
margin-inline-end: 32px;
|
||||
width: 224px;
|
||||
border-radius: 3px;
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
height: 266px;
|
||||
position: relative; }
|
||||
.card-outer .context-menu-button {
|
||||
|
@ -766,22 +828,23 @@ main {
|
|||
opacity: 1; }
|
||||
.card-outer .card {
|
||||
height: 100%;
|
||||
border-radius: 3px; }
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 1px 4px 0 rgba(9, 6, 13, 0.1); }
|
||||
.card-outer > a {
|
||||
display: block;
|
||||
color: inherit;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
position: absolute;
|
||||
max-width: 224px; }
|
||||
width: 224px; }
|
||||
.card-outer > a.active .card, .card-outer > a:focus .card {
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 150ms; }
|
||||
.card-outer > a.active .card-title, .card-outer > a:focus .card-title {
|
||||
color: #00AFF7; }
|
||||
.card-outer:hover, .card-outer:focus, .card-outer.active {
|
||||
outline: none;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1), 0 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 0 0 5px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 150ms; }
|
||||
.card-outer:hover .context-menu-button, .card-outer:focus .context-menu-button, .card-outer.active .context-menu-button {
|
||||
transform: scale(1);
|
||||
|
@ -799,12 +862,22 @@ main {
|
|||
border-bottom-width: 1px;
|
||||
border-radius: 3px 3px 0 0; }
|
||||
.card-outer .card-details {
|
||||
padding: 10px 16px 12px; }
|
||||
padding: 15px 16px 12px; }
|
||||
.card-outer .card-details.no-image {
|
||||
padding-top: 16px; }
|
||||
.card-outer .card-text {
|
||||
overflow: hidden;
|
||||
max-height: 78px; }
|
||||
.card-outer .card-text.full-height {
|
||||
max-height: 200px; }
|
||||
.card-outer .card-text.no-image {
|
||||
max-height: 192px; }
|
||||
.card-outer .card-text.no-host-name, .card-outer .card-text.no-context {
|
||||
max-height: 97px; }
|
||||
.card-outer .card-text.no-image.no-host-name, .card-outer .card-text.no-image.no-context {
|
||||
max-height: 211px; }
|
||||
.card-outer .card-text.no-host-name.no-context {
|
||||
max-height: 116px; }
|
||||
.card-outer .card-text.no-image.no-host-name.no-context {
|
||||
max-height: 230px; }
|
||||
.card-outer .card-host-name {
|
||||
color: #858585;
|
||||
font-size: 10px;
|
||||
|
@ -812,7 +885,7 @@ main {
|
|||
text-transform: uppercase; }
|
||||
.card-outer .card-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: inherit;
|
||||
font-size: 14px;
|
||||
word-wrap: break-word;
|
||||
line-height: 19px; }
|
||||
.card-outer .card-description {
|
||||
|
@ -822,15 +895,14 @@ main {
|
|||
overflow: hidden;
|
||||
line-height: 19px; }
|
||||
.card-outer .card-context {
|
||||
padding: 16px 16px 14px 14px;
|
||||
padding: 16px 16px 8px 14px;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
color: #A0A0A0;
|
||||
font-size: 11px;
|
||||
display: flex;
|
||||
align-items: center; }
|
||||
display: flex; }
|
||||
.card-outer .card-context-icon {
|
||||
opacity: 0.5;
|
||||
font-size: 13px;
|
||||
|
|
|
@ -4,6 +4,7 @@
|
|||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy-Report-Only" content="script-src 'unsafe-inline'; img-src http: https: data: blob:; style-src 'unsafe-inline'; child-src 'none'; object-src 'none'; report-uri https://tiles.services.mozilla.com/v4/links/activity-stream/csp">
|
||||
<title></title>
|
||||
<link rel="icon" type="image/png" id="favicon" href="chrome://branding/content/icon32.png"/>
|
||||
<link rel="stylesheet" href="chrome://browser/content/contentSearchUI.css" />
|
||||
<link rel="stylesheet" href="resource://activity-stream/data/content/activity-stream.css" />
|
||||
</head>
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<!-- 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" viewBox="0 0 16 16">
|
||||
<path fill="#4d4d4d" d="M14.5 8c-.971 0-1 1-1.75 1a.765.765 0 0 1-.75-.75V5a1 1 0 0 0-1-1H7.75A.765.765 0 0 1 7 3.25c0-.75 1-.779 1-1.75C8 .635 7.1 0 6 0S4 .635 4 1.5c0 .971 1 1 1 1.75a.765.765 0 0 1-.75.75H1a1 1 0 0 0-1 1v2.25A.765.765 0 0 0 .75 8c.75 0 .779-1 1.75-1C3.365 7 4 7.9 4 9s-.635 2-1.5 2c-.971 0-1-1-1.75-1a.765.765 0 0 0-.75.75V15a1 1 0 0 0 1 1h3.25a.765.765 0 0 0 .75-.75c0-.75-1-.779-1-1.75 0-.865.9-1.5 2-1.5s2 .635 2 1.5c0 .971-1 1-1 1.75a.765.765 0 0 0 .75.75H11a1 1 0 0 0 1-1v-3.25a.765.765 0 0 1 .75-.75c.75 0 .779 1 1.75 1 .865 0 1.5-.9 1.5-2s-.635-2-1.5-2z"/>
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 888 B |
|
@ -1163,6 +1163,7 @@
|
|||
"en-US": {
|
||||
"newtab_page_title": "New Tab",
|
||||
"default_label_loading": "Loading…",
|
||||
"home_page_title": "{build} Start Page",
|
||||
"header_top_sites": "Top Sites",
|
||||
"header_stories": "Top Stories",
|
||||
"header_visit_again": "Visit Again",
|
||||
|
|
|
@ -13,6 +13,7 @@ const {DefaultPrefs} = Cu.import("resource://activity-stream/lib/ActivityStreamP
|
|||
const {LocalizationFeed} = Cu.import("resource://activity-stream/lib/LocalizationFeed.jsm", {});
|
||||
const {ManualMigration} = Cu.import("resource://activity-stream/lib/ManualMigration.jsm", {});
|
||||
const {NewTabInit} = Cu.import("resource://activity-stream/lib/NewTabInit.jsm", {});
|
||||
const {SectionsFeed} = Cu.import("resource://activity-stream/lib/SectionsManager.jsm", {});
|
||||
const {PlacesFeed} = Cu.import("resource://activity-stream/lib/PlacesFeed.jsm", {});
|
||||
const {PrefsFeed} = Cu.import("resource://activity-stream/lib/PrefsFeed.jsm", {});
|
||||
const {Store} = Cu.import("resource://activity-stream/lib/Store.jsm", {});
|
||||
|
@ -141,6 +142,12 @@ const FEEDS_DATA = [
|
|||
title: "Preferences",
|
||||
value: true
|
||||
},
|
||||
{
|
||||
name: "sections",
|
||||
factory: () => new SectionsFeed(),
|
||||
title: "Manages sections",
|
||||
value: true
|
||||
},
|
||||
{
|
||||
name: "section.topstories",
|
||||
factory: () => new TopStoriesFeed(),
|
||||
|
|
|
@ -230,7 +230,7 @@ class PlacesFeed {
|
|||
if (action.data.referrer) {
|
||||
win.openLinkIn(action.data.url, where, {referrerURI: Services.io.newURI(action.data.referrer)});
|
||||
} else {
|
||||
win.openLinkIn(action.data.url, where);
|
||||
win.openLinkIn(action.data.url, where, {});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
@ -20,6 +20,24 @@ XPCOMUtils.defineLazyModuleGetter(this, "OS",
|
|||
"resource://gre/modules/osfile.jsm");
|
||||
|
||||
this.Screenshots = {
|
||||
/**
|
||||
* Convert bytes to a string using extremely fast String.fromCharCode without
|
||||
* exceeding the max number of arguments that can be provided to a function.
|
||||
*/
|
||||
_bytesToString(bytes) {
|
||||
// NB: This comes from js/src/vm/ArgumentsObject.h ARGS_LENGTH_MAX
|
||||
const ARGS_LENGTH_MAX = 500 * 1000;
|
||||
let i = 0;
|
||||
let str = "";
|
||||
let {length} = bytes;
|
||||
while (i < length) {
|
||||
const start = i;
|
||||
i += ARGS_LENGTH_MAX;
|
||||
str += String.fromCharCode.apply(null, bytes.slice(start, i));
|
||||
}
|
||||
return str;
|
||||
},
|
||||
|
||||
async getScreenshotForURL(url) {
|
||||
let screenshot = null;
|
||||
try {
|
||||
|
@ -34,7 +52,7 @@ this.Screenshots = {
|
|||
|
||||
const contentType = MIMEService.getTypeFromFile(nsFile);
|
||||
const bytes = await file.read();
|
||||
const encodedData = btoa(String.fromCharCode.apply(null, bytes));
|
||||
const encodedData = btoa(this._bytesToString(bytes));
|
||||
file.close();
|
||||
screenshot = `data:${contentType};base64,${encodedData}`;
|
||||
} catch (err) {
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
/* 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 {utils: Cu} = Components;
|
||||
const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
|
||||
Cu.import("resource://gre/modules/EventEmitter.jsm");
|
||||
|
||||
const SectionsManager = {
|
||||
ACTIONS_TO_PROXY: ["SYSTEM_TICK", "NEW_TAB_LOAD"],
|
||||
initialized: false,
|
||||
sections: new Set(),
|
||||
addSection(id, options) {
|
||||
this.sections.add(id);
|
||||
this.emit(this.ADD_SECTION, id, options);
|
||||
},
|
||||
removeSection(id) {
|
||||
this.emit(this.REMOVE_SECTION, id);
|
||||
this.sections.delete(id);
|
||||
},
|
||||
updateRows(id, rows, shouldBroadcast) {
|
||||
if (this.sections.has(id)) {
|
||||
this.emit(this.UPDATE_ROWS, id, rows, shouldBroadcast);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const action of [
|
||||
"ACTION_DISPATCHED",
|
||||
"ADD_SECTION",
|
||||
"REMOVE_SECTION",
|
||||
"UPDATE_ROWS",
|
||||
"INIT",
|
||||
"UNINIT"
|
||||
]) {
|
||||
SectionsManager[action] = action;
|
||||
}
|
||||
|
||||
EventEmitter.decorate(SectionsManager);
|
||||
|
||||
class SectionsFeed {
|
||||
constructor() {
|
||||
this.onAddSection = this.onAddSection.bind(this);
|
||||
this.onRemoveSection = this.onRemoveSection.bind(this);
|
||||
this.onUpdateRows = this.onUpdateRows.bind(this);
|
||||
}
|
||||
|
||||
init() {
|
||||
SectionsManager.on(SectionsManager.ADD_SECTION, this.onAddSection);
|
||||
SectionsManager.on(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
|
||||
SectionsManager.on(SectionsManager.UPDATE_ROWS, this.onUpdateRows);
|
||||
SectionsManager.initialized = true;
|
||||
SectionsManager.emit(SectionsManager.INIT);
|
||||
}
|
||||
|
||||
uninit() {
|
||||
SectionsManager.initialized = false;
|
||||
SectionsManager.emit(SectionsManager.UNINIT);
|
||||
SectionsManager.off(SectionsManager.ADD_SECTION, this.onAddSection);
|
||||
SectionsManager.off(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
|
||||
SectionsManager.off(SectionsManager.UPDATE_ROWS, this.onUpdateRows);
|
||||
}
|
||||
|
||||
onAddSection(event, id, options) {
|
||||
if (options) {
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_REGISTER, data: Object.assign({id}, options)}));
|
||||
}
|
||||
}
|
||||
|
||||
onRemoveSection(event, id) {
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: id}));
|
||||
}
|
||||
|
||||
onUpdateRows(event, id, rows, shouldBroadcast = false) {
|
||||
if (rows) {
|
||||
const action = {type: at.SECTION_ROWS_UPDATE, data: {id, rows}};
|
||||
this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : action);
|
||||
}
|
||||
}
|
||||
|
||||
onAction(action) {
|
||||
switch (action.type) {
|
||||
case at.INIT:
|
||||
this.init();
|
||||
break;
|
||||
}
|
||||
if (SectionsManager.ACTIONS_TO_PROXY.includes(action.type) && SectionsManager.sections.size > 0) {
|
||||
SectionsManager.emit(SectionsManager.ACTION_DISPATCHED, action.type, action.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.SectionsFeed = SectionsFeed;
|
||||
this.SectionsManager = SectionsManager;
|
||||
this.EXPORTED_SYMBOLS = ["SectionsFeed", "SectionsManager"];
|
|
@ -9,6 +9,8 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
const {actionTypes: at, actionCreators: ac} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ShellService",
|
||||
"resource:///modules/ShellService.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ProfileAge",
|
||||
"resource://gre/modules/ProfileAge.jsm");
|
||||
|
||||
|
@ -17,6 +19,10 @@ const SNIPPETS_URL_PREF = "browser.aboutHomeSnippets.updateUrl";
|
|||
const TELEMETRY_PREF = "datareporting.healthreport.uploadEnabled";
|
||||
const ONBOARDING_FINISHED_PREF = "browser.onboarding.notification.finished";
|
||||
const FXA_USERNAME_PREF = "services.sync.username";
|
||||
// Prefix for any target matching a search engine.
|
||||
const TARGET_SEARCHENGINE_PREFIX = "searchEngine-";
|
||||
|
||||
const SEARCH_ENGINE_OBSERVER_TOPIC = "browser-search-engine-modified";
|
||||
|
||||
// Should be bumped up if the snippets content format changes.
|
||||
const STARTPAGE_VERSION = 5;
|
||||
|
@ -27,12 +33,22 @@ this.SnippetsFeed = class SnippetsFeed {
|
|||
constructor() {
|
||||
this._refresh = this._refresh.bind(this);
|
||||
}
|
||||
|
||||
get snippetsURL() {
|
||||
const updateURL = Services
|
||||
.prefs.getStringPref(SNIPPETS_URL_PREF)
|
||||
.replace("%STARTPAGE_VERSION%", STARTPAGE_VERSION);
|
||||
return Services.urlFormatter.formatURL(updateURL);
|
||||
}
|
||||
|
||||
isDefaultBrowser() {
|
||||
try {
|
||||
return ShellService.isDefaultBrowser();
|
||||
} catch (e) {}
|
||||
// istanbul ignore next
|
||||
return null;
|
||||
}
|
||||
|
||||
async getProfileInfo() {
|
||||
const profileAge = new ProfileAge(null, null);
|
||||
const createdDate = await profileAge.created;
|
||||
|
@ -42,32 +58,68 @@ this.SnippetsFeed = class SnippetsFeed {
|
|||
resetWeeksAgo: resetDate ? Math.floor((Date.now() - resetDate) / ONE_WEEK) : null
|
||||
};
|
||||
}
|
||||
|
||||
getSelectedSearchEngine() {
|
||||
return new Promise(resolve => {
|
||||
// Note: calling init ensures this code is only executed after Search has been initialized
|
||||
Services.search.init(rv => {
|
||||
// istanbul ignore else
|
||||
if (Components.isSuccessCode(rv)) {
|
||||
let engines = Services.search.getVisibleEngines();
|
||||
resolve({
|
||||
searchEngineIdentifier: Services.search.defaultEngine.identifier,
|
||||
engines: engines
|
||||
.filter(engine => engine.identifier)
|
||||
.map(engine => `${TARGET_SEARCHENGINE_PREFIX}${engine.identifier}`)
|
||||
});
|
||||
} else {
|
||||
resolve({engines: [], searchEngineIdentifier: ""});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_dispatchChanges(data) {
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.SNIPPETS_DATA, data}));
|
||||
}
|
||||
|
||||
async _refresh() {
|
||||
const profileInfo = await this.getProfileInfo();
|
||||
const data = {
|
||||
snippetsURL: this.snippetsURL,
|
||||
version: STARTPAGE_VERSION,
|
||||
profileCreatedWeeksAgo: profileInfo.createdWeeksAgo,
|
||||
profileResetWeeksAgo: profileInfo.resetWeeksAgo,
|
||||
snippetsURL: this.snippetsURL,
|
||||
version: STARTPAGE_VERSION,
|
||||
telemetryEnabled: Services.prefs.getBoolPref(TELEMETRY_PREF),
|
||||
onboardingFinished: Services.prefs.getBoolPref(ONBOARDING_FINISHED_PREF),
|
||||
fxaccount: Services.prefs.prefHasUserValue(FXA_USERNAME_PREF)
|
||||
fxaccount: Services.prefs.prefHasUserValue(FXA_USERNAME_PREF),
|
||||
selectedSearchEngine: await this.getSelectedSearchEngine(),
|
||||
defaultBrowser: this.isDefaultBrowser()
|
||||
};
|
||||
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.SNIPPETS_DATA, data}));
|
||||
this._dispatchChanges(data);
|
||||
}
|
||||
|
||||
async observe(subject, topic, data) {
|
||||
if (topic === SEARCH_ENGINE_OBSERVER_TOPIC) {
|
||||
const selectedSearchEngine = await this.getSelectedSearchEngine();
|
||||
this._dispatchChanges({selectedSearchEngine});
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this._refresh();
|
||||
Services.prefs.addObserver(ONBOARDING_FINISHED_PREF, this._refresh);
|
||||
Services.prefs.addObserver(SNIPPETS_URL_PREF, this._refresh);
|
||||
Services.prefs.addObserver(TELEMETRY_PREF, this._refresh);
|
||||
Services.prefs.addObserver(FXA_USERNAME_PREF, this._refresh);
|
||||
Services.obs.addObserver(this, SEARCH_ENGINE_OBSERVER_TOPIC);
|
||||
}
|
||||
uninit() {
|
||||
Services.prefs.removeObserver(ONBOARDING_FINISHED_PREF, this._refresh);
|
||||
Services.prefs.removeObserver(SNIPPETS_URL_PREF, this._refresh);
|
||||
Services.prefs.removeObserver(TELEMETRY_PREF, this._refresh);
|
||||
Services.prefs.removeObserver(FXA_USERNAME_PREF, this._refresh);
|
||||
Services.obs.removeObserver(this, SEARCH_ENGINE_OBSERVER_TOPIC);
|
||||
this.store.dispatch({type: at.SNIPPETS_RESET});
|
||||
}
|
||||
onAction(action) {
|
||||
|
|
|
@ -8,7 +8,7 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|||
|
||||
const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
|
||||
const {TippyTopProvider} = Cu.import("resource://activity-stream/lib/TippyTopProvider.jsm", {});
|
||||
const {insertPinned} = Cu.import("resource://activity-stream/common/Reducers.jsm", {});
|
||||
const {insertPinned, TOP_SITES_SHOWMORE_LENGTH} = Cu.import("resource://activity-stream/common/Reducers.jsm", {});
|
||||
const {Dedupe} = Cu.import("resource://activity-stream/common/Dedupe.jsm", {});
|
||||
const {shortURL} = Cu.import("resource://activity-stream/common/ShortURL.jsm", {});
|
||||
|
||||
|
@ -17,7 +17,6 @@ XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils",
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "Screenshots",
|
||||
"resource://activity-stream/lib/Screenshots.jsm");
|
||||
|
||||
const TOP_SITES_SHOWMORE_LENGTH = 12;
|
||||
const UPDATE_TIME = 15 * 60 * 1000; // 15 minutes
|
||||
const DEFAULT_SITES_PREF = "default.sites";
|
||||
const DEFAULT_TOP_SITES = [];
|
||||
|
@ -26,7 +25,6 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
constructor() {
|
||||
this.lastUpdated = 0;
|
||||
this._tippyTopProvider = new TippyTopProvider();
|
||||
this._tippyTopProvider.init();
|
||||
this.dedupe = new Dedupe(this._dedupeKey);
|
||||
}
|
||||
_dedupeKey(site) {
|
||||
|
@ -53,7 +51,8 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
}
|
||||
async getLinksWithDefaults(action) {
|
||||
let frecent = await NewTabUtils.activityStreamLinks.getTopSites();
|
||||
const defaultUrls = DEFAULT_TOP_SITES.map(site => site.url);
|
||||
const notBlockedDefaultSites = DEFAULT_TOP_SITES.filter(site => !NewTabUtils.blockedLinks.isBlocked({url: site.url}));
|
||||
const defaultUrls = notBlockedDefaultSites.map(site => site.url);
|
||||
let pinned = NewTabUtils.pinnedLinks.links;
|
||||
pinned = pinned.map(site => site && Object.assign({}, site, {
|
||||
isDefault: defaultUrls.indexOf(site.url) !== -1,
|
||||
|
@ -68,7 +67,7 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
|
||||
// Group together websites that require deduping.
|
||||
let topsitesGroup = [];
|
||||
for (const group of [pinned, frecent, DEFAULT_TOP_SITES]) {
|
||||
for (const group of [pinned, frecent, notBlockedDefaultSites]) {
|
||||
topsitesGroup.push(group.filter(site => site).map(site => Object.assign({}, site, {hostname: shortURL(site)})));
|
||||
}
|
||||
|
||||
|
@ -137,8 +136,12 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
data: this._getPinnedWithData()
|
||||
}));
|
||||
}
|
||||
onAction(action) {
|
||||
async onAction(action) {
|
||||
switch (action.type) {
|
||||
case at.INIT:
|
||||
await this._tippyTopProvider.init();
|
||||
this.refresh();
|
||||
break;
|
||||
case at.NEW_TAB_LOAD:
|
||||
if (
|
||||
// When a new tab is opened, if the last time we refreshed the data
|
||||
|
@ -170,6 +173,5 @@ this.TopSitesFeed = class TopSitesFeed {
|
|||
};
|
||||
|
||||
this.UPDATE_TIME = UPDATE_TIME;
|
||||
this.TOP_SITES_SHOWMORE_LENGTH = TOP_SITES_SHOWMORE_LENGTH;
|
||||
this.DEFAULT_TOP_SITES = DEFAULT_TOP_SITES;
|
||||
this.EXPORTED_SYMBOLS = ["TopSitesFeed", "UPDATE_TIME", "DEFAULT_TOP_SITES", "TOP_SITES_SHOWMORE_LENGTH"];
|
||||
this.EXPORTED_SYMBOLS = ["TopSitesFeed", "UPDATE_TIME", "DEFAULT_TOP_SITES"];
|
||||
|
|
|
@ -43,7 +43,7 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
icon: options.provider_icon,
|
||||
title: {id: "header_recommended_by", values: {provider: options.provider_name}},
|
||||
rows: [],
|
||||
maxCards: 3,
|
||||
maxRows: 1,
|
||||
contextMenuOptions: ["CheckBookmark", "SaveToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
|
||||
infoOption: {
|
||||
header: {id: "pocket_feedback_header"},
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const {reducers, INITIAL_STATE, insertPinned} = require("common/Reducers.jsm");
|
||||
const {reducers, INITIAL_STATE, insertPinned, TOP_SITES_SHOWMORE_LENGTH} = require("common/Reducers.jsm");
|
||||
const {TopSites, App, Snippets, Prefs, Dialog, Sections} = reducers;
|
||||
|
||||
const {actionTypes: at} = require("common/Actions.jsm");
|
||||
|
@ -124,7 +124,17 @@ describe("Reducers", () => {
|
|||
const oldState = {rows: [{url: "foo.com"}, {url: "bar.com"}]};
|
||||
const action = {type: at.PINNED_SITES_UPDATED, data: [{url: "baz.com", title: "baz"}]};
|
||||
const nextState = TopSites(oldState, action);
|
||||
assert.deepEqual(nextState.rows, [{url: "baz.com", title: "baz", isPinned: true, pinIndex: 0, pinTitle: "baz"}, {url: "foo.com"}, {url: "bar.com"}]);
|
||||
assert.deepEqual(nextState.rows, [{url: "baz.com", title: "baz", isPinned: true, pinIndex: 0}, {url: "foo.com"}, {url: "bar.com"}]);
|
||||
});
|
||||
it("should return at most TOP_SITES_SHOWMORE_LENGTH sites on PINNED_SITES_UPDATED", () => {
|
||||
const oldState = {rows: [{url: "foo.com"}, {url: "bar.com"}]};
|
||||
const data = new Array(20).fill(null).map((s, i) => ({
|
||||
url: "foo.com",
|
||||
pinIndex: i
|
||||
}));
|
||||
const action = {type: at.PINNED_SITES_UPDATED, data};
|
||||
const nextState = TopSites(oldState, action);
|
||||
assert.lengthOf(nextState.rows, TOP_SITES_SHOWMORE_LENGTH);
|
||||
});
|
||||
});
|
||||
describe("Prefs", () => {
|
||||
|
@ -332,7 +342,6 @@ describe("Reducers", () => {
|
|||
for (let index of [0, 1]) {
|
||||
assert.equal(result[index].url, pinned[index].url);
|
||||
assert.ok(result[index].isPinned);
|
||||
assert.equal(result[index].pinTitle, pinned[index].title);
|
||||
assert.equal(result[index].pinIndex, index);
|
||||
}
|
||||
assert.deepEqual(result.slice(2), links);
|
||||
|
@ -349,7 +358,6 @@ describe("Reducers", () => {
|
|||
for (let index of [1, 4]) {
|
||||
assert.equal(result[index].url, pinned[index].url);
|
||||
assert.ok(result[index].isPinned);
|
||||
assert.equal(result[index].pinTitle, pinned[index].title);
|
||||
assert.equal(result[index].pinIndex, index);
|
||||
}
|
||||
result.splice(4, 1);
|
||||
|
@ -362,17 +370,14 @@ describe("Reducers", () => {
|
|||
const result = insertPinned([], pinned);
|
||||
assert.equal(result[11].url, pinned[11].url);
|
||||
assert.isTrue(result[11].isPinned);
|
||||
assert.equal(result[11].pinTitle, pinned[11].title);
|
||||
assert.equal(result[11].pinIndex, 11);
|
||||
});
|
||||
it("should unpin previously pinned links no longer in the pinned list", () => {
|
||||
const pinned = [];
|
||||
links[2].isPinned = true;
|
||||
links[2].pinTitle = "pinned site";
|
||||
links[2].pinIndex = 2;
|
||||
const result = insertPinned(links, pinned);
|
||||
assert.notProperty(result[2], "isPinned");
|
||||
assert.notProperty(result[2], "pinTitle");
|
||||
assert.notProperty(result[2], "pinIndex");
|
||||
});
|
||||
it("should handle a link present in both the links and pinned list", () => {
|
||||
|
|
|
@ -18,6 +18,7 @@ describe("ActivityStream", () => {
|
|||
"lib/NewTabInit.jsm": {NewTabInit: Fake},
|
||||
"lib/PlacesFeed.jsm": {PlacesFeed: Fake},
|
||||
"lib/PrefsFeed.jsm": {PrefsFeed: Fake},
|
||||
"lib/SectionsManager.jsm": {SectionsFeed: Fake},
|
||||
"lib/SnippetsFeed.jsm": {SnippetsFeed: Fake},
|
||||
"lib/SystemTickFeed.jsm": {SystemTickFeed: Fake},
|
||||
"lib/TelemetryFeed.jsm": {TelemetryFeed: Fake},
|
||||
|
@ -137,6 +138,10 @@ describe("ActivityStream", () => {
|
|||
const feed = as.feeds.get("feeds.migration")();
|
||||
assert.instanceOf(feed, Fake);
|
||||
});
|
||||
it("should create a SectionsFeed", () => {
|
||||
const feed = as.feeds.get("feeds.sections")();
|
||||
assert.instanceOf(feed, Fake);
|
||||
});
|
||||
it("should create a Snippets feed", () => {
|
||||
const feed = as.feeds.get("feeds.snippets")();
|
||||
assert.instanceOf(feed, Fake);
|
||||
|
|
|
@ -102,7 +102,7 @@ describe("ActivityStreamPrefs", () => {
|
|||
assert.calledWith(defaultPrefs.branch.setIntPref, "baz", 1);
|
||||
});
|
||||
it("should initialize a pref with value_local_dev if Firefox is a local build", () => {
|
||||
sandbox.stub(global.Services.prefs, "getStringPref", () => "default"); // eslint-disable-line max-nested-callbacks
|
||||
sandbox.stub(global.Services.prefs, "getStringPref").returns("default");
|
||||
defaultPrefs.init();
|
||||
assert.calledWith(defaultPrefs.branch.setStringPref, "qux", "foofoo");
|
||||
});
|
||||
|
|
|
@ -97,7 +97,8 @@ describe("ManualMigration", () => {
|
|||
});
|
||||
it("should set migrationStatus when isMigrationMessageExpired is true", async () => {
|
||||
const setStatusStub = sinon.stub(instance, "expireMigration");
|
||||
sinon.stub(instance, "isMigrationMessageExpired", () => new Promise(resolve => { resolve(true); }));
|
||||
sinon.stub(instance, "isMigrationMessageExpired")
|
||||
.returns(Promise.resolve(true));
|
||||
|
||||
await instance.expireIfNecessary(false);
|
||||
|
||||
|
|
|
@ -48,4 +48,27 @@ describe("Screenshots", () => {
|
|||
assert.equal(screenshot, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#_bytesToString", () => {
|
||||
it("should convert no bytes to empty string", () => {
|
||||
assert.equal(Screenshots._bytesToString([]), "");
|
||||
});
|
||||
it("should convert bytes to a string", () => {
|
||||
const str = Screenshots._bytesToString([104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]);
|
||||
|
||||
assert.equal(str, "hello world");
|
||||
});
|
||||
it("should convert very many bytes to a long string", () => {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < 1000 * 1000; i++) {
|
||||
bytes.push(9);
|
||||
}
|
||||
|
||||
const str = Screenshots._bytesToString(bytes);
|
||||
|
||||
assert.propertyVal(str, 0, "\t");
|
||||
assert.propertyVal(str, "length", 1000000);
|
||||
assert.propertyVal(str, 999999, "\u0009");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,187 @@
|
|||
"use strict";
|
||||
const {SectionsFeed, SectionsManager} = require("lib/SectionsManager.jsm");
|
||||
const {EventEmitter} = require("test/unit/utils");
|
||||
const {MAIN_MESSAGE_TYPE, CONTENT_MESSAGE_TYPE} = require("common/Actions.jsm");
|
||||
|
||||
const FAKE_ID = "FAKE_ID";
|
||||
const FAKE_OPTIONS = {icon: "FAKE_ICON", title: "FAKE_TITLE"};
|
||||
const FAKE_ROWS = [{url: "1"}, {url: "2"}, {"url": "3"}];
|
||||
|
||||
afterEach(() => {
|
||||
// Redecorate SectionsManager to remove any listeners that have been added
|
||||
EventEmitter.decorate(SectionsManager);
|
||||
});
|
||||
|
||||
describe("SectionsManager", () => {
|
||||
it("should be initialised with .initialized == false", () => {
|
||||
assert.notOk(SectionsManager.initialized);
|
||||
});
|
||||
describe("#addSection", () => {
|
||||
it("should add the id to sections and emit an ADD_SECTION event", () => {
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.ADD_SECTION, spy);
|
||||
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
|
||||
assert.ok(SectionsManager.sections.has(FAKE_ID));
|
||||
assert.calledOnce(spy);
|
||||
assert.calledWith(spy, SectionsManager.ADD_SECTION, FAKE_ID, FAKE_OPTIONS);
|
||||
});
|
||||
});
|
||||
describe("#removeSection", () => {
|
||||
it("should remove the id from sections and emit an REMOVE_SECTION event", () => {
|
||||
// Ensure we start with the id in the set
|
||||
assert.ok(SectionsManager.sections.has(FAKE_ID));
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.REMOVE_SECTION, spy);
|
||||
SectionsManager.removeSection(FAKE_ID);
|
||||
assert.notOk(SectionsManager.sections.has(FAKE_ID));
|
||||
assert.calledOnce(spy);
|
||||
assert.calledWith(spy, SectionsManager.REMOVE_SECTION, FAKE_ID);
|
||||
});
|
||||
});
|
||||
describe("#updateRows", () => {
|
||||
it("should emit an UPDATE_ROWS event with correct arguments", () => {
|
||||
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.UPDATE_ROWS, spy);
|
||||
SectionsManager.updateRows(FAKE_ID, FAKE_ROWS, true);
|
||||
assert.calledOnce(spy);
|
||||
assert.calledWith(spy, SectionsManager.UPDATE_ROWS, FAKE_ID, FAKE_ROWS, true);
|
||||
});
|
||||
it("should do nothing if the section doesn't exist", () => {
|
||||
SectionsManager.removeSection(FAKE_ID);
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.UPDATE_ROWS, spy);
|
||||
SectionsManager.updateRows(FAKE_ID, FAKE_ROWS, true);
|
||||
assert.notCalled(spy);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SectionsFeed", () => {
|
||||
let feed;
|
||||
|
||||
beforeEach(() => {
|
||||
feed = new SectionsFeed();
|
||||
feed.store = {dispatch: sinon.spy()};
|
||||
});
|
||||
afterEach(() => {
|
||||
feed.uninit();
|
||||
});
|
||||
describe("#init", () => {
|
||||
it("should create a SectionsFeed", () => {
|
||||
assert.instanceOf(feed, SectionsFeed);
|
||||
});
|
||||
it("should bind appropriate listeners", () => {
|
||||
sinon.spy(SectionsManager, "on");
|
||||
feed.init();
|
||||
assert.calledThrice(SectionsManager.on);
|
||||
for (const [event, listener] of [
|
||||
[SectionsManager.ADD_SECTION, feed.onAddSection],
|
||||
[SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
|
||||
[SectionsManager.UPDATE_ROWS, feed.onUpdateRows]
|
||||
]) {
|
||||
assert.calledWith(SectionsManager.on, event, listener);
|
||||
}
|
||||
});
|
||||
it("should emit an INIT event and set SectionsManager.initialized to true", () => {
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.INIT, spy);
|
||||
feed.init();
|
||||
assert.calledOnce(spy);
|
||||
assert.ok(SectionsManager.initialized);
|
||||
});
|
||||
});
|
||||
describe("#uninit", () => {
|
||||
it("should unbind all listeners", () => {
|
||||
sinon.spy(SectionsManager, "off");
|
||||
feed.init();
|
||||
feed.uninit();
|
||||
assert.calledThrice(SectionsManager.off);
|
||||
for (const [event, listener] of [
|
||||
[SectionsManager.ADD_SECTION, feed.onAddSection],
|
||||
[SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
|
||||
[SectionsManager.UPDATE_ROWS, feed.onUpdateRows]
|
||||
]) {
|
||||
assert.calledWith(SectionsManager.off, event, listener);
|
||||
}
|
||||
});
|
||||
it("should emit an UNINIT event and set SectionsManager.initialized to false", () => {
|
||||
const spy = sinon.spy();
|
||||
SectionsManager.on(SectionsManager.UNINIT, spy);
|
||||
feed.init();
|
||||
feed.uninit();
|
||||
assert.calledOnce(spy);
|
||||
assert.notOk(SectionsManager.initialized);
|
||||
});
|
||||
});
|
||||
describe("#onAddSection", () => {
|
||||
it("should broadcast a SECTION_REGISTER action with the correct data", () => {
|
||||
feed.onAddSection(null, FAKE_ID, FAKE_OPTIONS);
|
||||
const action = feed.store.dispatch.firstCall.args[0];
|
||||
assert.equal(action.type, "SECTION_REGISTER");
|
||||
assert.deepEqual(action.data, Object.assign({id: FAKE_ID}, FAKE_OPTIONS));
|
||||
assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
|
||||
assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
|
||||
});
|
||||
});
|
||||
describe("#onRemoveSection", () => {
|
||||
it("should broadcast a SECTION_DEREGISTER action with the correct data", () => {
|
||||
feed.onRemoveSection(null, FAKE_ID);
|
||||
const action = feed.store.dispatch.firstCall.args[0];
|
||||
assert.equal(action.type, "SECTION_DEREGISTER");
|
||||
assert.deepEqual(action.data, FAKE_ID);
|
||||
// Should be broadcast
|
||||
assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
|
||||
assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
|
||||
});
|
||||
});
|
||||
describe("#onUpdateRows", () => {
|
||||
it("should do nothing if no rows are provided", () => {
|
||||
feed.onUpdateRows(null, FAKE_ID, null);
|
||||
assert.notCalled(feed.store.dispatch);
|
||||
});
|
||||
it("should dispatch a SECTION_ROWS_UPDATE action with the correct data", () => {
|
||||
feed.onUpdateRows(null, FAKE_ID, FAKE_ROWS);
|
||||
const action = feed.store.dispatch.firstCall.args[0];
|
||||
assert.equal(action.type, "SECTION_ROWS_UPDATE");
|
||||
assert.deepEqual(action.data, {id: FAKE_ID, rows: FAKE_ROWS});
|
||||
// Should be not broadcast by default, so meta should not exist
|
||||
assert.notOk(action.meta);
|
||||
});
|
||||
it("should broadcast the action only if shouldBroadcast is true", () => {
|
||||
feed.onUpdateRows(null, FAKE_ID, FAKE_ROWS, true);
|
||||
const action = feed.store.dispatch.firstCall.args[0];
|
||||
// Should be broadcast
|
||||
assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
|
||||
assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
|
||||
});
|
||||
});
|
||||
describe("#onAction", () => {
|
||||
it("should call init() on action INIT", () => {
|
||||
sinon.spy(feed, "init");
|
||||
feed.onAction({type: "INIT"});
|
||||
assert.calledOnce(feed.init);
|
||||
});
|
||||
it("should emit a ACTION_DISPATCHED event and forward any action in ACTIONS_TO_PROXY if there are any sections", () => {
|
||||
const spy = sinon.spy();
|
||||
const allowedActions = SectionsManager.ACTIONS_TO_PROXY;
|
||||
const disallowedActions = ["PREF_CHANGED", "OPEN_PRIVATE_WINDOW"];
|
||||
SectionsManager.on(SectionsManager.ACTION_DISPATCHED, spy);
|
||||
// Make sure we start with no sections - no event should be emitted
|
||||
assert.equal(SectionsManager.sections.size, 0);
|
||||
feed.onAction({type: allowedActions[0]});
|
||||
assert.notCalled(spy);
|
||||
// Then add a section and check correct behaviour
|
||||
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
|
||||
for (const action of allowedActions.concat(disallowedActions)) {
|
||||
feed.onAction({type: action});
|
||||
}
|
||||
for (const action of allowedActions) {
|
||||
assert.calledWith(spy, "ACTION_DISPATCHED", action);
|
||||
}
|
||||
for (const action of disallowedActions) {
|
||||
assert.neverCalledWith(spy, "ACTION_DISPATCHED", action);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -3,6 +3,7 @@ const {actionTypes: at} = require("common/Actions.jsm");
|
|||
const {GlobalOverrider} = require("test/unit/utils");
|
||||
|
||||
const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const searchData = {searchEngineIdentifier: "google", engines: ["searchEngine-google", "searchEngine-bing"]};
|
||||
|
||||
let overrider = new GlobalOverrider();
|
||||
|
||||
|
@ -57,6 +58,9 @@ describe("SnippetsFeed", () => {
|
|||
assert.propertyVal(action.data, "telemetryEnabled", true);
|
||||
assert.propertyVal(action.data, "onboardingFinished", false);
|
||||
assert.propertyVal(action.data, "fxaccount", true);
|
||||
assert.property(action.data, "selectedSearchEngine");
|
||||
assert.deepEqual(action.data.selectedSearchEngine, searchData);
|
||||
assert.propertyVal(action.data, "defaultBrowser", true);
|
||||
});
|
||||
it("should call .init on an INIT aciton", () => {
|
||||
const feed = new SnippetsFeed();
|
||||
|
@ -83,4 +87,17 @@ describe("SnippetsFeed", () => {
|
|||
|
||||
assert.calledWith(feed.store.dispatch, {type: at.SNIPPETS_RESET});
|
||||
});
|
||||
it("should dispatch an update event when the Search observer is called", async () => {
|
||||
const feed = new SnippetsFeed();
|
||||
feed.store = {dispatch: sandbox.stub()};
|
||||
sandbox.stub(feed, "getSelectedSearchEngine")
|
||||
.returns(Promise.resolve(searchData));
|
||||
|
||||
await feed.observe(null, "browser-search-engine-modified");
|
||||
|
||||
assert.calledOnce(feed.store.dispatch);
|
||||
const action = feed.store.dispatch.firstCall.args[0];
|
||||
assert.equal(action.type, at.SNIPPETS_DATA);
|
||||
assert.deepEqual(action.data, {selectedSearchEngine: searchData});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,16 +1,16 @@
|
|||
"use strict";
|
||||
const injector = require("inject!lib/TopSitesFeed.jsm");
|
||||
const {UPDATE_TIME, TOP_SITES_SHOWMORE_LENGTH} = require("lib/TopSitesFeed.jsm");
|
||||
const {UPDATE_TIME} = require("lib/TopSitesFeed.jsm");
|
||||
const {FakePrefs, GlobalOverrider} = require("test/unit/utils");
|
||||
const action = {meta: {fromTarget: {}}};
|
||||
const {actionCreators: ac, actionTypes: at} = require("common/Actions.jsm");
|
||||
const {insertPinned} = require("common/Reducers.jsm");
|
||||
const {insertPinned, TOP_SITES_SHOWMORE_LENGTH} = require("common/Reducers.jsm");
|
||||
const FAKE_LINKS = new Array(TOP_SITES_SHOWMORE_LENGTH).fill(null).map((v, i) => ({url: `http://www.site${i}.com`}));
|
||||
const FAKE_SCREENSHOT = "data123";
|
||||
|
||||
function FakeTippyTopProvider() {}
|
||||
FakeTippyTopProvider.prototype = {
|
||||
init() {},
|
||||
async init() {},
|
||||
processSite(site) { return site; }
|
||||
};
|
||||
|
||||
|
@ -30,6 +30,10 @@ describe("Top Sites Feed", () => {
|
|||
globals = new GlobalOverrider();
|
||||
sandbox = globals.sandbox;
|
||||
fakeNewTabUtils = {
|
||||
blockedLinks: {
|
||||
links: [],
|
||||
isBlocked: () => false
|
||||
},
|
||||
activityStreamLinks: {getTopSites: sandbox.spy(() => Promise.resolve(links))},
|
||||
pinnedLinks: {
|
||||
links: [],
|
||||
|
@ -46,7 +50,7 @@ describe("Top Sites Feed", () => {
|
|||
({TopSitesFeed, DEFAULT_TOP_SITES} = injector({
|
||||
"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs},
|
||||
"common/Dedupe.jsm": {Dedupe: fakeDedupe},
|
||||
"common/Reducers.jsm": {insertPinned},
|
||||
"common/Reducers.jsm": {insertPinned, TOP_SITES_SHOWMORE_LENGTH},
|
||||
"lib/Screenshots.jsm": {Screenshots: fakeScreenshot},
|
||||
"lib/TippyTopProvider.jsm": {TippyTopProvider: FakeTippyTopProvider},
|
||||
"common/ShortURL.jsm": {shortURL: shortURLStub}
|
||||
|
@ -102,8 +106,22 @@ describe("Top Sites Feed", () => {
|
|||
assert.deepEqual(result, reference);
|
||||
assert.calledOnce(global.NewTabUtils.activityStreamLinks.getTopSites);
|
||||
});
|
||||
it("should filter out the defaults that have been blocked", async () => {
|
||||
// make sure we only have one top site, and we block the only default site we have to show
|
||||
const url = "www.myonlytopsite.com";
|
||||
const topsite = {url, hostname: shortURLStub({url})};
|
||||
const blockedDefaultSite = {url: "https://foo.com"};
|
||||
fakeNewTabUtils.activityStreamLinks.getTopSites = () => [topsite];
|
||||
fakeNewTabUtils.blockedLinks.isBlocked = site => (site.url === blockedDefaultSite.url);
|
||||
const result = await feed.getLinksWithDefaults();
|
||||
|
||||
// what we should be left with is just the top site we added, and not the default site we blocked
|
||||
assert.lengthOf(result, 1);
|
||||
assert.deepEqual(result[0], topsite);
|
||||
assert.notInclude(result, blockedDefaultSite);
|
||||
});
|
||||
it("should call dedupe on the links", async () => {
|
||||
const stub = sinon.stub(feed.dedupe, "group", id => id);
|
||||
const stub = sinon.stub(feed.dedupe, "group").callsFake(id => id);
|
||||
|
||||
await feed.getLinksWithDefaults();
|
||||
|
||||
|
@ -144,7 +162,7 @@ describe("Top Sites Feed", () => {
|
|||
beforeEach(() => {
|
||||
({TopSitesFeed, DEFAULT_TOP_SITES} = injector({
|
||||
"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs},
|
||||
"common/Reducers.jsm": {insertPinned},
|
||||
"common/Reducers.jsm": {insertPinned, TOP_SITES_SHOWMORE_LENGTH},
|
||||
"lib/Screenshots.jsm": {Screenshots: fakeScreenshot}
|
||||
}));
|
||||
feed = new TopSitesFeed();
|
||||
|
@ -157,7 +175,7 @@ describe("Top Sites Feed", () => {
|
|||
|
||||
const sites = await feed.getLinksWithDefaults();
|
||||
|
||||
assert.lengthOf(sites, 12);
|
||||
assert.lengthOf(sites, TOP_SITES_SHOWMORE_LENGTH);
|
||||
assert.equal(sites[0].url, fakeNewTabUtils.pinnedLinks.links[0].url);
|
||||
assert.equal(sites[1].url, fakeNewTabUtils.pinnedLinks.links[1].url);
|
||||
assert.equal(sites[0].hostname, sites[1].hostname);
|
||||
|
@ -179,13 +197,13 @@ describe("Top Sites Feed", () => {
|
|||
assert.lengthOf(sites, 2);
|
||||
});
|
||||
it("should return sites that have a title", async () => {
|
||||
// Simulate a pinned link with no pinTitle.
|
||||
// Simulate a pinned link with no title.
|
||||
fakeNewTabUtils.pinnedLinks.links = [{url: "https://github.com/mozilla/activity-stream"}];
|
||||
|
||||
const sites = await feed.getLinksWithDefaults();
|
||||
|
||||
for (const site of sites) {
|
||||
assert.isDefined(site.pinTitle || site.hostname);
|
||||
assert.isDefined(site.hostname);
|
||||
}
|
||||
});
|
||||
it("should check against null entries", async () => {
|
||||
|
@ -335,5 +353,10 @@ describe("Top Sites Feed", () => {
|
|||
assert.calledOnce(feed.store.dispatch);
|
||||
assert.propertyVal(feed.store.dispatch.firstCall.args[0], "type", at.TOP_SITES_UPDATED);
|
||||
});
|
||||
it("should call refresh on INIT action", async () => {
|
||||
sinon.stub(feed, "refresh");
|
||||
await feed.onAction({type: at.INIT});
|
||||
assert.calledOnce(feed.refresh);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -65,7 +65,7 @@ describe("Top Stories Feed", () => {
|
|||
icon: "provider-icon",
|
||||
title: {id: "header_recommended_by", values: {provider: "test-provider"}},
|
||||
rows: [],
|
||||
maxCards: 3,
|
||||
maxRows: 1,
|
||||
contextMenuOptions: ["CheckBookmark", "SaveToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
|
||||
infoOption: {
|
||||
header: {id: "pocket_feedback_header"},
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
const {GlobalOverrider, FakePrefs, FakePerformance} = require("test/unit/utils");
|
||||
const {GlobalOverrider, FakePrefs, FakePerformance, EventEmitter} = require("test/unit/utils");
|
||||
const {chaiAssertions} = require("test/schemas/pings");
|
||||
|
||||
const req = require.context(".", true, /\.test\.jsx?$/);
|
||||
|
@ -20,7 +20,8 @@ overrider.set({
|
|||
importGlobalProperties() {},
|
||||
reportError() {},
|
||||
now: () => window.performance.now()
|
||||
}
|
||||
},
|
||||
isSuccessCode: () => true
|
||||
},
|
||||
// eslint-disable-next-line object-shorthand
|
||||
ContentSearchUIController: function() {}, // NB: This is a function/constructor
|
||||
|
@ -60,13 +61,20 @@ overrider.set({
|
|||
},
|
||||
tm: {dispatchToMainThread: cb => cb()},
|
||||
eTLD: {getPublicSuffix() {}},
|
||||
io: {NewURI() {}}
|
||||
io: {NewURI() {}},
|
||||
search: {
|
||||
init(cb) { cb(); },
|
||||
getVisibleEngines: () => [{identifier: "google"}, {identifier: "bing"}],
|
||||
defaultEngine: {identifier: "google"}
|
||||
}
|
||||
},
|
||||
XPCOMUtils: {
|
||||
defineLazyModuleGetter() {},
|
||||
defineLazyServiceGetter() {},
|
||||
generateQI() { return {}; }
|
||||
}
|
||||
},
|
||||
EventEmitter,
|
||||
ShellService: {isDefaultBrowser: () => true}
|
||||
});
|
||||
|
||||
describe("activity-stream", () => {
|
||||
|
|
|
@ -119,6 +119,63 @@ FakePrefs.prototype = {
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Slimmed down version of toolkit/modules/EventEmitter.jsm
|
||||
*/
|
||||
function EventEmitter() {}
|
||||
EventEmitter.decorate = function(objectToDecorate) {
|
||||
let emitter = new EventEmitter();
|
||||
objectToDecorate.on = emitter.on.bind(emitter);
|
||||
objectToDecorate.off = emitter.off.bind(emitter);
|
||||
objectToDecorate.emit = emitter.emit.bind(emitter);
|
||||
};
|
||||
EventEmitter.prototype = {
|
||||
on(event, listener) {
|
||||
if (!this._eventEmitterListeners) {
|
||||
this._eventEmitterListeners = new Map();
|
||||
}
|
||||
if (!this._eventEmitterListeners.has(event)) {
|
||||
this._eventEmitterListeners.set(event, []);
|
||||
}
|
||||
this._eventEmitterListeners.get(event).push(listener);
|
||||
},
|
||||
off(event, listener) {
|
||||
if (!this._eventEmitterListeners) {
|
||||
return;
|
||||
}
|
||||
let listeners = this._eventEmitterListeners.get(event);
|
||||
if (listeners) {
|
||||
this._eventEmitterListeners.set(event, listeners.filter(
|
||||
l => l !== listener && l._originalListener !== listener
|
||||
));
|
||||
}
|
||||
},
|
||||
// All arguments to this method will be sent to listeners
|
||||
emit(event, ...args) {
|
||||
if (!this._eventEmitterListeners || !this._eventEmitterListeners.has(event)) {
|
||||
return;
|
||||
}
|
||||
let originalListeners = this._eventEmitterListeners.get(event);
|
||||
for (let listener of this._eventEmitterListeners.get(event)) {
|
||||
// If the object was destroyed during event emission, stop
|
||||
// emitting.
|
||||
if (!this._eventEmitterListeners) {
|
||||
break;
|
||||
}
|
||||
// If listeners were removed during emission, make sure the
|
||||
// event handler we're going to fire wasn't removed.
|
||||
if (originalListeners === this._eventEmitterListeners.get(event) ||
|
||||
this._eventEmitterListeners.get(event).some(l => l === listener)) {
|
||||
try {
|
||||
listener(event, ...args);
|
||||
} catch (ex) {
|
||||
// error with a listener
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function FakePerformance() {}
|
||||
FakePerformance.prototype = {
|
||||
marks: new Map(),
|
||||
|
@ -187,6 +244,7 @@ function mountWithIntl(node, options = {}) {
|
|||
module.exports = {
|
||||
FakePerformance,
|
||||
FakePrefs,
|
||||
EventEmitter,
|
||||
GlobalOverrider,
|
||||
addNumberReducer,
|
||||
mountWithIntl,
|
||||
|
|
|
@ -481,6 +481,22 @@ FormAutofillHandler.prototype = {
|
|||
let element = detail.elementWeakRef.get();
|
||||
// Remove the unnecessary spaces
|
||||
let value = element && element.value.trim();
|
||||
|
||||
// Try to abbreviate the value of select element.
|
||||
if (type == "address" &&
|
||||
detail.fieldName == "address-level1" &&
|
||||
element instanceof Ci.nsIDOMHTMLSelectElement) {
|
||||
// Don't save the record when the option value is empty *OR* there
|
||||
// are multiple options being selected. The empty option is usually
|
||||
// assumed to be default along with a meaningless text to users.
|
||||
if (!value || element.selectedOptions.length != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let text = element.selectedOptions[0].text.trim();
|
||||
value = FormAutofillUtils.getAbbreviatedStateName([value, text]) || text;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -204,6 +204,45 @@ this.FormAutofillUtils = {
|
|||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Try to find the abbreviation of the given state name
|
||||
* @param {string[]} stateValues A list of inferable state values.
|
||||
* @param {string} country A country name to be identified.
|
||||
* @returns {string} The matching state abbreviation.
|
||||
*/
|
||||
getAbbreviatedStateName(stateValues, country = this.DEFAULT_COUNTRY_CODE) {
|
||||
let values = Array.isArray(stateValues) ? stateValues : [stateValues];
|
||||
|
||||
let collators = this.getCollators(country);
|
||||
let {sub_keys: subKeys, sub_names: subNames} = this.getCountryAddressData(country);
|
||||
|
||||
if (!Array.isArray(subKeys)) {
|
||||
subKeys = subKeys.split("~");
|
||||
}
|
||||
if (!Array.isArray(subNames)) {
|
||||
subNames = subNames.split("~");
|
||||
}
|
||||
|
||||
let speculatedSubIndexes = [];
|
||||
for (const val of values) {
|
||||
let identifiedValue = this.identifyValue(subKeys, subNames, val, collators);
|
||||
if (identifiedValue) {
|
||||
return identifiedValue;
|
||||
}
|
||||
|
||||
// Predict the possible state by partial-matching if no exact match.
|
||||
[subKeys, subNames].forEach(sub => {
|
||||
speculatedSubIndexes.push(sub.findIndex(token => {
|
||||
let pattern = new RegExp("\\b" + this.escapeRegExp(token) + "\\b");
|
||||
|
||||
return pattern.test(val);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
return subKeys[speculatedSubIndexes.find(i => !!~i)] || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Find the option element from select element.
|
||||
* 1. Try to find the locale using the country from address.
|
||||
|
|
|
@ -5,6 +5,19 @@ Cu.import("resource://formautofill/FormAutofillContent.jsm");
|
|||
const MOCK_DOC = MockDocument.createTestDocument("http://localhost:8080/test/",
|
||||
`<form id="form1">
|
||||
<input id="street-addr" autocomplete="street-address">
|
||||
<select id="address-level1" autocomplete="address-level1">
|
||||
<option value=""></option>
|
||||
<option value="AL">Alabama</option>
|
||||
<option value="AK">Alaska</option>
|
||||
<option value="AP">Armed Forces Pacific</option>
|
||||
|
||||
<option value="ca">california</option>
|
||||
<option value="AR">US-Arkansas</option>
|
||||
<option value="US-CA">California</option>
|
||||
<option value="CA">California</option>
|
||||
<option value="US-AZ">US_Arizona</option>
|
||||
<option value="Ariz">Arizonac</option>
|
||||
</select>
|
||||
<input id="city" autocomplete="address-level2">
|
||||
<input id="country" autocomplete="country">
|
||||
<input id="email" autocomplete="email">
|
||||
|
@ -166,6 +179,185 @@ const TESTCASES = [
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Save state with regular select option",
|
||||
formValue: {
|
||||
"address-level1": "CA",
|
||||
"street-addr": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
expectedResult: {
|
||||
formSubmission: true,
|
||||
records: {
|
||||
address: {
|
||||
guid: null,
|
||||
record: {
|
||||
"address-level1": "CA",
|
||||
"street-address": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
untouchedFields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Save state with lowercase value",
|
||||
formValue: {
|
||||
"address-level1": "ca",
|
||||
"street-addr": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
expectedResult: {
|
||||
formSubmission: true,
|
||||
records: {
|
||||
address: {
|
||||
guid: null,
|
||||
record: {
|
||||
"address-level1": "CA",
|
||||
"street-address": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
untouchedFields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Save state with a country code prefixed to the label",
|
||||
formValue: {
|
||||
"address-level1": "AR",
|
||||
"street-addr": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
expectedResult: {
|
||||
formSubmission: true,
|
||||
records: {
|
||||
address: {
|
||||
guid: null,
|
||||
record: {
|
||||
"address-level1": "AR",
|
||||
"street-address": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
untouchedFields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Save state with a country code prefixed to the value",
|
||||
formValue: {
|
||||
"address-level1": "US-CA",
|
||||
"street-addr": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
expectedResult: {
|
||||
formSubmission: true,
|
||||
records: {
|
||||
address: {
|
||||
guid: null,
|
||||
record: {
|
||||
"address-level1": "CA",
|
||||
"street-address": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
untouchedFields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Save state with a country code prefixed to the value and label",
|
||||
formValue: {
|
||||
"address-level1": "US-AZ",
|
||||
"street-addr": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
expectedResult: {
|
||||
formSubmission: true,
|
||||
records: {
|
||||
address: {
|
||||
guid: null,
|
||||
record: {
|
||||
"address-level1": "AZ",
|
||||
"street-address": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
untouchedFields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Should save select label instead when failed to abbreviate the value",
|
||||
formValue: {
|
||||
"address-level1": "Ariz",
|
||||
"street-addr": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
expectedResult: {
|
||||
formSubmission: true,
|
||||
records: {
|
||||
address: {
|
||||
guid: null,
|
||||
record: {
|
||||
"address-level1": "Arizonac",
|
||||
"street-address": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
},
|
||||
untouchedFields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Shouldn't save select with multiple selections",
|
||||
formValue: {
|
||||
"address-level1": ["AL", "AK", "AP"],
|
||||
"street-addr": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
"tel": "1-650-903-0800",
|
||||
},
|
||||
expectedResult: {
|
||||
formSubmission: true,
|
||||
records: {
|
||||
address: {
|
||||
guid: null,
|
||||
record: {
|
||||
"street-address": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
"tel": "1-650-903-0800",
|
||||
},
|
||||
untouchedFields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "Shouldn't save select with empty value",
|
||||
formValue: {
|
||||
"address-level1": "",
|
||||
"street-addr": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
"tel": "1-650-903-0800",
|
||||
},
|
||||
expectedResult: {
|
||||
formSubmission: true,
|
||||
records: {
|
||||
address: {
|
||||
guid: null,
|
||||
record: {
|
||||
"street-address": "331 E. Evelyn Avenue",
|
||||
"country": "USA",
|
||||
"tel": "1-650-903-0800",
|
||||
},
|
||||
untouchedFields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
add_task(async function handle_earlyformsubmit_event() {
|
||||
|
@ -186,7 +378,16 @@ TESTCASES.forEach(testcase => {
|
|||
form.reset();
|
||||
for (let key in testcase.formValue) {
|
||||
let input = MOCK_DOC.getElementById(key);
|
||||
input.value = testcase.formValue[key];
|
||||
let value = testcase.formValue[key];
|
||||
|
||||
if (input instanceof Ci.nsIDOMHTMLSelectElement && value) {
|
||||
input.multiple = Array.isArray(value);
|
||||
[...input.options].forEach(option => {
|
||||
option.selected = value.includes(option.value);
|
||||
});
|
||||
} else {
|
||||
input.value = testcase.formValue[key];
|
||||
}
|
||||
}
|
||||
sinon.stub(FormAutofillContent, "_onFormSubmit");
|
||||
|
||||
|
|
|
@ -4,6 +4,9 @@
|
|||
"use strict";
|
||||
|
||||
const {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/AppConstants.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "LogManager",
|
||||
|
@ -11,10 +14,43 @@ XPCOMUtils.defineLazyModuleGetter(this, "LogManager",
|
|||
XPCOMUtils.defineLazyModuleGetter(this, "ShieldRecipeClient",
|
||||
"resource://shield-recipe-client/lib/ShieldRecipeClient.jsm");
|
||||
|
||||
const DEFAULT_PREFS = {
|
||||
"extensions.shield-recipe-client.api_url": "https://normandy.cdn.mozilla.net/api/v1",
|
||||
"extensions.shield-recipe-client.dev_mode": false,
|
||||
"extensions.shield-recipe-client.enabled": true,
|
||||
"extensions.shield-recipe-client.startup_delay_seconds": 300,
|
||||
"extensions.shield-recipe-client.logging.level": Log.Level.Warn,
|
||||
"extensions.shield-recipe-client.user_id": "",
|
||||
"extensions.shield-recipe-client.run_interval_seconds": 86400, // 24 hours
|
||||
"extensions.shield-recipe-client.first_run": true,
|
||||
"extensions.shield-recipe-client.shieldLearnMoreUrl": (
|
||||
"https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/shield"
|
||||
),
|
||||
"app.shield.optoutstudies.enabled": AppConstants.MOZ_DATA_REPORTING,
|
||||
};
|
||||
|
||||
this.install = function() {};
|
||||
|
||||
this.startup = async function() {
|
||||
await ShieldRecipeClient.startup();
|
||||
this.startup = function() {
|
||||
// Initialize preference defaults before anything else happens.
|
||||
const prefBranch = Services.prefs.getDefaultBranch("");
|
||||
for (const [name, value] of Object.entries(DEFAULT_PREFS)) {
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
prefBranch.setCharPref(name, value);
|
||||
break;
|
||||
case "number":
|
||||
prefBranch.setIntPref(name, value);
|
||||
break;
|
||||
case "boolean":
|
||||
prefBranch.setBoolPref(name, value);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid default preference type ${typeof value}`);
|
||||
}
|
||||
}
|
||||
|
||||
ShieldRecipeClient.startup();
|
||||
};
|
||||
|
||||
this.shutdown = function(data, reason) {
|
||||
|
@ -23,8 +59,10 @@ this.shutdown = function(data, reason) {
|
|||
// Unload add-on modules. We don't do this in ShieldRecipeClient so that
|
||||
// modules are not unloaded accidentally during tests.
|
||||
const log = LogManager.getLogger("bootstrap");
|
||||
const modules = [
|
||||
let modules = [
|
||||
"lib/ActionSandboxManager.jsm",
|
||||
"lib/Addons.jsm",
|
||||
"lib/AddonStudies.jsm",
|
||||
"lib/CleanupManager.jsm",
|
||||
"lib/ClientEnvironment.jsm",
|
||||
"lib/FilterExpressions.jsm",
|
||||
|
@ -37,13 +75,22 @@ this.shutdown = function(data, reason) {
|
|||
"lib/RecipeRunner.jsm",
|
||||
"lib/Sampling.jsm",
|
||||
"lib/SandboxManager.jsm",
|
||||
"lib/ShieldPreferences.jsm",
|
||||
"lib/ShieldRecipeClient.jsm",
|
||||
"lib/Storage.jsm",
|
||||
"lib/Uptake.jsm",
|
||||
"lib/Utils.jsm",
|
||||
];
|
||||
].map(m => `resource://shield-recipe-client/${m}`);
|
||||
modules = modules.concat([
|
||||
"AboutPages.jsm",
|
||||
].map(m => `resource://shield-recipe-client-content/${m}`));
|
||||
modules = modules.concat([
|
||||
"mozjexl.js",
|
||||
].map(m => `resource://shield-recipe-client-vendor/${m}`));
|
||||
|
||||
for (const module of modules) {
|
||||
log.debug(`Unloading ${module}`);
|
||||
Cu.unload(`resource://shield-recipe-client/${module}`);
|
||||
Cu.unload(module);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,218 @@
|
|||
/* 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 { interfaces: Ci, results: Cr, manager: Cm, utils: Cu } = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm",
|
||||
);
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "AddonStudies", "resource://shield-recipe-client/lib/AddonStudies.jsm",
|
||||
);
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["AboutPages"];
|
||||
|
||||
const SHIELD_LEARN_MORE_URL_PREF = "extensions.shield-recipe-client.shieldLearnMoreUrl";
|
||||
|
||||
// Due to bug 1051238 frame scripts are cached forever, so we can't update them
|
||||
// as a restartless add-on. The Math.random() is the work around for this.
|
||||
const PROCESS_SCRIPT = (
|
||||
`resource://shield-recipe-client-content/shield-content-process.js?${Math.random()}`
|
||||
);
|
||||
const FRAME_SCRIPT = (
|
||||
`resource://shield-recipe-client-content/shield-content-frame.js?${Math.random()}`
|
||||
);
|
||||
|
||||
/**
|
||||
* Class for managing an about: page that Shield provides. Adapted from
|
||||
* browser/extensions/pocket/content/AboutPocket.jsm.
|
||||
*
|
||||
* @implements nsIFactory
|
||||
* @implements nsIAboutModule
|
||||
*/
|
||||
class AboutPage {
|
||||
constructor({chromeUrl, aboutHost, classId, description, uriFlags}) {
|
||||
this.chromeUrl = chromeUrl;
|
||||
this.aboutHost = aboutHost;
|
||||
this.classId = Components.ID(classId);
|
||||
this.description = description;
|
||||
this.uriFlags = uriFlags;
|
||||
}
|
||||
|
||||
getURIFlags() {
|
||||
return this.uriFlags;
|
||||
}
|
||||
|
||||
newChannel(uri, loadInfo) {
|
||||
const newURI = Services.io.newURI(this.chromeUrl);
|
||||
const channel = Services.io.newChannelFromURIWithLoadInfo(newURI, loadInfo);
|
||||
channel.originalURI = uri;
|
||||
|
||||
if (this.uriFlags & Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT) {
|
||||
const principal = Services.scriptSecurityManager.createCodebasePrincipal(uri, {});
|
||||
channel.owner = principal;
|
||||
}
|
||||
return channel;
|
||||
}
|
||||
|
||||
createInstance(outer, iid) {
|
||||
if (outer !== null) {
|
||||
throw Cr.NS_ERROR_NO_AGGREGATION;
|
||||
}
|
||||
return this.QueryInterface(iid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this about: page with XPCOM. This must be called once in each
|
||||
* process (parent and content) to correctly initialize the page.
|
||||
*/
|
||||
register() {
|
||||
Cm.QueryInterface(Ci.nsIComponentRegistrar).registerFactory(
|
||||
this.classId,
|
||||
this.description,
|
||||
`@mozilla.org/network/protocol/about;1?what=${this.aboutHost}`,
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister this about: page with XPCOM. This must be called before the
|
||||
* add-on is cleaned up if the page has been registered.
|
||||
*/
|
||||
unregister() {
|
||||
Cm.QueryInterface(Ci.nsIComponentRegistrar).unregisterFactory(this.classId, this);
|
||||
}
|
||||
}
|
||||
AboutPage.prototype.QueryInterface = XPCOMUtils.generateQI([Ci.nsIAboutModule]);
|
||||
|
||||
/**
|
||||
* The module exported by this file.
|
||||
*/
|
||||
this.AboutPages = {
|
||||
async init() {
|
||||
// Load scripts in content processes and tabs
|
||||
Services.ppmm.loadProcessScript(PROCESS_SCRIPT, true);
|
||||
Services.mm.loadFrameScript(FRAME_SCRIPT, true);
|
||||
|
||||
// Register about: pages and their listeners
|
||||
this.aboutStudies.register();
|
||||
this.aboutStudies.registerParentListeners();
|
||||
|
||||
CleanupManager.addCleanupHandler(() => {
|
||||
// Stop loading processs scripts and notify existing scripts to clean up.
|
||||
Services.ppmm.removeDelayedProcessScript(PROCESS_SCRIPT);
|
||||
Services.ppmm.broadcastAsyncMessage("Shield:ShuttingDown");
|
||||
Services.mm.removeDelayedFrameScript(FRAME_SCRIPT);
|
||||
Services.mm.broadcastAsyncMessage("Shield:ShuttingDown");
|
||||
|
||||
// Clean up about pages
|
||||
this.aboutStudies.unregisterParentListeners();
|
||||
this.aboutStudies.unregister();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* about:studies page for displaying in-progress and past Shield studies.
|
||||
* @type {AboutPage}
|
||||
* @implements {nsIMessageListener}
|
||||
*/
|
||||
XPCOMUtils.defineLazyGetter(this.AboutPages, "aboutStudies", () => {
|
||||
const aboutStudies = new AboutPage({
|
||||
chromeUrl: "resource://shield-recipe-client-content/about-studies/about-studies.html",
|
||||
aboutHost: "studies",
|
||||
classId: "{6ab96943-a163-482c-9622-4faedc0e827f}",
|
||||
description: "Shield Study Listing",
|
||||
uriFlags: (
|
||||
Ci.nsIAboutModule.ALLOW_SCRIPT
|
||||
| Ci.nsIAboutModule.URI_SAFE_FOR_UNTRUSTED_CONTENT
|
||||
| Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD
|
||||
),
|
||||
});
|
||||
|
||||
// Extra methods for about:study-specific behavior.
|
||||
Object.assign(aboutStudies, {
|
||||
/**
|
||||
* Register listeners for messages from the content processes.
|
||||
*/
|
||||
registerParentListeners() {
|
||||
Services.mm.addMessageListener("Shield:GetStudyList", this);
|
||||
Services.mm.addMessageListener("Shield:RemoveStudy", this);
|
||||
Services.mm.addMessageListener("Shield:OpenOldDataPreferences", this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Unregister listeners for messages from the content process.
|
||||
*/
|
||||
unregisterParentListeners() {
|
||||
Services.mm.removeMessageListener("Shield:GetStudyList", this);
|
||||
Services.mm.removeMessageListener("Shield:RemoveStudy", this);
|
||||
Services.mm.removeMessageListener("Shield:OpenOldDataPreferences", this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Dispatch messages from the content process to the appropriate handler.
|
||||
* @param {Object} message
|
||||
* See the nsIMessageListener documentation for details about this object.
|
||||
*/
|
||||
receiveMessage(message) {
|
||||
switch (message.name) {
|
||||
case "Shield:GetStudyList":
|
||||
this.sendStudyList(message.target);
|
||||
break;
|
||||
case "Shield:RemoveStudy":
|
||||
this.removeStudy(message.data);
|
||||
break;
|
||||
case "Shield:OpenOldDataPreferences":
|
||||
this.openOldDataPreferences();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a list of studies from storage and send it to the process that
|
||||
* requested them.
|
||||
* @param {<browser>} target
|
||||
* XUL <browser> element for the tab containing the about:studies page
|
||||
* that requested a study list.
|
||||
*/
|
||||
async sendStudyList(target) {
|
||||
try {
|
||||
target.messageManager.sendAsyncMessage("Shield:ReceiveStudyList", {
|
||||
studies: await AddonStudies.getAll(),
|
||||
});
|
||||
} catch (err) {
|
||||
// The child process might be gone, so no need to throw here.
|
||||
Cu.reportError(err);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable an active study and remove its add-on.
|
||||
* @param {String} studyName
|
||||
*/
|
||||
async removeStudy(recipeId) {
|
||||
await AddonStudies.stop(recipeId);
|
||||
|
||||
// Update any open tabs with the new study list now that it has changed.
|
||||
Services.mm.broadcastAsyncMessage("Shield:ReceiveStudyList", {
|
||||
studies: await AddonStudies.getAll(),
|
||||
});
|
||||
},
|
||||
|
||||
openOldDataPreferences() {
|
||||
const browserWindow = Services.wm.getMostRecentWindow("navigator:browser");
|
||||
browserWindow.openAdvancedPreferences("dataChoicesTab", {origin: "aboutStudies"});
|
||||
},
|
||||
|
||||
getShieldLearnMoreHref() {
|
||||
return Services.urlFormatter.formatURLPref(SHIELD_LEARN_MORE_URL_PREF);
|
||||
},
|
||||
});
|
||||
|
||||
return aboutStudies;
|
||||
});
|
|
@ -0,0 +1,182 @@
|
|||
/* 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 {
|
||||
--icon-background-color-1: #0A84FF;
|
||||
--icon-background-color-2: #008EA4;
|
||||
--icon-background-color-3: #ED00B5;
|
||||
--icon-background-color-4: #058B00;
|
||||
--icon-background-color-5: #A47F00;
|
||||
--icon-background-color-6: #FF0039;
|
||||
--icon-background-disabled-color: #737373;
|
||||
--body-text-disabled-color: #737373;
|
||||
--info-box-background-color: #D7D7DB;
|
||||
--info-box-border-color: #98979C;
|
||||
--study-status-active-color: #058B00;
|
||||
--study-status-disabled-color: #737373;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button > .button-box {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.about-studies-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
font-size: 1.25rem;
|
||||
min-height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#categories {
|
||||
flex: 0 0;
|
||||
margin: 0;
|
||||
min-width: 200px;
|
||||
padding: 40px 0 0;
|
||||
}
|
||||
|
||||
#categories .category {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-box-content {
|
||||
align-items: center;
|
||||
background: var(--info-box-background-color);
|
||||
border: 1px solid var(--info-box-border-color);
|
||||
display: inline-flex;
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.info-box-content > * {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.info-box-content > *:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.study-list {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.study {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--in-content-border-color);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.study.disabled {
|
||||
color: var(--body-text-disabled-color);
|
||||
}
|
||||
|
||||
.study .study-status {
|
||||
color: var(--study-status-active-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.study.disabled .study-status {
|
||||
color: var(--study-status-disabled-color);
|
||||
}
|
||||
|
||||
.study:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.study > * {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
.study > *:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.study-icon {
|
||||
color: #FFF;
|
||||
flex: 0 0 40px;
|
||||
font-size: 26px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.study:nth-child(6n+0) .study-icon {
|
||||
background: var(--icon-background-color-1);
|
||||
}
|
||||
|
||||
.study:nth-child(6n+1) .study-icon {
|
||||
background: var(--icon-background-color-2);
|
||||
}
|
||||
|
||||
.study:nth-child(6n+2) .study-icon {
|
||||
background: var(--icon-background-color-3);
|
||||
}
|
||||
|
||||
.study:nth-child(6n+3) .study-icon {
|
||||
background: var(--icon-background-color-4);
|
||||
}
|
||||
|
||||
.study:nth-child(6n+4) .study-icon {
|
||||
background: var(--icon-background-color-5);
|
||||
}
|
||||
|
||||
.study:nth-child(6n+5) .study-icon {
|
||||
background: var(--icon-background-color-6);
|
||||
}
|
||||
|
||||
.study.disabled .study-icon {
|
||||
background: var(--icon-background-disabled-color);
|
||||
}
|
||||
|
||||
.study-details {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.study-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
|
||||
.study-description {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.study-description > * {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.study-description > *:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.study-actions {
|
||||
flex: 0 0;
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
<!-- This Source Code Form is subject to the terms of the Mozilla Public
|
||||
- License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
- file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>about:studies</title>
|
||||
<link rel="stylesheet" href="chrome://global/skin/global.css">
|
||||
<link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
|
||||
<link rel="stylesheet" href="resource://shield-recipe-client-content/about-studies/about-studies.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script src="resource://shield-recipe-client-vendor/React.js"></script>
|
||||
<script src="resource://shield-recipe-client-vendor/ReactDOM.js"></script>
|
||||
<script src="resource://shield-recipe-client-vendor/PropTypes.js"></script>
|
||||
<script src="resource://shield-recipe-client-vendor/classnames.js"></script>
|
||||
<script src="resource://shield-recipe-client-content/about-studies/common.js"></script>
|
||||
<script src="resource://shield-recipe-client-content/about-studies/shield-studies.js"></script>
|
||||
<script src="resource://shield-recipe-client-content/about-studies/about-studies.js"></script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,140 @@
|
|||
/* 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";
|
||||
/* global classnames PropTypes r React ReactDOM ShieldStudies */
|
||||
|
||||
/**
|
||||
* Mapping of pages displayed on the sidebar. Keys are the value used in the
|
||||
* URL hash to identify the current page.
|
||||
*
|
||||
* Pages will appear in the sidebar in the order they are defined here. If the
|
||||
* URL doesn't contain a hash, the first page will be displayed in the content area.
|
||||
*/
|
||||
const PAGES = new Map([
|
||||
["shieldStudies", {
|
||||
name: "Shield Studies",
|
||||
component: ShieldStudies,
|
||||
icon: "resource://shield-recipe-client-content/about-studies/img/shield-logo.png",
|
||||
}],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Handle basic layout and routing within about:studies.
|
||||
*/
|
||||
class AboutStudies extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
let hash = new URL(window.location).hash.slice(1);
|
||||
if (!PAGES.has(hash)) {
|
||||
hash = "shieldStudies";
|
||||
}
|
||||
|
||||
this.state = {
|
||||
currentPageId: hash,
|
||||
};
|
||||
|
||||
this.handleEvent = this.handleEvent.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener("hashchange", this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener("hashchange", this);
|
||||
}
|
||||
|
||||
handleEvent(event) {
|
||||
const newHash = new URL(event.newURL).hash.slice(1);
|
||||
if (PAGES.has(newHash)) {
|
||||
this.setState({currentPageId: newHash});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const currentPageId = this.state.currentPageId;
|
||||
const pageEntries = Array.from(PAGES.entries());
|
||||
const currentPage = PAGES.get(currentPageId);
|
||||
|
||||
return (
|
||||
r("div", {className: "about-studies-container"},
|
||||
r(Sidebar, {},
|
||||
pageEntries.map(([id, page]) => (
|
||||
r(SidebarItem, {
|
||||
key: id,
|
||||
pageId: id,
|
||||
selected: id === currentPageId,
|
||||
page,
|
||||
})
|
||||
)),
|
||||
),
|
||||
r(Content, {},
|
||||
currentPage && r(currentPage.component)
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Sidebar extends React.Component {
|
||||
render() {
|
||||
return r("ul", {id: "categories"}, this.props.children);
|
||||
}
|
||||
}
|
||||
Sidebar.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
class SidebarItem extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
window.location = `#${this.props.pageId}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { page, selected } = this.props;
|
||||
return (
|
||||
r("li", {
|
||||
className: classnames("category", {selected}),
|
||||
onClick: this.handleClick,
|
||||
},
|
||||
page.icon && r("img", {className: "category-icon", src: page.icon}),
|
||||
r("span", {className: "category-name"}, page.name),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
SidebarItem.propTypes = {
|
||||
pageId: PropTypes.string.isRequired,
|
||||
page: PropTypes.shape({
|
||||
icon: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
selected: PropTypes.bool,
|
||||
};
|
||||
|
||||
class Content extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
r("div", {className: "main-content"},
|
||||
r("div", {className: "content-box"},
|
||||
this.props.children,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
Content.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
ReactDOM.render(
|
||||
r(AboutStudies),
|
||||
document.getElementById("app"),
|
||||
);
|
|
@ -0,0 +1,137 @@
|
|||
/* 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";
|
||||
/* eslint-disable no-unused-vars */
|
||||
/* global PropTypes React */
|
||||
|
||||
/**
|
||||
* Shorthand for creating elements (to avoid using a JSX preprocessor)
|
||||
*/
|
||||
const r = React.createElement;
|
||||
|
||||
/**
|
||||
* Information box used at the top of listings.
|
||||
*/
|
||||
window.InfoBox = class InfoBox extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
r("div", {className: "info-box"},
|
||||
r("div", {className: "info-box-content"},
|
||||
this.props.children,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
window.InfoBox.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
/**
|
||||
* Button using in-product styling.
|
||||
*/
|
||||
window.FxButton = class FxButton extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
r("button", Object.assign({}, this.props, {children: undefined}),
|
||||
r("div", {className: "button-box"},
|
||||
this.props.children,
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
window.FxButton.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper class for a value that is provided by the frame script.
|
||||
*
|
||||
* Emits a "GetRemoteValue:{name}" page event on load to fetch the initial
|
||||
* value, and listens for "ReceiveRemoveValue:{name}" page callbacks to receive
|
||||
* the value when it updates.
|
||||
*
|
||||
* @example
|
||||
* const myRemoteValue = new RemoteValue("MyValue", 5);
|
||||
* class MyComponent extends React.Component {
|
||||
* constructor(props) {
|
||||
* super(props);
|
||||
* this.state = {
|
||||
* myValue: null,
|
||||
* };
|
||||
* }
|
||||
*
|
||||
* componentWillMount() {
|
||||
* myRemoteValue.subscribe(this);
|
||||
* }
|
||||
*
|
||||
* componentWillUnmount() {
|
||||
* myRemoteValue.unsubscribe(this);
|
||||
* }
|
||||
*
|
||||
* receiveRemoteValue(name, value) {
|
||||
* this.setState({myValue: value});
|
||||
* }
|
||||
*
|
||||
* render() {
|
||||
* return r("div", {}, this.state.myValue);
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
class RemoteValue {
|
||||
constructor(name, defaultValue = null) {
|
||||
this.name = name;
|
||||
this.handlers = [];
|
||||
this.value = defaultValue;
|
||||
|
||||
document.addEventListener(`ReceiveRemoteValue:${this.name}`, this);
|
||||
sendPageEvent(`GetRemoteValue:${this.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to this value as it updates. Handlers are called with the current
|
||||
* value immediately after subscribing.
|
||||
* @param {Object} handler
|
||||
* Object with a receiveRemoteValue(name, value) method that is called with
|
||||
* the name and value of this RemoteValue when it is updated.
|
||||
*/
|
||||
subscribe(handler) {
|
||||
this.handlers.push(handler);
|
||||
handler.receiveRemoteValue(this.name, this.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a previously-registered handler.
|
||||
* @param {Object} handler
|
||||
*/
|
||||
unsubscribe(handler) {
|
||||
this.handlers = this.handlers.filter(h => h !== handler);
|
||||
}
|
||||
|
||||
handleEvent(event) {
|
||||
this.value = event.detail;
|
||||
for (const handler of this.handlers) {
|
||||
handler.receiveRemoteValue(this.name, this.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of RemoteValue instances used within the page.
|
||||
*/
|
||||
const remoteValues = {
|
||||
StudyList: new RemoteValue("StudyList", []),
|
||||
ShieldLearnMoreHref: new RemoteValue("ShieldLearnMoreHref", ""),
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatches a page event to the privileged frame script for this tab.
|
||||
* @param {String} action
|
||||
* @param {Object} data
|
||||
*/
|
||||
function sendPageEvent(action, data) {
|
||||
const event = new CustomEvent("ShieldPageEvent", {bubbles: true, detail: {action, data}});
|
||||
document.dispatchEvent(event);
|
||||
}
|
Двоичные данные
browser/extensions/shield-recipe-client/content/about-studies/img/shield-logo.png
Normal file
Двоичные данные
browser/extensions/shield-recipe-client/content/about-studies/img/shield-logo.png
Normal file
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 5.3 KiB |
|
@ -0,0 +1,148 @@
|
|||
/* 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";
|
||||
/* global classnames FxButton InfoBox PropTypes r React remoteValues sendPageEvent */
|
||||
|
||||
window.ShieldStudies = class ShieldStudies extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
learnMoreHref: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
remoteValues.ShieldLearnMoreHref.subscribe(this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
remoteValues.ShieldLearnMoreHref.unsubscribe(this);
|
||||
}
|
||||
|
||||
receiveRemoteValue(name, value) {
|
||||
this.setState({
|
||||
learnMoreHref: value,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
r("div", {},
|
||||
r(InfoBox, {},
|
||||
r("span", {}, "What's this? Firefox may install and run studies from time to time."),
|
||||
r("a", {id: "shield-studies-learn-more", href: this.state.learnMoreHref}, "Learn more"),
|
||||
r(UpdatePreferencesButton, {}, "Update Preferences"),
|
||||
),
|
||||
r(StudyList),
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
class UpdatePreferencesButton extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
sendPageEvent("NavigateToDataPreferences");
|
||||
}
|
||||
|
||||
render() {
|
||||
return r(
|
||||
FxButton,
|
||||
Object.assign({
|
||||
id: "shield-studies-update-preferences",
|
||||
onClick: this.handleClick,
|
||||
}, this.props),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StudyList extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
studies: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
remoteValues.StudyList.subscribe(this);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
remoteValues.StudyList.unsubscribe(this);
|
||||
}
|
||||
|
||||
receiveRemoteValue(name, value) {
|
||||
const studies = value.slice();
|
||||
|
||||
// Sort by active status, then by start date descending.
|
||||
studies.sort((a, b) => {
|
||||
if (a.active !== b.active) {
|
||||
return a.active ? -1 : 1;
|
||||
}
|
||||
return b.studyStartDate - a.studyStartDate;
|
||||
});
|
||||
|
||||
this.setState({studies});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
r("ul", {className: "study-list"},
|
||||
this.state.studies.map(study => (
|
||||
r(StudyListItem, {key: study.name, study})
|
||||
))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StudyListItem extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClickRemove = this.handleClickRemove.bind(this);
|
||||
}
|
||||
|
||||
handleClickRemove() {
|
||||
sendPageEvent("RemoveStudy", this.props.study.recipeId);
|
||||
}
|
||||
|
||||
render() {
|
||||
const study = this.props.study;
|
||||
return (
|
||||
r("li", {
|
||||
className: classnames("study", {disabled: !study.active}),
|
||||
"data-study-name": study.name,
|
||||
},
|
||||
r("div", {className: "study-icon"},
|
||||
study.name.slice(0, 1)
|
||||
),
|
||||
r("div", {className: "study-details"},
|
||||
r("div", {className: "study-name"}, study.name),
|
||||
r("div", {className: "study-description", title: study.description},
|
||||
r("span", {className: "study-status"}, study.active ? "Active" : "Complete"),
|
||||
r("span", {}, "\u2022"), // •
|
||||
r("span", {}, study.description),
|
||||
),
|
||||
),
|
||||
r("div", {className: "study-actions"},
|
||||
study.active &&
|
||||
r(FxButton, {className: "remove-button", onClick: this.handleClickRemove}, "Remove"),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
StudyListItem.propTypes = {
|
||||
study: PropTypes.shape({
|
||||
recipeId: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
active: PropTypes.boolean,
|
||||
description: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
|
@ -0,0 +1,125 @@
|
|||
/* 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";
|
||||
|
||||
/**
|
||||
* Listen for DOM events bubbling up from the about:studies page, and perform
|
||||
* privileged actions in response to them. If we need to do anything that the
|
||||
* content process can't handle (such as reading IndexedDB), we send a message
|
||||
* to the parent process and handle it there.
|
||||
*
|
||||
* This file is loaded as a frame script. It will be loaded once per tab that
|
||||
* is opened.
|
||||
*/
|
||||
|
||||
/* global content addMessageListener removeMessageListener sendAsyncMessage */
|
||||
|
||||
const { utils: Cu } = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
const frameGlobal = {};
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
frameGlobal, "AboutPages", "resource://shield-recipe-client-content/AboutPages.jsm",
|
||||
);
|
||||
|
||||
const USE_OLD_PREF_ORGANIZATION_PREF = "browser.preferences.useOldOrganization";
|
||||
|
||||
/**
|
||||
* Handles incoming events from the parent process and about:studies.
|
||||
* @implements nsIMessageListener
|
||||
* @implements EventListener
|
||||
*/
|
||||
class ShieldFrameListener {
|
||||
handleEvent(event) {
|
||||
// Abort if the current page isn't about:studies.
|
||||
if (!this.ensureTrustedOrigin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We waited until after we received an event to register message listeners
|
||||
// in order to save resources for tabs that don't ever load about:studies.
|
||||
addMessageListener("Shield:ShuttingDown", this);
|
||||
addMessageListener("Shield:ReceiveStudyList", this);
|
||||
|
||||
switch (event.detail.action) {
|
||||
// Actions that require the parent process
|
||||
case "GetRemoteValue:StudyList":
|
||||
sendAsyncMessage("Shield:GetStudyList");
|
||||
break;
|
||||
case "RemoveStudy":
|
||||
sendAsyncMessage("Shield:RemoveStudy", event.detail.data);
|
||||
break;
|
||||
// Actions that can be performed in the content process
|
||||
case "GetRemoteValue:ShieldLearnMoreHref":
|
||||
this.triggerPageCallback(
|
||||
"ReceiveRemoteValue:ShieldLearnMoreHref",
|
||||
frameGlobal.AboutPages.aboutStudies.getShieldLearnMoreHref()
|
||||
);
|
||||
break;
|
||||
case "NavigateToDataPreferences":
|
||||
this.navigateToDataPreferences();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the current webpage's origin is about:studies.
|
||||
* @return {Boolean}
|
||||
*/
|
||||
ensureTrustedOrigin() {
|
||||
return content.document.documentURI.startsWith("about:studies");
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle messages from the parent process.
|
||||
* @param {Object} message
|
||||
* See the nsIMessageListener docs.
|
||||
*/
|
||||
receiveMessage(message) {
|
||||
switch (message.name) {
|
||||
case "Shield:ReceiveStudyList":
|
||||
this.triggerPageCallback("ReceiveRemoteValue:StudyList", message.data.studies);
|
||||
break;
|
||||
case "Shield:ShuttingDown":
|
||||
this.onShutdown();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an event to communicate with the unprivileged about: page.
|
||||
* @param {String} type
|
||||
* @param {Object} detail
|
||||
*/
|
||||
triggerPageCallback(type, detail) {
|
||||
// Do not communicate with untrusted pages.
|
||||
if (!this.ensureTrustedOrigin()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clone details and use the event class from the unprivileged context.
|
||||
const event = new content.document.defaultView.CustomEvent(type, {
|
||||
bubbles: true,
|
||||
detail: Cu.cloneInto(detail, content.document.defaultView),
|
||||
});
|
||||
content.document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
onShutdown() {
|
||||
removeMessageListener("Shield:SendStudyList", this);
|
||||
removeMessageListener("Shield:ShuttingDown", this);
|
||||
removeEventListener("Shield", this);
|
||||
}
|
||||
|
||||
navigateToDataPreferences() {
|
||||
if (Services.prefs.getBoolPref(USE_OLD_PREF_ORGANIZATION_PREF)) {
|
||||
sendAsyncMessage("Shield:OpenOldDataPreferences");
|
||||
} else {
|
||||
content.location = "about:preferences#privacy-reports";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("ShieldPageEvent", new ShieldFrameListener(), false, true);
|
|
@ -0,0 +1,48 @@
|
|||
/* 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";
|
||||
|
||||
/**
|
||||
* Registers about: pages provided by Shield, and listens for a shutdown event
|
||||
* from the add-on before un-registering them.
|
||||
*
|
||||
* This file is loaded as a process script. It is executed once for each
|
||||
* process, including the parent one.
|
||||
*/
|
||||
|
||||
const { utils: Cu } = Components;
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://shield-recipe-client-content/AboutPages.jsm");
|
||||
|
||||
class ShieldChildListener {
|
||||
onStartup() {
|
||||
Services.cpmm.addMessageListener("Shield:ShuttingDown", this, true);
|
||||
AboutPages.aboutStudies.register();
|
||||
}
|
||||
|
||||
onShutdown() {
|
||||
AboutPages.aboutStudies.unregister();
|
||||
Services.cpmm.removeMessageListener("Shield:ShuttingDown", this);
|
||||
|
||||
// Unload AboutPages.jsm in case the add-on is reinstalled and we need to
|
||||
// load a new version of it.
|
||||
Cu.unload("resource://shield-recipe-client-content/AboutPages.jsm");
|
||||
}
|
||||
|
||||
receiveMessage(message) {
|
||||
switch (message.name) {
|
||||
case "Shield:ShuttingDown":
|
||||
this.onShutdown();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only register in content processes; the parent process handles registration
|
||||
// separately.
|
||||
if (Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_CONTENT) {
|
||||
const listener = new ShieldChildListener();
|
||||
listener.onStartup();
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
<em:type>2</em:type>
|
||||
<em:bootstrap>true</em:bootstrap>
|
||||
<em:unpack>false</em:unpack>
|
||||
<em:version>55.1</em:version>
|
||||
<em:version>65</em:version>
|
||||
<em:name>Shield Recipe Client</em:name>
|
||||
<em:description>Client to download and run recipes for SHIELD, Heartbeat, etc.</em:description>
|
||||
<em:multiprocessCompatible>true</em:multiprocessCompatible>
|
||||
|
|
|
@ -3,7 +3,11 @@
|
|||
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
[features/shield-recipe-client@mozilla.org] chrome.jar:
|
||||
% resource shield-recipe-client %content/
|
||||
content/lib/ (./lib/*)
|
||||
content/node_modules/jexl/ (./node_modules/jexl/*)
|
||||
content/skin/ (skin/*)
|
||||
% resource shield-recipe-client %
|
||||
lib/ (./lib/*)
|
||||
data/ (./data/*)
|
||||
skin/ (skin/*)
|
||||
% resource shield-recipe-client-content %content/
|
||||
content/ (./content/*)
|
||||
% resource shield-recipe-client-vendor %vendor/
|
||||
vendor/ (./vendor/*)
|
||||
|
|
|
@ -72,13 +72,9 @@ this.ActionSandboxManager = class extends SandboxManager {
|
|||
}
|
||||
|
||||
this.cloneIntoGlobal("callbackArgs", args);
|
||||
try {
|
||||
const result = await this.evalInSandbox(`
|
||||
asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
|
||||
`);
|
||||
return Cu.cloneInto(result, {});
|
||||
} catch (err) {
|
||||
throw new Error(Cu.cloneInto(err.message, {}));
|
||||
}
|
||||
const result = await this.evalInSandbox(`
|
||||
asyncCallbacks.get("${callbackName}")(sandboxedDriver, ...callbackArgs);
|
||||
`);
|
||||
return Cu.cloneInto(result, {});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -0,0 +1,324 @@
|
|||
/* 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";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Study
|
||||
* @property {Number} recipeId
|
||||
* ID of the recipe that created the study. Used as the primary key of the
|
||||
* study.
|
||||
* @property {string} name
|
||||
* Name of the study
|
||||
* @property {string} description
|
||||
* Description of the study and its intent.
|
||||
* @property {boolean} active
|
||||
* Is the study still running?
|
||||
* @property {string} addonId
|
||||
* Add-on ID for this particular study.
|
||||
* @property {string} addonUrl
|
||||
* URL that the study add-on was installed from.
|
||||
* @property {string} addonVersion
|
||||
* Study add-on version number
|
||||
* @property {string} studyStartDate
|
||||
* Date when the study was started.
|
||||
* @property {Date} studyEndDate
|
||||
* Date when the study was ended.
|
||||
*/
|
||||
|
||||
const {utils: Cu, interfaces: Ci} = Components;
|
||||
Cu.import("resource://gre/modules/osfile.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "IndexedDB", "resource://gre/modules/IndexedDB.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Addons", "resource://shield-recipe-client/lib/Addons.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
|
||||
);
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "LogManager", "resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
|
||||
Cu.importGlobalProperties(["fetch"]); /* globals fetch */
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["AddonStudies"];
|
||||
|
||||
const DB_NAME = "shield";
|
||||
const STORE_NAME = "addon-studies";
|
||||
const DB_OPTIONS = {
|
||||
version: 1,
|
||||
storage: "persistent",
|
||||
};
|
||||
const STUDY_ENDED_TOPIC = "shield-study-ended";
|
||||
const log = LogManager.getLogger("addon-studies");
|
||||
|
||||
/**
|
||||
* Create a new connection to the database.
|
||||
*/
|
||||
function openDatabase() {
|
||||
return IndexedDB.open(DB_NAME, DB_OPTIONS, db => {
|
||||
db.createObjectStore(STORE_NAME, {
|
||||
keyPath: "recipeId",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the database connection so that it is shared among multiple operations.
|
||||
*/
|
||||
let databasePromise;
|
||||
async function getDatabase() {
|
||||
if (!databasePromise) {
|
||||
databasePromise = openDatabase();
|
||||
}
|
||||
return databasePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a transaction for interacting with the study store.
|
||||
*
|
||||
* NOTE: Methods on the store returned by this function MUST be called
|
||||
* synchronously, otherwise the transaction with the store will expire.
|
||||
* This is why the helper takes a database as an argument; if we fetched the
|
||||
* database in the helper directly, the helper would be async and the
|
||||
* transaction would expire before methods on the store were called.
|
||||
*/
|
||||
function getStore(db) {
|
||||
return db.objectStore(STORE_NAME, "readwrite");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a study object as having ended. Modifies the study in-place.
|
||||
* @param {IDBDatabase} db
|
||||
* @param {Study} study
|
||||
*/
|
||||
async function markAsEnded(db, study) {
|
||||
study.active = false;
|
||||
study.studyEndDate = new Date();
|
||||
await getStore(db).put(study);
|
||||
Services.obs.notifyObservers(study, STUDY_ENDED_TOPIC);
|
||||
}
|
||||
|
||||
this.AddonStudies = {
|
||||
/**
|
||||
* Test wrapper that temporarily replaces the stored studies with the given
|
||||
* ones. The original stored studies are restored upon completion.
|
||||
*
|
||||
* This is defined here instead of in test code since it needs to access the
|
||||
* getDatabase, which we don't expose to avoid outside modules relying on the
|
||||
* type of storage used for studies.
|
||||
*
|
||||
* @param {Array} [studies=[]]
|
||||
*/
|
||||
withStudies(studies = []) {
|
||||
return function wrapper(testFunction) {
|
||||
return async function wrappedTestFunction(...args) {
|
||||
const oldStudies = await AddonStudies.getAll();
|
||||
let db = await getDatabase();
|
||||
await AddonStudies.clear();
|
||||
for (const study of studies) {
|
||||
await getStore(db).add(study);
|
||||
}
|
||||
|
||||
try {
|
||||
await testFunction(...args, studies);
|
||||
} finally {
|
||||
db = await getDatabase(); // Re-acquire in case the test closed the connection.
|
||||
await AddonStudies.clear();
|
||||
for (const study of oldStudies) {
|
||||
await getStore(db).add(study);
|
||||
}
|
||||
|
||||
await AddonStudies.close();
|
||||
}
|
||||
};
|
||||
};
|
||||
},
|
||||
|
||||
async init() {
|
||||
// If an active study's add-on has been removed since we last ran, stop the
|
||||
// study.
|
||||
const activeStudies = (await this.getAll()).filter(study => study.active);
|
||||
const db = await getDatabase();
|
||||
for (const study of activeStudies) {
|
||||
const addon = await AddonManager.getAddonByID(study.addonId);
|
||||
if (!addon) {
|
||||
await markAsEnded(db, study);
|
||||
}
|
||||
}
|
||||
await this.close();
|
||||
|
||||
// Listen for add-on uninstalls so we can stop the corresponding studies.
|
||||
AddonManager.addAddonListener(this);
|
||||
CleanupManager.addCleanupHandler(() => {
|
||||
AddonManager.removeAddonListener(this);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* If a study add-on is uninstalled, mark the study as having ended.
|
||||
* @param {Addon} addon
|
||||
*/
|
||||
async onUninstalled(addon) {
|
||||
const activeStudies = (await this.getAll()).filter(study => study.active);
|
||||
const matchingStudy = activeStudies.find(study => study.addonId === addon.id);
|
||||
if (matchingStudy) {
|
||||
// Use a dedicated DB connection instead of the shared one so that we can
|
||||
// close it without fear of affecting other users of the shared connection.
|
||||
const db = await openDatabase();
|
||||
await markAsEnded(db, matchingStudy);
|
||||
await db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all stored studies.
|
||||
*/
|
||||
async clear() {
|
||||
const db = await getDatabase();
|
||||
await getStore(db).clear();
|
||||
},
|
||||
|
||||
/**
|
||||
* Close the current database connection if it is open.
|
||||
*/
|
||||
async close() {
|
||||
if (databasePromise) {
|
||||
const promise = databasePromise;
|
||||
databasePromise = null;
|
||||
const db = await promise;
|
||||
await db.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Test whether there is a study in storage for the given recipe ID.
|
||||
* @param {Number} recipeId
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
async has(recipeId) {
|
||||
const db = await getDatabase();
|
||||
const study = await getStore(db).get(recipeId);
|
||||
return !!study;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch a study from storage.
|
||||
* @param {Number} recipeId
|
||||
* @return {Study}
|
||||
*/
|
||||
async get(recipeId) {
|
||||
const db = await getDatabase();
|
||||
return getStore(db).get(recipeId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all studies in storage.
|
||||
* @return {Array<Study>}
|
||||
*/
|
||||
async getAll() {
|
||||
const db = await getDatabase();
|
||||
return getStore(db).getAll();
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a new study. Installs an add-on and stores the study info.
|
||||
* @param {Object} options
|
||||
* @param {Number} options.recipeId
|
||||
* @param {String} options.name
|
||||
* @param {String} options.description
|
||||
* @param {String} options.addonUrl
|
||||
* @throws
|
||||
* If any of the required options aren't given.
|
||||
* If a study for the given recipeID already exists in storage.
|
||||
* If add-on installation fails.
|
||||
*/
|
||||
async start({recipeId, name, description, addonUrl}) {
|
||||
if (!recipeId || !name || !description || !addonUrl) {
|
||||
throw new Error("Required arguments (recipeId, name, description, addonUrl) missing.");
|
||||
}
|
||||
|
||||
const db = await getDatabase();
|
||||
if (await getStore(db).get(recipeId)) {
|
||||
throw new Error(`A study for recipe ${recipeId} already exists.`);
|
||||
}
|
||||
|
||||
const addonFile = await this.downloadAddonToTemporaryFile(addonUrl);
|
||||
const install = await AddonManager.getInstallForFile(addonFile);
|
||||
const study = {
|
||||
recipeId,
|
||||
name,
|
||||
description,
|
||||
addonId: install.addon.id,
|
||||
addonVersion: install.addon.version,
|
||||
addonUrl,
|
||||
active: true,
|
||||
studyStartDate: new Date(),
|
||||
};
|
||||
|
||||
try {
|
||||
await getStore(db).add(study);
|
||||
await Addons.applyInstall(install, false);
|
||||
return study;
|
||||
} catch (err) {
|
||||
await getStore(db).delete(recipeId);
|
||||
throw err;
|
||||
} finally {
|
||||
Services.obs.notifyObservers(addonFile, "flush-cache-entry");
|
||||
await OS.File.remove(addonFile.path);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Download a remote add-on and store it in a temporary nsIFile.
|
||||
* @param {String} addonUrl
|
||||
* @returns {nsIFile}
|
||||
*/
|
||||
async downloadAddonToTemporaryFile(addonUrl) {
|
||||
const response = await fetch(addonUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Download for ${addonUrl} failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Create temporary file to store add-on.
|
||||
const path = OS.Path.join(OS.Constants.Path.tmpDir, "study.xpi");
|
||||
const {file, path: uniquePath} = await OS.File.openUnique(path);
|
||||
|
||||
// Write the add-on to the file
|
||||
try {
|
||||
const xpiArrayBufferView = new Uint8Array(await response.arrayBuffer());
|
||||
await file.write(xpiArrayBufferView);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
|
||||
return new FileUtils.File(uniquePath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop an active study, uninstalling the associated add-on.
|
||||
* @param {Number} recipeId
|
||||
* @throws
|
||||
* If no study is found with the given recipeId.
|
||||
* If the study is already inactive.
|
||||
*/
|
||||
async stop(recipeId) {
|
||||
const db = await getDatabase();
|
||||
const study = await getStore(db).get(recipeId);
|
||||
if (!study) {
|
||||
throw new Error(`No study found for recipe ${recipeId}`);
|
||||
}
|
||||
if (!study.active) {
|
||||
throw new Error(`Cannot stop study for recipe ${recipeId}; it is already inactive.`);
|
||||
}
|
||||
|
||||
await markAsEnded(db, study);
|
||||
|
||||
try {
|
||||
await Addons.uninstall(study.addonId);
|
||||
} catch (err) {
|
||||
log.warn(`Could not uninstall addon ${study.addonId} for recipe ${study.recipeId}:`, err);
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,135 @@
|
|||
/* 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 {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager", "resource://gre/modules/AddonManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Extension", "resource://gre/modules/Extension.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
|
||||
);
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["Addons"];
|
||||
|
||||
/**
|
||||
* SafeAddons store info about an add-on. They are single-depth
|
||||
* objects to simplify cloning, and have no methods so they are safe
|
||||
* to pass to sandboxes and filter expressions.
|
||||
*
|
||||
* @typedef {Object} SafeAddon
|
||||
* @property {string} id
|
||||
* Add-on id, such as "shield-recipe-client@mozilla.com" or "{4ea51ac2-adf2-4af8-a69d-17b48c558a12}"
|
||||
* @property {Date} installDate
|
||||
* @property {boolean} isActive
|
||||
* @property {string} name
|
||||
* @property {string} type
|
||||
* "extension", "theme", etc.
|
||||
* @property {string} version
|
||||
*/
|
||||
|
||||
this.Addons = {
|
||||
/**
|
||||
* Get information about an installed add-on by ID.
|
||||
*
|
||||
* @param {string} addonId
|
||||
* @returns {SafeAddon?} Add-on with given ID, or null if not found.
|
||||
* @throws If addonId is not specified or not a string.
|
||||
*/
|
||||
async get(addonId) {
|
||||
const addon = await AddonManager.getAddonByID(addonId);
|
||||
if (!addon) {
|
||||
return null;
|
||||
}
|
||||
return this.serializeForSandbox(addon);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get information about all installed add-ons.
|
||||
* @async
|
||||
* @returns {Array<SafeAddon>}
|
||||
*/
|
||||
async getAll(addonId) {
|
||||
const addons = await AddonManager.getAllAddons();
|
||||
return addons.map(this.serializeForSandbox.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Installs an add-on
|
||||
*
|
||||
* @param {string} addonUrl
|
||||
* Url to download the .xpi for the add-on from.
|
||||
* @param {object} options
|
||||
* @param {boolean} options.update=false
|
||||
* If true, will update an existing installed add-on with the same ID.
|
||||
* @async
|
||||
* @returns {string}
|
||||
* Add-on ID that was installed
|
||||
* @throws {string}
|
||||
* If the add-on can not be installed, or overwriting is disabled and an
|
||||
* add-on with a matching ID is already installed.
|
||||
*/
|
||||
async install(addonUrl, options) {
|
||||
const installObj = await AddonManager.getInstallForURL(addonUrl, null, "application/x-xpinstall");
|
||||
return this.applyInstall(installObj, options);
|
||||
},
|
||||
|
||||
async applyInstall(addonInstall, {update = false} = {}) {
|
||||
const result = new Promise((resolve, reject) => addonInstall.addListener({
|
||||
onInstallStarted(cbInstall) {
|
||||
if (cbInstall.existingAddon && !update) {
|
||||
reject(new Error(`
|
||||
Cannot install add-on ${cbInstall.addon.id}; an existing add-on
|
||||
with the same ID exists and updating is disabled.
|
||||
`));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
onInstallEnded(cbInstall, addon) {
|
||||
resolve(addon.id);
|
||||
},
|
||||
onInstallFailed(cbInstall) {
|
||||
reject(new Error(`AddonInstall error code: [${cbInstall.error}]`));
|
||||
},
|
||||
onDownloadFailed(cbInstall) {
|
||||
reject(new Error(`Download failed: [${cbInstall.sourceURI.spec}]`));
|
||||
},
|
||||
}));
|
||||
addonInstall.install();
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Uninstalls an add-on by ID.
|
||||
* @param addonId {string} Add-on ID to uninstall.
|
||||
* @async
|
||||
* @throws If no add-on with `addonId` is installed.
|
||||
*/
|
||||
async uninstall(addonId) {
|
||||
const addon = await AddonManager.getAddonByID(addonId);
|
||||
if (addon === null) {
|
||||
throw new Error(`No addon with ID [${addonId}] found.`);
|
||||
}
|
||||
addon.uninstall();
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Make a safe serialization of an add-on
|
||||
* @param addon {Object} An add-on object as returned from AddonManager.
|
||||
*/
|
||||
serializeForSandbox(addon) {
|
||||
return {
|
||||
id: addon.id,
|
||||
installDate: new Date(addon.installDate),
|
||||
isActive: addon.isActive,
|
||||
name: addon.name,
|
||||
type: addon.type,
|
||||
version: addon.version,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -16,9 +16,10 @@ XPCOMUtils.defineLazyModuleGetter(this, "NormandyApi", "resource://shield-recipe
|
|||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this,
|
||||
"PreferenceExperiments",
|
||||
"resource://shield-recipe-client/lib/PreferenceExperiments.jsm",
|
||||
"resource://shield-recipe-client/lib/PreferenceExperiments.jsm"
|
||||
);
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Utils", "resource://shield-recipe-client/lib/Utils.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Addons", "resource://shield-recipe-client/lib/Addons.jsm");
|
||||
|
||||
const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
|
||||
|
||||
|
@ -36,7 +37,7 @@ this.ClientEnvironment = {
|
|||
* The server request is made lazily and is cached for the entire browser
|
||||
* session.
|
||||
*/
|
||||
getClientClassification() {
|
||||
async getClientClassification() {
|
||||
if (!_classifyRequest) {
|
||||
_classifyRequest = NormandyApi.classifyClient();
|
||||
}
|
||||
|
@ -197,6 +198,11 @@ this.ClientEnvironment = {
|
|||
return names;
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "addons", async () => {
|
||||
const addons = await Addons.getAll();
|
||||
return Utils.keyBy(addons, "id");
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(environment, "isFirstRun", () => {
|
||||
return Preferences.get("extensions.shield-recipe-client.first_run");
|
||||
});
|
||||
|
|
|
@ -9,21 +9,12 @@ Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
|||
Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/PreferenceFilters.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "mozjexl", "resource://shield-recipe-client-vendor/mozjexl.js");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["FilterExpressions"];
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "nodeRequire", () => {
|
||||
const {Loader, Require} = Cu.import("resource://gre/modules/commonjs/toolkit/loader.js", {});
|
||||
const loader = new Loader({
|
||||
paths: {
|
||||
"": "resource://shield-recipe-client/node_modules/",
|
||||
},
|
||||
});
|
||||
return new Require(loader, {});
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "jexl", () => {
|
||||
const {Jexl} = nodeRequire("jexl/lib/Jexl.js");
|
||||
const jexl = new Jexl();
|
||||
const jexl = new mozjexl.Jexl();
|
||||
jexl.addTransforms({
|
||||
date: dateString => new Date(dateString),
|
||||
stableSample: Sampling.stableSample,
|
||||
|
@ -31,7 +22,9 @@ XPCOMUtils.defineLazyGetter(this, "jexl", () => {
|
|||
preferenceValue: PreferenceFilters.preferenceValue,
|
||||
preferenceIsUserSet: PreferenceFilters.preferenceIsUserSet,
|
||||
preferenceExists: PreferenceFilters.preferenceExists,
|
||||
keys,
|
||||
});
|
||||
jexl.addBinaryOp("intersect", 40, operatorIntersect);
|
||||
return jexl;
|
||||
});
|
||||
|
||||
|
@ -41,3 +34,32 @@ this.FilterExpressions = {
|
|||
return jexl.eval(onelineExpr, context);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Return an array of the given object's own keys (specifically, its enumerable
|
||||
* properties), or undefined if the argument isn't an object.
|
||||
* @param {Object} obj
|
||||
* @return {Array[String]|undefined}
|
||||
*/
|
||||
function keys(obj) {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.keys(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all the values that are present in both lists. Returns undefined if
|
||||
* the arguments are not both Arrays.
|
||||
* @param {Array} listA
|
||||
* @param {Array} listB
|
||||
* @return {Array|undefined}
|
||||
*/
|
||||
function operatorIntersect(listA, listB) {
|
||||
if (!Array.isArray(listA) || !Array.isArray(listB)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return listA.filter(item => listB.includes(item));
|
||||
}
|
||||
|
|
|
@ -6,9 +6,12 @@
|
|||
|
||||
const {utils: Cu, classes: Cc, interfaces: Ci} = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/CanonicalJSON.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/Utils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "CanonicalJSON", "resource://gre/modules/CanonicalJSON.jsm");
|
||||
|
||||
Cu.importGlobalProperties(["fetch", "URL"]); /* globals fetch, URL */
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["NormandyApi"];
|
||||
|
@ -19,6 +22,8 @@ const prefs = Services.prefs.getBranch("extensions.shield-recipe-client.");
|
|||
let indexPromise = null;
|
||||
|
||||
this.NormandyApi = {
|
||||
InvalidSignatureError: class InvalidSignatureError extends Error {},
|
||||
|
||||
clearIndexCache() {
|
||||
indexPromise = null;
|
||||
},
|
||||
|
@ -63,7 +68,7 @@ this.NormandyApi = {
|
|||
|
||||
async getApiUrl(name) {
|
||||
if (!indexPromise) {
|
||||
let apiBase = new URL(prefs.getCharPref("api_url"));
|
||||
const apiBase = new URL(prefs.getCharPref("api_url"));
|
||||
if (!apiBase.pathname.endsWith("/")) {
|
||||
apiBase.pathname += "/";
|
||||
}
|
||||
|
@ -77,46 +82,61 @@ this.NormandyApi = {
|
|||
return this.absolutify(url);
|
||||
},
|
||||
|
||||
async fetchRecipes(filters = {enabled: true}) {
|
||||
const signedRecipesUrl = await this.getApiUrl("recipe-signed");
|
||||
const recipesResponse = await this.get(signedRecipesUrl, filters);
|
||||
const rawText = await recipesResponse.text();
|
||||
const recipesWithSigs = JSON.parse(rawText);
|
||||
async fetchSignedObjects(type, filters) {
|
||||
const signedObjectsUrl = await this.getApiUrl(`${type}-signed`);
|
||||
const objectsResponse = await this.get(signedObjectsUrl, filters);
|
||||
const rawText = await objectsResponse.text();
|
||||
const objectsWithSigs = JSON.parse(rawText);
|
||||
|
||||
const verifiedRecipes = [];
|
||||
const verifiedObjects = [];
|
||||
|
||||
for (const {recipe, signature: {signature, x5u}} of recipesWithSigs) {
|
||||
const serialized = CanonicalJSON.stringify(recipe);
|
||||
for (const objectWithSig of objectsWithSigs) {
|
||||
const {signature, x5u} = objectWithSig.signature;
|
||||
const object = objectWithSig[type];
|
||||
|
||||
const serialized = CanonicalJSON.stringify(object);
|
||||
// Check that the rawtext (the object and the signature)
|
||||
// includes the CanonicalJSON version of the object. This isn't
|
||||
// strictly needed, but it is a great benefit for debugging
|
||||
// signature problems.
|
||||
if (!rawText.includes(serialized)) {
|
||||
log.debug(rawText, serialized);
|
||||
throw new Error("Canonical recipe serialization does not match!");
|
||||
throw new NormandyApi.InvalidSignatureError(
|
||||
`Canonical ${type} serialization does not match!`);
|
||||
}
|
||||
|
||||
const certChainResponse = await fetch(this.absolutify(x5u));
|
||||
const certChainResponse = await this.get(this.absolutify(x5u));
|
||||
const certChain = await certChainResponse.text();
|
||||
const builtSignature = `p384ecdsa=${signature}`;
|
||||
|
||||
const verifier = Cc["@mozilla.org/security/contentsignatureverifier;1"]
|
||||
.createInstance(Ci.nsIContentSignatureVerifier);
|
||||
|
||||
const valid = verifier.verifyContentSignature(
|
||||
serialized,
|
||||
builtSignature,
|
||||
certChain,
|
||||
"normandy.content-signature.mozilla.org"
|
||||
);
|
||||
if (!valid) {
|
||||
throw new Error("Recipe signature is not valid");
|
||||
let valid;
|
||||
try {
|
||||
valid = verifier.verifyContentSignature(
|
||||
serialized,
|
||||
builtSignature,
|
||||
certChain,
|
||||
"normandy.content-signature.mozilla.org"
|
||||
);
|
||||
} catch (err) {
|
||||
throw new NormandyApi.InvalidSignatureError(`${type} signature validation failed: ${err}`);
|
||||
}
|
||||
verifiedRecipes.push(recipe);
|
||||
|
||||
if (!valid) {
|
||||
throw new NormandyApi.InvalidSignatureError(`${type} signature is not valid`);
|
||||
}
|
||||
|
||||
verifiedObjects.push(object);
|
||||
}
|
||||
|
||||
log.debug(
|
||||
`Fetched ${verifiedRecipes.length} recipes from the server:`,
|
||||
verifiedRecipes.map(r => r.name).join(", ")
|
||||
`Fetched ${verifiedObjects.length} ${type} from the server:`,
|
||||
verifiedObjects.map(r => r.name).join(", ")
|
||||
);
|
||||
|
||||
return verifiedRecipes;
|
||||
return verifiedObjects;
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -133,20 +153,67 @@ this.NormandyApi = {
|
|||
|
||||
/**
|
||||
* Fetch an array of available actions from the server.
|
||||
* @param filters
|
||||
* @param filters.enabled {boolean} If true, only returns enabled
|
||||
* recipes. Default true.
|
||||
* @resolves {Array}
|
||||
*/
|
||||
async fetchActions() {
|
||||
const actionApiUrl = await this.getApiUrl("action-list");
|
||||
const res = await this.get(actionApiUrl);
|
||||
return res.json();
|
||||
async fetchRecipes(filters = {enabled: true}) {
|
||||
return this.fetchSignedObjects("recipe", filters);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch an array of available actions from the server.
|
||||
* @resolves {Array}
|
||||
*/
|
||||
async fetchActions(filters = {}) {
|
||||
return this.fetchSignedObjects("action", filters);
|
||||
},
|
||||
|
||||
async fetchImplementation(action) {
|
||||
const response = await fetch(action.implementation_url);
|
||||
if (response.ok) {
|
||||
return response.text();
|
||||
const implementationUrl = new URL(this.absolutify(action.implementation_url));
|
||||
|
||||
// fetch implementation
|
||||
const response = await fetch(implementationUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch action implementation for ${action.name}: ${response.status}`
|
||||
);
|
||||
}
|
||||
const responseText = await response.text();
|
||||
|
||||
// Try to verify integrity of the implementation text. If the
|
||||
// integrity value doesn't match the content or uses an unknown
|
||||
// algorithm, fail.
|
||||
|
||||
// Get the last non-empty portion of the url path, and split it
|
||||
// into two to get the aglorithm and hash.
|
||||
const parts = implementationUrl.pathname.split("/");
|
||||
const lastNonEmpty = parts.filter(p => p !== "").slice(-1)[0];
|
||||
const [algorithm, ...hashParts] = lastNonEmpty.split("-");
|
||||
const expectedHash = hashParts.join("-");
|
||||
|
||||
if (algorithm !== "sha384") {
|
||||
throw new Error(
|
||||
`Failed to fetch action implemenation for ${action.name}: ` +
|
||||
`Unexpected integrity algorithm, expected "sha384", got ${algorithm}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to fetch action implementation for ${action.name}: ${response.status}`);
|
||||
// verify integrity hash
|
||||
const hasher = Cc["@mozilla.org/security/hash;1"].createInstance(Ci.nsICryptoHash);
|
||||
hasher.init(hasher.SHA384);
|
||||
const dataToHash = new TextEncoder().encode(responseText);
|
||||
hasher.update(dataToHash, dataToHash.length);
|
||||
const useBase64 = true;
|
||||
const hash = hasher.finish(useBase64).replace(/\+/g, "-").replace(/\//g, "_");
|
||||
if (hash !== expectedHash) {
|
||||
throw new Error(
|
||||
`Failed to fetch action implementation for ${action.name}: ` +
|
||||
`Integrity hash does not match content. Expected ${expectedHash} got ${hash}.`
|
||||
);
|
||||
}
|
||||
|
||||
return responseText;
|
||||
},
|
||||
};
|
||||
|
|
|
@ -6,11 +6,13 @@
|
|||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource:///modules/ShellService.jsm");
|
||||
Cu.import("resource://gre/modules/AddonManager.jsm");
|
||||
Cu.import("resource://gre/modules/Timer.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/Addons.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/LogManager.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/Storage.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/Heartbeat.jsm");
|
||||
|
@ -19,6 +21,9 @@ Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm");
|
|||
Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/Sampling.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "AddonStudies", "resource://shield-recipe-client/lib/AddonStudies.jsm");
|
||||
|
||||
const {generateUUID} = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["NormandyDriver"];
|
||||
|
@ -156,6 +161,12 @@ this.NormandyDriver = function(sandboxManager) {
|
|||
sandboxManager.removeHold(`setTimeout-${token}`);
|
||||
},
|
||||
|
||||
addons: {
|
||||
get: sandboxManager.wrapAsync(Addons.get.bind(Addons), {cloneInto: true}),
|
||||
install: sandboxManager.wrapAsync(Addons.install.bind(Addons)),
|
||||
uninstall: sandboxManager.wrapAsync(Addons.uninstall.bind(Addons)),
|
||||
},
|
||||
|
||||
// Sampling
|
||||
ratioSample: sandboxManager.wrapAsync(Sampling.ratioSample),
|
||||
|
||||
|
@ -168,5 +179,51 @@ this.NormandyDriver = function(sandboxManager) {
|
|||
getAllActive: sandboxManager.wrapAsync(PreferenceExperiments.getAllActive, {cloneInto: true}),
|
||||
has: sandboxManager.wrapAsync(PreferenceExperiments.has),
|
||||
},
|
||||
|
||||
// Study storage API
|
||||
studies: {
|
||||
start: sandboxManager.wrapAsync(
|
||||
AddonStudies.start.bind(AddonStudies),
|
||||
{cloneArguments: true, cloneInto: true}
|
||||
),
|
||||
stop: sandboxManager.wrapAsync(AddonStudies.stop.bind(AddonStudies)),
|
||||
get: sandboxManager.wrapAsync(AddonStudies.get.bind(AddonStudies), {cloneInto: true}),
|
||||
getAll: sandboxManager.wrapAsync(AddonStudies.getAll.bind(AddonStudies), {cloneInto: true}),
|
||||
has: sandboxManager.wrapAsync(AddonStudies.has.bind(AddonStudies)),
|
||||
},
|
||||
|
||||
// Preference read-only API
|
||||
preferences: {
|
||||
getBool: wrapPrefGetter(Services.prefs.getBoolPref),
|
||||
getInt: wrapPrefGetter(Services.prefs.getIntPref),
|
||||
getChar: wrapPrefGetter(Services.prefs.getCharPref),
|
||||
has(name) {
|
||||
return Services.prefs.getPrefType(name) !== Services.prefs.PREF_INVALID;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a getter form nsIPrefBranch for use in the sandbox.
|
||||
*
|
||||
* We don't want to export the getters directly in case they add parameters that
|
||||
* aren't safe for the sandbox without us noticing; wrapping helps prevent
|
||||
* passing unknown parameters.
|
||||
*
|
||||
* @param {Function} getter
|
||||
* Function on an nsIPrefBranch that fetches a preference value.
|
||||
* @return {Function}
|
||||
*/
|
||||
function wrapPrefGetter(getter) {
|
||||
return (value, defaultValue = undefined) => {
|
||||
// Passing undefined as the defaultValue disables throwing exceptions when
|
||||
// the pref is missing or the type doesn't match, so we need to specifically
|
||||
// exclude it if we don't want default value behavior.
|
||||
const args = [value];
|
||||
if (defaultValue !== undefined) {
|
||||
args.push(defaultValue);
|
||||
}
|
||||
return getter.apply(null, args);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -149,7 +149,7 @@ this.PreferenceExperiments = {
|
|||
}
|
||||
|
||||
// Check that the current value of the preference is still what we set it to
|
||||
if (getPref(UserPreferences, experiment.preferenceName, experiment.preferenceType) !== experiment.preferenceValue) {
|
||||
if (getPref(UserPreferences, experiment.preferenceName, experiment.preferenceType, undefined) !== experiment.preferenceValue) {
|
||||
// if not, stop the experiment, and skip the remaining steps
|
||||
log.info(`Stopping experiment "${experiment.name}" because its value changed`);
|
||||
await this.stop(experiment.name, false);
|
||||
|
@ -242,7 +242,7 @@ this.PreferenceExperiments = {
|
|||
preferenceName,
|
||||
preferenceValue,
|
||||
preferenceType,
|
||||
previousPreferenceValue: getPref(preferences, preferenceName, preferenceType),
|
||||
previousPreferenceValue: getPref(preferences, preferenceName, preferenceType, undefined),
|
||||
preferenceBranchType,
|
||||
};
|
||||
|
||||
|
@ -289,7 +289,7 @@ this.PreferenceExperiments = {
|
|||
const observerInfo = {
|
||||
preferenceName,
|
||||
observer() {
|
||||
let newValue = getPref(UserPreferences, preferenceName, preferenceType);
|
||||
let newValue = getPref(UserPreferences, preferenceName, preferenceType, undefined);
|
||||
if (newValue !== preferenceValue) {
|
||||
PreferenceExperiments.stop(experimentName, false)
|
||||
.catch(Cu.reportError);
|
||||
|
|
|
@ -29,6 +29,10 @@ XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
|
|||
"resource://shield-recipe-client/lib/CleanupManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ActionSandboxManager",
|
||||
"resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonStudies",
|
||||
"resource://shield-recipe-client/lib/AddonStudies.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Uptake",
|
||||
"resource://shield-recipe-client/lib/Uptake.jsm");
|
||||
|
||||
Cu.importGlobalProperties(["fetch"]);
|
||||
|
||||
|
@ -127,7 +131,30 @@ this.RecipeRunner = {
|
|||
this.clearCaches();
|
||||
// Unless lazy classification is enabled, prep the classify cache.
|
||||
if (!Preferences.get("extensions.shield-recipe-client.experiments.lazy_classify", false)) {
|
||||
await ClientEnvironment.getClientClassification();
|
||||
try {
|
||||
await ClientEnvironment.getClientClassification();
|
||||
} catch (err) {
|
||||
// Try to go on without this data; the filter expressions will
|
||||
// gracefully fail without this info if they need it.
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes before execution in case we fail and exit early.
|
||||
let recipes;
|
||||
try {
|
||||
recipes = await NormandyApi.fetchRecipes({enabled: true});
|
||||
} catch (e) {
|
||||
const apiUrl = prefs.getCharPref("api_url");
|
||||
log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
|
||||
|
||||
let status = Uptake.RUNNER_SERVER_ERROR;
|
||||
if (/NetworkError/.test(e)) {
|
||||
status = Uptake.RUNNER_NETWORK_ERROR;
|
||||
} else if (e instanceof NormandyApi.InvalidSignatureError) {
|
||||
status = Uptake.RUNNER_INVALID_SIGNATURE;
|
||||
}
|
||||
Uptake.reportRunner(status);
|
||||
return;
|
||||
}
|
||||
|
||||
const actionSandboxManagers = await this.loadActionSandboxManagers();
|
||||
|
@ -142,19 +169,10 @@ this.RecipeRunner = {
|
|||
} catch (err) {
|
||||
log.error(`Could not run pre-execution hook for ${actionName}:`, err.message);
|
||||
manager.disabled = true;
|
||||
Uptake.reportAction(actionName, Uptake.ACTION_PRE_EXECUTION_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes from the API
|
||||
let recipes;
|
||||
try {
|
||||
recipes = await NormandyApi.fetchRecipes({enabled: true});
|
||||
} catch (e) {
|
||||
const apiUrl = prefs.getCharPref("api_url");
|
||||
log.error(`Could not fetch recipes from ${apiUrl}: "${e}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Evaluate recipe filters
|
||||
const recipesToRun = [];
|
||||
for (const recipe of recipes) {
|
||||
|
@ -169,23 +187,31 @@ this.RecipeRunner = {
|
|||
} else {
|
||||
for (const recipe of recipesToRun) {
|
||||
const manager = actionSandboxManagers[recipe.action];
|
||||
let status;
|
||||
if (!manager) {
|
||||
log.error(
|
||||
`Could not execute recipe ${recipe.name}:`,
|
||||
`Action ${recipe.action} is either missing or invalid.`
|
||||
);
|
||||
status = Uptake.RECIPE_INVALID_ACTION;
|
||||
} else if (manager.disabled) {
|
||||
log.warn(
|
||||
`Skipping recipe ${recipe.name} because ${recipe.action} failed during pre-execution.`
|
||||
);
|
||||
status = Uptake.RECIPE_ACTION_DISABLED;
|
||||
} else {
|
||||
try {
|
||||
log.info(`Executing recipe "${recipe.name}" (action=${recipe.action})`);
|
||||
await manager.runAsyncCallback("action", recipe);
|
||||
status = Uptake.RECIPE_SUCCESS;
|
||||
} catch (e) {
|
||||
log.error(`Could not execute recipe ${recipe.name}:`, e);
|
||||
log.error(`Could not execute recipe ${recipe.name}:`);
|
||||
Cu.reportError(e);
|
||||
status = Uptake.RECIPE_EXECUTION_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
Uptake.reportRecipe(recipe.id, status);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -199,13 +225,20 @@ this.RecipeRunner = {
|
|||
|
||||
try {
|
||||
await manager.runAsyncCallback("postExecution");
|
||||
Uptake.reportAction(actionName, Uptake.ACTION_SUCCESS);
|
||||
} catch (err) {
|
||||
log.info(`Could not run post-execution hook for ${actionName}:`, err.message);
|
||||
Uptake.reportAction(actionName, Uptake.ACTION_POST_EXECUTION_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
// Nuke sandboxes
|
||||
Object.values(actionSandboxManagers).forEach(manager => manager.removeHold("recipeRunner"));
|
||||
|
||||
// Close storage connections
|
||||
await AddonStudies.close();
|
||||
|
||||
Uptake.reportRunner(Uptake.RUNNER_SUCCESS);
|
||||
},
|
||||
|
||||
async loadActionSandboxManagers() {
|
||||
|
@ -217,6 +250,12 @@ this.RecipeRunner = {
|
|||
actionSandboxManagers[action.name] = new ActionSandboxManager(implementation);
|
||||
} catch (err) {
|
||||
log.warn(`Could not fetch implementation for ${action.name}:`, err);
|
||||
|
||||
let status = Uptake.ACTION_SERVER_ERROR;
|
||||
if (/NetworkError/.test(err)) {
|
||||
status = Uptake.ACTION_NETWORK_ERROR;
|
||||
}
|
||||
Uptake.reportAction(action.name, status);
|
||||
}
|
||||
}
|
||||
return actionSandboxManagers;
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
/* 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 {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "AppConstants", "resource://gre/modules/AppConstants.jsm"
|
||||
);
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "CleanupManager", "resource://shield-recipe-client/lib/CleanupManager.jsm"
|
||||
);
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["ShieldPreferences"];
|
||||
|
||||
const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
|
||||
const NS_PREFBRANCH_PREFCHANGE_TOPIC_ID = "nsPref:changed"; // from modules/libpref/nsIPrefBranch.idl
|
||||
const FHR_UPLOAD_ENABLED_PREF = "datareporting.healthreport.uploadEnabled";
|
||||
const OPT_OUT_STUDIES_ENABLED_PREF = "app.shield.optoutstudies.enabled";
|
||||
|
||||
/**
|
||||
* Handles Shield-specific preferences, including their UI.
|
||||
*/
|
||||
this.ShieldPreferences = {
|
||||
init() {
|
||||
// If the FHR pref was disabled since our last run, disable opt-out as well.
|
||||
if (!Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF)) {
|
||||
Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, false);
|
||||
}
|
||||
|
||||
// Watch for changes to the FHR pref
|
||||
Services.prefs.addObserver(FHR_UPLOAD_ENABLED_PREF, this);
|
||||
CleanupManager.addCleanupHandler(() => {
|
||||
Services.prefs.removeObserver(FHR_UPLOAD_ENABLED_PREF, this);
|
||||
});
|
||||
|
||||
// Disabled outside of en-* locales temporarily (bug 1377192).
|
||||
// Disabled when MOZ_DATA_REPORTING is false since the FHR UI is also hidden
|
||||
// when data reporting is false.
|
||||
if (AppConstants.MOZ_DATA_REPORTING && Services.locale.getAppLocaleAsLangTag().startsWith("en")) {
|
||||
Services.obs.addObserver(this, "advanced-pane-loaded");
|
||||
CleanupManager.addCleanupHandler(() => {
|
||||
Services.obs.removeObserver(this, "advanced-pane-loaded");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
observe(subject, topic, data) {
|
||||
switch (topic) {
|
||||
// Add the opt-out-study checkbox to the Privacy preferences when it is shown.
|
||||
case "advanced-pane-loaded":
|
||||
if (!Services.prefs.getBoolPref("browser.preferences.useOldOrganization", false)) {
|
||||
this.injectOptOutStudyCheckbox(subject.document);
|
||||
}
|
||||
break;
|
||||
// If the FHR pref changes, set the opt-out-study pref to the value it is changing to.
|
||||
case NS_PREFBRANCH_PREFCHANGE_TOPIC_ID:
|
||||
if (data === FHR_UPLOAD_ENABLED_PREF) {
|
||||
const fhrUploadEnabled = Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF);
|
||||
Services.prefs.setBoolPref(OPT_OUT_STUDIES_ENABLED_PREF, fhrUploadEnabled);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Injects the opt-out-study preference checkbox into about:preferences and
|
||||
* handles events coming from the UI for it.
|
||||
*/
|
||||
injectOptOutStudyCheckbox(doc) {
|
||||
const container = doc.createElementNS(XUL_NS, "vbox");
|
||||
container.classList.add("indent");
|
||||
|
||||
const hContainer = doc.createElementNS(XUL_NS, "hbox");
|
||||
hContainer.setAttribute("align", "center");
|
||||
container.appendChild(hContainer);
|
||||
|
||||
const checkbox = doc.createElementNS(XUL_NS, "checkbox");
|
||||
checkbox.setAttribute("id", "optOutStudiesEnabled");
|
||||
checkbox.setAttribute("label", "Allow Firefox to install and run studies");
|
||||
checkbox.setAttribute("preference", OPT_OUT_STUDIES_ENABLED_PREF);
|
||||
checkbox.setAttribute("disabled", !Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF));
|
||||
hContainer.appendChild(checkbox);
|
||||
|
||||
const viewStudies = doc.createElementNS(XUL_NS, "label");
|
||||
viewStudies.setAttribute("id", "viewShieldStudies");
|
||||
viewStudies.setAttribute("href", "about:studies");
|
||||
viewStudies.setAttribute("useoriginprincipal", true);
|
||||
viewStudies.textContent = "View Firefox Studies";
|
||||
viewStudies.classList.add("learnMore", "text-link");
|
||||
hContainer.appendChild(viewStudies);
|
||||
|
||||
// <prefrence> elements for prefs that we need to monitor while the page is open.
|
||||
const optOutPref = doc.createElementNS(XUL_NS, "preference");
|
||||
optOutPref.setAttribute("id", OPT_OUT_STUDIES_ENABLED_PREF);
|
||||
optOutPref.setAttribute("name", OPT_OUT_STUDIES_ENABLED_PREF);
|
||||
optOutPref.setAttribute("type", "bool");
|
||||
|
||||
// Weirdly, FHR doesn't have a <preference> element on the page, so we create it.
|
||||
const fhrPref = doc.createElementNS(XUL_NS, "preference");
|
||||
fhrPref.setAttribute("id", FHR_UPLOAD_ENABLED_PREF);
|
||||
fhrPref.setAttribute("name", FHR_UPLOAD_ENABLED_PREF);
|
||||
fhrPref.setAttribute("type", "bool");
|
||||
fhrPref.addEventListener("change", function(event) {
|
||||
// Avoid reference to the document directly, to avoid leaks.
|
||||
const eventTargetCheckbox = event.target.ownerDocument.getElementById("optOutStudiesEnabled");
|
||||
eventTargetCheckbox.disabled = !Services.prefs.getBoolPref(FHR_UPLOAD_ENABLED_PREF);
|
||||
});
|
||||
|
||||
// Actually inject the elements we've created.
|
||||
const parent = doc.getElementById("submitHealthReportBox").closest("vbox");
|
||||
parent.appendChild(container);
|
||||
|
||||
const preferences = doc.getElementById("privacyPreferences");
|
||||
preferences.appendChild(optOutPref);
|
||||
preferences.appendChild(fhrPref);
|
||||
},
|
||||
};
|
|
@ -16,6 +16,12 @@ XPCOMUtils.defineLazyModuleGetter(this, "CleanupManager",
|
|||
"resource://shield-recipe-client/lib/CleanupManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "PreferenceExperiments",
|
||||
"resource://shield-recipe-client/lib/PreferenceExperiments.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AboutPages",
|
||||
"resource://shield-recipe-client-content/AboutPages.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ShieldPreferences",
|
||||
"resource://shield-recipe-client/lib/ShieldPreferences.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonStudies",
|
||||
"resource://shield-recipe-client/lib/AddonStudies.jsm");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["ShieldRecipeClient"];
|
||||
|
||||
|
@ -31,19 +37,8 @@ const REASONS = {
|
|||
ADDON_UPGRADE: 7, // The add-on is being upgraded.
|
||||
ADDON_DOWNGRADE: 8, // The add-on is being downgraded.
|
||||
};
|
||||
const PREF_BRANCH = "extensions.shield-recipe-client.";
|
||||
const DEFAULT_PREFS = {
|
||||
api_url: ["https://normandy.cdn.mozilla.net/api/v1", PREF_STRING],
|
||||
dev_mode: [false, PREF_BOOL],
|
||||
enabled: [true, PREF_BOOL],
|
||||
startup_delay_seconds: [300, PREF_INT],
|
||||
"logging.level": [Log.Level.Warn, PREF_INT],
|
||||
user_id: ["", PREF_STRING],
|
||||
run_interval_seconds: [86400, PREF_INT], // 24 hours
|
||||
first_run: [true, PREF_BOOL],
|
||||
};
|
||||
const PREF_DEV_MODE = "extensions.shield-recipe-client.dev_mode";
|
||||
const PREF_LOGGING_LEVEL = PREF_BRANCH + "logging.level";
|
||||
const PREF_LOGGING_LEVEL = "extensions.shield-recipe-client.logging.level";
|
||||
|
||||
let log = null;
|
||||
|
||||
|
@ -54,8 +49,6 @@ let log = null;
|
|||
*/
|
||||
this.ShieldRecipeClient = {
|
||||
async startup() {
|
||||
ShieldRecipeClient.setDefaultPrefs();
|
||||
|
||||
// Setup logging and listen for changes to logging prefs
|
||||
LogManager.configure(Services.prefs.getIntPref(PREF_LOGGING_LEVEL));
|
||||
Services.prefs.addObserver(PREF_LOGGING_LEVEL, LogManager.configure);
|
||||
|
@ -64,6 +57,18 @@ this.ShieldRecipeClient = {
|
|||
);
|
||||
log = LogManager.getLogger("bootstrap");
|
||||
|
||||
try {
|
||||
await AboutPages.init();
|
||||
} catch (err) {
|
||||
log.error("Failed to initialize about pages:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
await AddonStudies.init();
|
||||
} catch (err) {
|
||||
log.error("Failed to initialize addon studies:", err);
|
||||
}
|
||||
|
||||
// Initialize experiments first to avoid a race between initializing prefs
|
||||
// and recipes rolling back pref changes when experiments end.
|
||||
try {
|
||||
|
@ -72,35 +77,16 @@ this.ShieldRecipeClient = {
|
|||
log.error("Failed to initialize preference experiments:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
ShieldPreferences.init();
|
||||
} catch (err) {
|
||||
log.error("Failed to initialize preferences UI:", err);
|
||||
}
|
||||
|
||||
await RecipeRunner.init();
|
||||
},
|
||||
|
||||
shutdown(reason) {
|
||||
CleanupManager.cleanup();
|
||||
},
|
||||
|
||||
setDefaultPrefs() {
|
||||
for (const [key, [val, type]] of Object.entries(DEFAULT_PREFS)) {
|
||||
const fullKey = PREF_BRANCH + key;
|
||||
// If someone beat us to setting a default, don't overwrite it.
|
||||
if (!Services.prefs.prefHasUserValue(fullKey)) {
|
||||
switch (type) {
|
||||
case PREF_BOOL:
|
||||
Services.prefs.setBoolPref(fullKey, val);
|
||||
break;
|
||||
|
||||
case PREF_INT:
|
||||
Services.prefs.setIntPref(fullKey, val);
|
||||
break;
|
||||
|
||||
case PREF_STRING:
|
||||
Services.prefs.setStringPref(fullKey, val);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new TypeError(`Unexpected type (${type}) for preference ${fullKey}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/* 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 {utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(
|
||||
this, "UptakeTelemetry", "resource://services-common/uptake-telemetry.js");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["Uptake"];
|
||||
|
||||
const SOURCE_PREFIX = "shield-recipe-client";
|
||||
|
||||
this.Uptake = {
|
||||
// Action uptake
|
||||
ACTION_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR,
|
||||
ACTION_PRE_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_1_ERROR,
|
||||
ACTION_POST_EXECUTION_ERROR: UptakeTelemetry.STATUS.CUSTOM_2_ERROR,
|
||||
ACTION_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR,
|
||||
ACTION_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
|
||||
|
||||
// Per-recipe uptake
|
||||
RECIPE_ACTION_DISABLED: UptakeTelemetry.STATUS.CUSTOM_1_ERROR,
|
||||
RECIPE_EXECUTION_ERROR: UptakeTelemetry.STATUS.APPLY_ERROR,
|
||||
RECIPE_INVALID_ACTION: UptakeTelemetry.STATUS.DOWNLOAD_ERROR,
|
||||
RECIPE_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
|
||||
|
||||
// Uptake for the runner as a whole
|
||||
RUNNER_INVALID_SIGNATURE: UptakeTelemetry.STATUS.SIGNATURE_ERROR,
|
||||
RUNNER_NETWORK_ERROR: UptakeTelemetry.STATUS.NETWORK_ERROR,
|
||||
RUNNER_SERVER_ERROR: UptakeTelemetry.STATUS.SERVER_ERROR,
|
||||
RUNNER_SUCCESS: UptakeTelemetry.STATUS.SUCCESS,
|
||||
|
||||
reportRunner(status) {
|
||||
UptakeTelemetry.report(`${SOURCE_PREFIX}/runner`, status);
|
||||
},
|
||||
|
||||
reportRecipe(recipeId, status) {
|
||||
UptakeTelemetry.report(`${SOURCE_PREFIX}/recipe/${recipeId}`, status);
|
||||
},
|
||||
|
||||
reportAction(actionName, status) {
|
||||
UptakeTelemetry.report(`${SOURCE_PREFIX}/action/${actionName}`, status);
|
||||
},
|
||||
};
|
19
browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
сгенерированный
поставляемый
19
browser/extensions/shield-recipe-client/node_modules/jexl/LICENSE.txt
сгенерированный
поставляемый
|
@ -1,19 +0,0 @@
|
|||
Copyright (c) 2015 TechnologyAdvice
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
225
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
сгенерированный
поставляемый
225
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Jexl.js
сгенерированный
поставляемый
|
@ -1,225 +0,0 @@
|
|||
/*
|
||||
* Jexl
|
||||
* Copyright (c) 2015 TechnologyAdvice
|
||||
*/
|
||||
|
||||
var Evaluator = require('./evaluator/Evaluator'),
|
||||
Lexer = require('./Lexer'),
|
||||
Parser = require('./parser/Parser'),
|
||||
defaultGrammar = require('./grammar').elements;
|
||||
|
||||
/**
|
||||
* Jexl is the Javascript Expression Language, capable of parsing and
|
||||
* evaluating basic to complex expression strings, combined with advanced
|
||||
* xpath-like drilldown into native Javascript objects.
|
||||
* @constructor
|
||||
*/
|
||||
function Jexl() {
|
||||
this._customGrammar = null;
|
||||
this._lexer = null;
|
||||
this._transforms = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a binary operator to Jexl at the specified precedence. The higher the
|
||||
* precedence, the earlier the operator is applied in the order of operations.
|
||||
* For example, * has a higher precedence than +, because multiplication comes
|
||||
* before division.
|
||||
*
|
||||
* Please see grammar.js for a listing of all default operators and their
|
||||
* precedence values in order to choose the appropriate precedence for the
|
||||
* new operator.
|
||||
* @param {string} operator The operator string to be added
|
||||
* @param {number} precedence The operator's precedence
|
||||
* @param {function} fn A function to run to calculate the result. The function
|
||||
* will be called with two arguments: left and right, denoting the values
|
||||
* on either side of the operator. It should return either the resulting
|
||||
* value, or a Promise that resolves with the resulting value.
|
||||
*/
|
||||
Jexl.prototype.addBinaryOp = function(operator, precedence, fn) {
|
||||
this._addGrammarElement(operator, {
|
||||
type: 'binaryOp',
|
||||
precedence: precedence,
|
||||
eval: fn
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a unary operator to Jexl. Unary operators are currently only supported
|
||||
* on the left side of the value on which it will operate.
|
||||
* @param {string} operator The operator string to be added
|
||||
* @param {function} fn A function to run to calculate the result. The function
|
||||
* will be called with one argument: the literal value to the right of the
|
||||
* operator. It should return either the resulting value, or a Promise
|
||||
* that resolves with the resulting value.
|
||||
*/
|
||||
Jexl.prototype.addUnaryOp = function(operator, fn) {
|
||||
this._addGrammarElement(operator, {
|
||||
type: 'unaryOp',
|
||||
weight: Infinity,
|
||||
eval: fn
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds or replaces a transform function in this Jexl instance.
|
||||
* @param {string} name The name of the transform function, as it will be used
|
||||
* within Jexl expressions
|
||||
* @param {function} fn The function to be executed when this transform is
|
||||
* invoked. It will be provided with two arguments:
|
||||
* - {*} value: The value to be transformed
|
||||
* - {{}} args: The arguments for this transform
|
||||
* - {function} cb: A callback function to be called with an error
|
||||
* if the transform fails, or a null first argument and the
|
||||
* transformed value as the second argument on success.
|
||||
*/
|
||||
Jexl.prototype.addTransform = function(name, fn) {
|
||||
this._transforms[name] = fn;
|
||||
};
|
||||
|
||||
/**
|
||||
* Syntactic sugar for calling {@link #addTransform} repeatedly. This function
|
||||
* accepts a map of one or more transform names to their transform function.
|
||||
* @param {{}} map A map of transform names to transform functions
|
||||
*/
|
||||
Jexl.prototype.addTransforms = function(map) {
|
||||
for (var key in map) {
|
||||
if (map.hasOwnProperty(key))
|
||||
this._transforms[key] = map[key];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a previously set transform function.
|
||||
* @param {string} name The name of the transform function
|
||||
* @returns {function} The transform function
|
||||
*/
|
||||
Jexl.prototype.getTransform = function(name) {
|
||||
return this._transforms[name];
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a Jexl string within an optional context.
|
||||
* @param {string} expression The Jexl expression to be evaluated
|
||||
* @param {Object} [context] A mapping of variables to values, which will be
|
||||
* made accessible to the Jexl expression when evaluating it
|
||||
* @param {function} [cb] An optional callback function to be executed when
|
||||
* evaluation is complete. It will be supplied with two arguments:
|
||||
* - {Error|null} err: Present if an error occurred
|
||||
* - {*} result: The result of the evaluation
|
||||
* @returns {Promise<*>} resolves with the result of the evaluation. Note that
|
||||
* if a callback is supplied, the returned promise will already have
|
||||
* a '.catch' attached to it in order to pass the error to the callback.
|
||||
*/
|
||||
Jexl.prototype.eval = function(expression, context, cb) {
|
||||
if (typeof context === 'function') {
|
||||
cb = context;
|
||||
context = {};
|
||||
}
|
||||
else if (!context)
|
||||
context = {};
|
||||
var valPromise = this._eval(expression, context);
|
||||
if (cb) {
|
||||
// setTimeout is used for the callback to break out of the Promise's
|
||||
// try/catch in case the callback throws.
|
||||
var called = false;
|
||||
return valPromise.then(function(val) {
|
||||
called = true;
|
||||
setTimeout(cb.bind(null, null, val), 0);
|
||||
}).catch(function(err) {
|
||||
if (!called)
|
||||
setTimeout(cb.bind(null, err), 0);
|
||||
});
|
||||
}
|
||||
return valPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a binary or unary operator from the Jexl grammar.
|
||||
* @param {string} operator The operator string to be removed
|
||||
*/
|
||||
Jexl.prototype.removeOp = function(operator) {
|
||||
var grammar = this._getCustomGrammar();
|
||||
if (grammar[operator] && (grammar[operator].type == 'binaryOp' ||
|
||||
grammar[operator].type == 'unaryOp')) {
|
||||
delete grammar[operator];
|
||||
this._lexer = null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds an element to the grammar map used by this Jexl instance, cloning
|
||||
* the default grammar first if necessary.
|
||||
* @param {string} str The key string to be added
|
||||
* @param {{type: <string>}} obj A map of configuration options for this
|
||||
* grammar element
|
||||
* @private
|
||||
*/
|
||||
Jexl.prototype._addGrammarElement = function(str, obj) {
|
||||
var grammar = this._getCustomGrammar();
|
||||
grammar[str] = obj;
|
||||
this._lexer = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a Jexl string in the given context.
|
||||
* @param {string} exp The Jexl expression to be evaluated
|
||||
* @param {Object} [context] A mapping of variables to values, which will be
|
||||
* made accessible to the Jexl expression when evaluating it
|
||||
* @returns {Promise<*>} resolves with the result of the evaluation.
|
||||
* @private
|
||||
*/
|
||||
Jexl.prototype._eval = function(exp, context) {
|
||||
var self = this,
|
||||
grammar = this._getGrammar(),
|
||||
parser = new Parser(grammar),
|
||||
evaluator = new Evaluator(grammar, this._transforms, context);
|
||||
return Promise.resolve().then(function() {
|
||||
parser.addTokens(self._getLexer().tokenize(exp));
|
||||
return evaluator.eval(parser.complete());
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the custom grammar object, creating it first if necessary. New custom
|
||||
* grammars are created by executing a shallow clone of the default grammar
|
||||
* map. The returned map is available to be changed.
|
||||
* @returns {{}} a customizable grammar map.
|
||||
* @private
|
||||
*/
|
||||
Jexl.prototype._getCustomGrammar = function() {
|
||||
if (!this._customGrammar) {
|
||||
this._customGrammar = {};
|
||||
for (var key in defaultGrammar) {
|
||||
if (defaultGrammar.hasOwnProperty(key))
|
||||
this._customGrammar[key] = defaultGrammar[key];
|
||||
}
|
||||
}
|
||||
return this._customGrammar;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the grammar map currently being used by Jexl; either the default map,
|
||||
* or a locally customized version. The returned map should never be changed
|
||||
* in any way.
|
||||
* @returns {{}} the grammar map currently in use.
|
||||
* @private
|
||||
*/
|
||||
Jexl.prototype._getGrammar = function() {
|
||||
return this._customGrammar || defaultGrammar;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a Lexer instance as a singleton in reference to this Jexl instance.
|
||||
* @returns {Lexer} an instance of Lexer, initialized with a grammar
|
||||
* appropriate to this Jexl instance.
|
||||
* @private
|
||||
*/
|
||||
Jexl.prototype._getLexer = function() {
|
||||
if (!this._lexer)
|
||||
this._lexer = new Lexer(this._getGrammar());
|
||||
return this._lexer;
|
||||
};
|
||||
|
||||
module.exports = new Jexl();
|
||||
module.exports.Jexl = Jexl;
|
244
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
сгенерированный
поставляемый
244
browser/extensions/shield-recipe-client/node_modules/jexl/lib/Lexer.js
сгенерированный
поставляемый
|
@ -1,244 +0,0 @@
|
|||
/*
|
||||
* Jexl
|
||||
* Copyright (c) 2015 TechnologyAdvice
|
||||
*/
|
||||
|
||||
var numericRegex = /^-?(?:(?:[0-9]*\.[0-9]+)|[0-9]+)$/,
|
||||
identRegex = /^[a-zA-Z_\$][a-zA-Z0-9_\$]*$/,
|
||||
escEscRegex = /\\\\/,
|
||||
preOpRegexElems = [
|
||||
// Strings
|
||||
"'(?:(?:\\\\')?[^'])*'",
|
||||
'"(?:(?:\\\\")?[^"])*"',
|
||||
// Whitespace
|
||||
'\\s+',
|
||||
// Booleans
|
||||
'\\btrue\\b',
|
||||
'\\bfalse\\b'
|
||||
],
|
||||
postOpRegexElems = [
|
||||
// Identifiers
|
||||
'\\b[a-zA-Z_\\$][a-zA-Z0-9_\\$]*\\b',
|
||||
// Numerics (without negative symbol)
|
||||
'(?:(?:[0-9]*\\.[0-9]+)|[0-9]+)'
|
||||
],
|
||||
minusNegatesAfter = ['binaryOp', 'unaryOp', 'openParen', 'openBracket',
|
||||
'question', 'colon'];
|
||||
|
||||
/**
|
||||
* Lexer is a collection of stateless, statically-accessed functions for the
|
||||
* lexical parsing of a Jexl string. Its responsibility is to identify the
|
||||
* "parts of speech" of a Jexl expression, and tokenize and label each, but
|
||||
* to do only the most minimal syntax checking; the only errors the Lexer
|
||||
* should be concerned with are if it's unable to identify the utility of
|
||||
* any of its tokens. Errors stemming from these tokens not being in a
|
||||
* sensible configuration should be left for the Parser to handle.
|
||||
* @type {{}}
|
||||
*/
|
||||
function Lexer(grammar) {
|
||||
this._grammar = grammar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a Jexl expression string into an array of expression elements.
|
||||
* @param {string} str A Jexl expression string
|
||||
* @returns {Array<string>} An array of substrings defining the functional
|
||||
* elements of the expression.
|
||||
*/
|
||||
Lexer.prototype.getElements = function(str) {
|
||||
var regex = this._getSplitRegex();
|
||||
return str.split(regex).filter(function(elem) {
|
||||
// Remove empty strings
|
||||
return elem;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an array of expression elements into an array of tokens. Note that
|
||||
* the resulting array may not equal the element array in length, as any
|
||||
* elements that consist only of whitespace get appended to the previous
|
||||
* token's "raw" property. For the structure of a token object, please see
|
||||
* {@link Lexer#tokenize}.
|
||||
* @param {Array<string>} elements An array of Jexl expression elements to be
|
||||
* converted to tokens
|
||||
* @returns {Array<{type, value, raw}>} an array of token objects.
|
||||
*/
|
||||
Lexer.prototype.getTokens = function(elements) {
|
||||
var tokens = [],
|
||||
negate = false;
|
||||
for (var i = 0; i < elements.length; i++) {
|
||||
if (this._isWhitespace(elements[i])) {
|
||||
if (tokens.length)
|
||||
tokens[tokens.length - 1].raw += elements[i];
|
||||
}
|
||||
else if (elements[i] === '-' && this._isNegative(tokens))
|
||||
negate = true;
|
||||
else {
|
||||
if (negate) {
|
||||
elements[i] = '-' + elements[i];
|
||||
negate = false;
|
||||
}
|
||||
tokens.push(this._createToken(elements[i]));
|
||||
}
|
||||
}
|
||||
// Catch a - at the end of the string. Let the parser handle that issue.
|
||||
if (negate)
|
||||
tokens.push(this._createToken('-'));
|
||||
return tokens;
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a Jexl string into an array of tokens. Each token is an object
|
||||
* in the following format:
|
||||
*
|
||||
* {
|
||||
* type: <string>,
|
||||
* [name]: <string>,
|
||||
* value: <boolean|number|string>,
|
||||
* raw: <string>
|
||||
* }
|
||||
*
|
||||
* Type is one of the following:
|
||||
*
|
||||
* literal, identifier, binaryOp, unaryOp
|
||||
*
|
||||
* OR, if the token is a control character its type is the name of the element
|
||||
* defined in the Grammar.
|
||||
*
|
||||
* Name appears only if the token is a control string found in
|
||||
* {@link grammar#elements}, and is set to the name of the element.
|
||||
*
|
||||
* Value is the value of the token in the correct type (boolean or numeric as
|
||||
* appropriate). Raw is the string representation of this value taken directly
|
||||
* from the expression string, including any trailing spaces.
|
||||
* @param {string} str The Jexl string to be tokenized
|
||||
* @returns {Array<{type, value, raw}>} an array of token objects.
|
||||
* @throws {Error} if the provided string contains an invalid token.
|
||||
*/
|
||||
Lexer.prototype.tokenize = function(str) {
|
||||
var elements = this.getElements(str);
|
||||
return this.getTokens(elements);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new token object from an element of a Jexl string. See
|
||||
* {@link Lexer#tokenize} for a description of the token object.
|
||||
* @param {string} element The element from which a token should be made
|
||||
* @returns {{value: number|boolean|string, [name]: string, type: string,
|
||||
* raw: string}} a token object describing the provided element.
|
||||
* @throws {Error} if the provided string is not a valid expression element.
|
||||
* @private
|
||||
*/
|
||||
Lexer.prototype._createToken = function(element) {
|
||||
var token = {
|
||||
type: 'literal',
|
||||
value: element,
|
||||
raw: element
|
||||
};
|
||||
if (element[0] == '"' || element[0] == "'")
|
||||
token.value = this._unquote(element);
|
||||
else if (element.match(numericRegex))
|
||||
token.value = parseFloat(element);
|
||||
else if (element === 'true' || element === 'false')
|
||||
token.value = element === 'true';
|
||||
else if (this._grammar[element])
|
||||
token.type = this._grammar[element].type;
|
||||
else if (element.match(identRegex))
|
||||
token.type = 'identifier';
|
||||
else
|
||||
throw new Error("Invalid expression token: " + element);
|
||||
return token;
|
||||
};
|
||||
|
||||
/**
|
||||
* Escapes a string so that it can be treated as a string literal within a
|
||||
* regular expression.
|
||||
* @param {string} str The string to be escaped
|
||||
* @returns {string} the RegExp-escaped string.
|
||||
* @see https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions
|
||||
* @private
|
||||
*/
|
||||
Lexer.prototype._escapeRegExp = function(str) {
|
||||
str = str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
if (str.match(identRegex))
|
||||
str = '\\b' + str + '\\b';
|
||||
return str;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a RegEx object appropriate for splitting a Jexl string into its core
|
||||
* elements.
|
||||
* @returns {RegExp} An element-splitting RegExp object
|
||||
* @private
|
||||
*/
|
||||
Lexer.prototype._getSplitRegex = function() {
|
||||
if (!this._splitRegex) {
|
||||
var elemArray = Object.keys(this._grammar);
|
||||
// Sort by most characters to least, then regex escape each
|
||||
elemArray = elemArray.sort(function(a ,b) {
|
||||
return b.length - a.length;
|
||||
}).map(function(elem) {
|
||||
return this._escapeRegExp(elem);
|
||||
}, this);
|
||||
this._splitRegex = new RegExp('(' + [
|
||||
preOpRegexElems.join('|'),
|
||||
elemArray.join('|'),
|
||||
postOpRegexElems.join('|')
|
||||
].join('|') + ')');
|
||||
}
|
||||
return this._splitRegex;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the addition of a '-' token should be interpreted as a
|
||||
* negative symbol for an upcoming number, given an array of tokens already
|
||||
* processed.
|
||||
* @param {Array<Object>} tokens An array of tokens already processed
|
||||
* @returns {boolean} true if adding a '-' should be considered a negative
|
||||
* symbol; false otherwise
|
||||
* @private
|
||||
*/
|
||||
Lexer.prototype._isNegative = function(tokens) {
|
||||
if (!tokens.length)
|
||||
return true;
|
||||
return minusNegatesAfter.some(function(type) {
|
||||
return type === tokens[tokens.length - 1].type;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* A utility function to determine if a string consists of only space
|
||||
* characters.
|
||||
* @param {string} str A string to be tested
|
||||
* @returns {boolean} true if the string is empty or consists of only spaces;
|
||||
* false otherwise.
|
||||
* @private
|
||||
*/
|
||||
Lexer.prototype._isWhitespace = function(str) {
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
if (str[i] != ' ')
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes the beginning and trailing quotes from a string, unescapes any
|
||||
* escaped quotes on its interior, and unescapes any escaped escape characters.
|
||||
* Note that this function is not defensive; it assumes that the provided
|
||||
* string is not empty, and that its first and last characters are actually
|
||||
* quotes.
|
||||
* @param {string} str A string whose first and last characters are quotes
|
||||
* @returns {string} a string with the surrounding quotes stripped and escapes
|
||||
* properly processed.
|
||||
* @private
|
||||
*/
|
||||
Lexer.prototype._unquote = function(str) {
|
||||
var quote = str[0],
|
||||
escQuoteRegex = new RegExp('\\\\' + quote, 'g');
|
||||
return str.substr(1, str.length - 2)
|
||||
.replace(escQuoteRegex, quote)
|
||||
.replace(escEscRegex, '\\');
|
||||
};
|
||||
|
||||
module.exports = Lexer;
|
153
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
сгенерированный
поставляемый
153
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/Evaluator.js
сгенерированный
поставляемый
|
@ -1,153 +0,0 @@
|
|||
/*
|
||||
* Jexl
|
||||
* Copyright (c) 2015 TechnologyAdvice
|
||||
*/
|
||||
|
||||
var handlers = require('./handlers');
|
||||
|
||||
/**
|
||||
* The Evaluator takes a Jexl expression tree as generated by the
|
||||
* {@link Parser} and calculates its value within a given context. The
|
||||
* collection of transforms, context, and a relative context to be used as the
|
||||
* root for relative identifiers, are all specific to an Evaluator instance.
|
||||
* When any of these things change, a new instance is required. However, a
|
||||
* single instance can be used to simultaneously evaluate many different
|
||||
* expressions, and does not have to be reinstantiated for each.
|
||||
* @param {{}} grammar A grammar map against which to evaluate the expression
|
||||
* tree
|
||||
* @param {{}} [transforms] A map of transform names to transform functions. A
|
||||
* transform function takes two arguments:
|
||||
* - {*} val: A value to be transformed
|
||||
* - {{}} args: A map of argument keys to their evaluated values, as
|
||||
* specified in the expression string
|
||||
* The transform function should return either the transformed value, or
|
||||
* a Promises/A+ Promise object that resolves with the value and rejects
|
||||
* or throws only when an unrecoverable error occurs. Transforms should
|
||||
* generally return undefined when they don't make sense to be used on the
|
||||
* given value type, rather than throw/reject. An error is only
|
||||
* appropriate when the transform would normally return a value, but
|
||||
* cannot due to some other failure.
|
||||
* @param {{}} [context] A map of variable keys to their values. This will be
|
||||
* accessed to resolve the value of each non-relative identifier. Any
|
||||
* Promise values will be passed to the expression as their resolved
|
||||
* value.
|
||||
* @param {{}|Array<{}|Array>} [relativeContext] A map or array to be accessed
|
||||
* to resolve the value of a relative identifier.
|
||||
* @constructor
|
||||
*/
|
||||
var Evaluator = function(grammar, transforms, context, relativeContext) {
|
||||
this._grammar = grammar;
|
||||
this._transforms = transforms || {};
|
||||
this._context = context || {};
|
||||
this._relContext = relativeContext || this._context;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates an expression tree within the configured context.
|
||||
* @param {{}} ast An expression tree object
|
||||
* @returns {Promise<*>} resolves with the resulting value of the expression.
|
||||
*/
|
||||
Evaluator.prototype.eval = function(ast) {
|
||||
var self = this;
|
||||
return Promise.resolve().then(function() {
|
||||
return handlers[ast.type].call(self, ast);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Simultaneously evaluates each expression within an array, and delivers the
|
||||
* response as an array with the resulting values at the same indexes as their
|
||||
* originating expressions.
|
||||
* @param {Array<string>} arr An array of expression strings to be evaluated
|
||||
* @returns {Promise<Array<{}>>} resolves with the result array
|
||||
*/
|
||||
Evaluator.prototype.evalArray = function(arr) {
|
||||
return Promise.all(arr.map(function(elem) {
|
||||
return this.eval(elem);
|
||||
}, this));
|
||||
};
|
||||
|
||||
/**
|
||||
* Simultaneously evaluates each expression within a map, and delivers the
|
||||
* response as a map with the same keys, but with the evaluated result for each
|
||||
* as their value.
|
||||
* @param {{}} map A map of expression names to expression trees to be
|
||||
* evaluated
|
||||
* @returns {Promise<{}>} resolves with the result map.
|
||||
*/
|
||||
Evaluator.prototype.evalMap = function(map) {
|
||||
var keys = Object.keys(map),
|
||||
result = {};
|
||||
var asts = keys.map(function(key) {
|
||||
return this.eval(map[key]);
|
||||
}, this);
|
||||
return Promise.all(asts).then(function(vals) {
|
||||
vals.forEach(function(val, idx) {
|
||||
result[keys[idx]] = val;
|
||||
});
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies a filter expression with relative identifier elements to a subject.
|
||||
* The intent is for the subject to be an array of subjects that will be
|
||||
* individually used as the relative context against the provided expression
|
||||
* tree. Only the elements whose expressions result in a truthy value will be
|
||||
* included in the resulting array.
|
||||
*
|
||||
* If the subject is not an array of values, it will be converted to a single-
|
||||
* element array before running the filter.
|
||||
* @param {*} subject The value to be filtered; usually an array. If this value is
|
||||
* not an array, it will be converted to an array with this value as the
|
||||
* only element.
|
||||
* @param {{}} expr The expression tree to run against each subject. If the
|
||||
* tree evaluates to a truthy result, then the value will be included in
|
||||
* the returned array; otherwise, it will be eliminated.
|
||||
* @returns {Promise<Array>} resolves with an array of values that passed the
|
||||
* expression filter.
|
||||
* @private
|
||||
*/
|
||||
Evaluator.prototype._filterRelative = function(subject, expr) {
|
||||
var promises = [];
|
||||
if (!Array.isArray(subject))
|
||||
subject = [subject];
|
||||
subject.forEach(function(elem) {
|
||||
var evalInst = new Evaluator(this._grammar, this._transforms,
|
||||
this._context, elem);
|
||||
promises.push(evalInst.eval(expr));
|
||||
}, this);
|
||||
return Promise.all(promises).then(function(values) {
|
||||
var results = [];
|
||||
values.forEach(function(value, idx) {
|
||||
if (value)
|
||||
results.push(subject[idx]);
|
||||
});
|
||||
return results;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies a static filter expression to a subject value. If the filter
|
||||
* expression evaluates to boolean true, the subject is returned; if false,
|
||||
* undefined.
|
||||
*
|
||||
* For any other resulting value of the expression, this function will attempt
|
||||
* to respond with the property at that name or index of the subject.
|
||||
* @param {*} subject The value to be filtered. Usually an Array (for which
|
||||
* the expression would generally resolve to a numeric index) or an
|
||||
* Object (for which the expression would generally resolve to a string
|
||||
* indicating a property name)
|
||||
* @param {{}} expr The expression tree to run against the subject
|
||||
* @returns {Promise<*>} resolves with the value of the drill-down.
|
||||
* @private
|
||||
*/
|
||||
Evaluator.prototype._filterStatic = function(subject, expr) {
|
||||
return this.eval(expr).then(function(res) {
|
||||
if (typeof res === 'boolean')
|
||||
return res ? subject : undefined;
|
||||
return subject[res];
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = Evaluator;
|
159
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
сгенерированный
поставляемый
159
browser/extensions/shield-recipe-client/node_modules/jexl/lib/evaluator/handlers.js
сгенерированный
поставляемый
|
@ -1,159 +0,0 @@
|
|||
/*
|
||||
* Jexl
|
||||
* Copyright (c) 2015 TechnologyAdvice
|
||||
*/
|
||||
|
||||
/**
|
||||
* Evaluates an ArrayLiteral by returning its value, with each element
|
||||
* independently run through the evaluator.
|
||||
* @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
|
||||
* ObjectLiteral as the top node
|
||||
* @returns {Promise.<[]>} resolves to a map contained evaluated values.
|
||||
* @private
|
||||
*/
|
||||
exports.ArrayLiteral = function(ast) {
|
||||
return this.evalArray(ast.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a BinaryExpression node by running the Grammar's evaluator for
|
||||
* the given operator.
|
||||
* @param {{type: 'BinaryExpression', operator: <string>, left: {},
|
||||
* right: {}}} ast An expression tree with a BinaryExpression as the top
|
||||
* node
|
||||
* @returns {Promise<*>} resolves with the value of the BinaryExpression.
|
||||
* @private
|
||||
*/
|
||||
exports.BinaryExpression = function(ast) {
|
||||
var self = this;
|
||||
return Promise.all([
|
||||
this.eval(ast.left),
|
||||
this.eval(ast.right)
|
||||
]).then(function(arr) {
|
||||
return self._grammar[ast.operator].eval(arr[0], arr[1]);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a ConditionalExpression node by first evaluating its test branch,
|
||||
* and resolving with the consequent branch if the test is truthy, or the
|
||||
* alternate branch if it is not. If there is no consequent branch, the test
|
||||
* result will be used instead.
|
||||
* @param {{type: 'ConditionalExpression', test: {}, consequent: {},
|
||||
* alternate: {}}} ast An expression tree with a ConditionalExpression as
|
||||
* the top node
|
||||
* @private
|
||||
*/
|
||||
exports.ConditionalExpression = function(ast) {
|
||||
var self = this;
|
||||
return this.eval(ast.test).then(function(res) {
|
||||
if (res) {
|
||||
if (ast.consequent)
|
||||
return self.eval(ast.consequent);
|
||||
return res;
|
||||
}
|
||||
return self.eval(ast.alternate);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a FilterExpression by applying it to the subject value.
|
||||
* @param {{type: 'FilterExpression', relative: <boolean>, expr: {},
|
||||
* subject: {}}} ast An expression tree with a FilterExpression as the top
|
||||
* node
|
||||
* @returns {Promise<*>} resolves with the value of the FilterExpression.
|
||||
* @private
|
||||
*/
|
||||
exports.FilterExpression = function(ast) {
|
||||
var self = this;
|
||||
return this.eval(ast.subject).then(function(subject) {
|
||||
if (ast.relative)
|
||||
return self._filterRelative(subject, ast.expr);
|
||||
return self._filterStatic(subject, ast.expr);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates an Identifier by either stemming from the evaluated 'from'
|
||||
* expression tree or accessing the context provided when this Evaluator was
|
||||
* constructed.
|
||||
* @param {{type: 'Identifier', value: <string>, [from]: {}}} ast An expression
|
||||
* tree with an Identifier as the top node
|
||||
* @returns {Promise<*>|*} either the identifier's value, or a Promise that
|
||||
* will resolve with the identifier's value.
|
||||
* @private
|
||||
*/
|
||||
exports.Identifier = function(ast) {
|
||||
if (ast.from) {
|
||||
return this.eval(ast.from).then(function(context) {
|
||||
if (context === undefined)
|
||||
return undefined;
|
||||
if (Array.isArray(context))
|
||||
context = context[0];
|
||||
return context[ast.value];
|
||||
});
|
||||
}
|
||||
else {
|
||||
return ast.relative ? this._relContext[ast.value] :
|
||||
this._context[ast.value];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a Literal by returning its value property.
|
||||
* @param {{type: 'Literal', value: <string|number|boolean>}} ast An expression
|
||||
* tree with a Literal as its only node
|
||||
* @returns {string|number|boolean} The value of the Literal node
|
||||
* @private
|
||||
*/
|
||||
exports.Literal = function(ast) {
|
||||
return ast.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates an ObjectLiteral by returning its value, with each key
|
||||
* independently run through the evaluator.
|
||||
* @param {{type: 'ObjectLiteral', value: <{}>}} ast An expression tree with an
|
||||
* ObjectLiteral as the top node
|
||||
* @returns {Promise<{}>} resolves to a map contained evaluated values.
|
||||
* @private
|
||||
*/
|
||||
exports.ObjectLiteral = function(ast) {
|
||||
return this.evalMap(ast.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a Transform node by applying a function from the transforms map
|
||||
* to the subject value.
|
||||
* @param {{type: 'Transform', name: <string>, subject: {}}} ast An
|
||||
* expression tree with a Transform as the top node
|
||||
* @returns {Promise<*>|*} the value of the transformation, or a Promise that
|
||||
* will resolve with the transformed value.
|
||||
* @private
|
||||
*/
|
||||
exports.Transform = function(ast) {
|
||||
var transform = this._transforms[ast.name];
|
||||
if (!transform)
|
||||
throw new Error("Transform '" + ast.name + "' is not defined.");
|
||||
return Promise.all([
|
||||
this.eval(ast.subject),
|
||||
this.evalArray(ast.args || [])
|
||||
]).then(function(arr) {
|
||||
return transform.apply(null, [arr[0]].concat(arr[1]));
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluates a Unary expression by passing the right side through the
|
||||
* operator's eval function.
|
||||
* @param {{type: 'UnaryExpression', operator: <string>, right: {}}} ast An
|
||||
* expression tree with a UnaryExpression as the top node
|
||||
* @returns {Promise<*>} resolves with the value of the UnaryExpression.
|
||||
* @constructor
|
||||
*/
|
||||
exports.UnaryExpression = function(ast) {
|
||||
var self = this;
|
||||
return this.eval(ast.right).then(function(right) {
|
||||
return self._grammar[ast.operator].eval(right);
|
||||
});
|
||||
};
|
66
browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
сгенерированный
поставляемый
66
browser/extensions/shield-recipe-client/node_modules/jexl/lib/grammar.js
сгенерированный
поставляемый
|
@ -1,66 +0,0 @@
|
|||
/*
|
||||
* Jexl
|
||||
* Copyright (c) 2015 TechnologyAdvice
|
||||
*/
|
||||
|
||||
/**
|
||||
* A map of all expression elements to their properties. Note that changes
|
||||
* here may require changes in the Lexer or Parser.
|
||||
* @type {{}}
|
||||
*/
|
||||
exports.elements = {
|
||||
'.': {type: 'dot'},
|
||||
'[': {type: 'openBracket'},
|
||||
']': {type: 'closeBracket'},
|
||||
'|': {type: 'pipe'},
|
||||
'{': {type: 'openCurl'},
|
||||
'}': {type: 'closeCurl'},
|
||||
':': {type: 'colon'},
|
||||
',': {type: 'comma'},
|
||||
'(': {type: 'openParen'},
|
||||
')': {type: 'closeParen'},
|
||||
'?': {type: 'question'},
|
||||
'+': {type: 'binaryOp', precedence: 30,
|
||||
eval: function(left, right) { return left + right; }},
|
||||
'-': {type: 'binaryOp', precedence: 30,
|
||||
eval: function(left, right) { return left - right; }},
|
||||
'*': {type: 'binaryOp', precedence: 40,
|
||||
eval: function(left, right) { return left * right; }},
|
||||
'/': {type: 'binaryOp', precedence: 40,
|
||||
eval: function(left, right) { return left / right; }},
|
||||
'//': {type: 'binaryOp', precedence: 40,
|
||||
eval: function(left, right) { return Math.floor(left / right); }},
|
||||
'%': {type: 'binaryOp', precedence: 50,
|
||||
eval: function(left, right) { return left % right; }},
|
||||
'^': {type: 'binaryOp', precedence: 50,
|
||||
eval: function(left, right) { return Math.pow(left, right); }},
|
||||
'==': {type: 'binaryOp', precedence: 20,
|
||||
eval: function(left, right) { return left == right; }},
|
||||
'!=': {type: 'binaryOp', precedence: 20,
|
||||
eval: function(left, right) { return left != right; }},
|
||||
'>': {type: 'binaryOp', precedence: 20,
|
||||
eval: function(left, right) { return left > right; }},
|
||||
'>=': {type: 'binaryOp', precedence: 20,
|
||||
eval: function(left, right) { return left >= right; }},
|
||||
'<': {type: 'binaryOp', precedence: 20,
|
||||
eval: function(left, right) { return left < right; }},
|
||||
'<=': {type: 'binaryOp', precedence: 20,
|
||||
eval: function(left, right) { return left <= right; }},
|
||||
'&&': {type: 'binaryOp', precedence: 10,
|
||||
eval: function(left, right) { return left && right; }},
|
||||
'||': {type: 'binaryOp', precedence: 10,
|
||||
eval: function(left, right) { return left || right; }},
|
||||
'in': {type: 'binaryOp', precedence: 20,
|
||||
eval: function(left, right) {
|
||||
if (typeof right === 'string')
|
||||
return right.indexOf(left) !== -1;
|
||||
if (Array.isArray(right)) {
|
||||
return right.some(function(elem) {
|
||||
return elem == left;
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}},
|
||||
'!': {type: 'unaryOp', precedence: Infinity,
|
||||
eval: function(right) { return !right; }}
|
||||
};
|
188
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
сгенерированный
поставляемый
188
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/Parser.js
сгенерированный
поставляемый
|
@ -1,188 +0,0 @@
|
|||
/*
|
||||
* Jexl
|
||||
* Copyright (c) 2015 TechnologyAdvice
|
||||
*/
|
||||
|
||||
var handlers = require('./handlers'),
|
||||
states = require('./states').states;
|
||||
|
||||
/**
|
||||
* The Parser is a state machine that converts tokens from the {@link Lexer}
|
||||
* into an Abstract Syntax Tree (AST), capable of being evaluated in any
|
||||
* context by the {@link Evaluator}. The Parser expects that all tokens
|
||||
* provided to it are legal and typed properly according to the grammar, but
|
||||
* accepts that the tokens may still be in an invalid order or in some other
|
||||
* unparsable configuration that requires it to throw an Error.
|
||||
* @param {{}} grammar The grammar map to use to parse Jexl strings
|
||||
* @param {string} [prefix] A string prefix to prepend to the expression string
|
||||
* for error messaging purposes. This is useful for when a new Parser is
|
||||
* instantiated to parse an subexpression, as the parent Parser's
|
||||
* expression string thus far can be passed for a more user-friendly
|
||||
* error message.
|
||||
* @param {{}} [stopMap] A mapping of token types to any truthy value. When the
|
||||
* token type is encountered, the parser will return the mapped value
|
||||
* instead of boolean false.
|
||||
* @constructor
|
||||
*/
|
||||
function Parser(grammar, prefix, stopMap) {
|
||||
this._grammar = grammar;
|
||||
this._state = 'expectOperand';
|
||||
this._tree = null;
|
||||
this._exprStr = prefix || '';
|
||||
this._relative = false;
|
||||
this._stopMap = stopMap || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a new token into the AST and manages the transitions of the state
|
||||
* machine.
|
||||
* @param {{type: <string>}} token A token object, as provided by the
|
||||
* {@link Lexer#tokenize} function.
|
||||
* @throws {Error} if a token is added when the Parser has been marked as
|
||||
* complete by {@link #complete}, or if an unexpected token type is added.
|
||||
* @returns {boolean|*} the stopState value if this parser encountered a token
|
||||
* in the stopState mapb; false if tokens can continue.
|
||||
*/
|
||||
Parser.prototype.addToken = function(token) {
|
||||
if (this._state == 'complete')
|
||||
throw new Error('Cannot add a new token to a completed Parser');
|
||||
var state = states[this._state],
|
||||
startExpr = this._exprStr;
|
||||
this._exprStr += token.raw;
|
||||
if (state.subHandler) {
|
||||
if (!this._subParser)
|
||||
this._startSubExpression(startExpr);
|
||||
var stopState = this._subParser.addToken(token);
|
||||
if (stopState) {
|
||||
this._endSubExpression();
|
||||
if (this._parentStop)
|
||||
return stopState;
|
||||
this._state = stopState;
|
||||
}
|
||||
}
|
||||
else if (state.tokenTypes[token.type]) {
|
||||
var typeOpts = state.tokenTypes[token.type],
|
||||
handleFunc = handlers[token.type];
|
||||
if (typeOpts.handler)
|
||||
handleFunc = typeOpts.handler;
|
||||
if (handleFunc)
|
||||
handleFunc.call(this, token);
|
||||
if (typeOpts.toState)
|
||||
this._state = typeOpts.toState;
|
||||
}
|
||||
else if (this._stopMap[token.type])
|
||||
return this._stopMap[token.type];
|
||||
else {
|
||||
throw new Error('Token ' + token.raw + ' (' + token.type +
|
||||
') unexpected in expression: ' + this._exprStr);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes an array of tokens iteratively through the {@link #addToken}
|
||||
* function.
|
||||
* @param {Array<{type: <string>}>} tokens An array of tokens, as provided by
|
||||
* the {@link Lexer#tokenize} function.
|
||||
*/
|
||||
Parser.prototype.addTokens = function(tokens) {
|
||||
tokens.forEach(this.addToken, this);
|
||||
};
|
||||
|
||||
/**
|
||||
* Marks this Parser instance as completed and retrieves the full AST.
|
||||
* @returns {{}|null} a full expression tree, ready for evaluation by the
|
||||
* {@link Evaluator#eval} function, or null if no tokens were passed to
|
||||
* the parser before complete was called
|
||||
* @throws {Error} if the parser is not in a state where it's legal to end
|
||||
* the expression, indicating that the expression is incomplete
|
||||
*/
|
||||
Parser.prototype.complete = function() {
|
||||
if (this._cursor && !states[this._state].completable)
|
||||
throw new Error('Unexpected end of expression: ' + this._exprStr);
|
||||
if (this._subParser)
|
||||
this._endSubExpression();
|
||||
this._state = 'complete';
|
||||
return this._cursor ? this._tree : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Indicates whether the expression tree contains a relative path identifier.
|
||||
* @returns {boolean} true if a relative identifier exists; false otherwise.
|
||||
*/
|
||||
Parser.prototype.isRelative = function() {
|
||||
return this._relative;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ends a subexpression by completing the subParser and passing its result
|
||||
* to the subHandler configured in the current state.
|
||||
* @private
|
||||
*/
|
||||
Parser.prototype._endSubExpression = function() {
|
||||
states[this._state].subHandler.call(this, this._subParser.complete());
|
||||
this._subParser = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Places a new tree node at the current position of the cursor (to the 'right'
|
||||
* property) and then advances the cursor to the new node. This function also
|
||||
* handles setting the parent of the new node.
|
||||
* @param {{type: <string>}} node A node to be added to the AST
|
||||
* @private
|
||||
*/
|
||||
Parser.prototype._placeAtCursor = function(node) {
|
||||
if (!this._cursor)
|
||||
this._tree = node;
|
||||
else {
|
||||
this._cursor.right = node;
|
||||
this._setParent(node, this._cursor);
|
||||
}
|
||||
this._cursor = node;
|
||||
};
|
||||
|
||||
/**
|
||||
* Places a tree node before the current position of the cursor, replacing
|
||||
* the node that the cursor currently points to. This should only be called in
|
||||
* cases where the cursor is known to exist, and the provided node already
|
||||
* contains a pointer to what's at the cursor currently.
|
||||
* @param {{type: <string>}} node A node to be added to the AST
|
||||
* @private
|
||||
*/
|
||||
Parser.prototype._placeBeforeCursor = function(node) {
|
||||
this._cursor = this._cursor._parent;
|
||||
this._placeAtCursor(node);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the parent of a node by creating a non-enumerable _parent property
|
||||
* that points to the supplied parent argument.
|
||||
* @param {{type: <string>}} node A node of the AST on which to set a new
|
||||
* parent
|
||||
* @param {{type: <string>}} parent An existing node of the AST to serve as the
|
||||
* parent of the new node
|
||||
* @private
|
||||
*/
|
||||
Parser.prototype._setParent = function(node, parent) {
|
||||
Object.defineProperty(node, '_parent', {
|
||||
value: parent,
|
||||
writable: true
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Prepares the Parser to accept a subexpression by (re)instantiating the
|
||||
* subParser.
|
||||
* @param {string} [exprStr] The expression string to prefix to the new Parser
|
||||
* @private
|
||||
*/
|
||||
Parser.prototype._startSubExpression = function(exprStr) {
|
||||
var endStates = states[this._state].endStates;
|
||||
if (!endStates) {
|
||||
this._parentStop = true;
|
||||
endStates = this._stopMap;
|
||||
}
|
||||
this._subParser = new Parser(this._grammar, exprStr, endStates);
|
||||
};
|
||||
|
||||
module.exports = Parser;
|
210
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
сгенерированный
поставляемый
210
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/handlers.js
сгенерированный
поставляемый
|
@ -1,210 +0,0 @@
|
|||
/*
|
||||
* Jexl
|
||||
* Copyright (c) 2015 TechnologyAdvice
|
||||
*/
|
||||
|
||||
/**
|
||||
* Handles a subexpression that's used to define a transform argument's value.
|
||||
* @param {{type: <string>}} ast The subexpression tree
|
||||
*/
|
||||
exports.argVal = function(ast) {
|
||||
this._cursor.args.push(ast);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles new array literals by adding them as a new node in the AST,
|
||||
* initialized with an empty array.
|
||||
*/
|
||||
exports.arrayStart = function() {
|
||||
this._placeAtCursor({
|
||||
type: 'ArrayLiteral',
|
||||
value: []
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a subexpression representing an element of an array literal.
|
||||
* @param {{type: <string>}} ast The subexpression tree
|
||||
*/
|
||||
exports.arrayVal = function(ast) {
|
||||
if (ast)
|
||||
this._cursor.value.push(ast);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles tokens of type 'binaryOp', indicating an operation that has two
|
||||
* inputs: a left side and a right side.
|
||||
* @param {{type: <string>}} token A token object
|
||||
*/
|
||||
exports.binaryOp = function(token) {
|
||||
var precedence = this._grammar[token.value].precedence || 0,
|
||||
parent = this._cursor._parent;
|
||||
while (parent && parent.operator &&
|
||||
this._grammar[parent.operator].precedence >= precedence) {
|
||||
this._cursor = parent;
|
||||
parent = parent._parent;
|
||||
}
|
||||
var node = {
|
||||
type: 'BinaryExpression',
|
||||
operator: token.value,
|
||||
left: this._cursor
|
||||
};
|
||||
this._setParent(this._cursor, node);
|
||||
this._cursor = parent;
|
||||
this._placeAtCursor(node);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles successive nodes in an identifier chain. More specifically, it
|
||||
* sets values that determine how the following identifier gets placed in the
|
||||
* AST.
|
||||
*/
|
||||
exports.dot = function() {
|
||||
this._nextIdentEncapsulate = this._cursor &&
|
||||
(this._cursor.type != 'BinaryExpression' ||
|
||||
(this._cursor.type == 'BinaryExpression' && this._cursor.right)) &&
|
||||
this._cursor.type != 'UnaryExpression';
|
||||
this._nextIdentRelative = !this._cursor ||
|
||||
(this._cursor && !this._nextIdentEncapsulate);
|
||||
if (this._nextIdentRelative)
|
||||
this._relative = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a subexpression used for filtering an array returned by an
|
||||
* identifier chain.
|
||||
* @param {{type: <string>}} ast The subexpression tree
|
||||
*/
|
||||
exports.filter = function(ast) {
|
||||
this._placeBeforeCursor({
|
||||
type: 'FilterExpression',
|
||||
expr: ast,
|
||||
relative: this._subParser.isRelative(),
|
||||
subject: this._cursor
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles identifier tokens by adding them as a new node in the AST.
|
||||
* @param {{type: <string>}} token A token object
|
||||
*/
|
||||
exports.identifier = function(token) {
|
||||
var node = {
|
||||
type: 'Identifier',
|
||||
value: token.value
|
||||
};
|
||||
if (this._nextIdentEncapsulate) {
|
||||
node.from = this._cursor;
|
||||
this._placeBeforeCursor(node);
|
||||
this._nextIdentEncapsulate = false;
|
||||
}
|
||||
else {
|
||||
if (this._nextIdentRelative)
|
||||
node.relative = true;
|
||||
this._placeAtCursor(node);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles literal values, such as strings, booleans, and numerics, by adding
|
||||
* them as a new node in the AST.
|
||||
* @param {{type: <string>}} token A token object
|
||||
*/
|
||||
exports.literal = function(token) {
|
||||
this._placeAtCursor({
|
||||
type: 'Literal',
|
||||
value: token.value
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Queues a new object literal key to be written once a value is collected.
|
||||
* @param {{type: <string>}} token A token object
|
||||
*/
|
||||
exports.objKey = function(token) {
|
||||
this._curObjKey = token.value;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles new object literals by adding them as a new node in the AST,
|
||||
* initialized with an empty object.
|
||||
*/
|
||||
exports.objStart = function() {
|
||||
this._placeAtCursor({
|
||||
type: 'ObjectLiteral',
|
||||
value: {}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles an object value by adding its AST to the queued key on the object
|
||||
* literal node currently at the cursor.
|
||||
* @param {{type: <string>}} ast The subexpression tree
|
||||
*/
|
||||
exports.objVal = function(ast) {
|
||||
this._cursor.value[this._curObjKey] = ast;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles traditional subexpressions, delineated with the groupStart and
|
||||
* groupEnd elements.
|
||||
* @param {{type: <string>}} ast The subexpression tree
|
||||
*/
|
||||
exports.subExpression = function(ast) {
|
||||
this._placeAtCursor(ast);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a completed alternate subexpression of a ternary operator.
|
||||
* @param {{type: <string>}} ast The subexpression tree
|
||||
*/
|
||||
exports.ternaryEnd = function(ast) {
|
||||
this._cursor.alternate = ast;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles a completed consequent subexpression of a ternary operator.
|
||||
* @param {{type: <string>}} ast The subexpression tree
|
||||
*/
|
||||
exports.ternaryMid = function(ast) {
|
||||
this._cursor.consequent = ast;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the start of a new ternary expression by encapsulating the entire
|
||||
* AST in a ConditionalExpression node, and using the existing tree as the
|
||||
* test element.
|
||||
*/
|
||||
exports.ternaryStart = function() {
|
||||
this._tree = {
|
||||
type: 'ConditionalExpression',
|
||||
test: this._tree
|
||||
};
|
||||
this._cursor = this._tree;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles identifier tokens when used to indicate the name of a transform to
|
||||
* be applied.
|
||||
* @param {{type: <string>}} token A token object
|
||||
*/
|
||||
exports.transform = function(token) {
|
||||
this._placeBeforeCursor({
|
||||
type: 'Transform',
|
||||
name: token.value,
|
||||
args: [],
|
||||
subject: this._cursor
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles token of type 'unaryOp', indicating that the operation has only
|
||||
* one input: a right side.
|
||||
* @param {{type: <string>}} token A token object
|
||||
*/
|
||||
exports.unaryOp = function(token) {
|
||||
this._placeAtCursor({
|
||||
type: 'UnaryExpression',
|
||||
operator: token.value
|
||||
});
|
||||
};
|
154
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
сгенерированный
поставляемый
154
browser/extensions/shield-recipe-client/node_modules/jexl/lib/parser/states.js
сгенерированный
поставляемый
|
@ -1,154 +0,0 @@
|
|||
/*
|
||||
* Jexl
|
||||
* Copyright (c) 2015 TechnologyAdvice
|
||||
*/
|
||||
|
||||
var h = require('./handlers');
|
||||
|
||||
/**
|
||||
* A mapping of all states in the finite state machine to a set of instructions
|
||||
* for handling or transitioning into other states. Each state can be handled
|
||||
* in one of two schemes: a tokenType map, or a subHandler.
|
||||
*
|
||||
* Standard expression elements are handled through the tokenType object. This
|
||||
* is an object map of all legal token types to encounter in this state (and
|
||||
* any unexpected token types will generate a thrown error) to an options
|
||||
* object that defines how they're handled. The available options are:
|
||||
*
|
||||
* {string} toState: The name of the state to which to transition
|
||||
* immediately after handling this token
|
||||
* {string} handler: The handler function to call when this token type is
|
||||
* encountered in this state. If omitted, the default handler
|
||||
* matching the token's "type" property will be called. If the handler
|
||||
* function does not exist, no call will be made and no error will be
|
||||
* generated. This is useful for tokens whose sole purpose is to
|
||||
* transition to other states.
|
||||
*
|
||||
* States that consume a subexpression should define a subHandler, the
|
||||
* function to be called with an expression tree argument when the
|
||||
* subexpression is complete. Completeness is determined through the
|
||||
* endStates object, which maps tokens on which an expression should end to the
|
||||
* state to which to transition once the subHandler function has been called.
|
||||
*
|
||||
* Additionally, any state in which it is legal to mark the AST as completed
|
||||
* should have a 'completable' property set to boolean true. Attempting to
|
||||
* call {@link Parser#complete} in any state without this property will result
|
||||
* in a thrown Error.
|
||||
*
|
||||
* @type {{}}
|
||||
*/
|
||||
exports.states = {
|
||||
expectOperand: {
|
||||
tokenTypes: {
|
||||
literal: {toState: 'expectBinOp'},
|
||||
identifier: {toState: 'identifier'},
|
||||
unaryOp: {},
|
||||
openParen: {toState: 'subExpression'},
|
||||
openCurl: {toState: 'expectObjKey', handler: h.objStart},
|
||||
dot: {toState: 'traverse'},
|
||||
openBracket: {toState: 'arrayVal', handler: h.arrayStart}
|
||||
}
|
||||
},
|
||||
expectBinOp: {
|
||||
tokenTypes: {
|
||||
binaryOp: {toState: 'expectOperand'},
|
||||
pipe: {toState: 'expectTransform'},
|
||||
dot: {toState: 'traverse'},
|
||||
question: {toState: 'ternaryMid', handler: h.ternaryStart}
|
||||
},
|
||||
completable: true
|
||||
},
|
||||
expectTransform: {
|
||||
tokenTypes: {
|
||||
identifier: {toState: 'postTransform', handler: h.transform}
|
||||
}
|
||||
},
|
||||
expectObjKey: {
|
||||
tokenTypes: {
|
||||
identifier: {toState: 'expectKeyValSep', handler: h.objKey},
|
||||
closeCurl: {toState: 'expectBinOp'}
|
||||
}
|
||||
},
|
||||
expectKeyValSep: {
|
||||
tokenTypes: {
|
||||
colon: {toState: 'objVal'}
|
||||
}
|
||||
},
|
||||
postTransform: {
|
||||
tokenTypes: {
|
||||
openParen: {toState: 'argVal'},
|
||||
binaryOp: {toState: 'expectOperand'},
|
||||
dot: {toState: 'traverse'},
|
||||
openBracket: {toState: 'filter'},
|
||||
pipe: {toState: 'expectTransform'}
|
||||
},
|
||||
completable: true
|
||||
},
|
||||
postTransformArgs: {
|
||||
tokenTypes: {
|
||||
binaryOp: {toState: 'expectOperand'},
|
||||
dot: {toState: 'traverse'},
|
||||
openBracket: {toState: 'filter'},
|
||||
pipe: {toState: 'expectTransform'}
|
||||
},
|
||||
completable: true
|
||||
},
|
||||
identifier: {
|
||||
tokenTypes: {
|
||||
binaryOp: {toState: 'expectOperand'},
|
||||
dot: {toState: 'traverse'},
|
||||
openBracket: {toState: 'filter'},
|
||||
pipe: {toState: 'expectTransform'},
|
||||
question: {toState: 'ternaryMid', handler: h.ternaryStart}
|
||||
},
|
||||
completable: true
|
||||
},
|
||||
traverse: {
|
||||
tokenTypes: {
|
||||
'identifier': {toState: 'identifier'}
|
||||
}
|
||||
},
|
||||
filter: {
|
||||
subHandler: h.filter,
|
||||
endStates: {
|
||||
closeBracket: 'identifier'
|
||||
}
|
||||
},
|
||||
subExpression: {
|
||||
subHandler: h.subExpression,
|
||||
endStates: {
|
||||
closeParen: 'expectBinOp'
|
||||
}
|
||||
},
|
||||
argVal: {
|
||||
subHandler: h.argVal,
|
||||
endStates: {
|
||||
comma: 'argVal',
|
||||
closeParen: 'postTransformArgs'
|
||||
}
|
||||
},
|
||||
objVal: {
|
||||
subHandler: h.objVal,
|
||||
endStates: {
|
||||
comma: 'expectObjKey',
|
||||
closeCurl: 'expectBinOp'
|
||||
}
|
||||
},
|
||||
arrayVal: {
|
||||
subHandler: h.arrayVal,
|
||||
endStates: {
|
||||
comma: 'arrayVal',
|
||||
closeBracket: 'expectBinOp'
|
||||
}
|
||||
},
|
||||
ternaryMid: {
|
||||
subHandler: h.ternaryMid,
|
||||
endStates: {
|
||||
colon: 'ternaryEnd'
|
||||
}
|
||||
},
|
||||
ternaryEnd: {
|
||||
subHandler: h.ternaryEnd,
|
||||
completable: true
|
||||
}
|
||||
};
|
|
@ -9,3 +9,16 @@ notification.heartbeat {
|
|||
border-bottom: 1px solid #C1C1C1 !important;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
/* In themes/osx/global/notification.css the close icon is inverted because notifications
|
||||
on OSX are usually dark. Heartbeat is light, so override that behaviour. */
|
||||
|
||||
notification.heartbeat[type="info"] .close-icon:not(:hover) {
|
||||
-moz-image-region: rect(0, 16px, 16px, 0) !important;
|
||||
}
|
||||
|
||||
@media (min-resolution: 2dppx) {
|
||||
notification.heartbeat[type="info"] .close-icon:not(:hover) {
|
||||
-moz-image-region: rect(0, 32px, 32px, 0) !important;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,7 +8,6 @@ notification.heartbeat {
|
|||
background-color: #F1F1F1 !important;
|
||||
border-bottom: 1px solid #C1C1C1 !important;
|
||||
height: 40px;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
@keyframes pulse-onshow {
|
||||
|
@ -50,6 +49,7 @@ notification.heartbeat {
|
|||
}
|
||||
|
||||
.messageText.heartbeat {
|
||||
color: #333 !important;
|
||||
margin-inline-end: 12px !important; /* The !important is required to override OSX default style. */
|
||||
margin-inline-start: 0;
|
||||
text-shadow: none;
|
||||
|
|
|
@ -8,10 +8,4 @@ module.exports = {
|
|||
plugins: [
|
||||
"mozilla"
|
||||
],
|
||||
|
||||
globals: {
|
||||
// Bug 1366720 - SimpleTest isn't being exported correctly, so list
|
||||
// it here for now.
|
||||
"SimpleTest": false
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,14 +1,22 @@
|
|||
[DEFAULT]
|
||||
support-files =
|
||||
action_server.sjs
|
||||
fixtures/normandy.xpi
|
||||
head = head.js
|
||||
[browser_ActionSandboxManager.js]
|
||||
[browser_Addons.js]
|
||||
[browser_AddonStudies.js]
|
||||
[browser_NormandyDriver.js]
|
||||
[browser_FilterExpressions.js]
|
||||
[browser_EventEmitter.js]
|
||||
[browser_Storage.js]
|
||||
[browser_Heartbeat.js]
|
||||
[browser_RecipeRunner.js]
|
||||
support-files =
|
||||
action_server.sjs
|
||||
[browser_LogManager.js]
|
||||
[browser_ClientEnvironment.js]
|
||||
[browser_ShieldRecipeClient.js]
|
||||
[browser_PreferenceExperiments.js]
|
||||
[browser_about_studies.js]
|
||||
[browser_about_preferences.js]
|
||||
# Skip this test when FHR/Telemetry aren't available.
|
||||
skip-if = !healthreport || !telemetry
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm");
|
||||
Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
|
||||
|
||||
async function withManager(script, testFunction) {
|
||||
const manager = new ActionSandboxManager(script);
|
||||
|
@ -12,7 +12,7 @@ async function withManager(script, testFunction) {
|
|||
|
||||
add_task(async function testMissingCallbackName() {
|
||||
await withManager("1 + 1", async manager => {
|
||||
equal(
|
||||
is(
|
||||
await manager.runAsyncCallback("missingCallback"),
|
||||
undefined,
|
||||
"runAsyncCallback returns undefined when given a missing callback name",
|
||||
|
@ -29,7 +29,7 @@ add_task(async function testCallback() {
|
|||
|
||||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("testCallback");
|
||||
equal(result, 5, "runAsyncCallback executes the named callback inside the sandbox");
|
||||
is(result, 5, "runAsyncCallback executes the named callback inside the sandbox");
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -42,7 +42,7 @@ add_task(async function testArguments() {
|
|||
|
||||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("testCallback", 4, 6);
|
||||
equal(result, 10, "runAsyncCallback passes arguments to the callback");
|
||||
is(result, 10, "runAsyncCallback passes arguments to the callback");
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -56,7 +56,7 @@ add_task(async function testCloning() {
|
|||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("testCallback", {baz: "biff"});
|
||||
|
||||
deepEqual(
|
||||
Assert.deepEqual(
|
||||
result,
|
||||
{foo: "bar", baz: "biff"},
|
||||
(
|
||||
|
@ -79,28 +79,26 @@ add_task(async function testError() {
|
|||
await manager.runAsyncCallback("testCallback");
|
||||
ok(false, "runAsnycCallbackFromScript throws errors when raised by the sandbox");
|
||||
} catch (err) {
|
||||
equal(err.message, "WHY", "runAsnycCallbackFromScript clones error messages");
|
||||
is(err.message, "WHY", "runAsnycCallbackFromScript throws errors when raised by the sandbox");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
add_task(async function testDriver() {
|
||||
// The value returned by runAsyncCallback is cloned without the cloneFunctions
|
||||
// option, so we can't inspect the driver itself since its methods will not be
|
||||
// present. Instead, we inspect the properties on it available to the sandbox.
|
||||
const script = `
|
||||
registerAsyncCallback("testCallback", async function(normandy) {
|
||||
return normandy;
|
||||
return Object.keys(normandy);
|
||||
});
|
||||
`;
|
||||
|
||||
await withManager(script, async manager => {
|
||||
const sandboxDriver = await manager.runAsyncCallback("testCallback");
|
||||
const sandboxDriverKeys = await manager.runAsyncCallback("testCallback");
|
||||
const referenceDriver = new NormandyDriver(manager);
|
||||
equal(
|
||||
sandboxDriver.constructor.name,
|
||||
"NormandyDriver",
|
||||
"runAsyncCallback passes a driver as the first parameter",
|
||||
);
|
||||
for (const prop in referenceDriver) {
|
||||
ok(prop in sandboxDriver, "runAsyncCallback passes a driver as the first parameter");
|
||||
for (const prop of Object.keys(referenceDriver)) {
|
||||
ok(sandboxDriverKeys.includes(prop), `runAsyncCallback's driver has the "${prop}" property.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -159,8 +157,8 @@ add_task(async function testRegisterActionShim() {
|
|||
|
||||
await withManager(script, async manager => {
|
||||
const result = await manager.runAsyncCallback("action", recipe);
|
||||
equal(result.foo, "bar", "registerAction registers an async callback for actions");
|
||||
equal(
|
||||
is(result.foo, "bar", "registerAction registers an async callback for actions");
|
||||
is(
|
||||
result.isDriver,
|
||||
true,
|
||||
"registerAction passes the driver to the action class constructor",
|
|
@ -0,0 +1,325 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/IndexedDB.jsm", this);
|
||||
Cu.import("resource://testing-common/TestUtils.jsm", this);
|
||||
Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/Addons.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
|
||||
|
||||
// Initialize test utils
|
||||
AddonTestUtils.initMochitest(this);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies(),
|
||||
async function testGetMissing() {
|
||||
is(
|
||||
await AddonStudies.get("does-not-exist"),
|
||||
null,
|
||||
"get returns null when the requested study does not exist"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({name: "test-study"}),
|
||||
]),
|
||||
async function testGet([study]) {
|
||||
const storedStudy = await AddonStudies.get(study.recipeId);
|
||||
Assert.deepEqual(study, storedStudy, "get retrieved a study from storage.");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory(),
|
||||
studyFactory(),
|
||||
]),
|
||||
async function testGetAll(studies) {
|
||||
const storedStudies = await AddonStudies.getAll();
|
||||
Assert.deepEqual(
|
||||
new Set(storedStudies),
|
||||
new Set(studies),
|
||||
"getAll returns every stored study.",
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({name: "test-study"}),
|
||||
]),
|
||||
async function testHas([study]) {
|
||||
let hasStudy = await AddonStudies.has(study.recipeId);
|
||||
ok(hasStudy, "has returns true for a study that exists in storage.");
|
||||
|
||||
hasStudy = await AddonStudies.has("does-not-exist");
|
||||
ok(!hasStudy, "has returns false for a study that doesn't exist in storage.");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies(),
|
||||
async function testCloseDatabase() {
|
||||
await AddonStudies.close();
|
||||
const openSpy = sinon.spy(IndexedDB, "open");
|
||||
sinon.assert.notCalled(openSpy);
|
||||
|
||||
// Using studies at all should open the database, but only once.
|
||||
await AddonStudies.has("foo");
|
||||
await AddonStudies.get("foo");
|
||||
sinon.assert.calledOnce(openSpy);
|
||||
|
||||
// close can be called multiple times
|
||||
await AddonStudies.close();
|
||||
await AddonStudies.close();
|
||||
|
||||
// After being closed, new operations cause the database to be opened again
|
||||
await AddonStudies.has("test-study");
|
||||
sinon.assert.calledTwice(openSpy);
|
||||
|
||||
openSpy.restore();
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({name: "test-study1"}),
|
||||
studyFactory({name: "test-study2"}),
|
||||
]),
|
||||
async function testClear([study1, study2]) {
|
||||
const hasAll = (
|
||||
(await AddonStudies.has(study1.recipeId)) &&
|
||||
(await AddonStudies.has(study2.recipeId))
|
||||
);
|
||||
ok(hasAll, "Before calling clear, both studies are in storage.");
|
||||
|
||||
await AddonStudies.clear();
|
||||
const hasAny = (
|
||||
(await AddonStudies.has(study1.recipeId)) ||
|
||||
(await AddonStudies.has(study2.recipeId))
|
||||
);
|
||||
ok(!hasAny, "After calling clear, all studies are removed from storage.");
|
||||
}
|
||||
);
|
||||
|
||||
let _startArgsFactoryId = 0;
|
||||
function startArgsFactory(args) {
|
||||
return Object.assign({
|
||||
recipeId: _startArgsFactoryId++,
|
||||
name: "Test",
|
||||
description: "Test",
|
||||
addonUrl: "http://test/addon.xpi",
|
||||
}, args);
|
||||
}
|
||||
|
||||
add_task(async function testStartRequiredArguments() {
|
||||
const requiredArguments = startArgsFactory();
|
||||
for (const key in requiredArguments) {
|
||||
const args = Object.assign({}, requiredArguments);
|
||||
delete args[key];
|
||||
Assert.rejects(
|
||||
AddonStudies.start(args),
|
||||
/Required arguments/,
|
||||
`start rejects when missing required argument ${key}.`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory(),
|
||||
]),
|
||||
async function testStartExisting([study]) {
|
||||
Assert.rejects(
|
||||
AddonStudies.start(startArgsFactory({recipeId: study.recipeId})),
|
||||
/already exists/,
|
||||
"start rejects when a study exists with the given recipeId already."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withStub(Addons, "applyInstall"),
|
||||
withWebExtension(),
|
||||
async function testStartAddonCleanup(applyInstallStub, [addonId, addonFile]) {
|
||||
applyInstallStub.rejects(new Error("Fake failure"));
|
||||
|
||||
const addonUrl = Services.io.newFileURI(addonFile).spec;
|
||||
await Assert.rejects(
|
||||
AddonStudies.start(startArgsFactory({addonUrl})),
|
||||
/Fake failure/,
|
||||
"start rejects when the Addons.applyInstall function rejects"
|
||||
);
|
||||
|
||||
const addon = await Addons.get(addonId);
|
||||
ok(!addon, "If something fails during start after the add-on is installed, it is uninstalled.");
|
||||
}
|
||||
);
|
||||
|
||||
const testOverwriteId = "testStartAddonNoOverwrite@example.com";
|
||||
decorate_task(
|
||||
withInstalledWebExtension({version: "1.0", id: testOverwriteId}),
|
||||
withWebExtension({version: "2.0", id: testOverwriteId}),
|
||||
async function testStartAddonNoOverwrite([installedId, installedFile], [id, addonFile]) {
|
||||
const addonUrl = Services.io.newFileURI(addonFile).spec;
|
||||
await Assert.rejects(
|
||||
AddonStudies.start(startArgsFactory({addonUrl})),
|
||||
/updating is disabled/,
|
||||
"start rejects when the study add-on is already installed"
|
||||
);
|
||||
|
||||
await Addons.uninstall(testOverwriteId);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withWebExtension({version: "2.0"}),
|
||||
async function testStart([addonId, addonFile]) {
|
||||
const startupPromise = AddonTestUtils.promiseWebExtensionStartup(addonId);
|
||||
const addonUrl = Services.io.newFileURI(addonFile).spec;
|
||||
|
||||
let addon = await Addons.get(addonId);
|
||||
is(addon, null, "Before start is called, the add-on is not installed.");
|
||||
|
||||
const args = startArgsFactory({
|
||||
name: "Test Study",
|
||||
description: "Test Desc",
|
||||
addonUrl,
|
||||
});
|
||||
await AddonStudies.start(args);
|
||||
await startupPromise;
|
||||
|
||||
addon = await Addons.get(addonId);
|
||||
ok(addon, "After start is called, the add-on is installed.");
|
||||
|
||||
const study = await AddonStudies.get(args.recipeId);
|
||||
Assert.deepEqual(
|
||||
study,
|
||||
{
|
||||
recipeId: args.recipeId,
|
||||
name: args.name,
|
||||
description: args.description,
|
||||
addonId,
|
||||
addonVersion: "2.0",
|
||||
addonUrl,
|
||||
active: true,
|
||||
studyStartDate: study.studyStartDate,
|
||||
},
|
||||
"start saves study data to storage",
|
||||
);
|
||||
ok(study.studyStartDate, "start assigns a value to the study start date.");
|
||||
|
||||
await Addons.uninstall(addonId);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies(),
|
||||
async function testStopNoStudy() {
|
||||
await Assert.rejects(
|
||||
AddonStudies.stop("does-not-exist"),
|
||||
/No study found/,
|
||||
"stop rejects when no study exists for the given recipe."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: false}),
|
||||
]),
|
||||
async function testStopInactiveStudy([study]) {
|
||||
await Assert.rejects(
|
||||
AddonStudies.stop(study.recipeId),
|
||||
/already inactive/,
|
||||
"stop rejects when the requested study is already inactive."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const testStopId = "testStop@example.com";
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true, addonId: testStopId, studyEndDate: null}),
|
||||
]),
|
||||
withInstalledWebExtension({id: testStopId}),
|
||||
async function testStop([study], [addonId, addonFile]) {
|
||||
await AddonStudies.stop(study.recipeId);
|
||||
const newStudy = await AddonStudies.get(study.recipeId);
|
||||
ok(!newStudy.active, "stop marks the study as inactive.");
|
||||
ok(newStudy.studyEndDate, "stop saves the study end date.");
|
||||
|
||||
const addon = await Addons.get(addonId);
|
||||
is(addon, null, "stop uninstalls the study add-on.");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true, addonId: "testStopWarn@example.com", studyEndDate: null}),
|
||||
]),
|
||||
async function testStopWarn([study]) {
|
||||
const addon = await Addons.get("testStopWarn@example.com");
|
||||
is(addon, null, "Before start is called, the add-on is not installed.");
|
||||
|
||||
// If the add-on is not installed, log a warning to the console, but do not
|
||||
// throw.
|
||||
await new Promise(resolve => {
|
||||
SimpleTest.waitForExplicitFinish();
|
||||
SimpleTest.monitorConsole(resolve, [{message: /Could not uninstall addon/}]);
|
||||
AddonStudies.stop(study.recipeId).then(() => SimpleTest.endMonitorConsole());
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true, addonId: "does.not.exist@example.com", studyEndDate: null}),
|
||||
studyFactory({active: true, addonId: "installed@example.com"}),
|
||||
studyFactory({active: false, addonId: "already.gone@example.com", studyEndDate: new Date(2012, 1)}),
|
||||
]),
|
||||
withInstalledWebExtension({id: "installed@example.com"}),
|
||||
async function testInit([activeStudy, activeInstalledStudy, inactiveStudy]) {
|
||||
await AddonStudies.init();
|
||||
|
||||
const newActiveStudy = await AddonStudies.get(activeStudy.recipeId);
|
||||
ok(!newActiveStudy.active, "init marks studies as inactive if their add-on is not installed.");
|
||||
ok(
|
||||
newActiveStudy.studyEndDate,
|
||||
"init sets the study end date if a study's add-on is not installed."
|
||||
);
|
||||
|
||||
const newInactiveStudy = await AddonStudies.get(inactiveStudy.recipeId);
|
||||
is(
|
||||
newInactiveStudy.studyEndDate.getFullYear(),
|
||||
2012,
|
||||
"init does not modify inactive studies."
|
||||
);
|
||||
|
||||
const newActiveInstalledStudy = await AddonStudies.get(activeInstalledStudy.recipeId);
|
||||
Assert.deepEqual(
|
||||
activeInstalledStudy,
|
||||
newActiveInstalledStudy,
|
||||
"init does not modify studies whose add-on is still installed."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
studyFactory({active: true, addonId: "installed@example.com", studyEndDate: null}),
|
||||
]),
|
||||
withInstalledWebExtension({id: "installed@example.com"}),
|
||||
async function testInit([study], [id, addonFile]) {
|
||||
await Addons.uninstall(id);
|
||||
await TestUtils.topicObserved("shield-study-ended");
|
||||
|
||||
const newStudy = await AddonStudies.get(study.recipeId);
|
||||
ok(!newStudy.active, "Studies are marked as inactive when their add-on is uninstalled.");
|
||||
ok(
|
||||
newStudy.studyEndDate,
|
||||
"The study end date is set when the add-on for the study is uninstalled."
|
||||
);
|
||||
}
|
||||
);
|
|
@ -0,0 +1,34 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
|
||||
Cu.import("resource://gre/modules/Services.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/Addons.jsm", this);
|
||||
|
||||
// Initialize test utils
|
||||
AddonTestUtils.initMochitest(this);
|
||||
|
||||
const testInstallId = "testInstallUpdate@example.com";
|
||||
decorate_task(
|
||||
withInstalledWebExtension({version: "1.0", id: testInstallId}),
|
||||
withWebExtension({version: "2.0", id: testInstallId}),
|
||||
async function testInstallUpdate([id1, addonFile1], [id2, addonFile2]) {
|
||||
// Fail to install the 2.0 add-on without updating enabled
|
||||
const newAddonUrl = Services.io.newFileURI(addonFile2).spec;
|
||||
await Assert.rejects(
|
||||
Addons.install(newAddonUrl, {update: false}),
|
||||
/updating is disabled/,
|
||||
"install rejects when the study add-on is already installed and updating is disabled"
|
||||
);
|
||||
|
||||
// Install the new add-on with updating enabled
|
||||
const startupPromise = AddonTestUtils.promiseWebExtensionStartup(testInstallId);
|
||||
await Addons.install(newAddonUrl, {update: true});
|
||||
|
||||
const addon = await startupPromise;
|
||||
is(
|
||||
addon.version,
|
||||
"2.0",
|
||||
"install can successfully update an already-installed addon when updating is enabled."
|
||||
);
|
||||
}
|
||||
);
|
|
@ -1,9 +1,13 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/Services.jsm", this);
|
||||
Cu.import("resource://gre/modules/TelemetryController.jsm", this);
|
||||
Cu.import("resource://gre/modules/AddonManager.jsm", this);
|
||||
Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
|
||||
|
||||
|
||||
add_task(async function testTelemetry() {
|
||||
// setup
|
||||
await TelemetryController.submitExternalPing("testfoo", {foo: 1});
|
||||
|
@ -110,15 +114,28 @@ add_task(async function testExperiments() {
|
|||
getAll.restore();
|
||||
});
|
||||
|
||||
add_task(withDriver(Assert, async function testAddonsInContext(driver) {
|
||||
// Create before install so that the listener is added before startup completes.
|
||||
const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com");
|
||||
const addonId = await driver.addons.install(TEST_XPI_URL);
|
||||
await startupPromise;
|
||||
|
||||
const environment = ClientEnvironment.getEnvironment();
|
||||
const addons = await environment.addons;
|
||||
Assert.deepEqual(addons[addonId], {
|
||||
id: [addonId],
|
||||
name: "normandy_fixture",
|
||||
version: "1.0",
|
||||
installDate: addons[addonId].installDate,
|
||||
isActive: true,
|
||||
type: "extension",
|
||||
}, "addons should be available in context");
|
||||
|
||||
await driver.addons.uninstall(addonId);
|
||||
}));
|
||||
|
||||
add_task(async function isFirstRun() {
|
||||
let environment = ClientEnvironment.getEnvironment();
|
||||
|
||||
// isFirstRun is initially set to true
|
||||
ok(environment.isFirstRun, "isFirstRun has a default value");
|
||||
|
||||
// isFirstRun is read from a preference
|
||||
await SpecialPowers.pushPrefEnv({set: [["extensions.shield-recipe-client.first_run", true]]});
|
||||
environment = ClientEnvironment.getEnvironment();
|
||||
const environment = ClientEnvironment.getEnvironment();
|
||||
ok(environment.isFirstRun, "isFirstRun is read from preferences");
|
||||
});
|
||||
|
||||
|
|
|
@ -26,102 +26,108 @@ function listenerC(x = 1) {
|
|||
evidence.log += "c";
|
||||
}
|
||||
|
||||
add_task(withSandboxManager(Assert, async function(sandboxManager) {
|
||||
const eventEmitter = new EventEmitter(sandboxManager);
|
||||
decorate_task(
|
||||
withSandboxManager(Assert),
|
||||
async function(sandboxManager) {
|
||||
const eventEmitter = new EventEmitter(sandboxManager);
|
||||
|
||||
// Fire an unrelated event, to make sure nothing goes wrong
|
||||
eventEmitter.on("nothing");
|
||||
// Fire an unrelated event, to make sure nothing goes wrong
|
||||
eventEmitter.on("nothing");
|
||||
|
||||
// bind listeners
|
||||
eventEmitter.on("event", listenerA);
|
||||
eventEmitter.on("event", listenerB);
|
||||
eventEmitter.once("event", listenerC);
|
||||
// bind listeners
|
||||
eventEmitter.on("event", listenerA);
|
||||
eventEmitter.on("event", listenerB);
|
||||
eventEmitter.once("event", listenerC);
|
||||
|
||||
// one event for all listeners
|
||||
eventEmitter.emit("event");
|
||||
// another event for a and b, since c should have turned off already
|
||||
eventEmitter.emit("event", 10);
|
||||
// one event for all listeners
|
||||
eventEmitter.emit("event");
|
||||
// another event for a and b, since c should have turned off already
|
||||
eventEmitter.emit("event", 10);
|
||||
|
||||
// make sure events haven't actually fired yet, just queued
|
||||
Assert.deepEqual(evidence, {
|
||||
a: 0,
|
||||
b: 0,
|
||||
c: 0,
|
||||
log: "",
|
||||
}, "events are fired async");
|
||||
// make sure events haven't actually fired yet, just queued
|
||||
Assert.deepEqual(evidence, {
|
||||
a: 0,
|
||||
b: 0,
|
||||
c: 0,
|
||||
log: "",
|
||||
}, "events are fired async");
|
||||
|
||||
// Spin the event loop to run events, so we can safely "off"
|
||||
await Promise.resolve();
|
||||
// Spin the event loop to run events, so we can safely "off"
|
||||
await Promise.resolve();
|
||||
|
||||
// Check intermediate event results
|
||||
Assert.deepEqual(evidence, {
|
||||
a: 11,
|
||||
b: 11,
|
||||
c: 1,
|
||||
log: "abcab",
|
||||
}, "intermediate events are fired");
|
||||
// Check intermediate event results
|
||||
Assert.deepEqual(evidence, {
|
||||
a: 11,
|
||||
b: 11,
|
||||
c: 1,
|
||||
log: "abcab",
|
||||
}, "intermediate events are fired");
|
||||
|
||||
// one more event for a
|
||||
eventEmitter.off("event", listenerB);
|
||||
eventEmitter.emit("event", 100);
|
||||
// one more event for a
|
||||
eventEmitter.off("event", listenerB);
|
||||
eventEmitter.emit("event", 100);
|
||||
|
||||
// And another unrelated event
|
||||
eventEmitter.on("nothing");
|
||||
// And another unrelated event
|
||||
eventEmitter.on("nothing");
|
||||
|
||||
// Spin the event loop to run events
|
||||
await Promise.resolve();
|
||||
// Spin the event loop to run events
|
||||
await Promise.resolve();
|
||||
|
||||
Assert.deepEqual(evidence, {
|
||||
a: 111,
|
||||
b: 11,
|
||||
c: 1,
|
||||
log: "abcaba", // events are in order
|
||||
}, "events fired as expected");
|
||||
Assert.deepEqual(evidence, {
|
||||
a: 111,
|
||||
b: 11,
|
||||
c: 1,
|
||||
log: "abcaba", // events are in order
|
||||
}, "events fired as expected");
|
||||
|
||||
// Test that mutating the data passed to the event doesn't actually
|
||||
// mutate it for other events.
|
||||
let handlerRunCount = 0;
|
||||
const mutationHandler = data => {
|
||||
handlerRunCount++;
|
||||
data.count++;
|
||||
is(data.count, 1, "Event data is not mutated between handlers.");
|
||||
};
|
||||
eventEmitter.on("mutationTest", mutationHandler);
|
||||
eventEmitter.on("mutationTest", mutationHandler);
|
||||
// Test that mutating the data passed to the event doesn't actually
|
||||
// mutate it for other events.
|
||||
let handlerRunCount = 0;
|
||||
const mutationHandler = data => {
|
||||
handlerRunCount++;
|
||||
data.count++;
|
||||
is(data.count, 1, "Event data is not mutated between handlers.");
|
||||
};
|
||||
eventEmitter.on("mutationTest", mutationHandler);
|
||||
eventEmitter.on("mutationTest", mutationHandler);
|
||||
|
||||
const data = {count: 0};
|
||||
eventEmitter.emit("mutationTest", data);
|
||||
await Promise.resolve();
|
||||
const data = {count: 0};
|
||||
eventEmitter.emit("mutationTest", data);
|
||||
await Promise.resolve();
|
||||
|
||||
is(handlerRunCount, 2, "Mutation handler was executed twice.");
|
||||
is(data.count, 0, "Event data cannot be mutated by handlers.");
|
||||
}));
|
||||
is(handlerRunCount, 2, "Mutation handler was executed twice.");
|
||||
is(data.count, 0, "Event data cannot be mutated by handlers.");
|
||||
}
|
||||
);
|
||||
|
||||
add_task(withSandboxManager(Assert, async function sandboxedEmitter(sandboxManager) {
|
||||
const eventEmitter = new EventEmitter(sandboxManager);
|
||||
decorate_task(
|
||||
withSandboxManager(Assert),
|
||||
async function sandboxedEmitter(sandboxManager) {
|
||||
const eventEmitter = new EventEmitter(sandboxManager);
|
||||
|
||||
// Event handlers inside the sandbox should be run in response to
|
||||
// events triggered outside the sandbox.
|
||||
sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
|
||||
sandboxManager.evalInSandbox(`
|
||||
this.eventCounts = {on: 0, once: 0};
|
||||
emitter.on("event", value => {
|
||||
this.eventCounts.on += value;
|
||||
});
|
||||
emitter.once("eventOnce", value => {
|
||||
this.eventCounts.once += value;
|
||||
});
|
||||
`);
|
||||
// Event handlers inside the sandbox should be run in response to
|
||||
// events triggered outside the sandbox.
|
||||
sandboxManager.addGlobal("emitter", eventEmitter.createSandboxedEmitter());
|
||||
sandboxManager.evalInSandbox(`
|
||||
this.eventCounts = {on: 0, once: 0};
|
||||
emitter.on("event", value => {
|
||||
this.eventCounts.on += value;
|
||||
});
|
||||
emitter.once("eventOnce", value => {
|
||||
this.eventCounts.once += value;
|
||||
});
|
||||
`);
|
||||
|
||||
eventEmitter.emit("event", 5);
|
||||
eventEmitter.emit("event", 10);
|
||||
eventEmitter.emit("eventOnce", 5);
|
||||
eventEmitter.emit("eventOnce", 10);
|
||||
await Promise.resolve();
|
||||
eventEmitter.emit("event", 5);
|
||||
eventEmitter.emit("event", 10);
|
||||
eventEmitter.emit("eventOnce", 5);
|
||||
eventEmitter.emit("eventOnce", 10);
|
||||
await Promise.resolve();
|
||||
|
||||
const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
|
||||
Assert.deepEqual(eventCounts, {
|
||||
on: 15,
|
||||
once: 5,
|
||||
}, "Events emitted outside a sandbox trigger handlers within a sandbox.");
|
||||
}));
|
||||
const eventCounts = sandboxManager.evalInSandbox("this.eventCounts");
|
||||
Assert.deepEqual(eventCounts, {
|
||||
on: 15,
|
||||
once: 5,
|
||||
}, "Events emitted outside a sandbox trigger handlers within a sandbox.");
|
||||
}
|
||||
);
|
||||
|
|
|
@ -91,3 +91,98 @@ add_task(async function() {
|
|||
val = await FilterExpressions.eval('"normandy.test.value"|preferenceExists == true');
|
||||
ok(val, "preferenceExists expression fails existence check appropriately");
|
||||
});
|
||||
|
||||
// keys tests
|
||||
add_task(async function testKeys() {
|
||||
let val;
|
||||
|
||||
// Test an object defined in JEXL
|
||||
val = await FilterExpressions.eval("{foo: 1, bar: 2}|keys");
|
||||
Assert.deepEqual(
|
||||
new Set(val),
|
||||
new Set(["foo", "bar"]),
|
||||
"keys returns the keys from an object in JEXL",
|
||||
);
|
||||
|
||||
// Test an object in the context
|
||||
let context = {ctxObject: {baz: "string", biff: NaN}};
|
||||
val = await FilterExpressions.eval("ctxObject|keys", context);
|
||||
|
||||
Assert.deepEqual(
|
||||
new Set(val),
|
||||
new Set(["baz", "biff"]),
|
||||
"keys returns the keys from an object in the context",
|
||||
);
|
||||
|
||||
// Test that values from the prototype are not included
|
||||
context = {ctxObject: Object.create({fooProto: 7})};
|
||||
context.ctxObject.baz = 8;
|
||||
context.ctxObject.biff = 5;
|
||||
is(
|
||||
await FilterExpressions.eval("ctxObject.fooProto", context),
|
||||
7,
|
||||
"Prototype properties are accessible via property access",
|
||||
);
|
||||
val = await FilterExpressions.eval("ctxObject|keys", context);
|
||||
Assert.deepEqual(
|
||||
new Set(val),
|
||||
new Set(["baz", "biff"]),
|
||||
"keys does not return properties from the object's prototype chain",
|
||||
);
|
||||
|
||||
// Return undefined for non-objects
|
||||
is(
|
||||
await FilterExpressions.eval("ctxObject|keys", {ctxObject: 45}),
|
||||
undefined,
|
||||
"keys returns undefined for numbers",
|
||||
);
|
||||
is(
|
||||
await FilterExpressions.eval("ctxObject|keys", {ctxObject: null}),
|
||||
undefined,
|
||||
"keys returns undefined for null",
|
||||
);
|
||||
});
|
||||
|
||||
// intersect tests
|
||||
add_task(async function testIntersect() {
|
||||
let val;
|
||||
|
||||
val = await FilterExpressions.eval("[1, 2, 3] intersect [4, 2, 6, 7, 3]");
|
||||
Assert.deepEqual(
|
||||
new Set(val),
|
||||
new Set([2, 3]),
|
||||
"intersect finds the common elements between two lists in JEXL",
|
||||
);
|
||||
|
||||
const context = {left: [5, 7], right: [4, 5, 3]};
|
||||
val = await FilterExpressions.eval("left intersect right", context);
|
||||
Assert.deepEqual(
|
||||
new Set(val),
|
||||
new Set([5]),
|
||||
"intersect finds the common elements between two lists in the context",
|
||||
);
|
||||
|
||||
val = await FilterExpressions.eval("['string', 2] intersect [4, 'string', 'other', 3]");
|
||||
Assert.deepEqual(
|
||||
new Set(val),
|
||||
new Set(["string"]),
|
||||
"intersect can compare strings",
|
||||
);
|
||||
|
||||
// Return undefined when intersecting things that aren't lists.
|
||||
is(
|
||||
await FilterExpressions.eval("5 intersect 7"),
|
||||
undefined,
|
||||
"intersect returns undefined for numbers",
|
||||
);
|
||||
is(
|
||||
await FilterExpressions.eval("val intersect other", {val: null, other: null}),
|
||||
undefined,
|
||||
"intersect returns undefined for null",
|
||||
);
|
||||
is(
|
||||
await FilterExpressions.eval("5 intersect [1, 2, 5]"),
|
||||
undefined,
|
||||
"intersect returns undefined if only one operand is a list",
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/AppConstants.jsm");
|
||||
Cu.import("resource://testing-common/AddonTestUtils.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyDriver.jsm", this);
|
||||
|
||||
add_task(withDriver(Assert, async function uuids(driver) {
|
||||
|
@ -12,6 +15,49 @@ add_task(withDriver(Assert, async function uuids(driver) {
|
|||
isnot(uuid1, uuid2, "uuids are unique");
|
||||
}));
|
||||
|
||||
add_task(withDriver(Assert, async function installXpi(driver) {
|
||||
// Test that we can install an XPI from any URL
|
||||
// Create before install so that the listener is added before startup completes.
|
||||
const startupPromise = AddonTestUtils.promiseWebExtensionStartup("normandydriver@example.com");
|
||||
|
||||
var addonId = await driver.addons.install(TEST_XPI_URL);
|
||||
is(addonId, "normandydriver@example.com", "Expected test addon was installed");
|
||||
isnot(addonId, null, "Addon install was successful");
|
||||
|
||||
// Wait until the add-on is fully started up to uninstall it.
|
||||
await startupPromise;
|
||||
|
||||
const uninstallMsg = await driver.addons.uninstall(addonId);
|
||||
is(uninstallMsg, null, `Uninstall returned an unexpected message [${uninstallMsg}]`);
|
||||
}));
|
||||
|
||||
add_task(withDriver(Assert, async function uninstallInvalidAddonId(driver) {
|
||||
const invalidAddonId = "not_a_valid_xpi_id@foo.bar";
|
||||
try {
|
||||
await driver.addons.uninstall(invalidAddonId);
|
||||
ok(false, `Uninstalling an invalid XPI should fail. addons.uninstall resolved successfully though.`);
|
||||
} catch (e) {
|
||||
ok(true, `This is the expected failure`);
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
add_task(withDriver(Assert, async function installXpiBadURL(driver) {
|
||||
let xpiUrl;
|
||||
if (AppConstants.platform === "win") {
|
||||
xpiUrl = "file:///C:/invalid_xpi.xpi";
|
||||
} else {
|
||||
xpiUrl = "file:///tmp/invalid_xpi.xpi";
|
||||
}
|
||||
|
||||
try {
|
||||
await driver.addons.install(xpiUrl);
|
||||
ok(false, "Installation succeeded on an XPI that doesn't exist");
|
||||
} catch (reason) {
|
||||
ok(true, `Installation was rejected: [${reason}]`);
|
||||
}
|
||||
}));
|
||||
|
||||
add_task(withDriver(Assert, async function userId(driver) {
|
||||
// Test that userId is a UUID
|
||||
ok(UUID_REGEX.test(driver.userId), "userId is a uuid");
|
||||
|
@ -45,38 +91,227 @@ add_task(withDriver(Assert, async function distribution(driver) {
|
|||
is(client.distribution, "funnelcake", "distribution is read from preferences");
|
||||
}));
|
||||
|
||||
add_task(withSandboxManager(Assert, async function testCreateStorage(sandboxManager) {
|
||||
const driver = new NormandyDriver(sandboxManager);
|
||||
sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
|
||||
decorate_task(
|
||||
withSandboxManager(Assert),
|
||||
async function testCreateStorage(sandboxManager) {
|
||||
const driver = new NormandyDriver(sandboxManager);
|
||||
sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
|
||||
|
||||
// Assertion helpers
|
||||
sandboxManager.addGlobal("is", is);
|
||||
sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
|
||||
// Assertion helpers
|
||||
sandboxManager.addGlobal("is", is);
|
||||
sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
|
||||
|
||||
await sandboxManager.evalInSandbox(`
|
||||
(async function sandboxTest() {
|
||||
const store = driver.createStorage("testprefix");
|
||||
const otherStore = driver.createStorage("othertestprefix");
|
||||
await store.clear();
|
||||
await otherStore.clear();
|
||||
await sandboxManager.evalInSandbox(`
|
||||
(async function sandboxTest() {
|
||||
const store = driver.createStorage("testprefix");
|
||||
const otherStore = driver.createStorage("othertestprefix");
|
||||
await store.clear();
|
||||
await otherStore.clear();
|
||||
|
||||
await store.setItem("willremove", 7);
|
||||
await otherStore.setItem("willremove", 4);
|
||||
is(await store.getItem("willremove"), 7, "createStorage stores sandbox values");
|
||||
is(
|
||||
await otherStore.getItem("willremove"),
|
||||
4,
|
||||
"values are not shared between createStorage stores",
|
||||
);
|
||||
await store.setItem("willremove", 7);
|
||||
await otherStore.setItem("willremove", 4);
|
||||
is(await store.getItem("willremove"), 7, "createStorage stores sandbox values");
|
||||
is(
|
||||
await otherStore.getItem("willremove"),
|
||||
4,
|
||||
"values are not shared between createStorage stores",
|
||||
);
|
||||
|
||||
const deepValue = {"foo": ["bar", "baz"]};
|
||||
await store.setItem("deepValue", deepValue);
|
||||
deepEqual(await store.getItem("deepValue"), deepValue, "createStorage clones stored values");
|
||||
const deepValue = {"foo": ["bar", "baz"]};
|
||||
await store.setItem("deepValue", deepValue);
|
||||
deepEqual(await store.getItem("deepValue"), deepValue, "createStorage clones stored values");
|
||||
|
||||
await store.removeItem("willremove");
|
||||
is(await store.getItem("willremove"), null, "createStorage removes items");
|
||||
await store.removeItem("willremove");
|
||||
is(await store.getItem("willremove"), null, "createStorage removes items");
|
||||
|
||||
is('prefix' in store, false, "createStorage doesn't expose non-whitelist attributes");
|
||||
})();
|
||||
`);
|
||||
is('prefix' in store, false, "createStorage doesn't expose non-whitelist attributes");
|
||||
})();
|
||||
`);
|
||||
}
|
||||
);
|
||||
|
||||
add_task(withDriver(Assert, async function getAddon(driver, sandboxManager) {
|
||||
const ADDON_ID = "normandydriver@example.com";
|
||||
let addon = await driver.addons.get(ADDON_ID);
|
||||
Assert.equal(addon, null, "Add-on is not yet installed");
|
||||
|
||||
await driver.addons.install(TEST_XPI_URL);
|
||||
addon = await driver.addons.get(ADDON_ID);
|
||||
|
||||
Assert.notEqual(addon, null, "Add-on object was returned");
|
||||
ok(addon.installDate instanceof sandboxManager.sandbox.Date, "installDate should be a Date object");
|
||||
|
||||
Assert.deepEqual(addon, {
|
||||
id: "normandydriver@example.com",
|
||||
name: "normandy_fixture",
|
||||
version: "1.0",
|
||||
installDate: addon.installDate,
|
||||
isActive: true,
|
||||
type: "extension",
|
||||
}, "Add-on is installed");
|
||||
|
||||
await driver.addons.uninstall(ADDON_ID);
|
||||
addon = await driver.addons.get(ADDON_ID);
|
||||
|
||||
Assert.equal(addon, null, "Add-on has been uninstalled");
|
||||
}));
|
||||
|
||||
decorate_task(
|
||||
withSandboxManager(Assert),
|
||||
async function testAddonsGetWorksInSandbox(sandboxManager) {
|
||||
const driver = new NormandyDriver(sandboxManager);
|
||||
sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
|
||||
|
||||
// Assertion helpers
|
||||
sandboxManager.addGlobal("is", is);
|
||||
sandboxManager.addGlobal("deepEqual", (...args) => Assert.deepEqual(...args));
|
||||
|
||||
const ADDON_ID = "normandydriver@example.com";
|
||||
|
||||
await driver.addons.install(TEST_XPI_URL);
|
||||
|
||||
await sandboxManager.evalInSandbox(`
|
||||
(async function sandboxTest() {
|
||||
const addon = await driver.addons.get("${ADDON_ID}");
|
||||
|
||||
deepEqual(addon, {
|
||||
id: "${ADDON_ID}",
|
||||
name: "normandy_fixture",
|
||||
version: "1.0",
|
||||
installDate: addon.installDate,
|
||||
isActive: true,
|
||||
type: "extension",
|
||||
}, "Add-on is accesible in the driver");
|
||||
})();
|
||||
`);
|
||||
|
||||
await driver.addons.uninstall(ADDON_ID);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withSandboxManager(Assert),
|
||||
withWebExtension({id: "driver-addon-studies@example.com"}),
|
||||
async function testAddonStudies(sandboxManager, [addonId, addonFile]) {
|
||||
const addonUrl = Services.io.newFileURI(addonFile).spec;
|
||||
const driver = new NormandyDriver(sandboxManager);
|
||||
sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
|
||||
|
||||
// Assertion helpers
|
||||
sandboxManager.addGlobal("is", is);
|
||||
sandboxManager.addGlobal("ok", ok);
|
||||
|
||||
await sandboxManager.evalInSandbox(`
|
||||
(async function sandboxTest() {
|
||||
const recipeId = 5;
|
||||
let hasStudy = await driver.studies.has(recipeId);
|
||||
ok(!hasStudy, "studies.has returns false if the study hasn't been started yet.");
|
||||
|
||||
await driver.studies.start({
|
||||
recipeId,
|
||||
name: "fake",
|
||||
description: "fake",
|
||||
addonUrl: "${addonUrl}",
|
||||
});
|
||||
hasStudy = await driver.studies.has(recipeId);
|
||||
ok(hasStudy, "studies.has returns true after the study has been started.");
|
||||
|
||||
let study = await driver.studies.get(recipeId);
|
||||
is(
|
||||
study.addonId,
|
||||
"driver-addon-studies@example.com",
|
||||
"studies.get fetches studies from within a sandbox."
|
||||
);
|
||||
ok(study.active, "Studies are marked as active after being started by the driver.");
|
||||
|
||||
await driver.studies.stop(recipeId);
|
||||
study = await driver.studies.get(recipeId);
|
||||
ok(!study.active, "Studies are marked as inactive after being stopped by the driver.");
|
||||
})();
|
||||
`);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
["test.char", "a string"],
|
||||
["test.int", 5],
|
||||
["test.bool", true],
|
||||
],
|
||||
}),
|
||||
withSandboxManager(Assert, async function testPreferences(sandboxManager) {
|
||||
const driver = new NormandyDriver(sandboxManager);
|
||||
sandboxManager.cloneIntoGlobal("driver", driver, {cloneFunctions: true});
|
||||
|
||||
// Assertion helpers
|
||||
sandboxManager.addGlobal("is", is);
|
||||
sandboxManager.addGlobal("ok", ok);
|
||||
sandboxManager.addGlobal("assertThrows", Assert.throws.bind(Assert));
|
||||
|
||||
await sandboxManager.evalInSandbox(`
|
||||
(async function sandboxTest() {
|
||||
ok(
|
||||
driver.preferences.getBool("test.bool"),
|
||||
"preferences.getBool can retrieve boolean preferences."
|
||||
);
|
||||
is(
|
||||
driver.preferences.getInt("test.int"),
|
||||
5,
|
||||
"preferences.getInt can retrieve integer preferences."
|
||||
);
|
||||
is(
|
||||
driver.preferences.getChar("test.char"),
|
||||
"a string",
|
||||
"preferences.getChar can retrieve string preferences."
|
||||
);
|
||||
assertThrows(
|
||||
() => driver.preferences.getChar("test.int"),
|
||||
"preferences.getChar throws when retreiving a non-string preference."
|
||||
);
|
||||
assertThrows(
|
||||
() => driver.preferences.getInt("test.bool"),
|
||||
"preferences.getInt throws when retreiving a non-integer preference."
|
||||
);
|
||||
assertThrows(
|
||||
() => driver.preferences.getBool("test.char"),
|
||||
"preferences.getBool throws when retreiving a non-boolean preference."
|
||||
);
|
||||
assertThrows(
|
||||
() => driver.preferences.getChar("test.does.not.exist"),
|
||||
"preferences.getChar throws when retreiving a non-existant preference."
|
||||
);
|
||||
assertThrows(
|
||||
() => driver.preferences.getInt("test.does.not.exist"),
|
||||
"preferences.getInt throws when retreiving a non-existant preference."
|
||||
);
|
||||
assertThrows(
|
||||
() => driver.preferences.getBool("test.does.not.exist"),
|
||||
"preferences.getBool throws when retreiving a non-existant preference."
|
||||
);
|
||||
ok(
|
||||
driver.preferences.getBool("test.does.not.exist", true),
|
||||
"preferences.getBool returns a default value if the preference doesn't exist."
|
||||
);
|
||||
is(
|
||||
driver.preferences.getInt("test.does.not.exist", 7),
|
||||
7,
|
||||
"preferences.getInt returns a default value if the preference doesn't exist."
|
||||
);
|
||||
is(
|
||||
driver.preferences.getChar("test.does.not.exist", "default"),
|
||||
"default",
|
||||
"preferences.getChar returns a default value if the preference doesn't exist."
|
||||
);
|
||||
ok(
|
||||
driver.preferences.has("test.char"),
|
||||
"preferences.has returns true if the given preference exists."
|
||||
);
|
||||
ok(
|
||||
!driver.preferences.has("test.does.not.exist"),
|
||||
"preferences.has returns false if the given preference does not exist."
|
||||
);
|
||||
})();
|
||||
`);
|
||||
})
|
||||
);
|
||||
|
|
|
@ -375,6 +375,9 @@ add_task(withMockExperiments(withMockPreferences(async function(experiments, moc
|
|||
// stop should also support user pref experiments
|
||||
add_task(withMockExperiments(withMockPreferences(async function(experiments, mockPreferences) {
|
||||
const stopObserver = sinon.stub(PreferenceExperiments, "stopObserver");
|
||||
const hasObserver = sinon.stub(PreferenceExperiments, "hasObserver");
|
||||
hasObserver.returns(true);
|
||||
|
||||
mockPreferences.set("fake.preference", "experimentvalue", "user");
|
||||
experiments.test = experimentFactory({
|
||||
name: "test",
|
||||
|
@ -397,6 +400,7 @@ add_task(withMockExperiments(withMockPreferences(async function(experiments, moc
|
|||
);
|
||||
|
||||
stopObserver.restore();
|
||||
hasObserver.restore();
|
||||
})));
|
||||
|
||||
// stop should not call stopObserver if there is no observer registered.
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://testing-common/TestUtils.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/ClientEnvironment.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/CleanupManager.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/NormandyApi.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/ActionSandboxManager.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/Uptake.jsm", this);
|
||||
|
||||
add_task(async function getFilterContext() {
|
||||
const recipe = {id: 17, arguments: {foo: "bar"}, unrelated: false};
|
||||
|
@ -69,7 +72,7 @@ add_task(withMockNormandyApi(async function testClientClassificationCache() {
|
|||
|
||||
await SpecialPowers.pushPrefEnv({set: [
|
||||
["extensions.shield-recipe-client.api_url",
|
||||
"https://example.com/selfsupport-dummy"],
|
||||
"https://example.com/selfsupport-dummy"],
|
||||
]});
|
||||
|
||||
// When the experiment pref is false, eagerly call getClientClassification.
|
||||
|
@ -98,27 +101,36 @@ add_task(withMockNormandyApi(async function testClientClassificationCache() {
|
|||
async function withMockActionSandboxManagers(actions, testFunction) {
|
||||
const managers = {};
|
||||
for (const action of actions) {
|
||||
managers[action.name] = new ActionSandboxManager("");
|
||||
const manager = new ActionSandboxManager("");
|
||||
manager.addHold("testing");
|
||||
managers[action.name] = manager;
|
||||
sinon.stub(managers[action.name], "runAsyncCallback");
|
||||
}
|
||||
|
||||
const loadActionSandboxManagers = sinon.stub(
|
||||
RecipeRunner,
|
||||
"loadActionSandboxManagers",
|
||||
async () => managers,
|
||||
);
|
||||
const loadActionSandboxManagers = sinon.stub(RecipeRunner, "loadActionSandboxManagers")
|
||||
.resolves(managers);
|
||||
await testFunction(managers);
|
||||
loadActionSandboxManagers.restore();
|
||||
|
||||
for (const manager of Object.values(managers)) {
|
||||
manager.removeHold("testing");
|
||||
await manager.isNuked();
|
||||
}
|
||||
}
|
||||
|
||||
add_task(withMockNormandyApi(async function testRun(mockApi) {
|
||||
const closeSpy = sinon.spy(AddonStudies, "close");
|
||||
const reportRunner = sinon.stub(Uptake, "reportRunner");
|
||||
const reportAction = sinon.stub(Uptake, "reportAction");
|
||||
const reportRecipe = sinon.stub(Uptake, "reportRecipe");
|
||||
|
||||
const matchAction = {name: "matchAction"};
|
||||
const noMatchAction = {name: "noMatchAction"};
|
||||
mockApi.actions = [matchAction, noMatchAction];
|
||||
|
||||
const matchRecipe = {action: "matchAction", filter_expression: "true"};
|
||||
const noMatchRecipe = {action: "noMatchAction", filter_expression: "false"};
|
||||
const missingRecipe = {action: "missingAction", filter_expression: "true"};
|
||||
const matchRecipe = {id: "match", action: "matchAction", filter_expression: "true"};
|
||||
const noMatchRecipe = {id: "noMatch", action: "noMatchAction", filter_expression: "false"};
|
||||
const missingRecipe = {id: "missing", action: "missingAction", filter_expression: "true"};
|
||||
mockApi.recipes = [matchRecipe, noMatchRecipe, missingRecipe];
|
||||
|
||||
await withMockActionSandboxManagers(mockApi.actions, async managers => {
|
||||
|
@ -139,18 +151,99 @@ add_task(withMockNormandyApi(async function testRun(mockApi) {
|
|||
sinon.assert.calledWith(noMatchManager.runAsyncCallback, "postExecution");
|
||||
|
||||
// missing is never called at all due to no matching action/manager.
|
||||
await matchManager.isNuked();
|
||||
await noMatchManager.isNuked();
|
||||
|
||||
// Test uptake reporting
|
||||
sinon.assert.calledWith(reportRunner, Uptake.RUNNER_SUCCESS);
|
||||
sinon.assert.calledWith(reportAction, "matchAction", Uptake.ACTION_SUCCESS);
|
||||
sinon.assert.calledWith(reportAction, "noMatchAction", Uptake.ACTION_SUCCESS);
|
||||
sinon.assert.calledWith(reportRecipe, "match", Uptake.RECIPE_SUCCESS);
|
||||
sinon.assert.neverCalledWith(reportRecipe, "noMatch", Uptake.RECIPE_SUCCESS);
|
||||
sinon.assert.calledWith(reportRecipe, "missing", Uptake.RECIPE_INVALID_ACTION);
|
||||
});
|
||||
|
||||
// Ensure storage is closed after the run.
|
||||
sinon.assert.calledOnce(closeSpy);
|
||||
|
||||
closeSpy.restore();
|
||||
reportRunner.restore();
|
||||
reportAction.restore();
|
||||
reportRecipe.restore();
|
||||
}));
|
||||
|
||||
add_task(withMockNormandyApi(async function testRunRecipeError(mockApi) {
|
||||
const reportRecipe = sinon.stub(Uptake, "reportRecipe");
|
||||
|
||||
const action = {name: "action"};
|
||||
mockApi.actions = [action];
|
||||
|
||||
const recipe = {id: "recipe", action: "action", filter_expression: "true"};
|
||||
mockApi.recipes = [recipe];
|
||||
|
||||
await withMockActionSandboxManagers(mockApi.actions, async managers => {
|
||||
const manager = managers.action;
|
||||
manager.runAsyncCallback.callsFake(async callbackName => {
|
||||
if (callbackName === "action") {
|
||||
throw new Error("Action execution failure");
|
||||
}
|
||||
});
|
||||
|
||||
await RecipeRunner.run();
|
||||
|
||||
// Uptake should report that the recipe threw an exception
|
||||
sinon.assert.calledWith(reportRecipe, "recipe", Uptake.RECIPE_EXECUTION_ERROR);
|
||||
});
|
||||
|
||||
reportRecipe.restore();
|
||||
}));
|
||||
|
||||
add_task(withMockNormandyApi(async function testRunFetchFail(mockApi) {
|
||||
const closeSpy = sinon.spy(AddonStudies, "close");
|
||||
const reportRunner = sinon.stub(Uptake, "reportRunner");
|
||||
|
||||
const action = {name: "action"};
|
||||
mockApi.actions = [action];
|
||||
mockApi.fetchRecipes.rejects(new Error("Signature not valid"));
|
||||
|
||||
await withMockActionSandboxManagers(mockApi.actions, async managers => {
|
||||
const manager = managers.action;
|
||||
await RecipeRunner.run();
|
||||
|
||||
// If the recipe fetch failed, do not run anything.
|
||||
sinon.assert.notCalled(manager.runAsyncCallback);
|
||||
sinon.assert.calledWith(reportRunner, Uptake.RUNNER_SERVER_ERROR);
|
||||
|
||||
// Test that network errors report a specific uptake error
|
||||
reportRunner.reset();
|
||||
mockApi.fetchRecipes.rejects(new Error("NetworkError: The system was down"));
|
||||
await RecipeRunner.run();
|
||||
sinon.assert.calledWith(reportRunner, Uptake.RUNNER_NETWORK_ERROR);
|
||||
|
||||
// Test that signature issues report a specific uptake error
|
||||
reportRunner.reset();
|
||||
mockApi.fetchRecipes.rejects(new NormandyApi.InvalidSignatureError("Signature fail"));
|
||||
await RecipeRunner.run();
|
||||
sinon.assert.calledWith(reportRunner, Uptake.RUNNER_INVALID_SIGNATURE);
|
||||
});
|
||||
|
||||
// If the recipe fetch failed, we don't need to call close since nothing
|
||||
// opened a connection in the first place.
|
||||
sinon.assert.notCalled(closeSpy);
|
||||
|
||||
closeSpy.restore();
|
||||
reportRunner.restore();
|
||||
}));
|
||||
|
||||
add_task(withMockNormandyApi(async function testRunPreExecutionFailure(mockApi) {
|
||||
const closeSpy = sinon.spy(AddonStudies, "close");
|
||||
const reportAction = sinon.stub(Uptake, "reportAction");
|
||||
const reportRecipe = sinon.stub(Uptake, "reportRecipe");
|
||||
|
||||
const passAction = {name: "passAction"};
|
||||
const failAction = {name: "failAction"};
|
||||
mockApi.actions = [passAction, failAction];
|
||||
|
||||
const passRecipe = {action: "passAction", filter_expression: "true"};
|
||||
const failRecipe = {action: "failAction", filter_expression: "true"};
|
||||
const passRecipe = {id: "pass", action: "passAction", filter_expression: "true"};
|
||||
const failRecipe = {id: "fail", action: "failAction", filter_expression: "true"};
|
||||
mockApi.recipes = [passRecipe, failRecipe];
|
||||
|
||||
await withMockActionSandboxManagers(mockApi.actions, async managers => {
|
||||
|
@ -170,9 +263,47 @@ add_task(withMockNormandyApi(async function testRunPreExecutionFailure(mockApi)
|
|||
sinon.assert.neverCalledWith(failManager.runAsyncCallback, "action", failRecipe);
|
||||
sinon.assert.neverCalledWith(failManager.runAsyncCallback, "postExecution");
|
||||
|
||||
await passManager.isNuked();
|
||||
await failManager.isNuked();
|
||||
sinon.assert.calledWith(reportAction, "passAction", Uptake.ACTION_SUCCESS);
|
||||
sinon.assert.calledWith(reportAction, "failAction", Uptake.ACTION_PRE_EXECUTION_ERROR);
|
||||
sinon.assert.calledWith(reportRecipe, "fail", Uptake.RECIPE_ACTION_DISABLED);
|
||||
});
|
||||
|
||||
// Ensure storage is closed after the run, despite the failures.
|
||||
sinon.assert.calledOnce(closeSpy);
|
||||
closeSpy.restore();
|
||||
reportAction.restore();
|
||||
reportRecipe.restore();
|
||||
}));
|
||||
|
||||
add_task(withMockNormandyApi(async function testRunPostExecutionFailure(mockApi) {
|
||||
const reportAction = sinon.stub(Uptake, "reportAction");
|
||||
|
||||
const failAction = {name: "failAction"};
|
||||
mockApi.actions = [failAction];
|
||||
|
||||
const failRecipe = {action: "failAction", filter_expression: "true"};
|
||||
mockApi.recipes = [failRecipe];
|
||||
|
||||
await withMockActionSandboxManagers(mockApi.actions, async managers => {
|
||||
const failManager = managers.failAction;
|
||||
failManager.runAsyncCallback.callsFake(async callbackName => {
|
||||
if (callbackName === "postExecution") {
|
||||
throw new Error("postExecution failure");
|
||||
}
|
||||
});
|
||||
|
||||
await RecipeRunner.run();
|
||||
|
||||
// fail should be called for every stage
|
||||
sinon.assert.calledWith(failManager.runAsyncCallback, "preExecution");
|
||||
sinon.assert.calledWith(failManager.runAsyncCallback, "action", failRecipe);
|
||||
sinon.assert.calledWith(failManager.runAsyncCallback, "postExecution");
|
||||
|
||||
// Uptake should report a post-execution error
|
||||
sinon.assert.calledWith(reportAction, "failAction", Uptake.ACTION_POST_EXECUTION_ERROR);
|
||||
});
|
||||
|
||||
reportAction.restore();
|
||||
}));
|
||||
|
||||
add_task(withMockNormandyApi(async function testLoadActionSandboxManagers(mockApi) {
|
||||
|
@ -193,42 +324,65 @@ add_task(withMockNormandyApi(async function testLoadActionSandboxManagers(mockAp
|
|||
);
|
||||
}));
|
||||
|
||||
add_task(async function testStartup() {
|
||||
const runStub = sinon.stub(RecipeRunner, "run");
|
||||
const addCleanupHandlerStub = sinon.stub(CleanupManager, "addCleanupHandler");
|
||||
const updateRunIntervalStub = sinon.stub(RecipeRunner, "updateRunInterval");
|
||||
|
||||
// in dev mode
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
["extensions.shield-recipe-client.dev_mode", true],
|
||||
["extensions.shield-recipe-client.first_run", false],
|
||||
],
|
||||
});
|
||||
}),
|
||||
withStub(RecipeRunner, "run"),
|
||||
withStub(CleanupManager, "addCleanupHandler"),
|
||||
withStub(RecipeRunner, "updateRunInterval"),
|
||||
async function testInitDevMode(runStub, addCleanupHandlerStub, updateRunIntervalStub) {
|
||||
RecipeRunner.init();
|
||||
ok(runStub.called, "RecipeRunner.run is called immediately when in dev mode");
|
||||
ok(addCleanupHandlerStub.called, "A cleanup function is registered when in dev mode");
|
||||
ok(updateRunIntervalStub.called, "A timer is registered when in dev mode");
|
||||
}
|
||||
);
|
||||
|
||||
RecipeRunner.init();
|
||||
ok(runStub.called, "RecipeRunner.run is called immediately when in dev mode");
|
||||
ok(addCleanupHandlerStub.called, "A cleanup function is registered when in dev mode");
|
||||
ok(updateRunIntervalStub.called, "A timer is registered when in dev mode");
|
||||
|
||||
runStub.reset();
|
||||
addCleanupHandlerStub.reset();
|
||||
updateRunIntervalStub.reset();
|
||||
|
||||
// not in dev mode
|
||||
await SpecialPowers.pushPrefEnv({
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
["extensions.shield-recipe-client.dev_mode", false],
|
||||
["extensions.shield-recipe-client.first_run", false],
|
||||
],
|
||||
});
|
||||
}),
|
||||
withStub(RecipeRunner, "run"),
|
||||
withStub(CleanupManager, "addCleanupHandler"),
|
||||
withStub(RecipeRunner, "updateRunInterval"),
|
||||
async function testInit(runStub, addCleanupHandlerStub, updateRunIntervalStub) {
|
||||
RecipeRunner.init();
|
||||
ok(!runStub.called, "RecipeRunner.run is not called immediately when not in dev mode");
|
||||
ok(addCleanupHandlerStub.called, "A cleanup function is registered when not in dev mode");
|
||||
ok(updateRunIntervalStub.called, "A timer is registered when not in dev mode");
|
||||
}
|
||||
);
|
||||
|
||||
RecipeRunner.init();
|
||||
ok(!runStub.called, "RecipeRunner.run is not called immediately when not in dev mode");
|
||||
ok(addCleanupHandlerStub.called, "A cleanup function is registered when not in dev mode");
|
||||
ok(updateRunIntervalStub.called, "A timer is registered when not in dev mode");
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
["extensions.shield-recipe-client.dev_mode", false],
|
||||
["extensions.shield-recipe-client.first_run", true],
|
||||
],
|
||||
}),
|
||||
withStub(RecipeRunner, "run"),
|
||||
withStub(RecipeRunner, "registerTimer"),
|
||||
withStub(CleanupManager, "addCleanupHandler"),
|
||||
withStub(RecipeRunner, "updateRunInterval"),
|
||||
async function testInitFirstRun(runStub, registerTimerStub) {
|
||||
RecipeRunner.init();
|
||||
ok(!runStub.called, "RecipeRunner.run is not called immediately");
|
||||
ok(!registerTimerStub.called, "RecipeRunner.registerTimer is not called immediately");
|
||||
|
||||
runStub.restore();
|
||||
addCleanupHandlerStub.restore();
|
||||
updateRunIntervalStub.restore();
|
||||
});
|
||||
Services.obs.notifyObservers(null, "sessionstore-windows-restored");
|
||||
await TestUtils.topicObserved("shield-init-complete");
|
||||
ok(runStub.called, "RecipeRunner.run is called after the UI is available");
|
||||
ok(registerTimerStub.called, "RecipeRunner.registerTimer is called after the UI is available");
|
||||
ok(
|
||||
!Services.prefs.getBoolPref("extensions.shield-recipe-client.first_run"),
|
||||
"On first run, the first run pref is set to false after the UI is available"
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
|
@ -3,28 +3,65 @@
|
|||
Cu.import("resource://shield-recipe-client/lib/ShieldRecipeClient.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/RecipeRunner.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/PreferenceExperiments.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client-content/AboutPages.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
|
||||
|
||||
add_task(async function testStartup() {
|
||||
sinon.stub(RecipeRunner, "init");
|
||||
sinon.stub(PreferenceExperiments, "init");
|
||||
function withStubInits(testFunction) {
|
||||
return decorate(
|
||||
withStub(AboutPages, "init"),
|
||||
withStub(AddonStudies, "init"),
|
||||
withStub(PreferenceExperiments, "init"),
|
||||
withStub(RecipeRunner, "init"),
|
||||
testFunction
|
||||
);
|
||||
}
|
||||
|
||||
await ShieldRecipeClient.startup();
|
||||
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
|
||||
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
|
||||
decorate_task(
|
||||
withStubInits,
|
||||
async function testStartup() {
|
||||
await ShieldRecipeClient.startup();
|
||||
ok(AboutPages.init.called, "startup calls AboutPages.init");
|
||||
ok(AddonStudies.init.called, "startup calls AddonStudies.init");
|
||||
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
|
||||
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
|
||||
}
|
||||
);
|
||||
|
||||
PreferenceExperiments.init.restore();
|
||||
RecipeRunner.init.restore();
|
||||
});
|
||||
decorate_task(
|
||||
withStubInits,
|
||||
async function testStartupPrefInitFail() {
|
||||
PreferenceExperiments.init.returns(Promise.reject(new Error("oh no")));
|
||||
|
||||
add_task(async function testStartupPrefInitFail() {
|
||||
sinon.stub(RecipeRunner, "init");
|
||||
sinon.stub(PreferenceExperiments, "init").returns(Promise.reject(new Error("oh no")));
|
||||
await ShieldRecipeClient.startup();
|
||||
ok(AboutPages.init.called, "startup calls AboutPages.init");
|
||||
ok(AddonStudies.init.called, "startup calls AddonStudies.init");
|
||||
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
|
||||
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
|
||||
}
|
||||
);
|
||||
|
||||
await ShieldRecipeClient.startup();
|
||||
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
|
||||
// Even if PreferenceExperiments.init fails, RecipeRunner.init should be called.
|
||||
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
|
||||
decorate_task(
|
||||
withStubInits,
|
||||
async function testStartupAboutPagesInitFail() {
|
||||
AboutPages.init.returns(Promise.reject(new Error("oh no")));
|
||||
|
||||
PreferenceExperiments.init.restore();
|
||||
RecipeRunner.init.restore();
|
||||
});
|
||||
await ShieldRecipeClient.startup();
|
||||
ok(AboutPages.init.called, "startup calls AboutPages.init");
|
||||
ok(AddonStudies.init.called, "startup calls AddonStudies.init");
|
||||
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
|
||||
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withStubInits,
|
||||
async function testStartupAddonStudiesInitFail() {
|
||||
AddonStudies.init.returns(Promise.reject(new Error("oh no")));
|
||||
|
||||
await ShieldRecipeClient.startup();
|
||||
ok(AboutPages.init.called, "startup calls AboutPages.init");
|
||||
ok(AddonStudies.init.called, "startup calls AddonStudies.init");
|
||||
ok(PreferenceExperiments.init.called, "startup calls PreferenceExperiments.init");
|
||||
ok(RecipeRunner.init.called, "startup calls RecipeRunner.init");
|
||||
}
|
||||
);
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/Services.jsm", this);
|
||||
|
||||
const OPT_OUT_PREF = "app.shield.optoutstudies.enabled";
|
||||
const FHR_PREF = "datareporting.healthreport.uploadEnabled";
|
||||
|
||||
function withPrivacyPrefs(testFunc) {
|
||||
return async (...args) => (
|
||||
BrowserTestUtils.withNewTab("about:preferences#privacy", async browser => (
|
||||
testFunc(...args, browser)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [[OPT_OUT_PREF, true]],
|
||||
}),
|
||||
withPrivacyPrefs,
|
||||
async function testCheckedOnLoad(browser) {
|
||||
const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
|
||||
ok(checkbox.checked, "Opt-out checkbox is checked on load when the pref is true");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [[OPT_OUT_PREF, false]],
|
||||
}),
|
||||
withPrivacyPrefs,
|
||||
async function testUncheckedOnLoad(browser) {
|
||||
const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
|
||||
ok(!checkbox.checked, "Opt-out checkbox is unchecked on load when the pref is false");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [[FHR_PREF, true]],
|
||||
}),
|
||||
withPrivacyPrefs,
|
||||
async function testEnabledOnLoad(browser) {
|
||||
const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
|
||||
ok(!checkbox.disabled, "Opt-out checkbox is enabled on load when the FHR pref is true");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [[FHR_PREF, false]],
|
||||
}),
|
||||
withPrivacyPrefs,
|
||||
async function testDisabledOnLoad(browser) {
|
||||
const checkbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
|
||||
ok(checkbox.disabled, "Opt-out checkbox is disabled on load when the FHR pref is false");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
[FHR_PREF, true],
|
||||
[OPT_OUT_PREF, true],
|
||||
],
|
||||
}),
|
||||
withPrivacyPrefs,
|
||||
async function testCheckboxes(browser) {
|
||||
const optOutCheckbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
|
||||
const fhrCheckbox = browser.contentDocument.getElementById("submitHealthReportBox");
|
||||
|
||||
optOutCheckbox.click();
|
||||
ok(
|
||||
!Services.prefs.getBoolPref(OPT_OUT_PREF),
|
||||
"Unchecking the opt-out checkbox sets the pref to false."
|
||||
);
|
||||
optOutCheckbox.click();
|
||||
ok(
|
||||
Services.prefs.getBoolPref(OPT_OUT_PREF),
|
||||
"Checking the opt-out checkbox sets the pref to true."
|
||||
);
|
||||
|
||||
fhrCheckbox.click();
|
||||
ok(
|
||||
!Services.prefs.getBoolPref(OPT_OUT_PREF),
|
||||
"Unchecking the FHR checkbox sets the opt-out pref to false."
|
||||
);
|
||||
ok(
|
||||
optOutCheckbox.disabled,
|
||||
"Unchecking the FHR checkbox disables the opt-out checkbox."
|
||||
);
|
||||
ok(
|
||||
!optOutCheckbox.checked,
|
||||
"Unchecking the FHR checkbox unchecks the opt-out checkbox."
|
||||
);
|
||||
|
||||
fhrCheckbox.click();
|
||||
ok(
|
||||
Services.prefs.getBoolPref(OPT_OUT_PREF),
|
||||
"Checking the FHR checkbox sets the opt-out pref to true."
|
||||
);
|
||||
ok(
|
||||
!optOutCheckbox.disabled,
|
||||
"Checking the FHR checkbox enables the opt-out checkbox."
|
||||
);
|
||||
ok(
|
||||
optOutCheckbox.checked,
|
||||
"Checking the FHR checkbox checks the opt-out checkbox."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [
|
||||
[FHR_PREF, true],
|
||||
[OPT_OUT_PREF, true],
|
||||
],
|
||||
}),
|
||||
withPrivacyPrefs,
|
||||
async function testPrefWatchers(browser) {
|
||||
const optOutCheckbox = browser.contentDocument.getElementById("optOutStudiesEnabled");
|
||||
|
||||
Services.prefs.setBoolPref(OPT_OUT_PREF, false);
|
||||
ok(
|
||||
!optOutCheckbox.checked,
|
||||
"Disabling the opt-out pref unchecks the opt-out checkbox."
|
||||
);
|
||||
Services.prefs.setBoolPref(OPT_OUT_PREF, true);
|
||||
ok(
|
||||
optOutCheckbox.checked,
|
||||
"Enabling the opt-out pref checks the opt-out checkbox."
|
||||
);
|
||||
|
||||
Services.prefs.setBoolPref(FHR_PREF, false);
|
||||
ok(
|
||||
!Services.prefs.getBoolPref(OPT_OUT_PREF),
|
||||
"Disabling the FHR pref sets the opt-out pref to false."
|
||||
);
|
||||
ok(
|
||||
optOutCheckbox.disabled,
|
||||
"Disabling the FHR pref disables the opt-out checkbox."
|
||||
);
|
||||
ok(
|
||||
!optOutCheckbox.checked,
|
||||
"Disabling the FHR pref unchecks the opt-out checkbox."
|
||||
);
|
||||
|
||||
Services.prefs.setBoolPref(FHR_PREF, true);
|
||||
ok(
|
||||
Services.prefs.getBoolPref(OPT_OUT_PREF),
|
||||
"Enabling the FHR pref sets the opt-out pref to true."
|
||||
);
|
||||
ok(
|
||||
!optOutCheckbox.disabled,
|
||||
"Enabling the FHR pref enables the opt-out checkbox."
|
||||
);
|
||||
ok(
|
||||
optOutCheckbox.checked,
|
||||
"Enabling the FHR pref checks the opt-out checkbox."
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrivacyPrefs,
|
||||
async function testViewStudiesLink(browser) {
|
||||
browser.contentDocument.getElementById("viewShieldStudies").click();
|
||||
await BrowserTestUtils.waitForLocationChange(gBrowser);
|
||||
|
||||
is(
|
||||
browser.currentURI.spec,
|
||||
"about:studies",
|
||||
"Clicking the view studies link opens about:studies."
|
||||
);
|
||||
}
|
||||
);
|
|
@ -0,0 +1,184 @@
|
|||
"use strict";
|
||||
|
||||
Cu.import("resource://shield-recipe-client/lib/AddonStudies.jsm", this);
|
||||
Cu.import("resource://shield-recipe-client-content/AboutPages.jsm", this);
|
||||
|
||||
function withAboutStudies(testFunc) {
|
||||
return async (...args) => (
|
||||
BrowserTestUtils.withNewTab("about:studies", async browser => (
|
||||
testFunc(...args, browser)
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
decorate_task(
|
||||
withAboutStudies,
|
||||
async function testAboutStudiesWorks(browser) {
|
||||
ok(browser.contentDocument.getElementById("app"), "App element was found");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["extensions.shield-recipe-client.shieldLearnMoreUrl", "http://test/%OS%/"]],
|
||||
}),
|
||||
withAboutStudies,
|
||||
async function testLearnMore(browser) {
|
||||
ContentTask.spawn(browser, null, () => {
|
||||
content.document.getElementById("shield-studies-learn-more").click();
|
||||
});
|
||||
await BrowserTestUtils.waitForLocationChange(gBrowser);
|
||||
|
||||
const location = browser.currentURI.spec;
|
||||
is(
|
||||
location,
|
||||
AboutPages.aboutStudies.getShieldLearnMoreHref(),
|
||||
"Clicking Learn More opens the correct page on SUMO.",
|
||||
);
|
||||
ok(!location.includes("%OS%"), "The Learn More URL is formatted.");
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["browser.preferences.useOldOrganization", false]],
|
||||
}),
|
||||
withAboutStudies,
|
||||
async function testUpdatePreferencesNewOrganization(browser) {
|
||||
ContentTask.spawn(browser, null, () => {
|
||||
content.document.getElementById("shield-studies-update-preferences").click();
|
||||
});
|
||||
await BrowserTestUtils.waitForLocationChange(gBrowser);
|
||||
|
||||
is(
|
||||
browser.currentURI.spec,
|
||||
"about:preferences#privacy-reports",
|
||||
"Clicking Update Preferences opens the privacy section of the new about:prefernces.",
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
withPrefEnv({
|
||||
set: [["browser.preferences.useOldOrganization", true]],
|
||||
}),
|
||||
withAboutStudies,
|
||||
async function testUpdatePreferencesOldOrganization(browser) {
|
||||
// We have to use gBrowser instead of browser in most spots since we're
|
||||
// dealing with a new tab outside of the about:studies tab.
|
||||
const tab = await BrowserTestUtils.switchTab(gBrowser, () => {
|
||||
ContentTask.spawn(browser, null, () => {
|
||||
content.document.getElementById("shield-studies-update-preferences").click();
|
||||
});
|
||||
});
|
||||
|
||||
if (gBrowser.contentDocument.readyState !== "complete") {
|
||||
await BrowserTestUtils.waitForEvent(gBrowser.contentWindow, "load");
|
||||
}
|
||||
|
||||
const location = gBrowser.contentWindow.location.href;
|
||||
is(
|
||||
location,
|
||||
"about:preferences#advanced",
|
||||
"Clicking Update Preferences opens the advanced section of the old about:prefernces.",
|
||||
);
|
||||
|
||||
const dataChoicesTab = gBrowser.contentDocument.getElementById("dataChoicesTab");
|
||||
ok(
|
||||
dataChoicesTab.selected,
|
||||
"Click Update preferences selects the Data Choices tab in the old about:preferences."
|
||||
);
|
||||
|
||||
await BrowserTestUtils.removeTab(tab);
|
||||
}
|
||||
);
|
||||
|
||||
decorate_task(
|
||||
AddonStudies.withStudies([
|
||||
// Sort order should be study3, study1, study2 (order by enabled, then most recent).
|
||||
studyFactory({
|
||||
name: "A Fake Study",
|
||||
active: true,
|
||||
description: "A fake description",
|
||||
studyStartDate: new Date(2017),
|
||||
}),
|
||||
studyFactory({
|
||||
name: "B Fake Study",
|
||||
active: false,
|
||||
description: "A fake description",
|
||||
studyStartDate: new Date(2019),
|
||||
}),
|
||||
studyFactory({
|
||||
name: "C Fake Study",
|
||||
active: true,
|
||||
description: "A fake description",
|
||||
studyStartDate: new Date(2018),
|
||||
}),
|
||||
]),
|
||||
withAboutStudies,
|
||||
async function testStudyListing([study1, study2, study3], browser) {
|
||||
await ContentTask.spawn(browser, [study1, study2, study3], async ([cStudy1, cStudy2, cStudy3]) => {
|
||||
const doc = content.document;
|
||||
|
||||
function getStudyRow(docElem, studyName) {
|
||||
return docElem.querySelector(`.study[data-study-name="${studyName}"]`);
|
||||
}
|
||||
|
||||
await ContentTaskUtils.waitForCondition(() => doc.querySelectorAll(".study-list .study").length);
|
||||
const studyRows = doc.querySelectorAll(".study-list .study");
|
||||
|
||||
const names = Array.from(studyRows).map(row => row.querySelector(".study-name").textContent);
|
||||
Assert.deepEqual(
|
||||
names,
|
||||
[cStudy3.name, cStudy1.name, cStudy2.name],
|
||||
"Studies are sorted first by enabled status, and then by descending start date."
|
||||
);
|
||||
|
||||
const study1Row = getStudyRow(doc, cStudy1.name);
|
||||
ok(
|
||||
study1Row.querySelector(".study-description").textContent.includes(cStudy1.description),
|
||||
"Study descriptions are shown in about:studies."
|
||||
);
|
||||
is(
|
||||
study1Row.querySelector(".study-status").textContent,
|
||||
"Active",
|
||||
"Active studies show an 'Active' indicator."
|
||||
);
|
||||
ok(
|
||||
study1Row.querySelector(".remove-button"),
|
||||
"Active studies show a remove button"
|
||||
);
|
||||
is(
|
||||
study1Row.querySelector(".study-icon").textContent.toLowerCase(),
|
||||
"a",
|
||||
"Study icons use the first letter of the study name."
|
||||
);
|
||||
|
||||
const study2Row = getStudyRow(doc, cStudy2.name);
|
||||
is(
|
||||
study2Row.querySelector(".study-status").textContent,
|
||||
"Complete",
|
||||
"Inactive studies are marked as complete."
|
||||
);
|
||||
ok(
|
||||
!study2Row.querySelector(".remove-button"),
|
||||
"Inactive studies do not show a remove button"
|
||||
);
|
||||
|
||||
study1Row.querySelector(".remove-button").click();
|
||||
await ContentTaskUtils.waitForCondition(() => (
|
||||
getStudyRow(doc, cStudy1.name).matches(".disabled")
|
||||
));
|
||||
ok(
|
||||
getStudyRow(doc, cStudy1.name).matches(".disabled"),
|
||||
"Clicking the remove button updates the UI to show that the study has been disabled."
|
||||
);
|
||||
});
|
||||
|
||||
const updatedStudy1 = await AddonStudies.get(study1.recipeId);
|
||||
ok(
|
||||
!updatedStudy1.active,
|
||||
"Clicking the remove button marks the study as inactive in storage."
|
||||
);
|
||||
}
|
||||
);
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче