ASRouterTargeting.test.js (16927B)
1 import { 2 ASRouterTargeting, 3 CachedTargetingGetter, 4 getSortedMessages, 5 } from "modules/ASRouterTargeting.sys.mjs"; 6 import { OnboardingMessageProvider } from "modules/OnboardingMessageProvider.sys.mjs"; 7 import { ASRouterPreferences } from "modules/ASRouterPreferences.sys.mjs"; 8 import { GlobalOverrider } from "tests/unit/utils"; 9 10 // Note that tests for the ASRouterTargeting environment can be found in 11 // test/functional/mochitest/browser_asrouter_targeting.js 12 13 describe("#CachedTargetingGetter", () => { 14 const sixHours = 6 * 60 * 60 * 1000; 15 let sandbox; 16 let clock; 17 let frecentStub; 18 let topsitesCache; 19 let globals; 20 let doesAppNeedPinStub; 21 let getAddonsByTypesStub; 22 beforeEach(() => { 23 sandbox = sinon.createSandbox(); 24 clock = sinon.useFakeTimers(); 25 frecentStub = sandbox.stub( 26 global.NewTabUtils.activityStreamProvider, 27 "getTopFrecentSites" 28 ); 29 topsitesCache = new CachedTargetingGetter("getTopFrecentSites"); 30 globals = new GlobalOverrider(); 31 globals.set( 32 "TargetingContext", 33 class { 34 static combineContexts() { 35 return sinon.stub(); 36 } 37 38 evalWithDefault() { 39 return sinon.stub(); 40 } 41 } 42 ); 43 doesAppNeedPinStub = sandbox.stub().resolves(); 44 getAddonsByTypesStub = sandbox.stub().resolves(); 45 }); 46 47 afterEach(() => { 48 sandbox.restore(); 49 clock.restore(); 50 globals.restore(); 51 }); 52 53 it("should cache allow for optional getter argument", async () => { 54 let pinCachedGetter = new CachedTargetingGetter( 55 "doesAppNeedPin", 56 true, 57 undefined, 58 { doesAppNeedPin: doesAppNeedPinStub } 59 ); 60 // Need to tick forward because Date.now() is stubbed 61 clock.tick(sixHours); 62 63 await pinCachedGetter.get(); 64 await pinCachedGetter.get(); 65 await pinCachedGetter.get(); 66 67 // Called once; cached request 68 assert.calledOnce(doesAppNeedPinStub); 69 70 // Called with option argument 71 assert.calledWith(doesAppNeedPinStub, true); 72 73 // Expire and call again 74 clock.tick(sixHours); 75 await pinCachedGetter.get(); 76 77 // Call goes through 78 assert.calledTwice(doesAppNeedPinStub); 79 80 let themesCachedGetter = new CachedTargetingGetter( 81 "getAddonsByTypes", 82 ["foo"], 83 undefined, 84 { getAddonsByTypes: getAddonsByTypesStub } 85 ); 86 87 // Need to tick forward because Date.now() is stubbed 88 clock.tick(sixHours); 89 90 await themesCachedGetter.get(); 91 await themesCachedGetter.get(); 92 await themesCachedGetter.get(); 93 94 // Called once; cached request 95 assert.calledOnce(getAddonsByTypesStub); 96 97 // Called with option argument 98 assert.calledWith(getAddonsByTypesStub, ["foo"]); 99 100 // Expire and call again 101 clock.tick(sixHours); 102 await themesCachedGetter.get(); 103 104 // Call goes through 105 assert.calledTwice(getAddonsByTypesStub); 106 }); 107 108 it("should only make a request every 6 hours", async () => { 109 frecentStub.resolves(); 110 clock.tick(sixHours); 111 112 await topsitesCache.get(); 113 await topsitesCache.get(); 114 115 assert.calledOnce( 116 global.NewTabUtils.activityStreamProvider.getTopFrecentSites 117 ); 118 119 clock.tick(sixHours); 120 121 await topsitesCache.get(); 122 123 assert.calledTwice( 124 global.NewTabUtils.activityStreamProvider.getTopFrecentSites 125 ); 126 }); 127 it("throws when failing getter", async () => { 128 frecentStub.rejects(new Error("fake error")); 129 clock.tick(sixHours); 130 131 // assert.throws expect a function as the first parameter, try/catch is a 132 // workaround 133 let rejected = false; 134 try { 135 await topsitesCache.get(); 136 } catch (e) { 137 rejected = true; 138 } 139 140 assert(rejected); 141 }); 142 describe("sortMessagesByPriority", () => { 143 it("should sort messages in descending priority order", async () => { 144 const [m1, m2, m3 = { id: "m3" }] = 145 await OnboardingMessageProvider.getUntranslatedMessages(); 146 const checkMessageTargetingStub = sandbox 147 .stub(ASRouterTargeting, "checkMessageTargeting") 148 .resolves(false); 149 sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); 150 151 await ASRouterTargeting.findMatchingMessage({ 152 messages: [ 153 { ...m1, priority: 0 }, 154 { ...m2, priority: 1 }, 155 { ...m3, priority: 2 }, 156 ], 157 trigger: "testing", 158 }); 159 160 assert.equal(checkMessageTargetingStub.callCount, 3); 161 162 const [arg_m1] = checkMessageTargetingStub.firstCall.args; 163 assert.equal(arg_m1.id, m3.id); 164 165 const [arg_m2] = checkMessageTargetingStub.secondCall.args; 166 assert.equal(arg_m2.id, m2.id); 167 168 const [arg_m3] = checkMessageTargetingStub.thirdCall.args; 169 assert.equal(arg_m3.id, m1.id); 170 }); 171 it("should sort messages with no priority last", async () => { 172 const [m1, m2, m3 = { id: "m3" }] = 173 await OnboardingMessageProvider.getUntranslatedMessages(); 174 const checkMessageTargetingStub = sandbox 175 .stub(ASRouterTargeting, "checkMessageTargeting") 176 .resolves(false); 177 sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); 178 179 await ASRouterTargeting.findMatchingMessage({ 180 messages: [ 181 { ...m1, priority: 0 }, 182 { ...m2, priority: undefined }, 183 { ...m3, priority: 2 }, 184 ], 185 trigger: "testing", 186 }); 187 188 assert.equal(checkMessageTargetingStub.callCount, 3); 189 190 const [arg_m1] = checkMessageTargetingStub.firstCall.args; 191 assert.equal(arg_m1.id, m3.id); 192 193 const [arg_m2] = checkMessageTargetingStub.secondCall.args; 194 assert.equal(arg_m2.id, m1.id); 195 196 const [arg_m3] = checkMessageTargetingStub.thirdCall.args; 197 assert.equal(arg_m3.id, m2.id); 198 }); 199 it("should keep the order of messages with same priority unchanged", async () => { 200 const [m1, m2, m3 = { id: "m3" }] = 201 await OnboardingMessageProvider.getUntranslatedMessages(); 202 const checkMessageTargetingStub = sandbox 203 .stub(ASRouterTargeting, "checkMessageTargeting") 204 .resolves(false); 205 sandbox.stub(ASRouterTargeting, "isTriggerMatch").resolves(true); 206 207 await ASRouterTargeting.findMatchingMessage({ 208 messages: [ 209 { ...m1, priority: 2, targeting: undefined, rank: 1 }, 210 { ...m2, priority: undefined, targeting: undefined, rank: 1 }, 211 { ...m3, priority: 2, targeting: undefined, rank: 1 }, 212 ], 213 trigger: "testing", 214 }); 215 216 assert.equal(checkMessageTargetingStub.callCount, 3); 217 218 const [arg_m1] = checkMessageTargetingStub.firstCall.args; 219 assert.equal(arg_m1.id, m1.id); 220 221 const [arg_m2] = checkMessageTargetingStub.secondCall.args; 222 assert.equal(arg_m2.id, m3.id); 223 224 const [arg_m3] = checkMessageTargetingStub.thirdCall.args; 225 assert.equal(arg_m3.id, m2.id); 226 }); 227 }); 228 }); 229 describe("#isTriggerMatch", () => { 230 let trigger; 231 let message; 232 beforeEach(() => { 233 trigger = { id: "openURL" }; 234 message = { id: "openURL" }; 235 }); 236 it("should return false if trigger and candidate ids are different", () => { 237 trigger.id = "trigger"; 238 message.id = "message"; 239 240 assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); 241 assert.isTrue( 242 ASRouterTargeting.isTriggerMatch({ id: "foo" }, { id: "foo" }) 243 ); 244 }); 245 it("should return true if the message we check doesn't have trigger params or patterns", () => { 246 // No params or patterns defined 247 assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); 248 }); 249 it("should return false if the trigger does not have params defined", () => { 250 message.params = {}; 251 252 // trigger.param is undefined 253 assert.isFalse(ASRouterTargeting.isTriggerMatch(trigger, message)); 254 }); 255 it("should return true if message params includes trigger host", () => { 256 message.params = ["mozilla.org"]; 257 trigger.param = { host: "mozilla.org" }; 258 259 assert.isTrue(ASRouterTargeting.isTriggerMatch(trigger, message)); 260 }); 261 it("should return true if message params includes trigger param.type", () => { 262 message.params = ["ContentBlockingMilestone"]; 263 trigger.param = { type: "ContentBlockingMilestone" }; 264 265 assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); 266 }); 267 it("should return true if message params match trigger mask", () => { 268 // STATE_BLOCKED_FINGERPRINTING_CONTENT 269 message.params = [0x00000040]; 270 trigger.param = { type: 538091584 }; 271 272 assert.isTrue(Boolean(ASRouterTargeting.isTriggerMatch(trigger, message))); 273 }); 274 }); 275 describe("ASRouterTargeting", () => { 276 let evalStub; 277 let sandbox; 278 let clock; 279 let globals; 280 let fakeTargetingContext; 281 beforeEach(() => { 282 sandbox = sinon.createSandbox(); 283 sandbox.replace(ASRouterTargeting, "Environment", {}); 284 clock = sinon.useFakeTimers(); 285 fakeTargetingContext = { 286 combineContexts: sandbox.stub(), 287 evalWithDefault: sandbox.stub().resolves(), 288 setTelemetrySource: sandbox.stub(), 289 }; 290 globals = new GlobalOverrider(); 291 globals.set( 292 "TargetingContext", 293 class { 294 static combineContexts(...args) { 295 return fakeTargetingContext.combineContexts.apply(sandbox, args); 296 } 297 298 setTelemetrySource(id) { 299 fakeTargetingContext.setTelemetrySource(id); 300 } 301 302 evalWithDefault(expr) { 303 return fakeTargetingContext.evalWithDefault(expr); 304 } 305 } 306 ); 307 evalStub = fakeTargetingContext.evalWithDefault; 308 }); 309 afterEach(() => { 310 clock.restore(); 311 sandbox.restore(); 312 globals.restore(); 313 }); 314 it("should provide message.id as source", async () => { 315 await ASRouterTargeting.checkMessageTargeting( 316 { 317 id: "message", 318 targeting: "true", 319 }, 320 fakeTargetingContext, 321 sandbox.stub(), 322 false 323 ); 324 assert.calledOnce(fakeTargetingContext.evalWithDefault); 325 assert.include( 326 fakeTargetingContext.evalWithDefault.firstCall.args[0], 327 "!isAIWindow" 328 ); 329 assert.calledWithExactly( 330 fakeTargetingContext.setTelemetrySource, 331 "message" 332 ); 333 }); 334 it("should cache evaluation result", async () => { 335 evalStub.resolves(true); 336 let targetingContext = new global.TargetingContext(); 337 338 await ASRouterTargeting.checkMessageTargeting( 339 { targeting: "jexl1" }, 340 targetingContext, 341 sandbox.stub(), 342 true 343 ); 344 await ASRouterTargeting.checkMessageTargeting( 345 { targeting: "jexl2" }, 346 targetingContext, 347 sandbox.stub(), 348 true 349 ); 350 await ASRouterTargeting.checkMessageTargeting( 351 { targeting: "jexl1" }, 352 targetingContext, 353 sandbox.stub(), 354 true 355 ); 356 357 assert.calledTwice(evalStub); 358 }); 359 it("should not cache evaluation result", async () => { 360 evalStub.resolves(true); 361 let targetingContext = new global.TargetingContext(); 362 363 await ASRouterTargeting.checkMessageTargeting( 364 { targeting: "jexl" }, 365 targetingContext, 366 sandbox.stub(), 367 false 368 ); 369 await ASRouterTargeting.checkMessageTargeting( 370 { targeting: "jexl" }, 371 targetingContext, 372 sandbox.stub(), 373 false 374 ); 375 await ASRouterTargeting.checkMessageTargeting( 376 { targeting: "jexl" }, 377 targetingContext, 378 sandbox.stub(), 379 false 380 ); 381 382 assert.calledThrice(evalStub); 383 }); 384 it("should expire cache entries", async () => { 385 evalStub.resolves(true); 386 let targetingContext = new global.TargetingContext(); 387 388 await ASRouterTargeting.checkMessageTargeting( 389 { targeting: "jexl" }, 390 targetingContext, 391 sandbox.stub(), 392 true 393 ); 394 await ASRouterTargeting.checkMessageTargeting( 395 { targeting: "jexl" }, 396 targetingContext, 397 sandbox.stub(), 398 true 399 ); 400 clock.tick(5 * 60 * 1000 + 1); 401 await ASRouterTargeting.checkMessageTargeting( 402 { targeting: "jexl" }, 403 targetingContext, 404 sandbox.stub(), 405 true 406 ); 407 408 assert.calledTwice(evalStub); 409 }); 410 it("defaults to Classic-only targeting when no targeting is specified", async () => { 411 evalStub.resolves(true); 412 const targetingContext = new global.TargetingContext(); 413 const message = { id: "test-message" }; 414 415 await ASRouterTargeting.checkMessageTargeting( 416 message, 417 targetingContext, 418 null, 419 false 420 ); 421 422 assert.calledOnce(fakeTargetingContext.evalWithDefault); 423 assert.calledWith(fakeTargetingContext.evalWithDefault, "!isAIWindow"); 424 }); 425 it("blocks messages in AI windows by default via !isAIWindow", async () => { 426 evalStub.resolves(true); 427 const targetingContext = new global.TargetingContext(); 428 targetingContext.isAIWindow = false; 429 const message = { id: "test-message" }; 430 431 await ASRouterTargeting.checkMessageTargeting( 432 message, 433 targetingContext, 434 null, 435 false 436 ); 437 438 assert.calledOnce(fakeTargetingContext.evalWithDefault); 439 assert.calledWith(fakeTargetingContext.evalWithDefault, "!isAIWindow"); 440 }); 441 it("does not modify targeting that explicitly references isAIWindow", async () => { 442 evalStub.resolves(true); 443 const targetingContext = new global.TargetingContext(); 444 targetingContext.isAIWindow = true; 445 const message = { id: "test-message", targeting: "isAIWindow" }; 446 447 await ASRouterTargeting.checkMessageTargeting( 448 message, 449 targetingContext, 450 null, 451 false 452 ); 453 454 assert.calledOnce(fakeTargetingContext.evalWithDefault); 455 assert.calledWith(fakeTargetingContext.evalWithDefault, "isAIWindow"); 456 }); 457 458 describe("#findMatchingMessage", () => { 459 let matchStub; 460 let messages = [ 461 { id: "FOO", targeting: "match" }, 462 { id: "BAR", targeting: "match" }, 463 { id: "BAZ" }, 464 ]; 465 beforeEach(() => { 466 matchStub = sandbox 467 .stub(ASRouterTargeting, "_isMessageMatch") 468 .callsFake(message => message.targeting === "match"); 469 }); 470 it("should return an array of matches if returnAll is true", async () => { 471 assert.deepEqual( 472 await ASRouterTargeting.findMatchingMessage({ 473 messages, 474 returnAll: true, 475 }), 476 [ 477 { id: "FOO", targeting: "match" }, 478 { id: "BAR", targeting: "match" }, 479 ] 480 ); 481 }); 482 it("should return an empty array if no matches were found and returnAll is true", async () => { 483 matchStub.returns(false); 484 assert.deepEqual( 485 await ASRouterTargeting.findMatchingMessage({ 486 messages, 487 returnAll: true, 488 }), 489 [] 490 ); 491 }); 492 it("should return the first match if returnAll is false", async () => { 493 assert.deepEqual( 494 await ASRouterTargeting.findMatchingMessage({ 495 messages, 496 }), 497 messages[0] 498 ); 499 }); 500 it("should return null if if no matches were found and returnAll is false", async () => { 501 matchStub.returns(false); 502 assert.deepEqual( 503 await ASRouterTargeting.findMatchingMessage({ 504 messages, 505 }), 506 null 507 ); 508 }); 509 }); 510 }); 511 512 /** 513 * Messages should be sorted in the following order: 514 * 1. Rank 515 * 2. Priority 516 * 3. If the message has targeting 517 * 4. Order or randomization, depending on input 518 */ 519 describe("getSortedMessages", () => { 520 let globals = new GlobalOverrider(); 521 let sandbox; 522 beforeEach(() => { 523 globals.set({ ASRouterPreferences }); 524 sandbox = sinon.createSandbox(); 525 }); 526 afterEach(() => { 527 sandbox.restore(); 528 globals.restore(); 529 }); 530 531 /** 532 * assertSortsCorrectly - Tests to see if an array, when sorted with getSortedMessages, 533 * returns the items in the expected order. 534 * 535 * @param {Message[]} expectedOrderArray - The array of messages in its expected order 536 * @param {{}} options - The options param for getSortedMessages 537 * @returns 538 */ 539 function assertSortsCorrectly(expectedOrderArray, options) { 540 const input = [...expectedOrderArray].reverse(); 541 const result = getSortedMessages(input, options); 542 const indexes = result.map(message => expectedOrderArray.indexOf(message)); 543 return assert.equal( 544 indexes.join(","), 545 [...expectedOrderArray.keys()].join(","), 546 "Messsages are out of order" 547 ); 548 } 549 550 it("should sort messages by priority, then by targeting", () => { 551 assertSortsCorrectly([ 552 { priority: 100, targeting: "isFoo" }, 553 { priority: 100 }, 554 { priority: 99 }, 555 { priority: 1, targeting: "isFoo" }, 556 { priority: 1 }, 557 {}, 558 ]); 559 }); 560 it("should sort messages by priority, then targeting, then order if ordered param is true", () => { 561 assertSortsCorrectly( 562 [ 563 { priority: 100, order: 4 }, 564 { priority: 100, order: 5 }, 565 { priority: 1, order: 3, targeting: "isFoo" }, 566 { priority: 1, order: 0 }, 567 { priority: 1, order: 1 }, 568 { priority: 1, order: 2 }, 569 { order: 0 }, 570 ], 571 { ordered: true } 572 ); 573 }); 574 });