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