ExternalComponentWrapper.test.jsx (9816B)
1 import { GlobalOverrider } from "test/unit/utils"; 2 import { mount } from "enzyme"; 3 import { INITIAL_STATE, reducers } from "common/Reducers.sys.mjs"; 4 import { combineReducers, createStore } from "redux"; 5 import { Provider } from "react-redux"; 6 import { ExternalComponentWrapper } from "content-src/components/ExternalComponentWrapper/ExternalComponentWrapper"; 7 import React from "react"; 8 9 const DEFAULT_PROPS = { 10 type: "SEARCH", 11 className: "test-wrapper", 12 }; 13 14 const flushPromises = () => new Promise(resolve => queueMicrotask(resolve)); 15 16 const createMockConfig = (overrides = {}) => ({ 17 type: "SEARCH", 18 componentURL: "chrome://test/content/component.mjs", 19 tagName: "test-component", 20 l10nURLs: [], 21 ...overrides, 22 }); 23 24 const createStateWithConfig = config => ({ 25 ...INITIAL_STATE, 26 ExternalComponents: { 27 components: [config], 28 }, 29 }); 30 31 const createMockElement = sandbox => { 32 const element = document.createElement("div"); 33 sandbox.spy(element, "setAttribute"); 34 sandbox.spy(element.style, "setProperty"); 35 return element; 36 }; 37 38 // Wrap this around any component that uses useSelector, 39 // or any mount that uses a child that uses redux. 40 function WrapWithProvider({ children, state = INITIAL_STATE }) { 41 let store = createStore(combineReducers(reducers), state); 42 return <Provider store={store}>{children}</Provider>; 43 } 44 45 describe("<ExternalComponentWrapper>", () => { 46 let globals; 47 let sandbox; 48 const TestWrapper = ExternalComponentWrapper; 49 50 beforeEach(() => { 51 globals = new GlobalOverrider(); 52 sandbox = globals.sandbox; 53 }); 54 55 afterEach(() => { 56 globals.restore(); 57 }); 58 59 const stubCreateElement = handlers => { 60 const originalCreateElement = document.createElement.bind(document); 61 return sandbox.stub(document, "createElement").callsFake(tagName => { 62 if (handlers[tagName]) { 63 return handlers[tagName](); 64 } 65 return originalCreateElement(tagName); 66 }); 67 }; 68 69 it("should render a container div", () => { 70 const wrapper = mount( 71 <WrapWithProvider> 72 <TestWrapper {...DEFAULT_PROPS} /> 73 </WrapWithProvider> 74 ); 75 assert.ok(wrapper.exists()); 76 assert.equal(wrapper.find("div").length, 1); 77 }); 78 79 it("should apply className to container div", () => { 80 const wrapper = mount( 81 <WrapWithProvider> 82 <TestWrapper {...DEFAULT_PROPS} /> 83 </WrapWithProvider> 84 ); 85 assert.equal(wrapper.find("div.test-wrapper").length, 1); 86 }); 87 88 it("should warn when no configuration is found for type", async () => { 89 const consoleWarnStub = sandbox.stub(console, "warn"); 90 mount( 91 <WrapWithProvider> 92 <TestWrapper {...DEFAULT_PROPS} /> 93 </WrapWithProvider> 94 ); 95 96 await flushPromises(); 97 98 assert.calledWith( 99 consoleWarnStub, 100 "No external component configuration found for type: SEARCH" 101 ); 102 }); 103 104 it("should not render custom element without configuration", async () => { 105 const importModuleStub = sandbox.stub().resolves(); 106 const wrapper = mount( 107 <WrapWithProvider> 108 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 109 </WrapWithProvider> 110 ); 111 112 await flushPromises(); 113 114 assert.notCalled(importModuleStub); 115 wrapper.unmount(); 116 }); 117 118 it("should load component module when configuration is available", async () => { 119 const mockConfig = createMockConfig(); 120 const stateWithConfig = createStateWithConfig(mockConfig); 121 const importModuleStub = sandbox.stub().resolves(); 122 123 const wrapper = mount( 124 <WrapWithProvider state={stateWithConfig}> 125 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 126 </WrapWithProvider> 127 ); 128 await flushPromises(); 129 130 assert.calledWith(importModuleStub, mockConfig.componentURL); 131 wrapper.unmount(); 132 }); 133 134 it("should create custom element with correct tag name", async () => { 135 const mockConfig = createMockConfig(); 136 const stateWithConfig = createStateWithConfig(mockConfig); 137 const mockElement = createMockElement(sandbox); 138 const importModuleStub = sandbox.stub().resolves(); 139 140 const createElementStub = stubCreateElement({ 141 "test-component": () => mockElement, 142 }); 143 144 const wrapper = mount( 145 <WrapWithProvider state={stateWithConfig}> 146 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 147 </WrapWithProvider> 148 ); 149 await flushPromises(); 150 151 assert.calledWith(createElementStub, "test-component"); 152 wrapper.unmount(); 153 }); 154 155 it("should add l10n link elements to document head", async () => { 156 const mockConfig = createMockConfig({ 157 l10nURLs: ["browser/test.ftl", "browser/test2.ftl"], 158 }); 159 const stateWithConfig = createStateWithConfig(mockConfig); 160 const mockLinkElement = { rel: "", href: "", remove: sandbox.spy() }; 161 const importModuleStub = sandbox.stub().resolves(); 162 163 stubCreateElement({ 164 link: () => mockLinkElement, 165 "test-component": () => createMockElement(sandbox), 166 }); 167 168 const appendChildStub = sandbox.stub(document.head, "appendChild"); 169 170 const wrapper = mount( 171 <WrapWithProvider state={stateWithConfig}> 172 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 173 </WrapWithProvider> 174 ); 175 await flushPromises(); 176 177 assert.equal(appendChildStub.callCount, 2, "Should append two l10n links"); 178 assert.equal(mockLinkElement.rel, "localization"); 179 wrapper.unmount(); 180 }); 181 182 it("should set attributes on custom element", async () => { 183 const mockConfig = createMockConfig({ 184 attributes: { 185 "data-test": "value", 186 role: "search", 187 }, 188 }); 189 const stateWithConfig = createStateWithConfig(mockConfig); 190 const mockElement = createMockElement(sandbox); 191 const importModuleStub = sandbox.stub().resolves(); 192 193 stubCreateElement({ 194 "test-component": () => mockElement, 195 }); 196 197 const wrapper = mount( 198 <WrapWithProvider state={stateWithConfig}> 199 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 200 </WrapWithProvider> 201 ); 202 await flushPromises(); 203 204 assert.calledWith(mockElement.setAttribute, "data-test", "value"); 205 assert.calledWith(mockElement.setAttribute, "role", "search"); 206 wrapper.unmount(); 207 }); 208 209 it("should set CSS variables on custom element", async () => { 210 const mockConfig = createMockConfig({ 211 cssVariables: { 212 "--test-color": "blue", 213 "--test-size": "10px", 214 }, 215 }); 216 const stateWithConfig = createStateWithConfig(mockConfig); 217 const mockElement = createMockElement(sandbox); 218 const importModuleStub = sandbox.stub().resolves(); 219 220 stubCreateElement({ 221 "test-component": () => mockElement, 222 }); 223 224 const wrapper = mount( 225 <WrapWithProvider state={stateWithConfig}> 226 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 227 </WrapWithProvider> 228 ); 229 await flushPromises(); 230 231 assert.calledWith(mockElement.style.setProperty, "--test-color", "blue"); 232 assert.calledWith(mockElement.style.setProperty, "--test-size", "10px"); 233 wrapper.unmount(); 234 }); 235 236 it("should handle component load errors gracefully", async () => { 237 const mockConfig = createMockConfig(); 238 const stateWithConfig = createStateWithConfig(mockConfig); 239 const consoleErrorStub = sandbox.stub(console, "error"); 240 const importModuleStub = sandbox 241 .stub() 242 .rejects(new Error("Module load failed")); 243 244 const wrapper = mount( 245 <WrapWithProvider state={stateWithConfig}> 246 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 247 </WrapWithProvider> 248 ); 249 await flushPromises(); 250 wrapper.update(); 251 252 assert.calledWith( 253 consoleErrorStub, 254 "Failed to load external component for type SEARCH:", 255 sinon.match.instanceOf(Error) 256 ); 257 258 assert.equal(wrapper.html(), "", "Should render null on error"); 259 wrapper.unmount(); 260 }); 261 262 it("should clean up l10n links on unmount", async () => { 263 const mockConfig = createMockConfig({ 264 l10nURLs: ["browser/test.ftl"], 265 }); 266 const stateWithConfig = createStateWithConfig(mockConfig); 267 const mockLinkElements = []; 268 const importModuleStub = sandbox.stub().resolves(); 269 270 stubCreateElement({ 271 "test-component": () => createMockElement(sandbox), 272 link: () => { 273 const linkEl = { remove: sandbox.spy() }; 274 mockLinkElements.push(linkEl); 275 return linkEl; 276 }, 277 }); 278 279 sandbox.stub(document.head, "appendChild"); 280 281 const wrapper = mount( 282 <WrapWithProvider state={stateWithConfig}> 283 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 284 </WrapWithProvider> 285 ); 286 await flushPromises(); 287 288 assert.equal(mockLinkElements.length, 1, "Should create one l10n link"); 289 290 wrapper.unmount(); 291 292 assert.called(mockLinkElements[0].remove); 293 }); 294 295 it("should not create duplicate elements on multiple renders", async () => { 296 const mockConfig = createMockConfig(); 297 const stateWithConfig = createStateWithConfig(mockConfig); 298 const mockElement = createMockElement(sandbox); 299 const importModuleStub = sandbox.stub().resolves(); 300 301 const createElementStub = stubCreateElement({ 302 "test-component": () => mockElement, 303 }); 304 305 const wrapper = mount( 306 <WrapWithProvider state={stateWithConfig}> 307 <TestWrapper {...DEFAULT_PROPS} importModule={importModuleStub} /> 308 </WrapWithProvider> 309 ); 310 await flushPromises(); 311 312 const initialCallCount = createElementStub.callCount; 313 314 wrapper.setProps({ className: "new-class" }); 315 await flushPromises(); 316 317 assert.equal( 318 createElementStub.callCount, 319 initialCallCount, 320 "Should not create element again on re-render with same type" 321 ); 322 wrapper.unmount(); 323 }); 324 });