1import { actionCreators as ac, actionTypes as at } from "common/Actions.jsm";
2import {
3  _Card as Card,
4  PlaceholderCard,
5} from "content-src/components/Card/Card";
6import { combineReducers, createStore } from "redux";
7import { GlobalOverrider } from "test/unit/utils";
8import { INITIAL_STATE, reducers } from "common/Reducers.jsm";
9import { cardContextTypes } from "content-src/components/Card/types";
10import { ContextMenuButton } from "content-src/components/ContextMenu/ContextMenuButton";
11import { LinkMenu } from "content-src/components/LinkMenu/LinkMenu";
12import { Provider } from "react-redux";
13import React from "react";
14import { shallow, mount } from "enzyme";
15
16let DEFAULT_PROPS = {
17  dispatch: sinon.stub(),
18  index: 0,
19  link: {
20    hostname: "foo",
21    title: "A title for foo",
22    url: "http://www.foo.com",
23    type: "history",
24    description: "A description for foo",
25    image: "http://www.foo.com/img.png",
26    guid: 1,
27  },
28  eventSource: "TOP_STORIES",
29  shouldSendImpressionStats: true,
30  contextMenuOptions: ["Separator"],
31};
32
33let DEFAULT_BLOB_IMAGE = {
34  path: "/testpath",
35  data: new Blob([0]),
36};
37
38function mountCardWithProps(props) {
39  const store = createStore(combineReducers(reducers), INITIAL_STATE);
40  return mount(
41    <Provider store={store}>
42      <Card {...props} />
43    </Provider>
44  );
45}
46
47describe("<Card>", () => {
48  let globals;
49  let wrapper;
50  beforeEach(() => {
51    globals = new GlobalOverrider();
52    wrapper = mountCardWithProps(DEFAULT_PROPS);
53  });
54  afterEach(() => {
55    DEFAULT_PROPS.dispatch.reset();
56    globals.restore();
57  });
58  it("should render a Card component", () => assert.ok(wrapper.exists()));
59  it("should add the right url", () => {
60    assert.propertyVal(
61      wrapper.find("a").props(),
62      "href",
63      DEFAULT_PROPS.link.url
64    );
65
66    // test that pocket cards get a special open_url href
67    const pocketLink = Object.assign({}, DEFAULT_PROPS.link, {
68      open_url: "getpocket.com/foo",
69      type: "pocket",
70    });
71    wrapper = mount(
72      <Card {...Object.assign({}, DEFAULT_PROPS, { link: pocketLink })} />
73    );
74    assert.propertyVal(wrapper.find("a").props(), "href", pocketLink.open_url);
75  });
76  it("should display a title", () =>
77    assert.equal(wrapper.find(".card-title").text(), DEFAULT_PROPS.link.title));
78  it("should display a description", () =>
79    assert.equal(
80      wrapper.find(".card-description").text(),
81      DEFAULT_PROPS.link.description
82    ));
83  it("should display a host name", () =>
84    assert.equal(wrapper.find(".card-host-name").text(), "foo"));
85  it("should have a link menu button", () =>
86    assert.ok(wrapper.find(".context-menu-button").exists()));
87  it("should render a link menu when button is clicked", () => {
88    const button = wrapper.find(".context-menu-button");
89    assert.equal(wrapper.find(LinkMenu).length, 0);
90    button.simulate("click", { preventDefault: () => {} });
91    assert.equal(wrapper.find(LinkMenu).length, 1);
92  });
93  it("should pass dispatch, source, onUpdate, site, options, and index to LinkMenu", () => {
94    wrapper
95      .find(".context-menu-button")
96      .simulate("click", { preventDefault: () => {} });
97    const { dispatch, source, onUpdate, site, options, index } = wrapper
98      .find(LinkMenu)
99      .props();
100    assert.equal(dispatch, DEFAULT_PROPS.dispatch);
101    assert.equal(source, DEFAULT_PROPS.eventSource);
102    assert.ok(onUpdate);
103    assert.equal(site, DEFAULT_PROPS.link);
104    assert.equal(options, DEFAULT_PROPS.contextMenuOptions);
105    assert.equal(index, DEFAULT_PROPS.index);
106  });
107  it("should pass through the correct menu options to LinkMenu if overridden by individual card", () => {
108    const link = Object.assign({}, DEFAULT_PROPS.link);
109    link.contextMenuOptions = ["CheckBookmark"];
110
111    wrapper = mountCardWithProps(Object.assign({}, DEFAULT_PROPS, { link }));
112    wrapper
113      .find(".context-menu-button")
114      .simulate("click", { preventDefault: () => {} });
115    const { options } = wrapper.find(LinkMenu).props();
116    assert.equal(options, link.contextMenuOptions);
117  });
118  it("should have a context based on type", () => {
119    wrapper = shallow(<Card {...DEFAULT_PROPS} />);
120    const context = wrapper.find(".card-context");
121    const { icon, fluentID } = cardContextTypes[DEFAULT_PROPS.link.type];
122    assert.isTrue(context.childAt(0).hasClass(`icon-${icon}`));
123    assert.isTrue(context.childAt(1).hasClass("card-context-label"));
124    assert.equal(context.childAt(1).prop("data-l10n-id"), fluentID);
125  });
126  it("should support setting custom context", () => {
127    const linkWithCustomContext = {
128      type: "history",
129      context: "Custom",
130      icon: "icon-url",
131    };
132
133    wrapper = shallow(
134      <Card
135        {...Object.assign({}, DEFAULT_PROPS, { link: linkWithCustomContext })}
136      />
137    );
138    const context = wrapper.find(".card-context");
139    const { icon } = cardContextTypes[DEFAULT_PROPS.link.type];
140    assert.isFalse(context.childAt(0).hasClass(`icon-${icon}`));
141    assert.equal(
142      context.childAt(0).props().style.backgroundImage,
143      "url('icon-url')"
144    );
145
146    assert.isTrue(context.childAt(1).hasClass("card-context-label"));
147    assert.equal(context.childAt(1).text(), linkWithCustomContext.context);
148  });
149  it("should parse args for fluent correctly", () => {
150    const title = '"fluent"';
151    const link = { ...DEFAULT_PROPS.link, title };
152
153    wrapper = mountCardWithProps({ ...DEFAULT_PROPS, link });
154    let button = wrapper.find(ContextMenuButton).find("button");
155
156    assert.equal(button.prop("data-l10n-args"), JSON.stringify({ title }));
157  });
158  it("should have .active class, on card-outer if context menu is open", () => {
159    const button = wrapper.find(ContextMenuButton);
160    assert.isFalse(
161      wrapper.find(".card-outer").hasClass("active"),
162      "does not have active class"
163    );
164    button.simulate("click", { preventDefault: () => {} });
165    assert.isTrue(
166      wrapper.find(".card-outer").hasClass("active"),
167      "has active class"
168    );
169  });
170  it("should send OPEN_DOWNLOAD_FILE if we clicked on a download", () => {
171    const downloadLink = {
172      type: "download",
173      url: "download.mov",
174    };
175    wrapper = mountCardWithProps(
176      Object.assign({}, DEFAULT_PROPS, { link: downloadLink })
177    );
178    const card = wrapper.find(".card");
179    card.simulate("click", { preventDefault: () => {} });
180    assert.calledThrice(DEFAULT_PROPS.dispatch);
181
182    assert.equal(
183      DEFAULT_PROPS.dispatch.firstCall.args[0].type,
184      at.OPEN_DOWNLOAD_FILE
185    );
186    assert.deepEqual(
187      DEFAULT_PROPS.dispatch.firstCall.args[0].data,
188      downloadLink
189    );
190  });
191  it("should send OPEN_LINK if we clicked on anything other than a download", () => {
192    const nonDownloadLink = {
193      type: "history",
194      url: "download.mov",
195    };
196    wrapper = mountCardWithProps(
197      Object.assign({}, DEFAULT_PROPS, { link: nonDownloadLink })
198    );
199    const card = wrapper.find(".card");
200    const event = {
201      altKey: "1",
202      button: "2",
203      ctrlKey: "3",
204      metaKey: "4",
205      shiftKey: "5",
206    };
207    card.simulate(
208      "click",
209      Object.assign({}, event, { preventDefault: () => {} })
210    );
211    assert.calledThrice(DEFAULT_PROPS.dispatch);
212
213    assert.equal(DEFAULT_PROPS.dispatch.firstCall.args[0].type, at.OPEN_LINK);
214  });
215  describe("card image display", () => {
216    const DEFAULT_BLOB_URL = "blob://test";
217    let url;
218    beforeEach(() => {
219      url = {
220        createObjectURL: globals.sandbox.stub().returns(DEFAULT_BLOB_URL),
221        revokeObjectURL: globals.sandbox.spy(),
222      };
223      globals.set("URL", url);
224    });
225    afterEach(() => {
226      globals.restore();
227    });
228    it("should display a regular image correctly and not call revokeObjectURL when unmounted", () => {
229      wrapper = shallow(<Card {...DEFAULT_PROPS} />);
230
231      assert.isUndefined(wrapper.state("cardImage").path);
232      assert.equal(wrapper.state("cardImage").url, DEFAULT_PROPS.link.image);
233      assert.equal(
234        wrapper.find(".card-preview-image").props().style.backgroundImage,
235        `url(${wrapper.state("cardImage").url})`
236      );
237
238      wrapper.unmount();
239      assert.notCalled(url.revokeObjectURL);
240    });
241    it("should display a blob image correctly and revoke blob url when unmounted", () => {
242      const link = Object.assign({}, DEFAULT_PROPS.link, {
243        image: DEFAULT_BLOB_IMAGE,
244      });
245      wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
246
247      assert.equal(wrapper.state("cardImage").path, DEFAULT_BLOB_IMAGE.path);
248      assert.equal(wrapper.state("cardImage").url, DEFAULT_BLOB_URL);
249      assert.equal(
250        wrapper.find(".card-preview-image").props().style.backgroundImage,
251        `url(${wrapper.state("cardImage").url})`
252      );
253
254      wrapper.unmount();
255      assert.calledOnce(url.revokeObjectURL);
256    });
257    it("should not show an image if there isn't one and not call revokeObjectURL when unmounted", () => {
258      const link = Object.assign({}, DEFAULT_PROPS.link);
259      delete link.image;
260
261      wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
262
263      assert.isNull(wrapper.state("cardImage"));
264      assert.lengthOf(wrapper.find(".card-preview-image"), 0);
265
266      wrapper.unmount();
267      assert.notCalled(url.revokeObjectURL);
268    });
269    it("should remove current card image if new image is not present", () => {
270      wrapper = shallow(<Card {...DEFAULT_PROPS} />);
271
272      const otherLink = Object.assign({}, DEFAULT_PROPS.link);
273      delete otherLink.image;
274      wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
275
276      assert.isNull(wrapper.state("cardImage"));
277    });
278    it("should not create or revoke urls if normal image is already in state", () => {
279      wrapper = shallow(<Card {...DEFAULT_PROPS} />);
280
281      wrapper.setProps(DEFAULT_PROPS);
282
283      assert.notCalled(url.createObjectURL);
284      assert.notCalled(url.revokeObjectURL);
285    });
286    it("should not create or revoke more urls if blob image is already in state", () => {
287      const link = Object.assign({}, DEFAULT_PROPS.link, {
288        image: DEFAULT_BLOB_IMAGE,
289      });
290      wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
291
292      assert.calledOnce(url.createObjectURL);
293      assert.notCalled(url.revokeObjectURL);
294
295      wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link }));
296
297      assert.calledOnce(url.createObjectURL);
298      assert.notCalled(url.revokeObjectURL);
299    });
300    it("should create blob urls for new blobs and revoke existing ones", () => {
301      const link = Object.assign({}, DEFAULT_PROPS.link, {
302        image: DEFAULT_BLOB_IMAGE,
303      });
304      wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
305
306      assert.calledOnce(url.createObjectURL);
307      assert.notCalled(url.revokeObjectURL);
308
309      const otherLink = Object.assign({}, DEFAULT_PROPS.link, {
310        image: { path: "/newpath", data: new Blob([0]) },
311      });
312      wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
313
314      assert.calledTwice(url.createObjectURL);
315      assert.calledOnce(url.revokeObjectURL);
316    });
317    it("should not call createObjectURL and revokeObjectURL for normal images", () => {
318      wrapper = shallow(<Card {...DEFAULT_PROPS} />);
319
320      assert.notCalled(url.createObjectURL);
321      assert.notCalled(url.revokeObjectURL);
322
323      const otherLink = Object.assign({}, DEFAULT_PROPS.link, {
324        image: "https://other/image",
325      });
326      wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
327
328      assert.notCalled(url.createObjectURL);
329      assert.notCalled(url.revokeObjectURL);
330    });
331  });
332  describe("image loading", () => {
333    let link;
334    let triggerImage = {};
335    let uniqueLink = 0;
336    beforeEach(() => {
337      global.Image.prototype = {
338        addEventListener(event, callback) {
339          triggerImage[event] = () => Promise.resolve(callback());
340        },
341      };
342
343      link = Object.assign({}, DEFAULT_PROPS.link);
344      link.image += uniqueLink++;
345      wrapper = shallow(<Card {...DEFAULT_PROPS} link={link} />);
346    });
347    it("should have a loaded preview image when the image is loaded", () => {
348      assert.isFalse(wrapper.find(".card-preview-image").hasClass("loaded"));
349
350      wrapper.setState({ imageLoaded: true });
351
352      assert.isTrue(wrapper.find(".card-preview-image").hasClass("loaded"));
353    });
354    it("should start not loaded", () => {
355      assert.isFalse(wrapper.state("imageLoaded"));
356    });
357    it("should be loaded after load", async () => {
358      await triggerImage.load();
359
360      assert.isTrue(wrapper.state("imageLoaded"));
361    });
362    it("should be not be loaded after error ", async () => {
363      await triggerImage.error();
364
365      assert.isFalse(wrapper.state("imageLoaded"));
366    });
367    it("should be not be loaded if image changes", async () => {
368      await triggerImage.load();
369      const otherLink = Object.assign({}, link, {
370        image: "https://other/image",
371      });
372
373      wrapper.setProps(Object.assign({}, DEFAULT_PROPS, { link: otherLink }));
374
375      assert.isFalse(wrapper.state("imageLoaded"));
376    });
377  });
378  describe("placeholder=true", () => {
379    beforeEach(() => {
380      wrapper = mount(<Card placeholder={true} />);
381    });
382    it("should render when placeholder=true", () => {
383      assert.ok(wrapper.exists());
384    });
385    it("should add a placeholder class to the outer element", () => {
386      assert.isTrue(wrapper.find(".card-outer").hasClass("placeholder"));
387    });
388    it("should not have a context menu button or LinkMenu", () => {
389      assert.isFalse(
390        wrapper.find(ContextMenuButton).exists(),
391        "context menu button"
392      );
393      assert.isFalse(wrapper.find(LinkMenu).exists(), "LinkMenu");
394    });
395    it("should not call onLinkClick when the link is clicked", () => {
396      const spy = sinon.spy(wrapper.instance(), "onLinkClick");
397      const card = wrapper.find(".card");
398      card.simulate("click");
399      assert.notCalled(spy);
400    });
401  });
402  describe("#trackClick", () => {
403    it("should call dispatch when the link is clicked with the right data", () => {
404      const card = wrapper.find(".card");
405      const event = {
406        altKey: "1",
407        button: "2",
408        ctrlKey: "3",
409        metaKey: "4",
410        shiftKey: "5",
411      };
412      card.simulate(
413        "click",
414        Object.assign({}, event, { preventDefault: () => {} })
415      );
416      assert.calledThrice(DEFAULT_PROPS.dispatch);
417
418      // first dispatch call is the AlsoToMain message which will open a link in a window, and send some event data
419      assert.equal(DEFAULT_PROPS.dispatch.firstCall.args[0].type, at.OPEN_LINK);
420      assert.deepEqual(
421        DEFAULT_PROPS.dispatch.firstCall.args[0].data.event,
422        event
423      );
424
425      // second dispatch call is a UserEvent action for telemetry
426      assert.isUserEventAction(DEFAULT_PROPS.dispatch.secondCall.args[0]);
427      assert.calledWith(
428        DEFAULT_PROPS.dispatch.secondCall,
429        ac.UserEvent({
430          event: "CLICK",
431          source: DEFAULT_PROPS.eventSource,
432          action_position: DEFAULT_PROPS.index,
433        })
434      );
435
436      // third dispatch call is to send impression stats
437      assert.calledWith(
438        DEFAULT_PROPS.dispatch.thirdCall,
439        ac.ImpressionStats({
440          source: DEFAULT_PROPS.eventSource,
441          click: 0,
442          tiles: [{ id: DEFAULT_PROPS.link.guid, pos: DEFAULT_PROPS.index }],
443        })
444      );
445    });
446    it("should provide card_type to telemetry info if type is not history", () => {
447      const link = Object.assign({}, DEFAULT_PROPS.link);
448      link.type = "bookmark";
449      wrapper = mount(<Card {...Object.assign({}, DEFAULT_PROPS, { link })} />);
450      const card = wrapper.find(".card");
451      const event = {
452        altKey: "1",
453        button: "2",
454        ctrlKey: "3",
455        metaKey: "4",
456        shiftKey: "5",
457      };
458
459      card.simulate(
460        "click",
461        Object.assign({}, event, { preventDefault: () => {} })
462      );
463
464      assert.isUserEventAction(DEFAULT_PROPS.dispatch.secondCall.args[0]);
465      assert.calledWith(
466        DEFAULT_PROPS.dispatch.secondCall,
467        ac.UserEvent({
468          event: "CLICK",
469          source: DEFAULT_PROPS.eventSource,
470          action_position: DEFAULT_PROPS.index,
471          value: { card_type: link.type },
472        })
473      );
474    });
475    it("should notify Web Extensions with WEBEXT_CLICK if props.isWebExtension is true", () => {
476      wrapper = mountCardWithProps(
477        Object.assign({}, DEFAULT_PROPS, {
478          isWebExtension: true,
479          eventSource: "MyExtension",
480          index: 3,
481        })
482      );
483      const card = wrapper.find(".card");
484      const event = { preventDefault() {} };
485      card.simulate("click", event);
486      assert.calledWith(
487        DEFAULT_PROPS.dispatch,
488        ac.WebExtEvent(at.WEBEXT_CLICK, {
489          source: "MyExtension",
490          url: DEFAULT_PROPS.link.url,
491          action_position: 3,
492        })
493      );
494    });
495  });
496});
497
498describe("<PlaceholderCard />", () => {
499  it("should render a Card with placeholder=true", () => {
500    const wrapper = mount(
501      <Provider store={createStore(combineReducers(reducers), INITIAL_STATE)}>
502        <PlaceholderCard />
503      </Provider>
504    );
505    assert.isTrue(wrapper.find(Card).props().placeholder);
506  });
507});
508