summaryrefslogtreecommitdiffstats
path: root/browser/components/urlbar/tests/quicksuggest/browser/browser_telemetry_impressionEdgeCases.js
blob: c52d22a886d1d56eb426cb0c955c453c34f8b32e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
/* Any copyright is dedicated to the Public Domain.
 * http://creativecommons.org/publicdomain/zero/1.0/ */

/**
 * This file tests abandonment and edge cases related to impressions.
 */

"use strict";

ChromeUtils.defineESModuleGetters(this, {
  CONTEXTUAL_SERVICES_PING_TYPES:
    "resource:///modules/PartnerLinkAttribution.sys.mjs",
  UrlbarView: "resource:///modules/UrlbarView.sys.mjs",
  sinon: "resource://testing-common/Sinon.sys.mjs",
});

const { TELEMETRY_SCALARS } = UrlbarProviderQuickSuggest;

const REMOTE_SETTINGS_RESULTS = [
  {
    id: 1,
    url: "https://example.com/sponsored",
    title: "Sponsored suggestion",
    keywords: ["sponsored"],
    click_url: "https://example.com/click",
    impression_url: "https://example.com/impression",
    advertiser: "testadvertiser",
  },
  {
    id: 2,
    url: "https://example.com/nonsponsored",
    title: "Non-sponsored suggestion",
    keywords: ["nonsponsored"],
    click_url: "https://example.com/click",
    impression_url: "https://example.com/impression",
    advertiser: "testadvertiser",
    iab_category: "5 - Education",
  },
];

const SPONSORED_RESULT = REMOTE_SETTINGS_RESULTS[0];

// Spy for the custom impression/click sender
let spy;

add_setup(async function () {
  ({ spy } = QuickSuggestTestUtils.createTelemetryPingSpy());

  await PlacesUtils.history.clear();
  await PlacesUtils.bookmarks.eraseEverything();
  await UrlbarTestUtils.formHistory.clear();

  Services.telemetry.clearScalars();
  Services.telemetry.clearEvents();

  // Add a mock engine so we don't hit the network.
  await SearchTestUtils.installSearchExtension({}, { setAsDefault: true });

  await QuickSuggestTestUtils.ensureQuickSuggestInit({
    remoteSettingsResults: [
      {
        type: "data",
        attachment: REMOTE_SETTINGS_RESULTS,
      },
    ],
  });
});

// Makes sure impression telemetry is not recorded when the urlbar engagement is
// abandoned.
add_task(async function abandonment() {
  Services.telemetry.clearEvents();
  await UrlbarTestUtils.promiseAutocompleteResultPopup({
    window,
    value: "sponsored",
    fireInputEvent: true,
  });
  await QuickSuggestTestUtils.assertIsQuickSuggest({
    window,
    url: SPONSORED_RESULT.url,
  });
  await UrlbarTestUtils.promisePopupClose(window, () => {
    gURLBar.blur();
  });
  QuickSuggestTestUtils.assertScalars({});
  QuickSuggestTestUtils.assertEvents([]);
  QuickSuggestTestUtils.assertPings(spy, []);
});

// Makes sure impression telemetry is not recorded when a quick suggest result
// is not present.
add_task(async function noQuickSuggestResult() {
  await BrowserTestUtils.withNewTab("about:blank", async () => {
    Services.telemetry.clearEvents();
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "noImpression_noQuickSuggestResult",
      fireInputEvent: true,
    });
    await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
    await UrlbarTestUtils.promisePopupClose(window, () => {
      EventUtils.synthesizeKey("KEY_Enter");
    });
    QuickSuggestTestUtils.assertScalars({});
    QuickSuggestTestUtils.assertEvents([]);
    QuickSuggestTestUtils.assertPings(spy, []);
  });
  await PlacesUtils.history.clear();
});

