browser_resources_stylesheets.js (21269B)
1 /* Any copyright is dedicated to the Public Domain. 2 http://creativecommons.org/publicdomain/zero/1.0/ */ 3 4 "use strict"; 5 6 // Test the ResourceCommand API around STYLESHEET. 7 8 const ResourceCommand = require("resource://devtools/shared/commands/resource/resource-command.js"); 9 10 const STYLE_TEST_URL = URL_ROOT_SSL + "style_document.html"; 11 12 const EXISTING_RESOURCES = [ 13 { 14 styleText: "body { color: lime; }", 15 href: null, 16 nodeHref: 17 "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", 18 isNew: false, 19 disabled: false, 20 constructed: false, 21 ruleCount: 1, 22 atRules: [], 23 }, 24 { 25 styleText: "body { margin: 1px; }", 26 href: "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.css", 27 nodeHref: 28 "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", 29 isNew: false, 30 disabled: false, 31 constructed: false, 32 ruleCount: 1, 33 atRules: [], 34 }, 35 { 36 styleText: "", 37 href: null, 38 nodeHref: null, 39 isNew: false, 40 disabled: false, 41 constructed: true, 42 ruleCount: 1, 43 atRules: [], 44 }, 45 { 46 styleText: "body { background-color: pink; }", 47 href: null, 48 nodeHref: 49 "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html", 50 isNew: false, 51 disabled: false, 52 constructed: false, 53 ruleCount: 1, 54 atRules: [], 55 }, 56 { 57 styleText: "body { padding: 1px; }", 58 href: "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.css", 59 nodeHref: 60 "https://example.org/browser/devtools/shared/commands/resource/tests/style_iframe.html", 61 isNew: false, 62 disabled: false, 63 constructed: false, 64 ruleCount: 1, 65 atRules: [], 66 }, 67 ]; 68 69 const ADDITIONAL_INLINE_RESOURCE = { 70 styleText: 71 "@media all { body { color: red; } } @media print { body { color: cyan; } } body { font-size: 10px; }", 72 href: null, 73 nodeHref: 74 "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", 75 isNew: false, 76 disabled: false, 77 constructed: false, 78 ruleCount: 5, 79 atRules: [ 80 { 81 type: "media", 82 conditionText: "all", 83 matches: true, 84 line: 1, 85 column: 1, 86 }, 87 { 88 type: "media", 89 conditionText: "print", 90 matches: false, 91 line: 1, 92 column: 37, 93 }, 94 ], 95 }; 96 97 const ADDITIONAL_CONSTRUCTED_RESOURCE = { 98 styleText: "", 99 href: null, 100 nodeHref: null, 101 isNew: false, 102 disabled: false, 103 constructed: true, 104 ruleCount: 2, 105 atRules: [], 106 }; 107 108 const ADDITIONAL_FROM_ACTOR_RESOURCE = { 109 styleText: "body { font-size: 10px; }", 110 href: null, 111 nodeHref: 112 "https://example.com/browser/devtools/shared/commands/resource/tests/style_document.html", 113 isNew: true, 114 disabled: false, 115 constructed: false, 116 ruleCount: 1, 117 atRules: [], 118 }; 119 120 add_task(async function () { 121 // Enable @property 122 await pushPref("layout.css.properties-and-values.enabled", true); 123 await testResourceAvailableDestroyedFeature(); 124 await testResourceUpdateFeature(); 125 await testNestedResourceUpdateFeature(); 126 }); 127 128 function pushAvailableResource(availableResources) { 129 // TODO(bug 1826538): Find a better way of dealing with these. 130 return function (resources) { 131 for (const resource of resources) { 132 if (resource.href?.startsWith("resource://")) { 133 continue; 134 } 135 availableResources.push(resource); 136 } 137 }; 138 } 139 140 async function testResourceAvailableDestroyedFeature() { 141 info("Check resource available feature of the ResourceCommand"); 142 143 const tab = await addTab(STYLE_TEST_URL); 144 let resourceTimingEntryCounts = await getResourceTimingCount(tab); 145 is( 146 resourceTimingEntryCounts, 147 2, 148 "Should have two entires for resource timing" 149 ); 150 151 const { client, resourceCommand, targetCommand } = 152 await initResourceCommand(tab); 153 154 info("Check whether ResourceCommand gets existing stylesheet"); 155 const availableResources = []; 156 const destroyedResources = []; 157 await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { 158 onAvailable: pushAvailableResource(availableResources), 159 onDestroyed: resources => destroyedResources.push(...resources), 160 }); 161 162 is( 163 availableResources.length, 164 EXISTING_RESOURCES.length, 165 "Length of existing resources is correct" 166 ); 167 for (let i = 0; i < availableResources.length; i++) { 168 const availableResource = availableResources[i]; 169 // We can not expect the resources to always be forwarded in the same order. 170 // See intermittent Bug 1655016. 171 const expectedResource = findMatchingExpectedResource(availableResource); 172 ok(expectedResource, "Found a matching expected resource for the resource"); 173 await assertResource(availableResource, expectedResource); 174 } 175 176 resourceTimingEntryCounts = await getResourceTimingCount(tab); 177 is( 178 resourceTimingEntryCounts, 179 2, 180 "Should still have two entires for resource timing after devtools APIs have been triggered" 181 ); 182 183 info("Check whether ResourceCommand gets additonal stylesheet"); 184 await ContentTask.spawn( 185 tab.linkedBrowser, 186 ADDITIONAL_INLINE_RESOURCE.styleText, 187 text => { 188 const document = content.document; 189 const stylesheet = document.createElement("style"); 190 stylesheet.id = "inline-from-test"; 191 stylesheet.textContent = text; 192 document.body.appendChild(stylesheet); 193 } 194 ); 195 await waitUntil( 196 () => availableResources.length === EXISTING_RESOURCES.length + 1 197 ); 198 await assertResource( 199 availableResources[availableResources.length - 1], 200 ADDITIONAL_INLINE_RESOURCE 201 ); 202 203 info("Check whether ResourceCommand gets additonal constructed stylesheet"); 204 await ContentTask.spawn(tab.linkedBrowser, null, () => { 205 const document = content.document; 206 const s = new content.CSSStyleSheet(); 207 // We use the different number of rules to meaningfully differentiate 208 // between constructed stylesheets. 209 s.replaceSync("foo { color: red } bar { color: blue }"); 210 // TODO(bug 1751346): wrappedJSObject should be unnecessary. 211 document.wrappedJSObject.adoptedStyleSheets.push(s); 212 }); 213 await waitUntil( 214 () => availableResources.length === EXISTING_RESOURCES.length + 2 215 ); 216 await assertResource( 217 availableResources[availableResources.length - 1], 218 ADDITIONAL_CONSTRUCTED_RESOURCE 219 ); 220 221 info( 222 "Check whether ResourceCommand gets additonal stylesheet which is added by DevTools" 223 ); 224 const styleSheetsFront = 225 await targetCommand.targetFront.getFront("stylesheets"); 226 await styleSheetsFront.addStyleSheet( 227 ADDITIONAL_FROM_ACTOR_RESOURCE.styleText 228 ); 229 await waitUntil( 230 () => availableResources.length === EXISTING_RESOURCES.length + 3 231 ); 232 await assertResource( 233 availableResources[availableResources.length - 1], 234 ADDITIONAL_FROM_ACTOR_RESOURCE 235 ); 236 237 info("Check resource destroyed feature of the ResourceCommand"); 238 is(destroyedResources.length, 0, "There was no removed stylesheets yet"); 239 240 info("Remove inline stylesheet added in the test"); 241 await ContentTask.spawn(tab.linkedBrowser, null, () => { 242 content.document.querySelector("#inline-from-test").remove(); 243 }); 244 await waitUntil(() => destroyedResources.length === 1); 245 assertDestroyed(destroyedResources[0], { 246 resourceId: availableResources.at(-3).resourceId, 247 }); 248 249 info("Remove existing top-level inline stylesheet"); 250 await ContentTask.spawn(tab.linkedBrowser, null, () => { 251 content.document.querySelector("style").remove(); 252 }); 253 await waitUntil(() => destroyedResources.length === 2); 254 assertDestroyed(destroyedResources[1], { 255 resourceId: availableResources.find( 256 resource => 257 findMatchingExpectedResource(resource) === EXISTING_RESOURCES[0] 258 ).resourceId, 259 }); 260 261 info("Remove existing top-level <link> stylesheet"); 262 await ContentTask.spawn(tab.linkedBrowser, null, () => { 263 content.document.querySelector("link").remove(); 264 }); 265 await waitUntil(() => destroyedResources.length === 3); 266 assertDestroyed(destroyedResources[2], { 267 resourceId: availableResources.find( 268 resource => 269 findMatchingExpectedResource(resource) === EXISTING_RESOURCES[1] 270 ).resourceId, 271 }); 272 273 info("Remove existing iframe inline stylesheet"); 274 const iframeBrowsingContext = await SpecialPowers.spawn( 275 tab.linkedBrowser, 276 [], 277 () => content.document.querySelector("iframe").browsingContext 278 ); 279 280 await SpecialPowers.spawn(iframeBrowsingContext, [], () => { 281 content.document.querySelector("style").remove(); 282 }); 283 await waitUntil(() => destroyedResources.length === 4); 284 assertDestroyed(destroyedResources[3], { 285 resourceId: availableResources.find( 286 resource => 287 findMatchingExpectedResource(resource) === EXISTING_RESOURCES[3] 288 ).resourceId, 289 }); 290 291 info("Remove existing iframe <link> stylesheet"); 292 await SpecialPowers.spawn(iframeBrowsingContext, [], () => { 293 content.document.querySelector("link").remove(); 294 }); 295 await waitUntil(() => destroyedResources.length === 5); 296 assertDestroyed(destroyedResources[4], { 297 resourceId: availableResources.find( 298 resource => 299 findMatchingExpectedResource(resource) === EXISTING_RESOURCES[4] 300 ).resourceId, 301 }); 302 303 targetCommand.destroy(); 304 await client.close(); 305 } 306 307 async function testResourceUpdateFeature() { 308 info("Check resource update feature of the ResourceCommand"); 309 310 const tab = await addTab(STYLE_TEST_URL); 311 312 const { client, resourceCommand, targetCommand } = 313 await initResourceCommand(tab); 314 315 info("Setup the watcher"); 316 const availableResources = []; 317 const updates = []; 318 await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { 319 onAvailable: pushAvailableResource(availableResources), 320 onUpdated: newUpdates => updates.push(...newUpdates), 321 }); 322 is( 323 availableResources.length, 324 EXISTING_RESOURCES.length, 325 "Length of existing resources is correct" 326 ); 327 is(updates.length, 0, "there's no update yet"); 328 329 info("Check toggleDisabled function"); 330 // Retrieve the stylesheet of the top-level target 331 const resource = availableResources.find( 332 innerResource => innerResource.targetFront.isTopLevel 333 ); 334 const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); 335 await styleSheetsFront.toggleDisabled(resource.resourceId); 336 await waitUntil(() => updates.length === 1); 337 338 // Check the content of the update object. 339 assertUpdate(updates[0].update, { 340 resourceId: resource.resourceId, 341 updateType: "property-change", 342 }); 343 is( 344 updates[0].update.resourceUpdates.disabled, 345 true, 346 "resourceUpdates is correct" 347 ); 348 349 // Check whether the cached resource is updated correctly. 350 is( 351 updates[0].resource.disabled, 352 true, 353 "cached resource is updated correctly" 354 ); 355 356 // Check whether the actual stylesheet is updated correctly. 357 const styleSheetDisabled = await ContentTask.spawn( 358 tab.linkedBrowser, 359 null, 360 () => { 361 const document = content.document; 362 const stylesheet = document.styleSheets[0]; 363 return stylesheet.disabled; 364 } 365 ); 366 is(styleSheetDisabled, true, "actual stylesheet was updated correctly"); 367 368 info("Check update function"); 369 const expectedAtRules = [ 370 { 371 type: "media", 372 conditionText: "screen", 373 matches: true, 374 }, 375 { 376 type: "media", 377 conditionText: "print", 378 matches: false, 379 }, 380 ]; 381 382 const updateCause = "updated-by-test"; 383 await styleSheetsFront.update( 384 resource.resourceId, 385 "@media screen { color: red; } @media print { color: green; } body { color: cyan; }", 386 false, 387 updateCause 388 ); 389 await waitUntil(() => updates.length === 4); 390 391 assertUpdate(updates[1].update, { 392 resourceId: resource.resourceId, 393 updateType: "property-change", 394 }); 395 is( 396 updates[1].update.resourceUpdates.ruleCount, 397 3, 398 "resourceUpdates is correct" 399 ); 400 is(updates[1].resource.ruleCount, 3, "cached resource is updated correctly"); 401 402 assertUpdate(updates[2].update, { 403 resourceId: resource.resourceId, 404 updateType: "style-applied", 405 event: { 406 cause: updateCause, 407 }, 408 }); 409 is( 410 updates[2].update.resourceUpdates, 411 undefined, 412 "resourceUpdates is correct" 413 ); 414 415 assertUpdate(updates[3].update, { 416 resourceId: resource.resourceId, 417 updateType: "at-rules-changed", 418 }); 419 assertAtRules(updates[3].update.resourceUpdates.atRules, expectedAtRules); 420 421 // Check the actual page. 422 const styleSheetResult = await getStyleSheetResult(tab); 423 424 is( 425 styleSheetResult.ruleCount, 426 3, 427 "ruleCount of actual stylesheet is updated correctly" 428 ); 429 assertAtRules(styleSheetResult.atRules, expectedAtRules); 430 431 targetCommand.destroy(); 432 await client.close(); 433 } 434 435 function resizeToInner(win, wantedInnerWidth, wantedInnerHeight) { 436 const diffX = wantedInnerWidth - win.innerWidth; 437 const diffY = wantedInnerHeight - win.innerHeight; 438 win.resizeBy(diffX, diffY); 439 } 440 441 async function testNestedResourceUpdateFeature() { 442 info("Check nested resource update feature of the ResourceCommand"); 443 444 const tab = await addTab(STYLE_TEST_URL); 445 const win = tab.ownerGlobal; 446 447 const { innerWidth: originalWindowWidth, innerHeight: originalWindowHeight } = 448 win; 449 450 registerCleanupFunction(() => { 451 resizeToInner(win, originalWindowWidth, originalWindowHeight); 452 }); 453 454 const { client, resourceCommand, targetCommand } = 455 await initResourceCommand(tab); 456 457 info("Setup the watcher"); 458 const availableResources = []; 459 const updates = []; 460 await resourceCommand.watchResources([resourceCommand.TYPES.STYLESHEET], { 461 onAvailable: pushAvailableResource(availableResources), 462 onUpdated: newUpdates => updates.push(...newUpdates), 463 }); 464 is( 465 availableResources.length, 466 EXISTING_RESOURCES.length, 467 "Length of existing resources is correct" 468 ); 469 470 info("Apply new media query"); 471 // In order to avoid applying the media query (min-height: 400px). 472 if (originalWindowHeight !== 300) { 473 await new Promise(resolve => { 474 win.addEventListener("resize", resolve, { once: true }); 475 resizeToInner(win, originalWindowWidth, 300); 476 }); 477 } 478 479 // Retrieve the stylesheet of the top-level target 480 const resource = availableResources.find( 481 innerResource => innerResource.targetFront.isTopLevel 482 ); 483 const styleSheetsFront = await resource.targetFront.getFront("stylesheets"); 484 await styleSheetsFront.update( 485 resource.resourceId, 486 `@media (min-height: 400px) { 487 html { 488 color: red; 489 } 490 @layer myLayer { 491 @supports (container-type) { 492 :root { 493 color: gold; 494 container: root inline-size; 495 } 496 497 @container root (width > 10px) { 498 body { 499 color: gold; 500 } 501 } 502 } 503 } 504 } 505 @property --my-property { 506 syntax: "<color>"; 507 inherits: true; 508 initial-value: #f06; 509 }`, 510 false 511 ); 512 await waitUntil(() => updates.length === 3); 513 is( 514 updates.at(-1).resource.ruleCount, 515 8, 516 "Resource in update has expected ruleCount" 517 ); 518 519 is(resource.atRules[0].matches, false, "Media query is not matched yet"); 520 521 info("Change window size to fire matches-change event"); 522 resizeToInner(win, originalWindowWidth, 500); 523 await waitUntil(() => updates.length === 4); 524 525 // Check the update content. 526 const targetUpdate = updates[3]; 527 assertUpdate(targetUpdate.update, { 528 resourceId: resource.resourceId, 529 updateType: "matches-change", 530 }); 531 Assert.strictEqual( 532 resource, 533 targetUpdate.resource, 534 "Update object has the same resource" 535 ); 536 537 is( 538 JSON.stringify(targetUpdate.update.nestedResourceUpdates[0].path), 539 JSON.stringify(["atRules", 0, "matches"]), 540 "path of nestedResourceUpdates is correct" 541 ); 542 is( 543 targetUpdate.update.nestedResourceUpdates[0].value, 544 true, 545 "value of nestedResourceUpdates is correct" 546 ); 547 548 // Check the resource. 549 const expectedAtRules = [ 550 { 551 type: "media", 552 conditionText: "(min-height: 400px)", 553 matches: true, 554 }, 555 { 556 type: "layer", 557 layerName: "myLayer", 558 }, 559 { 560 type: "support", 561 conditionText: "(container-type)", 562 }, 563 { 564 type: "container", 565 conditionText: "root (width > 10px)", 566 }, 567 { 568 type: "property", 569 propertyName: "--my-property", 570 }, 571 ]; 572 573 assertAtRules(targetUpdate.resource.atRules, expectedAtRules); 574 575 // Check the actual page. 576 const styleSheetResult = await getStyleSheetResult(tab); 577 is( 578 styleSheetResult.ruleCount, 579 8, 580 "ruleCount of actual stylesheet is updated correctly" 581 ); 582 assertAtRules(styleSheetResult.atRules, expectedAtRules); 583 584 resizeToInner(win, originalWindowWidth, originalWindowHeight); 585 586 targetCommand.destroy(); 587 await client.close(); 588 } 589 590 function findMatchingExpectedResource(resource) { 591 return EXISTING_RESOURCES.find( 592 expected => 593 resource.href === expected.href && 594 resource.nodeHref === expected.nodeHref && 595 resource.ruleCount === expected.ruleCount && 596 resource.constructed == expected.constructed 597 ); 598 } 599 600 async function getStyleSheetResult(tab) { 601 const result = await ContentTask.spawn(tab.linkedBrowser, null, () => { 602 const document = content.document; 603 const stylesheet = document.styleSheets[0]; 604 let ruleCount = 0; 605 const atRules = []; 606 607 const traverseRules = ruleList => { 608 for (const rule of ruleList) { 609 ruleCount++; 610 611 if (rule.media) { 612 let matches = false; 613 try { 614 const mql = content.matchMedia(rule.media.mediaText); 615 matches = mql.matches; 616 } catch (e) { 617 // Ignored 618 } 619 620 atRules.push({ 621 type: "media", 622 conditionText: rule.conditionText, 623 matches, 624 }); 625 } else if (rule instanceof content.CSSContainerRule) { 626 atRules.push({ 627 type: "container", 628 conditionText: rule.conditionText, 629 }); 630 } else if (rule instanceof content.CSSLayerBlockRule) { 631 atRules.push({ type: "layer", layerName: rule.name }); 632 } else if (rule instanceof content.CSSSupportsRule) { 633 atRules.push({ 634 type: "support", 635 conditionText: rule.conditionText, 636 }); 637 } else if (rule instanceof content.CSSPropertyRule) { 638 atRules.push({ 639 type: "property", 640 propertyName: rule.name, 641 }); 642 } 643 644 if (rule.cssRules) { 645 traverseRules(rule.cssRules); 646 } 647 } 648 }; 649 traverseRules(stylesheet.cssRules); 650 651 return { ruleCount, atRules }; 652 }); 653 654 return result; 655 } 656 657 function assertAtRules(atRules, expectedAtRules) { 658 is( 659 atRules.length, 660 expectedAtRules.length, 661 "Length of the atRules is correct" 662 ); 663 664 for (let i = 0; i < atRules.length; i++) { 665 const atRule = atRules[i]; 666 const expected = expectedAtRules[i]; 667 is(atRule.type, expected.type, "at-rule is of expected type"); 668 is( 669 atRules[i].conditionText, 670 expected.conditionText, 671 "conditionText is correct" 672 ); 673 if (expected.type === "media") { 674 is(atRule.matches, expected.matches, "matches is correct"); 675 } else if (expected.type === "layer") { 676 is(atRule.layerName, expected.layerName, "layerName is correct"); 677 } else if (expected.type === "property") { 678 is(atRule.propertyName, expected.propertyName, "propertyName is correct"); 679 } 680 681 if (expected.line !== undefined) { 682 is(atRule.line, expected.line, "line is correct"); 683 } 684 685 if (expected.column !== undefined) { 686 is(atRule.column, expected.column, "column is correct"); 687 } 688 } 689 } 690 691 async function assertResource(resource, expected) { 692 is( 693 resource.resourceType, 694 ResourceCommand.TYPES.STYLESHEET, 695 "Resource type is correct" 696 ); 697 const styleText = (await getStyleSheetResourceText(resource)).trim(); 698 is(styleText, expected.styleText, "Style text is correct"); 699 is(resource.href, expected.href, "href is correct"); 700 is(resource.nodeHref, expected.nodeHref, "nodeHref is correct"); 701 is(resource.isNew, expected.isNew, "isNew is correct"); 702 is(resource.disabled, expected.disabled, "disabled is correct"); 703 is(resource.constructed, expected.constructed, "constructed is correct"); 704 is(resource.ruleCount, expected.ruleCount, "ruleCount is correct"); 705 assertAtRules(resource.atRules, expected.atRules); 706 } 707 708 function assertUpdate(update, expected) { 709 is( 710 update.resourceType, 711 ResourceCommand.TYPES.STYLESHEET, 712 "Resource type is correct" 713 ); 714 is(update.resourceId, expected.resourceId, "resourceId is correct"); 715 is(update.updateType, expected.updateType, "updateType is correct"); 716 if (expected.event?.cause) { 717 is(update.event?.cause, expected.event.cause, "cause is correct"); 718 } 719 } 720 721 function assertDestroyed(resource, expected) { 722 is( 723 resource.resourceType, 724 ResourceCommand.TYPES.STYLESHEET, 725 "Resource type is correct" 726 ); 727 is(resource.resourceId, expected.resourceId, "resourceId is correct"); 728 } 729 730 function getResourceTimingCount(tab) { 731 return ContentTask.spawn(tab.linkedBrowser, [], () => { 732 return content.performance.getEntriesByType("resource").length; 733 }); 734 }