tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

ImpressionStats.test.jsx (11750B)


      1 import {
      2  ImpressionStats,
      3  INTERSECTION_RATIO,
      4 } from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats";
      5 import { actionTypes as at } from "common/Actions.mjs";
      6 import React from "react";
      7 import { shallow } from "enzyme";
      8 
      9 describe("<ImpressionStats>", () => {
     10  const SOURCE = "TEST_SOURCE";
     11  const FullIntersectEntries = [
     12    { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO },
     13  ];
     14  const ZeroIntersectEntries = [
     15    { isIntersecting: false, intersectionRatio: 0 },
     16  ];
     17  const PartialIntersectEntries = [
     18    { isIntersecting: true, intersectionRatio: INTERSECTION_RATIO / 2 },
     19  ];
     20 
     21  // Build IntersectionObserver class with the arg `entries` for the intersect callback.
     22  function buildIntersectionObserver(entries) {
     23    return class {
     24      constructor(callback) {
     25        this.callback = callback;
     26      }
     27 
     28      observe() {
     29        this.callback(entries);
     30      }
     31 
     32      unobserve() {}
     33    };
     34  }
     35 
     36  const TEST_FETCH_TIMESTAMP = Date.now();
     37  const TEST_FIRST_VISIBLE_TIMESTAMP = Date.now();
     38  const DEFAULT_PROPS = {
     39    rows: [
     40      { id: 1, pos: 0, fetchTimestamp: TEST_FETCH_TIMESTAMP },
     41      { id: 2, pos: 1, fetchTimestamp: TEST_FETCH_TIMESTAMP },
     42      { id: 3, pos: 2, fetchTimestamp: TEST_FETCH_TIMESTAMP },
     43    ],
     44    firstVisibleTimestamp: TEST_FIRST_VISIBLE_TIMESTAMP,
     45    source: SOURCE,
     46    IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
     47    document: {
     48      visibilityState: "visible",
     49      addEventListener: sinon.stub(),
     50      removeEventListener: sinon.stub(),
     51    },
     52  };
     53 
     54  const InnerEl = () => <div>Inner Element</div>;
     55 
     56  function renderImpressionStats(props = {}) {
     57    return shallow(
     58      <ImpressionStats {...DEFAULT_PROPS} {...props}>
     59        <InnerEl />
     60      </ImpressionStats>
     61    );
     62  }
     63 
     64  it("should render props.children", () => {
     65    const wrapper = renderImpressionStats();
     66    assert.ok(wrapper.contains(<InnerEl />));
     67  });
     68  it("should not send loaded content nor impression when the page is not visible", () => {
     69    const dispatch = sinon.spy();
     70    const props = {
     71      dispatch,
     72      document: {
     73        visibilityState: "hidden",
     74        addEventListener: sinon.spy(),
     75        removeEventListener: sinon.spy(),
     76      },
     77    };
     78    renderImpressionStats(props);
     79 
     80    assert.notCalled(dispatch);
     81  });
     82  it("should only send loaded content but not impression when the wrapped item is not visbible", () => {
     83    const dispatch = sinon.spy();
     84    const props = {
     85      dispatch,
     86      IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries),
     87    };
     88    renderImpressionStats(props);
     89 
     90    // This one is for loaded content.
     91    assert.calledOnce(dispatch);
     92    const [action] = dispatch.firstCall.args;
     93    assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
     94    assert.equal(action.data.source, SOURCE);
     95    assert.deepEqual(action.data.tiles, [
     96      { id: 1, pos: 0 },
     97      { id: 2, pos: 1 },
     98      { id: 3, pos: 2 },
     99    ]);
    100  });
    101  it("should not send impression when the wrapped item is visbible but below the ratio", () => {
    102    const dispatch = sinon.spy();
    103    const props = {
    104      dispatch,
    105      IntersectionObserver: buildIntersectionObserver(PartialIntersectEntries),
    106    };
    107    renderImpressionStats(props);
    108 
    109    // This one is for loaded content.
    110    assert.calledOnce(dispatch);
    111  });
    112  it("should send a loaded content and an impression when the page is visible and the wrapped item meets the visibility ratio", () => {
    113    const dispatch = sinon.spy();
    114    const props = {
    115      dispatch,
    116      IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
    117    };
    118    renderImpressionStats(props);
    119 
    120    assert.calledTwice(dispatch);
    121 
    122    let [action] = dispatch.firstCall.args;
    123    assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
    124    assert.equal(action.data.source, SOURCE);
    125    assert.deepEqual(action.data.tiles, [
    126      { id: 1, pos: 0 },
    127      { id: 2, pos: 1 },
    128      { id: 3, pos: 2 },
    129    ]);
    130 
    131    [action] = dispatch.secondCall.args;
    132    assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS);
    133    assert.equal(action.data.source, SOURCE);
    134    assert.equal(
    135      action.data.firstVisibleTimestamp,
    136      TEST_FIRST_VISIBLE_TIMESTAMP
    137    );
    138    assert.deepEqual(action.data.tiles, [
    139      {
    140        id: 1,
    141        pos: 0,
    142        type: "organic",
    143        recommendation_id: undefined,
    144        fetchTimestamp: TEST_FETCH_TIMESTAMP,
    145        scheduled_corpus_item_id: undefined,
    146        corpus_item_id: undefined,
    147        recommended_at: undefined,
    148        received_rank: undefined,
    149        topic: undefined,
    150        features: undefined,
    151        attribution: undefined,
    152        format: "medium-card",
    153      },
    154      {
    155        id: 2,
    156        pos: 1,
    157        type: "organic",
    158        recommendation_id: undefined,
    159        fetchTimestamp: TEST_FETCH_TIMESTAMP,
    160        scheduled_corpus_item_id: undefined,
    161        corpus_item_id: undefined,
    162        recommended_at: undefined,
    163        received_rank: undefined,
    164        topic: undefined,
    165        features: undefined,
    166        attribution: undefined,
    167        format: "medium-card",
    168      },
    169      {
    170        id: 3,
    171        pos: 2,
    172        type: "organic",
    173        recommendation_id: undefined,
    174        fetchTimestamp: TEST_FETCH_TIMESTAMP,
    175        scheduled_corpus_item_id: undefined,
    176        corpus_item_id: undefined,
    177        recommended_at: undefined,
    178        received_rank: undefined,
    179        topic: undefined,
    180        features: undefined,
    181        attribution: undefined,
    182        format: "medium-card",
    183      },
    184    ]);
    185    assert.equal(
    186      action.data.firstVisibleTimestamp,
    187      TEST_FIRST_VISIBLE_TIMESTAMP
    188    );
    189  });
    190  it("should send a DISCOVERY_STREAM_SPOC_IMPRESSION when the wrapped item has a flightId", () => {
    191    const dispatch = sinon.spy();
    192    const flightId = "a_flight_id";
    193    const props = {
    194      dispatch,
    195      flightId,
    196      rows: [{ id: 1, pos: 1, advertiser: "test advertiser" }],
    197      source: "TOP_SITES",
    198      IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
    199    };
    200    renderImpressionStats(props);
    201 
    202    // Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + TOP_SITES_SPONSORED_IMPRESSION_STATS + impression
    203    assert.callCount(dispatch, 4);
    204 
    205    const [action] = dispatch.secondCall.args;
    206    assert.equal(action.type, at.DISCOVERY_STREAM_SPOC_IMPRESSION);
    207    assert.deepEqual(action.data, { flightId });
    208  });
    209  it("should send a TOP_SITES_SPONSORED_IMPRESSION_STATS when the wrapped item has a flightId", () => {
    210    const dispatch = sinon.spy();
    211    const flightId = "a_flight_id";
    212    const props = {
    213      dispatch,
    214      flightId,
    215      rows: [{ id: 1, pos: 1, advertiser: "test advertiser" }],
    216      source: "TOP_SITES",
    217      IntersectionObserver: buildIntersectionObserver(FullIntersectEntries),
    218    };
    219    renderImpressionStats(props);
    220 
    221    // Loaded content + DISCOVERY_STREAM_SPOC_IMPRESSION + TOP_SITES_SPONSORED_IMPRESSION_STATS + impression
    222    assert.callCount(dispatch, 4);
    223 
    224    const [action] = dispatch.getCall(2).args;
    225    assert.equal(action.type, at.TOP_SITES_SPONSORED_IMPRESSION_STATS);
    226    assert.deepEqual(action.data, {
    227      type: "impression",
    228      tile_id: 1,
    229      source: "newtab",
    230      advertiser: "test advertiser",
    231      position: 1,
    232      attribution: undefined,
    233    });
    234  });
    235  it("should send an impression when the wrapped item transiting from invisible to visible", () => {
    236    const dispatch = sinon.spy();
    237    const props = {
    238      dispatch,
    239      IntersectionObserver: buildIntersectionObserver(ZeroIntersectEntries),
    240    };
    241    const wrapper = renderImpressionStats(props);
    242 
    243    // For the loaded content
    244    assert.calledOnce(dispatch);
    245 
    246    let [action] = dispatch.firstCall.args;
    247    assert.equal(action.type, at.DISCOVERY_STREAM_LOADED_CONTENT);
    248    assert.equal(action.data.source, SOURCE);
    249    assert.deepEqual(action.data.tiles, [
    250      { id: 1, pos: 0 },
    251      { id: 2, pos: 1 },
    252      { id: 3, pos: 2 },
    253    ]);
    254 
    255    dispatch.resetHistory();
    256    wrapper.instance().impressionObserver.callback(FullIntersectEntries);
    257 
    258    // For the impression
    259    assert.calledOnce(dispatch);
    260 
    261    [action] = dispatch.firstCall.args;
    262    assert.equal(action.type, at.DISCOVERY_STREAM_IMPRESSION_STATS);
    263    assert.deepEqual(action.data.tiles, [
    264      {
    265        id: 1,
    266        pos: 0,
    267        type: "organic",
    268        recommendation_id: undefined,
    269        scheduled_corpus_item_id: undefined,
    270        corpus_item_id: undefined,
    271        recommended_at: undefined,
    272        received_rank: undefined,
    273        fetchTimestamp: TEST_FETCH_TIMESTAMP,
    274        topic: undefined,
    275        features: undefined,
    276        attribution: undefined,
    277        format: "medium-card",
    278      },
    279      {
    280        id: 2,
    281        pos: 1,
    282        type: "organic",
    283        recommendation_id: undefined,
    284        scheduled_corpus_item_id: undefined,
    285        corpus_item_id: undefined,
    286        recommended_at: undefined,
    287        received_rank: undefined,
    288        fetchTimestamp: TEST_FETCH_TIMESTAMP,
    289        topic: undefined,
    290        features: undefined,
    291        attribution: undefined,
    292        format: "medium-card",
    293      },
    294      {
    295        id: 3,
    296        pos: 2,
    297        type: "organic",
    298        recommendation_id: undefined,
    299        scheduled_corpus_item_id: undefined,
    300        corpus_item_id: undefined,
    301        recommended_at: undefined,
    302        received_rank: undefined,
    303        fetchTimestamp: TEST_FETCH_TIMESTAMP,
    304        topic: undefined,
    305        features: undefined,
    306        attribution: undefined,
    307        format: "medium-card",
    308      },
    309    ]);
    310    assert.equal(
    311      action.data.firstVisibleTimestamp,
    312      TEST_FIRST_VISIBLE_TIMESTAMP
    313    );
    314  });
    315  it("should remove visibility change listener when the wrapper is removed", () => {
    316    const props = {
    317      dispatch: sinon.spy(),
    318      document: {
    319        visibilityState: "hidden",
    320        addEventListener: sinon.spy(),
    321        removeEventListener: sinon.spy(),
    322      },
    323      IntersectionObserver,
    324    };
    325 
    326    const wrapper = renderImpressionStats(props);
    327    assert.calledWith(props.document.addEventListener, "visibilitychange");
    328    const [, listener] = props.document.addEventListener.firstCall.args;
    329 
    330    wrapper.unmount();
    331    assert.calledWith(
    332      props.document.removeEventListener,
    333      "visibilitychange",
    334      listener
    335    );
    336  });
    337  it("should unobserve the intersection observer when the wrapper is removed", () => {
    338    // eslint-disable-next-line no-shadow
    339    const IntersectionObserver =
    340      buildIntersectionObserver(ZeroIntersectEntries);
    341    const spy = sinon.spy(IntersectionObserver.prototype, "unobserve");
    342    const props = { dispatch: sinon.spy(), IntersectionObserver };
    343 
    344    const wrapper = renderImpressionStats(props);
    345    wrapper.unmount();
    346 
    347    assert.calledOnce(spy);
    348  });
    349  it("should only send the latest impression on a visibility change", () => {
    350    const listeners = new Set();
    351    const props = {
    352      dispatch: sinon.spy(),
    353      document: {
    354        visibilityState: "hidden",
    355        addEventListener: (ev, cb) => listeners.add(cb),
    356        removeEventListener: (ev, cb) => listeners.delete(cb),
    357      },
    358    };
    359 
    360    const wrapper = renderImpressionStats(props);
    361 
    362    // Update twice
    363    wrapper.setProps({ ...props, ...{ rows: [{ id: 123, pos: 4 }] } });
    364    wrapper.setProps({ ...props, ...{ rows: [{ id: 2432, pos: 5 }] } });
    365 
    366    assert.notCalled(props.dispatch);
    367 
    368    // Simulate listeners getting called
    369    props.document.visibilityState = "visible";
    370    listeners.forEach(l => l());
    371 
    372    // Make sure we only sent the latest event
    373    assert.calledTwice(props.dispatch);
    374    const [action] = props.dispatch.firstCall.args;
    375    assert.deepEqual(action.data.tiles, [{ id: 2432, pos: 5 }]);
    376  });
    377 });