// When a quick suggest result is added to the view but hidden during the view
// update, impression telemetry should not be recorded for it.
add_task(async function hiddenRow() {
  Services.telemetry.clearEvents();

  // Increase the timeout of the remove-stale-rows timer so that it doesn't
  // interfere with this task.
  let originalRemoveStaleRowsTimeout = UrlbarView.removeStaleRowsTimeout;
  UrlbarView.removeStaleRowsTimeout = 30000;
  registerCleanupFunction(() => {
    UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout;
  });

  // Set up a test provider that doesn't add any results until we resolve its
  // `finishQueryPromise`. For the first search below, it will add many search
  // suggestions.
  let maxCount = UrlbarPrefs.get("maxRichResults");
  let results = [];
  for (let i = 0; i < maxCount; i++) {
    results.push(
      new UrlbarResult(
        UrlbarUtils.RESULT_TYPE.SEARCH,
        UrlbarUtils.RESULT_SOURCE.SEARCH,
        {
          engine: "Example",
          suggestion: "suggestion " + i,
          lowerCaseSuggestion: "suggestion " + i,
          query: "test",
        }
      )
    );
  }
  let provider = new DelayingTestProvider({ results });
  UrlbarProvidersManager.registerProvider(provider);

  // Open a new tab since we'll load a page below.
  let tab = await BrowserTestUtils.openNewForegroundTab({ gBrowser });

  // Do a normal search and allow the test provider to finish.
  provider.finishQueryPromise = Promise.resolve();
  await UrlbarTestUtils.promiseAutocompleteResultPopup({
    window,
    value: "test",
    fireInputEvent: true,
  });

  // Sanity check the rows. After the heuristic, the remaining rows should be
  // the search results added by the test provider.
  Assert.equal(
    UrlbarTestUtils.getResultCount(window),
    maxCount,
    "Row count after first search"
  );
  for (let i = 1; i < maxCount; i++) {
    let result = await UrlbarTestUtils.getDetailsOfResultAt(window, i);
    Assert.equal(
      result.type,
      UrlbarUtils.RESULT_TYPE.SEARCH,
      "Expected result type at index " + i
    );
    Assert.equal(
      result.source,
      UrlbarUtils.RESULT_SOURCE.SEARCH,
      "Expected result source at index " + i
    );
  }

  // Now set up a second search that triggers a quick suggest result. Add a
  // mutation listener to the view so we can tell when the quick suggest row is
  // added.
  let mutationPromise = new Promise(resolve => {
    let observer = new MutationObserver(mutations => {
      let rows = UrlbarTestUtils.getResultsContainer(window).children;
      for (let row of rows) {
        if (row.result.providerName == "UrlbarProviderQuickSuggest") {
          observer.disconnect();
          resolve(row);
          return;
        }
      }
    });
    observer.observe(UrlbarTestUtils.getResultsContainer(window), {
      childList: true,
    });
  });

  // Set the test provider's `finishQueryPromise` to a promise that doesn't
  // resolve. That will prevent the search from completing, which will prevent
  // the view from removing stale rows and showing the quick suggest row.
  let resolveQuery;
  provider.finishQueryPromise = new Promise(
    resolve => (resolveQuery = resolve)
  );

  // Start the second search but don't wait for it to finish.
  gURLBar.focus();
  let queryPromise = UrlbarTestUtils.promiseAutocompleteResultPopup({
    window,
    value: REMOTE_SETTINGS_RESULTS[0].keywords[0],
    fireInputEvent: true,
  });

  // Wait for the quick suggest row to be added to the view. It should be hidden
  // because (a) quick suggest results have a `suggestedIndex`, and rows with
  // suggested indexes can't replace rows without suggested indexes, and (b) the
  // view already contains the maximum number of rows due to the first search.
  // It should remain hidden until the search completes or the remove-stale-rows
  // timer fires. Next, we'll hit enter, which will cancel the search and close
  // the view, so the row should never appear.
  let quickSuggestRow = await mutationPromise;
  Assert.ok(
    BrowserTestUtils.is_hidden(quickSuggestRow),
    "Quick suggest row is hidden"
  );

  // Hit enter to pick the heuristic search result. This will cancel the search
  // and notify the quick suggest provider that an engagement occurred.
  let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
  await UrlbarTestUtils.promisePopupClose(window, () => {
    EventUtils.synthesizeKey("KEY_Enter");
  });
  await loadPromise;

  // Resolve the test provider's promise finally.
  resolveQuery();
  await queryPromise;

  // The quick suggest provider added a result but it wasn't visible in the
  // view. No impression telemetry should be recorded for it.
  QuickSuggestTestUtils.assertScalars({});
  QuickSuggestTestUtils.assertEvents([]);
  QuickSuggestTestUtils.assertPings(spy, []);

  BrowserTestUtils.removeTab(tab);
  UrlbarProvidersManager.unregisterProvider(provider);
  UrlbarView.removeStaleRowsTimeout = originalRemoveStaleRowsTimeout;
});

