head.js (15549B)
1 "use strict"; 2 3 // This file expects these globals to be defined by the test case. 4 /* global gTestTab:true, gContentAPI:true, tests:false */ 5 6 ChromeUtils.defineESModuleGetters(this, { 7 UITour: "moz-src:///browser/components/uitour/UITour.sys.mjs", 8 }); 9 10 const { PermissionTestUtils } = ChromeUtils.importESModule( 11 "resource://testing-common/PermissionTestUtils.sys.mjs" 12 ); 13 14 const SINGLE_TRY_TIMEOUT = 100; 15 const NUMBER_OF_TRIES = 30; 16 17 let gProxyCallbackMap = new Map(); 18 19 function waitForConditionPromise( 20 condition, 21 timeoutMsg, 22 tryCount = NUMBER_OF_TRIES 23 ) { 24 return new Promise((resolve, reject) => { 25 let tries = 0; 26 function checkCondition() { 27 if (tries >= tryCount) { 28 reject(timeoutMsg); 29 } 30 var conditionPassed; 31 try { 32 conditionPassed = condition(); 33 } catch (e) { 34 return reject(e); 35 } 36 if (conditionPassed) { 37 return resolve(); 38 } 39 tries++; 40 setTimeout(checkCondition, SINGLE_TRY_TIMEOUT); 41 return undefined; 42 } 43 setTimeout(checkCondition, SINGLE_TRY_TIMEOUT); 44 }); 45 } 46 47 function waitForCondition(condition, nextTestFn, errorMsg) { 48 waitForConditionPromise(condition, errorMsg).then(nextTestFn, reason => { 49 ok(false, reason + (reason.stack ? "\n" + reason.stack : "")); 50 }); 51 } 52 53 /** 54 * Wrapper to partially transition tests to Task. Use `add_UITour_task` instead for new tests. 55 */ 56 function taskify(fun) { 57 return doneFn => { 58 // Output the inner function name otherwise no name will be output. 59 info("\t" + fun.name); 60 return fun().then(doneFn, reason => { 61 console.error(reason); 62 ok(false, reason); 63 doneFn(); 64 }); 65 }; 66 } 67 68 function is_hidden(element) { 69 let win = element.ownerGlobal; 70 let style = win.getComputedStyle(element); 71 if (style.display == "none") { 72 return true; 73 } 74 if (style.visibility != "visible") { 75 return true; 76 } 77 if (win.XULPopupElement.isInstance(element)) { 78 return ["hiding", "closed"].includes(element.state); 79 } 80 81 // Hiding a parent element will hide all its children 82 if (element.parentNode != element.ownerDocument) { 83 return is_hidden(element.parentNode); 84 } 85 86 return false; 87 } 88 89 function is_visible(element) { 90 let win = element.ownerGlobal; 91 let style = win.getComputedStyle(element); 92 if (style.display == "none") { 93 return false; 94 } 95 if (style.visibility != "visible") { 96 return false; 97 } 98 if (win.XULPopupElement.isInstance(element) && element.state != "open") { 99 return false; 100 } 101 102 // Hiding a parent element will hide all its children 103 if (element.parentNode != element.ownerDocument) { 104 return is_visible(element.parentNode); 105 } 106 107 return true; 108 } 109 110 function is_element_visible(element, msg) { 111 isnot(element, null, "Element should not be null, when checking visibility"); 112 ok(is_visible(element), msg); 113 } 114 115 function waitForElementToBeVisible(element, nextTestFn, msg) { 116 waitForCondition( 117 () => is_visible(element), 118 () => { 119 ok(true, msg); 120 nextTestFn(); 121 }, 122 "Timeout waiting for visibility: " + msg 123 ); 124 } 125 126 function waitForElementToBeHidden(element, nextTestFn, msg) { 127 waitForCondition( 128 () => is_hidden(element), 129 () => { 130 ok(true, msg); 131 nextTestFn(); 132 }, 133 "Timeout waiting for invisibility: " + msg 134 ); 135 } 136 137 function elementVisiblePromise(element, msg) { 138 return waitForConditionPromise( 139 () => is_visible(element), 140 "Timeout waiting for visibility: " + msg 141 ); 142 } 143 144 function elementHiddenPromise(element, msg) { 145 return waitForConditionPromise( 146 () => is_hidden(element), 147 "Timeout waiting for invisibility: " + msg 148 ); 149 } 150 151 function waitForPopupAtAnchor(popup, anchorNode, nextTestFn, msg) { 152 waitForCondition( 153 () => is_visible(popup) && popup.anchorNode == anchorNode, 154 () => { 155 ok(true, msg); 156 is_element_visible(popup, "Popup should be visible"); 157 nextTestFn(); 158 }, 159 "Timeout waiting for popup at anchor: " + msg 160 ); 161 } 162 163 function getConfigurationPromise(configName) { 164 return SpecialPowers.spawn( 165 gTestTab.linkedBrowser, 166 [configName], 167 contentConfigName => { 168 return new Promise(resolve => { 169 let contentWin = Cu.waiveXrays(content); 170 contentWin.Mozilla.UITour.getConfiguration(contentConfigName, resolve); 171 }); 172 } 173 ); 174 } 175 176 function getShowHighlightTargetName() { 177 let highlight = document.getElementById("UITourHighlight"); 178 return highlight.parentElement.getAttribute("targetName"); 179 } 180 181 function getShowInfoTargetName() { 182 let tooltip = document.getElementById("UITourTooltip"); 183 return tooltip.getAttribute("targetName"); 184 } 185 186 function hideInfoPromise(...args) { 187 let popup = document.getElementById("UITourTooltip"); 188 gContentAPI.hideInfo.apply(gContentAPI, args); 189 return promisePanelElementHidden(window, popup); 190 } 191 192 /** 193 * `buttons` and `options` require functions from the content scope so we take a 194 * function name to call to generate the buttons/options instead of the 195 * buttons/options themselves. This makes the signature differ from the content one. 196 */ 197 function showInfoPromise() { 198 let popup = document.getElementById("UITourTooltip"); 199 let shownPromise = promisePanelElementShown(window, popup); 200 return SpecialPowers.spawn(gTestTab.linkedBrowser, [[...arguments]], args => { 201 let contentWin = Cu.waiveXrays(content); 202 let [ 203 contentTarget, 204 contentTitle, 205 contentText, 206 contentIcon, 207 contentButtonsFunctionName, 208 contentOptionsFunctionName, 209 ] = args; 210 let buttons = contentButtonsFunctionName 211 ? contentWin[contentButtonsFunctionName]() 212 : null; 213 let options = contentOptionsFunctionName 214 ? contentWin[contentOptionsFunctionName]() 215 : null; 216 contentWin.Mozilla.UITour.showInfo( 217 contentTarget, 218 contentTitle, 219 contentText, 220 contentIcon, 221 buttons, 222 options 223 ); 224 }).then(() => shownPromise); 225 } 226 227 function showHighlightPromise(...args) { 228 let popup = document.getElementById("UITourHighlightContainer"); 229 gContentAPI.showHighlight.apply(gContentAPI, args); 230 return promisePanelElementShown(window, popup); 231 } 232 233 function showMenuPromise(name) { 234 return SpecialPowers.spawn(gTestTab.linkedBrowser, [name], contentName => { 235 return new Promise(resolve => { 236 let contentWin = Cu.waiveXrays(content); 237 contentWin.Mozilla.UITour.showMenu(contentName, resolve); 238 }); 239 }); 240 } 241 242 function waitForCallbackResultPromise() { 243 return SpecialPowers.spawn(gTestTab.linkedBrowser, [], async function () { 244 let contentWin = Cu.waiveXrays(content); 245 await ContentTaskUtils.waitForCondition(() => { 246 return contentWin.callbackResult; 247 }, "callback should be called"); 248 return { 249 data: contentWin.callbackData, 250 result: contentWin.callbackResult, 251 }; 252 }); 253 } 254 255 function promisePanelShown(win) { 256 let panelEl = win.PanelUI.panel; 257 return promisePanelElementShown(win, panelEl); 258 } 259 260 function promisePanelElementEvent(win, aPanel, aEvent) { 261 return new Promise((resolve, reject) => { 262 let timeoutId = win.setTimeout(() => { 263 aPanel.removeEventListener(aEvent, onPanelEvent); 264 reject(aEvent + " event did not happen within 5 seconds."); 265 }, 5000); 266 267 function onPanelEvent() { 268 aPanel.removeEventListener(aEvent, onPanelEvent); 269 win.clearTimeout(timeoutId); 270 // Wait one tick to let UITour.sys.mjs process the event as well. 271 executeSoon(resolve); 272 } 273 274 aPanel.addEventListener(aEvent, onPanelEvent); 275 }); 276 } 277 278 function promisePanelElementShown(win, aPanel) { 279 return promisePanelElementEvent(win, aPanel, "popupshown"); 280 } 281 282 function promisePanelElementHidden(win, aPanel) { 283 return promisePanelElementEvent(win, aPanel, "popuphidden"); 284 } 285 286 function is_element_hidden(element, msg) { 287 isnot(element, null, "Element should not be null, when checking visibility"); 288 ok(is_hidden(element), msg); 289 } 290 291 function isTourBrowser(aBrowser) { 292 let chromeWindow = aBrowser.ownerGlobal; 293 return ( 294 UITour.tourBrowsersByWindow.has(chromeWindow) && 295 UITour.tourBrowsersByWindow.get(chromeWindow).has(aBrowser) 296 ); 297 } 298 299 async function loadUITourTestPage(callback, host = "https://example.org/") { 300 if (gTestTab) { 301 gProxyCallbackMap.clear(); 302 gBrowser.removeTab(gTestTab); 303 } 304 305 if (!window.gProxyCallbackMap) { 306 window.gProxyCallbackMap = gProxyCallbackMap; 307 } 308 309 let url = getRootDirectory(gTestPath) + "uitour.html"; 310 url = url.replace("chrome://mochitests/content/", host); 311 312 gTestTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); 313 // When e10s is enabled, make gContentAPI a proxy which has every property 314 // return a function which calls the method of the same name on 315 // contentWin.Mozilla.UITour in a ContentTask. 316 let UITourHandler = { 317 get(target, prop) { 318 return (...args) => { 319 let browser = gTestTab.linkedBrowser; 320 // We need to proxy any callback functions using messages: 321 let fnIndices = []; 322 args = args.map((arg, index) => { 323 // Replace function arguments with "", and add them to the list of 324 // forwarded functions. We'll construct a function on the content-side 325 // that forwards all its arguments to a message, and we'll listen for 326 // those messages on our side and call the corresponding function with 327 // the arguments we got from the content side. 328 if (typeof arg == "function") { 329 gProxyCallbackMap.set(index, arg); 330 fnIndices.push(index); 331 return ""; 332 } 333 return arg; 334 }); 335 let taskArgs = { 336 methodName: prop, 337 args, 338 fnIndices, 339 }; 340 return SpecialPowers.spawn( 341 browser, 342 [taskArgs], 343 async function (contentArgs) { 344 let contentWin = Cu.waiveXrays(content); 345 let callbacksCalled = 0; 346 let resolveCallbackPromise; 347 let allCallbacksCalledPromise = new Promise( 348 resolve => (resolveCallbackPromise = resolve) 349 ); 350 let argumentsWithFunctions = Cu.cloneInto( 351 contentArgs.args.map((arg, index) => { 352 if (arg === "" && contentArgs.fnIndices.includes(index)) { 353 return function () { 354 callbacksCalled++; 355 SpecialPowers.spawnChrome( 356 [index, Array.from(arguments)], 357 (indexParent, argumentsParent) => { 358 // Please note that this handler only allows the callback to be used once. 359 // That means that a single gContentAPI.observer() call can't be used 360 // to observe multiple events. 361 let window = this.browsingContext.topChromeWindow; 362 let cb = window.gProxyCallbackMap.get(indexParent); 363 window.gProxyCallbackMap.delete(indexParent); 364 cb.apply(null, argumentsParent); 365 } 366 ); 367 if (callbacksCalled >= contentArgs.fnIndices.length) { 368 resolveCallbackPromise(); 369 } 370 }; 371 } 372 return arg; 373 }), 374 content, 375 { cloneFunctions: true } 376 ); 377 let rv = contentWin.Mozilla.UITour[contentArgs.methodName].apply( 378 contentWin.Mozilla.UITour, 379 argumentsWithFunctions 380 ); 381 if (contentArgs.fnIndices.length) { 382 await allCallbacksCalledPromise; 383 } 384 return rv; 385 } 386 ); 387 }; 388 }, 389 }; 390 gContentAPI = new Proxy({}, UITourHandler); 391 392 await SimpleTest.promiseFocus(gTestTab.linkedBrowser); 393 callback(); 394 } 395 396 // Wrapper for UITourTest to be used by add_task tests. 397 function setup_UITourTest() { 398 return UITourTest(true); 399 } 400 401 // Use `add_task(setup_UITourTest);` instead as we will fold this into `setup_UITourTest` once all tests are using `add_UITour_task`. 402 function UITourTest(usingAddTask = false) { 403 Services.prefs.setBoolPref("browser.uitour.enabled", true); 404 let testHttpsOrigin = "https://example.org"; 405 let testHttpOrigin = "http://example.org"; 406 PermissionTestUtils.add( 407 testHttpsOrigin, 408 "uitour", 409 Services.perms.ALLOW_ACTION 410 ); 411 PermissionTestUtils.add( 412 testHttpOrigin, 413 "uitour", 414 Services.perms.ALLOW_ACTION 415 ); 416 417 UITour.getHighlightContainerAndMaybeCreate(window.document); 418 UITour.getTooltipAndMaybeCreate(window.document); 419 420 // If a test file is using add_task, we don't need to have a test function or 421 // call `waitForExplicitFinish`. 422 if (!usingAddTask) { 423 waitForExplicitFinish(); 424 } 425 426 registerCleanupFunction(function () { 427 delete window.gContentAPI; 428 if (gTestTab) { 429 gBrowser.removeTab(gTestTab); 430 } 431 delete window.gTestTab; 432 delete window.gProxyCallbackMap; 433 Services.prefs.clearUserPref("browser.uitour.enabled"); 434 PermissionTestUtils.remove(testHttpsOrigin, "uitour"); 435 PermissionTestUtils.remove(testHttpOrigin, "uitour"); 436 }); 437 438 // When using tasks, the harness will call the next added task for us. 439 if (!usingAddTask) { 440 nextTest(); 441 } 442 } 443 444 function done(usingAddTask = false) { 445 info("== Done test, doing shared checks before teardown =="); 446 return new Promise(resolve => { 447 executeSoon(() => { 448 if (gTestTab) { 449 gBrowser.removeTab(gTestTab); 450 } 451 gTestTab = null; 452 gProxyCallbackMap.clear(); 453 454 let highlight = document.getElementById("UITourHighlightContainer"); 455 is_element_hidden( 456 highlight, 457 "Highlight should be closed/hidden after UITour tab is closed" 458 ); 459 460 let tooltip = document.getElementById("UITourTooltip"); 461 is_element_hidden( 462 tooltip, 463 "Tooltip should be closed/hidden after UITour tab is closed" 464 ); 465 466 ok( 467 !PanelUI.panel.hasAttribute("noautohide"), 468 "@noautohide on the menu panel should have been cleaned up" 469 ); 470 ok( 471 !PanelUI.panel.hasAttribute("panelopen"), 472 "The panel shouldn't have @panelopen" 473 ); 474 isnot(PanelUI.panel.state, "open", "The panel shouldn't be open"); 475 is( 476 document.getElementById("PanelUI-menu-button").hasAttribute("open"), 477 false, 478 "Menu button should know that the menu is closed" 479 ); 480 481 info("Done shared checks"); 482 if (usingAddTask) { 483 executeSoon(resolve); 484 } else { 485 executeSoon(nextTest); 486 } 487 }); 488 }); 489 } 490 491 function nextTest() { 492 if (!tests.length) { 493 info("finished tests in this file"); 494 finish(); 495 return; 496 } 497 let test = tests.shift(); 498 info("Starting " + test.name); 499 waitForFocus(function () { 500 loadUITourTestPage(function () { 501 test(done); 502 }); 503 }); 504 } 505 506 /** 507 * All new tests that need the help of `loadUITourTestPage` should use this 508 * wrapper around their test's generator function to reduce boilerplate. 509 */ 510 function add_UITour_task(func) { 511 let genFun = async function () { 512 await new Promise(resolve => { 513 waitForFocus(function () { 514 loadUITourTestPage(function () { 515 let funcPromise = (func() || Promise.resolve()).then( 516 () => done(true), 517 reason => { 518 ok(false, reason); 519 return done(true); 520 } 521 ); 522 resolve(funcPromise); 523 }); 524 }); 525 }); 526 }; 527 Object.defineProperty(genFun, "name", { 528 configurable: true, 529 value: func.name, 530 }); 531 add_task(genFun); 532 }