summaryrefslogtreecommitdiffstats
path: root/toolkit/mozapps/extensions/test/browser/browser_html_discover_view.js
blob: 9dcc3a0006cd1dc0cb5dc04ac39201db5de1c785 (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
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
/* eslint max-len: ["error", 80] */
"use strict";

loadTestSubscript("head_disco.js");

// The response to the discovery API, as documented at:
// https://addons-server.readthedocs.io/en/latest/topics/api/discovery.html
//
// The test is designed to easily verify whether the discopane works with the
// latest AMO API, by replacing API_RESPONSE_FILE's content with latest AMO API
// response, e.g. from https://addons.allizom.org/api/v4/discovery/?lang=en-US
// The response must contain at least one theme, and one extension.

const API_RESPONSE_FILE = PathUtils.join(
  Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
  // Trim empty component from splitting with trailing slash.
  ...RELATIVE_DIR.split("/").filter(c => c.length),
  "discovery",
  "api_response.json"
);

const AMO_TEST_HOST = "rewritten-for-testing.addons.allizom.org";

const ArrayBufferInputStream = Components.Constructor(
  "@mozilla.org/io/arraybuffer-input-stream;1",
  "nsIArrayBufferInputStream",
  "setData"
);

const amoServer = AddonTestUtils.createHttpServer({ hosts: [AMO_TEST_HOST] });

amoServer.registerFile(
  "/png",
  FileUtils.getFile(
    "CurWorkD",
    `${RELATIVE_DIR}discovery/small-1x1.png`.split("/")
  )
);
amoServer.registerPathHandler("/dummy", (request, response) => {
  response.write("Dummy");
});

// `result` is an element in the `results` array from AMO's discovery API,
// stored in API_RESPONSE_FILE.
function getTestExpectationFromApiResult(result) {
  return {
    typeIsTheme: result.addon.type === "statictheme",
    addonName: result.addon.name,
    authorName: result.addon.authors[0].name,
    editorialBody: result.description_text,
    dailyUsers: result.addon.average_daily_users,
    rating: result.addon.ratings.average,
  };
}

// A helper to declare a response to discovery API requests.
class DiscoveryAPIHandler {
  constructor(responseText) {
    this.setResponseText(responseText);
    this.requestCount = 0;

    // Overwrite the previous discovery response handler.
    amoServer.registerPathHandler("/discoapi", this);
  }

  setResponseText(responseText) {
    this.responseBody = new TextEncoder().encode(responseText).buffer;
  }

  // Suspend discovery API requests until unblockResponses is called.
  blockNextResponses() {
    this._unblockPromise = new Promise(resolve => {
      this.unblockResponses = resolve;
    });
  }

  unblockResponses(responseText) {
    throw new Error("You need to call blockNextResponses first!");
  }

  // nsIHttpRequestHandler::handle
  async handle(request, response) {
    ++this.requestCount;

    response.setHeader("Cache-Control", "no-cache", false);
    response.processAsync();
    await this._unblockPromise;

    let body = this.responseBody;
    let binStream = new ArrayBufferInputStream(body, 0, body.byteLength);
    response.bodyOutputStream.writeFrom(binStream, body.byteLength);
    response.finish();
  }
}

// Retrieve the list of visible action elements inside a document or container.
function getVisibleActions(documentOrElement) {
  return Array.from(documentOrElement.querySelectorAll("[action]")).filter(
    elem =>
      elem.getAttribute("action") !== "page-options" &&
      elem.offsetWidth &&
      elem.offsetHeight
  );
}

function getActionName(actionElement) {
  return actionElement.getAttribute("action");
}

function getCardByAddonId(win, addonId) {
  for (let card of win.document.querySelectorAll("recommended-addon-card")) {
    if (card.addonId === addonId) {
      return card;
    }
  }
  return null;
}

// Switch to a different view so we can switch back to the discopane later.
async function switchToNonDiscoView(win) {
  // Listeners registered while the discopane was the active view continue to be
  // active when the view switches to the extensions list, because both views
  // share the same document.
  win.gViewController.loadView("addons://list/extension");
  await wait_for_view_load(win);
  ok(
    win.document.querySelector("addon-list"),
    "Should be at the extension list view"
  );
}

// Switch to the discopane and wait until it has fully rendered, including any
// cards from the discovery API.
async function switchToDiscoView(win) {
  is(
    getDiscoveryElement(win),
    null,
    "Cannot switch to discopane when the discopane is already shown"
  );
  win.gViewController.loadView("addons://discover/");
  await wait_for_view_load(win);
  await promiseDiscopaneUpdate(win);
}

// Wait until all images in the DOM have successfully loaded.
// There must be at least one `<img>` in the document.
// Returns the number of loaded images.
async function waitForAllImagesLoaded(win) {
  let imgs = Array.from(
    win.document.querySelectorAll("discovery-pane img[src]")
  );
  function areAllImagesLoaded() {
    let loadCount = imgs.filter(img => img.naturalWidth).length;
    info(`Loaded ${loadCount} out of ${imgs.length} images`);
    return loadCount === imgs.length;
  }
  if (!areAllImagesLoaded()) {
    await promiseEvent(win.document, "load", true, areAllImagesLoaded);
  }
  return imgs.length;
}

// Install an add-on by clicking on the card.
// The promise resolves once the card has been updated.
async function testCardInstall(card) {
  Assert.deepEqual(
    getVisibleActions(card).map(getActionName),
    ["install-addon"],
    "Should have an Install button before install"
  );

  let installButton =
    card.querySelector("[data-l10n-id='install-extension-button']") ||
    card.querySelector("[data-l10n-id='install-theme-button']");

  let updatePromise = promiseEvent(card, "disco-card-updated");
  installButton.click();
  await updatePromise;

  Assert.deepEqual(
    getVisibleActions(card).map(getActionName),
    ["manage-addon"],
    "Should have a Manage button after install"
  );
}

// Uninstall the add-on (not via the card, since it has no uninstall button).
// The promise resolves once the card has been updated.
async function testAddonUninstall(card) {
  Assert.deepEqual(
    getVisibleActions(card).map(getActionName),
    ["manage-addon"],
    "Should have a Manage button before uninstall"
  );

  let addon = await AddonManager.getAddonByID(card.addonId);

  let updatePromise = promiseEvent(card, "disco-card-updated");
  await addon.uninstall();
  await updatePromise;

  Assert.deepEqual(
    getVisibleActions(card).map(getActionName),
    ["install-addon"],
    "Should have an Install button after uninstall"
  );
}

add_setup(async function () {
  await SpecialPowers.pushPrefEnv({
    set: [
      [
        "extensions.getAddons.discovery.api_url",
        `http://${AMO_TEST_HOST}/discoapi`,
      ],
      // Disable non-discopane recommendations to avoid unexpected discovery
      // API requests.
      ["extensions.htmlaboutaddons.recommendations.enabled", false],
      // Disable the telemetry client ID (and its associated UI warning).
      // browser_html_discover_view_clientid.js covers this functionality.
      ["browser.discovery.enabled", false],
    ],
  });
});

// Test that the discopane can be loaded and that meaningful results are shown.
// This relies on response data from the AMO API, stored in API_RESPONSE_FILE.
add_task(async function discopane_with_real_api_data() {
  const apiText = await readAPIResponseFixture(
    AMO_TEST_HOST,
    API_RESPONSE_FILE
  );
  let apiHandler = new DiscoveryAPIHandler(apiText);

  const apiResultArray = JSON.parse(apiText).results;
  ok(apiResultArray.length, `Mock has ${apiResultArray.length} results`);

  apiHandler.blockNextResponses();
  let win = await loadInitialView("discover");

  Assert.deepEqual(
    getVisibleActions(win.document).map(getActionName),
    [],
    "The AMO button should be invisible when the AMO API hasn't responded"
  );

  apiHandler.unblockResponses();
  await promiseDiscopaneUpdate(win);

  let actionElements = getVisibleActions(win.document);
  Assert.deepEqual(
    actionElements.map(getActionName),
    [
      // Expecting an install button for every result.
      ...new Array(apiResultArray.length).fill("install-addon"),
      "open-amo",
    ],
    "All add-on cards should be rendered, with AMO button at the end."
  );

  let imgCount = await waitForAllImagesLoaded(win);
  is(imgCount, apiResultArray.length, "Expected an image for every result");

  // Check that the cards have the expected content.
  let cards = Array.from(
    win.document.querySelectorAll("recommended-addon-card")
  );
  is(cards.length, apiResultArray.length, "Every API result has a card");
  for (let [i, card] of cards.entries()) {
    let expectations = getTestExpectationFromApiResult(apiResultArray[i]);
    info(`Expectations for card ${i}: ${JSON.stringify(expectations)}`);

    let checkContent = (selector, expectation) => {
      let text = card.querySelector(selector).textContent;
      is(text, expectation, `Content of selector "${selector}"`);
    };
    checkContent(".disco-addon-name", expectations.addonName);
    await win.document.l10n.translateFragment(card);
    checkContent(
      ".disco-addon-author [data-l10n-name='author']",
      expectations.authorName
    );

    let amoListingLink = card.querySelector(".disco-addon-author a");
    ok(
      amoListingLink.search.includes("utm_source=firefox-browser"),
      `Listing link should have attribution parameter, url=${amoListingLink}`
    );

    let actions = getVisibleActions(card);
    is(actions.length, 1, "Card should only have one install button");
    let installButton = actions[0];
    if (expectations.typeIsTheme) {
      // Theme button + screenshot
      ok(
        installButton.matches("[data-l10n-id='install-theme-button'"),
        "Has theme install button"
      );
      ok(
        card.querySelector(".card-heading-image").offsetWidth,
        "Preview image must be visible"
      );
    } else {
      // Extension button + extended description.
      ok(
        installButton.matches("[data-l10n-id='install-extension-button'"),
        "Has extension install button"
      );
      checkContent(".disco-description-main", expectations.editorialBody);

      let ratingElem = card.querySelector("five-star-rating");
      if (expectations.rating) {
        is(ratingElem.rating, expectations.rating, "Expected rating value");
        ok(ratingElem.offsetWidth, "Rating element is visible");
      } else {
        is(ratingElem.offsetWidth, 0, "Rating element is not visible");
      }

      let userCountElem = card.querySelector(".disco-user-count");
      if (expectations.dailyUsers) {
        Assert.deepEqual(
          win.document.l10n.getAttributes(userCountElem),
          { id: "user-count", args: { dailyUsers: expectations.dailyUsers } },
          "Card count should be rendered"
        );
      } else {
        is(userCountElem.offsetWidth, 0, "User count element is not visible");
      }
    }
  }

  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");

  await closeView(win);
});

// Test whether extensions and themes can be installed from the discopane.
// Also checks that items in the list do not change position after installation,
// and that they are shown at the bottom of the list when the discopane is
// reopened.
add_task(async function install_from_discopane() {
  const apiText = await readAPIResponseFixture(
    AMO_TEST_HOST,
    API_RESPONSE_FILE
  );
  const apiResultArray = JSON.parse(apiText).results;
  let getAddonIdByAMOAddonType = type =>
    apiResultArray.find(r => r.addon.type === type).addon.guid;
  const FIRST_EXTENSION_ID = getAddonIdByAMOAddonType("extension");
  const FIRST_THEME_ID = getAddonIdByAMOAddonType("statictheme");

  let apiHandler = new DiscoveryAPIHandler(apiText);

  let win = await loadInitialView("discover");
  await promiseDiscopaneUpdate(win);
  await waitForAllImagesLoaded(win);

  // Test extension install.
  let installExtensionPromise = promiseAddonInstall(amoServer, {
    manifest: {
      name: "My Awesome Add-on",
      description: "Test extension install button",
      browser_specific_settings: { gecko: { id: FIRST_EXTENSION_ID } },
      permissions: ["<all_urls>"],
    },
  });
  await testCardInstall(getCardByAddonId(win, FIRST_EXTENSION_ID));
  await installExtensionPromise;

  // Test theme install.
  let installThemePromise = promiseAddonInstall(amoServer, {
    manifest: {
      name: "My Fancy Theme",
      description: "Test theme install button",
      browser_specific_settings: { gecko: { id: FIRST_THEME_ID } },
      theme: {
        colors: {
          tab_selected: "red",
        },
      },
    },
  });
  let promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
  await testCardInstall(getCardByAddonId(win, FIRST_THEME_ID));
  await installThemePromise;
  await promiseThemeChange;

  // After installing, the cards should have manage buttons instead of install
  // buttons. The cards should still be at the top of the pane (and not be
  // moved to the bottom).
  Assert.deepEqual(
    getVisibleActions(win.document).map(getActionName),
    [
      "manage-addon",
      "manage-addon",
      ...new Array(apiResultArray.length - 2).fill("install-addon"),
      "open-amo",
    ],
    "The Install buttons should be replaced with Manage buttons"
  );

  // End of the testing installation from a card.

  // Click on the Manage button to verify that it does something useful,
  // and in order to be able to force the discovery pane to be rendered again.
  let loaded = waitForViewLoad(win);
  getCardByAddonId(win, FIRST_EXTENSION_ID)
    .querySelector("[action='manage-addon']")
    .click();
  await loaded;
  {
    let addonCard = win.document.querySelector(
      `addon-card[addon-id="${FIRST_EXTENSION_ID}"]`
    );
    ok(addonCard, "Add-on details should be shown");
    ok(addonCard.expanded, "The card should have been expanded");
    // TODO bug 1540253: Check that the "recommended" badge is visible.
  }

  // Now we are going to force an updated rendering and check that the cards are
  // in the expected order, and then test uninstallation of the above add-ons.
  await switchToDiscoView(win);
  await waitForAllImagesLoaded(win);

  Assert.deepEqual(
    getVisibleActions(win.document).map(getActionName),
    [
      ...new Array(apiResultArray.length - 2).fill("install-addon"),
      "manage-addon",
      "manage-addon",
      "open-amo",
    ],
    "Already-installed add-ons should be rendered at the end of the list"
  );

  promiseThemeChange = promiseObserved("lightweight-theme-styling-update");
  await testAddonUninstall(getCardByAddonId(win, FIRST_THEME_ID));
  await promiseThemeChange;
  await testAddonUninstall(getCardByAddonId(win, FIRST_EXTENSION_ID));

  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");

  await closeView(win);
});

// Tests that the page is able to switch views while the discopane is loading,
// without inadvertently replacing the page when the request finishes.
add_task(async function discopane_navigate_while_loading() {
  let apiHandler = new DiscoveryAPIHandler(`{"results": []}`);

  apiHandler.blockNextResponses();
  let win = await loadInitialView("discover");

  let updatePromise = promiseDiscopaneUpdate(win);
  let didUpdateDiscopane = false;
  updatePromise.then(() => {
    didUpdateDiscopane = true;
  });

  // Switch views while the request is pending.
  await switchToNonDiscoView(win);

  is(
    didUpdateDiscopane,
    false,
    "discopane should still not be updated because the request is blocked"
  );
  is(
    getDiscoveryElement(win),
    null,
    "Discopane should be removed after switching to the extension list"
  );

  // Release pending requests, to verify that completing the request will not
  // cause changes to the visible view. The updatePromise will still resolve
  // though, because the event is dispatched to the removed `<discovery-pane>`.
  apiHandler.unblockResponses();

  await updatePromise;
  ok(
    win.document.querySelector("addon-list"),
    "Should still be at the extension list view"
  );
  is(
    getDiscoveryElement(win),
    null,
    "Discopane should not be in the document when it is not the active view"
  );

  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");

  await closeView(win);
});

// Tests that invalid responses are handled correctly and not cached.
// Also verifies that the response is cached as long as the page is active,
// but not when the page is fully reloaded.
add_task(async function discopane_cache_api_responses() {
  const INVALID_RESPONSE_BODY = `{"This is some": invalid} JSON`;
  let apiHandler = new DiscoveryAPIHandler(INVALID_RESPONSE_BODY);

  let expectedErrMsg;
  try {
    JSON.parse(INVALID_RESPONSE_BODY);
    ok(false, "JSON.parse should have thrown");
  } catch (e) {
    expectedErrMsg = e.message;
  }

  let invalidResponseHandledPromise = new Promise(resolve => {
    Services.console.registerListener(function listener(msg) {
      if (msg.message.includes(expectedErrMsg)) {
        resolve();
        Services.console.unregisterListener(listener);
      }
    });
  });

  let win = await loadInitialView("discover"); // Request #1
  await promiseDiscopaneUpdate(win);

  info("Waiting for expected error");
  await invalidResponseHandledPromise;
  is(apiHandler.requestCount, 1, "Discovery API should be fetched once");

  Assert.deepEqual(
    getVisibleActions(win.document).map(getActionName),
    ["open-amo"],
    "The AMO button should be visible even when the response was invalid"
  );

  // Change to a valid response, so that the next response will be cached.
  apiHandler.setResponseText(`{"results": []}`);

  await switchToNonDiscoView(win);
  await switchToDiscoView(win); // Request #2

  is(
    apiHandler.requestCount,
    2,
    "Should fetch new data because an invalid response should not be cached"
  );

  await switchToNonDiscoView(win);
  await switchToDiscoView(win);
  await closeView(win);

  is(
    apiHandler.requestCount,
    2,
    "The previous response was valid and should have been reused"
  );

  // Now open a new about:addons page and verify that a new API request is sent.
  let anotherWin = await loadInitialView("discover");
  await promiseDiscopaneUpdate(anotherWin);
  await closeView(anotherWin);

  is(apiHandler.requestCount, 3, "discovery API should be requested again");
});

add_task(async function discopane_no_cookies() {
  let requestPromise = new Promise(resolve => {
    amoServer.registerPathHandler("/discoapi", resolve);
  });
  Services.cookies.add(
    AMO_TEST_HOST,
    "/",
    "name",
    "value",
    false,
    false,
    false,
    Date.now() / 1000 + 600,
    {},
    Ci.nsICookie.SAMESITE_NONE,
    Ci.nsICookie.SCHEME_HTTP
  );
  let win = await loadInitialView("discover");
  let request = await requestPromise;
  ok(!request.hasHeader("Cookie"), "discovery API should not receive cookies");
  await closeView(win);
});

// The CSP of about:addons whitelists http:, but not data:, hence we are
// loading a little red data: image which gets blocked by the CSP.
add_task(async function csp_img_src() {
  const RED_DATA_IMAGE =
    "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAA" +
    "AHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";

  // Minimal API response to get the image in recommended-addon-card to render.
  const DUMMY_EXTENSION_ID = "dummy-csp@extensionid";
  const apiResponse = {
    results: [
      {
        addon: {
          guid: DUMMY_EXTENSION_ID,
          type: "extension",
          authors: [
            {
              name: "Some CSP author",
            },
          ],
          url: `http://${AMO_TEST_HOST}/dummy`,
          icon_url: RED_DATA_IMAGE,
        },
      },
    ],
  };

  let apiHandler = new DiscoveryAPIHandler(JSON.stringify(apiResponse));
  apiHandler.blockNextResponses();
  let win = await loadInitialView("discover");

  let cspPromise = new Promise(resolve => {
    win.addEventListener("securitypolicyviolation", e => {
      // non http(s) loads only report the scheme
      is(e.blockedURI, "data", "CSP: blocked URI");
      is(e.violatedDirective, "img-src", "CSP: violated directive");
      resolve();
    });
  });

  apiHandler.unblockResponses();
  await cspPromise;

  await closeView(win);
});

add_task(async function checkDiscopaneNotice() {
  await SpecialPowers.pushPrefEnv({
    set: [
      ["browser.discovery.enabled", true],
      // Enabling the Data Upload pref may upload data.
      // Point data reporting services to localhost so the data doesn't escape.
      ["toolkit.telemetry.server", "https://localhost:1337"],
      ["telemetry.fog.test.localhost_port", -1],
      ["datareporting.healthreport.uploadEnabled", true],
      ["extensions.htmlaboutaddons.recommendations.enabled", true],
      ["extensions.recommendations.hideNotice", false],
    ],
  });

  let win = await loadInitialView("extension");
  let messageBar = win.document.querySelector("message-bar.discopane-notice");
  ok(messageBar, "Recommended notice should exist in extensions view");
  await switchToDiscoView(win);
  messageBar = win.document.querySelector("message-bar.discopane-notice");
  ok(messageBar, "Recommended notice should exist in disco view");

  messageBar.closeButton.click();
  messageBar = win.document.querySelector("message-bar.discopane-notice");
  ok(!messageBar, "Recommended notice should not exist in disco view");
  await switchToNonDiscoView(win);
  messageBar = win.document.querySelector("message-bar.discopane-notice");
  ok(!messageBar, "Recommended notice should not exist in extensions view");

  await closeView(win);
});