// When a quick suggest result has not been added to the view, impression
// telemetry should not be recorded for it even if it's the result most recently
// returned by the provider.
add_task(async function notAddedToView() {
  Services.telemetry.clearEvents();

  // Open a new tab since we'll load a page.
  await BrowserTestUtils.withNewTab("about:blank", async () => {
    // Do an initial search that doesn't match any suggestions to make sure
    // there aren't any quick suggest results in the view to start.
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: "this doesn't match anything",
      fireInputEvent: true,
    });
    await QuickSuggestTestUtils.assertNoQuickSuggestResults(window);
    await UrlbarTestUtils.promisePopupClose(window);

    // Now do a search for a suggestion and hit enter after the provider adds it
    // but before it appears in the view.
    await doEngagementWithoutAddingResultToView(
      REMOTE_SETTINGS_RESULTS[0].keywords[0]
    );

    // The quick suggest provider added a result but it wasn't visible in the
    // view, and no other quick suggest results were visible in the view. No
    // impression telemetry should be recorded.
    QuickSuggestTestUtils.assertScalars({});
    QuickSuggestTestUtils.assertEvents([]);
    QuickSuggestTestUtils.assertPings(spy, []);
  });
});

// When a quick suggest result is visible in the view, impression telemetry
// should be recorded for it even if it's not the result most recently returned
// by the provider.
add_task(async function previousResultStillVisible() {
  Services.telemetry.clearEvents();

  // Open a new tab since we'll load a page.
  await BrowserTestUtils.withNewTab("about:blank", async () => {
    // Do a search for the first suggestion.
    let firstSuggestion = REMOTE_SETTINGS_RESULTS[0];
    await UrlbarTestUtils.promiseAutocompleteResultPopup({
      window,
      value: firstSuggestion.keywords[0],
      fireInputEvent: true,
    });

    let index = 1;
    await QuickSuggestTestUtils.assertIsQuickSuggest({
      window,
      index,
      url: firstSuggestion.url,
    });

    // Without closing the view, do a second search for the second suggestion
    // and hit enter after the provider adds it but before it appears in the
    // view.
    await doEngagementWithoutAddingResultToView(
      REMOTE_SETTINGS_RESULTS[1].keywords[0],
      index
    );

    // An impression for the first suggestion should be recorded since it's
    // still visible in the view, not the second suggestion.
    QuickSuggestTestUtils.assertScalars({
      [TELEMETRY_SCALARS.IMPRESSION_SPONSORED]: index + 1,
    });
    QuickSuggestTestUtils.assertEvents([
      {
        category: QuickSuggest.TELEMETRY_EVENT_CATEGORY,
        method: "engagement",
        object: "impression_only",
        extra: {
          match_type: "firefox-suggest",
          position: String(index + 1),
          suggestion_type: "sponsored",
        },
      },
    ]);
    QuickSuggestTestUtils.assertPings(spy, [
      {
        type: CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION,
        payload: {
          improve_suggest_experience_checked: false,
          block_id: firstSuggestion.id,
          is_clicked: false,
          match_type: "firefox-suggest",
          position: index + 1,
        },
      },
    ]);
  });
});

/**
 * Does a search that causes the quick suggest provider to return a result
 * without adding it to the view and then hits enter to load a SERP and create
 * an engagement.
 *
 * @param {string} searchString
 *   The search string.
 * @param {number} previousResultIndex
 *   If the view is already open and showing a quick suggest result, pass its
 *   index here. Otherwise pass -1.
 */
