test_AboutHomeStartupCacheWorker.js (8443B)
1 /* Any copyright is dedicated to the Public Domain. 2 http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 /** 7 * This test ensures that the about:home startup cache worker 8 * script can correctly convert a state object from the Activity 9 * Stream Redux store into an HTML document and script. 10 */ 11 12 const { SearchTestUtils } = ChromeUtils.importESModule( 13 "resource://testing-common/SearchTestUtils.sys.mjs" 14 ); 15 const { TestUtils } = ChromeUtils.importESModule( 16 "resource://testing-common/TestUtils.sys.mjs" 17 ); 18 const { sinon } = ChromeUtils.importESModule( 19 "resource://testing-common/Sinon.sys.mjs" 20 ); 21 22 SearchTestUtils.init(this); 23 24 const { AboutNewTab } = ChromeUtils.importESModule( 25 "resource:///modules/AboutNewTab.sys.mjs" 26 ); 27 28 ChromeUtils.defineESModuleGetters(this, { 29 BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs", 30 DiscoveryStreamFeed: "resource://newtab/lib/DiscoveryStreamFeed.sys.mjs", 31 PREFS_CONFIG: "resource://newtab/lib/ActivityStream.sys.mjs", 32 }); 33 34 const CACHE_WORKER_URL = "resource://newtab/lib/cache.worker.js"; 35 const NEWTAB_RENDER_URL = "resource://newtab/data/content/newtab-render.js"; 36 37 /** 38 * In order to make this test less brittle, much of Activity Stream is 39 * initialized here in order to generate a state object at runtime, rather 40 * than hard-coding one in. This requires quite a bit of machinery in order 41 * to work properly. Specifically, we need to launch an HTTP server to serve 42 * a dynamic layout, and then have that layout point to a local feed rather 43 * than one from the Pocket CDN. 44 */ 45 add_setup(async function () { 46 do_get_profile(); 47 // The SearchService is also needed in order to construct the initial state, 48 // which means that the AddonManager needs to be available. 49 await SearchTestUtils.initXPCShellAddonManager(); 50 51 // The example.com domain will be used to host the dynamic layout JSON and 52 // the top stories JSON. 53 let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); 54 server.registerDirectory("/", do_get_cwd()); 55 56 // Top Stories are disabled by default in our testing profiles. 57 Services.prefs.setBoolPref( 58 "browser.newtabpage.activity-stream.feeds.section.topstories", 59 true 60 ); 61 Services.prefs.setBoolPref( 62 "browser.newtabpage.activity-stream.feeds.system.topstories", 63 true 64 ); 65 Services.prefs.setStringPref( 66 "browser.newtabpage.activity-stream.discoverystream.region-weather-config", 67 "" 68 ); 69 Services.prefs.setBoolPref( 70 "browser.newtabpage.activity-stream.newtabWallpapers.enabled", 71 false 72 ); 73 74 let defaultDSConfig = JSON.parse( 75 PREFS_CONFIG.get("discoverystream.config").getValue({ 76 geo: "US", 77 locale: "en-US", 78 }) 79 ); 80 const sandbox = sinon.createSandbox(); 81 sandbox 82 .stub(DiscoveryStreamFeed.prototype, "generateFeedUrl") 83 .returns("http://example.com/topstories.json"); 84 85 // Configure Activity Stream to query for the layout JSON file that points 86 // at the local top stories feed. 87 Services.prefs.setCharPref( 88 "browser.newtabpage.activity-stream.discoverystream.config", 89 JSON.stringify(defaultDSConfig) 90 ); 91 92 // We need to allow example.com as a place to get both the layout and the 93 // top stories from. 94 Services.prefs.setCharPref( 95 "browser.newtabpage.activity-stream.discoverystream.endpoints", 96 `http://example.com` 97 ); 98 99 Services.prefs.setBoolPref( 100 "browser.newtabpage.activity-stream.telemetry.structuredIngestion", 101 false 102 ); 103 104 // We need a default search engine set up for rendering the search input. 105 await SearchTestUtils.installSearchExtension( 106 { 107 name: "Test engine", 108 keyword: "@testengine", 109 search_url_get_params: "s={searchTerms}", 110 }, 111 { setAsDefault: true } 112 ); 113 114 // Pretend that a new window has been loaded to kick off initializing all of 115 // the feeds. 116 AboutNewTab.onBrowserReady(); 117 118 // Much of Activity Stream initializes asynchronously. This is the easiest way 119 // I could find to ensure that enough of the feeds had initialized to produce 120 // a meaningful cached document. 121 await TestUtils.waitForCondition(() => { 122 let feed = AboutNewTab.activityStream.store.feeds.get( 123 "feeds.discoverystreamfeed" 124 ); 125 return feed?.loaded; 126 }); 127 }); 128 129 /** 130 * Gets the Activity Stream Redux state from Activity Stream and sends it 131 * into an instance of the cache worker to ensure that the resulting markup 132 * and script makes sense. 133 */ 134 add_task(async function test_cache_worker() { 135 Services.prefs.setBoolPref( 136 "security.allow_parent_unrestricted_js_loads", 137 true 138 ); 139 registerCleanupFunction(() => { 140 Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); 141 }); 142 143 let state = AboutNewTab.activityStream.store.getState(); 144 145 let cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL); 146 let { page, script } = await cacheWorker.post("construct", [state]); 147 ok(!!page.length, "Got page content"); 148 ok(!!script.length, "Got script content"); 149 150 // The template strings should have been replaced. 151 equal( 152 page.indexOf("{{ MARKUP }}"), 153 -1, 154 "Page template should have {{ MARKUP }} replaced" 155 ); 156 equal( 157 page.indexOf("{{ CACHE_TIME }}"), 158 -1, 159 "Page template should have {{ CACHE_TIME }} replaced" 160 ); 161 equal( 162 script.indexOf("{{ STATE }}"), 163 -1, 164 "Script template should have {{ STATE }} replaced" 165 ); 166 167 // Now let's make sure that the generated script makes sense. We'll 168 // evaluate it in a sandbox to make sure broken JS doesn't break the 169 // test. 170 let sandbox = Cu.Sandbox(Cu.getGlobalForObject({})); 171 let passedState = null; 172 173 // window.NewtabRenderUtils.renderCache is the exposed API from 174 // activity-stream.jsx that the script is expected to call to hydrate 175 // the pre-rendered markup. We'll implement that, and use that to ensure 176 // that the passed in state object matches the state we sent into the 177 // worker. 178 sandbox.window = { 179 NewtabRenderUtils: { 180 renderCache(aState) { 181 passedState = aState; 182 }, 183 }, 184 }; 185 Cu.evalInSandbox(script, sandbox); 186 187 // The NEWTAB_RENDER_URL script is what ultimately causes the state 188 // to be passed into the renderCache function. 189 Services.scriptloader.loadSubScript(NEWTAB_RENDER_URL, sandbox); 190 191 equal( 192 sandbox.window.__FROM_STARTUP_CACHE__, 193 true, 194 "Should have set __FROM_STARTUP_CACHE__ to true" 195 ); 196 197 // The worker is expected to modify the state slightly before running 198 // it through ReactDOMServer by setting App.isForStartupCache to true. 199 // This allows React components to change their behaviour if the cache 200 // is being generated. 201 state.App.isForStartupCache = { 202 App: true, 203 TopSites: true, 204 DiscoveryStream: true, 205 Weather: true, 206 Wallpaper: true, 207 }; 208 209 // Some of the properties on the state might have values set to undefined. 210 // There is no way to express a named undefined property on an object in 211 // JSON, so we filter those out by stringifying and re-parsing. 212 state = JSON.parse(JSON.stringify(state)); 213 214 Assert.deepEqual( 215 passedState, 216 state, 217 "Should have called renderCache with the expected state" 218 ); 219 220 // Now let's do a quick smoke-test on the markup to ensure that the 221 // one Top Story from topstories.json is there. 222 let parser = new DOMParser(); 223 let doc = parser.parseFromString(page, "text/html"); 224 let root = doc.getElementById("root"); 225 ok(root.childElementCount, "There are children on the root node"); 226 227 // There should be the 1 top story, and 23 placeholders. 228 equal( 229 Array.from(root.querySelectorAll(".ds-card")).length, 230 24, 231 "There are 24 DSCards" 232 ); 233 let cardHostname = doc.querySelector( 234 "[data-section-id='topstories'] .source" 235 ).innerText; 236 equal(cardHostname, "bbc.com", "Card hostname is bbc.com"); 237 238 let placeholders = doc.querySelectorAll(".ds-card.placeholder"); 239 equal(placeholders.length, 23, "There should be 23 placeholders"); 240 }); 241 242 /** 243 * Tests that if the cache-worker construct method throws an exception 244 * that the construct Promise still resolves. Passing a null state should 245 * be enough to get it to throw. 246 */ 247 add_task(async function test_cache_worker_exception() { 248 let cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL); 249 let { page, script } = await cacheWorker.post("construct", [null]); 250 equal(page, null, "Should have gotten a null page nsIInputStream"); 251 equal(script, null, "Should have gotten a null script nsIInputStream"); 252 });