Source code
Revision control
Copy as Markdown
Other Tools
import {
ASRouterTargeting,
CachedTargetingGetter,
getSortedMessages,
QueryCache,
} from "modules/ASRouterTargeting.sys.mjs";
import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs";
import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs";
import { GlobalOverrider } from "test/unit/utils";
// Note that tests for the ASRouterTargeting environment can be found in
// test/functional/mochitest/browser_asrouter_targeting.js
describe("#CachedTargetingGetter", () => {
const sixHours = 6 * 60 * 60 * 1000;
let sandbox;
let clock;
let frecentStub;
let topsitesCache;
let globals;
let doesAppNeedPinStub;
let getAddonsByTypesStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
clock = sinon.useFakeTimers();
frecentStub = sandbox.stub(
global.NewTabUtils.activityStreamProvider,
"getTopFrecentSites"
);
topsitesCache = new CachedTargetingGetter("getTopFrecentSites");
globals = new GlobalOverrider();
globals.set(
"TargetingContext",
class {
static combineContexts() {
return sinon.stub();
}
evalWithDefault() {
return sinon.stub();
}
}
);
doesAppNeedPinStub = sandbox.stub().resolves();
getAddonsByTypesStub = sandbox.stub().resolves();
});
afterEach(() => {
sandbox.restore();
clock.restore();
globals.restore();
});
it("should cache allow for optional getter argument", async () => {
let pinCachedGetter = new CachedTargetingGetter(
"doesAppNeedPin",
true,
undefined,
{ doesAppNeedPin: doesAppNeedPinStub }
);
// Need to tick forward because Date.now() is stubbed
clock.tick(sixHours);
await pinCachedGetter.get();
await pinCachedGetter.get();
await pinCachedGetter.get();
// Called once; cached request
assert.calledOnce(doesAppNeedPinStub);
// Called with option argument
assert.calledWith(doesAppNeedPinStub, true);
// Expire and call again
clock.tick(sixHours);
await pinCachedGetter.get();
// Call goes through
assert.calledTwice(doesAppNeedPinStub);
let themesCachedGetter = new CachedTargetingGetter(
"getAddonsByTypes",
["foo"],
undefined,
{ getAddonsByTypes: getAddonsByTypesStub }
);
// Need to tick forward because Date.now() is stubbed
clock.tick(sixHours);
await themesCachedGetter.get();
await themesCachedGetter.get();
await themesCachedGetter.get();
// Called once; cached request
assert.calledOnce(getAddonsByTypesStub);
// Called with option argument
assert.calledWith(getAddonsByTypesStub, ["foo"]);
// Expire and call again
clock.tick(sixHours);
await themesCachedGetter.get();
// Call goes through
assert.calledTwice(getAddonsByTypesStub);
});
it("should only make a request every 6 hours", async () => {
frecentStub.resolves();
clock.tick(sixHours);
await topsitesCache.get();
await topsitesCache.get();
assert.calledOnce(
global.NewTabUtils.activityStreamProvider.getTopFrecentSites
);
clock.tick(sixHours);
await topsitesCache.get();
assert.calledTwice(
global.NewTabUtils.activityStreamProvider.getTopFrecentSites
);
});
it("throws when failing getter", async () => {
frecentStub.rejects(new Error("fake error"));
clock.tick(sixHours);
// assert.throws expect a function as the first parameter, try/catch is a
// workaround
let rejected = false;
try {
await topsitesCache.get();
} catch (e) {
rejected = true;
}
assert(rejected);
});
describe("sortMessagesByPriority", () => {
it("should sort messages in descending priority order", async () => {
const [m1, m2, m3 = { id: "m3" }] =
await OnboardingMessageProvider.getUntranslatedMessages();
const checkMessageTargetingStub = sandbox
.stub(ASRouterTargeting, "checkMessageTargeting")
.resolves(false);
sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
await ASRouterTargeting.findMatchingMessage({
messages: [
{ ...m1, priority: 0 },
{ ...m2, priority: 1 },
{ ...m3, priority: 2 },
],
trigger: "testing",
});
assert.equal(checkMessageTargetingStub.callCount, 3);
const [arg_m1] = checkMessageTargetingStub.firstCall.args;
assert.equal(arg_m1.id, m3.id);
const [arg_m2] = checkMessageTargetingStub.secondCall.args;
assert.equal(arg_m2.id, m2.id);
const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
assert.equal(arg_m3.id, m1.id);
});
it("should sort messages with no priority last", async () => {
const [m1, m2, m3 = { id: "m3" }] =
await OnboardingMessageProvider.getUntranslatedMessages();
const checkMessageTargetingStub = sandbox
.stub(ASRouterTargeting, "checkMessageTargeting")
.resolves(false);
sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
await ASRouterTargeting.findMatchingMessage({
messages: [
{ ...m1, priority: 0 },
{ ...m2, priority: undefined },
{ ...m3, priority: 2 },
],
trigger: "testing",
});
assert.equal(checkMessageTargetingStub.callCount, 3);
const [arg_m1] = checkMessageTargetingStub.firstCall.args;
assert.equal(arg_m1.id, m3.id);
const [arg_m2] = checkMessageTargetingStub.secondCall.args;
assert.equal(arg_m2.id, m1.id);
const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
assert.equal(arg_m3.id, m2.id);
});
it("should keep the order of messages with same priority unchanged", async () => {
const [m1, m2, m3 = { id: "m3" }] =
await OnboardingMessageProvider.getUntranslatedMessages();
const checkMessageTargetingStub = sandbox
.stub(ASRouterTargeting, "checkMessageTargeting")
.resolves(false);
sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true);
await ASRouterTargeting.findMatchingMessage({
messages: [
{ ...m1, priority: 2, targeting: undefined, rank: 1 },
{ ...m2, priority: undefined, targeting: undefined, rank: 1 },
{ ...m3, priority: 2, targeting: undefined, rank: 1 },
],
trigger: "testing",
});
assert.equal(checkMessageTargetingStub.callCount, 3);
const [arg_m1] = checkMessageTargetingStub.firstCall.args;
assert.equal(arg_m1.id, m1.id);
const [arg_m2] = checkMessageTargetingStub.secondCall.args;
assert.equal(arg_m2.id, m3.id);
const [arg_m3] = checkMessageTargetingStub.thirdCall.args;
assert.equal(arg_m3.id, m2.id);
});
});
});
describe("#isTriggerMatch", () => {
let trigger;
let message;
beforeEach(() => {
trigger = { id: "openURL" };
message = { id: "openURL" };
});
it("should return false if trigger and candidate ids are different", () => {
trigger.id = "trigger";
message.id = "message";
assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message));
assert.isTrue(
ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" })
);
});
it("should return true if the message we check doesn't have trigger params or patterns", () => {
// No params or patterns defined
assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message));
});
it("should return false if the trigger does not have params defined", () => {
message.params = {};
// trigger.param is undefined
assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message));
});
it("should return true if message params includes trigger host", () => {
message.params = ["mozilla.org"];
trigger.param = { host: "mozilla.org" };
assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message));
});
it("should return true if message params includes trigger param.type", () => {
message.params = ["ContentBlockingMilestone"];
trigger.param = { type: "ContentBlockingMilestone" };
assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message)));
});
it("should return true if message params match trigger mask", () => {
// STATE_BLOCKED_FINGERPRINTING_CONTENT
message.params = [0x00000040];
trigger.param = { type: 538091584 };
assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message)));
});
});
describe("#CacheListAttachedOAuthClients", () => {
const fourHours = 4 * 60 * 60 * 1000;
let sandbox;
let clock;
let fakeFxAccount;
let authClientsCache;
let globals;
beforeEach(() => {
globals = new GlobalOverrider();
sandbox = sinon.createSandbox();
clock = sinon.useFakeTimers();
fakeFxAccount = {
listAttachedOAuthClients: () => {},
};
globals.set("fxAccounts", fakeFxAccount);
authClientsCache = QueryCache.queries.ListAttachedOAuthClients;
sandbox
.stub(global.fxAccounts, "listAttachedOAuthClients")
.returns(Promise.resolve({}));
});
afterEach(() => {
authClientsCache.expire();
sandbox.restore();
clock.restore();
});
it("should only make additional request every 4 hours", async () => {
clock.tick(fourHours);
await authClientsCache.get();
assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
clock.tick(fourHours);
await authClientsCache.get();
assert.calledTwice(global.fxAccounts.listAttachedOAuthClients);
});
it("should not make additional request before 4 hours", async () => {
clock.tick(fourHours);
await authClientsCache.get();
assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
await authClientsCache.get();
assert.calledOnce(global.fxAccounts.listAttachedOAuthClients);
});
});
describe("ASRouterTargeting", () => {
let evalStub;
let sandbox;
let clock;
let globals;
let fakeTargetingContext;
beforeEach(() => {
sandbox = sinon.createSandbox();
sandbox.replace(ASRouterTargeting, "Environment", {});
clock = sinon.useFakeTimers();
fakeTargetingContext = {
combineContexts: sandbox.stub(),
evalWithDefault: sandbox.stub().resolves(),
setTelemetrySource: sandbox.stub(),
};
globals = new GlobalOverrider();
globals.set(
"TargetingContext",
class {
static combineContexts(...args) {
return fakeTargetingContext.combineContexts.apply(sandbox, args);
}
setTelemetrySource(id) {
fakeTargetingContext.setTelemetrySource(id);
}
evalWithDefault(expr) {
return fakeTargetingContext.evalWithDefault(expr);
}
}
);
evalStub = fakeTargetingContext.evalWithDefault;
});
afterEach(() => {
clock.restore();
sandbox.restore();
globals.restore();
});
it("should provide message.id as source", async () => {
await ASRouterTargeting.checkMessageTargeting(
{
id: "message",
targeting: "true",
},
fakeTargetingContext,
sandbox.stub(),
false
);
assert.calledOnce(fakeTargetingContext.evalWithDefault);
assert.calledWithExactly(fakeTargetingContext.evalWithDefault, "true");
assert.calledWithExactly(
fakeTargetingContext.setTelemetrySource,
"message"
);
});
it("should cache evaluation result", async () => {
evalStub.resolves(true);
let targetingContext = new global.TargetingContext();
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl1" },
targetingContext,
sandbox.stub(),
true
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl2" },
targetingContext,
sandbox.stub(),
true
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl1" },
targetingContext,
sandbox.stub(),
true
);
assert.calledTwice(evalStub);
});
it("should not cache evaluation result", async () => {
evalStub.resolves(true);
let targetingContext = new global.TargetingContext();
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
targetingContext,
sandbox.stub(),
false
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
targetingContext,
sandbox.stub(),
false
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
targetingContext,
sandbox.stub(),
false
);
assert.calledThrice(evalStub);
});
it("should expire cache entries", async () => {
evalStub.resolves(true);
let targetingContext = new global.TargetingContext();
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
targetingContext,
sandbox.stub(),
true
);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
targetingContext,
sandbox.stub(),
true
);
clock.tick(5 * 60 * 1000 + 1);
await ASRouterTargeting.checkMessageTargeting(
{ targeting: "jexl" },
targetingContext,
sandbox.stub(),
true
);
assert.calledTwice(evalStub);
});
describe("#findMatchingMessage", () => {
let matchStub;
let messages = [
{ id: "FOO", targeting: "match" },
{ id: "BAR", targeting: "match" },
{ id: "BAZ" },
];
beforeEach(() => {
matchStub = sandbox
.stub(ASRouterTargeting, "_isMessageMatch")
.callsFake(message => message.targeting === "match");
});
it("should return an array of matches if returnAll is true", async () => {
assert.deepEqual(
await ASRouterTargeting.findMatchingMessage({
messages,
returnAll: true,
}),
[
{ id: "FOO", targeting: "match" },
{ id: "BAR", targeting: "match" },
]
);
});
it("should return an empty array if no matches were found and returnAll is true", async () => {
matchStub.returns(false);
assert.deepEqual(
await ASRouterTargeting.findMatchingMessage({
messages,
returnAll: true,
}),
[]
);
});
it("should return the first match if returnAll is false", async () => {
assert.deepEqual(
await ASRouterTargeting.findMatchingMessage({
messages,
}),
messages[0]
);
});
it("should return null if if no matches were found and returnAll is false", async () => {
matchStub.returns(false);
assert.deepEqual(
await ASRouterTargeting.findMatchingMessage({
messages,
}),
null
);
});
});
});
/**
* Messages should be sorted in the following order:
* 1. Rank
* 2. Priority
* 3. If the message has targeting
* 4. Order or randomization, depending on input
*/
describe("getSortedMessages", () => {
let globals = new GlobalOverrider();
let sandbox;
beforeEach(() => {
globals.set({ ASRouterPreferences });
sandbox = sinon.createSandbox();
});
afterEach(() => {
sandbox.restore();
globals.restore();
});
/**
* assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages,
* returns the items in the expected order.
*
* @param {Message[]} expectedOrderArray - The array of messages in its expected order
* @param {{}} options - The options param for getSortedMessages
* @returns
*/
function assertSortsCorrectly(expectedOrderArray, options) {
const input = [...expectedOrderArray].reverse();
const result = getSortedMessages(input, options);
const indexes = result.map(message => expectedOrderArray.indexOf(message));
return assert.equal(
indexes.join(","),
[...expectedOrderArray.keys()].join(","),
"Messsages are out of order"
);
}
it("should sort messages by priority, then by targeting", () => {
assertSortsCorrectly([
{ priority: 100, targeting: "isFoo" },
{ priority: 100 },
{ priority: 99 },
{ priority: 1, targeting: "isFoo" },
{ priority: 1 },
{},
]);
});
it("should sort messages by priority, then targeting, then order if ordered param is true", () => {
assertSortsCorrectly(
[
{ priority: 100, order: 4 },
{ priority: 100, order: 5 },
{ priority: 1, order: 3, targeting: "isFoo" },
{ priority: 1, order: 0 },
{ priority: 1, order: 1 },
{ priority: 1, order: 2 },
{ order: 0 },
],
{ ordered: true }
);
});
});