async function doEngagementWithoutAddingResultToView(
  searchString,
  previousResultIndex = -1
) {
  // Set the timeout of the chunk timer to a really high value so that it will
  // not fire. The view updates when the timer fires, which we specifically want
  // to avoid here.
  let originalChunkDelayMs = UrlbarProvidersManager._chunkResultsDelayMs;
  UrlbarProvidersManager._chunkResultsDelayMs = 30000;
  registerCleanupFunction(() => {
    UrlbarProvidersManager._chunkResultsDelayMs = originalChunkDelayMs;
  });

  // Stub `UrlbarProviderQuickSuggest.getPriority()` to return Infinity.
  let sandbox = sinon.createSandbox();
  let getPriorityStub = sandbox.stub(UrlbarProviderQuickSuggest, "getPriority");
  getPriorityStub.returns(Infinity);

  // Spy on `UrlbarProviderQuickSuggest.onEngagement()`.
  let onEngagementSpy = sandbox.spy(UrlbarProviderQuickSuggest, "onEngagement");

  let sandboxCleanup = () => {
    getPriorityStub?.restore();
    getPriorityStub = null;
    sandbox?.restore();
    sandbox = null;
  };
  registerCleanupFunction(sandboxCleanup);

  // In addition to setting the chunk timeout to a large value above, in order
  // to prevent the view from updating there also needs to be a heuristic
  // provider that takes a long time to add results. Set one up that doesn't add
  // any results until we resolve its `finishQueryPromise`. Set its priority to
  // Infinity too so that only it and the quick suggest provider will be active.
  let provider = new DelayingTestProvider({
    results: [],
    priority: Infinity,
    type: UrlbarUtils.PROVIDER_TYPE.HEURISTIC,
  });
  UrlbarProvidersManager.registerProvider(provider);

  let resolveQuery;
  provider.finishQueryPromise = new Promise(r => (resolveQuery = r));

  // Add a query listener so we can grab the query context.
  let context;
  let queryListener = {
    onQueryStarted: c => (context = c),
  };
  gURLBar.controller.addQueryListener(queryListener);

  // Do a search but don't wait for it to finish.
  gURLBar.focus();
  UrlbarTestUtils.promiseAutocompleteResultPopup({
    window,
    value: searchString,
    fireInputEvent: true,
  });

  // Wait for the quick suggest provider to add its result to `context.unsortedResults`.
  let result = await TestUtils.waitForCondition(() => {
    let query = UrlbarProvidersManager.queries.get(context);
    return query?.unsortedResults.find(
      r => r.providerName == "UrlbarProviderQuickSuggest"
    );
  }, "Waiting for quick suggest result to be added to context.unsortedResults");

  gURLBar.controller.removeQueryListener(queryListener);

  // The view should not have updated, so the result's `rowIndex` should still
  // have its initial value of -1.
  Assert.equal(result.rowIndex, -1, "result.rowIndex is still -1");

  // If there's a result from the previous query, assert it's still in the
  // view. Otherwise assume that the view should be closed. These are mostly
  // sanity checks because they should only fail if the telemetry assertions
  // below also fail.
  if (previousResultIndex >= 0) {
    let rows = gURLBar.view.panel.querySelector(".urlbarView-results");
    Assert.equal(
      rows.children[previousResultIndex].result.providerName,
      "UrlbarProviderQuickSuggest",
      "Result already in view is a quick suggest"
    );
  } else {
    Assert.ok(!gURLBar.view.isOpen, "View is closed");
  }

  // Hit enter to load a SERP for the search string. This should notify the
  // quick suggest provider that an engagement occurred.
  let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
  await UrlbarTestUtils.promisePopupClose(window, () => {
    EventUtils.synthesizeKey("KEY_Enter");
  });
  await loadPromise;

  let engagementCalls = onEngagementSpy.getCalls().filter(call => {
    let state = call.args[1];
    return state == "engagement";
  });
  Assert.equal(engagementCalls.length, 1, "One engagement occurred");

  // Clean up.
  resolveQuery();
  UrlbarProvidersManager.unregisterProvider(provider);
  UrlbarProvidersManager._chunkResultsDelayMs = originalChunkDelayMs;
  sandboxCleanup();
}

/**
 * A test provider that doesn't finish `startQuery()` until `finishQueryPromise`
 * is resolved.
 */
class DelayingTestProvider extends UrlbarTestUtils.TestProvider {
  finishQueryPromise = null;
  async startQuery(context, addCallback) {
    for (let result of this._results) {
      addCallback(this, result);
    }
    await this.finishQueryPromise;
  }
}