Merge autoland to central, a=merge

MozReview-Commit-ID: IhZjTRz0dA5
This commit is contained in:
Wes Kocher 2017-08-18 13:10:54 -07:00
Родитель 8d084472a9 b40badc569
Коммит 5fa2384aeb
562 изменённых файлов: 17837 добавлений и 4225 удалений

Просмотреть файл

@ -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);
}

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 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"), // &bullet;
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 сгенерированный поставляемый
Просмотреть файл

@ -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 сгенерированный поставляемый
Просмотреть файл

@ -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 сгенерированный поставляемый
Просмотреть файл

@ -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 сгенерированный поставляемый
Просмотреть файл

@ -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 сгенерированный поставляемый
Просмотреть файл

@ -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 сгенерированный поставляемый
Просмотреть файл

@ -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 сгенерированный поставляемый
Просмотреть файл

@ -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 сгенерированный поставляемый
Просмотреть файл

@ -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 сгенерированный поставляемый
Просмотреть файл

@ -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."
);
}
);

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше