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