contextmenu_common.js (14517B)
1 // This file expects contextMenu to be defined in the scope it is loaded into. 2 /* global contextMenu:true */ 3 4 var lastElement; 5 const FRAME_OS_PID = "context-frameOsPid"; 6 7 function openContextMenuFor(element, shiftkey, waitForSpellCheck) { 8 // Context menu should be closed before we open it again. 9 is( 10 SpecialPowers.wrap(contextMenu).state, 11 "closed", 12 "checking if popup is closed" 13 ); 14 15 if (lastElement) { 16 lastElement.blur(); 17 } 18 element.focus(); 19 20 // Some elements need time to focus and spellcheck before any tests are 21 // run on them. 22 function actuallyOpenContextMenuFor() { 23 lastElement = element; 24 var eventDetails = { type: "contextmenu", button: 2, shiftKey: shiftkey }; 25 synthesizeMouse(element, 2, 2, eventDetails, element.ownerGlobal); 26 } 27 28 if (waitForSpellCheck) { 29 var { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule( 30 "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs" 31 ); 32 onSpellCheck(element, actuallyOpenContextMenuFor); 33 } else { 34 actuallyOpenContextMenuFor(); 35 } 36 } 37 38 function closeContextMenu() { 39 contextMenu.hidePopup(); 40 } 41 42 function getVisibleMenuItems(aMenu) { 43 var items = []; 44 var accessKeys = {}; 45 for (var i = 0; i < aMenu.children.length; i++) { 46 var item = aMenu.children[i]; 47 if (item.hidden) { 48 continue; 49 } 50 51 var key = item.accessKey; 52 if (key) { 53 key = key.toLowerCase(); 54 } 55 56 if (item.nodeName == "menuitem") { 57 var isGenerated = 58 item.classList.contains("spell-suggestion") || 59 item.classList.contains("sendtab-target"); 60 if (isGenerated) { 61 is(item.id, "", "child menuitem #" + i + " is generated"); 62 } else { 63 ok(item.id, "child menuitem #" + i + " has an ID"); 64 } 65 var label = item.getAttribute("label"); 66 ok(label.length, "menuitem " + item.id + " has a label"); 67 if (isGenerated) { 68 is(key, null, "Generated items shouldn't have an access key"); 69 items.push("*" + label); 70 } else if ( 71 item.id.indexOf("spell-check-dictionary-") != 0 && 72 item.id != "spell-no-suggestions" && 73 item.id != "spell-add-dictionaries-main" && 74 item.id != "fill-login-no-logins" && 75 // Inspect accessibility properties does not have an access key. See 76 // bug 1630717 for more details. 77 item.id != "context-inspect-a11y" && 78 !item.id.includes("context-media-playbackrate") && 79 item.id != "context-copy-link-to-highlight" && 80 item.id != "context-copy-clean-link-to-highlight" 81 ) { 82 if (item.id != FRAME_OS_PID) { 83 ok(key, "menuitem " + item.id + " has an access key"); 84 } 85 if (accessKeys[key]) { 86 ok( 87 false, 88 "menuitem " + item.id + " has same accesskey as " + accessKeys[key] 89 ); 90 } else { 91 accessKeys[key] = item.id; 92 } 93 } 94 if (!isGenerated) { 95 items.push(item.id); 96 } 97 items.push(!item.disabled); 98 } else if (item.nodeName == "menuseparator") { 99 ok(true, "--- seperator id is " + item.id); 100 items.push("---"); 101 items.push(null); 102 } else if (item.nodeName == "menu") { 103 ok(item.id, "child menu #" + i + " has an ID"); 104 ok(key, "menu has an access key"); 105 if (accessKeys[key]) { 106 ok( 107 false, 108 "menu " + item.id + " has same accesskey as " + accessKeys[key] 109 ); 110 } else { 111 accessKeys[key] = item.id; 112 } 113 items.push(item.id); 114 items.push(!item.disabled); 115 // Add a dummy item so that the indexes in checkMenu are the same 116 // for expectedItems and actualItems. 117 items.push([]); 118 items.push(null); 119 } else if (item.nodeName == "menugroup") { 120 ok(item.id, "child menugroup #" + i + " has an ID"); 121 items.push(item.id); 122 items.push(!item.disabled); 123 var menugroupChildren = []; 124 for (var child of item.children) { 125 if (child.hidden) { 126 continue; 127 } 128 129 menugroupChildren.push([child.id, !child.disabled]); 130 } 131 items.push(menugroupChildren); 132 items.push(null); 133 } else { 134 ok( 135 false, 136 "child #" + 137 i + 138 " of menu ID " + 139 aMenu.id + 140 " has an unknown type (" + 141 item.nodeName + 142 ")" 143 ); 144 } 145 } 146 return items; 147 } 148 149 function checkContextMenu(expectedItems) { 150 is(contextMenu.state, "open", "checking if popup is open"); 151 var data = { generatedSubmenuId: 1 }; 152 checkMenu(contextMenu, expectedItems, data); 153 } 154 155 function checkMenuItem( 156 actualItem, 157 actualEnabled, 158 expectedItem, 159 expectedEnabled, 160 index 161 ) { 162 is( 163 `${actualItem}`, 164 expectedItem, 165 "checking item #" + index / 2 + " (" + expectedItem + ") name" 166 ); 167 168 if ( 169 (typeof expectedEnabled == "object" && expectedEnabled != null) || 170 (typeof actualEnabled == "object" && actualEnabled != null) 171 ) { 172 ok(!(actualEnabled == null), "actualEnabled is not null"); 173 ok(!(expectedEnabled == null), "expectedEnabled is not null"); 174 is(typeof actualEnabled, typeof expectedEnabled, "checking types"); 175 176 if ( 177 typeof actualEnabled != typeof expectedEnabled || 178 actualEnabled == null || 179 expectedEnabled == null 180 ) { 181 return; 182 } 183 184 is( 185 actualEnabled.type, 186 expectedEnabled.type, 187 "checking item #" + index / 2 + " (" + expectedItem + ") type attr value" 188 ); 189 var icon = actualEnabled.icon; 190 if (icon) { 191 var tmp = ""; 192 var j = icon.length - 1; 193 while (j && icon[j] != "/") { 194 tmp = icon[j--] + tmp; 195 } 196 icon = tmp; 197 } 198 is( 199 icon, 200 expectedEnabled.icon, 201 "checking item #" + index / 2 + " (" + expectedItem + ") icon attr value" 202 ); 203 is( 204 actualEnabled.checked, 205 expectedEnabled.checked, 206 "checking item #" + index / 2 + " (" + expectedItem + ") has checked attr" 207 ); 208 is( 209 actualEnabled.disabled, 210 expectedEnabled.disabled, 211 "checking item #" + 212 index / 2 + 213 " (" + 214 expectedItem + 215 ") has disabled attr" 216 ); 217 } else if (expectedEnabled != null) { 218 is( 219 actualEnabled, 220 expectedEnabled, 221 "checking item #" + index / 2 + " (" + expectedItem + ") enabled state" 222 ); 223 } 224 } 225 226 /* 227 * checkMenu - checks to see if the specified <menupopup> contains the 228 * expected items and state. 229 * expectedItems is a array of (1) item IDs and (2) a boolean specifying if 230 * the item is enabled or not (or null to ignore it). Submenus can be checked 231 * by providing a nested array entry after the expected <menu> ID. 232 * For example: ["blah", true, // item enabled 233 * "submenu", null, // submenu 234 * ["sub1", true, // submenu contents 235 * "sub2", false], null, // submenu contents 236 * "lol", false] // item disabled 237 * 238 */ 239 function checkMenu(menu, expectedItems, data) { 240 var actualItems = getVisibleMenuItems(menu, data); 241 // ok(false, "Items are: " + actualItems); 242 for (var i = 0; i < expectedItems.length; i += 2) { 243 var actualItem = actualItems[i]; 244 var actualEnabled = actualItems[i + 1]; 245 var expectedItem = expectedItems[i]; 246 var expectedEnabled = expectedItems[i + 1]; 247 if (expectedItem instanceof Array) { 248 ok(true, "Checking submenu/menugroup..."); 249 var previousId = expectedItems[i - 2]; // The last item was the menu ID. 250 var previousItem = menu.getElementsByAttribute("id", previousId)[0]; 251 ok( 252 previousItem, 253 (previousItem ? previousItem.nodeName : "item") + 254 " with previous id (" + 255 previousId + 256 ") found" 257 ); 258 if (previousItem && previousItem.nodeName == "menu") { 259 ok(previousItem, "got a submenu element of id='" + previousId + "'"); 260 is( 261 previousItem.nodeName, 262 "menu", 263 "submenu element of id='" + previousId + "' has expected nodeName" 264 ); 265 checkMenu(previousItem.menupopup, expectedItem, data, i); 266 } else if (previousItem && previousItem.nodeName == "menugroup") { 267 ok(expectedItem.length, "menugroup must not be empty"); 268 for (var j = 0; j < expectedItem.length / 2; j++) { 269 checkMenuItem( 270 actualItems[i][j][0], 271 actualItems[i][j][1], 272 expectedItem[j * 2], 273 expectedItem[j * 2 + 1], 274 i + j * 2 275 ); 276 } 277 i += j; 278 } else { 279 ok(false, "previous item is not a menu or menugroup"); 280 } 281 } else { 282 checkMenuItem( 283 actualItem, 284 actualEnabled, 285 expectedItem, 286 expectedEnabled, 287 i 288 ); 289 } 290 } 291 // Could find unexpected extra items at the end... 292 is( 293 actualItems.length, 294 expectedItems.length, 295 "checking expected number of menu entries" 296 ); 297 } 298 299 let lastElementSelector = null; 300 /** 301 * Right-clicks on the element that matches `selector` and checks the 302 * context menu that appears against the `menuItems` array. 303 * 304 * @param {string} selector 305 * A selector passed to querySelector to find 306 * the element that will be referenced. 307 * @param {Array} menuItems 308 * An array of menuitem ids and their associated enabled state. A state 309 * of null means that it will be ignored. Ids of '---' are used for 310 * menuseparators. 311 * @param {object} options, optional 312 * skipFocusChange: don't move focus to the element before test, useful 313 * if you want to delay spell-check initialization 314 * offsetX: horizontal mouse offset from the top-left corner of 315 * the element, optional 316 * offsetY: vertical mouse offset from the top-left corner of the 317 * element, optional 318 * centered: if true, mouse position is centered in element, defaults 319 * to true if offsetX and offsetY are not provided 320 * waitForSpellCheck: wait until spellcheck is initialized before 321 * starting test 322 * preCheckContextMenuFn: callback to run before opening menu 323 * onContextMenuShown: callback to run when the context menu is shown 324 * postCheckContextMenuFn: callback to run after opening menu 325 * keepMenuOpen: if true, we do not call hidePopup, the consumer is 326 * responsible for calling it. 327 * @return {Promise} resolved after the test finishes 328 */ 329 async function test_contextmenu(selector, menuItems, options = {}) { 330 contextMenu = document.getElementById("contentAreaContextMenu"); 331 is(contextMenu.state, "closed", "checking if popup is closed"); 332 333 // Default to centered if no positioning is defined. 334 if (!options.offsetX && !options.offsetY) { 335 options.centered = true; 336 } 337 338 if (!options.skipFocusChange) { 339 await SpecialPowers.spawn( 340 gBrowser.selectedBrowser, 341 [[lastElementSelector, selector]], 342 async function ([contentLastElementSelector, contentSelector]) { 343 if (contentLastElementSelector) { 344 let contentLastElement = content.document.querySelector( 345 contentLastElementSelector 346 ); 347 contentLastElement.blur(); 348 } 349 let element = content.document.querySelector(contentSelector); 350 element.focus(); 351 } 352 ); 353 lastElementSelector = selector; 354 info(`Moved focus to ${selector}`); 355 } 356 357 if (options.preCheckContextMenuFn) { 358 await options.preCheckContextMenuFn(); 359 info("Completed preCheckContextMenuFn"); 360 } 361 362 if (options.waitForSpellCheck) { 363 info("Waiting for spell check"); 364 await SpecialPowers.spawn( 365 gBrowser.selectedBrowser, 366 [selector], 367 async function (contentSelector) { 368 let { onSpellCheck } = ChromeUtils.importESModule( 369 "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs" 370 ); 371 let element = content.document.querySelector(contentSelector); 372 await new Promise(resolve => onSpellCheck(element, resolve)); 373 info("Spell check running"); 374 } 375 ); 376 } 377 378 let awaitPopupShown = BrowserTestUtils.waitForEvent( 379 contextMenu, 380 "popupshown" 381 ); 382 await BrowserTestUtils.synthesizeMouse( 383 selector, 384 options.offsetX || 0, 385 options.offsetY || 0, 386 { 387 type: "contextmenu", 388 button: 2, 389 shiftkey: options.shiftkey, 390 centered: options.centered, 391 }, 392 gBrowser.selectedBrowser 393 ); 394 await awaitPopupShown; 395 info("Popup Shown"); 396 397 if (options.onContextMenuShown) { 398 await options.onContextMenuShown(); 399 info("Completed onContextMenuShown"); 400 } 401 402 if ( 403 typeof options.awaitOnMenuBuilt === "object" && 404 options.awaitOnMenuBuilt.id 405 ) { 406 const elementId = options.awaitOnMenuBuilt.id; 407 const menu = document.getElementById(elementId); 408 await TestUtils.waitForCondition( 409 () => menu && !menu.hidden, 410 `Menu ${elementId} did not appear in time` 411 ); 412 info(`Menu "${elementId}" was built and is now visible`); 413 } 414 415 if (menuItems) { 416 if (Services.prefs.getBoolPref("devtools.inspector.enabled", true)) { 417 let inspectItems = []; 418 let hasSeparatorAboveAskChat = false; 419 const hasViewSource = 420 menuItems.includes("context-viewsource") || 421 menuItems.includes("context-viewpartialsource-selection"); 422 423 const askChatIndex = menuItems.indexOf("context-ask-chat"); 424 const isAskChatLastItem = menuItems.at(-6) === "context-ask-chat"; 425 if (askChatIndex >= 2) { 426 hasSeparatorAboveAskChat = menuItems[askChatIndex - 2] === "---"; 427 } 428 429 if (!hasViewSource && !(isAskChatLastItem && hasSeparatorAboveAskChat)) { 430 inspectItems.push("---", null); 431 } 432 433 if ( 434 Services.prefs.getBoolPref("devtools.accessibility.enabled", true) && 435 (Services.prefs.getBoolPref("devtools.everOpened", false) || 436 Services.prefs.getIntPref("devtools.selfxss.count", 0) > 0) 437 ) { 438 inspectItems.push("context-inspect-a11y", true); 439 } 440 inspectItems.push("context-inspect", true); 441 442 menuItems = menuItems.concat(inspectItems); 443 } 444 445 checkContextMenu(menuItems); 446 } 447 448 let awaitPopupHidden = BrowserTestUtils.waitForEvent( 449 contextMenu, 450 "popuphidden" 451 ); 452 453 if (options.postCheckContextMenuFn) { 454 await options.postCheckContextMenuFn(); 455 info("Completed postCheckContextMenuFn"); 456 } 457 458 if (!options.keepMenuOpen) { 459 contextMenu.hidePopup(); 460 await awaitPopupHidden; 461 } 462 }