diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /devtools/server/tests/browser | |
parent | Initial commit. (diff) | |
download | firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'devtools/server/tests/browser')
110 files changed, 13066 insertions, 0 deletions
diff --git a/devtools/server/tests/browser/animation-data.html b/devtools/server/tests/browser/animation-data.html new file mode 100644 index 0000000000..1ee654cb17 --- /dev/null +++ b/devtools/server/tests/browser/animation-data.html @@ -0,0 +1,115 @@ +<html> +<head> + <meta charset="UTF-8"> + <title>Animation Test Data</title> + <style> + .ball { + width: 80px; + height: 80px; + border-radius: 50%; + background: #f06; + + position: absolute; + } + + .still { + top: 0; + left: 10px; + } + + .animated { + top: 100px; + left: 10px; + + animation: simple-animation 2s infinite alternate; + } + + .multi { + top: 200px; + left: 10px; + + animation: simple-animation 2s infinite alternate, + other-animation 5s infinite alternate; + } + + .delayed { + top: 300px; + left: 10px; + background: rebeccapurple; + + animation: simple-animation 3s 60s 10; + } + + .multi-finite { + top: 400px; + left: 10px; + background: yellow; + + animation: simple-animation 3s, + other-animation 4s; + } + + .short { + top: 500px; + left: 10px; + background: red; + + animation: simple-animation 2s; + } + + .long { + top: 600px; + left: 10px; + background: blue; + + animation: simple-animation 120s; + } + + .negative-delay { + top: 700px; + left: 10px; + background: gray; + + animation: simple-animation 15s -10s; + animation-fill-mode: forwards; + } + + .no-compositor { + top: 0; + right: 10px; + background: gold; + + animation: no-compositor 10s cubic-bezier(.57,-0.02,1,.31) forwards; + } + + @keyframes simple-animation { + 100% { + transform: translateX(300px); + } + } + + @keyframes other-animation { + 100% { + background: blue; + } + } + + @keyframes no-compositor { + 100% { + margin-right: 600px; + } + } + </style> +</head> +</body> + <div class="ball still"></div> + <div class="ball animated"></div> + <div class="ball multi"></div> + <div class="ball delayed"></div> + <div class="ball multi-finite"></div> + <div class="ball short"></div> + <div class="ball long"></div> + <div class="ball negative-delay"></div> + <div class="ball no-compositor"></div> +</body> +</html> diff --git a/devtools/server/tests/browser/animation.html b/devtools/server/tests/browser/animation.html new file mode 100644 index 0000000000..f7b83df283 --- /dev/null +++ b/devtools/server/tests/browser/animation.html @@ -0,0 +1,170 @@ +<!DOCTYPE html> +<style> + .not-animated { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: #eee; + } + + .simple-animation { + display: inline-block; + + width: 64px; + height: 64px; + border-radius: 50%; + background: red; + + animation: move 200s infinite; + } + + .multiple-animations { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: #eee; + + animation: move 200s infinite , glow 100s 5; + animation-timing-function: ease-out; + animation-direction: reverse; + animation-fill-mode: both; + } + + .transition { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: #f06; + + transition: width 500s ease-out; + } + .transition.get-round { + width: 200px; + } + + .long-animation { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: gold; + + animation: move 100s; + } + + .short-animation { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: purple; + + animation: move 1s; + } + + .delayed-animation { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: rebeccapurple; + + animation: move 200s 5s infinite; + } + + .delayed-transition { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: black; + + transition: width 500s 3s; + } + .delayed-transition.get-round { + width: 200px; + } + + .delayed-multiple-animations { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: green; + + animation: move .5s 1s 10, glow 1s .75s 30; + } + + .multiple-animations-2 { + display: inline-block; + + width: 50px; + height: 50px; + border-radius: 50%; + background: blue; + + animation: move .5s, glow 100s 2s infinite, grow 300s 1s 100; + } + + .all-transitions { + position: absolute; + top: 0; + right: 0; + width: 50px; + height: 50px; + background: blue; + transition: all .2s; + } + .all-transitions.expand { + width: 200px; + height: 100px; + } + + @keyframes move { + 100% { + transform: translateY(100px); + } + } + + @keyframes glow { + 100% { + background: yellow; + } + } + + @keyframes grow { + 100% { + width: 100px; + } + } +</style> +<div class="not-animated"></div> +<div class="simple-animation"></div> +<div class="multiple-animations"></div> +<div class="transition"></div> +<div class="long-animation"></div> +<div class="short-animation"></div> +<div class="delayed-animation"></div> +<div class="delayed-transition"></div> +<div class="delayed-multiple-animations"></div> +<div class="multiple-animations-2"></div> +<div class="all-transitions"></div> +<script type="text/javascript"> + "use strict"; + // Get the transitions started when the page loads + addEventListener("load", function() { + document.querySelector(".transition").classList.add("get-round"); + document.querySelector(".delayed-transition").classList.add("get-round"); + }); +</script> diff --git a/devtools/server/tests/browser/application-manifest-404-manifest.html b/devtools/server/tests/browser/application-manifest-404-manifest.html new file mode 100644 index 0000000000..fd182a69a6 --- /dev/null +++ b/devtools/server/tests/browser/application-manifest-404-manifest.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Simple manifest</title> + <link rel="manifest" href="non-existing-manifest.json"> +</head> +<body> + <p>This page links to a manifest URL that is a 404.</p> +</body> diff --git a/devtools/server/tests/browser/application-manifest-basic.html b/devtools/server/tests/browser/application-manifest-basic.html new file mode 100644 index 0000000000..a8e11a645f --- /dev/null +++ b/devtools/server/tests/browser/application-manifest-basic.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Simple manifest</title> + <link rel="manifest" href='data:application/manifest+json,{"name": "FooApp"}'> +</head> +<body> + <pre><code>{ "name": "Foo App" }</code></pre> +</body> diff --git a/devtools/server/tests/browser/application-manifest-invalid-json.html b/devtools/server/tests/browser/application-manifest-invalid-json.html new file mode 100644 index 0000000000..2717a97ddd --- /dev/null +++ b/devtools/server/tests/browser/application-manifest-invalid-json.html @@ -0,0 +1,11 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Invalid JSON</title> + <link rel="manifest" href='data:application/manifest+json,foo:'> +</head> +<body> + <p>Invalid JSON:</p> + <pre><code>foo:</code></pre> +</body> diff --git a/devtools/server/tests/browser/application-manifest-no-manifest.html b/devtools/server/tests/browser/application-manifest-no-manifest.html new file mode 100644 index 0000000000..5f0668aa50 --- /dev/null +++ b/devtools/server/tests/browser/application-manifest-no-manifest.html @@ -0,0 +1,9 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>No manifest</title> +</head> +<body> + <p>This page does not link to a manifest</p> +</body> diff --git a/devtools/server/tests/browser/application-manifest-warnings.html b/devtools/server/tests/browser/application-manifest-warnings.html new file mode 100644 index 0000000000..57f8b9b4e7 --- /dev/null +++ b/devtools/server/tests/browser/application-manifest-warnings.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Empty manifest</title> + <link rel="manifest" href='data:application/manifest+json,{"name": 0}'> +</head> +<body> + <pre><code>{ }</code></pre> +</body> diff --git a/devtools/server/tests/browser/browser.ini b/devtools/server/tests/browser/browser.ini new file mode 100644 index 0000000000..771bd6f95f --- /dev/null +++ b/devtools/server/tests/browser/browser.ini @@ -0,0 +1,151 @@ +[DEFAULT] +tags = devtools +subsuite = devtools +skip-if = http3 # Bug 1829298 +support-files = + head.js + animation.html + animation-data.html + application-manifest-404-manifest.html + application-manifest-basic.html + application-manifest-invalid-json.html + application-manifest-no-manifest.html + application-manifest-warnings.html + doc_accessibility_audit.html + doc_accessibility_infobar.html + doc_accessibility_keyboard_audit.html + doc_accessibility_text_label_audit_frame.html + doc_accessibility_text_label_audit.html + doc_accessibility.html + doc_allocations.html + doc_compatibility.html + doc_force_cc.html + doc_force_gc.html + doc_innerHTML.html + doc_iframe.html + doc_iframe_content.html + doc_iframe2.html + error-actor.js + grid.html + inspector-isScrollable-data.html + inspector-search-data.html + inspector-traversal-data.html + inspector-shadow.html + storage-cookies-same-name.html + storage-dynamic-windows.html + storage-listings.html + storage-unsecured-iframe.html + storage-updates.html + storage-secured-iframe.html + test-errors-actor.js + test-window.xhtml + inspector-helpers.js + storage-helpers.js + !/devtools/client/shared/test/shared-head.js + !/devtools/client/shared/test/telemetry-test-helpers.js + !/devtools/server/tests/chrome/hello-actor.js + +[browser_accessibility_highlighter_infobar.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_infobar_show.js] +[browser_accessibility_keyboard_audit.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_infobar_audit_keyboard.js] +[browser_accessibility_infobar_audit_text_label.js] +[browser_accessibility_node.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_node_audit.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_node_events.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_node_tabbing_order_highlighter.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_simple.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_simulator.js] +[browser_accessibility_tabbing_order_highlighter.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_text_label_audit_frame.js] +skip-if = + (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_text_label_audit.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_walker_audit.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533184 +[browser_accessibility_walker.js] +skip-if = (os == 'win' && processor == 'aarch64') # bug 1533487 +[browser_actor_error.js] +[browser_animation_actor-lifetime.js] +[browser_animation_emitMutations.js] +[browser_animation_getMultipleStates.js] +[browser_animation_getPlayers.js] +[browser_animation_getStateAfterFinished.js] +[browser_animation_getSubTreeAnimations.js] +[browser_animation_keepFinished.js] +[browser_animation_playerState.js] +[browser_animation_playPauseIframe.js] +[browser_animation_playPauseSeveral.js] +[browser_animation_reconstructState.js] +[browser_animation_refreshTransitions.js] +[browser_animation_setCurrentTime.js] +[browser_animation_setPlaybackRate.js] +[browser_animation_simple.js] +[browser_animation_updatedState.js] +[browser_application_manifest.js] +[browser_canvasframe_helper_01.js] +skip-if = true # Bug 1183605 +[browser_canvasframe_helper_02.js] +skip-if = true # iframe will not be loaded in xul:window with strict xhtml. +[browser_canvasframe_helper_03.js] +skip-if = true # Bug 1183605 +[browser_canvasframe_helper_04.js] +skip-if = true # Bug 1183605 +[browser_canvasframe_helper_05.js] +skip-if = true # Bug 1183605 +[browser_canvasframe_helper_06.js] +skip-if = true # Bug 1183605 +[browser_compatibility_cssIssues.js] +[browser_connectToFrame.js] +[browser_debugger_server.js] +[browser_getProcess.js] +[browser_inspector-anonymous.js] +[browser_inspector-iframe.js] +[browser_inspector-insert.js] +[browser_inspector-isScrollable.js] +[browser_inspector-mutations-childlist.js] +skip-if = + win10_2004 && fission && debug && socketprocess_networking # high frequency intermittent +[browser_inspector-release.js] +[browser_inspector-remove.js] +[browser_inspector-retain.js] +[browser_inspector-search.js] +[browser_inspector-shadow.js] +[browser_inspector-traversal.js] +[browser_inspector-utils.js] +[browser_layout_getGrids.js] +[browser_layout_simple.js] +[browser_memory_allocations_01.js] +[browser_perf-01.js] +skip-if = tsan # bug 1804081, profiler issues in TSAN +[browser_perf-02.js] +skip-if = tsan # bug 1804081, profiler issues in TSAN +[browser_perf-04.js] +skip-if = tsan # bug 1804081, profiler issues in TSAN +[browser_perf-getSupportedFeatures.js] +skip-if = tsan # bug 1804081, profiler issues in TSAN +[browser_storage_cookies-duplicate-names.js] +https_first_disabled = true +[browser_storage_dynamic_windows.js] +https_first_disabled = true +skip-if = + debug # Bug 1715916 - test is having race conditions on slow hardware + tsan # high frequency intermittent + win10_2004 && asan && fission # high frequency intermittent + win11_2009 && asan # high frequency intermittent +[browser_storage_listings.js] +https_first_disabled = true +[browser_storage_updates.js] +https_first_disabled = true +[browser_style_utils_getFontPreviewData.js] +[browser_styles_getRuleText.js] +[browser_stylesheets_getTextEmpty.js] diff --git a/devtools/server/tests/browser/browser_accessibility_highlighter_infobar.js b/devtools/server/tests/browser/browser_accessibility_highlighter_infobar.js new file mode 100644 index 0000000000..0979276230 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_highlighter_infobar.js @@ -0,0 +1,73 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Test the accessible highlighter's infobar content. + +const { + truncateString, +} = require("resource://devtools/shared/inspector/utils.js"); +const { + MAX_STRING_LENGTH, +} = require("resource://devtools/server/actors/highlighters/utils/accessibility.js"); + +add_task(async function () { + const { target, walker, parentAccessibility, a11yWalker } = + await initAccessibilityFrontsForUrl( + MAIN_DOMAIN + "doc_accessibility_infobar.html" + ); + + info("Button front checks"); + await checkNameAndRole(walker, "#button", a11yWalker, "Accessible Button"); + + info("Front with long name checks"); + await checkNameAndRole( + walker, + "#h1", + a11yWalker, + "Lorem ipsum dolor sit ame" + "\u2026" + "e et dolore magna aliqua." + ); + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +/** + * A helper function for testing the accessible's displayed name and roles. + * + * @param {Object} walker + * The DOM walker. + * @param {String} querySelector + * The selector for the node to retrieve accessible from. + * @param {Object} a11yWalker + * The accessibility walker. + * @param {String} expectedName + * Expected string content for displaying the accessible's name. + * We are testing this in particular because name can be truncated. + */ +async function checkNameAndRole( + walker, + querySelector, + a11yWalker, + expectedName +) { + const node = await walker.querySelector(walker.rootNode, querySelector); + const accessibleFront = await a11yWalker.getAccessibleFor(node); + + const { name, role } = accessibleFront; + const onHighlightEvent = a11yWalker.once("highlighter-event"); + + await a11yWalker.highlightAccessible(accessibleFront); + const { options } = await onHighlightEvent; + is(options.name, name, "Accessible highlight has correct name option"); + is(options.role, role, "Accessible highlight has correct role option"); + + is( + `"${truncateString(name, MAX_STRING_LENGTH)}"`, + `"${expectedName}"`, + "Accessible has correct displayed name." + ); +} diff --git a/devtools/server/tests/browser/browser_accessibility_infobar_audit_keyboard.js b/devtools/server/tests/browser/browser_accessibility_infobar_audit_keyboard.js new file mode 100644 index 0000000000..73fc7127f4 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_infobar_audit_keyboard.js @@ -0,0 +1,157 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks for the AccessibleHighlighter's infobar component and its keyboard +// audit. + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: MAIN_DOMAIN + "doc_accessibility_infobar.html", + }, + async function (browser) { + await SpecialPowers.spawn(browser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + AccessibleHighlighter, + } = require("resource://devtools/server/actors/highlighters/accessible.js"); + const { + LocalizationHelper, + } = require("resource://devtools/shared/l10n.js"); + const L10N = new LocalizationHelper( + "devtools/shared/locales/accessibility.properties" + ); + + const { + accessibility: { + AUDIT_TYPE, + ISSUE_TYPE: { + [AUDIT_TYPE.KEYBOARD]: { + INTERACTIVE_NO_ACTION, + FOCUSABLE_NO_SEMANTICS, + }, + }, + SCORES: { FAIL, WARNING }, + }, + } = require("resource://devtools/shared/constants.js"); + + /** + * Checks for updated content for an infobar. + * + * @param {Object} infobar + * Accessible highlighter's infobar component. + * @param {Object} audit + * Audit information that is passed on highlighter show. + */ + function checkKeyboard(infobar, audit) { + const { issue, score } = audit || {}; + let expected = ""; + if (issue) { + const { ISSUE_TO_INFOBAR_LABEL_MAP } = + infobar.audit.reports[AUDIT_TYPE.KEYBOARD].constructor; + expected = L10N.getStr(ISSUE_TO_INFOBAR_LABEL_MAP[issue]); + } + + is( + infobar.getTextContent("keyboard"), + expected, + "infobar keyboard audit text content is correct" + ); + if (score) { + ok(infobar.getElement("keyboard").classList.contains(score)); + } + } + + // Start testing. First, create highlighter environment and initialize. + const env = new HighlighterEnvironment(); + env.initFromWindow(content.window); + + // Wait for loading highlighter environment content to complete before creating the + // highlighter. + await new Promise(resolve => { + const doc = env.document; + + function onContentLoaded() { + if ( + doc.readyState === "interactive" || + doc.readyState === "complete" + ) { + resolve(); + } else { + doc.addEventListener("DOMContentLoaded", onContentLoaded, { + once: true, + }); + } + } + + onContentLoaded(); + }); + + // Now, we can test the Infobar's audit content. + const node = content.document.createElement("div"); + content.document.body.append(node); + const highlighter = new AccessibleHighlighter(env); + await highlighter.isReady; + const infobar = highlighter.accessibleInfobar; + const bounds = { + x: 0, + y: 0, + w: 250, + h: 100, + }; + + const tests = [ + { + desc: "Infobar is shown with no keyboard audit content when no audit.", + }, + { + desc: "Infobar is shown with no keyboard audit content when audit is null.", + audit: null, + }, + { + desc: + "Infobar is shown with no keyboard audit content when empty " + + "keyboard audit.", + audit: { [AUDIT_TYPE.KEYBOARD]: null }, + }, + { + desc: "Infobar is shown with keyboard audit content for an error.", + audit: { + [AUDIT_TYPE.KEYBOARD]: { + score: FAIL, + issue: INTERACTIVE_NO_ACTION, + }, + }, + }, + { + desc: "Infobar is shown with keyboard audit content for a warning.", + audit: { + [AUDIT_TYPE.KEYBOARD]: { + score: WARNING, + issue: FOCUSABLE_NO_SEMANTICS, + }, + }, + }, + ]; + + for (const test of tests) { + const { desc, audit } = test; + + info(desc); + highlighter.show(node, { ...bounds, audit }); + checkKeyboard(infobar, audit && audit[AUDIT_TYPE.KEYBOARD]); + highlighter.hide(); + } + }); + } + ); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_infobar_audit_text_label.js b/devtools/server/tests/browser/browser_accessibility_infobar_audit_text_label.js new file mode 100644 index 0000000000..a4e2d895ee --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_infobar_audit_text_label.js @@ -0,0 +1,164 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks for the AccessibleHighlighter's infobar component and its text label +// audit. + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: MAIN_DOMAIN + "doc_accessibility_infobar.html", + }, + async function (browser) { + await SpecialPowers.spawn(browser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + AccessibleHighlighter, + } = require("resource://devtools/server/actors/highlighters/accessible.js"); + const { + LocalizationHelper, + } = require("resource://devtools/shared/l10n.js"); + const L10N = new LocalizationHelper( + "devtools/shared/locales/accessibility.properties" + ); + + const { + accessibility: { + AUDIT_TYPE, + ISSUE_TYPE: { + [AUDIT_TYPE.TEXT_LABEL]: { + DIALOG_NO_NAME, + FORM_NO_VISIBLE_NAME, + TOOLBAR_NO_NAME, + }, + }, + SCORES: { BEST_PRACTICES, FAIL, WARNING }, + }, + } = require("resource://devtools/shared/constants.js"); + + /** + * Checks for updated content for an infobar. + * + * @param {Object} infobar + * Accessible highlighter's infobar component. + * @param {Object} audit + * Audit information that is passed on highlighter show. + */ + function checkTextLabel(infobar, audit) { + const { issue, score } = audit || {}; + let expected = ""; + if (issue) { + const { ISSUE_TO_INFOBAR_LABEL_MAP } = + infobar.audit.reports[AUDIT_TYPE.TEXT_LABEL].constructor; + expected = L10N.getStr(ISSUE_TO_INFOBAR_LABEL_MAP[issue]); + } + + is( + infobar.getTextContent("text-label"), + expected, + "infobar text label audit text content is correct" + ); + if (score) { + ok(infobar.getElement("text-label").classList.contains(score)); + } + } + + // Start testing. First, create highlighter environment and initialize. + const env = new HighlighterEnvironment(); + env.initFromWindow(content.window); + + // Wait for loading highlighter environment content to complete before creating the + // highlighter. + await new Promise(resolve => { + const doc = env.document; + + function onContentLoaded() { + if ( + doc.readyState === "interactive" || + doc.readyState === "complete" + ) { + resolve(); + } else { + doc.addEventListener("DOMContentLoaded", onContentLoaded, { + once: true, + }); + } + } + + onContentLoaded(); + }); + + // Now, we can test the Infobar's audit content. + const node = content.document.createElement("div"); + content.document.body.append(node); + const highlighter = new AccessibleHighlighter(env); + await highlighter.isReady; + const infobar = highlighter.accessibleInfobar; + const bounds = { + x: 0, + y: 0, + w: 250, + h: 100, + }; + + const tests = [ + { + desc: "Infobar is shown with no text label audit content when no audit.", + }, + { + desc: "Infobar is shown with no text label audit content when audit is null.", + audit: null, + }, + { + desc: + "Infobar is shown with no text label audit content when empty " + + "text label audit.", + audit: { [AUDIT_TYPE.TEXT_LABEL]: null }, + }, + { + desc: "Infobar is shown with text label audit content for an error.", + audit: { + [AUDIT_TYPE.TEXT_LABEL]: { score: FAIL, issue: TOOLBAR_NO_NAME }, + }, + }, + { + desc: "Infobar is shown with text label audit content for a warning.", + audit: { + [AUDIT_TYPE.TEXT_LABEL]: { + score: WARNING, + issue: FORM_NO_VISIBLE_NAME, + }, + }, + }, + { + desc: "Infobar is shown with text label audit content for best practices.", + audit: { + [AUDIT_TYPE.TEXT_LABEL]: { + score: BEST_PRACTICES, + issue: DIALOG_NO_NAME, + }, + }, + }, + ]; + + for (const test of tests) { + const { desc, audit } = test; + + info(desc); + highlighter.show(node, { ...bounds, audit }); + checkTextLabel(infobar, audit && audit[AUDIT_TYPE.TEXT_LABEL]); + highlighter.hide(); + } + }); + } + ); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_infobar_show.js b/devtools/server/tests/browser/browser_accessibility_infobar_show.js new file mode 100644 index 0000000000..9fedf6d3b4 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_infobar_show.js @@ -0,0 +1,181 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks for the AccessibleHighlighter's and XULWindowHighlighter's infobar components. + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: MAIN_DOMAIN + "doc_accessibility_infobar.html", + }, + async function (browser) { + await SpecialPowers.spawn(browser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + AccessibleHighlighter, + } = require("resource://devtools/server/actors/highlighters/accessible.js"); + + /** + * Get whether or not infobar container is hidden. + * + * @param {Object} infobar + * Accessible highlighter's infobar component. + * @return {String|null} If the infobar container is hidden. + */ + function isContainerHidden(infobar) { + return !!infobar + .getElement("infobar-container") + .getAttribute("hidden"); + } + + /** + * Get name of accessible object. + * + * @param {Object} infobar + * Accessible highlighter's infobar component. + * @return {String} The text content of the infobar-name element. + */ + function getName(infobar) { + return infobar.getTextContent("infobar-name"); + } + + /** + * Get role of accessible object. + * + * @param {Object} infobar + * Accessible highlighter's infobar component. + * @return {String} The text content of the infobar-role element. + */ + function getRole(infobar) { + return infobar.getTextContent("infobar-role"); + } + + /** + * Checks for updated content for an infobar with valid bounds. + * + * @param {Object} infobar + * Accessible highlighter's infobar component. + * @param {Object} options + * Options to pass for the highlighter's show method. + * Available options: + * - {String} role + * Role value of the accessible. + * - {String} name + * Name value of the accessible. + * - {Boolean} shouldBeHidden + * If the infobar component should be hidden. + */ + function checkInfobar(infobar, { shouldBeHidden, role, name }) { + is( + isContainerHidden(infobar), + shouldBeHidden, + "Infobar's hidden state is correct." + ); + + if (shouldBeHidden) { + return; + } + + is(getRole(infobar), role, "infobarRole text content is correct"); + is( + getName(infobar), + `"${name}"`, + "infoBarName text content is correct" + ); + } + + /** + * Checks for updated content of an infobar with valid bounds. + * + * @param {Element} node + * Node to check infobar content on. + * @param {Object} highlighter + * Accessible highlighter. + */ + function testInfobar(node, highlighter) { + const infobar = highlighter.accessibleInfobar; + const bounds = { + x: 0, + y: 0, + w: 250, + h: 100, + }; + + info("Check that infobar is shown with valid bounds."); + highlighter.show(node, { + ...bounds, + role: "button", + name: "Accessible Button", + }); + + checkInfobar(infobar, { + role: "button", + name: "Accessible Button", + shouldBeHidden: false, + }); + highlighter.hide(); + + info("Check that infobar is hidden after .hide() is called."); + checkInfobar(infobar, { shouldBeHidden: true }); + + info("Check to make sure content is updated with new options."); + highlighter.show(node, { + ...bounds, + name: "Test link", + role: "link", + }); + checkInfobar(infobar, { + name: "Test link", + role: "link", + shouldBeHidden: false, + }); + highlighter.hide(); + } + + // Start testing. First, create highlighter environment and initialize. + const env = new HighlighterEnvironment(); + env.initFromWindow(content.window); + + // Wait for loading highlighter environment content to complete before creating the + // highlighter. + await new Promise(resolve => { + const doc = env.document; + + function onContentLoaded() { + if ( + doc.readyState === "interactive" || + doc.readyState === "complete" + ) { + resolve(); + } else { + doc.addEventListener("DOMContentLoaded", onContentLoaded, { + once: true, + }); + } + } + + onContentLoaded(); + }); + + // Now, we can test the Infobar and XULWindowInfobar components with their + // respective highlighters. + const node = content.document.createElement("div"); + content.document.body.append(node); + + info("Checks for Infobar's show method"); + const highlighter = new AccessibleHighlighter(env); + await highlighter.isReady; + testInfobar(node, highlighter); + }); + } + ); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_keyboard_audit.js b/devtools/server/tests/browser/browser_accessibility_keyboard_audit.js new file mode 100644 index 0000000000..5cf211acfa --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_keyboard_audit.js @@ -0,0 +1,371 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Checks functionality around text label audit for the AccessibleActor. + */ + +const { + accessibility: { + AUDIT_TYPE: { KEYBOARD }, + SCORES: { FAIL, WARNING }, + ISSUE_TYPE: { + [KEYBOARD]: { + FOCUSABLE_NO_SEMANTICS, + FOCUSABLE_POSITIVE_TABINDEX, + INTERACTIVE_NO_ACTION, + INTERACTIVE_NOT_FOCUSABLE, + MOUSE_INTERACTIVE_ONLY, + NO_FOCUS_VISIBLE, + }, + }, + }, +} = require("resource://devtools/shared/constants.js"); + +add_task(async function () { + const { target, walker, parentAccessibility, a11yWalker } = + await initAccessibilityFrontsForUrl( + `${MAIN_DOMAIN}doc_accessibility_keyboard_audit.html` + ); + + const tests = [ + [ + "Focusable element (styled button) with no semantics.", + "#button-1", + { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS }, + ], + ["Element (styled button) with no semantics.", "#button-2", null], + [ + "Container element for out of order focusable element.", + "#input-container", + null, + ], + [ + "Interactive element with focus out of order (-1).", + "#input-1", + { + score: FAIL, + issue: INTERACTIVE_NOT_FOCUSABLE, + }, + ], + [ + "Interactive element with focus out of order (-1) when disabled.", + "#input-2", + null, + ], + ["Interactive element when disabled.", "#input-3", null], + ["Focusable interactive element.", "#input-4", null], + [ + "Interactive accesible (link with no attributes) with no accessible actions.", + "#link-1", + { + score: FAIL, + issue: INTERACTIVE_NO_ACTION, + }, + ], + ["Interactive accessible (link with valid href).", "#link-2", null], + ["Interactive accessible (link with # as href).", "#link-3", null], + [ + "Interactive accessible (link with empty string as href).", + "#link-4", + null, + ], + ["Interactive accessible with no tabindex.", "#button-3", null], + [ + "Interactive accessible with -1 tabindex.", + "#button-4", + { + score: FAIL, + issue: INTERACTIVE_NOT_FOCUSABLE, + }, + ], + ["Interactive accessible with 0 tabindex.", "#button-5", null], + [ + "Interactive accessible with 1 tabindex.", + "#button-6", + { score: WARNING, issue: FOCUSABLE_POSITIVE_TABINDEX }, + ], + [ + "Focusable ARIA button with no focus styling.", + "#focusable-1", + { score: WARNING, issue: NO_FOCUS_VISIBLE }, + ], + ["Focusable ARIA button with focus styling.", "#focusable-2", null], + ["Focusable ARIA button with browser styling.", "#focusable-3", null], + [ + "Not focusable, non-semantic element that has a click handler.", + "#mouse-only-1", + { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY }, + ], + [ + "Focusable, non-semantic element that has a click handler.", + "#focusable-4", + { score: WARNING, issue: FOCUSABLE_NO_SEMANTICS }, + ], + [ + "Not focusable, ARIA button that has a click handler.", + "#button-7", + { score: FAIL, issue: INTERACTIVE_NOT_FOCUSABLE }, + ], + ["Focusable, ARIA button with a click handler.", "#button-8", null], + ["Regular image, no keyboard checks should flag an issue.", "#img-1", null], + [ + "Image with a longdesc (accessible will have showlongdesc action).", + "#img-2", + null, + ], + [ + "Clickable image with a longdesc (accessible will have click and showlongdesc actions).", + "#img-3", + { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY }, + ], + [ + "Clickable image (accessible will have click action).", + "#img-4", + { score: FAIL, issue: MOUSE_INTERACTIVE_ONLY }, + ], + ["Focusable button with aria-haspopup.", "#buttonmenu-1", null], + [ + "Not focusable aria button with aria-haspopup.", + "#buttonmenu-2", + { + score: FAIL, + issue: INTERACTIVE_NOT_FOCUSABLE, + }, + ], + ["Focusable checkbox.", "#checkbox-1", null], + ["Focusable select element size > 1", "#listbox-1", null], + ["Focusable select element with one option", "#combobox-1", null], + ["Focusable select element with no options", "#combobox-2", null], + ["Focusable select element with two options", "#combobox-3", null], + [ + "Non-focusable aria combobox with one aria option.", + "#editcombobox-1", + null, + ], + ["Non-focusable aria combobox with no options.", "#editcombobox-2", null], + ["Focusable aria combobox with no options.", "#editcombobox-3", null], + [ + "Non-focusable aria switch", + "#switch-1", + { + score: FAIL, + issue: INTERACTIVE_NOT_FOCUSABLE, + }, + ], + ["Focusable aria switch", "#switch-2", null], + [ + "Combobox list that is visible (has focusable state)", + "#owned_listbox", + null, + ], + [ + "Mouse interactive, label that contains form element (linked)", + "#label-1", + null, + ], + ["Mouse interactive label for external element (linked)", "#label-2", null], + ["Not interactive unlinked label", "#label-3", null], + [ + "Not interactive unlinked label with folloing form element", + "#label-4", + null, + ], + ["Image inside an anchor (href)", "#img-5", null], + ["Image inside an anchor (onmousedown)", "#img-6", null], + ["Image inside an anchor (onclick)", "#img-7", null], + ["Image inside an anchor (onmouseup)", "#img-8", null], + [ + "Section with a collapse action from aria-expanded attribute", + "#section-1", + null, + ], + ["Tabindex -1 should not report an element as focusable", "#main", null], + [ + "Not keyboard focusable element with no focus styling.", + "#not-keyboard-focusable-1", + null, + ], + ["Interactive grid that is not focusable.", "#grid-1", null], + ["Focusable interactive grid.", "#grid-2", null], + [ + "Non interactive ARIA table does not need to be focusable.", + "#table-1", + null, + ], + [ + "Focusable ARIA table does not have interactive semantics", + "#table-2", + { score: "WARNING", issue: "FOCUSABLE_NO_SEMANTICS" }, + ], + ["Non interactive table does not need to be focusable.", "#table-3", null], + [ + "Focusable table does not have interactive semantics", + "#table-4", + { score: "WARNING", issue: "FOCUSABLE_NO_SEMANTICS" }, + ], + [ + "Article that is not focusable is not considered interactive", + "#article-1", + null, + ], + ["Focusable article is considered interactive", "#article-2", null], + [ + "Column header that is not focusable is not considered interactive (ARIA grid)", + "#columnheader-1", + null, + ], + [ + "Column header that is not focusable is not considered interactive (ARIA table)", + "#columnheader-2", + null, + ], + [ + "Column header that is not focusable is not considered interactive (table)", + "#columnheader-3", + null, + ], + [ + "Column header that is focusable is considered interactive (table)", + "#columnheader-4", + null, + ], + [ + "Column header that is not focusable is not considered interactive (table as ARIA grid)", + "#columnheader-5", + null, + ], + [ + "Column header that is focusable is considered interactive (table as ARIA grid)", + "#columnheader-6", + null, + ], + [ + "Row header that is not focusable is not considered interactive", + "#rowheader-1", + null, + ], + [ + "Row header that is not focusable is not considered interactive", + "#rowheader-2", + null, + ], + [ + "Row header that is not focusable is not considered interactive", + "#rowheader-3", + null, + ], + [ + "Row header that is focusable is considered interactive", + "#rowheader-4", + null, + ], + [ + "Row header that is not focusable is not considered interactive (table as ARIA grid)", + "#rowheader-5", + null, + ], + [ + "Row header that is focusable is considered interactive (table as ARIA grid)", + "#rowheader-6", + null, + ], + [ + "Gridcell that is not focusable is not considered interactive (ARIA grid)", + "#gridcell-1", + null, + ], + [ + "Gridcell that is focusable is considered interactive (ARIA grid)", + "#gridcell-2", + null, + ], + [ + "Gridcell that is not focusable is not considered interactive (table as ARIA grid)", + "#gridcell-3", + null, + ], + [ + "Gridcell that is focusable is considered interactive (table as ARIA grid)", + "#gridcell-4", + null, + ], + [ + "Tab list that is not focusable is not considered interactive", + "#tablist-1", + null, + ], + ["Focusable tab list is considered interactive", "#tablist-2", null], + [ + "Scrollbar that is not focusable is not considered interactive", + "#scrollbar-1", + null, + ], + ["Focusable scrollbar is considered interactive", "#scrollbar-2", null], + [ + "Separator that is not focusable is not considered interactive", + "#separator-1", + null, + ], + ["Focusable separator is considered interactive", "#separator-2", null], + [ + "Toolbar that is not focusable is not considered interactive", + "#toolbar-1", + null, + ], + ["Focusable toolbar is considered interactive", "#toolbar-2", null], + [ + "Menu popup that is not focusable is not considered interactive", + "#menu-1", + null, + ], + ["Focusable menu popup is considered interactive", "#menu-2", null], + [ + "Menubar that is not focusable is not considered interactive", + "#menubar-1", + null, + ], + ["Focusable menubar is considered interactive", "#menubar-2", null], + ]; + + for (const [description, selector, expected] of tests) { + info(description); + const node = await walker.querySelector(walker.rootNode, selector); + const front = await a11yWalker.getAccessibleFor(node); + const audit = await front.audit({ types: [KEYBOARD] }); + Assert.deepEqual( + audit[KEYBOARD], + expected, + `Audit result for ${selector} is correct.` + ); + } + + info("Text leaf inside a link (jump action is propagated to the text link)"); + let node = await walker.querySelector(walker.rootNode, "#link-5"); + let parent = await a11yWalker.getAccessibleFor(node); + let front = (await parent.children())[0]; + let audit = await front.audit({ types: [KEYBOARD] }); + Assert.deepEqual( + audit[KEYBOARD], + null, + "Text leafs are excluded from semantics rule." + ); + + info("Combobox list that is invisible"); + node = await walker.querySelector(walker.rootNode, "#combobox-1"); + parent = await a11yWalker.getAccessibleFor(node); + front = (await parent.children())[0]; + audit = await front.audit({ types: [KEYBOARD] }); + Assert.deepEqual( + audit[KEYBOARD], + null, + "Combobox lists (invisible) are excluded from semantics rule." + ); + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_node.js b/devtools/server/tests/browser/browser_accessibility_node.js new file mode 100644 index 0000000000..a7f2749f30 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_node.js @@ -0,0 +1,150 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks for the AccessibleActor + +add_task(async function () { + const { target, walker, a11yWalker, parentAccessibility } = + await initAccessibilityFrontsForUrl(MAIN_DOMAIN + "doc_accessibility.html"); + const modifiers = + Services.appinfo.OS === "Darwin" ? "\u2303\u2325" : "Alt+Shift+"; + + const buttonNode = await walker.querySelector(walker.rootNode, "#button"); + const accessibleFront = await a11yWalker.getAccessibleFor(buttonNode); + + checkA11yFront(accessibleFront, { + name: "Accessible Button", + role: "button", + childCount: 1, + }); + + await accessibleFront.hydrate(); + + checkA11yFront(accessibleFront, { + name: "Accessible Button", + role: "button", + value: "", + description: "Accessibility Test", + keyboardShortcut: modifiers + "b", + childCount: 1, + domNodeType: 1, + indexInParent: 1, + states: ["focusable", "opaque", "enabled", "sensitive"], + actions: ["Press"], + attributes: { + "margin-top": "0px", + display: "inline-block", + "text-align": "center", + "text-indent": "0px", + "margin-left": "0px", + tag: "button", + "margin-right": "0px", + id: "button", + "margin-bottom": "0px", + }, + }); + + info("Children"); + const children = await accessibleFront.children(); + is(children.length, 1, "Accessible Front has correct number of children"); + checkA11yFront(children[0], { + name: "Accessible Button", + role: "text leaf", + }); + + info("Relations"); + const labelNode = await walker.querySelector(walker.rootNode, "#label"); + const controlNode = await walker.querySelector(walker.rootNode, "#control"); + const labelAccessibleFront = await a11yWalker.getAccessibleFor(labelNode); + const controlAccessibleFront = await a11yWalker.getAccessibleFor(controlNode); + const docAccessibleFront = await a11yWalker.getAccessibleFor(walker.rootNode); + const relations = await labelAccessibleFront.getRelations(); + is(relations.length, 2, "Accessible front has a correct number of relations"); + is(relations[0].type, "label for", "Label has a label for relation"); + is(relations[0].targets.length, 1, "Label is a label for one target"); + is( + relations[0].targets[0], + controlAccessibleFront, + "Label is a label for control accessible front" + ); + is( + relations[1].type, + "containing document", + "Label has a containing document relation" + ); + is(relations[1].targets.length, 1, "Label is contained by just one document"); + is( + relations[1].targets[0], + docAccessibleFront, + "Label's containing document is a root document" + ); + + info("Snapshot"); + const snapshot = await controlAccessibleFront.snapshot(); + Assert.deepEqual(snapshot, { + name: "Label", + role: "textbox", + actions: ["Activate"], + value: "", + nodeCssSelector: "#control", + nodeType: 1, + description: "", + keyboardShortcut: "", + childCount: 0, + indexInParent: 1, + states: [ + "focusable", + "autocompletion", + "selectable text", + "editable", + "opaque", + "single line", + "enabled", + "sensitive", + ], + children: [], + attributes: { + "margin-left": "0px", + "text-align": "start", + "text-indent": "0px", + id: "control", + tag: "input", + "margin-top": "0px", + "margin-bottom": "0px", + "margin-right": "0px", + display: "inline-block", + "explicit-name": "true", + }, + }); + + // Check that we're using ARIA role tokens for landmarks implicit in native + // markup. + const headerNode = await walker.querySelector(walker.rootNode, "#header"); + const headerAccessibleFront = await a11yWalker.getAccessibleFor(headerNode); + checkA11yFront(headerAccessibleFront, { + name: null, + role: "banner", + childCount: 1, + }); + const navNode = await walker.querySelector(walker.rootNode, "#nav"); + const navAccessibleFront = await a11yWalker.getAccessibleFor(navNode); + checkA11yFront(navAccessibleFront, { + name: null, + role: "navigation", + childCount: 1, + }); + const footerNode = await walker.querySelector(walker.rootNode, "#footer"); + const footerAccessibleFront = await a11yWalker.getAccessibleFor(footerNode); + checkA11yFront(footerAccessibleFront, { + name: null, + role: "contentinfo", + childCount: 1, + }); + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_node_audit.js b/devtools/server/tests/browser/browser_accessibility_node_audit.js new file mode 100644 index 0000000000..a3115a2846 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_node_audit.js @@ -0,0 +1,116 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Checks functionality around audit for the AccessibleActor. This includes + * tests for the return value when calling the audit method, payload of the + * corresponding event as well as the AccesibleFront state being up to date. + */ + +const { + accessibility: { AUDIT_TYPE, SCORES }, +} = require("resource://devtools/shared/constants.js"); +const EMPTY_AUDIT = Object.keys(AUDIT_TYPE).reduce((audit, key) => { + audit[key] = null; + return audit; +}, {}); + +const EXPECTED_CONTRAST_DATA = { + value: 21, + color: [0, 0, 0, 1], + backgroundColor: [255, 255, 255, 1], + isLargeText: true, + score: SCORES.AAA, +}; + +const EMPTY_CONTRAST_AUDIT = { + [AUDIT_TYPE.CONTRAST]: null, +}; + +const CONTRAST_AUDIT = { + [AUDIT_TYPE.CONTRAST]: EXPECTED_CONTRAST_DATA, +}; + +const FULL_AUDIT = { + ...EMPTY_AUDIT, + [AUDIT_TYPE.CONTRAST]: EXPECTED_CONTRAST_DATA, +}; + +async function checkAudit(a11yWalker, node, expected, options) { + const front = await a11yWalker.getAccessibleFor(node); + const [textLeafNode] = await front.children(); + + const onAudited = textLeafNode.once("audited"); + const audit = await textLeafNode.audit(options); + const auditFromEvent = await onAudited; + + Assert.deepEqual(audit, expected.audit, "Audit results are correct."); + Assert.deepEqual(textLeafNode.checks, expected.checks, "Checks are correct."); + Assert.deepEqual( + auditFromEvent, + expected.audit, + "Audit results from event are correct." + ); +} + +add_task(async function () { + const { target, walker, a11yWalker, parentAccessibility } = + await initAccessibilityFrontsForUrl( + MAIN_DOMAIN + "doc_accessibility_infobar.html" + ); + + const headerNode = await walker.querySelector(walker.rootNode, "#h1"); + await checkAudit( + a11yWalker, + headerNode, + { audit: CONTRAST_AUDIT, checks: CONTRAST_AUDIT }, + { types: [AUDIT_TYPE.CONTRAST] } + ); + await checkAudit(a11yWalker, headerNode, { + audit: FULL_AUDIT, + checks: FULL_AUDIT, + }); + await checkAudit( + a11yWalker, + headerNode, + { audit: CONTRAST_AUDIT, checks: FULL_AUDIT }, + { types: [AUDIT_TYPE.CONTRAST] } + ); + await checkAudit( + a11yWalker, + headerNode, + { audit: FULL_AUDIT, checks: FULL_AUDIT }, + { types: [] } + ); + + const paragraphNode = await walker.querySelector(walker.rootNode, "#p"); + await checkAudit( + a11yWalker, + paragraphNode, + { audit: EMPTY_CONTRAST_AUDIT, checks: EMPTY_CONTRAST_AUDIT }, + { types: [AUDIT_TYPE.CONTRAST] } + ); + await checkAudit(a11yWalker, paragraphNode, { + audit: EMPTY_AUDIT, + checks: EMPTY_AUDIT, + }); + await checkAudit( + a11yWalker, + paragraphNode, + { audit: EMPTY_CONTRAST_AUDIT, checks: EMPTY_AUDIT }, + { types: [AUDIT_TYPE.CONTRAST] } + ); + await checkAudit( + a11yWalker, + paragraphNode, + { audit: EMPTY_AUDIT, checks: EMPTY_AUDIT }, + { types: [] } + ); + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_node_events.js b/devtools/server/tests/browser/browser_accessibility_node_events.js new file mode 100644 index 0000000000..77a1e7892f --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_node_events.js @@ -0,0 +1,197 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks for the AccessibleActor events + +add_task(async function () { + const { target, walker, a11yWalker, parentAccessibility } = + await initAccessibilityFrontsForUrl(MAIN_DOMAIN + "doc_accessibility.html"); + const modifiers = + Services.appinfo.OS === "Darwin" ? "\u2303\u2325" : "Alt+Shift+"; + + const rootNode = await walker.getRootNode(); + const a11yDoc = await a11yWalker.getAccessibleFor(rootNode); + const buttonNode = await walker.querySelector(walker.rootNode, "#button"); + const accessibleFront = await a11yWalker.getAccessibleFor(buttonNode); + const sliderNode = await walker.querySelector(walker.rootNode, "#slider"); + const accessibleSliderFront = await a11yWalker.getAccessibleFor(sliderNode); + const browser = gBrowser.selectedBrowser; + + checkA11yFront(accessibleFront, { + name: "Accessible Button", + role: "button", + childCount: 1, + }); + + await accessibleFront.hydrate(); + + checkA11yFront(accessibleFront, { + name: "Accessible Button", + role: "button", + value: "", + description: "Accessibility Test", + keyboardShortcut: modifiers + "b", + childCount: 1, + domNodeType: 1, + indexInParent: 1, + states: ["focusable", "opaque", "enabled", "sensitive"], + actions: ["Press"], + attributes: { + "margin-top": "0px", + display: "inline-block", + "text-align": "center", + "text-indent": "0px", + "margin-left": "0px", + tag: "button", + "margin-right": "0px", + id: "button", + "margin-bottom": "0px", + }, + }); + + info("Name change event"); + await emitA11yEvent( + accessibleFront, + "name-change", + (name, parent) => { + checkA11yFront(accessibleFront, { name: "Renamed" }); + checkA11yFront(parent, {}, a11yDoc); + }, + () => + SpecialPowers.spawn(browser, [], () => + content.document + .getElementById("button") + .setAttribute("aria-label", "Renamed") + ) + ); + + info("Description change event"); + await emitA11yEvent( + accessibleFront, + "description-change", + () => checkA11yFront(accessibleFront, { description: "" }), + () => + SpecialPowers.spawn(browser, [], () => + content.document + .getElementById("button") + .removeAttribute("aria-describedby") + ) + ); + + info("State change event"); + const expectedStates = ["unavailable", "opaque"]; + await emitA11yEvent( + accessibleFront, + "states-change", + newStates => { + checkA11yFront(accessibleFront, { states: expectedStates }); + SimpleTest.isDeeply(newStates, expectedStates, "States are updated"); + }, + () => + SpecialPowers.spawn(browser, [], () => + content.document.getElementById("button").setAttribute("disabled", true) + ) + ); + + info("Attributes change event"); + await emitA11yEvent( + accessibleFront, + "attributes-change", + newAttrs => { + checkA11yFront(accessibleFront, { + attributes: { + "container-live": "polite", + display: "inline-block", + "event-from-input": "false", + "explicit-name": "true", + id: "button", + live: "polite", + "margin-bottom": "0px", + "margin-left": "0px", + "margin-right": "0px", + "margin-top": "0px", + tag: "button", + "text-align": "center", + "text-indent": "0px", + }, + }); + is(newAttrs.live, "polite", "Attributes are updated"); + }, + () => + SpecialPowers.spawn(browser, [], () => + content.document + .getElementById("button") + .setAttribute("aria-live", "polite") + ) + ); + + info("Value change event"); + await accessibleSliderFront.hydrate(); + checkA11yFront(accessibleSliderFront, { value: "5" }); + await emitA11yEvent( + accessibleSliderFront, + "value-change", + () => checkA11yFront(accessibleSliderFront, { value: "6" }), + () => + SpecialPowers.spawn(browser, [], () => + content.document + .getElementById("slider") + .setAttribute("aria-valuenow", "6") + ) + ); + + info("Reorder event"); + is(accessibleSliderFront.childCount, 1, "Slider has only 1 child"); + const [firstChild] = await accessibleSliderFront.children(); + await firstChild.hydrate(); + is( + firstChild.indexInParent, + 0, + "Slider's first child has correct index in parent" + ); + await emitA11yEvent( + accessibleSliderFront, + "reorder", + childCount => { + is(childCount, 2, "Child count is updated"); + is(accessibleSliderFront.childCount, 2, "Child count is updated"); + is( + firstChild.indexInParent, + 1, + "Slider's first child has an updated index in parent" + ); + }, + () => + SpecialPowers.spawn(browser, [], () => { + const doc = content.document; + const slider = doc.getElementById("slider"); + const button = doc.createElement("button"); + button.innerText = "Slider button"; + content.document + .getElementById("slider") + .insertBefore(button, slider.firstChild); + }) + ); + + await emitA11yEvent( + firstChild, + "index-in-parent-change", + indexInParent => + is( + indexInParent, + 0, + "Slider's first child has an updated index in parent" + ), + () => + SpecialPowers.spawn(browser, [], () => + content.document.getElementById("slider").firstChild.remove() + ) + ); + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_node_tabbing_order_highlighter.js b/devtools/server/tests/browser/browser_accessibility_node_tabbing_order_highlighter.js new file mode 100644 index 0000000000..adb47c0ec6 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_node_tabbing_order_highlighter.js @@ -0,0 +1,92 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks for the NodeTabbingOrderHighlighter. + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: MAIN_DOMAIN + "doc_accessibility_infobar.html", + }, + async function (browser) { + await SpecialPowers.spawn(browser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + NodeTabbingOrderHighlighter, + } = require("resource://devtools/server/actors/highlighters/node-tabbing-order.js"); + + // Checks for updated content for an infobar. + async function testShowHide(highlighter, node, index) { + const shown = highlighter.show(node, { index }); + const infoBarText = highlighter.getElement("infobar-text"); + + ok(shown, "Highlighter is shown."); + is( + parseInt(infoBarText.getTextContent(), 10), + index, + "infobar text content is correct" + ); + + highlighter.hide(); + } + + // Start testing. First, create highlighter environment and initialize. + const env = new HighlighterEnvironment(); + env.initFromWindow(content.window); + + // Wait for loading highlighter environment content to complete before + // creating the highlighter. + await new Promise(resolve => { + const doc = env.document; + + function onContentLoaded() { + if ( + doc.readyState === "interactive" || + doc.readyState === "complete" + ) { + resolve(); + } else { + doc.addEventListener("DOMContentLoaded", onContentLoaded, { + once: true, + }); + } + } + + onContentLoaded(); + }); + + // Now, we can test the Infobar's index content. + const node = content.document.createElement("div"); + content.document.body.append(node); + const highlighter = new NodeTabbingOrderHighlighter(env); + await highlighter.isReady; + + info("Showing Node tabbing order highlighter with index"); + await testShowHide(highlighter, node, 1); + + info("Showing Node tabbing order highlighter with new index"); + await testShowHide(highlighter, node, 9); + + info( + "Showing and highlighting focused node with the Node tabbing order highlighter" + ); + highlighter.show(node, { index: 1 }); + highlighter.updateFocus(true); + const { classList } = highlighter.getElement("root"); + ok(classList.contains("focused"), "Focus styling is applied"); + highlighter.updateFocus(false); + ok(!classList.contains("focused"), "Focus styling is removed"); + highlighter.hide(); + }); + } + ); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_simple.js b/devtools/server/tests/browser/browser_accessibility_simple.js new file mode 100644 index 0000000000..518d4dbb99 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_simple.js @@ -0,0 +1,106 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const PREF_ACCESSIBILITY_FORCE_DISABLED = "accessibility.force_disabled"; + +function checkAccessibilityState(accessibility, parentAccessibility, expected) { + const { enabled } = accessibility; + const { canBeDisabled, canBeEnabled } = parentAccessibility; + is(enabled, expected.enabled, "Enabled state is correct."); + is(canBeDisabled, expected.canBeDisabled, "canBeDisabled state is correct."); + is(canBeEnabled, expected.canBeEnabled, "canBeEnabled state is correct."); +} + +// Simple checks for the AccessibilityActor and AccessibleWalkerActor + +add_task(async function () { + const { + walker: domWalker, + target, + accessibility, + parentAccessibility, + a11yWalker, + } = await initAccessibilityFrontsForUrl( + "data:text/html;charset=utf-8,<title>test</title><div></div>", + { enableByDefault: false } + ); + + ok(accessibility, "The AccessibilityFront was created"); + ok(accessibility.getWalker, "The getWalker method exists"); + ok(accessibility.getSimulator, "The getSimulator method exists"); + + ok(accessibility.accessibleWalkerFront, "Accessible walker was initialized"); + + is( + a11yWalker, + accessibility.accessibleWalkerFront, + "The AccessibleWalkerFront was returned" + ); + + const a11ySimulator = accessibility.simulatorFront; + ok(accessibility.simulatorFront, "Accessible simulator was initialized"); + is( + a11ySimulator, + accessibility.simulatorFront, + "The SimulatorFront was returned" + ); + + checkAccessibilityState(accessibility, parentAccessibility, { + enabled: false, + canBeDisabled: true, + canBeEnabled: true, + }); + + info("Force disable accessibility service: updates canBeEnabled flag"); + let onEvent = parentAccessibility.once("can-be-enabled-change"); + Services.prefs.setIntPref(PREF_ACCESSIBILITY_FORCE_DISABLED, 1); + await onEvent; + checkAccessibilityState(accessibility, parentAccessibility, { + enabled: false, + canBeDisabled: true, + canBeEnabled: false, + }); + + info("Clear force disable accessibility service: updates canBeEnabled flag"); + onEvent = parentAccessibility.once("can-be-enabled-change"); + Services.prefs.clearUserPref(PREF_ACCESSIBILITY_FORCE_DISABLED); + await onEvent; + checkAccessibilityState(accessibility, parentAccessibility, { + enabled: false, + canBeDisabled: true, + canBeEnabled: true, + }); + + info("Initialize accessibility service"); + const initEvent = accessibility.once("init"); + await parentAccessibility.enable(); + await waitForA11yInit(); + await initEvent; + checkAccessibilityState(accessibility, parentAccessibility, { + enabled: true, + canBeDisabled: true, + canBeEnabled: true, + }); + + const rootNode = await domWalker.getRootNode(); + const a11yDoc = await accessibility.accessibleWalkerFront.getAccessibleFor( + rootNode + ); + ok(a11yDoc, "Accessible document actor is created"); + + info("Shutdown accessibility service"); + const shutdownEvent = accessibility.once("shutdown"); + await waitForA11yShutdown(parentAccessibility); + await shutdownEvent; + checkAccessibilityState(accessibility, parentAccessibility, { + enabled: false, + canBeDisabled: true, + canBeEnabled: true, + }); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_simulator.js b/devtools/server/tests/browser/browser_accessibility_simulator.js new file mode 100644 index 0000000000..47e3b898a3 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_simulator.js @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + accessibility: { + SIMULATION_TYPE: { PROTANOPIA }, + }, +} = require("resource://devtools/shared/constants.js"); +const { + simulation: { + COLOR_TRANSFORMATION_MATRICES: { + PROTANOPIA: PROTANOPIA_MATRIX, + NONE: DEFAULT_MATRIX, + }, + }, +} = require("resource://devtools/server/actors/accessibility/constants.js"); + +// Checks for the SimulatorActor + +async function setup() { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.window.testColorMatrix = function (actual, expected) { + for (const idx in actual) { + is( + actual[idx].toFixed(3), + expected[idx].toFixed(3), + "Color matrix value is set correctly." + ); + } + }; + }); + SimpleTest.registerCleanupFunction(async function () { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.window.testColorMatrix = null; + }); + }); +} + +async function testSimulate(simulator, matrix, type = null) { + const matrixApplied = await simulator.simulate({ types: type ? [type] : [] }); + ok(matrixApplied, "Simulation color matrix is successfully applied."); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[type, matrix]], + ([simulationType, simulationMatrix]) => { + const { window } = content; + info( + `Test that color matrix is set to ${ + simulationType || "default" + } simulation values.` + ); + window.testColorMatrix( + window.docShell.getColorMatrix(), + simulationMatrix + ); + } + ); +} + +add_task(async function () { + const { target, accessibility } = await initAccessibilityFrontsForUrl( + MAIN_DOMAIN + "doc_accessibility.html", + { enableByDefault: false } + ); + + const simulator = accessibility.simulatorFront; + if (!simulator) { + ok(false, "Missing simulator actor."); + return; + } + + await setup(); + + info("Test that protanopia is successfully simulated."); + await testSimulate(simulator, PROTANOPIA_MATRIX, PROTANOPIA); + + info( + "Test that simulations are successfully removed by setting default color matrix." + ); + await testSimulate(simulator, DEFAULT_MATRIX); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_tabbing_order_highlighter.js b/devtools/server/tests/browser/browser_accessibility_tabbing_order_highlighter.js new file mode 100644 index 0000000000..fb99534318 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_tabbing_order_highlighter.js @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks for the TabbingOrderHighlighter. + +add_task(async function () { + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: MAIN_DOMAIN + "doc_accessibility_infobar.html", + }, + async function (browser) { + await SpecialPowers.spawn(browser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + TabbingOrderHighlighter, + } = require("resource://devtools/server/actors/highlighters/tabbing-order.js"); + + // Start testing. First, create highlighter environment and initialize. + const env = new HighlighterEnvironment(); + env.initFromWindow(content.window); + + // Wait for loading highlighter environment content to complete before + // creating the highlighter. + await new Promise(resolve => { + const doc = env.document; + + function onContentLoaded() { + if ( + doc.readyState === "interactive" || + doc.readyState === "complete" + ) { + resolve(); + } else { + doc.addEventListener("DOMContentLoaded", onContentLoaded, { + once: true, + }); + } + } + + onContentLoaded(); + }); + + // Now, we can test the Infobar's index content. + const node = content.document.createElement("div"); + content.document.body.append(node); + const highlighter = new TabbingOrderHighlighter(env); + await highlighter.isReady; + + info("Showing tabbing order highlighter for all tabbable nodes"); + const { contentDOMReference, index } = await highlighter.show( + content.document, + { + index: 0, + } + ); + + is( + contentDOMReference, + null, + "No current element when at the end of the tab order" + ); + is(index, 2, "Current index is correct"); + is( + highlighter._highlighters.size, + 2, + "Number of node tabbing order highlighters is correct" + ); + for (let i = 0; i < highlighter._highlighters.size; i++) { + const nodeHighlighter = [...highlighter._highlighters.values()][i]; + const infoBarText = nodeHighlighter.getElement("infobar-text"); + + is( + parseInt(infoBarText.getTextContent(), 10), + i + 1, + "infobar text content is correct" + ); + } + + info("Showing focus highlighting"); + const input = content.document.getElementById("input"); + highlighter.updateFocus({ node: input, focused: true }); + const nodeHighlighter = highlighter._highlighters.get(input); + const { classList } = nodeHighlighter.getElement("root"); + ok(classList.contains("focused"), "Focus styling is applied"); + highlighter.updateFocus({ node: input, focused: false }); + ok(!classList.contains("focused"), "Focus styling is removed"); + + highlighter.hide(); + }); + } + ); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_text_label_audit.js b/devtools/server/tests/browser/browser_accessibility_text_label_audit.js new file mode 100644 index 0000000000..dad2bcaa75 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_text_label_audit.js @@ -0,0 +1,1138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Checks functionality around text label audit for the AccessibleActor. + */ + +const { + accessibility: { + AUDIT_TYPE: { TEXT_LABEL }, + SCORES: { BEST_PRACTICES, FAIL, WARNING }, + ISSUE_TYPE: { + [TEXT_LABEL]: { + DIALOG_NO_NAME, + DOCUMENT_NO_TITLE, + EMBED_NO_NAME, + FIGURE_NO_NAME, + FORM_FIELDSET_NO_NAME, + FORM_FIELDSET_NO_NAME_FROM_LEGEND, + FORM_NO_NAME, + FORM_NO_VISIBLE_NAME, + FORM_OPTGROUP_NO_NAME_FROM_LABEL, + HEADING_NO_CONTENT, + HEADING_NO_NAME, + IFRAME_NO_NAME_FROM_TITLE, + IMAGE_NO_NAME, + INTERACTIVE_NO_NAME, + MATHML_GLYPH_NO_NAME, + TOOLBAR_NO_NAME, + }, + }, + }, +} = require("resource://devtools/shared/constants.js"); + +add_task(async function () { + const { target, walker, a11yWalker, parentAccessibility } = + await initAccessibilityFrontsForUrl( + `${MAIN_DOMAIN}doc_accessibility_text_label_audit.html` + ); + + const tests = [ + ["Button menu with inner content", "#buttonmenu-1", null], + ["Button menu nested inside a <label>", "#buttonmenu-2", null], + [ + "Button menu with no name", + "#buttonmenu-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Button menu with aria-label", "#buttonmenu-4", null], + ["Button menu with <label>", "#buttonmenu-5", null], + ["Button menu with aria-labelledby", "#buttonmenu-6", null], + ["Paragraph with inner content", "#p1", null], + ["Empty paragraph", "#p2", null], + [ + "<canvas> with no name", + "#canvas-1", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + ["<canvas> with aria-label", "#canvas-2", null], + ["<canvas> with aria-labelledby", "#canvas-3", null], + [ + "<canvas> with inner content", + "#canvas-4", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + [ + "Checkbox with no name", + "#checkbox-1", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Checkbox with unrelated label", + "#checkbox-2", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["Checkbox nested inside a <label>", "#checkbox-3", null], + ["Checkbox with a label", "#checkbox-4", null], + [ + "Checkbox with aria-label", + "#checkbox-5", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + ["Checkbox with aria-labelledby visible label", "#checkbox-6", null], + [ + "Empty aria checkbox", + "#checkbox-7", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria checkbox with aria-label", + "#checkbox-8", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + ["Aria checkbox with aria-labelledby visible label", "#checkbox-9", null], + ["Menuitem checkbox with inner content", "#menuitemcheckbox-1", null], + [ + "Menuitem checkbox with unlabelled inner content", + "#menuitemcheckbox-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Empty menuitem checkbox", + "#menuitemcheckbox-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Menuitem checkbox with no textual inner content", + "#menuitemcheckbox-4", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Menuitem checkbox with labelled inner content", + "#menuitemcheckbox-5", + null, + ], + [ + "Menuitem checkbox with white space inner content", + "#menuitemcheckbox-6", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Column header with inner content", "#columnheader-1", null], + [ + "Empty column header", + "#columnheader-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Column header with white space inner content", + "#columnheader-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Column header with aria-label", "#columnheader-4", null], + [ + "Column header with empty aria-label", + "#columnheader-5", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Column header with white space aria-label", + "#columnheader-6", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Column header with aria-labelledby", "#columnheader-7", null], + ["Aria column header with inner content", "#columnheader-8", null], + [ + "Empty aria column header", + "#columnheader-9", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria column header with white space inner content", + "#columnheader-10", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria column header with aria-label", "#columnheader-11", null], + [ + "Aria column header with empty aria-label", + "#columnheader-12", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria column header with white space aria-label", + "#columnheader-13", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria column header with aria-labelledby", "#columnheader-14", null], + ["Combobox with a <label>", "#combobox-1", null], + [ + "Combobox with no label", + "#combobox-2", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Combobox with unrelated label", + "#combobox-3", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["Combobox nested inside a label", "#combobox-4", null], + [ + "Combobox with aria-label", + "#combobox-5", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + ["Combobox with aria-labelledby a visible label", "#combobox-6", null], + ["Combobox option with inner content", "#combobox-option-1", null], + [ + "Combobox option with no inner content", + "#combobox-option-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Combobox option with white string inner content", + "#combobox-option-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Combobox option with label attribute", "#combobox-option-4", null], + [ + "Combobox option with empty label attribute", + "#combobox-option-5", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Combobox option with white string label attribute", + "#combobox-option-6", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Svg diagram with no name", + "#diagram-1", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + [ + "Svg diagram with empty aria-label", + "#diagram-2", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + ["Svg diagram with aria-label", "#diagram-3", null], + ["Svg diagram with aria-labelledby", "#diagram-4", null], + [ + "Svg diagram with aria-labelledby an element with empty content", + "#diagram-5", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + [ + "Dialog with no name", + "#dialog-1", + { score: BEST_PRACTICES, issue: DIALOG_NO_NAME }, + ], + [ + "Dialog with empty aria-label", + "#dialog-2", + { score: BEST_PRACTICES, issue: DIALOG_NO_NAME }, + ], + ["Dialog with aria-label", "#dialog-3", null], + ["Dialog with aria-labelledby", "#dialog-4", null], + [ + "Aria dialog with no name", + "#dialog-5", + { score: BEST_PRACTICES, issue: DIALOG_NO_NAME }, + ], + [ + "Aria dialog with empty aria-label", + "#dialog-6", + { score: BEST_PRACTICES, issue: DIALOG_NO_NAME }, + ], + ["Aria dialog with aria-label", "#dialog-7", null], + ["Aria dialog with aria-labelledby", "#dialog-8", null], + [ + "Dialog with aria-labelledby an element with empty content", + "#dialog-9", + { score: BEST_PRACTICES, issue: DIALOG_NO_NAME }, + ], + [ + "Aria dialog with aria-labelledby an element with empty content", + "#dialog-10", + { score: BEST_PRACTICES, issue: DIALOG_NO_NAME }, + ], + [ + "Edit combobox with no name", + "#editcombobox-1", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Edit combobox with aria-label", + "#editcombobox-2", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Edit combobox with aria-labelled a visible label", + "#editcombobox-3", + null, + ], + ["Input nested inside a <label>", "#entry-1", null], + ["Input with no name", "#entry-2", { score: FAIL, issue: FORM_NO_NAME }], + [ + "Input with aria-label", + "#entry-3", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Input with unrelated <label>", + "#entry-4", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["Input with <label>", "#entry-5", null], + ["Input with aria-labelledby", "#entry-6", null], + [ + "Aria textbox with no name", + "#entry-7", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria textbox with aria-label", + "#entry-8", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + ["Aria textbox with aria-labelledby", "#entry-9", null], + ["Figure with <figcaption>", "#figure-1", null], + [ + "Figore with no <figcaption>", + "#figure-2", + { score: BEST_PRACTICES, issue: FIGURE_NO_NAME }, + ], + ["Aria figure with aria-labelledby", "#figure-3", null], + [ + "Aria figure with aria-labelledby an element with empty content", + "#figure-4", + { score: BEST_PRACTICES, issue: FIGURE_NO_NAME }, + ], + [ + "Aria figure with no name", + "#figure-5", + { score: BEST_PRACTICES, issue: FIGURE_NO_NAME }, + ], + ["Image with no alt text", "#img-1", { score: FAIL, issue: IMAGE_NO_NAME }], + ["Image with aria-label", "#img-2", null], + ["Image with aria-labelledby", "#img-3", null], + ["Image with alt text", "#img-4", null], + [ + "Image with aria-labelledby an element with empty content", + "#img-5", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + [ + "Aria image with no name", + "#img-6", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + ["Aria image with aria-label", "#img-7", null], + ["Aria image with aria-labelledby", "#img-8", null], + [ + "Aria image with empty aria-label", + "#img-9", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + [ + "Aria image with aria-labelledby an element with empty content", + "#img-10", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + ["<optgroup> with label", "#optgroup-1", null], + [ + "<optgroup> with empty label", + "#optgroup-2", + { score: FAIL, issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL }, + ], + [ + "<optgroup> with no label", + "#optgroup-3", + { score: FAIL, issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL }, + ], + [ + "<optgroup> with aria-label", + "#optgroup-4", + { score: FAIL, issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL }, + ], + [ + "<optgroup> with aria-labelledby", + "#optgroup-5", + { score: FAIL, issue: FORM_OPTGROUP_NO_NAME_FROM_LABEL }, + ], + ["<fieldset> with <legend>", "#fieldset-1", null], + [ + "<fieldset> with empty <legend>", + "#fieldset-2", + { score: FAIL, issue: FORM_FIELDSET_NO_NAME }, + ], + [ + "<fieldset> with no <legend>", + "#fieldset-3", + { score: FAIL, issue: FORM_FIELDSET_NO_NAME }, + ], + [ + "<fieldset> with aria-label", + "#fieldset-4", + { score: WARNING, issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND }, + ], + [ + "<fieldset> with aria-labelledby", + "#fieldset-5", + { score: WARNING, issue: FORM_FIELDSET_NO_NAME_FROM_LEGEND }, + ], + ["Empty <h1>", "#heading-1", { score: FAIL, issue: HEADING_NO_NAME }], + ["<h1> with inner content", "#heading-2", null], + [ + "<h1> with white space inner content", + "#heading-3", + { score: FAIL, issue: HEADING_NO_NAME }, + ], + [ + "<h1> with aria-label", + "#heading-4", + { score: WARNING, issue: HEADING_NO_CONTENT }, + ], + [ + "<h1> with aria-labelledby", + "#heading-5", + { score: WARNING, issue: HEADING_NO_CONTENT }, + ], + ["<h1> with inner content and aria-label", "#heading-6", null], + ["<h1> with inner content and aria-labelledby", "#heading-7", null], + [ + "Empty aria heading", + "#heading-8", + { score: FAIL, issue: HEADING_NO_NAME }, + ], + ["Aria heading with content", "#heading-9", null], + [ + "Aria heading with white space inner content", + "#heading-10", + { score: FAIL, issue: HEADING_NO_NAME }, + ], + [ + "Aria heading with aria-label", + "#heading-11", + { score: WARNING, issue: HEADING_NO_CONTENT }, + ], + [ + "Aria heading with aria-labelledby", + "#heading-12", + { score: WARNING, issue: HEADING_NO_CONTENT }, + ], + ["Aria heading with inner content and aria-label", "#heading-13", null], + [ + "Aria heading with inner content and aria-labelledby", + "#heading-14", + null, + ], + [ + "Image map with no name", + "#imagemap-1", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + ["Image map with aria-label", "#imagemap-2", null], + ["Image map with aria-labelledby", "#imagemap-3", null], + ["Image map with alt attribute", "#imagemap-4", null], + [ + "Image map with aria-labelledby an element with empty content", + "#imagemap-5", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + ["<iframe> with title", "#iframe-1", null], + [ + "<iframe> with empty title", + "#iframe-2", + { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE }, + ], + [ + "<iframe> with no title", + "#iframe-3", + { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE }, + ], + [ + "<iframe> with aria-label", + "#iframe-4", + { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE }, + ], + [ + "<iframe> with aria-label and title", + "#iframe-5", + { score: FAIL, issue: IFRAME_NO_NAME_FROM_TITLE }, + ], + [ + "<object> with image data type and no name", + "#object-1", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + ["<object> with image data type and aria-label", "#object-2", null], + ["<object> with image data type and aria-labelledby", "#object-3", null], + ["<object> with non-image data type", "#object-4", null], + [ + "<embed> with image data type and no name", + "#embed-1", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + [ + "<embed> with video data type and no name", + "#embed-2", + { score: FAIL, issue: EMBED_NO_NAME }, + ], + ["<embed> with video data type and aria-label", "#embed-3", null], + ["<embed> with video data type and aria-labelledby", "#embed-4", null], + [ + "Link with no inner content", + "#link-1", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Link with inner content", "#link-2", null], + [ + "Link with href and no inner content", + "#link-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Link with href and inner content", "#link-4", null], + [ + "Link with empty href and no inner content", + "#link-5", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Link with empty href and inner content", "#link-6", null], + [ + "Link with # href and no inner content", + "#link-7", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Link with # href and inner content", "#link-8", null], + [ + "Link with non empty href and no inner content", + "#link-9", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Link with non empty href and inner content", "#link-10", null], + ["Link with aria-label", "#link-11", null], + ["Link with aria-labelledby", "#link-12", null], + [ + "Aria link with no inner content", + "#link-13", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria link with inner content", "#link-14", null], + ["Aria link with aria-label", "#link-15", null], + ["Aria link with aria-labelledby", "#link-16", null], + ["<select> with a visible <label>", "#listbox-1", null], + [ + "<select> with no name", + "#listbox-2", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "<select> with unrelated <label>", + "#listbox-3", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["<select> nested inside a <label>", "#listbox-4", null], + [ + "<select> with aria-label", + "#listbox-5", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + ["<select> with aria-labelledby a visible element", "#listbox-6", null], + [ + "MathML glyph with no name", + "#mglyph-1", + { score: FAIL, issue: MATHML_GLYPH_NO_NAME }, + ], + ["MathML glyph with aria-label", "#mglyph-2", null], + ["MathML glyph with aria-labelledby", "#mglyph-3", null], + ["MathML glyph with alt text", "#mglyph-4", null], + [ + "MathML glyph with empty alt text", + "#mglyph-5", + { score: FAIL, issue: MATHML_GLYPH_NO_NAME }, + ], + [ + "MathML glyph with aria-labelledby an element with no inner content", + "#mglyph-6", + { score: FAIL, issue: MATHML_GLYPH_NO_NAME }, + ], + [ + "Aria menu item with no name", + "#menuitem-1", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria menu item with empty aria-label", + "#menuitem-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria menu item with aria-label", "#menuitem-3", null], + ["Aria menu item with aria-labelledby", "#menuitem-4", null], + [ + "Aria menu item with aria-labelledby element with empty inner content", + "#menuitem-5", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria menu item with inner content", "#menuitem-6", null], + ["Option with inner content", "#option-1", null], + [ + "Option with no inner content", + "#option-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Option with white space inner ", + "#option-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Option with a label", "#option-4", null], + [ + "Option with an empty label", + "#option-5", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Option with a white space label", + "#option-6", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria option with inner content", "#option-7", null], + [ + "Aria option with no inner content", + "#option-8", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria option with white space inner content", + "#option-9", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria option with aria-label", "#option-10", null], + [ + "Aria option with empty aria-label", + "#option-11", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria option with white space aria-label", + "#option-12", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria option with aria-labelledby", "#option-13", null], + [ + "Aria option with aria-labelledby an element with empty content", + "#option-14", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria option with aria-labelledby an element with white space content", + "#option-15", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Empty aria treeitem", + "#treeitem-1", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria treeitem with empty aria-label", + "#treeitem-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria treeitem with aria-label", "#treeitem-3", null], + ["Aria treeitem with aria-labelledby", "#treeitem-4", null], + [ + "Aria treeitem with aria-labelledby an element with empty content", + "#treeitem-5", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria treeitem with inner content", "#treeitem-6", null], + [ + "Aria tab with no content", + "#tab-1", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria tab with empty aria-label", + "#tab-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria tab with aria-label", "#tab-3", null], + ["Aria tab with aria-labelledby", "#tab-4", null], + [ + "Aria tab with aria-labelledby an element with empty content", + "#tab-5", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria tab with inner content", "#tab-6", null], + ["Password nested inside a <label>", "#password-1", null], + ["Password no name", "#password-2", { score: FAIL, issue: FORM_NO_NAME }], + [ + "Password with aria-label", + "#password-3", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Password with unrelated label", + "#password-4", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["Password with <label>", "#password-5", null], + ["Password with aria-labelledby a visible element", "#password-6", null], + ["<progress> nested inside a label", "#progress-1", null], + [ + "<progress> with no name", + "#progress-2", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "<progress> with aria-label", + "#progress-3", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "<progress> with unrelated <label>", + "#progress-4", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["<progress> with <label>", "#progress-5", null], + ["<progress> with aria-labelledby a visible element", "#progress-6", null], + [ + "Aria progressbar nested inside a <label>", + "#progress-7", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria progressbar with aria-labelledby a visible element", + "#progress-8", + null, + ], + [ + "Aria progressbar no name", + "#progress-9", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria progressbar with aria-label", + "#progress-10", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Aria progressbar with unrelated <label>", + "#progress-11", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria progressbar with <label>", + "#progress-12", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria progressbar with aria-labelledby a visible <label>", + "#progress-13", + null, + ], + ["Button with inner content", "#button-1", null], + [ + "Image button with no name", + "#button-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Button with no name", + "#button-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Image button with empty alt text", + "#button-4", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Image button with alt text", "#button-5", null], + [ + "Button with white space inner content", + "#button-6", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Button inside a <label>", "#button-7", null], + ["Button with aria-label", "#button-8", null], + [ + "Button with unrelated <label>", + "#button-9", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Button with <label>", "#button-10", null], + ["Button with aria-labelledby a visile <label>", "#button-11", null], + [ + "Aria button inside a label", + "#button-12", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria button with aria-labelled by a <label>", "#button-13", null], + [ + "Aria button with no content", + "#button-14", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria button with aria-label", "#button-15", null], + [ + "Aria button with unrelated <label>", + "#button-16", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria button with <label>", + "#button-17", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria button with aria-labelledby a visible <label>", "#button-18", null], + ["Radio nested inside a label", "#radiobutton-1", null], + [ + "Radio with no name", + "#radiobutton-2", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Radio with aria-label", + "#radiobutton-3", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Radio with unrelated <label>", + "#radiobutton-4", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["Radio with visible label>", "#radiobutton-5", null], + ["Radio with aria-labelledby a visible <label>", "#radiobutton-6", null], + [ + "Aria radio with no name", + "#radiobutton-7", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria radio with aria-label", + "#radiobutton-8", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Aria radio with aria-labelledby a visible element", + "#radiobutton-9", + null, + ], + ["Aria menuitemradio with inner content", "#menuitemradio-1", null], + [ + "Aria menuitemradio with no inner content", + "#menuitemradio-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria menuitemradio with white space inner content", + "#menuitemradio-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Rowheader with inner content", "#rowheader-1", null], + [ + "Rowheader with no inner content", + "#rowheader-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Rowheader with white space inner content", + "#rowheader-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Rowheader with aria-label", "#rowheader-4", null], + [ + "Rowheader with empty aria-label", + "#rowheader-5", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Rowheader with white space aria-label", + "#rowheader-6", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Rowheader with aria-labelledby", "#rowheader-7", null], + ["Aria rowheader with inner content", "#rowheader-8", null], + [ + "Aria rowheader with no inner content", + "#rowheader-9", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria rowheader with white space inner content", + "#rowheader-10", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria rowheader with aria-label", "#rowheader-11", null], + [ + "Aria rowheader with empty aria-label", + "#rowheader-12", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria rowheader with white space aria-label", + "#rowheader-13", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria rowheader with aria-labelledby", "#rowheader-14", null], + ["Slider nested inside a <label>", "#slider-1", null], + ["Slider with no name", "#slider-2", { score: FAIL, issue: FORM_NO_NAME }], + [ + "Slider with aria-label", + "#slider-3", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Slider with unrelated <label>", + "#slider-4", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["Slider with a visible <label>", "#slider-5", null], + ["Slider with aria-labelled by a visible <label>", "#slider-6", null], + [ + "Aria slider with no name", + "#slider-7", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria slider with aria-label", + "#slider-8", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + ["Aria slider with aria-labelledby a visible element", "#slider-9", null], + ["Number input inside a label", "#spinbutton-1", null], + [ + "Number input with no label", + "#spinbutton-2", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Number input with aria-label", + "#spinbutton-3", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Number input with unrelated <label>", + "#spinbutton-4", + { score: FAIL, issue: FORM_NO_NAME }, + ], + ["Number input with visible <label>", "#spinbutton-5", null], + [ + "Number input with aria-labelled by a visible <label>", + "#spinbutton-6", + null, + ], + [ + "Aria spinbutton with no name", + "#spinbutton-7", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria spinbutton with aria-label", + "#spinbutton-8", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + [ + "Aria spinbutton with aria-labelledby a visible element", + "#spinbutton-9", + null, + ], + [ + "Aria switch with no name", + "#switch-1", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria switch wtih aria-label", + "#switch-2", + { score: WARNING, issue: FORM_NO_VISIBLE_NAME }, + ], + ["Aria switch with aria-labelledby a visible element", "#switch-3", null], + [ + "Aria switch with unrelated <label>", + "#switch-4", + { score: FAIL, issue: FORM_NO_NAME }, + ], + [ + "Aria switch nested inside a <label>", + "#switch-5", + { score: FAIL, issue: FORM_NO_NAME }, + ], + // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770 + // ["Meter inside a label", "#meter-1", null], + // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770 + // ["Meter with no name", "#meter-2", { score: FAIL, issue: FORM_NO_NAME }], + // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770 + // ["Meter with aria-label", "#meter-3", + // { score: WARNING, issue: FORM_NO_VISIBLE_NAME}], + // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770 + // ["Meter with unrelated <label>", "#meter-4", { score: FAIL, issue: FORM_NO_NAME }], + ["Meter with visible <label>", "#meter-5", null], + ["Meter with aria-labelledby a visible <label>", "#meter-6", null], + // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770 + // ["Aria meter with no name", "#meter-7", { score: FAIL, issue: FORM_NO_NAME }], + // See bug: https://bugzilla.mozilla.org/show_bug.cgi?id=559770 + // ["Aria meter with aria-label", "#meter-8", + // { score: WARNING, issue: FORM_NO_VISIBLE_NAME}], + ["Aria meter with aria-labelledby a visible element", "#meter-9", null], + ["Toggle button with inner content", "#togglebutton-1", null], + [ + "Image toggle button with no name", + "#togglebutton-2", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Empty toggle button", + "#togglebutton-3", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Image toggle button with empty alt text", + "#togglebutton-4", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Image toggle button with alt text", "#togglebutton-5", null], + [ + "Toggle button with white space inner content", + "#togglebutton-6", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Toggle button nested inside a label", "#togglebutton-7", null], + ["Toggle button with aria-label", "#togglebutton-8", null], + [ + "Toggle button with unrelated <label>", + "#togglebutton-9", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Toggle button with <label>", "#togglebutton-10", null], + [ + "Toggle button with aria-labelled by a visible <label>", + "#togglebutton-11", + null, + ], + [ + "Aria toggle button nested inside a label", + "#togglebutton-12", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria toggle button with aria-labelled by and nested inside a label", + "#togglebutton-13", + null, + ], + [ + "Aria toggle button with no name", + "#togglebutton-14", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + ["Aria toggle button with aria-label", "#togglebutton-15", null], + [ + "Aria toggle button with unrelated <label>", + "#togglebutton-16", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria toggle button with <label>", + "#togglebutton-17", + { score: FAIL, issue: INTERACTIVE_NO_NAME }, + ], + [ + "Aria toggle button with aria-labelledby a visible <label>", + "#togglebutton-18", + null, + ], + ["Non-unique aria toolbar with aria-label", "#toolbar-1", null], + [ + "Non-unique aria toolbar with no name (", + "#toolbar-2", + { score: FAIL, issue: TOOLBAR_NO_NAME }, + ], + [ + "Non-unique aAria toolbar with aria-labelledby an element with empty content", + "#toolbar-3", + { score: FAIL, issue: TOOLBAR_NO_NAME }, + ], + ["Non-unique aria toolbar with aria-labelledby", "#toolbar-4", null], + ["SVGElement with role=img that has a title", "#svg-1", null], + ["SVGElement without role=img that has a title", "#svg-2", null], + [ + "SVGElement with role=img and no name", + "#svg-3", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + [ + "SVGElement with no name", + "#svg-4", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + ["SVGElement with a name", "#svg-5", null], + [ + "SVGElement with a name and with ownerSVGElement with a name", + "#svg-6", + null, + ], + ["SVGElement with a title", "#svg-7", null], + [ + "SVGElement with a name and with ownerSVGElement with a title", + "#svg-8", + null, + ], + ["SVGElement with role=img that has a title", "#svg-9", null], + [ + "SVGElement with a name and with ownerSVGElement with role=img that has a title", + "#svg-10", + null, + ], + [ + "SVGElement with role=img and no title", + "#svg-11", + { score: FAIL, issue: IMAGE_NO_NAME }, + ], + [ + "SVGElement with a name and with ownerSVGElement with role=img and no title", + "#svg-12", + null, + ], + ]; + + for (const [description, selector, expected] of tests) { + info(description); + const node = await walker.querySelector(walker.rootNode, selector); + const front = await a11yWalker.getAccessibleFor(node); + const audit = await front.audit({ types: [TEXT_LABEL] }); + Assert.deepEqual( + audit[TEXT_LABEL], + expected, + `Audit result for ${selector} is correct.` + ); + } + + info("Test document rule:"); + const front = await a11yWalker.getAccessibleFor(walker.rootNode); + let audit = await front.audit({ types: [TEXT_LABEL] }); + info("Document with no title"); + Assert.deepEqual( + audit[TEXT_LABEL], + { score: FAIL, issue: DOCUMENT_NO_TITLE }, + "Audit result for document is correct." + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.document.title = "Hello world"; + }); + audit = await front.audit({ types: [TEXT_LABEL] }); + info("Document with title"); + Assert.deepEqual( + audit[TEXT_LABEL], + null, + "Audit result for document is correct." + ); + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_text_label_audit_frame.js b/devtools/server/tests/browser/browser_accessibility_text_label_audit_frame.js new file mode 100644 index 0000000000..fbd56cee60 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_text_label_audit_frame.js @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Checks functionality around text label audit for the AccessibleActor that is + * created for frame elements. + */ + +const { + accessibility: { + AUDIT_TYPE: { TEXT_LABEL }, + SCORES: { FAIL }, + ISSUE_TYPE: { + [TEXT_LABEL]: { FRAME_NO_NAME }, + }, + }, +} = require("resource://devtools/shared/constants.js"); + +add_task(async function () { + const { target, walker, a11yWalker, parentAccessibility } = + await initAccessibilityFrontsForUrl( + `${MAIN_DOMAIN}doc_accessibility_text_label_audit_frame.html` + ); + + const tests = [ + ["Frame with no name", "#frame-1", { score: FAIL, issue: FRAME_NO_NAME }], + ["Frame with aria-label", "#frame-2", null], + ]; + + for (const [description, selector, expected] of tests) { + info(description); + const node = await walker.querySelector(walker.rootNode, selector); + const front = await a11yWalker.getAccessibleFor(node); + const audit = await front.audit({ types: [TEXT_LABEL] }); + Assert.deepEqual( + audit[TEXT_LABEL], + expected, + `Audit result for ${selector} is correct.` + ); + } + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_walker.js b/devtools/server/tests/browser/browser_accessibility_walker.js new file mode 100644 index 0000000000..607807efb4 --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_walker.js @@ -0,0 +1,170 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +// Checks for the AccessibleWalkerActor + +add_task(async function () { + const { target, walker, a11yWalker, parentAccessibility } = + await initAccessibilityFrontsForUrl(MAIN_DOMAIN + "doc_accessibility.html"); + + ok(a11yWalker, "The AccessibleWalkerFront was returned"); + const rootNode = await walker.getRootNode(); + const a11yDoc = await a11yWalker.getAccessibleFor(rootNode); + ok(a11yDoc, "The AccessibleFront for root doc is created"); + + const children = await a11yWalker.children(); + is( + children.length, + 1, + "AccessibleWalker only has 1 child - root doc accessible" + ); + is( + a11yDoc, + children[0], + "Root accessible must be AccessibleWalker's only child" + ); + + const buttonNode = await walker.querySelector(walker.rootNode, "#button"); + const accessibleFront = await a11yWalker.getAccessibleFor(buttonNode); + + checkA11yFront(accessibleFront, { + name: "Accessible Button", + role: "button", + }); + + const ancestry = await a11yWalker.getAncestry(accessibleFront); + is(ancestry.length, 1, "Button is a direct child of a root document."); + is( + ancestry[0].accessible, + a11yDoc, + "Button's only ancestor is a root document" + ); + is( + ancestry[0].children.length, + 7, + "Root doc should have correct number of children" + ); + ok( + ancestry[0].children.includes(accessibleFront), + "Button accessible front is in root doc's children" + ); + + const browser = gBrowser.selectedBrowser; + + // Ensure name-change event is emitted by walker when cached accessible's name + // gets updated (via DOM manipularion). + await emitA11yEvent( + a11yWalker, + "name-change", + (front, parent) => { + checkA11yFront(front, { name: "Renamed" }, accessibleFront); + checkA11yFront(parent, {}, a11yDoc); + }, + () => + SpecialPowers.spawn(browser, [], () => + content.document + .getElementById("button") + .setAttribute("aria-label", "Renamed") + ) + ); + + // Ensure reorder event is emitted by walker when DOM tree changes. + let docChildren = await a11yDoc.children(); + is(docChildren.length, 7, "Root doc should have correct number of children"); + + await emitA11yEvent( + a11yWalker, + "reorder", + front => checkA11yFront(front, {}, a11yDoc), + () => + SpecialPowers.spawn(browser, [], () => { + const input = content.document.createElement("input"); + input.type = "text"; + input.title = "This is a tooltip"; + input.value = "New input"; + content.document.body.appendChild(input); + }) + ); + + docChildren = await a11yDoc.children(); + is(docChildren.length, 8, "Root doc should have correct number of children"); + + let shown = await a11yWalker.highlightAccessible(docChildren[0]); + ok(shown, "AccessibleHighlighter highlighted the node"); + + shown = await a11yWalker.highlightAccessible(a11yDoc); + ok(shown, "AccessibleHighlighter highlights the document correctly."); + await a11yWalker.unhighlight(); + + info("Checking AccessibleWalker picker functionality"); + ok(a11yWalker.pick, "AccessibleWalker pick method exists"); + ok(a11yWalker.pickAndFocus, "AccessibleWalker pickAndFocus method exists"); + ok(a11yWalker.cancelPick, "AccessibleWalker cancelPick method exists"); + + let onPickerEvent = a11yWalker.once("picker-accessible-hovered"); + await a11yWalker.pick(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#h1", + { type: "mousemove" }, + browser + ); + let acc = await onPickerEvent; + checkA11yFront(acc, { name: "Accessibility Test" }, docChildren[0]); + + onPickerEvent = a11yWalker.once("picker-accessible-previewed"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#h1", + { shiftKey: true }, + browser + ); + acc = await onPickerEvent; + checkA11yFront(acc, { name: "Accessibility Test" }, docChildren[0]); + + onPickerEvent = a11yWalker.once("picker-accessible-canceled"); + await BrowserTestUtils.synthesizeKey( + "VK_ESCAPE", + { type: "keydown" }, + browser + ); + await onPickerEvent; + + onPickerEvent = a11yWalker.once("picker-accessible-hovered"); + await a11yWalker.pick(); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#h1", + { type: "mousemove" }, + browser + ); + await onPickerEvent; + + onPickerEvent = a11yWalker.once("picker-accessible-picked"); + await BrowserTestUtils.synthesizeMouseAtCenter("#h1", {}, browser); + acc = await onPickerEvent; + checkA11yFront(acc, { name: "Accessibility Test" }, docChildren[0]); + + await a11yWalker.cancelPick(); + + info("Checking tabbing order highlighter"); + let { elm, index } = await a11yWalker.showTabbingOrder(rootNode, 0); + isnot(!!elm, "No current element when at the end of the tab order"); + is(index, 3, "Current index is correct"); + await a11yWalker.hideTabbingOrder(); + + ({ elm, index } = await a11yWalker.showTabbingOrder(buttonNode, 0)); + isnot(!!elm, "No current element when at the end of the tab order"); + is(index, 2, "Current index is correct"); + await a11yWalker.hideTabbingOrder(); + + info( + "When targets follow the WindowGlobal lifecycle and handle only one document, " + + "only check that the panel refreshes correctly and emit its 'reloaded' event" + ); + await reloadBrowser(); + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_accessibility_walker_audit.js b/devtools/server/tests/browser/browser_accessibility_walker_audit.js new file mode 100644 index 0000000000..289023043f --- /dev/null +++ b/devtools/server/tests/browser/browser_accessibility_walker_audit.js @@ -0,0 +1,155 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { + accessibility: { AUDIT_TYPE, ISSUE_TYPE, SCORES }, +} = require("resource://devtools/shared/constants.js"); + +// Checks for the AccessibleWalkerActor audit. +add_task(async function () { + const { target, a11yWalker, parentAccessibility } = + await initAccessibilityFrontsForUrl( + MAIN_DOMAIN + "doc_accessibility_audit.html" + ); + + const accessibles = [ + { + name: "", + role: "document", + childCount: 2, + checks: { + [AUDIT_TYPE.CONTRAST]: null, + [AUDIT_TYPE.KEYBOARD]: null, + [AUDIT_TYPE.TEXT_LABEL]: { + score: SCORES.FAIL, + issue: ISSUE_TYPE.DOCUMENT_NO_TITLE, + }, + }, + }, + { + name: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + + "eiusmod tempor incididunt ut labore et dolore magna aliqua.", + role: "paragraph", + childCount: 1, + checks: { + [AUDIT_TYPE.CONTRAST]: null, + [AUDIT_TYPE.KEYBOARD]: null, + [AUDIT_TYPE.TEXT_LABEL]: null, + }, + }, + { + name: + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + + "eiusmod tempor incididunt ut labore et dolore magna aliqua.", + role: "text leaf", + childCount: 0, + checks: { + [AUDIT_TYPE.CONTRAST]: { + value: 4.0, + color: [255, 0, 0, 1], + backgroundColor: [255, 255, 255, 1], + isLargeText: false, + score: SCORES.FAIL, + }, + [AUDIT_TYPE.KEYBOARD]: null, + [AUDIT_TYPE.TEXT_LABEL]: null, + }, + }, + { + name: "", + role: "paragraph", + childCount: 1, + checks: { + [AUDIT_TYPE.CONTRAST]: null, + [AUDIT_TYPE.KEYBOARD]: null, + [AUDIT_TYPE.TEXT_LABEL]: null, + }, + }, + { + name: "Accessible Paragraph", + role: "text leaf", + childCount: 0, + checks: { + [AUDIT_TYPE.CONTRAST]: { + value: 4.0, + color: [255, 0, 0, 1], + backgroundColor: [255, 255, 255, 1], + isLargeText: false, + score: SCORES.FAIL, + }, + [AUDIT_TYPE.KEYBOARD]: null, + [AUDIT_TYPE.TEXT_LABEL]: null, + }, + }, + ]; + const total = accessibles.length; + const auditProgress = [ + { total, percentage: 20, completed: 1 }, + { total, percentage: 40, completed: 2 }, + { total, percentage: 60, completed: 3 }, + { total, percentage: 80, completed: 4 }, + { total, percentage: 100, completed: 5 }, + ]; + + function findAccessible(name, role) { + return accessibles.find( + accessible => accessible.name === name && accessible.role === role + ); + } + + async function checkWalkerAudit(walker, expectedSize, options) { + info("Checking AccessibleWalker audit functionality"); + const expectedProgress = Array.from(auditProgress); + const ancestries = await new Promise((resolve, reject) => { + const auditEventHandler = ({ type, ancestries: response, progress }) => { + switch (type) { + case "error": + walker.off("audit-event", auditEventHandler); + reject(); + break; + case "completed": + walker.off("audit-event", auditEventHandler); + resolve(response); + is(expectedProgress.length, 0, "All progress events fired"); + break; + case "progress": + SimpleTest.isDeeply( + progress, + expectedProgress.shift(), + "Progress data is correct" + ); + break; + default: + break; + } + }; + + walker.on("audit-event", auditEventHandler); + walker.startAudit(options); + }); + + is(ancestries.length, expectedSize, "The size of ancestries is correct"); + for (const ancestry of ancestries) { + for (const { accessible, children } of ancestry) { + checkA11yFront( + accessible, + findAccessible(accessibles.name, accessibles.role) + ); + for (const child of children) { + checkA11yFront(child, findAccessible(child.name, child.role)); + } + } + } + } + + await checkWalkerAudit(a11yWalker, 3); + await checkWalkerAudit(a11yWalker, 2, { types: [AUDIT_TYPE.CONTRAST] }); + + await waitForA11yShutdown(parentAccessibility); + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_actor_error.js b/devtools/server/tests/browser/browser_actor_error.js new file mode 100644 index 0000000000..0c28d77cca --- /dev/null +++ b/devtools/server/tests/browser/browser_actor_error.js @@ -0,0 +1,94 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Test that clients can catch errors in actors. + */ + +const ACTORS_URL = + "chrome://mochitests/content/browser/devtools/server/tests/browser/error-actor.js"; + +add_task(async function test_old_actor() { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + + ActorRegistry.registerModule(ACTORS_URL, { + prefix: "error", + constructor: "ErrorActor", + type: { global: true }, + }); + + const transport = DevToolsServer.connectPipe(); + const gClient = new DevToolsClient(transport); + await gClient.connect(); + + const { errorActor } = await gClient.mainRoot.rootForm; + ok(errorActor, "Found the error actor."); + + await Assert.rejects( + gClient.request({ to: errorActor, type: "error" }), + err => + err.error == "unknownError" && + /error occurred while processing 'error/.test(err.message), + "The request should be rejected" + ); + + await gClient.close(); +}); + +const TEST_ERRORS_ACTOR_URL = + "chrome://mochitests/content/browser/devtools/server/tests/browser/test-errors-actor.js"; +add_task(async function test_protocoljs_actor() { + DevToolsServer.init(); + DevToolsServer.registerAllActors(); + + info("Register the new TestErrorsActor"); + require(TEST_ERRORS_ACTOR_URL); + ActorRegistry.registerModule(TEST_ERRORS_ACTOR_URL, { + prefix: "testErrors", + constructor: "TestErrorsActor", + type: { global: true }, + }); + + info("Create a DevTools client/server pair"); + const transport = DevToolsServer.connectPipe(); + const gClient = new DevToolsClient(transport); + await gClient.connect(); + + info("Retrieve a TestErrorsFront instance"); + const testErrorsFront = await gClient.mainRoot.getFront("testErrors"); + ok(testErrorsFront, "has a TestErrorsFront instance"); + + await Assert.rejects(testErrorsFront.throwsComponentsException(), e => { + return new RegExp( + `NS_ERROR_NOT_IMPLEMENTED from: ${testErrorsFront.actorID} ` + + `\\(${TEST_ERRORS_ACTOR_URL}:\\d+:\\d+\\)` + ).test(e.message); + }); + await Assert.rejects(testErrorsFront.throwsException(), e => { + // Not asserting the specific error message here, as it changes depending + // on the channel. + return new RegExp( + `Protocol error \\(TypeError\\):.* from: ${testErrorsFront.actorID} ` + + `\\(${TEST_ERRORS_ACTOR_URL}:\\d+:\\d+\\)` + ).test(e.message); + }); + await Assert.rejects(testErrorsFront.throwsJSError(), e => { + return new RegExp( + `Protocol error \\(Error\\): JSError from: ${testErrorsFront.actorID} ` + + `\\(${TEST_ERRORS_ACTOR_URL}:\\d+:\\d+\\)` + ).test(e.message); + }); + await Assert.rejects(testErrorsFront.throwsString(), e => { + return new RegExp(`ErrorString from: ${testErrorsFront.actorID}`).test( + e.message + ); + }); + await Assert.rejects(testErrorsFront.throwsObject(), e => { + return new RegExp(`foo from: ${testErrorsFront.actorID}`).test(e.message); + }); + + await gClient.close(); +}); diff --git a/devtools/server/tests/browser/browser_animation_actor-lifetime.js b/devtools/server/tests/browser/browser_animation_actor-lifetime.js new file mode 100644 index 0000000000..ef157d31fc --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_actor-lifetime.js @@ -0,0 +1,80 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for Bug 1247243 + +add_task(async function () { + info("Setting up inspector and animation actors."); + const { animations, walker } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation-data.html" + ); + + info("Testing animated node actor"); + const animatedNodeActor = await walker.querySelector( + walker.rootNode, + ".animated" + ); + await animations.getAnimationPlayersForNode(animatedNodeActor); + + await assertNumberOfAnimationActors( + 1, + "AnimationActor have 1 AnimationPlayerActors" + ); + + info("Testing AnimationPlayerActors release"); + const stillNodeActor = await walker.querySelector(walker.rootNode, ".still"); + await animations.getAnimationPlayersForNode(stillNodeActor); + await assertNumberOfAnimationActors( + 0, + "AnimationActor does not have any AnimationPlayerActors anymore" + ); + + info("Testing multi animated node actor"); + const multiNodeActor = await walker.querySelector(walker.rootNode, ".multi"); + await animations.getAnimationPlayersForNode(multiNodeActor); + await assertNumberOfAnimationActors( + 2, + "AnimationActor has now 2 AnimationPlayerActors" + ); + + info("Testing single animated node actor"); + await animations.getAnimationPlayersForNode(animatedNodeActor); + await assertNumberOfAnimationActors( + 1, + "AnimationActor has only one AnimationPlayerActors" + ); + + info("Testing AnimationPlayerActors release again"); + await animations.getAnimationPlayersForNode(stillNodeActor); + await assertNumberOfAnimationActors( + 0, + "AnimationActor does not have any AnimationPlayerActors anymore" + ); + + async function assertNumberOfAnimationActors(expected, message) { + const actors = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[animations.actorID]], + function (actorID) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + // Convert actorID to current compartment string otherwise + // searchAllConnectionsForActor is confused and won't find the actor. + actorID = String(actorID); + const animationActors = + DevToolsServer.searchAllConnectionsForActor(actorID); + if (!animationActors) { + return 0; + } + return animationActors.actors.length; + } + ); + is(actors, expected, message); + } +}); diff --git a/devtools/server/tests/browser/browser_animation_emitMutations.js b/devtools/server/tests/browser/browser_animation_emitMutations.js new file mode 100644 index 0000000000..796418c937 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_emitMutations.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the AnimationsActor emits events about changed animations on a +// node after getAnimationPlayersForNode was called on that node. + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + info("Retrieve a non-animated node"); + const node = await walker.querySelector(walker.rootNode, ".not-animated"); + + info("Retrieve the animation player for the node"); + const players = await animations.getAnimationPlayersForNode(node); + is(players.length, 0, "The node has no animation players"); + + info("Listen for new animations"); + let onMutations = once(animations, "mutations"); + + info("Add a couple of animation on the node"); + await node.modifyAttributes([ + { attributeName: "class", newValue: "multiple-animations" }, + ]); + let changes = await onMutations; + + ok(true, "The mutations event was emitted"); + is(changes.length, 2, "There are 2 changes in the mutation event"); + ok( + changes.every(({ type }) => type === "added"), + "Both changes are additions" + ); + + const names = changes.map(c => c.player.initialState.name).sort(); + is(names[0], "glow", "The animation 'glow' was added"); + is(names[1], "move", "The animation 'move' was added"); + + info("Store the 2 new players for comparing later"); + const p1 = changes[0].player; + const p2 = changes[1].player; + + info("Listen for removed animations"); + onMutations = once(animations, "mutations"); + + info("Remove the animation css class on the node"); + await node.modifyAttributes([ + { attributeName: "class", newValue: "not-animated" }, + ]); + + changes = await onMutations; + + ok(true, "The mutations event was emitted"); + is(changes.length, 2, "There are 2 changes in the mutation event"); + ok( + changes.every(({ type }) => type === "removed"), + "Both are removals" + ); + ok( + changes[0].player === p1 || changes[0].player === p2, + "The first removed player was one of the previously added players" + ); + ok( + changes[1].player === p1 || changes[1].player === p2, + "The second removed player was one of the previously added players" + ); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_getMultipleStates.js b/devtools/server/tests/browser/browser_animation_getMultipleStates.js new file mode 100644 index 0000000000..77e6a7722b --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getMultipleStates.js @@ -0,0 +1,63 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the duration, iterationCount and delay are retrieved correctly for +// multiple animations. + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + await playerHasAnInitialState(walker, animations); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function playerHasAnInitialState(walker, animations) { + let state = await getAnimationStateForNode( + walker, + animations, + ".delayed-multiple-animations", + 0 + ); + + is(state.duration, 500, "The duration of the first animation is correct"); + is( + state.iterationCount, + 10, + "The iterationCount of the first animation is correct" + ); + is(state.delay, 1000, "The delay of the first animation is correct"); + + state = await getAnimationStateForNode( + walker, + animations, + ".delayed-multiple-animations", + 1 + ); + + is(state.duration, 1000, "The duration of the second animation is correct"); + is( + state.iterationCount, + 30, + "The iterationCount of the second animation is correct" + ); + is(state.delay, 750, "The delay of the second animation is correct"); +} + +async function getAnimationStateForNode( + walker, + animations, + selector, + playerIndex +) { + const node = await walker.querySelector(walker.rootNode, selector); + const players = await animations.getAnimationPlayersForNode(node); + const player = players[playerIndex]; + const state = await player.getCurrentState(); + return state; +} diff --git a/devtools/server/tests/browser/browser_animation_getPlayers.js b/devtools/server/tests/browser/browser_animation_getPlayers.js new file mode 100644 index 0000000000..de78bab02f --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getPlayers.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check the output of getAnimationPlayersForNode + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + await theRightNumberOfPlayersIsReturned(walker, animations); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function theRightNumberOfPlayersIsReturned(walker, animations) { + let node = await walker.querySelector(walker.rootNode, ".not-animated"); + let players = await animations.getAnimationPlayersForNode(node); + is(players.length, 0, "0 players were returned for the unanimated node"); + + node = await walker.querySelector(walker.rootNode, ".simple-animation"); + players = await animations.getAnimationPlayersForNode(node); + is(players.length, 1, "One animation player was returned"); + + node = await walker.querySelector(walker.rootNode, ".multiple-animations"); + players = await animations.getAnimationPlayersForNode(node); + is(players.length, 2, "Two animation players were returned"); + + node = await walker.querySelector(walker.rootNode, ".transition"); + players = await animations.getAnimationPlayersForNode(node); + is( + players.length, + 1, + "One animation player was returned for the transitioned node" + ); +} diff --git a/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js b/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js new file mode 100644 index 0000000000..038d7b4911 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getStateAfterFinished.js @@ -0,0 +1,76 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +// Check that the right duration/iterationCount/delay are retrieved even when +// the node has multiple animations and one of them already ended before getting +// the player objects. +// See devtools/server/actors/animation.js |getPlayerIndex| for more +// information. + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + info("Retrieve a non animated node"); + const node = await walker.querySelector(walker.rootNode, ".not-animated"); + + info("Apply the multiple-animations-2 class to start the animations"); + await node.modifyAttributes([ + { attributeName: "class", newValue: "multiple-animations-2" }, + ]); + + info( + "Get the list of players, by the time this executes, the first, " + + "short, animation should have ended." + ); + let players = await animations.getAnimationPlayersForNode(node); + if (players.length === 3) { + info("The short animation hasn't ended yet, wait for a bit."); + // The animation lasts for 500ms, so 1000ms should do it. + await new Promise(resolve => setTimeout(resolve, 1000)); + + info("And get the list again"); + players = await animations.getAnimationPlayersForNode(node); + } + + is(players.length, 2, "2 animations remain on the node"); + + is( + players[0].state.duration, + 100000, + "The duration of the first animation is correct" + ); + is( + players[0].state.delay, + 2000, + "The delay of the first animation is correct" + ); + is( + players[0].state.iterationCount, + null, + "The iterationCount of the first animation is correct" + ); + + is( + players[1].state.duration, + 300000, + "The duration of the second animation is correct" + ); + is( + players[1].state.delay, + 1000, + "The delay of the second animation is correct" + ); + is( + players[1].state.iterationCount, + 100, + "The iterationCount of the second animation is correct" + ); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js b/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js new file mode 100644 index 0000000000..e8fa912fc5 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_getSubTreeAnimations.js @@ -0,0 +1,46 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the AnimationsActor can retrieve all animations inside a node's +// subtree (but not going into iframes). + +const URL = MAIN_DOMAIN + "animation.html"; + +// Import inspector's shared head. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +add_task(async function () { + info("Creating a test document with 2 iframes containing animated nodes"); + + const { inspector, target, walker, animations } = + await initAnimationsFrontForUrl( + "data:text/html;charset=utf-8," + + "<iframe id='iframe' src='" + + URL + + "'></iframe>" + ); + + info("Try retrieving all animations from the root doc's <body> node"); + const rootBody = await walker.querySelector(walker.rootNode, "body"); + let players = await animations.getAnimationPlayersForNode(rootBody); + is(players.length, 0, "The node has no animation players"); + + info("Retrieve all animations from the iframe's <body> node"); + const frameBody = await getNodeFrontInFrames(["#iframe", "body"], inspector); + const animationsForFrame = await frameBody.targetFront.getFront("animations"); + players = await animationsForFrame.getAnimationPlayersForNode(frameBody); + + // Testing for a hard-coded number of animations here would intermittently + // fail depending on how fast or slow the test is (indeed, the test page + // contains short transitions, and delayed animations). So just make sure we + // at least have the infinitely running animations. + ok(players.length >= 4, "All subtree animations were retrieved"); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_keepFinished.js b/devtools/server/tests/browser/browser_animation_keepFinished.js new file mode 100644 index 0000000000..0adb98ad69 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_keepFinished.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +// Test that the AnimationsActor doesn't report finished animations as removed. +// Indeed, animations that only have the "finished" playState can be modified +// still, so we want the AnimationsActor to preserve the corresponding +// AnimationPlayerActor. + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + info("Retrieve a non-animated node"); + const node = await walker.querySelector(walker.rootNode, ".not-animated"); + + info("Retrieve the animation player for the node"); + let players = await animations.getAnimationPlayersForNode(node); + is(players.length, 0, "The node has no animation players"); + + info("Listen for new animations"); + let reportedMutations = []; + function onMutations(mutations) { + reportedMutations = [...reportedMutations, ...mutations]; + } + animations.on("mutations", onMutations); + + info("Add a short animation on the node"); + await node.modifyAttributes([ + { attributeName: "class", newValue: "short-animation" }, + ]); + + info("Wait for longer than the animation's duration"); + await wait(2000); + + players = await animations.getAnimationPlayersForNode(node); + is(players.length, 0, "The added animation is surely finished"); + + is(reportedMutations.length, 1, "Only one mutation was reported"); + is(reportedMutations[0].type, "added", "The mutation was an addition"); + + animations.off("mutations", onMutations); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +function wait(ms) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/devtools/server/tests/browser/browser_animation_playPauseIframe.js b/devtools/server/tests/browser/browser_animation_playPauseIframe.js new file mode 100644 index 0000000000..e10fceb0bc --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_playPauseIframe.js @@ -0,0 +1,70 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the AnimationsActor can pause/play all animations even those +// within iframes. + +const URL = MAIN_DOMAIN + "animation.html"; + +// Import inspector's shared head. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/inspector/test/shared-head.js", + this +); + +add_task(async function () { + info("Creating a test document with 2 iframes containing animated nodes"); + + const { inspector, target } = await initAnimationsFrontForUrl( + "data:text/html;charset=utf-8," + + "<iframe id='i1' src='" + + URL + + "'></iframe>" + + "<iframe id='i2' src='" + + URL + + "'></iframe>" + ); + + info("Getting the 2 iframe container nodes and animated nodes in them"); + const nodeInFrame1 = await getNodeFrontInFrames( + ["#i1", ".simple-animation"], + inspector + ); + const nodeInFrame2 = await getNodeFrontInFrames( + ["#i2", ".simple-animation"], + inspector + ); + + info("Pause all animations in the test document"); + await toggleAndCheckStates(nodeInFrame1, "paused"); + await toggleAndCheckStates(nodeInFrame2, "paused"); + + info("Play all animations in the test document"); + await toggleAndCheckStates(nodeInFrame1, "running"); + await toggleAndCheckStates(nodeInFrame2, "running"); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function toggleAndCheckStates(nodeFront, playState) { + const animations = await nodeFront.targetFront.getFront("animations"); + const [player] = await animations.getAnimationPlayersForNode(nodeFront); + + if (playState === "paused") { + await animations.pauseSome([player]); + } else { + await animations.playSome([player]); + } + + info("Getting the AnimationPlayerFront for the test node"); + await player.ready; + const state = await player.getCurrentState(); + is( + state.playState, + playState, + "The playState of the test node is " + playState + ); +} diff --git a/devtools/server/tests/browser/browser_animation_playPauseSeveral.js b/devtools/server/tests/browser/browser_animation_playPauseSeveral.js new file mode 100644 index 0000000000..d478a801d0 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_playPauseSeveral.js @@ -0,0 +1,67 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the AnimationsActor can pause/play a given list of animations at once. + +// List of selectors that match "all" animated nodes in the test page. +// This list misses a bunch of animated nodes on purpose. Only the ones that +// have infinite animations are listed. This is done to avoid intermittents +// caused when finite animations are already done playing by the time the test +// runs. +const ALL_ANIMATED_NODES = [ + ".simple-animation", + ".multiple-animations", + ".delayed-animation", +]; + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + info("Pause all animations in the test document"); + await toggleAndCheckStates(walker, animations, ALL_ANIMATED_NODES, "paused"); + + info("Play all animations in the test document"); + await toggleAndCheckStates(walker, animations, ALL_ANIMATED_NODES, "running"); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function toggleAndCheckStates(walker, animations, selectors, playState) { + info( + "Checking the playState of all the nodes that have infinite running " + + "animations" + ); + + for (const selector of selectors) { + const players = await getPlayersFor(walker, animations, selector); + + if (playState === "paused") { + await animations.pauseSome(players); + } else { + await animations.playSome(players); + } + + info("Getting the AnimationPlayerFront for node " + selector); + const player = players[0]; + await checkPlayState(player, selector, playState); + } +} + +async function getPlayersFor(walker, animations, selector) { + const node = await walker.querySelector(walker.rootNode, selector); + return animations.getAnimationPlayersForNode(node); +} + +async function checkPlayState(player, selector, expectedState) { + const state = await player.getCurrentState(); + is( + state.playState, + expectedState, + "The playState of node " + selector + " is " + expectedState + ); +} diff --git a/devtools/server/tests/browser/browser_animation_playerState.js b/devtools/server/tests/browser/browser_animation_playerState.js new file mode 100644 index 0000000000..e010b576b5 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_playerState.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check the animation player's initial state + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + await playerHasAnInitialState(walker, animations); + await playerStateIsCorrect(walker, animations); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function playerHasAnInitialState(walker, animations) { + const node = await walker.querySelector(walker.rootNode, ".simple-animation"); + const [player] = await animations.getAnimationPlayersForNode(node); + + ok(player.initialState, "The player front has an initial state"); + ok("startTime" in player.initialState, "Player's state has startTime"); + ok("currentTime" in player.initialState, "Player's state has currentTime"); + ok("playState" in player.initialState, "Player's state has playState"); + ok("playbackRate" in player.initialState, "Player's state has playbackRate"); + ok("name" in player.initialState, "Player's state has name"); + ok("duration" in player.initialState, "Player's state has duration"); + ok("delay" in player.initialState, "Player's state has delay"); + ok( + "iterationCount" in player.initialState, + "Player's state has iterationCount" + ); + ok("fill" in player.initialState, "Player's state has fill"); + ok("easing" in player.initialState, "Player's state has easing"); + ok("direction" in player.initialState, "Player's state has direction"); + ok( + "isRunningOnCompositor" in player.initialState, + "Player's state has isRunningOnCompositor" + ); + ok("type" in player.initialState, "Player's state has type"); + ok( + "documentCurrentTime" in player.initialState, + "Player's state has documentCurrentTime" + ); + ok("properties" in player.initialState, "Player's state has properties"); +} + +async function playerStateIsCorrect(walker, animations) { + info("Checking the state of the simple animation"); + + let player = await getAnimationPlayerForNode( + walker, + animations, + ".simple-animation", + 0 + ); + let state = await player.getCurrentState(); + is(state.name, "move", "Name is correct"); + is(state.duration, 200000, "Duration is correct"); + // null = infinite count + is(state.iterationCount, null, "Iteration count is correct"); + is(state.fill, "none", "Fill is correct"); + is(state.easing, "linear", "Easing is correct"); + is(state.direction, "normal", "Direction is correct"); + is(state.playState, "running", "PlayState is correct"); + is(state.playbackRate, 1, "PlaybackRate is correct"); + is(state.type, "cssanimation", "Type is correct"); + + info("Checking the state of the transition"); + + player = await getAnimationPlayerForNode( + walker, + animations, + ".transition", + 0 + ); + state = await player.getCurrentState(); + is(state.name, "width", "Transition name matches transition property"); + is(state.duration, 500000, "Transition duration is correct"); + // transitions run only once + is(state.iterationCount, 1, "Transition iteration count is correct"); + is(state.fill, "backwards", "Transition fill is correct"); + is(state.easing, "ease-out", "Transition easing is correct"); + is(state.direction, "normal", "Transition direction is correct"); + is(state.playState, "running", "Transition playState is correct"); + is(state.playbackRate, 1, "Transition playbackRate is correct"); + is(state.type, "csstransition", "Transition type is correct"); + // check easing in properties + let properties = state.properties; + is(properties.length, 1, "Length of animated properties is correct"); + let keyframes = properties[0].values; + is(keyframes.length, 2, "Transition length of keyframe is correct"); + is(keyframes[0].easing, "linear", "Transition keyframes's easing is correct"); + + info("Checking the state of one of multiple animations on a node"); + + // Checking the 2nd player + player = await getAnimationPlayerForNode( + walker, + animations, + ".multiple-animations", + 1 + ); + state = await player.getCurrentState(); + is(state.name, "glow", "The 2nd animation's name is correct"); + is(state.duration, 100000, "The 2nd animation's duration is correct"); + is(state.iterationCount, 5, "The 2nd animation's iteration count is correct"); + is(state.fill, "both", "The 2nd animation's fill is correct"); + is(state.easing, "linear", "The 2nd animation's easing is correct"); + is(state.direction, "reverse", "The 2nd animation's direction is correct"); + is(state.playState, "running", "The 2nd animation's playState is correct"); + is(state.playbackRate, 1, "The 2nd animation's playbackRate is correct"); + // chech easing in keyframe + properties = state.properties; + keyframes = properties[0].values; + is(keyframes.length, 2, "The 2nd animation's length of keyframe is correct"); + is( + keyframes[0].easing, + "ease-out", + "The 2nd animation's easing of keyframes is correct" + ); + + info("Checking the state of an animation with delay"); + + player = await getAnimationPlayerForNode( + walker, + animations, + ".delayed-animation", + 0 + ); + state = await player.getCurrentState(); + is(state.delay, 5000, "The animation delay is correct"); + + info("Checking the state of an transition with delay"); + + player = await getAnimationPlayerForNode( + walker, + animations, + ".delayed-transition", + 0 + ); + state = await player.getCurrentState(); + is(state.delay, 3000, "The transition delay is correct"); +} + +async function getAnimationPlayerForNode( + walker, + animations, + nodeSelector, + index +) { + const node = await walker.querySelector(walker.rootNode, nodeSelector); + const players = await animations.getAnimationPlayersForNode(node); + const player = players[index]; + return player; +} diff --git a/devtools/server/tests/browser/browser_animation_reconstructState.js b/devtools/server/tests/browser/browser_animation_reconstructState.js new file mode 100644 index 0000000000..6cb64dcc72 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_reconstructState.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that, even though the AnimationPlayerActor only sends the bits of its +// state that change, the front reconstructs the whole state everytime. + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + await playerHasCompleteStateAtAllTimes(walker, animations); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function playerHasCompleteStateAtAllTimes(walker, animations) { + const node = await walker.querySelector(walker.rootNode, ".simple-animation"); + const [player] = await animations.getAnimationPlayersForNode(node); + + // Get the list of state key names from the initialstate. + const keys = Object.keys(player.initialState); + + // Get the state over and over again and check that the object returned + // contains all keys. + // Normally, only the currentTime will have changed in between 2 calls. + for (let i = 0; i < 10; i++) { + await player.refreshState(); + keys.forEach(key => { + ok( + typeof player.state[key] !== "undefined", + "The state retrieved has key " + key + ); + }); + } +} diff --git a/devtools/server/tests/browser/browser_animation_refreshTransitions.js b/devtools/server/tests/browser/browser_animation_refreshTransitions.js new file mode 100644 index 0000000000..a48ea90e3d --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_refreshTransitions.js @@ -0,0 +1,97 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// When a transition finishes, no "removed" event is sent because it may still +// be used, but when it restarts again (transitions back), then a new +// AnimationPlayerFront should be sent, and the old one should be removed. + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + info("Retrieve the test node"); + const node = await walker.querySelector(walker.rootNode, ".all-transitions"); + + info("Retrieve the animation players for the node"); + const players = await animations.getAnimationPlayersForNode(node); + is(players.length, 0, "The node has no animation players yet"); + + info("Play a transition by adding the expand class, wait for mutations"); + let onMutations = expectMutationEvents(animations, 2); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const el = content.document.querySelector(".all-transitions"); + el.classList.add("expand"); + }); + let reportedMutations = await onMutations; + + is(reportedMutations.length, 2, "2 mutation events were received"); + is(reportedMutations[0].type, "added", "The first event was 'added'"); + is(reportedMutations[1].type, "added", "The second event was 'added'"); + + info("Wait for the transitions to be finished"); + await waitForEnd(reportedMutations[0].player); + await waitForEnd(reportedMutations[1].player); + + info("Play the transition back by removing the class, wait for mutations"); + onMutations = expectMutationEvents(animations, 4); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + const el = content.document.querySelector(".all-transitions"); + el.classList.remove("expand"); + }); + reportedMutations = await onMutations; + + is(reportedMutations.length, 4, "4 new mutation events were received"); + is( + reportedMutations.filter(m => m.type === "removed").length, + 2, + "2 'removed' events were sent (for the old transitions)" + ); + is( + reportedMutations.filter(m => m.type === "added").length, + 2, + "2 'added' events were sent (for the new transitions)" + ); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +function expectMutationEvents(animationsFront, nbOfEvents) { + return new Promise(resolve => { + let reportedMutations = []; + function onMutations(mutations) { + reportedMutations = [...reportedMutations, ...mutations]; + info( + "Received " + + reportedMutations.length + + " mutation events, " + + "expecting " + + nbOfEvents + ); + if (reportedMutations.length === nbOfEvents) { + animationsFront.off("mutations", onMutations); + resolve(reportedMutations); + } + } + + info("Start listening for mutation events from the AnimationsFront"); + animationsFront.on("mutations", onMutations); + }); +} + +async function waitForEnd(animationFront) { + let playState; + while (playState !== "finished") { + const state = await animationFront.getCurrentState(); + playState = state.playState; + info( + "Wait for transition " + + animationFront.state.name + + " to finish, playState=" + + playState + ); + } +} diff --git a/devtools/server/tests/browser/browser_animation_setCurrentTime.js b/devtools/server/tests/browser/browser_animation_setCurrentTime.js new file mode 100644 index 0000000000..ee6defa997 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_setCurrentTime.js @@ -0,0 +1,47 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that the AnimationsActor allows changing many players' currentTimes at once. + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + await testSetCurrentTimes(walker, animations); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function testSetCurrentTimes(walker, animations) { + ok(animations.setCurrentTimes, "The AnimationsActor has the right method"); + + info("Retrieve multiple animated node and its animation players"); + + const nodeMulti = await walker.querySelector( + walker.rootNode, + ".multiple-animations" + ); + const players = await animations.getAnimationPlayersForNode(nodeMulti); + + ok(players.length > 1, "Node has more than 1 animation player"); + + info("Try to set multiple current times at once"); + // Assume that all animations were created at same time. + const createdTime = players[1].state.createdTime; + await animations.setCurrentTimes(players, createdTime + 500, true); + + info("Get the states of players and verify their correctness"); + for (let i = 0; i < players.length; i++) { + const state = await players[i].getCurrentState(); + is(state.playState, "paused", `Player ${i + 1} is paused`); + is( + parseInt(state.currentTime.toPrecision(4), 10), + 500, + `Player ${i + 1} has the right currentTime` + ); + } +} diff --git a/devtools/server/tests/browser/browser_animation_setPlaybackRate.js b/devtools/server/tests/browser/browser_animation_setPlaybackRate.js new file mode 100644 index 0000000000..b14751b114 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_setPlaybackRate.js @@ -0,0 +1,49 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check that a player's playbackRate can be changed, and that multiple players +// can have their rates changed at the same time. + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + info("Retrieve an animated node"); + let node = await walker.querySelector(walker.rootNode, ".simple-animation"); + + info("Retrieve the animation player for the node"); + const [player] = await animations.getAnimationPlayersForNode(node); + + info("Change the rate to 10"); + await animations.setPlaybackRates([player], 10); + + info("Query the state again"); + let state = await player.getCurrentState(); + is(state.playbackRate, 10, "The playbackRate was updated"); + + info("Change the rate back to 1"); + await animations.setPlaybackRates([player], 1); + + info("Query the state again"); + state = await player.getCurrentState(); + is(state.playbackRate, 1, "The playbackRate was changed back"); + + info("Retrieve several animation players and set their rates"); + node = await walker.querySelector(walker.rootNode, "body"); + const players = await animations.getAnimationPlayersForNode(node); + + info("Change all animations in <body> to .5 rate"); + await animations.setPlaybackRates(players, 0.5); + + info("Query their states and check they are correct"); + for (const animPlayer of players) { + const animPlayerState = await animPlayer.getCurrentState(); + is(animPlayerState.playbackRate, 0.5, "The playbackRate was updated"); + } + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_simple.js b/devtools/server/tests/browser/browser_animation_simple.js new file mode 100644 index 0000000000..0dd8adfde9 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_simple.js @@ -0,0 +1,39 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Simple checks for the AnimationsActor + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + "data:text/html;charset=utf-8,<title>test</title><div></div>" + ); + + ok(animations, "The AnimationsFront was created"); + ok( + animations.getAnimationPlayersForNode, + "The getAnimationPlayersForNode method exists" + ); + ok(animations.pauseSome, "The pauseSome method exists"); + ok(animations.playSome, "The playSome method exists"); + ok(animations.setCurrentTimes, "The setCurrentTimes method exists"); + ok(animations.setPlaybackRates, "The setPlaybackRates method exists"); + ok(animations.setWalkerActor, "The setWalkerActor method exists"); + + let didThrow = false; + try { + await animations.getAnimationPlayersForNode(null); + } catch (e) { + didThrow = true; + } + ok(didThrow, "An exception was thrown for a missing NodeActor"); + + const invalidNode = await walker.querySelector(walker.rootNode, "title"); + const players = await animations.getAnimationPlayersForNode(invalidNode); + ok(Array.isArray(players), "An array of players was returned"); + is(players.length, 0, "0 players have been returned for the invalid node"); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_animation_updatedState.js b/devtools/server/tests/browser/browser_animation_updatedState.js new file mode 100644 index 0000000000..b209a12158 --- /dev/null +++ b/devtools/server/tests/browser/browser_animation_updatedState.js @@ -0,0 +1,65 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ + +"use strict"; + +// Check the animation player's updated state + +add_task(async function () { + const { target, walker, animations } = await initAnimationsFrontForUrl( + MAIN_DOMAIN + "animation.html" + ); + + await playStateIsUpdatedDynamically(walker, animations); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); + +async function playStateIsUpdatedDynamically(walker, animations) { + info("Getting the test node (which runs a very long animation)"); + // The animation lasts for 100s, to avoid intermittents. + const node = await walker.querySelector(walker.rootNode, ".long-animation"); + + info("Getting the animation player front for this node"); + const [player] = await animations.getAnimationPlayersForNode(node); + + let state = await player.getCurrentState(); + is( + state.playState, + "running", + "The playState is running while the animation is running" + ); + + info( + "Change the animation's currentTime to be near the end and wait for " + + "it to finish" + ); + const onFinished = waitForAnimationPlayState(player, "finished"); + // Set the currentTime to 98s, knowing that the animation lasts for 100s. + await animations.setCurrentTimes([player], 98 * 1000, false); + state = await onFinished; + is( + state.playState, + "finished", + "The animation has ended and the state has been updated" + ); + ok( + state.currentTime > player.initialState.currentTime, + "The currentTime has been updated" + ); +} + +async function waitForAnimationPlayState(player, playState) { + let state = {}; + while (state.playState !== playState) { + state = await player.getCurrentState(); + await wait(500); + } + return state; +} + +function wait(ms) { + return new Promise(r => setTimeout(r, ms)); +} diff --git a/devtools/server/tests/browser/browser_application_manifest.js b/devtools/server/tests/browser/browser_application_manifest.js new file mode 100644 index 0000000000..c92a3c0a2f --- /dev/null +++ b/devtools/server/tests/browser/browser_application_manifest.js @@ -0,0 +1,87 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Enable web manifest processing. +Services.prefs.setBoolPref("dom.manifest.enabled", true); + +add_task(async function () { + info("Testing fetching a valid manifest"); + const response = await fetchManifest("application-manifest-basic.html"); + + ok( + response.manifest && response.manifest.name == "FooApp", + "Returns an object populated with the manifest data" + ); +}); + +add_task(async function () { + info("Testing fetching an existing manifest with invalid values"); + const response = await fetchManifest("application-manifest-warnings.html"); + + ok( + response.manifest && response.manifest.moz_validation, + "Returns an object populated with the manifest data" + ); + + const warnings = response.manifest.moz_validation; + ok( + warnings.length === 1 && + warnings[0].warn && + warnings[0].warn.includes("name member to be a string"), + "The returned object contains the expected warning info" + ); +}); + +add_task(async function () { + info("Testing fetching a manifest in a page that does not have one"); + const response = await fetchManifest("application-manifest-no-manifest.html"); + + is(response.manifest, null, "Returns an object with a `null` manifest"); + ok(!response.errorMessage, "Does not return an error message"); +}); + +add_task(async function () { + info("Testing an error happening fetching a manifest"); + // the page that we are testing contains an invalid URL for the manifest + const response = await fetchManifest( + "application-manifest-404-manifest.html" + ); + + is(response.manifest, null, "Returns an object with a `null` manifest"); + ok( + response.errorMessage && + response.errorMessage.toLowerCase().includes("404 - not found"), + "Returns the expected error message" + ); +}); + +add_task(async function () { + info("Testing a validation error when fetching a manifest with invalid JSON"); + const response = await fetchManifest( + "application-manifest-invalid-json.html" + ); + ok( + response.manifest && response.manifest.moz_validation, + "Returns an object with validation data" + ); + const validation = response.manifest.moz_validation; + ok( + validation.find(x => x.error && x.type === "json"), + "Has the expected error in the validation field" + ); +}); + +async function fetchManifest(filename) { + const url = MAIN_DOMAIN + filename; + const target = await addTabTarget(url); + + info("Initializing manifest front for tab"); + const manifestFront = await target.getFront("manifest"); + + info("Fetching manifest"); + const response = await manifestFront.fetchCanonicalManifest(); + + return response; +} diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_01.js b/devtools/server/tests/browser/browser_canvasframe_helper_01.js new file mode 100644 index 0000000000..14c947db7e --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_01.js @@ -0,0 +1,170 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Simple CanvasFrameAnonymousContentHelper tests. + +const TEST_URL = + "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test"; + +add_task(async function () { + const tab = await addTab(TEST_URL); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + CanvasFrameAnonymousContentHelper, + } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); + const doc = content.document; + + const nodeBuilder = () => { + const root = doc.createElement("div"); + const child = doc.createElement("div"); + child.style = "width:200px;height:200px;background:red;"; + child.id = "child-element"; + child.className = "child-element"; + child.textContent = "test element"; + root.appendChild(child); + return root; + }; + + info("Building the helper"); + const env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + await helper.initialize(); + + ok( + content.AnonymousContent.isInstance(helper.content), + "The helper owns the AnonymousContent object" + ); + ok( + helper.getTextContentForElement, + "The helper has the getTextContentForElement method" + ); + ok( + helper.setTextContentForElement, + "The helper has the setTextContentForElement method" + ); + ok( + helper.setAttributeForElement, + "The helper has the setAttributeForElement method" + ); + ok( + helper.getAttributeForElement, + "The helper has the getAttributeForElement method" + ); + ok( + helper.removeAttributeForElement, + "The helper has the removeAttributeForElement method" + ); + ok( + helper.addEventListenerForElement, + "The helper has the addEventListenerForElement method" + ); + ok( + helper.removeEventListenerForElement, + "The helper has the removeEventListenerForElement method" + ); + ok(helper.getElement, "The helper has the getElement method"); + ok(helper.scaleRootElement, "The helper has the scaleRootElement method"); + + is( + helper.getTextContentForElement("child-element"), + "test element", + "The text content was retrieve correctly" + ); + is( + helper.getAttributeForElement("child-element", "id"), + "child-element", + "The ID attribute was retrieve correctly" + ); + is( + helper.getAttributeForElement("child-element", "class"), + "child-element", + "The class attribute was retrieve correctly" + ); + + const el = helper.getElement("child-element"); + ok(el, "The DOMNode-like element was created"); + + is( + el.getTextContent(), + "test element", + "The text content was retrieve correctly" + ); + is( + el.getAttribute("id"), + "child-element", + "The ID attribute was retrieve correctly" + ); + is( + el.getAttribute("class"), + "child-element", + "The class attribute was retrieve correctly" + ); + + info("Test the toggle API"); + el.classList.toggle("test"); // This will set the class + is( + el.getAttribute("class"), + "child-element test", + "After toggling the class 'test', the class attribute contained the 'test' class" + ); + el.classList.toggle("test"); // This will remove the class + is( + el.getAttribute("class"), + "child-element", + "After toggling the class 'test' again, the class attribute removed the 'test' class" + ); + el.classList.toggle("test", true); // This will set the class + is( + el.getAttribute("class"), + "child-element test", + "After toggling the class 'test' again and keeping force=true, the class attribute added the 'test' class" + ); + el.classList.toggle("test", true); // This will keep the class set + is( + el.getAttribute("class"), + "child-element test", + "After toggling the class 'test' again and keeping force=true,the class attribute contained the 'test' class" + ); + el.classList.toggle("test", false); // This will remove the class + is( + el.getAttribute("class"), + "child-element", + "After toggling the class 'test' again and keeping force=false, the class attribute removed the 'test' class" + ); + el.classList.toggle("test", false); // This will keep the class removed + is( + el.getAttribute("class"), + "child-element", + "After toggling the class 'test' again and keeping force=false, the class attribute removed the 'test' class" + ); + + info("Destroying the helper"); + helper.destroy(); + env.destroy(); + + ok( + !helper.getTextContentForElement("child-element"), + "No text content was retrieved after the helper was destroyed" + ); + ok( + !helper.getAttributeForElement("child-element", "id"), + "No ID attribute was retrieved after the helper was destroyed" + ); + ok( + !helper.getAttributeForElement("child-element", "class"), + "No class attribute was retrieved after the helper was destroyed" + ); + }); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_02.js b/devtools/server/tests/browser/browser_canvasframe_helper_02.js new file mode 100644 index 0000000000..bd54a03933 --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_02.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the CanvasFrameAnonymousContentHelper does not insert content in +// XUL windows. + +add_task(async function () { + const tab = await addTab( + "chrome://mochitests/content/browser/devtools/server/tests/browser/test-window.xhtml" + ); + + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + CanvasFrameAnonymousContentHelper, + } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); + const doc = content.document; + + const nodeBuilder = () => { + const root = doc.createElement("div"); + const child = doc.createElement("div"); + child.style = "width:200px;height:200px;background:red;"; + child.id = "child-element"; + child.className = "child-element"; + child.textContent = "test element"; + root.appendChild(child); + return root; + }; + + info("Building the helper"); + const env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + + ok(!helper.content, "The AnonymousContent was not inserted in the window"); + ok( + !helper.getTextContentForElement("child-element"), + "No text content is returned" + ); + + env.destroy(); + helper.destroy(); + }); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_03.js b/devtools/server/tests/browser/browser_canvasframe_helper_03.js new file mode 100644 index 0000000000..52aa4b5a6f --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_03.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the CanvasFrameAnonymousContentHelper event handling mechanism. + +const TEST_URL = + "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test"; + +add_task(async function () { + const tab = await addTab(TEST_URL); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + CanvasFrameAnonymousContentHelper, + } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); + const doc = content.document; + + const nodeBuilder = () => { + const root = doc.createElement("div"); + const child = doc.createElement("div"); + child.style = + "pointer-events:auto;width:200px;height:200px;background:red;"; + child.id = "child-element"; + child.className = "child-element"; + root.appendChild(child); + return root; + }; + + info("Building the helper"); + const env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + await helper.initialize(); + + const el = helper.getElement("child-element"); + + info("Adding an event listener on the inserted element"); + let mouseDownHandled = 0; + function onMouseDown(e, id) { + is( + id, + "child-element", + "The mousedown event was triggered on the element" + ); + ok(!e.originalTarget, "The originalTarget property isn't available"); + mouseDownHandled++; + } + el.addEventListener("mousedown", onMouseDown); + + function once(target, event) { + return new Promise(done => { + target.addEventListener(event, done, { once: true }); + }); + } + + info("Synthesizing an event on the inserted element"); + let onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + await onDocMouseDown; + + is( + mouseDownHandled, + 1, + "The mousedown event was handled once on the element" + ); + + info("Synthesizing an event somewhere else"); + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(400, 400, doc.defaultView); + await onDocMouseDown; + + is( + mouseDownHandled, + 1, + "The mousedown event was not handled on the element" + ); + + info("Removing the event listener"); + el.removeEventListener("mousedown", onMouseDown); + + info("Synthesizing another event after the listener has been removed"); + // Using a document event listener to know when the event has been synthesized. + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + await onDocMouseDown; + + is( + mouseDownHandled, + 1, + "The mousedown event hasn't been handled after the listener was removed" + ); + + info("Adding again the event listener"); + el.addEventListener("mousedown", onMouseDown); + + info("Destroying the helper"); + env.destroy(); + helper.destroy(); + + info("Synthesizing another event after the helper has been destroyed"); + // Using a document event listener to know when the event has been synthesized. + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + await onDocMouseDown; + + is( + mouseDownHandled, + 1, + "The mousedown event hasn't been handled after the helper was destroyed" + ); + + function synthesizeMouseDown(x, y, win) { + // We need to make sure the inserted anonymous content can be targeted by the + // event right after having been inserted, and so we need to force a sync + // reflow. + win.document.documentElement.offsetWidth; + EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win); + } + }); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_04.js b/devtools/server/tests/browser/browser_canvasframe_helper_04.js new file mode 100644 index 0000000000..85368ff2b5 --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_04.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test the CanvasFrameAnonymousContentHelper re-inserts the content when the +// page reloads. + +const TEST_URL_1 = + "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test 1"; +const TEST_URL_2 = + "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test 2"; + +add_task(async function () { + const tab = await addTab(TEST_URL_1); + await SpecialPowers.spawn( + tab.linkedBrowser, + [TEST_URL_2], + async function (url2) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + CanvasFrameAnonymousContentHelper, + } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); + let doc = content.document; + + const nodeBuilder = () => { + const root = doc.createElement("div"); + const child = doc.createElement("div"); + child.style = + "pointer-events:auto;width:200px;height:200px;background:red;"; + child.id = "child-element"; + child.className = "child-element"; + child.textContent = "test content"; + root.appendChild(child); + return root; + }; + + info("Building the helper"); + const env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + await helper.initialize(); + + info("Get an element from the helper"); + const el = helper.getElement("child-element"); + + info("Try to access the element"); + is( + el.getAttribute("class"), + "child-element", + "The attribute is correct before navigation" + ); + is( + el.getTextContent(), + "test content", + "The text content is correct before navigation" + ); + + info("Add an event listener on the element"); + let mouseDownHandled = 0; + const onMouseDown = (e, id) => { + is( + id, + "child-element", + "The mousedown event was triggered on the element" + ); + mouseDownHandled++; + }; + el.addEventListener("mousedown", onMouseDown); + + const once = function once(target, event) { + return new Promise(done => { + target.addEventListener(event, done, { once: true }); + }); + }; + + const synthesizeMouseDown = function synthesizeMouseDown(x, y, win) { + // We need to make sure the inserted anonymous content can be targeted by the + // event right after having been inserted, and so we need to force a sync + // reflow. + win.document.documentElement.offsetWidth; + EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win); + }; + + info("Synthesizing an event on the element"); + let onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + await onDocMouseDown; + is( + mouseDownHandled, + 1, + "The mousedown event was handled once before navigation" + ); + + info("Navigating to a new page"); + const loaded = once(this, "load"); + content.location = url2; + await loaded; + + // Wait for the next event tick to make sure the remaining part of the + // test is not executed in the microtask checkpoint for load event + // itself. Otherwise the synthesizeMouseDown doesn't work. + await new Promise(r => content.setTimeout(r, 0)); + + // Update to the new document we just loaded + doc = content.document; + + info("Try to access the element again"); + is( + el.getAttribute("class"), + "child-element", + "The attribute is correct after navigation" + ); + is( + el.getTextContent(), + "test content", + "The text content is correct after navigation" + ); + + info("Synthesizing an event on the element again"); + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + await onDocMouseDown; + is( + mouseDownHandled, + 1, + "The mousedown event was not handled after navigation" + ); + + info("Destroying the helper"); + env.destroy(); + helper.destroy(); + } + ); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_05.js b/devtools/server/tests/browser/browser_canvasframe_helper_05.js new file mode 100644 index 0000000000..b542b14221 --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_05.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test some edge cases of the CanvasFrameAnonymousContentHelper event handling +// mechanism. + +const TEST_URL = + "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test"; + +add_task(async function () { + const tab = await addTab(TEST_URL); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + CanvasFrameAnonymousContentHelper, + } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); + const doc = content.document; + + const nodeBuilder = () => { + const root = doc.createElement("div"); + + const parent = doc.createElement("div"); + parent.style = + "pointer-events:auto;width:300px;height:300px;background:yellow;"; + parent.id = "parent-element"; + root.appendChild(parent); + + const child = doc.createElement("div"); + child.style = + "pointer-events:auto;width:200px;height:200px;background:red;"; + child.id = "child-element"; + parent.appendChild(child); + + return root; + }; + + info("Building the helper"); + const env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + await helper.initialize(); + + info("Getting the parent and child elements"); + const parentEl = helper.getElement("parent-element"); + const childEl = helper.getElement("child-element"); + + info("Adding an event listener on both elements"); + let mouseDownHandled = []; + function onMouseDown(e, id) { + mouseDownHandled.push(id); + } + parentEl.addEventListener("mousedown", onMouseDown); + childEl.addEventListener("mousedown", onMouseDown); + + function once(target, event) { + return new Promise(done => { + target.addEventListener(event, done, { once: true }); + }); + } + + info("Synthesizing an event on the child element"); + let onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + await onDocMouseDown; + + is(mouseDownHandled.length, 2, "The mousedown event was handled twice"); + is( + mouseDownHandled[0], + "child-element", + "The mousedown event was handled on the child element" + ); + is( + mouseDownHandled[1], + "parent-element", + "The mousedown event was handled on the parent element" + ); + + info("Synthesizing an event on the parent, outside of the child element"); + mouseDownHandled = []; + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(250, 250, doc.defaultView); + await onDocMouseDown; + + is(mouseDownHandled.length, 1, "The mousedown event was handled only once"); + is( + mouseDownHandled[0], + "parent-element", + "The mousedown event was handled on the parent element" + ); + + info("Removing the event listener"); + parentEl.removeEventListener("mousedown", onMouseDown); + childEl.removeEventListener("mousedown", onMouseDown); + + info("Adding an event listener on the parent element only"); + mouseDownHandled = []; + parentEl.addEventListener("mousedown", onMouseDown); + + info("Synthesizing an event on the child element"); + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + await onDocMouseDown; + + is(mouseDownHandled.length, 1, "The mousedown event was handled once"); + is( + mouseDownHandled[0], + "parent-element", + "The mousedown event did bubble to the parent element" + ); + + info("Removing the parent listener"); + parentEl.removeEventListener("mousedown", onMouseDown); + + env.destroy(); + helper.destroy(); + + function synthesizeMouseDown(x, y, win) { + // We need to make sure the inserted anonymous content can be targeted by the + // event right after having been inserted, and so we need to force a sync + // reflow. + win.document.documentElement.offsetWidth; + EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win); + } + }); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_canvasframe_helper_06.js b/devtools/server/tests/browser/browser_canvasframe_helper_06.js new file mode 100644 index 0000000000..e0222b33b1 --- /dev/null +++ b/devtools/server/tests/browser/browser_canvasframe_helper_06.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test support for event propagation stop in the +// CanvasFrameAnonymousContentHelper event handling mechanism. + +const TEST_URL = + "data:text/html;charset=utf-8,CanvasFrameAnonymousContentHelper test"; + +add_task(async function () { + const tab = await addTab(TEST_URL); + await SpecialPowers.spawn(tab.linkedBrowser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + HighlighterEnvironment, + } = require("resource://devtools/server/actors/highlighters.js"); + const { + CanvasFrameAnonymousContentHelper, + } = require("resource://devtools/server/actors/highlighters/utils/markup.js"); + const doc = content.document; + + const nodeBuilder = () => { + const root = doc.createElement("div"); + + const parent = doc.createElement("div"); + parent.style = + "pointer-events:auto;width:300px;height:300px;background:yellow;"; + parent.id = "parent-element"; + root.appendChild(parent); + + const child = doc.createElement("div"); + child.style = + "pointer-events:auto;width:200px;height:200px;background:red;"; + child.id = "child-element"; + parent.appendChild(child); + + return root; + }; + + info("Building the helper"); + const env = new HighlighterEnvironment(); + env.initFromWindow(doc.defaultView); + const helper = new CanvasFrameAnonymousContentHelper(env, nodeBuilder); + await helper.initialize(); + + info("Getting the parent and child elements"); + const parentEl = helper.getElement("parent-element"); + const childEl = helper.getElement("child-element"); + + info("Adding an event listener on both elements"); + let mouseDownHandled = []; + + function onParentMouseDown(e, id) { + mouseDownHandled.push(id); + } + parentEl.addEventListener("mousedown", onParentMouseDown); + + function onChildMouseDown(e, id) { + mouseDownHandled.push(id); + e.stopPropagation(); + } + childEl.addEventListener("mousedown", onChildMouseDown); + + function once(target, event) { + return new Promise(done => { + target.addEventListener(event, done, { once: true }); + }); + } + + info("Synthesizing an event on the child element"); + let onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(100, 100, doc.defaultView); + await onDocMouseDown; + + is(mouseDownHandled.length, 1, "The mousedown event was handled only once"); + is( + mouseDownHandled[0], + "child-element", + "The mousedown event was handled on the child element" + ); + + info("Synthesizing an event on the parent, outside of the child element"); + mouseDownHandled = []; + onDocMouseDown = once(doc, "mousedown"); + synthesizeMouseDown(250, 250, doc.defaultView); + await onDocMouseDown; + + is(mouseDownHandled.length, 1, "The mousedown event was handled only once"); + is( + mouseDownHandled[0], + "parent-element", + "The mousedown event was handled on the parent element" + ); + + info("Removing the event listener"); + parentEl.removeEventListener("mousedown", onParentMouseDown); + childEl.removeEventListener("mousedown", onChildMouseDown); + + env.destroy(); + helper.destroy(); + + function synthesizeMouseDown(x, y, win) { + // We need to make sure the inserted anonymous content can be targeted by the + // event right after having been inserted, and so we need to force a sync + // reflow. + win.document.documentElement.offsetWidth; + EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win); + } + }); + + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_compatibility_cssIssues.js b/devtools/server/tests/browser/browser_compatibility_cssIssues.js new file mode 100644 index 0000000000..4cd244688c --- /dev/null +++ b/devtools/server/tests/browser/browser_compatibility_cssIssues.js @@ -0,0 +1,137 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check the output of getNodeCssIssues + +const { + COMPATIBILITY_ISSUE_TYPE, +} = require("resource://devtools/shared/constants.js"); +const URL = MAIN_DOMAIN + "doc_compatibility.html"; + +const CHROME_81 = { + id: "chrome", + version: "81", +}; + +const CHROME_ANDROID = { + id: "chrome_android", + version: "81", +}; + +const EDGE_81 = { + id: "edge", + version: "81", +}; + +const FIREFOX_1 = { + id: "firefox", + version: "1", +}; + +const FIREFOX_60 = { + id: "firefox", + version: "60", +}; + +const FIREFOX_69 = { + id: "firefox", + version: "69", +}; + +const FIREFOX_MOBILE = { + id: "firefox_android", + version: "68", +}; + +const SAFARI_13 = { + id: "safari", + version: "13", +}; + +const SAFARI_MOBILE = { + id: "safari_ios", + version: "13.4", +}; + +const TARGET_BROWSERS = [ + FIREFOX_1, + FIREFOX_60, + FIREFOX_69, + FIREFOX_MOBILE, + CHROME_81, + CHROME_ANDROID, + SAFARI_13, + SAFARI_MOBILE, + EDGE_81, +]; + +const ISSUE_USER_SELECT = { + type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY_ALIASES, + property: "user-select", + aliases: ["-moz-user-select"], + url: "https://developer.mozilla.org/docs/Web/CSS/user-select", + specUrl: "https://drafts.csswg.org/css-ui/#content-selection", + deprecated: false, + experimental: false, + prefixNeeded: true, + unsupportedBrowsers: [ + CHROME_81, + CHROME_ANDROID, + SAFARI_13, + SAFARI_MOBILE, + EDGE_81, + ], +}; + +const ISSUE_CLIP = { + type: COMPATIBILITY_ISSUE_TYPE.CSS_PROPERTY, + property: "clip", + url: "https://developer.mozilla.org/docs/Web/CSS/clip", + specUrl: "https://drafts.fxtf.org/css-masking/#clip-property", + deprecated: true, + experimental: false, + unsupportedBrowsers: [], +}; + +async function testNodeCssIssues(selector, walker, compatibility, expected) { + const node = await walker.querySelector(walker.rootNode, selector); + const cssCompatibilityIssues = await compatibility.getNodeCssIssues( + node, + TARGET_BROWSERS + ); + info("Ensure result is correct"); + Assert.deepEqual( + cssCompatibilityIssues, + expected, + "Expected CSS browser compat data is correct." + ); +} + +add_task(async function () { + const { inspector, walker, target } = await initInspectorFront(URL); + const compatibility = await inspector.getCompatibilityFront(); + + info('Test CSS properties linked with the "div" tag'); + await testNodeCssIssues("div", walker, compatibility, []); + + info('Test CSS properties linked with class "class-user-select"'); + await testNodeCssIssues(".class-user-select", walker, compatibility, [ + ISSUE_USER_SELECT, + ]); + + info("Test CSS properties linked with multiple classes and id"); + await testNodeCssIssues( + "div#id-clip.class-clip.class-user-select", + walker, + compatibility, + [ISSUE_CLIP, ISSUE_USER_SELECT] + ); + + info("Repeated incompatible CSS rule should be only reported once"); + await testNodeCssIssues(".duplicate", walker, compatibility, [ISSUE_CLIP]); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_connectToFrame.js b/devtools/server/tests/browser/browser_connectToFrame.js new file mode 100644 index 0000000000..568eb1acc1 --- /dev/null +++ b/devtools/server/tests/browser/browser_connectToFrame.js @@ -0,0 +1,142 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test `connectToFrame` method + */ + +"use strict"; + +const { + connectToFrame, +} = require("resource://devtools/server/connectors/frame-connector.js"); + +add_task(async function () { + // Create a minimal browser with a message manager + const browser = document.createXULElement("browser"); + browser.setAttribute("type", "content"); + document.body.appendChild(browser); + + await TestUtils.waitForCondition( + () => browser.browsingContext.currentWindowGlobal, + "browser has no window global" + ); + + // Register a test actor in the child process so that we can know if and when + // this fake actor is destroyed. + await SpecialPowers.spawn(browser, [], () => { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + const { + ActorRegistry, + } = require("resource://devtools/server/actors/utils/actor-registry.js"); + + DevToolsServer.init(); + + const { Actor } = require("resource://devtools/shared/protocol/Actor.js"); + class ConnectToFrameTestActor extends Actor { + constructor(conn, tab) { + super(conn, { typeName: "connectToFrameTest", methods: [] }); + dump("instantiate test actor\n"); + this.requestTypes = { + hello: this.hello, + }; + } + hello() { + return { msg: "world" }; + } + + destroy() { + SpecialPowers.notifyObserversInParentProcess( + null, + "devtools-test-actor-destroyed", + "" + ); + } + } + + ActorRegistry.addTargetScopedActor( + { + constructorName: "ConnectToFrameTestActor", + constructorFun: ConnectToFrameTestActor, + }, + "connectToFrameTestActor" + ); + }); + + // Instantiate a minimal server + DevToolsServer.init(); + if (!DevToolsServer.createRootActor) { + DevToolsServer.registerAllActors(); + } + + async function initAndCloseFirstClient() { + // Fake a first connection to a browser + const transport = DevToolsServer.connectPipe(); + const conn = transport._serverConnection; + const client = new DevToolsClient(transport); + const actor = await connectToFrame(conn, browser); + ok(actor.connectToFrameTestActor, "Got the test actor"); + + // Ensure sending at least one request to our actor, + // otherwise it won't be instantiated, nor be destroyed... + await client.request({ + to: actor.connectToFrameTestActor, + type: "hello", + }); + + // Connect a second client in parallel to assert that it received a distinct set of + // target actors + await initAndCloseSecondClient(actor.connectToFrameTestActor); + + ok( + DevToolsServer.initialized, + "DevToolsServer isn't destroyed until all clients are disconnected" + ); + + // Ensure that our test actor got cleaned up; + // its destroy method should be called + const onActorDestroyed = TestUtils.topicObserved( + "devtools-test-actor-destroyed" + ); + + // Then close the client. That should end up cleaning our test actor + await client.close(); + + await onActorDestroyed; + + // This test loads a frame in the parent process, so that we end up sharing the same + // DevToolsServer instance + ok( + !DevToolsServer.initialized, + "DevToolsServer is destroyed when all clients are disconnected" + ); + } + + async function initAndCloseSecondClient(firstActor) { + // Then fake a second one, that should spawn a new set of target-scoped actors + const transport = DevToolsServer.connectPipe(); + const conn = transport._serverConnection; + const client = new DevToolsClient(transport); + const actor = await connectToFrame(conn, browser); + ok( + actor.connectToFrameTestActor, + "Got a test actor for the second connection" + ); + isnot( + actor.connectToFrameTestActor, + firstActor, + "We get different actor instances between two connections" + ); + return client.close(); + } + + await initAndCloseFirstClient(); + + DevToolsServer.destroy(); + browser.remove(); +}); diff --git a/devtools/server/tests/browser/browser_debugger_server.js b/devtools/server/tests/browser/browser_debugger_server.js new file mode 100644 index 0000000000..8b36076b34 --- /dev/null +++ b/devtools/server/tests/browser/browser_debugger_server.js @@ -0,0 +1,198 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test basic features of DevToolsServer + +add_task(async function () { + // When running some other tests before, they may not destroy the main server. + // Do it manually before running our tests. + if (DevToolsServer.initialized) { + DevToolsServer.destroy(); + } + + await testDevToolsServerInitialized(); + await testDevToolsServerKeepAlive(); +}); + +async function testDevToolsServerInitialized() { + const tab = await addTab("data:text/html;charset=utf-8,foo"); + + ok( + !DevToolsServer.initialized, + "By default, the DevToolsServer isn't initialized in parent process" + ); + await assertServerInitialized( + tab, + false, + "By default, the DevToolsServer isn't initialized not in content process" + ); + await assertDevToolsOpened( + tab, + false, + "By default, the DevTools are reported as closed" + ); + + const commands = await CommandsFactory.forTab(tab); + + ok( + DevToolsServer.initialized, + "Creating the commands will initialize the DevToolsServer in parent process" + ); + await assertServerInitialized( + tab, + false, + "Creating the commands isn't enough to initialize the DevToolsServer in content process" + ); + await assertDevToolsOpened( + tab, + false, + "DevTools are still reported as closed after having created the commands" + ); + + await commands.targetCommand.startListening(); + + await assertServerInitialized( + tab, + true, + "Initializing the TargetCommand will initialize the DevToolsServer in content process" + ); + await assertDevToolsOpened( + tab, + true, + "Initializing the TargetCommand will start reporting the DevTools as opened" + ); + + await commands.destroy(); + + // Disconnecting the client will remove all connections from both server, in parent and content process. + ok( + !DevToolsServer.initialized, + "Destroying the commands destroys the DevToolsServer in the parent process" + ); + await assertServerInitialized( + tab, + false, + "But destroying the commands ends up destroying the DevToolsServer in the content process" + ); + await assertDevToolsOpened( + tab, + false, + "Destroying the commands will report DevTools as being closed" + ); + + gBrowser.removeCurrentTab(); + DevToolsServer.destroy(); +} + +async function testDevToolsServerKeepAlive() { + const tab = await addTab("data:text/html;charset=utf-8,foo"); + + await assertServerInitialized( + tab, + false, + "Server not started in content process" + ); + await assertDevToolsOpened(tab, false, "DevTools are reported as closed"); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + + await assertServerInitialized(tab, true, "Server started in content process"); + await assertDevToolsOpened(tab, true, "DevTools are reported as opened"); + + info("Set DevToolsServer.keepAlive to true in the content process"); + DevToolsServer.keepAlive = true; + await setContentServerKeepAlive(tab, true); + + info("Destroy the commands, the content server should be kept alive"); + await commands.destroy(); + + await assertServerInitialized( + tab, + true, + "Server still running in content process" + ); + await assertDevToolsOpened( + tab, + false, + "DevTools are reported as close, even if the server is still running because there is no more client connected" + ); + + ok( + DevToolsServer.initialized, + "Destroying the commands never destroys the DevToolsServer in the parent process when keepAlive is true" + ); + + info("Set DevToolsServer.keepAlive back to false"); + DevToolsServer.keepAlive = false; + await setContentServerKeepAlive(tab, false); + + info("Create and destroy a commands again"); + const newCommands = await CommandsFactory.forTab(tab); + await newCommands.targetCommand.startListening(); + + await newCommands.destroy(); + + await assertServerInitialized( + tab, + false, + "Server stopped in content process" + ); + await assertDevToolsOpened( + tab, + false, + "DevTools are reported as closed after destroying the second commands" + ); + + ok( + !DevToolsServer.initialized, + "When turning keepAlive to false, the server in the parent process is destroyed" + ); + + gBrowser.removeCurrentTab(); + DevToolsServer.destroy(); +} + +async function assertServerInitialized(tab, expected, message) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [expected, message], + function (_expected, _message) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + is(DevToolsServer.initialized, _expected, _message); + } + ); +} + +async function assertDevToolsOpened(tab, expected, message) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [expected, message], + function (_expected, _message) { + is(ChromeUtils.isDevToolsOpened(), _expected, _message); + } + ); +} + +async function setContentServerKeepAlive(tab, keepAlive, message) { + await SpecialPowers.spawn( + tab.linkedBrowser, + [keepAlive], + function (_keepAlive) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + DevToolsServer.keepAlive = _keepAlive; + } + ); +} diff --git a/devtools/server/tests/browser/browser_getProcess.js b/devtools/server/tests/browser/browser_getProcess.js new file mode 100644 index 0000000000..30c9fff589 --- /dev/null +++ b/devtools/server/tests/browser/browser_getProcess.js @@ -0,0 +1,129 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Test `RootActor.getProcess` method + */ + +"use strict"; + +add_task(async () => { + let client, tab; + + function connect() { + // Fake a first connection to the content process + const transport = DevToolsServer.connectPipe(); + client = new DevToolsClient(transport); + return client.connect(); + } + + async function listProcess() { + const onNewProcess = new Promise(resolve => { + // Call listProcesses in order to start receiving new process notifications + client.mainRoot.on("processListChanged", function listener() { + client.off("processListChanged", listener); + ok(true, "Received processListChanged event"); + resolve(); + }); + }); + await client.mainRoot.listProcesses(); + await createNewProcess(); + return onNewProcess; + } + + async function createNewProcess() { + tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "data:text/html,new-process", + forceNewProcess: true, + }); + } + + async function getProcess() { + // Note that we can't assert process count as the number of processes + // is affected by previous tests. + const processes = await client.mainRoot.listProcesses(); + const { osPid } = tab.linkedBrowser.browsingContext.currentWindowGlobal; + const descriptor = processes.find(process => process.id == osPid); + ok(descriptor, "Got the new process descriptor"); + + // Connect to the first content process available + const content = processes.filter(p => !p.isParentProcessDescriptor)[0]; + + const processDescriptor = await client.mainRoot.getProcess(content.id); + const front = await processDescriptor.getTarget(); + const targetForm = front.targetForm; + ok(targetForm.consoleActor, "Got the console actor"); + ok(targetForm.threadActor, "Got the thread actor"); + + // Process target are no longer really used/supported beyond listing their workers + // from RootFront. + const { workers } = await front.listWorkers(); + is(workers.length, 0, "listWorkers worked and reported no workers"); + + return [front, content.id]; + } + + // Assert that calling client.getProcess against the same process id is + // returning the same actor. + async function getProcessAgain(firstTargetFront, id) { + const processDescriptor = await client.mainRoot.getProcess(id); + const front = await processDescriptor.getTarget(); + is( + front, + firstTargetFront, + "Second call to getProcess with the same id returns the same form" + ); + } + + function processScript() { + /* eslint-env mozilla/process-script */ + const listener = function () { + Services.obs.removeObserver(listener, "devtools:loader:destroy"); + sendAsyncMessage("test:getProcess-destroy", null); + }; + Services.obs.addObserver(listener, "devtools:loader:destroy"); + } + + async function closeClient() { + const onLoaderDestroyed = new Promise(done => { + const processListener = function () { + Services.ppmm.removeMessageListener( + "test:getProcess-destroy", + processListener + ); + done(); + }; + Services.ppmm.addMessageListener( + "test:getProcess-destroy", + processListener + ); + }); + const script = `data:,(${encodeURI(processScript)})()`; + Services.ppmm.loadProcessScript(script, true); + await client.close(); + + await onLoaderDestroyed; + Services.ppmm.removeDelayedProcessScript(script); + info("Loader destroyed in the content process"); + } + + // Instantiate a minimal server + DevToolsServer.init(); + DevToolsServer.allowChromeProcess = true; + if (!DevToolsServer.createRootActor) { + DevToolsServer.registerAllActors(); + } + + await connect(); + await listProcess(); + + const [front, contentId] = await getProcess(); + + await getProcessAgain(front, contentId); + + await closeClient(); + + BrowserTestUtils.removeTab(tab); + DevToolsServer.destroy(); +}); diff --git a/devtools/server/tests/browser/browser_inspector-anonymous.js b/devtools/server/tests/browser/browser_inspector-anonymous.js new file mode 100644 index 0000000000..024b7af1bb --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-anonymous.js @@ -0,0 +1,204 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test for Bug 777674 + +add_task(async function () { + await SpecialPowers.pushPermissions([ + { type: "allowXULXBL", allow: true, context: MAIN_DOMAIN }, + ]); + + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + await testXBLAnonymousInHTMLDocument(walker); + await testNativeAnonymous(walker); + await testNativeAnonymousStartingNode(walker); + + await testPseudoElements(walker); + await testEmptyWithPseudo(walker); + await testShadowAnonymous(walker); +}); + +async function testXBLAnonymousInHTMLDocument(walker) { + info("Testing XBL anonymous in an HTML document."); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const XUL_NS = + "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + const rawToolbarbutton = content.document.createElementNS( + XUL_NS, + "toolbarbutton" + ); + content.document.documentElement.appendChild(rawToolbarbutton); + }); + + const toolbarbutton = await walker.querySelector( + walker.rootNode, + "toolbarbutton" + ); + const children = await walker.children(toolbarbutton); + + is(toolbarbutton.numChildren, 0, "XBL content is not visible in HTML doc"); + is(children.nodes.length, 0, "XBL content is not returned in HTML doc"); +} + +async function testNativeAnonymous(walker) { + info("Testing native anonymous content with walker."); + + const select = await walker.querySelector(walker.rootNode, "select"); + const children = await walker.children(select); + + is(select.numChildren, 2, "No native anon content for form control"); + is(children.nodes.length, 2, "No native anon content for form control"); +} + +async function testNativeAnonymousStartingNode(walker) { + info("Tests attaching an element that a walker can't see."); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[walker.actorID]], + async function (actorID) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + + const { + DocumentWalker, + } = require("resource://devtools/server/actors/inspector/document-walker.js"); + const nodeFilterConstants = require("resource://devtools/shared/dom-node-filter-constants.js"); + + const docwalker = new DocumentWalker( + content.document.querySelector("select"), + content, + { + filter: () => { + return nodeFilterConstants.FILTER_ACCEPT; + }, + } + ); + const scrollbar = docwalker.lastChild(); + is(scrollbar.tagName, "scrollbar", "An anonymous child has been fetched"); + + // Convert actorID to current compartment string otherwise + // searchAllConnectionsForActor is confused and won't find the actor. + actorID = String(actorID); + const serverWalker = DevToolsServer.searchAllConnectionsForActor(actorID); + const node = await serverWalker.attachElement(scrollbar); + + ok(node, "A response has arrived"); + ok(node.node, "A node is in the response"); + is( + node.node.rawNode.tagName, + "SELECT", + "The node has changed to a parent that the walker recognizes" + ); + } + ); +} + +async function testPseudoElements(walker) { + info("Testing pseudo elements with walker."); + + // Markup looks like: <div><::before /><span /><::after /></div> + const pseudo = await walker.querySelector(walker.rootNode, "#pseudo"); + const children = await walker.children(pseudo); + + is( + pseudo.numChildren, + 1, + "::before/::after are not counted if there is a child" + ); + is(children.nodes.length, 3, "Correct number of children"); + + const before = children.nodes[0]; + ok(before.isAnonymous, "Child is anonymous"); + ok(before._form.isNativeAnonymous, "Child is native anonymous"); + + const span = children.nodes[1]; + ok(!span.isAnonymous, "Child is not anonymous"); + + const after = children.nodes[2]; + ok(after.isAnonymous, "Child is anonymous"); + ok(after._form.isNativeAnonymous, "Child is native anonymous"); +} + +async function testEmptyWithPseudo(walker) { + info("Testing elements with no childrent, except for pseudos."); + + info("Checking an element whose only child is a pseudo element"); + const pseudo = await walker.querySelector(walker.rootNode, "#pseudo-empty"); + const children = await walker.children(pseudo); + + is( + pseudo.numChildren, + 1, + "::before/::after are is counted if there are no other children" + ); + is(children.nodes.length, 1, "Correct number of children"); + + const before = children.nodes[0]; + ok(before.isAnonymous, "Child is anonymous"); + ok(before._form.isNativeAnonymous, "Child is native anonymous"); +} + +async function testShadowAnonymous(walker) { + info("Testing shadow DOM content."); + + const host = await walker.querySelector(walker.rootNode, "#shadow"); + const children = await walker.children(host); + + // #shadow-root, ::before, light dom + is(host.numChildren, 3, "Children of the shadow root are counted"); + is(children.nodes.length, 3, "Children returned from walker"); + + const before = children.nodes[1]; + is( + before._form.nodeName, + "_moz_generated_content_before", + "Should be the ::before pseudo-element" + ); + ok(before.isAnonymous, "::before is anonymous"); + ok(before._form.isNativeAnonymous, "::before is native anonymous"); + info(JSON.stringify(before._form)); + + const shadow = children.nodes[0]; + const shadowChildren = await walker.children(shadow); + // <h3>...</h3>, <select multiple></select> + is(shadow.numChildren, 2, "Children of the shadow root are counted"); + is(shadowChildren.nodes.length, 2, "Children returned from walker"); + + // <h3>Shadow <em>DOM</em></h3> + const shadowChild1 = shadowChildren.nodes[0]; + ok(!shadowChild1.isAnonymous, "Shadow child is not anonymous"); + ok( + !shadowChild1._form.isNativeAnonymous, + "Shadow child is not native anonymous" + ); + + const shadowSubChildren = await walker.children(shadowChild1); + is(shadowChild1.numChildren, 2, "Subchildren of the shadow root are counted"); + is(shadowSubChildren.nodes.length, 2, "Subchildren are returned from walker"); + + // <em>DOM</em> + const shadowSubChild = shadowSubChildren.nodes[1]; + ok( + !shadowSubChild.isAnonymous, + "Subchildren of shadow root are not anonymous" + ); + ok( + !shadowSubChild._form.isNativeAnonymous, + "Subchildren of shadow root is not native anonymous" + ); + + // <select multiple></select> + const shadowChild2 = shadowChildren.nodes[1]; + ok(!shadowChild2.isAnonymous, "Child is anonymous"); + ok(!shadowChild2._form.isNativeAnonymous, "Child is not native anonymous"); +} diff --git a/devtools/server/tests/browser/browser_inspector-iframe.js b/devtools/server/tests/browser/browser_inspector-iframe.js new file mode 100644 index 0000000000..cbe4f872dd --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-iframe.js @@ -0,0 +1,92 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const BYPASS_WALKERFRONT_CHILDREN_IFRAME_GUARD_PREF = + "devtools.testing.bypass-walker-children-iframe-guard"; + +add_task(async function testIframe() { + info("Check that dedicated walker is used for retrieving iframe children"); + + const TEST_URI = `https://example.com/document-builder.sjs?html=${encodeURIComponent(` + <h1>Test iframe</h1> + <iframe src="https://example.com/document-builder.sjs?html=Hello"></iframe> +`)}`; + + const { walker } = await initInspectorFront(TEST_URI); + const iframeNodeFront = await walker.querySelector(walker.rootNode, "iframe"); + + is( + iframeNodeFront.useChildTargetToFetchChildren, + isEveryFrameTargetEnabled(), + "useChildTargetToFetchChildren has expected value" + ); + is( + iframeNodeFront.numChildren, + 1, + "numChildren is set to 1 (for the #document node)" + ); + + const res = await walker.children(iframeNodeFront); + is( + res.nodes.length, + 1, + "Retrieving the iframe children return an array with one element" + ); + const documentNodeFront = res.nodes[0]; + is( + documentNodeFront.nodeName, + "#document", + "The child is the #document element" + ); + if (isEveryFrameTargetEnabled()) { + ok( + documentNodeFront.walkerFront !== walker, + "The child walker is different from the top level document one when EFT is enabled" + ); + } + is( + documentNodeFront.parentNode(), + iframeNodeFront, + "The child parent was set to the original iframe nodeFront" + ); +}); + +add_task(async function testIframeBlockedByCSP() { + info("Check that iframe blocked by CSP don't have any children"); + + const TEST_URI = `https://example.com/document-builder.sjs?html=${encodeURIComponent(` + <h1>Test CSP-blocked iframe</h1> + <iframe src="https://example.org/document-builder.sjs?html=Hello"></iframe> +`)}&headers=content-security-policy:default-src 'self'`; + + const { walker } = await initInspectorFront(TEST_URI); + const iframeNodeFront = await walker.querySelector(walker.rootNode, "iframe"); + + is( + iframeNodeFront.useChildTargetToFetchChildren, + false, + "useChildTargetToFetchChildren is false" + ); + is(iframeNodeFront.numChildren, 0, "numChildren is set to 0"); + + info("Test calling WalkerFront#children with the safe guard removed"); + await pushPref(BYPASS_WALKERFRONT_CHILDREN_IFRAME_GUARD_PREF, true); + + let res = await walker.children(iframeNodeFront); + is( + res.nodes.length, + 0, + "Retrieving the iframe children return an empty array" + ); + + info("Test calling WalkerFront#children again, but with the safe guard"); + Services.prefs.clearUserPref(BYPASS_WALKERFRONT_CHILDREN_IFRAME_GUARD_PREF); + res = await walker.children(iframeNodeFront); + is( + res.nodes.length, + 0, + "Retrieving the iframe children return an empty array" + ); +}); diff --git a/devtools/server/tests/browser/browser_inspector-insert.js b/devtools/server/tests/browser/browser_inspector-insert.js new file mode 100644 index 0000000000..d3f2ea482d --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-insert.js @@ -0,0 +1,158 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + await testRearrange(walker); + await testInsertInvalidInput(walker); +}); + +async function testRearrange(walker) { + const longlist = await walker.querySelector(walker.rootNode, "#longlist"); + let children = await walker.children(longlist); + const nodeA = children.nodes[0]; + is(nodeA.id, "a", "Got the expected node."); + + // Move nodeA to the end of the list. + await walker.insertBefore(nodeA, longlist, null); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + ok( + !content.document.querySelector("#a").nextSibling, + "a should now be at the end of the list." + ); + }); + + children = await walker.children(longlist); + is( + nodeA, + children.nodes[children.nodes.length - 1], + "a should now be the last returned child." + ); + + // Now move it to the middle of the list. + const nextNode = children.nodes[13]; + await walker.insertBefore(nodeA, longlist, nextNode); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[nextNode.actorID]], + async function (actorID) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + const { + DocumentWalker, + } = require("resource://devtools/server/actors/inspector/document-walker.js"); + const sibling = new DocumentWalker( + content.document.querySelector("#a"), + content + ).nextSibling(); + // Convert actorID to current compartment string otherwise + // searchAllConnectionsForActor is confused and won't find the actor. + actorID = String(actorID); + const nodeActor = DevToolsServer.searchAllConnectionsForActor(actorID); + is( + sibling, + nodeActor.rawNode, + "Node should match the expected next node." + ); + } + ); + + children = await walker.children(longlist); + is(nodeA, children.nodes[13], "a should be where we expect it."); + is(nextNode, children.nodes[14], "next node should be where we expect it."); +} + +async function testInsertInvalidInput(walker) { + const longlist = await walker.querySelector(walker.rootNode, "#longlist"); + const children = await walker.children(longlist); + const nodeA = children.nodes[0]; + const nextSibling = children.nodes[1]; + + // Now move it to the original location and make sure no mutation happens. + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[longlist.actorID]], + async function (actorID) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + // Convert actorID to current compartment string otherwise + // searchAllConnectionsForActor is confused and won't find the actor. + actorID = String(actorID); + const nodeActor = DevToolsServer.searchAllConnectionsForActor(actorID); + content.hasMutated = false; + content.observer = new content.MutationObserver(() => { + content.hasMutated = true; + }); + content.observer.observe(nodeActor.rawNode, { + childList: true, + }); + } + ); + + await walker.insertBefore(nodeA, longlist, nodeA); + let hasMutated = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + const state = content.hasMutated; + content.hasMutated = false; + return state; + } + ); + ok(!hasMutated, "hasn't mutated"); + + await walker.insertBefore(nodeA, longlist, nextSibling); + hasMutated = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + const state = content.hasMutated; + content.hasMutated = false; + return state; + } + ); + ok(!hasMutated, "still hasn't mutated after inserting before nextSibling"); + + await walker.insertBefore(nodeA, longlist); + hasMutated = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + const state = content.hasMutated; + content.hasMutated = false; + return state; + } + ); + ok(hasMutated, "has mutated after inserting with null sibling"); + + await walker.insertBefore(nodeA, longlist); + hasMutated = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + async function () { + const state = content.hasMutated; + content.hasMutated = false; + return state; + } + ); + ok(!hasMutated, "hasn't mutated after inserting with null sibling again"); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + content.observer.disconnect(); + }); +} diff --git a/devtools/server/tests/browser/browser_inspector-isScrollable.js b/devtools/server/tests/browser/browser_inspector-isScrollable.js new file mode 100644 index 0000000000..e28fc01ce9 --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-isScrollable.js @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const URL = MAIN_DOMAIN + "inspector-isScrollable-data.html"; + +const CASES = [ + { id: "body", expected: false }, + { id: "no_children", expected: false }, + { id: "one_child_no_overflow", expected: false }, + { id: "margin_left_overflow", expected: true }, + { id: "transform_overflow", expected: true }, + { id: "nested_overflow", expected: true }, + { id: "intermediate_overflow", expected: true }, + { id: "multiple_overflow_at_different_depths", expected: true }, + { id: "overflow_hidden", expected: false }, + { id: "scrollbar_none", expected: false }, +]; + +add_task(async function () { + info( + "Test that elements with scrollbars have a true value for isScrollable, and elements without scrollbars have a false value." + ); + const { walker } = await initInspectorFront(URL); + + for (const { id, expected } of CASES) { + info(`Checking element id ${id}.`); + + const el = await walker.querySelector(walker.rootNode, `#${id}`); + is(el.isScrollable, expected, `${id} has expected value for isScrollable.`); + } +}); diff --git a/devtools/server/tests/browser/browser_inspector-mutations-childlist.js b/devtools/server/tests/browser/browser_inspector-mutations-childlist.js new file mode 100644 index 0000000000..6818c9c8dc --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-mutations-childlist.js @@ -0,0 +1,282 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js", + this +); + +function loadSelector(walker, selector) { + return walker.querySelectorAll(walker.rootNode, selector).then(nodeList => { + return nodeList.items(); + }); +} + +function loadSelectors(walker, selectors) { + return Promise.all(Array.from(selectors, sel => loadSelector(walker, sel))); +} + +function doMoves(movesArg) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [movesArg], + function (moves) { + function setParent(nodeSelector, newParentSelector) { + const node = content.document.querySelector(nodeSelector); + if (newParentSelector) { + const newParent = content.document.querySelector(newParentSelector); + newParent.appendChild(node); + } else { + node.remove(); + } + } + for (const move of moves) { + setParent(move[0], move[1]); + } + } + ); +} + +/** + * Test a set of tree rearrangements and make sure they cause the expected changes. + */ + +var gDummySerial = 0; + +function mutationTest(testSpec) { + return async function () { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + await loadSelectors(walker, testSpec.load || ["html"]); + walker.autoCleanup = !!testSpec.autoCleanup; + if (testSpec.preCheck) { + testSpec.preCheck(); + } + const onMutations = walker.once("mutations"); + + await doMoves(testSpec.moves || []); + + // Some of these moves will trigger no mutation events, + // so do a dummy change to the root node to trigger + // a mutation event anyway. + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[gDummySerial++]], + function (serial) { + content.document.documentElement.setAttribute("data-dummy", serial); + } + ); + + let mutations = await onMutations; + + // Filter out our dummy mutation. + mutations = mutations.filter(change => { + if (change.type == "attributes" && change.attributeName == "data-dummy") { + return false; + } + return true; + }); + await assertOwnershipTrees(walker); + if (testSpec.postCheck) { + testSpec.postCheck(walker, mutations); + } + }; +} + +// Verify that our dummy mutation works. +add_task( + mutationTest({ + autoCleanup: false, + postCheck(walker, mutations) { + is(mutations.length, 0, "Dummy mutation is filtered out."); + }, + }) +); + +// Test a simple move to a different location in the sibling list for the same +// parent. +add_task( + mutationTest({ + autoCleanup: false, + load: ["#longlist div"], + moves: [["#a", "#longlist"]], + postCheck(walker, mutations) { + const remove = mutations[0]; + is(remove.type, "childList", "First mutation should be a childList."); + ok(!!remove.removed.length, "First mutation should be a removal."); + const add = mutations[1]; + is( + add.type, + "childList", + "Second mutation should be a childList removal." + ); + ok(!!add.added.length, "Second mutation should be an addition."); + const a = add.added[0]; + is(a.id, "a", "Added node should be #a"); + is(a.parentNode(), remove.target, "Should still be a child of longlist."); + is( + remove.target, + add.target, + "First and second mutations should be against the same node." + ); + }, + }) +); + +// Test a move to another location that is within our ownership tree. +add_task( + mutationTest({ + autoCleanup: false, + load: ["#longlist div", "#longlist-sibling"], + moves: [["#a", "#longlist-sibling"]], + postCheck(walker, mutations) { + const remove = mutations[0]; + is(remove.type, "childList", "First mutation should be a childList."); + ok(!!remove.removed.length, "First mutation should be a removal."); + const add = mutations[1]; + is( + add.type, + "childList", + "Second mutation should be a childList removal." + ); + ok(!!add.added.length, "Second mutation should be an addition."); + const a = add.added[0]; + is(a.id, "a", "Added node should be #a"); + is(a.parentNode(), add.target, "Should still be a child of longlist."); + is( + add.target.id, + "longlist-sibling", + "long-sibling should be the target." + ); + }, + }) +); + +// Move an unseen node with a seen parent into our ownership tree - should generate a +// childList pair with no adds or removes. +add_task( + mutationTest({ + autoCleanup: false, + load: ["#longlist"], + moves: [["#longlist-sibling", "#longlist"]], + postCheck(walker, mutations) { + is(mutations.length, 2, "Should generate two mutations"); + is(mutations[0].type, "childList", "Should be childList mutations."); + is(mutations[0].added.length, 0, "Should have no adds."); + is(mutations[0].removed.length, 0, "Should have no removes."); + is(mutations[1].type, "childList", "Should be childList mutations."); + is(mutations[1].added.length, 0, "Should have no adds."); + is(mutations[1].removed.length, 0, "Should have no removes."); + }, + }) +); + +// Move an unseen node with an unseen parent into our ownership tree. Should only +// generate one childList mutation with no adds or removes. +add_task( + mutationTest({ + autoCleanup: false, + load: ["#longlist div"], + moves: [["#longlist-sibling-firstchild", "#longlist"]], + postCheck(walker, mutations) { + is(mutations.length, 1, "Should generate two mutations"); + is(mutations[0].type, "childList", "Should be childList mutations."); + is(mutations[0].added.length, 0, "Should have no adds."); + is(mutations[0].removed.length, 0, "Should have no removes."); + }, + }) +); + +// Move a node between unseen nodes, should generate no mutations. +add_task( + mutationTest({ + autoCleanup: false, + load: ["html"], + moves: [["#longlist-sibling", "#longlist"]], + postCheck(walker, mutations) { + is(mutations.length, 0, "Should generate no mutations."); + }, + }) +); + +// Orphan a node and don't clean it up +add_task( + mutationTest({ + autoCleanup: false, + load: ["#longlist div"], + moves: [["#longlist", null]], + postCheck(walker, mutations) { + is(mutations.length, 1, "Should generate one mutation."); + const change = mutations[0]; + is(change.type, "childList", "Should be a childList."); + is(change.removed.length, 1, "Should have removed a child."); + const ownership = clientOwnershipTree(walker); + is(ownership.orphaned.length, 1, "Should have one orphaned subtree."); + is( + ownershipTreeSize(ownership.orphaned[0]), + 1 + 26 + 26, + "Should have orphaned longlist, and 26 children, and 26 singleTextChilds" + ); + }, + }) +); + +// Orphan a node, and do clean it up. +add_task( + mutationTest({ + autoCleanup: true, + load: ["#longlist div"], + moves: [["#longlist", null]], + postCheck(walker, mutations) { + is(mutations.length, 1, "Should generate one mutation."); + const change = mutations[0]; + is(change.type, "childList", "Should be a childList."); + is(change.removed.length, 1, "Should have removed a child."); + const ownership = clientOwnershipTree(walker); + is(ownership.orphaned.length, 0, "Should have no orphaned subtrees."); + }, + }) +); + +// Orphan a node by moving it into the tree but out of our visible subtree. +add_task( + mutationTest({ + autoCleanup: false, + load: ["#longlist div"], + moves: [["#longlist", "#longlist-sibling"]], + postCheck(walker, mutations) { + is(mutations.length, 1, "Should generate one mutation."); + const change = mutations[0]; + is(change.type, "childList", "Should be a childList."); + is(change.removed.length, 1, "Should have removed a child."); + const ownership = clientOwnershipTree(walker); + is(ownership.orphaned.length, 1, "Should have one orphaned subtree."); + is( + ownershipTreeSize(ownership.orphaned[0]), + 1 + 26 + 26, + "Should have orphaned longlist, 26 children, and 26 singleTextChilds." + ); + }, + }) +); + +// Orphan a node by moving it into the tree but out of our visible subtree, +// and clean it up. +add_task( + mutationTest({ + autoCleanup: true, + load: ["#longlist div"], + moves: [["#longlist", "#longlist-sibling"]], + postCheck(walker, mutations) { + is(mutations.length, 1, "Should generate one mutation."); + const change = mutations[0]; + is(change.type, "childList", "Should be a childList."); + is(change.removed.length, 1, "Should have removed a child."); + const ownership = clientOwnershipTree(walker); + is(ownership.orphaned.length, 0, "Should have no orphaned subtrees."); + }, + }) +); diff --git a/devtools/server/tests/browser/browser_inspector-release.js b/devtools/server/tests/browser/browser_inspector-release.js new file mode 100644 index 0000000000..5546da605a --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-release.js @@ -0,0 +1,54 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js", + this +); + +add_task(async function loadNewChild() { + const { target, walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + let originalOwnershipSize = 0; + let longlist = null; + let firstChild = null; + const list = await walker.querySelectorAll(walker.rootNode, "#longlist div"); + // Make sure we have the 26 children of longlist in our ownership tree. + is(list.length, 26, "Expect 26 div children."); + // Make sure we've read in all those children and incorporated them + // in our ownership tree. + const items = await list.items(); + originalOwnershipSize = await assertOwnershipTrees(walker); + + // Here is how the ownership tree is summed up: + // #document 1 + // <html> 1 + // <body> 1 + // <div id=longlist> 1 + // <div id=a>a</div> 26*2 (each child plus it's singleTextChild) + // ... + // <div id=z>z</div> + // ----- + // 56 + is(originalOwnershipSize, 56, "Correct number of items in ownership tree"); + firstChild = items[0].actorID; + // Now get the longlist and release it from the ownership tree. + const node = await walker.querySelector(walker.rootNode, "#longlist"); + longlist = node.actorID; + await walker.releaseNode(node); + // Our ownership size should now be 53 fewer + // (we forgot about #longlist + 26 children + 26 singleTextChild nodes) + const newOwnershipSize = await assertOwnershipTrees(walker); + is( + newOwnershipSize, + originalOwnershipSize - 53, + "Ownership tree should be lower" + ); + // Now verify that some nodes have gone away + await checkMissing(target, longlist); + await checkMissing(target, firstChild); +}); diff --git a/devtools/server/tests/browser/browser_inspector-remove.js b/devtools/server/tests/browser/browser_inspector-remove.js new file mode 100644 index 0000000000..8338e40ea2 --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-remove.js @@ -0,0 +1,102 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js", + this +); + +add_task(async function testRemoveSubtree() { + const { target, walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + function ignoreNode(node) { + // Duplicate the walker logic to skip blank nodes... + return ( + node.nodeType === content.Node.TEXT_NODE && + !/[^\s]/.test(node.nodeValue) + ); + } + + let nextSibling = content.document.querySelector("#longlist").nextSibling; + while (nextSibling && ignoreNode(nextSibling)) { + nextSibling = nextSibling.nextSibling; + } + + let previousSibling = + content.document.querySelector("#longlist").previousSibling; + while (previousSibling && ignoreNode(previousSibling)) { + previousSibling = previousSibling.previousSibling; + } + content.nextSibling = nextSibling; + content.previousSibling = previousSibling; + }); + + let originalOwnershipSize = 0; + const longlist = await walker.querySelector(walker.rootNode, "#longlist"); + const longlistID = longlist.actorID; + await walker.children(longlist); + originalOwnershipSize = await assertOwnershipTrees(walker); + // Here is how the ownership tree is summed up: + // #document 1 + // <html> 1 + // <body> 1 + // <div id=longlist> 1 + // <div id=a>a</div> 26*2 (each child plus it's singleTextChild) + // ... + // <div id=z>z</div> + // ----- + // 56 + is(originalOwnershipSize, 56, "Correct number of items in ownership tree"); + + const onMutation = waitForMutation(walker, isChildList); + const siblings = await walker.removeNode(longlist); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[siblings.previousSibling.actorID, siblings.nextSibling.actorID]], + function ([previousActorID, nextActorID]) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + + // Convert actorID to current compartment string otherwise + // searchAllConnectionsForActor is confused and won't find the actor. + previousActorID = String(previousActorID); + nextActorID = String(nextActorID); + const previous = + DevToolsServer.searchAllConnectionsForActor(previousActorID); + const next = DevToolsServer.searchAllConnectionsForActor(nextActorID); + + is( + previous.rawNode, + content.previousSibling, + "Should have returned the previous sibling." + ); + is( + next.rawNode, + content.nextSibling, + "Should have returned the next sibling." + ); + } + ); + await onMutation; + // Our ownership size should now be 51 fewer (we forgot about #longlist + 26 + // children + 26 singleTextChild nodes, but learned about #longlist's + // prev/next sibling) + const newOwnershipSize = await assertOwnershipTrees(walker); + is( + newOwnershipSize, + originalOwnershipSize - 51, + "Ownership tree should be lower" + ); + // Now verify that some nodes have gone away + return checkMissing(target, longlistID); +}); diff --git a/devtools/server/tests/browser/browser_inspector-retain.js b/devtools/server/tests/browser/browser_inspector-retain.js new file mode 100644 index 0000000000..43d156675e --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-retain.js @@ -0,0 +1,157 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js", + this +); + +// Retain a node, and a second-order child (in another document, for kicks) +// Release the parent of the top item, which should cause one retained orphan. + +// Then unretain the top node, which should retain the orphan. + +// Then change the source of the iframe, which should kill that orphan. + +add_task(async function testRetain() { + // The test does not make sense when EFT is enabled, as different documents will have + // different walkers. + if (isEveryFrameTargetEnabled()) { + return; + } + + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + // Get the toplevel body element and retain it. + const bodyFront = await walker.querySelector(walker.rootNode, "body"); + await walker.retainNode(bodyFront); + // Get an element in the child frame and retain it. + const frame = await walker.querySelector(walker.rootNode, "#childFrame"); + const children = await walker.children(frame, { maxNodes: 1 }); + const childDoc = children.nodes[0]; + const childListFront = await walker.querySelector(childDoc, "#longlist"); + const originalOwnershipSize = await assertOwnershipTrees(walker); + // and retain it. + await walker.retainNode(childListFront); + // OK, try releasing the parent of the first retained. + await walker.releaseNode(bodyFront.parentNode()); + const clientTree = clientOwnershipTree(walker); + + // That request should have freed the parent of the first retained + // but moved the rest into the retained orphaned tree. + is( + ownershipTreeSize(clientTree.root) + + ownershipTreeSize(clientTree.retained[0]) + + 1, + originalOwnershipSize, + "Should have only lost one item overall." + ); + is(walker._retainedOrphans.size, 1, "Should have retained one orphan"); + ok( + walker._retainedOrphans.has(bodyFront), + "Should have retained the expected node." + ); + // Unretain the body, which should promote the childListFront to a retained orphan. + await walker.unretainNode(bodyFront); + await assertOwnershipTrees(walker); + + is( + walker._retainedOrphans.size, + 1, + "Should still only have one retained orphan." + ); + ok( + !walker._retainedOrphans.has(bodyFront), + "Should have dropped the body node." + ); + ok( + walker._retainedOrphans.has(childListFront), + "Should have retained the child node." + ); + + // Change the source of the iframe, which should kill the retained orphan. + const onMutations = waitForMutation(walker, isUnretained); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + content.document.querySelector("#childFrame").src = + "data:text/html,<html>new child</html>"; + }); + await onMutations; + + await assertOwnershipTrees(walker); + is(walker._retainedOrphans.size, 0, "Should have no more retained orphans."); +}); + +// Get a hold of a node, remove it from the doc and retain it at the same time. +// We should always win that race (even though the mutation happens before the +// retain request), because we haven't issued `getMutations` yet. +add_task(async function testWinRace() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + const front = await walker.querySelector(walker.rootNode, "#a"); + const onMutation = waitForMutation(walker, isChildList); + SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const contentNode = content.document.querySelector("#a"); + contentNode.remove(); + }); + // Now wait for that mutation and retain response to come in. + await walker.retainNode(front); + await onMutation; + + await assertOwnershipTrees(walker); + is(walker._retainedOrphans.size, 1, "Should have a retained orphan."); + ok( + walker._retainedOrphans.has(front), + "Should have retained our expected node." + ); + await walker.unretainNode(front); + + // Make sure we're clear for the next test. + await assertOwnershipTrees(walker); + is(walker._retainedOrphans.size, 0, "Should have no more retained orphans."); +}); + +// Same as above, but issue the request right after the 'new-mutations' event, so that +// we *lose* the race. +add_task(async function testLoseRace() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + const front = await walker.querySelector(walker.rootNode, "#z"); + const onMutation = walker.once("new-mutations"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const contentNode = content.document.querySelector("#z"); + contentNode.remove(); + }); + await onMutation; + + // Verify that we have an outstanding request (no good way to tell that it's a + // getMutations request, but there's nothing else it would be). + is(walker._requests.length, 1, "Should have an outstanding request."); + try { + await walker.retainNode(front); + ok(false, "Request should not have succeeded!"); + } catch (err) { + // XXX: Switched to from ok() to todo_is() in Bug 1467712. Follow up in + // 1500960 + // This is throwing because of + // `gInspectee.querySelector("#z").parentNode = null;` two blocks above... + // Even if you fix that, the test is still failing because "#a" was removed + // by the previous test. I am switching this to "#z" because I think that + // was the original intent. Still not failing with the expected error message + // Needs more work. + // ok(err, "noSuchActor", "Should have lost the race."); + is( + walker._retainedOrphans.size, + 0, + "Should have no more retained orphans." + ); + // Don't re-throw the error. + } +}); diff --git a/devtools/server/tests/browser/browser_inspector-search.js b/devtools/server/tests/browser/browser_inspector-search.js new file mode 100644 index 0000000000..705685681c --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-search.js @@ -0,0 +1,339 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js", + this +); + +// Test for Bug 835896 +// WalkerSearch specific tests. This is to make sure search results are +// coming back as expected. +// See also test_inspector-search-front.html. + +add_task(async function () { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-search-data.html" + ); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[walker.actorID]], + async function (actorID) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + const { + DocumentWalker: _documentWalker, + } = require("resource://devtools/server/actors/inspector/document-walker.js"); + + // Convert actorID to current compartment string otherwise + // searchAllConnectionsForActor is confused and won't find the actor. + actorID = String(actorID); + const walkerActor = DevToolsServer.searchAllConnectionsForActor(actorID); + const walkerSearch = walkerActor.walkerSearch; + const { + WalkerSearch, + WalkerIndex, + } = require("resource://devtools/server/actors/utils/walker-search.js"); + + info("Testing basic index APIs exist."); + const index = new WalkerIndex(walkerActor); + ok(index.data.size > 0, "public index is filled after getting"); + + index.clearIndex(); + ok(!index._data, "private index is empty after clearing"); + ok(index.data.size > 0, "public index is filled after getting"); + + index.destroy(); + + info("Testing basic search APIs exist."); + + ok(walkerSearch, "walker search exists on the WalkerActor"); + ok(walkerSearch.search, "walker search has `search` method"); + ok(walkerSearch.index, "walker search has `index` property"); + is( + walkerSearch.walker, + walkerActor, + "referencing the correct WalkerActor" + ); + + const walkerSearch2 = new WalkerSearch(walkerActor); + ok(walkerSearch2, "a new search instance can be created"); + ok(walkerSearch2.search, "new search instance has `search` method"); + ok(walkerSearch2.index, "new search instance has `index` property"); + isnot( + walkerSearch2, + walkerSearch, + "new search instance differs from the WalkerActor's" + ); + + walkerSearch2.destroy(); + + info("Testing search with an empty query."); + let results = walkerSearch.search(""); + is(results.length, 0, "No results when searching for ''"); + + results = walkerSearch.search(null); + is(results.length, 0, "No results when searching for null"); + + results = walkerSearch.search(undefined); + is(results.length, 0, "No results when searching for undefined"); + + results = walkerSearch.search(10); + is(results.length, 0, "No results when searching for 10"); + + const inspectee = content.document; + const testData = [ + { + desc: "Search for tag with one result.", + search: "body", + expected: [{ node: inspectee.body, type: "tag" }], + }, + { + desc: "Search for tag with multiple results", + search: "h2", + expected: [ + { node: inspectee.querySelectorAll("h2")[0], type: "tag" }, + { node: inspectee.querySelectorAll("h2")[1], type: "tag" }, + { node: inspectee.querySelectorAll("h2")[2], type: "tag" }, + ], + }, + { + desc: "Search for selector with multiple results", + search: "body > h2", + expected: [ + { node: inspectee.querySelectorAll("h2")[0], type: "selector" }, + { node: inspectee.querySelectorAll("h2")[1], type: "selector" }, + { node: inspectee.querySelectorAll("h2")[2], type: "selector" }, + ], + }, + { + desc: "Search for selector with multiple results", + search: ":root h2", + expected: [ + { node: inspectee.querySelectorAll("h2")[0], type: "selector" }, + { node: inspectee.querySelectorAll("h2")[1], type: "selector" }, + { node: inspectee.querySelectorAll("h2")[2], type: "selector" }, + ], + }, + { + desc: "Search for selector with multiple results", + search: "* h2", + expected: [ + { node: inspectee.querySelectorAll("h2")[0], type: "selector" }, + { node: inspectee.querySelectorAll("h2")[1], type: "selector" }, + { node: inspectee.querySelectorAll("h2")[2], type: "selector" }, + ], + }, + { + desc: "Search with multiple matches in a single tag expecting a single result", + search: "💩", + expected: [ + { node: inspectee.getElementById("💩"), type: "attributeValue" }, + ], + }, + { + desc: "Search that has tag and text results", + search: "h1", + expected: [ + { node: inspectee.querySelector("h1"), type: "tag" }, + { + node: inspectee.querySelector("h1 + p").childNodes[0], + type: "text", + }, + { + node: inspectee.querySelector("h1 + p > strong").childNodes[0], + type: "text", + }, + ], + }, + { + desc: "Search for XPath with one result", + search: "//strong", + expected: [ + { node: inspectee.querySelector("strong"), type: "xpath" }, + ], + }, + { + desc: "Search for XPath with multiple results", + search: "//h2", + expected: [ + { node: inspectee.querySelectorAll("h2")[0], type: "xpath" }, + { node: inspectee.querySelectorAll("h2")[1], type: "xpath" }, + { node: inspectee.querySelectorAll("h2")[2], type: "xpath" }, + ], + }, + { + desc: "Search for XPath via containing text", + search: "//*[contains(text(), 'p tag')]", + expected: [{ node: inspectee.querySelector("p"), type: "xpath" }], + }, + { + desc: "Search for XPath matching text node", + search: "//strong/text()", + expected: [ + { + node: inspectee.querySelector("strong").firstChild, + type: "xpath", + }, + ], + }, + { + desc: "Search using XPath grouping expression", + search: "(//*)[2]", + expected: [{ node: inspectee.querySelector("head"), type: "xpath" }], + }, + { + desc: "Search using XPath function", + search: "id('arrows')", + expected: [ + { node: inspectee.querySelector("#arrows"), type: "xpath" }, + ], + }, + ]; + + const isDeeply = (a, b, msg) => { + return is(JSON.stringify(a), JSON.stringify(b), msg); + }; + for (const { desc, search, expected } of testData) { + info("Running test: " + desc); + results = walkerSearch.search(search); + isDeeply( + results, + expected, + "Search returns correct results with '" + search + "'" + ); + } + + info("Testing ::before and ::after element matching"); + + const beforeElt = new _documentWalker( + inspectee.querySelector("#pseudo"), + inspectee.defaultView + ).firstChild(); + const afterElt = new _documentWalker( + inspectee.querySelector("#pseudo"), + inspectee.defaultView + ).lastChild(); + const styleText = inspectee.querySelector("style").childNodes[0]; + + // ::before + results = walkerSearch.search("::before"); + isDeeply( + results, + [{ node: beforeElt, type: "tag" }], + "Tag search works for pseudo element" + ); + + results = walkerSearch.search("_moz_generated_content_before"); + is(results.length, 0, "No results for anon tag name"); + + results = walkerSearch.search("before element"); + isDeeply( + results, + [ + { node: styleText, type: "text" }, + { node: beforeElt, type: "text" }, + ], + "Text search works for pseudo element" + ); + + // ::after + results = walkerSearch.search("::after"); + isDeeply( + results, + [{ node: afterElt, type: "tag" }], + "Tag search works for pseudo element" + ); + + results = walkerSearch.search("_moz_generated_content_after"); + is(results.length, 0, "No results for anon tag name"); + + results = walkerSearch.search("after element"); + isDeeply( + results, + [ + { node: styleText, type: "text" }, + { node: afterElt, type: "text" }, + ], + "Text search works for pseudo element" + ); + + info("Testing search before and after a mutation."); + const expected = [ + { node: inspectee.querySelectorAll("h3")[0], type: "tag" }, + { node: inspectee.querySelectorAll("h3")[1], type: "tag" }, + { node: inspectee.querySelectorAll("h3")[2], type: "tag" }, + ]; + + results = walkerSearch.search("h3"); + isDeeply(results, expected, "Search works with tag results"); + + function mutateDocumentAndWaitForMutation(mutationFn) { + // eslint-disable-next-line new-cap + return new Promise(resolve => { + info("Listening to markup mutation on the inspectee"); + const observer = new inspectee.defaultView.MutationObserver(resolve); + observer.observe(inspectee, { childList: true, subtree: true }); + mutationFn(); + }); + } + await mutateDocumentAndWaitForMutation(() => { + expected[0].node.remove(); + }); + + results = walkerSearch.search("h3"); + isDeeply( + results, + [expected[1], expected[2]], + "Results are updated after removal" + ); + + // eslint-disable-next-line new-cap + await new Promise(resolve => { + info("Waiting for a mutation to happen"); + const observer = new inspectee.defaultView.MutationObserver(() => { + resolve(); + }); + observer.observe(inspectee, { attributes: true, subtree: true }); + inspectee.body.setAttribute("h3", "true"); + }); + + results = walkerSearch.search("h3"); + isDeeply( + results, + [ + { node: inspectee.body, type: "attributeName" }, + expected[1], + expected[2], + ], + "Results are updated after addition" + ); + + // eslint-disable-next-line new-cap + await new Promise(resolve => { + info("Waiting for a mutation to happen"); + const observer = new inspectee.defaultView.MutationObserver(() => { + resolve(); + }); + observer.observe(inspectee, { + attributes: true, + childList: true, + subtree: true, + }); + inspectee.body.removeAttribute("h3"); + expected[1].node.remove(); + expected[2].node.remove(); + }); + + results = walkerSearch.search("h3"); + is(results.length, 0, "Results are updated after removal"); + } + ); +}); diff --git a/devtools/server/tests/browser/browser_inspector-shadow.js b/devtools/server/tests/browser/browser_inspector-shadow.js new file mode 100644 index 0000000000..7675593c96 --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-shadow.js @@ -0,0 +1,231 @@ +"use strict"; + +const URL = MAIN_DOMAIN + "inspector-shadow.html"; + +add_task(async function () { + info("Test that a shadow host has a shadow root"); + const { walker } = await initInspectorFront(URL); + + const el = await walker.querySelector(walker.rootNode, "#empty"); + const children = await walker.children(el); + + is(el.displayName, "test-empty", "#empty exists"); + ok(el.isShadowHost, "#empty is a shadow host"); + + const shadowRoot = children.nodes[0]; + ok(shadowRoot.isShadowRoot, "#empty has a shadow-root child"); + is(children.nodes.length, 1, "#empty has no other children"); +}); + +add_task(async function () { + info("Test that a shadow host has its children too"); + const { walker } = await initInspectorFront(URL); + + const el = await walker.querySelector(walker.rootNode, "#one-child"); + const children = await walker.children(el); + + is( + children.nodes.length, + 2, + "#one-child has two children " + "(shadow root + another child)" + ); + ok(children.nodes[0].isShadowRoot, "First child is a shadow-root"); + is(children.nodes[1].displayName, "h1", "Second child is <h1>"); +}); + +add_task(async function () { + info("Test that shadow-root has its children"); + const { walker } = await initInspectorFront(URL); + + const el = await walker.querySelector(walker.rootNode, "#shadow-children"); + ok(el.isShadowHost, "#shadow-children is a shadow host"); + + const children = await walker.children(el); + ok( + children.nodes.length === 1 && children.nodes[0].isShadowRoot, + "#shadow-children has only one child and it's a shadow-root" + ); + + const shadowRoot = children.nodes[0]; + const shadowChildren = await walker.children(shadowRoot); + is(shadowChildren.nodes.length, 2, "shadow-root has two children"); + is(shadowChildren.nodes[0].displayName, "h1", "First child is <h1>"); + is(shadowChildren.nodes[1].displayName, "p", "Second child is <p>"); +}); + +add_task(async function () { + info("Test that shadow root has its children and slotted nodes"); + const { walker } = await initInspectorFront(URL); + + const el = await walker.querySelector(walker.rootNode, "#named-slot"); + ok(el.isShadowHost, "#named-slot is a shadow host"); + + const children = await walker.children(el); + is(children.nodes.length, 2, "#named-slot has two children"); + const shadowRoot = children.nodes[0]; + ok(shadowRoot.isShadowRoot, "#named-slot has a shadow-root child"); + + const slotted = children.nodes[1]; + is( + slotted.getAttribute("slot"), + "slot1", + "#named-slot as a child that is slotted" + ); + + const shadowChildren = await walker.children(shadowRoot); + is( + shadowChildren.nodes[0].displayName, + "h1", + "shadow-root first child is a regular <h1> tag" + ); + is( + shadowChildren.nodes[1].displayName, + "slot", + "shadow-root second child is a slot" + ); + + const slottedChildren = await walker.children(shadowChildren.nodes[1]); + is( + slottedChildren.nodes[0], + slotted, + "The slot has the slotted node as a child" + ); +}); + +add_task(async function () { + info("Test pseudoelements in shadow host"); + const { walker } = await initInspectorFront(URL); + + const el = await walker.querySelector(walker.rootNode, "#host-pseudo"); + const children = await walker.children(el); + + ok(children.nodes[0].isShadowRoot, "#host-pseudo 1st child is a shadow root"); + ok( + children.nodes[1].isBeforePseudoElement, + "#host-pseudo 2nd child is ::before" + ); + ok( + children.nodes[2].isAfterPseudoElement, + "#host-pseudo 3rd child is ::after" + ); +}); + +add_task(async function () { + info("Test pseudoelements in slotted nodes"); + const { walker } = await initInspectorFront(URL); + + const el = await walker.querySelector(walker.rootNode, "#slot-pseudo"); + const shadowRoot = (await walker.children(el)).nodes[0]; + ok(shadowRoot.isShadowRoot, "#slot-pseudo has a shadow-root child"); + + const shadowChildren = await walker.children(shadowRoot); + is(shadowChildren.nodes[1].displayName, "slot", "shadow-root has a slot"); + + const slottedChildren = await walker.children(shadowChildren.nodes[1]); + ok(slottedChildren.nodes[0].isBeforePseudoElement, "slot has ::before"); + ok( + slottedChildren.nodes[slottedChildren.nodes.length - 1] + .isAfterPseudoElement, + "slot has ::after" + ); +}); + +add_task(async function () { + info("Test open/closed modes in shadow roots"); + const { walker } = await initInspectorFront(URL); + + const openEl = await walker.querySelector(walker.rootNode, "#mode-open"); + const openShadowRoot = (await walker.children(openEl)).nodes[0]; + const closedEl = await walker.querySelector(walker.rootNode, "#mode-closed"); + const closedShadowRoot = (await walker.children(closedEl)).nodes[0]; + + is( + openShadowRoot.shadowRootMode, + "open", + "#mode-open has a shadow root with open mode" + ); + is( + closedShadowRoot.shadowRootMode, + "closed", + "#mode-closed has a shadow root with closed mode" + ); +}); + +add_task(async function () { + info("Test that slotted inline text nodes appear in the Shadow DOM tree"); + const { walker } = await initInspectorFront(URL); + + const el = await walker.querySelector(walker.rootNode, "#slot-inline-text"); + const hostChildren = await walker.children(el); + const originalSlot = hostChildren.nodes[1]; + is( + originalSlot.displayName, + "#text", + "Shadow host as a text node to be slotted" + ); + + const shadowRoot = hostChildren.nodes[0]; + const shadowChildren = await walker.children(shadowRoot); + const slot = shadowChildren.nodes[0]; + is(slot.displayName, "slot", "shadow-root has a slot child"); + ok(!slot._form.inlineTextChild, "Slotted node is not an inline text"); + + const slotChildren = await walker.children(slot); + const slotted = slotChildren.nodes[0]; + is(slotted.displayName, "#text", "Slotted node is a text node"); + is( + slotted._form.nodeValue, + originalSlot._form.nodeValue, + "Slotted content is the same as original's" + ); +}); + +add_task(async function () { + info("Test UA widgets when showAllAnonymousContent is true"); + await SpecialPowers.pushPrefEnv({ + set: [["devtools.inspector.showAllAnonymousContent", true]], + }); + + const { walker } = await initInspectorFront(URL); + + let el = await walker.querySelector(walker.rootNode, "#video-controls"); + let hostChildren = await walker.children(el); + is(hostChildren.nodes.length, 3, "#video-controls tag has 3 children"); + const shadowRoot = hostChildren.nodes[0]; + ok(shadowRoot.isShadowRoot, "#video-controls has a shadow-root child"); + + el = await walker.querySelector( + walker.rootNode, + "#video-controls-with-children" + ); + hostChildren = await walker.children(el); + is( + hostChildren.nodes.length, + 4, + "#video-controls-with-children has 4 children" + ); +}); + +add_task(async function () { + info("Test UA widgets when showAllAnonymousContent is false"); + await SpecialPowers.pushPrefEnv({ + set: [["devtools.inspector.showAllAnonymousContent", false]], + }); + + const { walker } = await initInspectorFront(URL); + + let el = await walker.querySelector(walker.rootNode, "#video-controls"); + let hostChildren = await walker.children(el); + is(hostChildren.nodes.length, 0, "#video-controls tag has no children"); + + el = await walker.querySelector( + walker.rootNode, + "#video-controls-with-children" + ); + hostChildren = await walker.children(el); + is( + hostChildren.nodes.length, + 1, + "#video-controls-with-children has one child" + ); +}); diff --git a/devtools/server/tests/browser/browser_inspector-traversal.js b/devtools/server/tests/browser/browser_inspector-traversal.js new file mode 100644 index 0000000000..d0a10bc107 --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-traversal.js @@ -0,0 +1,349 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js", + this +); + +const checkActorIDs = []; + +add_task(async function loadNewChild() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + // Make sure that refetching the root document of the walker returns the same + // actor as the getWalker returned. + const root = await walker.document(); + ok( + root === walker.rootNode, + "Re-fetching the document node should match the root document node." + ); + checkActorIDs.push(root.actorID); + await assertOwnershipTrees(walker); +}); + +add_task(async function testInnerHTML() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + const docElement = await walker.documentElement(); + const longstring = await walker.innerHTML(docElement); + const innerHTML = await longstring.string(); + const actualInnerHTML = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + return content.document.documentElement.innerHTML; + } + ); + ok(innerHTML === actualInnerHTML, "innerHTML should match"); +}); + +add_task(async function testOuterHTML() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + const docElement = await walker.documentElement(); + const longstring = await walker.outerHTML(docElement); + const outerHTML = await longstring.string(); + const actualOuterHTML = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + return content.document.documentElement.outerHTML; + } + ); + ok(outerHTML === actualOuterHTML, "outerHTML should match"); +}); + +add_task(async function testSetOuterHTMLNode() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const newHTML = '<p id="edit-html-done">after edit</p>'; + let node = await walker.querySelector(walker.rootNode, "#edit-html"); + await walker.setOuterHTML(node, newHTML); + node = await walker.querySelector(walker.rootNode, "#edit-html-done"); + const longstring = await walker.outerHTML(node); + const outerHTML = await longstring.string(); + is(outerHTML, newHTML, "outerHTML has been updated"); + node = await walker.querySelector(walker.rootNode, "#edit-html"); + ok(!node, "The node with the old ID cannot be selected anymore"); +}); + +add_task(async function testQuerySelector() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + let node = await walker.querySelector(walker.rootNode, "#longlist"); + is( + node.getAttribute("data-test"), + "exists", + "should have found the right node" + ); + await assertOwnershipTrees(walker); + node = await walker.querySelector(walker.rootNode, "unknownqueryselector"); + ok(!node, "Should not find a node here."); + await assertOwnershipTrees(walker); +}); + +add_task(async function testQuerySelectors() { + const { target, walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const nodeList = await walker.querySelectorAll( + walker.rootNode, + "#longlist div" + ); + is(nodeList.length, 26, "Expect 26 div children."); + await assertOwnershipTrees(walker); + const firstNode = await nodeList.item(0); + checkActorIDs.push(firstNode.actorID); + is(firstNode.id, "a", "First child should be a"); + await assertOwnershipTrees(walker); + let nodes = await nodeList.items(); + is(nodes.length, 26, "Expect 26 nodes"); + is(nodes[0], firstNode, "First node should be reused."); + ok(nodes[0]._parent, "Parent node should be set."); + ok(nodes[0]._next || nodes[0]._prev, "Siblings should be set."); + ok( + nodes[25]._next || nodes[25]._prev, + "Siblings of " + nodes[25] + " should be set." + ); + await assertOwnershipTrees(walker); + nodes = await nodeList.items(-1); + is(nodes.length, 1, "Expect 1 node"); + is(nodes[0].id, "z", "Expect it to be the last node."); + checkActorIDs.push(nodes[0].actorID); + // Save the node list ID so we can ensure it was destroyed. + const nodeListID = nodeList.actorID; + await assertOwnershipTrees(walker); + await nodeList.release(); + ok(!nodeList.actorID, "Actor should have been destroyed."); + await assertOwnershipTrees(walker); + await checkMissing(target, nodeListID); +}); + +// Helper to check the response of requests that return hasFirst/hasLast/nodes +// node lists (like `children` and `siblings`) +async function checkArray(walker, children, first, last, ids) { + is( + children.hasFirst, + first, + "Should " + (first ? "" : "not ") + " have the first node." + ); + is( + children.hasLast, + last, + "Should " + (last ? "" : "not ") + " have the last node." + ); + is( + children.nodes.length, + ids.length, + "Should have " + ids.length + " children listed." + ); + let responseIds = ""; + for (const node of children.nodes) { + responseIds += node.id; + } + is(responseIds, ids, "Correct nodes were returned."); + await assertOwnershipTrees(walker); +} + +add_task(async function testNoChildren() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const empty = await walker.querySelector(walker.rootNode, "#empty"); + await assertOwnershipTrees(walker); + const children = await walker.children(empty); + await checkArray(walker, children, true, true, ""); +}); + +add_task(async function testLongListTraversal() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const longList = await walker.querySelector(walker.rootNode, "#longlist"); + // First call with no options, expect all children. + await assertOwnershipTrees(walker); + let children = await walker.children(longList); + await checkArray(walker, children, true, true, "abcdefghijklmnopqrstuvwxyz"); + const allChildren = children.nodes; + await assertOwnershipTrees(walker); + // maxNodes should limit us to the first 5 nodes. + await assertOwnershipTrees(walker); + children = await walker.children(longList, { maxNodes: 5 }); + await checkArray(walker, children, true, false, "abcde"); + await assertOwnershipTrees(walker); + // maxNodes with the second item centered should still give us the first 5 nodes. + children = await walker.children(longList, { + maxNodes: 5, + center: allChildren[1], + }); + await checkArray(walker, children, true, false, "abcde"); + // maxNodes with a center in the middle of the list should put that item in the middle + const center = allChildren[13]; + is(center.id, "n", "Make sure I know how to count letters."); + children = await walker.children(longList, { maxNodes: 5, center }); + await checkArray(walker, children, false, false, "lmnop"); + // maxNodes with the second-to-last item centered should give us the last 5 nodes. + children = await walker.children(longList, { + maxNodes: 5, + center: allChildren[24], + }); + await checkArray(walker, children, false, true, "vwxyz"); + // maxNodes with a start in the middle should start at that node and fetch 5 + const start = allChildren[13]; + is(start.id, "n", "Make sure I know how to count letters."); + children = await walker.children(longList, { maxNodes: 5, start }); + await checkArray(walker, children, false, false, "nopqr"); + // maxNodes near the end should only return what's left + children = await walker.children(longList, { + maxNodes: 5, + start: allChildren[24], + }); + await checkArray(walker, children, false, true, "yz"); +}); + +add_task(async function testObjectNodeChildren() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const object = await walker.querySelector(walker.rootNode, "object"); + const children = await walker.children(object); + await checkArray(walker, children, true, true, "1"); +}); + +add_task(async function testNextSibling() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const y = await walker.querySelector(walker.rootNode, "#y"); + is(y.id, "y", "Got the right node."); + const z = await walker.nextSibling(y); + is(z.id, "z", "nextSibling got the next node."); + const nothing = await walker.nextSibling(z); + is(nothing, null, "nextSibling on the last node returned null."); +}); + +add_task(async function testPreviousSibling() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const b = await walker.querySelector(walker.rootNode, "#b"); + is(b.id, "b", "Got the right node."); + const a = await walker.previousSibling(b); + is(a.id, "a", "nextSibling got the next node."); + const nothing = await walker.previousSibling(a); + is(nothing, null, "previousSibling on the first node returned null."); +}); + +add_task(async function testFrameTraversal() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const childFrame = await walker.querySelector(walker.rootNode, "#childFrame"); + const children = await walker.children(childFrame); + const nodes = children.nodes; + is(nodes.length, 1, "There should be only one child of the iframe"); + is( + nodes[0].nodeType, + Node.DOCUMENT_NODE, + "iframe child should be a document node" + ); + await walker.querySelector(nodes[0], "#z"); +}); + +add_task(async function testLongValue() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + + SimpleTest.registerCleanupFunction(async function () { + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const WalkerActor = require("resource://devtools/server/actors/inspector/walker.js"); + WalkerActor.setValueSummaryLength( + WalkerActor.DEFAULT_VALUE_SUMMARY_LENGTH + ); + }); + }); + + const longstringText = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const testSummaryLength = 10; + const WalkerActor = require("resource://devtools/server/actors/inspector/walker.js"); + + WalkerActor.setValueSummaryLength(testSummaryLength); + return content.document.getElementById("longstring").firstChild.nodeValue; + } + ); + + const node = await walker.querySelector(walker.rootNode, "#longstring"); + ok(!node.inlineTextChild, "Text is too long to be inlined"); + // Now we need to get the text node child... + const children = await walker.children(node, { maxNodes: 1 }); + const textNode = children.nodes[0]; + is(textNode.nodeType, Node.TEXT_NODE, "Value should be a text node"); + const value = await textNode.getNodeValue(); + const valueStr = await value.string(); + is( + valueStr, + longstringText, + "Full node value should match the string from the document." + ); +}); + +add_task(async function testShortValue() { + const { walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + const shortstringText = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + return content.document.getElementById("shortstring").firstChild + .nodeValue; + } + ); + + const node = await walker.querySelector(walker.rootNode, "#shortstring"); + ok(!!node.inlineTextChild, "Text is short enough to be inlined"); + // Now we need to get the text node child... + const children = await walker.children(node, { maxNodes: 1 }); + const textNode = children.nodes[0]; + is(textNode.nodeType, Node.TEXT_NODE, "Value should be a text node"); + const value = await textNode.getNodeValue(); + const valueStr = await value.string(); + is( + valueStr, + shortstringText, + "Full node value should match the string from the document." + ); +}); + +add_task(async function testReleaseWalker() { + const { target, walker } = await initInspectorFront( + MAIN_DOMAIN + "inspector-traversal-data.html" + ); + checkActorIDs.push(walker.actorID); + + await walker.release(); + for (const id of checkActorIDs) { + await checkMissing(target, id); + } +}); diff --git a/devtools/server/tests/browser/browser_inspector-utils.js b/devtools/server/tests/browser/browser_inspector-utils.js new file mode 100644 index 0000000000..b81eeb0178 --- /dev/null +++ b/devtools/server/tests/browser/browser_inspector-utils.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js", + this +); + +const COLOR_WHITE = [255, 255, 255, 1]; + +add_task(async function loadNewChild() { + const { walker } = await initInspectorFront( + `data:text/html,<style>body{color:red;background-color:white;}body::before{content:"test";}</style>` + ); + + const body = await walker.querySelector(walker.rootNode, "body"); + const color = await body.getBackgroundColor(); + Assert.deepEqual( + color.value, + COLOR_WHITE, + "Background color is calculated correctly for an element with a pseudo child." + ); +}); diff --git a/devtools/server/tests/browser/browser_layout_getGrids.js b/devtools/server/tests/browser/browser_layout_getGrids.js new file mode 100644 index 0000000000..ce40cf7a22 --- /dev/null +++ b/devtools/server/tests/browser/browser_layout_getGrids.js @@ -0,0 +1,145 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Check the output of getGrids for the LayoutActor + +const GRID_FRAGMENT_DATA = { + areas: [ + { + columnEnd: 3, + columnStart: 2, + name: "header", + rowEnd: 2, + rowStart: 1, + type: "explicit", + }, + { + columnEnd: 2, + columnStart: 1, + name: "sidebar", + rowEnd: 3, + rowStart: 2, + type: "explicit", + }, + { + columnEnd: 3, + columnStart: 2, + name: "content", + rowEnd: 3, + rowStart: 2, + type: "explicit", + }, + ], + cols: { + lines: [ + { + breadth: 0, + names: ["col-1", "col-start-1", "sidebar-start"], + number: 1, + start: 0, + type: "explicit", + }, + { + breadth: 0, + names: ["col-2", "header-start", "sidebar-end", "content-start"], + number: 2, + start: 100, + type: "explicit", + }, + { + breadth: 0, + names: ["header-end", "content-end"], + number: 3, + start: 200, + type: "explicit", + }, + ], + tracks: [ + { + breadth: 100, + start: 0, + state: "static", + type: "explicit", + }, + { + breadth: 100, + start: 100, + state: "static", + type: "explicit", + }, + ], + }, + rows: { + lines: [ + { + breadth: 0, + names: ["header-start"], + number: 1, + start: 0, + type: "explicit", + }, + { + breadth: 0, + names: ["header-end", "sidebar-start", "content-start"], + number: 2, + start: 100, + type: "explicit", + }, + { + breadth: 0, + names: ["sidebar-end", "content-end"], + number: 3, + start: 200, + type: "explicit", + }, + ], + tracks: [ + { + breadth: 100, + start: 0, + state: "static", + type: "explicit", + }, + { + breadth: 100, + start: 100, + state: "static", + type: "explicit", + }, + ], + }, +}; + +add_task(async function () { + const { target, walker, layout } = await initLayoutFrontForUrl( + MAIN_DOMAIN + "grid.html" + ); + const grids = await layout.getGrids(walker.rootNode); + const grid = grids[0]; + const { gridFragments } = grid; + + is(grids.length, 1, "One grid was returned."); + is(gridFragments.length, 1, "One grid fragment was returned."); + ok(Array.isArray(gridFragments), "An array of grid fragments was returned."); + Assert.deepEqual( + gridFragments[0], + GRID_FRAGMENT_DATA, + "Got the correct grid fragment data." + ); + + info("Get the grid container node front."); + + try { + const nodeFront = await walker.getNodeFromActor(grids[0].actorID, [ + "containerEl", + ]); + ok(nodeFront, "Got the grid container node front."); + } catch (e) { + ok(false, "Did not get grid container node front."); + } + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_layout_simple.js b/devtools/server/tests/browser/browser_layout_simple.js new file mode 100644 index 0000000000..d4caba572e --- /dev/null +++ b/devtools/server/tests/browser/browser_layout_simple.js @@ -0,0 +1,31 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Simple checks for the LayoutActor and GridActor + +add_task(async function () { + const { target, walker, layout } = await initLayoutFrontForUrl( + "data:text/html;charset=utf-8,<title>test</title><div></div>" + ); + + ok(layout, "The LayoutFront was created"); + ok(layout.getGrids, "The getGrids method exists"); + + let didThrow = false; + try { + await layout.getGrids(null); + } catch (e) { + didThrow = true; + } + ok(didThrow, "An exception was thrown for a missing NodeActor in getGrids"); + + const invalidNode = await walker.querySelector(walker.rootNode, "title"); + const grids = await layout.getGrids(invalidNode); + ok(Array.isArray(grids), "An array of grids was returned"); + is(grids.length, 0, "0 grids have been returned for the invalid node"); + + await target.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/devtools/server/tests/browser/browser_memory_allocations_01.js b/devtools/server/tests/browser/browser_memory_allocations_01.js new file mode 100644 index 0000000000..d0bb53faa6 --- /dev/null +++ b/devtools/server/tests/browser/browser_memory_allocations_01.js @@ -0,0 +1,106 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function () { + const target = await addTabTarget("data:text/html;charset=utf-8,test-doc"); + const memory = await target.getFront("memory"); + + await memory.attach(); + + await memory.startRecordingAllocations(); + ok(true, "Can start recording allocations"); + + // Allocate some objects. + const [line1, line2, line3] = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + function () { + // Use eval to ensure allocating the object in the page's compartment + return content.eval( + "(" + + function () { + let alloc1, alloc2, alloc3; + + /* eslint-disable max-nested-callbacks */ + (function outer() { + (function middle() { + (function inner() { + alloc1 = {}; + alloc1.line = Error().lineNumber; + alloc2 = []; + alloc2.line = Error().lineNumber; + // eslint-disable-next-line new-parens + alloc3 = new (function () {})(); + alloc3.line = Error().lineNumber; + })(); + })(); + })(); + /* eslint-enable max-nested-callbacks */ + + return [alloc1.line, alloc2.line, alloc3.line]; + } + + ")()" + ); + } + ); + + const response = await memory.getAllocations(); + + await memory.stopRecordingAllocations(); + ok(true, "Can stop recording allocations"); + + // Filter out allocations by library and test code, and get only the + // allocations that occurred in our test case above. + + function isTestAllocation(alloc) { + const frame = response.frames[alloc]; + return ( + frame && + frame.functionDisplayName === "inner" && + (frame.line === line1 || frame.line === line2 || frame.line === line3) + ); + } + + const testAllocations = response.allocations.filter(isTestAllocation); + ok( + testAllocations.length >= 3, + "Should find our 3 test allocations (plus some allocations for the error " + + "objects used to get line numbers)" + ); + + // For each of the test case's allocations, ensure that the parent frame + // indices are correct. Also test that we did get an allocation at each + // line we expected (rather than a bunch on the first line and none on the + // others, etc). + + const expectedLines = new Set([line1, line2, line3]); + is(expectedLines.size, 3, "We are expecting 3 allocations"); + + for (const alloc of testAllocations) { + const innerFrame = response.frames[alloc]; + ok(innerFrame, "Should get the inner frame"); + is(innerFrame.functionDisplayName, "inner"); + expectedLines.delete(innerFrame.line); + + const middleFrame = response.frames[innerFrame.parent]; + ok(middleFrame, "Should get the middle frame"); + is(middleFrame.functionDisplayName, "middle"); + + const outerFrame = response.frames[middleFrame.parent]; + ok(outerFrame, "Should get the outer frame"); + is(outerFrame.functionDisplayName, "outer"); + + // Not going to test the rest of the frames because they are Task.jsm + // and promise frames and it gets gross. Plus, I wouldn't want this test + // to start failing if they changed their implementations in a way that + // added or removed stack frames here. + } + + is(expectedLines.size, 0, "Should have found all the expected lines"); + + await memory.detach(); + + await target.destroy(); +}); diff --git a/devtools/server/tests/browser/browser_perf-01.js b/devtools/server/tests/browser/browser_perf-01.js new file mode 100644 index 0000000000..96afc8151e --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-01.js @@ -0,0 +1,57 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +// This test is at the edge of timing out, probably because of LUL +// initialization on Linux. This is also happening only once, which is why only +// this test needs it: for other tests LUL is already initialized because +// they're running in the same Firefox instance. +// See also bug 1635442. +requestLongerTimeout(2); + +/** + * Run through a series of basic recording actions for the perf actor. + */ +add_task(async function () { + const { front, client } = await initPerfFront(); + + // Assert the initial state. + is( + await front.isSupportedPlatform(), + true, + "This test only runs on supported platforms." + ); + is(await front.isActive(), false, "The profiler is not active yet."); + + // Start the profiler. + const profilerStarted = once(front, "profiler-started"); + await front.startProfiler(); + await profilerStarted; + is(await front.isActive(), true, "The profiler was started."); + + // Stop the profiler and assert the results. + const profilerStopped1 = once(front, "profiler-stopped"); + const profile = await front.getProfileAndStopProfiler(); + await profilerStopped1; + is(await front.isActive(), false, "The profiler was stopped."); + ok("threads" in profile, "The actor was used to record a profile."); + + // Restart the profiler. + await front.startProfiler(); + is(await front.isActive(), true, "The profiler was re-started."); + + // Stop and discard. + const profilerStopped2 = once(front, "profiler-stopped"); + await front.stopProfilerAndDiscardProfile(); + await profilerStopped2; + is( + await front.isActive(), + false, + "The profiler was stopped and the profile discarded." + ); + + // Clean up. + await front.destroy(); + await client.close(); +}); diff --git a/devtools/server/tests/browser/browser_perf-02.js b/devtools/server/tests/browser/browser_perf-02.js new file mode 100644 index 0000000000..c7276d8a3f --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-02.js @@ -0,0 +1,37 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * Test what happens when other tools control the profiler. + */ +add_task(async function () { + const { front, client } = await initPerfFront(); + + // Simulate other tools by getting an independent handle on the Gecko Profiler. + // eslint-disable-next-line mozilla/use-services + const geckoProfiler = Cc["@mozilla.org/tools/profiler;1"].getService( + Ci.nsIProfiler + ); + + is(await front.isActive(), false, "The profiler hasn't been started yet."); + + // Start the profiler. + await front.startProfiler(); + is(await front.isActive(), true, "The profiler was started."); + + // Stop the profiler manually through the Gecko Profiler interface. + const profilerStopped = once(front, "profiler-stopped"); + geckoProfiler.StopProfiler(); + await profilerStopped; + is( + await front.isActive(), + false, + "The profiler was stopped by another tool." + ); + + // Clean up. + await front.destroy(); + await client.close(); +}); diff --git a/devtools/server/tests/browser/browser_perf-04.js b/devtools/server/tests/browser/browser_perf-04.js new file mode 100644 index 0000000000..9fba77d053 --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-04.js @@ -0,0 +1,53 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +/** + * Run through a series of basic recording actions for the perf actor. + */ +add_task(async function () { + const { front, client } = await initPerfFront(); + + // Assert the initial state. + is( + await front.isSupportedPlatform(), + true, + "This test only runs on supported platforms." + ); + is(await front.isActive(), false, "The profiler is not active yet."); + + // Getting the active Browser ID to assert in the "profiler-started" event. + const win = Services.wm.getMostRecentWindow("navigator:browser"); + const activeTabID = win.gBrowser.selectedBrowser.browsingContext.browserId; + + front.once( + "profiler-started", + (entries, interval, features, duration, activeTID) => { + is(entries, 1024, "Should apply entries by startProfiler"); + is(interval, 0.1, "Should apply interval by startProfiler"); + is(typeof features, "number", "Should apply features by startProfiler"); + is(duration, 2, "Should apply duration by startProfiler"); + is( + activeTID, + activeTabID, + "Should apply active browser ID by startProfiler" + ); + } + ); + + // Start the profiler. + await front.startProfiler({ + entries: 1000, + duration: 2, + interval: 0.1, + features: ["js", "stackwalk"], + }); + + is(await front.isActive(), true, "The profiler is active."); + + // clean up + await front.stopProfilerAndDiscardProfile(); + await front.destroy(); + await client.close(); +}); diff --git a/devtools/server/tests/browser/browser_perf-getSupportedFeatures.js b/devtools/server/tests/browser/browser_perf-getSupportedFeatures.js new file mode 100644 index 0000000000..331d6d329c --- /dev/null +++ b/devtools/server/tests/browser/browser_perf-getSupportedFeatures.js @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +add_task(async function () { + const { front, client } = await initPerfFront(); + + info("Get the supported features from the perf actor."); + const features = await front.getSupportedFeatures(); + + ok(Array.isArray(features), "The features are an array."); + ok(!!features.length, "There are many features supported."); + ok( + features.includes("js"), + "All platforms support the js feature, and it's in this list." + ); + + // clean up + await front.stopProfilerAndDiscardProfile(); + await front.destroy(); + await client.close(); +}); diff --git a/devtools/server/tests/browser/browser_storage_cookies-duplicate-names.js b/devtools/server/tests/browser/browser_storage_cookies-duplicate-names.js new file mode 100644 index 0000000000..0342e1b896 --- /dev/null +++ b/devtools/server/tests/browser/browser_storage_cookies-duplicate-names.js @@ -0,0 +1,134 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that the storage panel is able to display multiple cookies with the same +// name (and different paths). + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js", + this +); + +const l10n = new Localization(["devtools/client/storage.ftl"], true); +const sessionString = l10n.formatValueSync("storage-expires-session"); + +const TESTDATA = { + "http://test1.example.org": [ + { + name: "name", + value: "value1", + expires: 0, + path: "/", + host: "test1.example.org", + hostOnly: true, + isSecure: false, + }, + { + name: "name", + value: "value2", + expires: 0, + path: "/path2/", + host: "test1.example.org", + hostOnly: true, + isSecure: false, + }, + { + name: "name", + value: "value3", + expires: 0, + path: "/path3/", + host: "test1.example.org", + hostOnly: true, + isSecure: false, + }, + ], +}; + +add_task(async function () { + const { commands } = await openTabAndSetupStorage( + MAIN_DOMAIN + "storage-cookies-same-name.html" + ); + + const { resourceCommand } = commands; + const { TYPES } = resourceCommand; + const data = {}; + await resourceCommand.watchResources( + [TYPES.COOKIE, TYPES.LOCAL_STORAGE, TYPES.SESSION_STORAGE], + { + async onAvailable(resources) { + for (const resource of resources) { + const { resourceType } = resource; + if (!data[resourceType]) { + data[resourceType] = { hosts: {}, dataByHost: {} }; + } + + for (const host in resource.hosts) { + if (!data[resourceType].hosts[host]) { + data[resourceType].hosts[host] = []; + } + // For indexed DB, we have some values, the database names. Other are empty arrays. + const hostValues = resource.hosts[host]; + data[resourceType].hosts[host].push(...hostValues); + data[resourceType].dataByHost[host] = + await resource.getStoreObjects(host, null, { sessionString }); + } + } + }, + } + ); + + ok(data.cookies, "Cookies storage actor is present"); + + await testCookies(data.cookies); + await clearStorage(); + + // Forcing GC/CC to get rid of docshells and windows created by this test. + forceCollections(); + await commands.destroy(); + forceCollections(); +}); + +function testCookies({ hosts, dataByHost }) { + const numHosts = Object.keys(hosts).length; + is(numHosts, 1, "Correct number of host entries for cookies"); + return testCookiesObjects(0, hosts, dataByHost); +} + +var testCookiesObjects = async function (index, hosts, dataByHost) { + const host = Object.keys(hosts)[index]; + const data = dataByHost[host]; + is( + data.total, + TESTDATA[host].length, + "Number of cookies in host " + host + " matches" + ); + for (const item of data.data) { + let found = false; + for (const toMatch of TESTDATA[host]) { + if ( + item.name === toMatch.name && + item.host === toMatch.host && + item.path === toMatch.path + ) { + found = true; + ok(true, "Found cookie " + item.name + " in response"); + is(item.value.str, toMatch.value, "The value matches."); + is(item.expires, toMatch.expires, "The expiry time matches."); + is(item.path, toMatch.path, "The path matches."); + is(item.host, toMatch.host, "The host matches."); + is(item.isSecure, toMatch.isSecure, "The isSecure value matches."); + is(item.hostOnly, toMatch.hostOnly, "The hostOnly value matches."); + break; + } + } + ok(found, "cookie " + item.name + " should exist in response"); + } + + ok(!!TESTDATA[host], "Host is present in the list : " + host); + if (index == Object.keys(hosts).length - 1) { + return; + } + await testCookiesObjects(++index, hosts, dataByHost); +}; diff --git a/devtools/server/tests/browser/browser_storage_dynamic_windows.js b/devtools/server/tests/browser/browser_storage_dynamic_windows.js new file mode 100644 index 0000000000..0417cf0f09 --- /dev/null +++ b/devtools/server/tests/browser/browser_storage_dynamic_windows.js @@ -0,0 +1,410 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js", + this +); + +// beforeReload references an object representing the initialized state of the +// storage actor. +const beforeReload = { + cookies: { + "http://test1.example.org": ["c1", "cs2", "c3", "uc1"], + "http://sectest1.example.org": ["uc1", "cs2"], + }, + "indexed-db": { + "http://test1.example.org": [ + JSON.stringify(["idb1", "obj1"]), + JSON.stringify(["idb1", "obj2"]), + JSON.stringify(["idb2", "obj3"]), + ], + "http://sectest1.example.org": [], + }, + "local-storage": { + "http://test1.example.org": ["ls1", "ls2"], + "http://sectest1.example.org": ["iframe-u-ls1"], + }, + "session-storage": { + "http://test1.example.org": ["ss1"], + "http://sectest1.example.org": ["iframe-u-ss1", "iframe-u-ss2"], + }, +}; + +// afterIframeAdded references the items added when an iframe containing storage +// items is added to the page. +const afterIframeAdded = { + cookies: { + "https://sectest1.example.org": [ + getCookieId("cs2", ".example.org", "/"), + getCookieId( + "sc1", + "sectest1.example.org", + "/browser/devtools/server/tests/browser" + ), + ], + "http://sectest1.example.org": [ + getCookieId( + "sc1", + "sectest1.example.org", + "/browser/devtools/server/tests/browser" + ), + ], + }, + "indexed-db": { + // empty because indexed db creation happens after the page load, so at + // the time of window-ready, there was no indexed db present. + "https://sectest1.example.org": [], + }, + "local-storage": { + "https://sectest1.example.org": ["iframe-s-ls1"], + }, + "session-storage": { + "https://sectest1.example.org": ["iframe-s-ss1"], + }, +}; + +// afterIframeRemoved references the items deleted when an iframe containing +// storage items is removed from the page. +const afterIframeRemoved = { + cookies: { + "http://sectest1.example.org": [], + }, + "indexed-db": { + "http://sectest1.example.org": [], + }, + "local-storage": { + "http://sectest1.example.org": [], + }, + "session-storage": { + "http://sectest1.example.org": [], + }, +}; + +add_task(async function () { + const { commands } = await openTabAndSetupStorage( + MAIN_DOMAIN + "storage-dynamic-windows.html" + ); + + const { resourceCommand } = commands; + const { TYPES } = resourceCommand; + const allResources = {}; + const onAvailable = resources => { + for (const resource of resources) { + is( + resource.targetFront.targetType, + commands.targetCommand.TYPES.FRAME, + "Each storage resource has a valid 'targetFront' attribute" + ); + // Because we have iframes, we have distinct targets, each spawning their own storage resource + if (allResources[resource.resourceType]) { + allResources[resource.resourceType].push(resource); + } else { + allResources[resource.resourceType] = [resource]; + } + } + }; + const parentProcessStorages = [TYPES.COOKIE, TYPES.INDEXED_DB]; + const contentProcessStorages = [TYPES.LOCAL_STORAGE, TYPES.SESSION_STORAGE]; + const allStorages = [...parentProcessStorages, ...contentProcessStorages]; + await resourceCommand.watchResources(allStorages, { onAvailable }); + is( + Object.keys(allStorages).length, + allStorages.length, + "Got all the storage resources" + ); + + // Do a copy of all the initial storages as test function may spawn new resources for the same + // type and override the initial ones. + // We do not call unwatchResources as it would clear its cache and next call + // to watchResources with ignoreExistingResources would break and reprocess all resources again. + const initialResources = Object.assign({}, allResources); + + testWindowsBeforeReload(initialResources); + + await testAddIframe(commands, initialResources, { + contentProcessStorages, + parentProcessStorages, + allStorages, + }); + + await testRemoveIframe(commands, initialResources, { + contentProcessStorages, + parentProcessStorages, + allStorages, + }); + + await clearStorage(); + + // Forcing GC/CC to get rid of docshells and windows created by this test. + forceCollections(); + await commands.destroy(); + forceCollections(); +}); + +function testWindowsBeforeReload(resources) { + for (const storageType in beforeReload) { + ok(resources[storageType], `${storageType} storage actor is present`); + + const hosts = {}; + for (const resource of resources[storageType]) { + for (const [hostType, hostValues] of Object.entries(resource.hosts)) { + if (!hosts[hostType]) { + hosts[hostType] = []; + } + + hosts[hostType].push(hostValues); + } + } + + // If this test is run with chrome debugging enabled we get an extra + // key for "chrome". We don't want the test to fail in this case, so + // ignore it. + if (storageType == "indexedDB") { + delete hosts.chrome; + } + + is( + Object.keys(hosts).length, + Object.keys(beforeReload[storageType]).length, + `Number of hosts for ${storageType} match` + ); + for (const host in beforeReload[storageType]) { + ok(hosts[host], `Host ${host} is present`); + } + } +} + +/** + * Wait for new storage resources to be created of the given types. + */ +async function waitForNewResourcesAndUpdates(commands, resourceTypes) { + // When fission is off, we don't expect any new resource + if (resourceTypes.length === 0) { + return { newResources: [], updates: [] }; + } + const { resourceCommand } = commands; + let resolve; + const promise = new Promise(r => (resolve = r)); + const allResources = {}; + const allUpdates = {}; + const onAvailable = resources => { + for (const resource of resources) { + if (resource.resourceType in allResources) { + ok(false, `Got multiple ${resource.resourceTypes} resources`); + } + allResources[resource.resourceType] = resource; + ok(true, `Got resource for ${resource.resourceType}`); + + // Stop watching for resources when we got them all + if (Object.keys(allResources).length == resourceTypes.length) { + resourceCommand.unwatchResources(resourceTypes, { + onAvailable, + }); + } + + // But also listen for updates on each new resource + resource.once("single-store-update").then(update => { + ok(true, `Got updates for ${resource.resourceType}`); + allUpdates[resource.resourceType] = update; + + // Resolve only once we got all the updates, for all the resources + if (Object.keys(allUpdates).length == resourceTypes.length) { + resolve({ newResources: allResources, updates: allUpdates }); + } + }); + } + }; + await resourceCommand.watchResources(resourceTypes, { + onAvailable, + ignoreExistingResources: true, + }); + return promise; +} + +/** + * Wait for single-store-update events on all the given storage resources. + */ +function waitForResourceUpdates(resources, resourceTypes) { + const allUpdates = {}; + const promises = []; + for (const type of resourceTypes) { + // Resolves once any of the many resources for the given storage type updates + const promise = Promise.any( + resources[type].map(resource => resource.once("single-store-update")) + ); + promise.then(update => { + ok(true, `Got updates for ${type}`); + allUpdates[type] = update; + }); + promises.push(promise); + } + return Promise.all(promises).then(() => allUpdates); +} + +async function testAddIframe( + commands, + resources, + { contentProcessStorages, parentProcessStorages, allStorages } +) { + info("Testing if new iframe addition works properly"); + + // If Fission or EFT is enabled: + // * we get new resources alongside single-store-update events for content process storages + // * only single-store-update events for previous resources for parent process storages + // Otherwise if fission is disables: + // * we get single-store-update events for all previous resources + const onResources = waitForNewResourcesAndUpdates( + commands, + isFissionEnabled() || isEveryFrameTargetEnabled() + ? contentProcessStorages + : [] + ); + // If fission or EFT is enabled, we only get update for parent process storages. + // The content process storage resources are notified via brand new resource instances. + const storagesWithUpdates = + isFissionEnabled() || isEveryFrameTargetEnabled() + ? parentProcessStorages + : allStorages; + const onUpdates = waitForResourceUpdates(resources, storagesWithUpdates); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [ALT_DOMAIN_SECURED], + secured => { + const doc = content.document; + + const iframe = doc.createElement("iframe"); + iframe.src = secured + "storage-secured-iframe.html"; + + doc.querySelector("body").appendChild(iframe); + } + ); + + info("Wait for all resources"); + const { newResources, updates } = await onResources; + info("Wait for all updates"); + const previousResourceUpdates = await onUpdates; + + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + for (const resourceType of contentProcessStorages) { + const resource = newResources[resourceType]; + const expected = afterIframeAdded[resourceType]; + // The resource only comes with hosts, without any values. + // Each host will be an empty array. + Assert.deepEqual( + Object.keys(resource.hosts), + Object.keys(expected), + `List of hosts for resource ${resourceType} is correct` + ); + for (const host in resource.hosts) { + is( + resource.hosts[host].length, + 0, + "For new resources, each host has no value and is an empty array" + ); + } + const update = updates[resourceType]; + const storageKey = resourceTypeToStorageKey(resourceType); + Assert.deepEqual( + update.added[storageKey], + expected, + "We get an update after the resource, with the host values" + ); + } + } + + for (const resourceType of storagesWithUpdates) { + const expected = afterIframeAdded[resourceType]; + const update = previousResourceUpdates[resourceType]; + const storageKey = resourceTypeToStorageKey(resourceType); + Assert.deepEqual( + update.added[storageKey], + expected, + `We get an update after the resource ${resourceType}, with the host values` + ); + } + + return newResources; +} + +async function testRemoveIframe( + commands, + resources, + { contentProcessStorages, parentProcessStorages, allStorages } +) { + info("Testing if iframe removal works properly"); + + // If fission or EFT is enabled, we only get update for parent process storages. + // The content process storage resources are wiped via their related target destruction. + const onUpdates = waitForResourceUpdates( + resources, + isFissionEnabled() || isEveryFrameTargetEnabled() + ? parentProcessStorages + : allStorages + ); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + for (const iframe of content.document.querySelectorAll("iframe")) { + if (iframe.src.startsWith("http:")) { + iframe.remove(); + break; + } + } + }); + + info("Wait for all updates"); + const previousResourceUpdates = await onUpdates; + + const storagesWithUpdates = + isFissionEnabled() || isEveryFrameTargetEnabled() + ? parentProcessStorages + : allStorages; + for (const resourceType of storagesWithUpdates) { + const expected = afterIframeRemoved[resourceType]; + const update = previousResourceUpdates[resourceType]; + const storageKey = resourceTypeToStorageKey(resourceType); + Assert.deepEqual( + update.deleted[storageKey], + expected, + `We get an update after the resource ${resourceType}, with the host values` + ); + } + + // With Fission or EFT, the iframe target is destroyed, + // which ends up destroying the related resources + if (isFissionEnabled() || isEveryFrameTargetEnabled()) { + const destroyedResourceTypes = []; + for (const storageType in resources) { + for (const resource of resources[storageType]) { + if (resource.isDestroyed()) { + destroyedResourceTypes.push(resource.resourceType); + } + } + } + Assert.deepEqual( + destroyedResourceTypes.sort(), + contentProcessStorages.sort(), + "Content process storage resources have been destroyed [local and session storages]" + ); + } +} + +/** + * single-store-update emits objects using attributes with old "storage key" namings, + * which is different from resource type namings. + */ +function resourceTypeToStorageKey(resourceType) { + if (resourceType == "local-storage") { + return "localStorage"; + } + if (resourceType == "session-storage") { + return "sessionStorage"; + } + if (resourceType == "indexed-db") { + return "indexedDB"; + } + return resourceType; +} diff --git a/devtools/server/tests/browser/browser_storage_listings.js b/devtools/server/tests/browser/browser_storage_listings.js new file mode 100644 index 0000000000..40365ede85 --- /dev/null +++ b/devtools/server/tests/browser/browser_storage_listings.js @@ -0,0 +1,743 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js", + this +); + +const l10n = new Localization(["devtools/client/storage.ftl"], true); +const sessionString = l10n.formatValueSync("storage-expires-session"); + +const storeMap = { + cookies: { + "http://test1.example.org": [ + { + name: "c1", + value: "foobar", + expires: 2000000000000, + path: "/browser", + host: "test1.example.org", + hostOnly: true, + isSecure: false, + }, + { + name: "cs2", + value: "sessionCookie", + path: "/", + host: ".example.org", + expires: 0, + hostOnly: false, + isSecure: false, + }, + { + name: "c3", + value: "foobar-2", + expires: 2000000001000, + path: "/", + host: "test1.example.org", + hostOnly: true, + isSecure: true, + }, + ], + + "http://sectest1.example.org": [ + { + name: "cs2", + value: "sessionCookie", + path: "/", + host: ".example.org", + expires: 0, + hostOnly: false, + isSecure: false, + }, + { + name: "sc1", + value: "foobar", + path: "/browser/devtools/server/tests/browser", + host: "sectest1.example.org", + expires: 0, + hostOnly: true, + isSecure: false, + }, + ], + + "https://sectest1.example.org": [ + { + name: "uc1", + value: "foobar", + host: ".example.org", + path: "/", + expires: 0, + hostOnly: false, + isSecure: true, + }, + { + name: "cs2", + value: "sessionCookie", + path: "/", + host: ".example.org", + expires: 0, + hostOnly: false, + isSecure: false, + }, + { + name: "sc1", + value: "foobar", + path: "/browser/devtools/server/tests/browser", + host: "sectest1.example.org", + expires: 0, + hostOnly: true, + isSecure: false, + }, + ], + }, + "local-storage": { + "http://test1.example.org": [ + { + name: "ls1", + value: "foobar", + }, + { + name: "ls2", + value: "foobar-2", + }, + ], + "http://sectest1.example.org": [ + { + name: "iframe-u-ls1", + value: "foobar", + }, + ], + "https://sectest1.example.org": [ + { + name: "iframe-s-ls1", + value: "foobar", + }, + ], + }, + "session-storage": { + "http://test1.example.org": [ + { + name: "ss1", + value: "foobar-3", + }, + ], + "http://sectest1.example.org": [ + { + name: "iframe-u-ss1", + value: "foobar1", + }, + { + name: "iframe-u-ss2", + value: "foobar2", + }, + ], + "https://sectest1.example.org": [ + { + name: "iframe-s-ss1", + value: "foobar-2", + }, + ], + }, +}; + +const IDBValues = { + listStoresResponse: { + "http://test1.example.org": [ + ["idb1 (default)", "obj1"], + ["idb1 (default)", "obj2"], + ["idb2 (default)", "obj3"], + ], + "http://sectest1.example.org": [], + "https://sectest1.example.org": [ + ["idb-s1 (default)", "obj-s1"], + ["idb-s2 (default)", "obj-s2"], + ], + }, + dbDetails: { + "http://test1.example.org": [ + { + db: "idb1 (default)", + origin: "http://test1.example.org", + version: 1, + objectStores: 2, + }, + { + db: "idb2 (default)", + origin: "http://test1.example.org", + version: 1, + objectStores: 1, + }, + ], + "http://sectest1.example.org": [], + "https://sectest1.example.org": [ + { + db: "idb-s1 (default)", + origin: "https://sectest1.example.org", + version: 1, + objectStores: 1, + }, + { + db: "idb-s2 (default)", + origin: "https://sectest1.example.org", + version: 1, + objectStores: 1, + }, + ], + }, + objectStoreDetails: { + "http://test1.example.org": { + "idb1 (default)": [ + { + objectStore: "obj1", + keyPath: "id", + autoIncrement: false, + indexes: [ + { + name: "name", + keyPath: "name", + unique: false, + multiEntry: false, + }, + { + name: "email", + keyPath: "email", + unique: true, + multiEntry: false, + }, + ], + }, + { + objectStore: "obj2", + keyPath: "id2", + autoIncrement: false, + indexes: [], + }, + ], + "idb2 (default)": [ + { + objectStore: "obj3", + keyPath: "id3", + autoIncrement: false, + indexes: [ + { + name: "name2", + keyPath: "name2", + unique: true, + multiEntry: false, + }, + ], + }, + ], + }, + "http://sectest1.example.org": {}, + "https://sectest1.example.org": { + "idb-s1 (default)": [ + { + objectStore: "obj-s1", + keyPath: "id", + autoIncrement: false, + indexes: [], + }, + ], + "idb-s2 (default)": [ + { + objectStore: "obj-s2", + keyPath: "id3", + autoIncrement: true, + indexes: [ + { + name: "name2", + keyPath: "name2", + unique: true, + multiEntry: false, + }, + ], + }, + ], + }, + }, + entries: { + "http://test1.example.org": { + "idb1 (default)#obj1": [ + { + name: 1, + value: { + id: 1, + name: "foo", + email: "foo@bar.com", + }, + }, + { + name: 2, + value: { + id: 2, + name: "foo2", + email: "foo2@bar.com", + }, + }, + { + name: 3, + value: { + id: 3, + name: "foo2", + email: "foo3@bar.com", + }, + }, + ], + "idb1 (default)#obj2": [ + { + name: 1, + value: { + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz", + }, + }, + ], + "idb2 (default)#obj3": [], + }, + "http://sectest1.example.org": {}, + "https://sectest1.example.org": { + "idb-s1 (default)#obj-s1": [ + { + name: 6, + value: { + id: 6, + name: "foo", + email: "foo@bar.com", + }, + }, + { + name: 7, + value: { + id: 7, + name: "foo2", + email: "foo2@bar.com", + }, + }, + ], + "idb-s2 (default)#obj-s2": [ + { + name: 13, + value: { + id2: 13, + name2: "foo", + email: "foo@bar.com", + }, + }, + ], + }, + }, +}; + +async function testStores(commands) { + const { resourceCommand } = commands; + const { TYPES } = resourceCommand; + /** + * Data is a dictionary whose keys are storage types (their resourceType) + * while values are objects with following attributes: + * - hosts: dictionary of storage values (values are specific to each storage type) + * keyed by host names. + * - dataByHost: dictionary of storage objects keyed by host names. + * storages objects are returned by StorageActor.getStoreObjects. + * For IndexedDB it is different, instead it is still a dictionary + * keyed by host names, but each value is yet another sub dictionary with + * a special "main" attribute, with global store objects. + * Then, there will be one key per idb database, with their store objects + * as value. + */ + const data = {}; + await resourceCommand.watchResources( + [ + TYPES.COOKIE, + TYPES.LOCAL_STORAGE, + TYPES.SESSION_STORAGE, + TYPES.INDEXED_DB, + ], + { + async onAvailable(resources) { + for (const resource of resources) { + const { resourceType } = resource; + if (!data[resourceType]) { + data[resourceType] = { hosts: {}, dataByHost: {} }; + } + + for (const host in resource.hosts) { + if (!data[resourceType].hosts[host]) { + data[resourceType].hosts[host] = []; + } + // For indexed DB, we have some values, the database names. Other are empty arrays. + const hostValues = resource.hosts[host]; + data[resourceType].hosts[host].push(...hostValues); + + // For INDEXED_DB, it is slightly more complex, as we may have 3 store per host, + if (resourceType == TYPES.INDEXED_DB) { + if (!data[resourceType].dataByHost[host]) { + data[resourceType].dataByHost[host] = {}; + } + data[resourceType].dataByHost[host].main = + await resource.getStoreObjects(host, null, { + sessionString, + }); + for (const name of resource.hosts[host]) { + const objName = JSON.parse(name).slice(0, 1); + data[resourceType].dataByHost[host][objName] = + await resource.getStoreObjects( + host, + [JSON.stringify(objName)], + { sessionString } + ); + data[resourceType].dataByHost[host][name] = + await resource.getStoreObjects(host, [name], { + sessionString, + }); + } + } else { + data[resourceType].dataByHost[host] = + await resource.getStoreObjects(host, null, { sessionString }); + } + } + } + }, + } + ); + + await testCookies(data.cookies); + await testLocalStorage(data["local-storage"]); + await testSessionStorage(data["session-storage"]); + await testIndexedDB(data["indexed-db"]); +} + +function testCookies({ hosts, dataByHost }) { + is( + Object.keys(hosts).length, + 3, + "Correct number of host entries for cookies" + ); + return testCookiesObjects(0, hosts, dataByHost); +} + +async function testCookiesObjects(index, hosts, dataByHost) { + const host = Object.keys(hosts)[index]; + ok(!!storeMap.cookies[host], "Host is present in the list : " + host); + const data = dataByHost[host]; + let cookiesLength = 0; + for (const secureCookie of storeMap.cookies[host]) { + if (secureCookie.isSecure) { + ++cookiesLength; + } + } + // Any secure cookies did not get stored in the database. + is( + data.total, + storeMap.cookies[host].length - cookiesLength, + "Number of cookies in host " + host + " matches" + ); + for (const item of data.data) { + let found = false; + for (const toMatch of storeMap.cookies[host]) { + if (item.name == toMatch.name) { + found = true; + ok(true, "Found cookie " + item.name + " in response"); + is(item.value.str, toMatch.value, "The value matches."); + is(item.expires, toMatch.expires, "The expiry time matches."); + is(item.path, toMatch.path, "The path matches."); + is(item.host, toMatch.host, "The host matches."); + is(item.isSecure, toMatch.isSecure, "The isSecure value matches."); + is(item.hostOnly, toMatch.hostOnly, "The hostOnly value matches."); + break; + } + } + ok(found, "cookie " + item.name + " should exist in response"); + } + + if (index == Object.keys(hosts).length - 1) { + return; + } + await testCookiesObjects(++index, hosts, dataByHost); +} + +function testLocalStorage({ hosts, dataByHost }) { + is( + Object.keys(hosts).length, + 3, + "Correct number of host entries for local storage" + ); + return testLocalStorageObjects(0, hosts, dataByHost); +} + +var testLocalStorageObjects = async function (index, hosts, dataByHost) { + const host = Object.keys(hosts)[index]; + ok( + !!storeMap["local-storage"][host], + "Host is present in the list : " + host + ); + const data = dataByHost[host]; + is( + data.total, + storeMap["local-storage"][host].length, + "Number of local storage items in host " + host + " matches" + ); + for (const item of data.data) { + let found = false; + for (const toMatch of storeMap["local-storage"][host]) { + if (item.name == toMatch.name) { + found = true; + ok(true, "Found local storage item " + item.name + " in response"); + is(item.value.str, toMatch.value, "The value matches."); + break; + } + } + ok(found, "local storage item " + item.name + " should exist in response"); + } + + if (index == Object.keys(hosts).length - 1) { + return; + } + await testLocalStorageObjects(++index, hosts, dataByHost); +}; + +function testSessionStorage({ hosts, dataByHost }) { + is( + Object.keys(hosts).length, + 3, + "Correct number of host entries for session storage" + ); + return testSessionStorageObjects(0, hosts, dataByHost); +} + +async function testSessionStorageObjects(index, hosts, dataByHost) { + const host = Object.keys(hosts)[index]; + ok( + !!storeMap["session-storage"][host], + "Host is present in the list : " + host + ); + const data = dataByHost[host]; + is( + data.total, + storeMap["session-storage"][host].length, + "Number of session storage items in host " + host + " matches" + ); + for (const item of data.data) { + let found = false; + for (const toMatch of storeMap["session-storage"][host]) { + if (item.name == toMatch.name) { + found = true; + ok(true, "Found session storage item " + item.name + " in response"); + is(item.value.str, toMatch.value, "The value matches."); + break; + } + } + ok( + found, + "session storage item " + item.name + " should exist in response" + ); + } + + if (index == Object.keys(hosts).length - 1) { + return; + } + await testSessionStorageObjects(++index, hosts, dataByHost); +} + +async function testIndexedDB({ hosts, dataByHost }) { + is( + Object.keys(hosts).length, + 3, + "Correct number of host entries for indexed db" + ); + + for (const host in hosts) { + for (const item of hosts[host]) { + const parsedItem = JSON.parse(item); + let found = false; + for (const toMatch of IDBValues.listStoresResponse[host]) { + if (toMatch[0] == parsedItem[0] && toMatch[1] == parsedItem[1]) { + found = true; + break; + } + } + ok(found, item + " should exist in list stores response"); + } + } + + await testIndexedDBs(0, hosts, dataByHost); + await testObjectStores(0, hosts, dataByHost); + await testIDBEntries(0, hosts, dataByHost); +} + +async function testIndexedDBs(index, hosts, dataByHost) { + const host = Object.keys(hosts)[index]; + const data = dataByHost[host].main; + is( + data.total, + IDBValues.dbDetails[host].length, + "Number of indexed db in host " + host + " matches" + ); + for (const item of data.data) { + let found = false; + for (const toMatch of IDBValues.dbDetails[host]) { + if (item.uniqueKey == toMatch.db) { + found = true; + ok(true, "Found indexed db " + item.uniqueKey + " in response"); + is(item.origin, toMatch.origin, "The origin matches."); + is(item.version, toMatch.version, "The version matches."); + is( + item.objectStores, + toMatch.objectStores, + "The number of object stores matches." + ); + break; + } + } + ok(found, "indexed db " + item.uniqueKey + " should exist in response"); + } + + ok(!!IDBValues.dbDetails[host], "Host is present in the list : " + host); + if (index == Object.keys(hosts).length - 1) { + return; + } + await testIndexedDBs(++index, hosts, dataByHost); +} + +async function testObjectStores(ix, hosts, dataByHost) { + const host = Object.keys(hosts)[ix]; + const matchItems = (data, db) => { + is( + data.total, + IDBValues.objectStoreDetails[host][db].length, + "Number of object stores in host " + host + " matches" + ); + for (const item of data.data) { + let found = false; + for (const toMatch of IDBValues.objectStoreDetails[host][db]) { + if (item.objectStore == toMatch.objectStore) { + found = true; + ok(true, "Found object store " + item.objectStore + " in response"); + is(item.keyPath, toMatch.keyPath, "The keyPath matches."); + is( + item.autoIncrement, + toMatch.autoIncrement, + "The autoIncrement matches." + ); + // We might already have parsed the JSON value, in which case this will no longer be a string + item.indexes = + typeof item.indexes == "string" + ? JSON.parse(item.indexes) + : item.indexes; + is( + item.indexes.length, + toMatch.indexes.length, + "Number of indexes match" + ); + for (const index of item.indexes) { + let indexFound = false; + for (const toMatchIndex of toMatch.indexes) { + if (toMatchIndex.name == index.name) { + indexFound = true; + ok(true, "Found index " + index.name); + is( + index.keyPath, + toMatchIndex.keyPath, + "The keyPath of index matches." + ); + is(index.unique, toMatchIndex.unique, "The unique matches"); + is( + index.multiEntry, + toMatchIndex.multiEntry, + "The multiEntry matches" + ); + break; + } + } + ok(indexFound, "Index " + index + " should exist in response"); + } + break; + } + } + ok(found, "indexed db " + item.name + " should exist in response"); + } + }; + + ok( + !!IDBValues.objectStoreDetails[host], + "Host is present in the list : " + host + ); + for (const name of hosts[host]) { + const objName = JSON.parse(name).slice(0, 1); + matchItems(dataByHost[host][objName], objName[0]); + } + if (ix == Object.keys(hosts).length - 1) { + return; + } + await testObjectStores(++ix, hosts, dataByHost); +} + +async function testIDBEntries(index, hosts, dataByHost) { + const host = Object.keys(hosts)[index]; + const matchItems = (data, obj) => { + is( + data.total, + IDBValues.entries[host][obj].length, + "Number of items in object store " + obj + " matches" + ); + for (const item of data.data) { + let found = false; + for (const toMatch of IDBValues.entries[host][obj]) { + if (item.name == toMatch.name) { + found = true; + ok(true, "Found indexed db item " + item.name + " in response"); + const value = JSON.parse(item.value.str); + is( + Object.keys(value).length, + Object.keys(toMatch.value).length, + "Number of entries in the value matches" + ); + for (const key in value) { + is( + value[key], + toMatch.value[key], + "value of " + key + " value key matches" + ); + } + break; + } + } + ok(found, "indexed db item " + item.name + " should exist in response"); + } + }; + + ok(!!IDBValues.entries[host], "Host is present in the list : " + host); + for (const name of hosts[host]) { + const parsed = JSON.parse(name); + matchItems(dataByHost[host][name], parsed[0] + "#" + parsed[1]); + } + if (index == Object.keys(hosts).length - 1) { + return; + } + await testObjectStores(++index, hosts, dataByHost); +} + +add_task(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.documentCookies.maxage", 0]], + }); + + const { commands } = await openTabAndSetupStorage( + MAIN_DOMAIN + "storage-listings.html" + ); + + await testStores(commands); + + await clearStorage(); + + // Forcing GC/CC to get rid of docshells and windows created by this test. + forceCollections(); + await commands.destroy(); + forceCollections(); +}); diff --git a/devtools/server/tests/browser/browser_storage_updates.js b/devtools/server/tests/browser/browser_storage_updates.js new file mode 100644 index 0000000000..50926538a5 --- /dev/null +++ b/devtools/server/tests/browser/browser_storage_updates.js @@ -0,0 +1,343 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// Ensure that storage updates are detected and that the correct information is +// contained inside the storage actors. + +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/server/tests/browser/storage-helpers.js", + this +); + +const l10n = new Localization(["devtools/client/storage.ftl"], true); +const sessionString = l10n.formatValueSync("storage-expires-session"); + +const TESTS = [ + // index 0 + { + async action(win) { + await addCookie("c1", "foobar1"); + await addCookie("c2", "foobar2"); + await localStorageSetItem("l1", "foobar1"); + }, + snapshot: { + cookies: [ + { + name: "c1", + value: "foobar1", + }, + { + name: "c2", + value: "foobar2", + }, + ], + "local-storage": [ + { + name: "l1", + value: "foobar1", + }, + ], + }, + }, + + // index 1 + { + async action() { + await addCookie("c1", "new_foobar1"); + await localStorageSetItem("l2", "foobar2"); + }, + snapshot: { + cookies: [ + { + name: "c1", + value: "new_foobar1", + }, + { + name: "c2", + value: "foobar2", + }, + ], + "local-storage": [ + { + name: "l1", + value: "foobar1", + }, + { + name: "l2", + value: "foobar2", + }, + ], + }, + }, + + // index 2 + { + async action() { + await removeCookie("c2"); + await localStorageRemoveItem("l1"); + await localStorageSetItem("l3", "foobar3"); + }, + snapshot: { + cookies: [ + { + name: "c1", + value: "new_foobar1", + }, + ], + "local-storage": [ + { + name: "l2", + value: "foobar2", + }, + { + name: "l3", + value: "foobar3", + }, + ], + }, + }, + + // index 3 + { + async action() { + await removeCookie("c1"); + await addCookie("c3", "foobar3"); + await localStorageRemoveItem("l2"); + await sessionStorageSetItem("s1", "foobar1"); + await sessionStorageSetItem("s2", "foobar2"); + await localStorageSetItem("l3", "new_foobar3"); + }, + snapshot: { + cookies: [ + { + name: "c3", + value: "foobar3", + }, + ], + "local-storage": [ + { + name: "l3", + value: "new_foobar3", + }, + ], + "session-storage": [ + { + name: "s1", + value: "foobar1", + }, + { + name: "s2", + value: "foobar2", + }, + ], + }, + }, + + // index 4 + { + async action() { + await sessionStorageRemoveItem("s1"); + }, + snapshot: { + cookies: [ + { + name: "c3", + value: "foobar3", + }, + ], + "local-storage": [ + { + name: "l3", + value: "new_foobar3", + }, + ], + "session-storage": [ + { + name: "s2", + value: "foobar2", + }, + ], + }, + }, + + // index 5 + { + async action() { + await clearCookies(); + }, + snapshot: { + cookies: [], + "local-storage": [ + { + name: "l3", + value: "new_foobar3", + }, + ], + "session-storage": [ + { + name: "s2", + value: "foobar2", + }, + ], + }, + }, + + // index 6 + { + async action() { + await clearLocalAndSessionStores(); + }, + snapshot: { + cookies: [], + "local-storage": [], + "session-storage": [], + }, + }, +]; + +add_task(async function () { + const { commands } = await openTabAndSetupStorage( + MAIN_DOMAIN + "storage-updates.html" + ); + + for (let i = 0; i < TESTS.length; i++) { + const test = TESTS[i]; + await runTest(test, commands, i); + } + + await commands.destroy(); +}); + +async function runTest({ action, snapshot }, commands, index) { + info("Running test at index " + index); + await action(); + await checkStores(commands, snapshot); +} + +async function checkStores(commands, snapshot) { + const { resourceCommand } = commands; + const { TYPES } = resourceCommand; + const actual = {}; + await resourceCommand.watchResources( + [TYPES.COOKIE, TYPES.LOCAL_STORAGE, TYPES.SESSION_STORAGE], + { + async onAvailable(resources) { + for (const resource of resources) { + actual[resource.resourceType] = await resource.getStoreObjects( + TEST_DOMAIN, + null, + { + sessionString, + } + ); + } + }, + } + ); + + for (const [type, entries] of Object.entries(snapshot)) { + const store = actual[type].data; + + is( + store.length, + entries.length, + `The number of entries in ${type} is correct` + ); + + for (const entry of entries) { + checkStoreValue(entry.name, entry.value, store); + } + } +} + +function checkStoreValue(name, value, store) { + for (const entry of store) { + if (entry.name === name) { + ok(true, `There is an entry for "${name}"`); + + // entry.value is a longStringActor so we need to read it's value using + // entry.value.str. + is(entry.value.str, value, `Value for ${name} is correct`); + return; + } + } + ok(false, `There is an entry for "${name}"`); +} + +async function addCookie(name, value) { + info(`addCookie("${name}", "${value}")`); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[name, value]], + ([iName, iValue]) => { + content.wrappedJSObject.window.addCookie(iName, iValue); + } + ); +} + +async function removeCookie(name) { + info(`removeCookie("${name}")`); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [name], iName => { + content.wrappedJSObject.window.removeCookie(iName); + }); +} + +async function localStorageSetItem(name, value) { + info(`localStorageSetItem("${name}", "${value}")`); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[name, value]], + ([iName, iValue]) => { + content.window.localStorage.setItem(iName, iValue); + } + ); +} + +async function localStorageRemoveItem(name) { + info(`localStorageRemoveItem("${name}")`); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [name], iName => { + content.window.localStorage.removeItem(iName); + }); +} + +async function sessionStorageSetItem(name, value) { + info(`sessionStorageSetItem("${name}", "${value}")`); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[name, value]], + ([iName, iValue]) => { + content.window.sessionStorage.setItem(iName, iValue); + } + ); +} + +async function sessionStorageRemoveItem(name) { + info(`sessionStorageRemoveItem("${name}")`); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [name], iName => { + content.window.sessionStorage.removeItem(iName); + }); +} + +async function clearCookies() { + info(`clearCookies()`); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.wrappedJSObject.window.clearCookies(); + }); +} + +async function clearLocalAndSessionStores() { + info(`clearLocalAndSessionStores()`); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { + content.wrappedJSObject.window.clearLocalAndSessionStores(); + }); +} diff --git a/devtools/server/tests/browser/browser_style_utils_getFontPreviewData.js b/devtools/server/tests/browser/browser_style_utils_getFontPreviewData.js new file mode 100644 index 0000000000..f6f09225ce --- /dev/null +++ b/devtools/server/tests/browser/browser_style_utils_getFontPreviewData.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that getFontPreviewData of the style utils generates font previews. + +const TEST_URI = "data:text/html,<title>Test getFontPreviewData</title>"; + +add_task(async function () { + await addTab(TEST_URI); + + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + getFontPreviewData, + } = require("resource://devtools/server/actors/utils/style-utils.js"); + + const font = Services.appinfo.OS === "WINNT" ? "Arial" : "Liberation Sans"; + let fontPreviewData = getFontPreviewData(font, content.document); + ok( + fontPreviewData?.dataURL, + "Returned a font preview with a valid dataURL" + ); + + // Create <img> element and load the generated preview into it + // to check whether the image is valid and get its dimensions + const image = content.document.createElement("img"); + let imageLoaded = new Promise(loaded => + image.addEventListener("load", loaded, { once: true }) + ); + image.src = fontPreviewData.dataURL; + await imageLoaded; + + const { naturalWidth: widthImage1, naturalHeight: heightImage1 } = image; + + ok(widthImage1 > 0, "Preview width is greater than 0"); + ok(heightImage1 > 0, "Preview height is greater than 0"); + + // Create a preview with different text and compare + // its dimensions with the first one + fontPreviewData = getFontPreviewData(font, content.document, { + previewText: "Abcdef", + }); + + ok( + fontPreviewData?.dataURL, + "Returned a font preview with a valid dataURL" + ); + + imageLoaded = new Promise(loaded => + image.addEventListener("load", loaded, { once: true }) + ); + image.src = fontPreviewData.dataURL; + await imageLoaded; + + const { naturalWidth: widthImage2, naturalHeight: heightImage2 } = image; + + // Check whether the width is greater than with the default parameters + // and that the height is the same + ok( + widthImage2 > widthImage1, + "Preview width is greater than with default parameters" + ); + ok( + heightImage2 === heightImage1, + "Preview height is the same as with default parameters" + ); + + // Create a preview with smaller font size and compare + // its dimensions with the first one + fontPreviewData = getFontPreviewData(font, content.document, { + previewFontSize: 20, + }); + + ok( + fontPreviewData?.dataURL, + "Returned a font preview with a valid dataURL" + ); + + imageLoaded = new Promise(loaded => + image.addEventListener("load", loaded, { once: true }) + ); + image.src = fontPreviewData.dataURL; + await imageLoaded; + + const { naturalWidth: widthImage3, naturalHeight: heightImage3 } = image; + + // Check whether the width and height are smaller than with the default parameters + ok( + widthImage3 < widthImage1, + "Preview width is smaller than with default parameters" + ); + ok( + heightImage3 < heightImage1, + "Preview height is smaller than with default parameters" + ); + + // Create a preview with multiple lines and compare + // its dimensions with the first one + fontPreviewData = getFontPreviewData(font, content.document, { + previewText: "Abc\ndef", + }); + + ok( + fontPreviewData?.dataURL, + "Returned a font preview with a valid dataURL" + ); + + imageLoaded = new Promise(loaded => + image.addEventListener("load", loaded, { once: true }) + ); + image.src = fontPreviewData.dataURL; + await imageLoaded; + + const { naturalWidth: widthImage4, naturalHeight: heightImage4 } = image; + + // Check whether the width is the same as with the default parameters + // and that the height is greater + ok( + widthImage4 === widthImage1, + "Preview width is the same as with default parameters" + ); + ok( + heightImage4 > heightImage1, + "Preview height is greater than with default parameters" + ); + }); +}); diff --git a/devtools/server/tests/browser/browser_styles_getRuleText.js b/devtools/server/tests/browser/browser_styles_getRuleText.js new file mode 100644 index 0000000000..e775bcbb28 --- /dev/null +++ b/devtools/server/tests/browser/browser_styles_getRuleText.js @@ -0,0 +1,34 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that StyleRuleActor.getRuleText returns the contents of the CSS rule. + +const CSS_RULE = `#test { + background-color: #f06; +}`; + +const CONTENT = ` + <style type='text/css'> + ${CSS_RULE} + </style> + <div id="test"></div> +`; + +const TEST_URI = `data:text/html;charset=utf-8,${encodeURIComponent(CONTENT)}`; + +add_task(async function () { + const { inspector, target, walker } = await initInspectorFront(TEST_URI); + + const pageStyle = await inspector.getPageStyle(); + const element = await walker.querySelector(walker.rootNode, "#test"); + const entries = await pageStyle.getApplied(element, { inherited: false }); + + const rule = entries[1].rule; + const text = await rule.getRuleText(); + + is(text, CSS_RULE, "CSS rule text content matches"); + + await target.destroy(); +}); diff --git a/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js b/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js new file mode 100644 index 0000000000..a8c069e950 --- /dev/null +++ b/devtools/server/tests/browser/browser_stylesheets_getTextEmpty.js @@ -0,0 +1,53 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Test that StyleSheetsActor.getText handles empty text correctly. + +const CSS_CONTENT = "body { background-color: #f06; }"; +const TEST_URI = `data:text/html;charset=utf-8,<style>${encodeURIComponent( + CSS_CONTENT +)}</style>`; + +add_task(async function () { + const tab = await addTab(TEST_URI); + + const commands = await CommandsFactory.forTab(tab); + await commands.targetCommand.startListening(); + const target = commands.targetCommand.targetFront; + + const styleSheetsFront = await target.getFront("stylesheets"); + ok(styleSheetsFront, "The StyleSheetsFront was created."); + + const sheets = []; + await commands.resourceCommand.watchResources( + [commands.resourceCommand.TYPES.STYLESHEET], + { + onAvailable: resources => sheets.push(...resources), + } + ); + is(sheets.length, 1, "watchResources returned the correct number of sheets"); + + const { resourceId } = sheets[0]; + + is( + await getStyleSheetText(styleSheetsFront, resourceId), + CSS_CONTENT, + "The stylesheet has expected initial text" + ); + info("Update stylesheet content via the styleSheetsFront"); + await styleSheetsFront.update(resourceId, "", false); + is( + await getStyleSheetText(styleSheetsFront, resourceId), + "", + "Stylesheet is now empty, as expected" + ); + + await commands.destroy(); +}); + +async function getStyleSheetText(styleSheetsFront, resourceId) { + const longStringFront = await styleSheetsFront.getText(resourceId); + return longStringFront.string(); +} diff --git a/devtools/server/tests/browser/director-script-target.html b/devtools/server/tests/browser/director-script-target.html new file mode 100644 index 0000000000..c436a5446c --- /dev/null +++ b/devtools/server/tests/browser/director-script-target.html @@ -0,0 +1,18 @@ +<html> + <head> + <script> + /* exported globalAccessibleVar */ + "use strict"; + // change the eval function to ensure the window object + // in the debug-script is correctly wrapped + // eslint-disable-next-line no-eval + window.eval = function() { + return "unsecure-eval-called"; + }; + var globalAccessibleVar = "global-value"; + </script> + </head> + <body> + <h1>debug script target</h1> + </body> +</html> diff --git a/devtools/server/tests/browser/doc_accessibility.html b/devtools/server/tests/browser/doc_accessibility.html new file mode 100644 index 0000000000..1beb4c9838 --- /dev/null +++ b/devtools/server/tests/browser/doc_accessibility.html @@ -0,0 +1,18 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf-8"> + </head> +<body> + <h1 id="h1">Accessibility Test</h1> + <button id="button" aria-describedby="h1" accesskey="b">Accessible Button</button> + <div id="slider" role="slider" aria-valuenow="5" + aria-valuemin="0" aria-valuemax="7">slider</div> + <label id="label" for="control">Label + <input id="control"> + </label> + <header id="header">header</header> + <nav id="nav">nav</nav> + <footer id="footer">footer</footer> +</body> +</html> diff --git a/devtools/server/tests/browser/doc_accessibility_audit.html b/devtools/server/tests/browser/doc_accessibility_audit.html new file mode 100644 index 0000000000..0667e0569e --- /dev/null +++ b/devtools/server/tests/browser/doc_accessibility_audit.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf-8"> + </head> +<body style="color: red;"> + <p id="p1">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p> + <p id="p2">Accessible Paragraph</p> +</body> +</html> diff --git a/devtools/server/tests/browser/doc_accessibility_infobar.html b/devtools/server/tests/browser/doc_accessibility_infobar.html new file mode 100644 index 0000000000..8f3c66911c --- /dev/null +++ b/devtools/server/tests/browser/doc_accessibility_infobar.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf-8"> + </head> +<body> + <h1 id="h1">Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</h1> + <button id="button">Accessible Button</button> + <p id="p" style="font-size: 0;">This is a paragraph that has no bounds.</p> + <label>Enter text: <input id="input" type="text"></text></label> +</body> +</html> diff --git a/devtools/server/tests/browser/doc_accessibility_keyboard_audit.html b/devtools/server/tests/browser/doc_accessibility_keyboard_audit.html new file mode 100644 index 0000000000..00c002efe9 --- /dev/null +++ b/devtools/server/tests/browser/doc_accessibility_keyboard_audit.html @@ -0,0 +1,150 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf-8"> + <style> + #focusable-1 { + outline: none; + } + + #focusable-2:focus { + outline: none; + border: 1px solid black; + } + </style> + </head> +<body> + <div id="button-1" class="Button" tabindex="0">I should really be a button</div> + <div id="button-2" class="Button">I should really be a button</div> + <div id="input-container"><input id="input-1" type="text" tabindex="-1" /></div> + <input id="input-2" type="text" tabindex="-1" disabled /> + <input id="input-3" type="text" disabled /> + <input id="input-4" type="text" /> + <a id="link-1">Though a link, I'm not interactive.</a> + <a id="link-2" href="example.com">I'm a proper link.</a> + <a id="link-3" href="#">Link 3</a> + <a id="link-4" href="">Link 4</a> + <a id="link-5" href="https://example.com">Website</a> + <button id="button-3">Button with no tabindex</button> + <button id="button-4" tabindex="-1">Button with -1 tabindex</button> + <button id="button-5" tabindex="0">Button with 0 tabindex</button> + <button id="button-6" tabindex="1">Button with 1 tabindex</button> + <div id="focusable-1" role="button" tabindex="0">Focusable with no focus style.</div> + <div id="focusable-2" role="button" tabindex="0">Focusable with focus style.</div> + <div id="focusable-3" role="button" tabindex="0">Focusable with focus style.</div> + <div id="mouse-only-1" onclick="console.log('foo');">Button for mouse only</div> + <div id="focusable-4" onclick="console.log('foo');" tabindex="0">Button no semantics</div> + <div id="button-7" onclick="console.log('foo');" role="button">Semantic button not focusable</div> + <div id="button-8" onclick="console.log('foo');" role="button" tabindex="0">Button</div> + <img id="img-1" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + <img id="img-2" longdesc="https://example.com" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + <img id="img-3" longdesc="https://example.com" onclick="console.log('foo');" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + <img id="img-4" onclick="console.log('foo');" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + <button id="buttonmenu-1" aria-haspopup="true">I have a popup</button> + <div role="button" id="buttonmenu-2" aria-haspopup="true">I have a popup</div> + <input id="checkbox-1" type="checkbox" name="hello" /> + <select id="listbox-1" size="2"> + <option id="lb_orange">orange</option> + <option id="lb_apple">apple</option> + </select> + <select id="combobox-1"></select> + <select id="combobox-2"><option>One</option></select> + <select id="combobox-3"> + <option id="cb_orange">orange</option> + <option id="cb_apple">apple</option> + </select> + <div id="editcombobox-1" role="combobox"><span role="option">One</span></div> + <span id="editcombobox-2"role="combobox"></span> + <span id="editcombobox-3"role="combobox" tabindex="0"></span> + <span id="switch-1" role="switch"></span> + <span id="switch-2" role="switch" tabindex="0"></span> + <div aria-label="Tag" role="combobox" aria-expanded="true" aria-owns="owned_listbox" aria-haspopup="listbox"> + <input type="text" aria-autocomplete="list" aria-controls="owned_listbox" aria-activedescendant="selected_option"> + </div> + <ul role="listbox" id="owned_listbox"> + <li role="option">Zebra</li> + <li role="option" id="selected_option">Zoom</li> + </ul> + <label id="label-1">hello<input type="checkbox" name="world" /></label> + <label id="label-2" for="checkbox-1">hello</label> + <label id="label-3">hello</label> + <label id="label-4">hello</label><input type="checkbox" name="world" /> + <a href="about:mozilla" target="_blank" rel="opener"> + <img id="img-5" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + </a> + <a onmousedown=""> + <img id="img-6" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + </a> + <a onclick=""> + <img id="img-7" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + </a> + <a onmouseup=""> + <img id="img-8" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + </a> + <section id="section-1" class="collapsible-section top-sites animation-enabled" aria-expanded="true"></section> + <main id="main" tabindex="-1">Main content</main> + <div id="not-keyboard-focusable-1" tabindex="-1">Not keyboard fqocusable with no focus style.</div> + <div id="grid-1" role="grid" aria-label="Interactive grid"></div> + <div id="grid-2" tabindex="0" role="grid" aria-label="Interactive grid"></div> + <div id="table-1" role="table" aria-label="Non-interactive ARIA table"></div> + <div id="table-2" tabindex="0" role="table" aria-label="Non-interactive ARIA table"></div> + <table id="table-3" aria-label="Non-interactive table"></table> + <table id="table-4" tabindex="0" aria-label="Non-interactive table"></table> + <div id="article-1" role="article"></div> + <div id="article-2" role="article" tabindex="0"></div> + <div role="grid" aria-label="Interactive grid"> + <div id="columnheader-1" role="columnheader"></div> + <div id="rowheader-1" role="rowheader"></div> + <div id="gridcell-1" role="gridcell"></div> + <div id="gridcell-2" role="gridcell" tabindex="0"></div> + </div> + <div role="table" aria-label="Non-interactive table"> + <div id="columnheader-2" role="columnheader"></div> + <div id="rowheader-2" role="rowheader"></div> + </div> + <table> + <tr> + <th id="columnheader-3">Animals</th> + </tr> + <tr> + <th id="columnheader-4" tabindex="0">Hippopotamus</th> + </tr> + <tr> + <th id="rowheader-3">Horse</th> + <td>Mare</td> + </tr> + <tr> + <th id="rowheader-4" tabindex="0">Chicken</th> + <td>Hen</td> + </tr> + </table> + <table role="grid"> + <tr> + <th id="columnheader-5">Animals</th> + </tr> + <tr> + <th id="columnheader-6" tabindex="0">Hippopotamus</th> + </tr> + <tr> + <th id="rowheader-5">Horse</th> + <td id="gridcell-3">Mare</td> + </tr> + <tr> + <th id="rowheader-6" tabindex="0">Chicken</th> + <td id="gridcell-4" tabindex="0">Hen</td> + </tr> + </table> + <div id="tablist-1" role="tablist"></div> + <div id="tablist-2" role="tablist" tabindex="0"></div> + <div id="scrollbar-1" role="scrollbar"></div> + <div id="scrollbar-2" role="scrollbar" tabindex="0"></div> + <div id="separator-1" role="separator"></div> + <div id="separator-2" role="separator" tabindex="0"></div> + <div id="toolbar-1" role="toolbar"></div> + <div id="toolbar-2" role="toolbar" tabindex="0"></div> + <div id="menu-1" role="menu"></div> + <div id="menu-2" role="menu" tabindex="0"></div> + <div id="menubar-1" role="menubar"></div> + <div id="menubar-2" role="menubar" tabindex="0"></div> +</body> +</html> diff --git a/devtools/server/tests/browser/doc_accessibility_text_label_audit.html b/devtools/server/tests/browser/doc_accessibility_text_label_audit.html new file mode 100644 index 0000000000..982cc5c243 --- /dev/null +++ b/devtools/server/tests/browser/doc_accessibility_text_label_audit.html @@ -0,0 +1,463 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf-8"> + </head> +<body> + <button id="buttonmenu-1" aria-haspopup="true">I have a popup</button> + <label>I have a popup<button id="buttonmenu-2" aria-haspopup="true"></button></label> + <button id="buttonmenu-3" aria-haspopup="true"></button> + <button id="buttonmenu-4" aria-haspopup="true" aria-label="I have a popup"></button> + <label for="buttonmenu-5">I have a popup </label><button id="buttonmenu-5" aria-haspopup="true"></button> + <label id="buttonmenu-6-label">I have a popup </label><button id="buttonmenu-6" aria-haspopup="true" aria-labelledby="buttonmenu-6-label"></button> + <p id="p1">I am a paragraph</p> + <p id="p2"></p> + <canvas id="canvas-1"></canvas> + <canvas id="canvas-2" aria-label="Canvas label"></canvas> + <canvas id="canvas-3" aria-labelledby="canvas-3-heading"> + <h2 id="canvas-3-heading">Shapes</h2> + </canvas> + <canvas id="canvas-4"> + <h2>Shapes</h2> + </canvas> + <input id="checkbox-1" type="checkbox" name="world" /> + <label>hello</label><input id="checkbox-2" type="checkbox" name="world" /> + <label>hello<input id="checkbox-3" type="checkbox" name="world" /></label> + <label for="checkbox-4">hello</label><input id="checkbox-4" type="checkbox" name="world" /> + <input id="checkbox-5" type="checkbox" name="world" aria-label="hello" /> + <label id="checkbox-6-label">hello</label><input id="checkbox-6" type="checkbox" name="world" aria-labelledby="checkbox-6-label" /> + <div id="checkbox-7" role="checkbox"></div> + <div id="checkbox-8" aria-label="hello" role="checkbox"></div> + <div id="checkbox-9-label">hello</div><div id="checkbox-9" aria-labelledby="checkbox-9-label" role="checkbox"></div> + <div role="menu"> + <div id="menuitemcheckbox-1" role="menuitemcheckbox">hello</div> + <div id="menuitemcheckbox-2" role="menuitemcheckbox"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" /></div> + <div id="menuitemcheckbox-3" role="menuitemcheckbox"></div> + <div id="menuitemcheckbox-4" role="menuitemcheckbox"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="" /></div> + <div id="menuitemcheckbox-5" role="menuitemcheckbox"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="hello" /></div> + <div id="menuitemcheckbox-6" role="menuitemcheckbox"> </div> + </div> + <p id="columnheader-7-label">Budget</p> + <p id="rowheader-7-label">Toy Story 3</p> + <table> + <thead> + <tr> + <th id="columnheader-1" scope="col">Film Title</th> + <th id="columnheader-2" scope="col"></th> + <th id="columnheader-3" scope="col"> </th> + <th id="columnheader-4" scope="col" aria-label="Worldwide Gross"></th> + <th id="columnheader-5" scope="col" aria-label=""></th> + <th id="columnheader-6" scope="col" aria-label=" "></th> + <th id="columnheader-7" scope="col" aria-labelledby="columnheader-7-label"></th> + </tr> + </thead> + <tbody> + <tr><th id="rowheader-1" scope="row">Toy Story 3</th></tr> + <tr><th id="rowheader-2" scope="row"></th></tr> + <tr><th id="rowheader-3" scope="row"> </th></tr> + <tr><th id="rowheader-4" scope="row" aria-label="Alladin"></th></tr> + <tr><th id="rowheader-5" scope="row" aria-label=""></th></tr> + <tr><th id="rowheader-6" scope="row" aria-label=" "></th></tr> + <tr><th id="rowheader-7" scope="row" aria-labelledby="columnheader-7-label"></th></tr> + </tbody> + </table> + <div role="columnheader" id="columnheader-8">Film Title</div> + <div role="columnheader" id="columnheader-9"></div> + <div role="columnheader" id="columnheader-10"> </div> + <div role="columnheader" id="columnheader-11" aria-label="Worldwide Gross"></div> + <div role="columnheader" id="columnheader-12" aria-label=""></div> + <div role="columnheader" id="columnheader-13" aria-label=" "></div> + <div role="columnheader" id="columnheader-14" aria-labelledby="columnheader-7-label"></div> + <label for="combobox-1">Choose a pet:</label> + <select id="combobox-1"> + <option id="combobox-option-1" value="">--Please choose an option--</option> + <option id="combobox-option-2" value="dog"></option> + <option id="combobox-option-3" value="cat"> </option> + <option id="combobox-option-4" value="" label="--Please choose an option--"></option> + <option id="combobox-option-5" value="dog" label=""></option> + <option id="combobox-option-6" value="cat" label=" "></option> + </select> + <select id="combobox-2"></select> + <label>Choose a pet:</label><select id="combobox-3"></select> + <label>Choose a pet:<select id="combobox-4"></select></label> + <select id="combobox-5" aria-label="Choose a pet:"></select> + <label id="combobox-6-label">Choose a pet:</label><select id="combobox-6" aria-labelledby="combobox-6-label"></select> + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-1" + xmlns:xlink="http://www.w3.org/1999/xlink"></svg> + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-2" aria-label="" + xmlns:xlink="http://www.w3.org/1999/xlink"></svg> + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-3" aria-label="empty drawing" + xmlns:xlink="http://www.w3.org/1999/xlink"></svg> + <div id="diagram-4-label">Empty drawing</div> + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-4" aria-labelledby="diagram-4-label" + xmlns:xlink="http://www.w3.org/1999/xlink"></svg> + <div id="diagram-5-label"></div> + <svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="diagram-5" aria-labelledby="diagram-5-label" + xmlns:xlink="http://www.w3.org/1999/xlink"></svg> + <dialog id="dialog-1" open> + <p>Greetings, one and all!</p> + </dialog> + <dialog id="dialog-2" aria-label="" open> + <p>Greetings, one and all!</p> + </dialog> + <dialog id="dialog-3" aria-label="Greetings" open> + <p>Greetings, one and all!</p> + </dialog> + <dialog id="dialog-4" aria-labelledby="dialog-4-label" open> + <p id="dialog-4-label">Greetings, one and all!</p> + </dialog> + <div role="dialog" id="dialog-5"> + <p>Greetings, one and all!</p> + </div> + <div role="dialog" id="dialog-6" aria-label=""> + <p>Greetings, one and all!</p> + </div> + <div role="dialog" id="dialog-7" aria-label="Greetings"> + <p>Greetings, one and all!</p> + </div> + <div role="dialog" id="dialog-8" aria-labelledby="dialog-8-label"> + <p id="dialog-8-label">Greetings, one and all!</p> + </div> + <dialog id="dialog-9" aria-labelledby="dialog-9-label" open> + <p id="dialog-9-label"></p> + </dialog> + <div role="dialog" id="dialog-10" aria-labelledby="dialog-10-label"> + <p id="dialog-10-label"></p> + </div> + <div id="editcombobox-1" role="combobox"></div> + <div id="editcombobox-2" aria-label="Choose a pet:" role="combobox"></div> + <div id="editcombobox-3-label">Choose a pet:</div><div id="editcombobox-3" aria-labelledby="editcombobox-3-label" role="combobox"></div> + <label>Customer name: <input id="entry-1"></label> + <input id="entry-2"> + <input id="entry-3" aria-label="Customer name:"> + <label>Customer name: </label><input id="entry-4"> + <label for="entry-5">Customer name: </label><input id="entry-5"> + <label id="entry-6-label">Customer name: </label><input id="entry-6" aria-labelledby="entry-6-label"> + <div id="entry-7" role="textbox"></div> + <div id="entry-8" aria-label="Customer name:" role="textbox"></div> + <div id="entry-9-label">Customer name: </div><div id="entry-9" aria-labelledby="entry-9-label" role="textbox"></div> + <figure id="figure-1"> + <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + <figcaption>Figure 1: The four layers of awesome.</figcaption> + </figure> + <figure id="figure-2"> + <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + </figure> + <div id="figure-3" role="figure" aria-labelledby="caption-figure-3"> + <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + <p id="caption-figure-3">Figure 1: The caption</p> + </div> + <div id="figure-4" role="figure" aria-labelledby="caption-figure-4"> + <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + <p id="caption-figure-4"></p> + </div> + <div id="figure-5" role="figure"> + <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + </div> + <img id="img-1" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="> + <img id="img-2" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-label="alt text"> + <p id="img-3-label">Label</p> + <img id="img-3" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="img-3-label"> + <img id="img-4" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"> + <p id="img-5-label"></p> + <img id="img-5" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="img-5-label"> + <div id="img-6" role="img"></div> + <div id="img-7" role="img" aria-label="alt text"></div> + <p id="img-8-label">Label</p> + <div id="img-8" role="img" aria-labelledby="img-8-label"></div> + <div id="img-9" role="img" aria-label=""></div> + <p id="img-10-label"></p> + <div id="img-10" role="img" aria-labelledby="img-10-label"></div> + <select> + <optgroup id="optgroup-1" label="Group 1"> + <option>Option 1.1</option> + </optgroup> + <optgroup id="optgroup-2" label=""> + <option>Option 2.1</option> + </optgroup> + <optgroup id="optgroup-3"> + <option>Option 3.1</option> + </optgroup> + <optgroup id="optgroup-4" aria-label="Group 4"> + <option>Option 4.1</option> + </optgroup> + <optgroup id="optgroup-5" aria-labelledby="optgroup-5-label"> + <option id="optgroup-5-label">Option 5.1</option> + </optgroup> + </select> + <fieldset id="fieldset-1"><legend>Choose your favorite monster</legend></fieldset> + <fieldset id="fieldset-2"><legend></legend></fieldset> + <fieldset id="fieldset-3"></fieldset> + <fieldset id="fieldset-4" aria-label="Choose your favorite monster"></fieldset> + <p id="fieldset-5-label">Choose your favorite monster</p> + <fieldset id="fieldset-5" aria-labelledby="fieldset-5-label"></fieldset> + <h1 id="heading-1"></h1> + <h1 id="heading-2">Heading</h1> + <h1 id="heading-3"> </h1> + <h1 id="heading-4" aria-label="Heading"></h1> + <h1 id="heading-5" aria-labelledby="heading-5-label"></h1> + <p id="heading-5-label">Heading</p> + <h1 id="heading-6" aria-label="Heading">H</h1> + <h1 id="heading-7" aria-labelledby="heading-7-label">H</h1> + <p id="heading-7-label">Heading</p> + <div role="heading" aria-level="1" id="heading-8"></div> + <div role="heading" aria-level="1" id="heading-9">Heading</div> + <div role="heading" aria-level="1" id="heading-10"> </div> + <div role="heading" aria-level="1" id="heading-11" aria-label="Heading"></div> + <div role="heading" aria-level="1" id="heading-12" aria-labelledby="heading-12-label"></div> + <p id="heading-12-label">Heading</p> + <div role="heading" aria-level="1" id="heading-13" aria-label="Heading">H</div> + <div role="heading" aria-level="1" id="heading-14" aria-labelledby="heading-14-label">H</div> + <p id="heading-14-label">Heading</p> + <map name="imagemap"> + <area alt="One" shape="rect" coords="0,0,14,28" href="1.html"> + <area shape="rect" coords="14,0,28,28" href="2.html"> + </map> + <img id="imagemap-1" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="> + <img id="imagemap-2" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-label="image map name"> + <p id="imagemap-3-label">image map name</p> + <img id="imagemap-3" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="imagemap-3-label"> + <img id="imagemap-4" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="image map name"> + <p id="imagemap-5-label"></p> + <img id="imagemap-5" usemap="#imagemap" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="img-5-label"> + <iframe id="iframe-1" title="IFrame Title" src="https://example.com"></iframe> + <iframe id="iframe-2" title="" src="https://example.com"></iframe> + <iframe id="iframe-3" src="https://example.com"></iframe> + <iframe id="iframe-4" aria-label="Bad Title" src="https://example.com"></iframe> + <iframe id="iframe-5" aria-label="Bad Title" title="Good Title" src="https://example.com"></iframe> + <object id="object-1" type="image/png" data="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="></object> + <object id="object-2" aria-label="Image object" type="image/png" data="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="></object> + <p id="object-3-label">Image object</p> + <object id="object-3" aria-labelledby="object-3-label" type="image/png" data="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="></object> + <object id="object-4" type="text/html" data="https://example.com"></object> + <embed id="embed-1" type="image/png" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="> + <embed id="embed-2" type="video/webm" src="data:video/webm,xxx"> + <embed id="embed-3" aria-label="Embedded video" type="video/webm" src="data:video/webm,xxx"> + <p id="embed-4-label">Embedded video</p> + <embed id="embed-4" aria-labelledby="embed-4-label" type="video/webm" src="data:video/webm,xxx"> + <a id="link-1"></a> + <a id="link-2">Hello world</a> + <a id="link-3" href></a> + <a id="link-4" href>Hello world</a> + <a id="link-5" href=""></a> + <a id="link-6" href="">Hello world</a> + <a id="link-7" href="#"></a> + <a id="link-8" href="#">Hello world</a> + <a id="link-9" href="https://example.com"></a> + <a id="link-10" href="https://example.com">Hello world</a> + <a id="link-11" aria-label="Hello world" href="https://example.com"></a> + <p id="link-12-label">Hello world</p> + <a id="link-12" aria-labelledby="link-12-label" href="https://example.com"></a> + <div role="link" id="link-13"></div> + <div role="link" id="link-14">Hello world</div> + <div role="link" id="link-15" aria-label="Hello world"></div> + <p id="link-16-label">Hello world</p> + <div role="link" id="link-16" aria-labelledby="link-16-label"></div> + <p id="mglyph-3-label">Label</p> + <p id="mglyph-6-label"></p> + <math> + <mi><mglyph id="mglyph-1" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII="/></mi> + <mi><mglyph id="mglyph-2" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-label="alt text"/></mi> + <mi><mglyph id="mglyph-3" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="mglyph-3-label"/></mi> + <mi><mglyph id="mglyph-4" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="alt text"/></mi> + <mi><mglyph id="mglyph-5" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt=""/></mi> + <mi><mglyph id="mglyph-6" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" aria-labelledby="mglyph-6-label"/></mi> + </math> + <span id="menuitem-1" role="menuitem"></span> + <span id="menuitem-2" aria-label="" role="menuitem"></span> + <span id="menuitem-3" aria-label="Menu Item" role="menuitem"></span> + <p id="menuitem-4-label">Menu Item</p> + <span id="menuitem-4" aria-labelledby="menuitem-4-label" role="menuitem"></span> + <p id="menuitem-5-label"></p> + <span id="menuitem-5" aria-labelledby="menuitem-5-label" role="menuitem"></span> + <span id="menuitem-6" role="menuitem">Menu Item</span> + <label for="listbox-1">Choose a pet:</label> + <select id="listbox-1" size="6"> + <option id="option-1" value="">--Please choose an option--</option> + <option id="option-2" value="dog"></option> + <option id="option-3" value="cat"> </option> + <option id="option-4" value="" label="--Please choose an option--"></option> + <option id="option-5" value="dog" label=""></option> + <option id="option-6" value="cat" label=" "></option> + </select> + <select id="listbox-2" size="2"></select> + <label>Choose a pet:</label><select id="listbox-3" size="2"></select> + <label>Choose a pet:<select id="listbox-4" size="2"></select></label> + <select id="listbox-5" aria-label="Choose a pet:" size="2"></select> + <label id="listbox-6-label">Choose a pet:</label><select id="listbox-6" aria-labelledby="listbox-6-label" size="2"></select> + <div role="listbox"> + <div role="option" id="option-7">--Please choose an option--</div> + <div role="option" id="option-8"></div> + <div role="option" id="option-9"> </div> + <div role="option" id="option-10" aria-label="--Please choose an option--"></div> + <div role="option" id="option-11" aria-label=""></div> + <div role="option" id="option-12" aria-label=" "></div> + <p id="option-13-label">--Please choose an option--</p> + <div role="option" id="option-13" aria-labelledby="option-13-label"></div> + <p id="option-14-label"></p> + <div role="option" id="option-14" aria-labelledby="option-14-label"></div> + <p id="option-15-label"> </p> + <div role="option" id="option-15" aria-labelledby="option-15-label"></div> + </div> + <span id="treeitem-1" role="treeitem"></span> + <span id="treeitem-2" aria-label="" role="treeitem"></span> + <span id="treeitem-3" aria-label="Tree Item" role="treeitem"></span> + <p id="treeitem-4-label">Tree Item</p> + <span id="treeitem-4" aria-labelledby="treeitem-4-label" role="treeitem"></span> + <p id="treeitem-5-label"></p> + <span id="treeitem-5" aria-labelledby="treeitem-5-label" role="treeitem"></span> + <span id="treeitem-6" role="treeitem">Tree Item</span> + <div role="tablist"> + <span id="tab-1" role="tab"></span> + <span id="tab-2" aria-label="" role="tab"></span> + <span id="tab-3" aria-label="Tab" role="tab"></span> + <p id="tab-4-label">Tab</p> + <span id="tab-4" aria-labelledby="tab-4-label" role="tab"></span> + <p id="tab-5-label"></p> + <span id="tab-5" aria-labelledby="tab-5-label" role="tab"></span> + <span id="tab-6" role="tab">Tab</span> + </div> + <label>Password: <input type="password" id="password-1"></label> + <input type="password" id="password-2"> + <input type="password" id="password-3" aria-label="Password:"> + <label>Password: </label><input type="password" id="password-4"> + <label for="password-5">Password: </label><input type="password" id="password-5"> + <label id="password-6-label">Password: </label><input type="password" id="password-6" aria-labelledby="password-6-label"> + <label>Progress: <progress id="progress-1"></progress></label> + <progress id="progress-2"></progress> + <progress id="progress-3" aria-label="Progress:"></progress> + <label>Progress: </label><progress id="progress-4"></progress> + <label for="progress-5">Progress: </label><progress id="progress-5"></progress> + <label id="progress-6-label">Progress: </label><progress id="progress-6" aria-labelledby="progress-6-label"></progress> + <label>Progress: <div role="progressbar" id="progress-7"></div></label> + <label id="progress-8-label">Progress: <div role="progressbar" id="progress-8" aria-labelledby="progress-8-label"></div></label> + <div role="progressbar" id="progress-9"></div> + <div role="progressbar" id="progress-10" aria-label="Progress:"></div> + <label>Progress: </label><div role="progressbar" id="progress-11"></div> + <label for="progress-12">Progress: </label><div role="progressbar" id="progress-12"></div> + <label id="progress-13-label">Progress: </label><div role="progressbar" id="progress-13" aria-labelledby="progress-13-label"></div> + <button id="button-1">hello</button> + <button id="button-2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" /></button> + <button id="button-3"></button> + <button id="button-4"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="" /></button> + <button id="button-5"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="hello" /></button> + <button id="button-6"> </button> + <label>Button: <button id="button-7"></button></label> + <button id="button-8" aria-label="Button:"></button> + <label>Button: </label><button id="button-9"></button> + <label for="button-10">Button: </label><button id="button-10"></button> + <label id="button-11-label">Button: </label><button id="button-11" aria-labelledby="button-11-label"></button> + <label>Button: <div role="button" id="button-12"></div></label> + <label id="button-13-label">Button: <div role="button" id="button-13" aria-labelledby="button-13-label"></div></label> + <div role="button" id="button-14"></div> + <div role="button" id="button-15" aria-label="Button:"></div> + <label>Button: </label><div role="button" id="button-16"></div> + <label for="button-17">Button: </label><div role="button" id="button-17"></div> + <label id="button-18-label">Button: </label><div role="button" id="button-18" aria-labelledby="button-18-label"></div> + <label>Radio label: <input type="radio" id="radiobutton-1"></label> + <input type="radio" id="radiobutton-2"> + <input type="radio" id="radiobutton-3" aria-label="Radio label:"> + <label>Radio label: </label><input type="radio" id="radiobutton-4"> + <label for="radiobutton-5">Radio label: </label><input type="radio" id="radiobutton-5"> + <label id="radiobutton-6-label">Radio label: </label><input type="radio" id="radiobutton-6" aria-labelledby="radiobutton-6-label"> + <div id="radiobutton-7" role="radio"></div> + <div id="radiobutton-8" aria-label="Radio label:" role="radio"></div> + <div id="radiobutton-9-label">Radio label: </div><div id="radiobutton-9" aria-labelledby="radiobutton-9-label" role="radio"></div> + <div role="menu"> + <div id="menuitemradio-1" role="menuitemradio">hello</div> + <div id="menuitemradio-2" role="menuitemradio"></div> + <div id="menuitemradio-3" role="menuitemradio"> </div> + </div> + <div role="rowheader" id="rowheader-8">Toy Story 3</div> + <div role="rowheader" id="rowheader-9"></div> + <div role="rowheader" id="rowheader-10"> </div> + <div role="rowheader" id="rowheader-11" aria-label="Alladin"></div> + <div role="rowheader" id="rowheader-12" aria-label=""></div> + <div role="rowheader" id="rowheader-13" aria-label=" "></div> + <div role="rowheader" id="rowheader-14" aria-labelledby="columnheader-7-label"></div> + <label>Slider label: <input type="range" id="slider-1"></label> + <input type="range" id="slider-2"> + <input type="range" id="slider-3" aria-label="Slider label:"> + <label>Slider label: </label><input type="range" id="slider-4"> + <label for="slider-5">Slider label: </label><input type="range" id="slider-5"> + <label id="slider-6-label">Slider label: </label><input type="range" id="slider-6" aria-labelledby="slider-6-label"> + <div id="slider-7" role="slider"></div> + <div id="slider-8" aria-label="Slider label:" role="slider"></div> + <div id="slider-9-label">Slider label: </div><div id="slider-9" aria-labelledby="slider-9-label" role="slider"></div> + <label>Spin button label: <input type="number" id="spinbutton-1"></label> + <input type="number" id="spinbutton-2"> + <input type="number" id="spinbutton-3" aria-label="Spin button label:"> + <label>Spin button label: </label><input type="number" id="spinbutton-4"> + <label for="spinbutton-5">Spin button label: </label><input type="number" id="spinbutton-5"> + <label id="spinbutton-6-label">Spin button label: </label><input type="number" id="spinbutton-6" aria-labelledby="spinbutton-6-label"> + <div id="spinbutton-7" role="spinbutton"></div> + <div id="spinbutton-8" aria-label="Spin button label:" role="spinbutton"></div> + <div id="spinbutton-9-label">Spin button label: </div><div id="spinbutton-9" aria-labelledby="spinbutton-9-label" role="spinbutton"></div> + <div id="switch-1" role="switch"></div> + <div id="switch-2" aria-label="hello" role="switch"></div> + <div id="switch-3-label">hello</div><div id="switch-3" aria-labelledby="switch-3-label" role="switch"></div> + <label for="switch-4">hello</label><div id="switch-4" role="switch"></div> + <label>hello<div id="switch-5" role="switch"></div></label> + <label>Meter label: <meter id="meter-1"></meter></label> + <meter id="meter-2"></meter> + <meter id="meter-3" aria-label="Meter label:"></meter> + <label>Meter label: </label><meter id="meter-4"></meter> + <label for="meter-5">Meter label: </label><meter id="meter-5"></meter> + <label id="meter-6-label">Meter label: </label><meter id="meter-6" aria-labelledby="meter-6-label"></meter> + <div id="meter-7" role="meter"></div> + <div id="meter-8" aria-label="Meter label:" role="meter"></div> + <div id="meter-9-label">Meter label: </div><div id="meter-9" aria-labelledby="meter-9-label" role="meter"></div> + <button aria-pressed="true" id="togglebutton-1" >hello</button> + <button aria-pressed="true" id="togglebutton-2"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" /></button> + <button aria-pressed="true" id="togglebutton-3"></button> + <button aria-pressed="true" id="togglebutton-4"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="" /></button> + <button aria-pressed="true" id="togglebutton-5"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAAJklEQVRIie3NMREAAAgAoe9fWls4eAzMVM0xoVAoFAqFQqFQ+C9chp4NHvu+4Q4AAAAASUVORK5CYII=" alt="hello" /></button> + <button aria-pressed="true" id="togglebutton-6"> </button> + <label>Button: <button aria-pressed="true" id="togglebutton-7"></button></label> + <button aria-pressed="true" id="togglebutton-8" aria-label="Button:"></button> + <label>Button: </label><button aria-pressed="true" id="togglebutton-9"></button> + <label for="togglebutton-10">Button: </label><button aria-pressed="true" id="togglebutton-10"></button> + <label id="togglebutton-11-label">Button: </label><button aria-pressed="true" id="togglebutton-11" aria-labelledby="togglebutton-11-label"></button> + <label>Button: <div role="button" aria-pressed="true" id="togglebutton-12"></div></label> + <label id="togglebutton-13-label">Button: <div role="button" aria-pressed="true" id="togglebutton-13" aria-labelledby="togglebutton-13-label"></div></label> + <div role="button" aria-pressed="true" id="togglebutton-14"></div> + <div role="button" aria-pressed="true" id="togglebutton-15" aria-label="Button:"></div> + <label>Button: </label><div role="button" aria-pressed="true" id="togglebutton-16"></div> + <label for="togglebutton-17">Button: </label><div role="button" aria-pressed="true" id="togglebutton-17"></div> + <label id="togglebutton-18-label">Button: </label><div role="button" aria-pressed="true" id="togglebutton-18" aria-labelledby="togglebutton-18-label"></div> + <span id="toolbar-1" role="toolbar" aria-label="Toolbar"></span> + <span id="toolbar-2" role="toolbar"></span> + <p id="toolbar-3-label"></p> + <span id="toolbar-3" role="toolbar" aria-labelledby="toolbar-3-label"></span> + <p id="toolbar-4-label">Toolbar</p> + <span id="toolbar-4" role="toolbar" aria-labelledby="toolbar-4-label"></span> + <svg id="svg-1" role="img" viewbox="0 0 100 10" height="10px"> + <title id="siteLogoTitle">Site Logo</title> + <rect x="0" y="00" width="100" height="10" fill="red"></rect> + </svg> + <svg id="svg-2" viewbox="0 0 100 10" height="10px"> + <title id="siteLogoTitle">Site Logo</title> + <rect x="0" y="00" width="100" height="10" fill="red"></rect> + </svg> + <svg id="svg-3" role="img" viewbox="0 0 100 10" height="10px"> + <rect x="0" y="00" width="100" height="10" fill="red"></rect> + </svg> + <svg id="svg-4" viewbox="0 0 100 10" height="10px"> + <rect x="0" y="00" width="100" height="10" fill="red"></rect> + </svg> + <svg id="svg-5" aria-label="foo" viewbox="0 0 100 10" height="10px"> + <rect id="svg-6" aria-label="bar" x="0" y="00" width="100" height="10" fill="red"></rect> + </svg> + <svg id="svg-7" viewbox="0 0 100 10" height="10px"> + <title id="siteLogoTitle">Site Logo</title> + <rect id="svg-8" aria-label="foo" x="0" y="00" width="100" height="10" fill="red"></rect> + </svg> + <svg id="svg-9" role="img" viewbox="0 0 100 10" height="10px"> + <title id="siteLogoTitle">Site Logo</title> + <rect aria-label="foo" id="svg-10" x="0" y="00" width="100" height="10" fill="red"></rect> + </svg> + <svg id="svg-11" role="img" viewbox="0 0 100 10" height="10px"> + <rect aria-label="foo" id="svg-12" x="0" y="00" width="100" height="10" fill="red"></rect> + </svg> +</body> +</html> diff --git a/devtools/server/tests/browser/doc_accessibility_text_label_audit_frame.html b/devtools/server/tests/browser/doc_accessibility_text_label_audit_frame.html new file mode 100644 index 0000000000..34a32abeb2 --- /dev/null +++ b/devtools/server/tests/browser/doc_accessibility_text_label_audit_frame.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> +<html> + <head> + <meta charset="utf-8"> + </head> + <frameset cols="50%,50%"> + <frame id="frame-1" src="https://example.com"></frame> + <frame id="frame-2" aria-label="Label" src="https://example.com"></frame> + </frameset> +</html> diff --git a/devtools/server/tests/browser/doc_allocations.html b/devtools/server/tests/browser/doc_allocations.html new file mode 100644 index 0000000000..a5c9ea6d41 --- /dev/null +++ b/devtools/server/tests/browser/doc_allocations.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> +<script> +"use strict"; + +window.allocs = []; +window.onload = function() { + function allocator() { + for (let i = 0; i < 1000; i++) { + window.allocs.push({}); + } + } + + window.setInterval(allocator, 1); +}; +</script> +</pre> +</body> +</html> diff --git a/devtools/server/tests/browser/doc_compatibility.html b/devtools/server/tests/browser/doc_compatibility.html new file mode 100644 index 0000000000..82cee286b5 --- /dev/null +++ b/devtools/server/tests/browser/doc_compatibility.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<style> + div { + color: lime; + } + + #id-clip { + clip: rect(10px, 10px, 10px, 10px); + } + + .class-clip { + clip: rect(5px, 5px, 5px, 5px); + } + + .class-user-select { + -moz-user-select: all; + } + + .duplicate { + clip: rect(10px, 10px, 10px, 10px); + clip: rect(5px, 5px, 5px, 5px); + clip: rect(2px, 2px, 2px, 2px); + } +</style> +<div></div> +<div class="class-user-select"></div> +<div id="id-clip" class="class-clip class-user-select"></div> +<div class="duplicate"></div> diff --git a/devtools/server/tests/browser/doc_force_cc.html b/devtools/server/tests/browser/doc_force_cc.html new file mode 100644 index 0000000000..22b1eb4071 --- /dev/null +++ b/devtools/server/tests/browser/doc_force_cc.html @@ -0,0 +1,32 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance tool + cycle collection test page</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + + /* global test */ + window.test = function() { + document.body.expando1 = { cycle: document.body }; + SpecialPowers.Cu.forceCC(); + + document.body.expando2 = { cycle: document.body }; + SpecialPowers.Cu.forceCC(); + + document.body.expando3 = { cycle: document.body }; + SpecialPowers.Cu.forceCC(); + + setTimeout(window.test, 100); + }; + test(); + </script> + </body> + +</html> diff --git a/devtools/server/tests/browser/doc_force_gc.html b/devtools/server/tests/browser/doc_force_gc.html new file mode 100644 index 0000000000..7dee110501 --- /dev/null +++ b/devtools/server/tests/browser/doc_force_gc.html @@ -0,0 +1,31 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance tool + garbage collection test page</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + + var x = 1; + /* global test */ + window.test = function() { + SpecialPowers.Cu.forceGC(); + document.body.style.borderTop = x + "px solid red"; + x = 1 ^ x; + // flush pending reflows + document.body.innerHeight; + + // Prevent this script from being garbage collected. + setTimeout(window.test, 100); + }; + test(); + </script> + </body> + +</html> diff --git a/devtools/server/tests/browser/doc_iframe.html b/devtools/server/tests/browser/doc_iframe.html new file mode 100644 index 0000000000..445361f7fa --- /dev/null +++ b/devtools/server/tests/browser/doc_iframe.html @@ -0,0 +1,17 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> + +<html> + <head> + <meta charset="utf-8"/> + <title>iframe test page</title> + </head> + + <body> + <iframe id="better-not-ask" src="data:text/html,<iframe src='data:text/html,foo'></iframe>"></iframe> + <!-- This page is loaded on an example.org subdomain, so we switch to .com --> + <iframe id="remote-frame" src="http://example.com/browser/devtools/server/tests/browser/doc_iframe_content.html"></iframe> + </body> + +</html> diff --git a/devtools/server/tests/browser/doc_iframe2.html b/devtools/server/tests/browser/doc_iframe2.html new file mode 100644 index 0000000000..2255490f26 --- /dev/null +++ b/devtools/server/tests/browser/doc_iframe2.html @@ -0,0 +1,15 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Sub document page</title> + </head> + + <body> + Iframe document + </body> + +</html> diff --git a/devtools/server/tests/browser/doc_iframe_content.html b/devtools/server/tests/browser/doc_iframe_content.html new file mode 100644 index 0000000000..6f80e4dd6d --- /dev/null +++ b/devtools/server/tests/browser/doc_iframe_content.html @@ -0,0 +1,14 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Frame for browser_resource_list-remote-frames.js</title> + </head> + + <body> + <div>Remote frame content</div> + </body> +</html> diff --git a/devtools/server/tests/browser/doc_innerHTML.html b/devtools/server/tests/browser/doc_innerHTML.html new file mode 100644 index 0000000000..e58b32f51e --- /dev/null +++ b/devtools/server/tests/browser/doc_innerHTML.html @@ -0,0 +1,21 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!doctype html> + +<html> + <head> + <meta charset="utf-8"/> + <title>Performance tool + innerHTML test page</title> + </head> + + <body> + <script type="text/javascript"> + "use strict"; + window.test = function() { + document.body.innerHTML = "<h1>LOL</h1>"; + }; + setInterval(window.test, 100); + </script> + </body> + +</html> diff --git a/devtools/server/tests/browser/error-actor.js b/devtools/server/tests/browser/error-actor.js new file mode 100644 index 0000000000..3872d8ad96 --- /dev/null +++ b/devtools/server/tests/browser/error-actor.js @@ -0,0 +1,25 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { Actor } = require("resource://devtools/shared/protocol/Actor.js"); + +/** + * Test actor designed to check that clients are properly notified of errors when calling + * methods on old style actors. + */ +class ErrorActor extends Actor { + constructor(conn, tab) { + super(conn, { typeName: "error", methods: [] }); + this.tab = tab; + this.requestTypes = { + error: this.onError, + }; + } + onError() { + throw new Error("error"); + } +} + +exports.ErrorActor = ErrorActor; diff --git a/devtools/server/tests/browser/grid.html b/devtools/server/tests/browser/grid.html new file mode 100644 index 0000000000..3bd0e1ec26 --- /dev/null +++ b/devtools/server/tests/browser/grid.html @@ -0,0 +1,42 @@ +<!-- Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"/> + <title>Grid test page</title> + <style type='text/css'> + #grid { + display: grid; + grid-template-columns: [col-1 col-start-1] 100px [col-2] 100px; + grid-template-rows: 100px 100px; + grid-template-areas: ". header" + "sidebar content"; + } + #cell1 { + grid-column: 1; + grid-row: 1; + } + #cell2 { + grid-column: 2; + grid-row: 1; + } + #cell3 { + grid-column: 1; + grid-row: 2; + } + #cell4 { + grid-column: 2; + grid-row: 2; + } + </style> +</head> +<body> + <div id="grid"> + <div id="cell1">cell1</div> + <div id="cell2">cell2</div> + <div id="cell3">cell3</div> + <div id="cell4">cell4</div> + </div> +</body> +</html> diff --git a/devtools/server/tests/browser/head.js b/devtools/server/tests/browser/head.js new file mode 100644 index 0000000000..aba6d578f2 --- /dev/null +++ b/devtools/server/tests/browser/head.js @@ -0,0 +1,337 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* eslint no-unused-vars: [2, {"vars": "local"}] */ + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { + DevToolsClient, +} = require("resource://devtools/client/devtools-client.js"); +const { + ActorRegistry, +} = require("resource://devtools/server/actors/utils/actor-registry.js"); +const { + DevToolsServer, +} = require("resource://devtools/server/devtools-server.js"); + +const PATH = "browser/devtools/server/tests/browser/"; +const TEST_DOMAIN = "http://test1.example.org"; +const MAIN_DOMAIN = `${TEST_DOMAIN}/${PATH}`; +const ALT_DOMAIN = "http://sectest1.example.org/" + PATH; +const ALT_DOMAIN_SECURED = "https://sectest1.example.org:443/" + PATH; + +// GUID to be used as a separator in compound keys. This must match the same +// constant in devtools/server/actors/resources/storage/index.js, +// devtools/client/storage/ui.js and devtools/client/storage/test/head.js +const SEPARATOR_GUID = "{9d414cc5-8319-0a04-0586-c0a6ae01670a}"; + +// All tests are asynchronous. +waitForExplicitFinish(); + +// does almost the same thing as addTab, but directly returns an object +async function addTabTarget(url) { + info(`Adding a new tab with URL: ${url}`); + const tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url)); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser); + info(`Tab added a URL ${url} loaded`); + return createAndAttachTargetForTab(tab); +} + +async function initAnimationsFrontForUrl(url) { + const { inspector, walker, target } = await initInspectorFront(url); + const animations = await target.getFront("animations"); + + return { inspector, walker, animations, target }; +} + +async function initLayoutFrontForUrl(url) { + const { inspector, walker, target } = await initInspectorFront(url); + const layout = await walker.getLayoutInspector(); + + return { inspector, walker, layout, target }; +} + +async function initAccessibilityFrontsForUrl( + url, + { enableByDefault = true } = {} +) { + const { inspector, walker, target } = await initInspectorFront(url); + const parentAccessibility = await target.client.mainRoot.getFront( + "parentaccessibility" + ); + const accessibility = await target.getFront("accessibility"); + const a11yWalker = accessibility.accessibleWalkerFront; + if (enableByDefault) { + await parentAccessibility.enable(); + } + + return { + inspector, + walker, + accessibility, + parentAccessibility, + a11yWalker, + target, + }; +} + +function initDevToolsServer() { + try { + // Sometimes devtools server does not get destroyed correctly by previous + // tests. + DevToolsServer.destroy(); + } catch (e) { + info(`DevToolsServer destroy error: ${e}\n${e.stack}`); + } + DevToolsServer.init(); + DevToolsServer.registerAllActors(); +} + +async function initPerfFront() { + initDevToolsServer(); + const client = new DevToolsClient(DevToolsServer.connectPipe()); + await waitUntilClientConnected(client); + const front = await client.mainRoot.getFront("perf"); + return { front, client }; +} + +async function initInspectorFront(url) { + const target = await addTabTarget(url); + const inspector = await target.getFront("inspector"); + const walker = inspector.walker; + + return { inspector, walker, target }; +} + +/** + * Wait until a DevToolsClient is connected. + * @param {DevToolsClient} client + * @return {Promise} Resolves when connected. + */ +function waitUntilClientConnected(client) { + return client.once("connected"); +} + +/** + * Wait for eventName on target. + * @param {Object} target An observable object that either supports on/off or + * addEventListener/removeEventListener + * @param {String} eventName + * @param {Boolean} useCapture Optional, for addEventListener/removeEventListener + * @return A promise that resolves when the event has been handled + */ +function once(target, eventName, useCapture = false) { + info("Waiting for event: '" + eventName + "' on " + target + "."); + + return new Promise(resolve => { + for (const [add, remove] of [ + ["addEventListener", "removeEventListener"], + ["addListener", "removeListener"], + ["on", "off"], + ]) { + if (add in target && remove in target) { + target[add]( + eventName, + function onEvent(...aArgs) { + info("Got event: '" + eventName + "' on " + target + "."); + target[remove](eventName, onEvent, useCapture); + resolve(...aArgs); + }, + useCapture + ); + break; + } + } + }); +} + +/** + * Forces GC, CC and Shrinking GC to get rid of disconnected docshells and + * windows. + */ +function forceCollections() { + Cu.forceGC(); + Cu.forceCC(); + Cu.forceShrinkingGC(); +} + +registerCleanupFunction(function tearDown() { + Services.cookies.removeAll(); + + while (gBrowser.tabs.length > 1) { + gBrowser.removeCurrentTab(); + } +}); + +function idleWait(time) { + return DevToolsUtils.waitForTime(time); +} + +function busyWait(time) { + const start = Date.now(); + let stack; + while (Date.now() - start < time) { + stack = Components.stack; // eslint-disable-line no-unused-vars + } +} + +/** + * Waits until a predicate returns true. + * + * @param function predicate + * Invoked once in a while until it returns true. + * @param number interval [optional] + * How often the predicate is invoked, in milliseconds. + */ +function waitUntil(predicate, interval = 10) { + if (predicate()) { + return Promise.resolve(true); + } + return new Promise(resolve => { + setTimeout(function () { + waitUntil(predicate).then(() => resolve(true)); + }, interval); + }); +} + +function waitForMarkerType( + front, + types, + predicate, + unpackFun = (name, data) => data.markers, + eventName = "timeline-data" +) { + types = [].concat(types); + predicate = + predicate || + function () { + return true; + }; + let filteredMarkers = []; + + return new Promise(resolve => { + info("Waiting for markers of type: " + types); + + function handler(name, data) { + if (typeof name === "string" && name !== "markers") { + return; + } + + const markers = unpackFun(name, data); + info("Got markers"); + + filteredMarkers = filteredMarkers.concat( + markers.filter(m => types.includes(m.name)) + ); + + if ( + types.every(t => filteredMarkers.some(m => m.name === t)) && + predicate(filteredMarkers) + ) { + front.off(eventName, handler); + resolve(filteredMarkers); + } + } + front.on(eventName, handler); + }); +} + +function getCookieId(name, domain, path) { + return `${name}${SEPARATOR_GUID}${domain}${SEPARATOR_GUID}${path}`; +} + +/** + * Trigger DOM activity and wait for the corresponding accessibility event. + * @param {Object} emitter Devtools event emitter, usually a front. + * @param {Sting} name Accessibility event in question. + * @param {Function} handler Accessibility event handler function with checks. + * @param {Promise} task A promise that resolves when DOM activity is done. + */ +async function emitA11yEvent(emitter, name, handler, task) { + const promise = emitter.once(name, handler); + await task(); + await promise; +} + +/** + * Check that accessibilty front is correct and its attributes are also + * up-to-date. + * @param {Object} front Accessibility front to be tested. + * @param {Object} expected A map of a11y front properties to be verified. + * @param {Object} expectedFront Expected accessibility front. + */ +function checkA11yFront(front, expected, expectedFront) { + ok(front, "The accessibility front is created"); + + if (expectedFront) { + is(front, expectedFront, "Matching accessibility front"); + } + + // Clone the front so we could modify some values for comparison. + front = Object.assign(front); + for (const key in expected) { + if (key === "checks") { + const { CONTRAST } = front[key]; + // Contrast values are rounded to two digits after the decimal point. + if (CONTRAST && CONTRAST.value) { + CONTRAST.value = parseFloat(CONTRAST.value.toFixed(2)); + } + } + + if (["actions", "states", "attributes", "checks"].includes(key)) { + SimpleTest.isDeeply( + front[key], + expected[key], + `Accessible Front has correct ${key}` + ); + } else { + is(front[key], expected[key], `accessibility front has correct ${key}`); + } + } +} + +function getA11yInitOrShutdownPromise() { + return new Promise(resolve => { + const observe = (subject, topic, data) => { + Services.obs.removeObserver(observe, "a11y-init-or-shutdown"); + resolve(data); + }; + Services.obs.addObserver(observe, "a11y-init-or-shutdown"); + }); +} + +/** + * Wait for accessibility service to shut down. We consider it shut down when + * an "a11y-init-or-shutdown" event is received with a value of "0". + */ +async function waitForA11yShutdown(parentAccessibility) { + await parentAccessibility.disable(); + if (!Services.appinfo.accessibilityEnabled) { + return; + } + + await getA11yInitOrShutdownPromise().then(data => + data === "0" ? Promise.resolve() : Promise.reject() + ); +} + +/** + * Wait for accessibility service to initialize. We consider it initialized when + * an "a11y-init-or-shutdown" event is received with a value of "1". + */ +async function waitForA11yInit() { + if (Services.appinfo.accessibilityEnabled) { + return; + } + + await getA11yInitOrShutdownPromise().then(data => + data === "1" ? Promise.resolve() : Promise.reject() + ); +} diff --git a/devtools/server/tests/browser/inspector-helpers.js b/devtools/server/tests/browser/inspector-helpers.js new file mode 100644 index 0000000000..799a2893ec --- /dev/null +++ b/devtools/server/tests/browser/inspector-helpers.js @@ -0,0 +1,166 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* exported assertOwnershipTrees, checkMissing, waitForMutation, + isSrcChange, isUnretained, isChildList */ + +function serverOwnershipTree(walkerArg) { + return SpecialPowers.spawn( + gBrowser.selectedBrowser, + [[walkerArg.actorID]], + function (actorID) { + const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" + ); + const { + DevToolsServer, + } = require("resource://devtools/server/devtools-server.js"); + const { + DocumentWalker, + } = require("resource://devtools/server/actors/inspector/document-walker.js"); + + // Convert actorID to current compartment string otherwise + // searchAllConnectionsForActor is confused and won't find the actor. + actorID = String(actorID); + const serverWalker = DevToolsServer.searchAllConnectionsForActor(actorID); + + function sortOwnershipChildrenContentScript(children) { + return children.sort((a, b) => a.name.localeCompare(b.name)); + } + + function serverOwnershipSubtree(walker, node) { + const actor = walker.getNode(node); + if (!actor) { + return undefined; + } + + const children = []; + const docwalker = new DocumentWalker(node, content); + let child = docwalker.firstChild(); + while (child) { + const item = serverOwnershipSubtree(walker, child); + if (item) { + children.push(item); + } + child = docwalker.nextSibling(); + } + return { + name: actor.actorID, + children: sortOwnershipChildrenContentScript(children), + }; + } + return { + root: serverOwnershipSubtree(serverWalker, serverWalker.rootDoc), + orphaned: [...serverWalker._orphaned].map(o => + serverOwnershipSubtree(serverWalker, o.rawNode) + ), + retained: [...serverWalker._retainedOrphans].map(o => + serverOwnershipSubtree(serverWalker, o.rawNode) + ), + }; + } + ); +} + +function sortOwnershipChildren(children) { + return children.sort((a, b) => a.name.localeCompare(b.name)); +} + +function clientOwnershipSubtree(node) { + return { + name: node.actorID, + children: sortOwnershipChildren( + node.treeChildren().map(child => clientOwnershipSubtree(child)) + ), + }; +} + +function clientOwnershipTree(walker) { + return { + root: clientOwnershipSubtree(walker.rootNode), + orphaned: [...walker._orphaned].map(o => clientOwnershipSubtree(o)), + retained: [...walker._retainedOrphans].map(o => clientOwnershipSubtree(o)), + }; +} + +function ownershipTreeSize(tree) { + let size = 1; + for (const child of tree.children) { + size += ownershipTreeSize(child); + } + return size; +} + +async function assertOwnershipTrees(walker) { + const serverTree = await serverOwnershipTree(walker); + const clientTree = clientOwnershipTree(walker); + is( + JSON.stringify(clientTree, null, " "), + JSON.stringify(serverTree, null, " "), + "Server and client ownership trees should match." + ); + + return ownershipTreeSize(clientTree.root); +} + +// Verify that an actorID is inaccessible both from the client library and the server. +function checkMissing({ client }, actorID) { + return new Promise(resolve => { + const front = client.getFrontByID(actorID); + ok( + !front, + "Front shouldn't be accessible from the client for actorID: " + actorID + ); + + client + .request( + { + to: actorID, + type: "request", + }, + response => { + is( + response.error, + "noSuchActor", + "node list actor should no longer be contactable." + ); + resolve(undefined); + } + ) + .catch(() => {}); + }); +} + +// Load mutations aren't predictable, so keep accumulating mutations until +// the one we're looking for shows up. +function waitForMutation(walker, test, mutations = []) { + return new Promise(resolve => { + for (const change of mutations) { + if (test(change)) { + resolve(mutations); + } + } + + walker.once("mutations", newMutations => { + waitForMutation(walker, test, mutations.concat(newMutations)).then( + finalMutations => { + resolve(finalMutations); + } + ); + }); + }); +} + +function isSrcChange(change) { + return change.type === "attributes" && change.attributeName === "src"; +} + +function isUnretained(change) { + return change.type === "unretained"; +} + +function isChildList(change) { + return change.type === "childList"; +} diff --git a/devtools/server/tests/browser/inspector-isScrollable-data.html b/devtools/server/tests/browser/inspector-isScrollable-data.html new file mode 100644 index 0000000000..07caabd894 --- /dev/null +++ b/devtools/server/tests/browser/inspector-isScrollable-data.html @@ -0,0 +1,79 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Inspector test of isScrollable</title> + <style> + /* "e" is our custom tag name for "element" */ + e { + background: lightgray; + display: inline-block; + margin: 10px; + padding: 0; + border: 0; + width: 100px; + height: 100px; + overflow: auto; + } + + /* "c" is our custom tag name for "child" */ + c { + display: block; + background: green; + } + + .fixedSize { + width: 10px; + height: 10px; + } + + .target { + background: red; + } + </style> +</head> +<body id="body"> +<e id="no_children"></e> + +<e id="one_child_no_overflow"> + <c></c> +</e> + +<e id="margin_left_overflow"> + <c class="target" style="margin-left:100px">abcd</c> +</e> + +<e id="transform_overflow"> + <c class="target" style="transform: translate(50px)">abcd</c> +</e> + +<e id="nested_overflow"> + <c> + <c class="target" style="margin-left:100px">abcd</c> + </c> +</e> + +<e id="intermediate_overflow"> + <c class="fixedSize target" style="margin-left:100px"> + <c></c> + </c> +</e> + +<e id="multiple_overflow_at_different_depths"> + <c class="fixedSize target" style="margin-left:100px"> + <c></c> + </c> + <c style="margin-left:100px"> + <c class="target">abcd</c> + </c> +</e> + +<e id="overflow_hidden" style="overflow:hidden"> + <c class="target" style="margin-left:100px">abcd</c> +</e> + +<e id="scrollbar_none" style="scrollbar-width:none"> + <c class="target" style="margin-left:100px">abcd</c> +</e> +</body> +</html> diff --git a/devtools/server/tests/browser/inspector-search-data.html b/devtools/server/tests/browser/inspector-search-data.html new file mode 100644 index 0000000000..784dcb7c9b --- /dev/null +++ b/devtools/server/tests/browser/inspector-search-data.html @@ -0,0 +1,54 @@ +<html> +<head> + <meta charset="UTF-8"> + <title>Inspector Search Test Data</title> + <style> + #pseudo { + display: block; + margin: 0; + } + #pseudo:before { + content: "before element"; + } + #pseudo:after { + content: "after element"; + } + </style> + <script type="text/javascript"> + "use strict"; + + window.onload = function() { + window.opener.postMessage("ready", "*"); + }; + </script> +</head> +</body> + <!-- A comment + spread across multiple lines --> + + <img width="100" height="100" src="large-image.jpg" /> + + <h1 id="pseudo">Heading 1</h1> + <p>A p tag with the text 'h1' inside of it. + <strong>A strong h1 result</strong> + </p> + + <div id="arrows" northwest="↖" northeast="↗" southeast="↘" southwest="↙"> + Unicode arrows + </div> + + <h2>Heading 2</h2> + <h2>Heading 2</h2> + <h2>Heading 2</h2> + + <h3>Heading 3</h3> + <h3>Heading 3</h3> + <h3>Heading 3</h3> + + <h4>Heading 4</h4> + <h4>Heading 4</h4> + <h4>Heading 4</h4> + + <div class="💩" id="💩" 💩="💩"></div> +</body> +</html> diff --git a/devtools/server/tests/browser/inspector-shadow.html b/devtools/server/tests/browser/inspector-shadow.html new file mode 100644 index 0000000000..eb600548e2 --- /dev/null +++ b/devtools/server/tests/browser/inspector-shadow.html @@ -0,0 +1,117 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> + <title>Inspector (empty page)</title> + <script> + "use strict"; + + window.onload = function() { + customElements.define("test-empty", class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: "open"}); + } + }); + + customElements.define("test-empty-closed", class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: "closed"}); + } + }); + + customElements.define("test-children", class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: "open"}); + this.shadowRoot.innerHTML = ` + <h1>One child</h1> + <p>A second child</p>`; + } + }); + + customElements.define("test-named-slot", class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: "open"}); + this.shadowRoot.innerHTML = ` + <h1>With slot</h1> + <slot name="slot1"></slot>`; + } + }); + + customElements.define("test-slot", class extends HTMLElement { + constructor() { + super(); + this.attachShadow({mode: "open"}); + this.shadowRoot.innerHTML = ` + <style> + slot::before { content: "[SLOT BEFORE]"; color: red; } + slot::after { content: "[SLOT AFTER]"; color: blue; } + </style> + <slot></slot>`; + } + }); + + customElements.define("test-simple-slot", class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: "open"}); + this.shadowRoot.innerHTML = "<slot></slot>"; + } + }); + }; + </script> + <style> + #host-pseudo::before { content: "[HOST BEFORE]"; color: red; } + #host-pseudo::after { content: "[HOST AFTER]"; color: blue; } + </style> +</head> +<body> + <test-empty id="empty"></test-empty> + + <hr> + + <test-empty id="one-child"> + <h1>One child</h1> + </test-empty> + + <hr> + + <test-children id="shadow-children"></test-children> + + <hr> + + <test-named-slot id="named-slot"> + <p class="slotted" slot="slot1">Slotted</p> + </test-named-slot> + + <hr> + + <test-slot id="slot-pseudo"> + <span class="has-before">Slotted</span> + </test-slot> + + <hr> + + <test-empty id="host-pseudo"></test-empty> + + <hr> + + <test-empty id="mode-open"></test-empty> + <test-empty-closed id="mode-closed"></test-empty-closed> + + <hr> + + <test-simple-slot id="slot-inline-text"> + Lorem ipsum + </test-simple-slot> + + <hr> + <video id="video-controls" controls></video> + <video id="video-controls-with-children" controls> + <div>some content</div> + </video> +</body> +</html> diff --git a/devtools/server/tests/browser/inspector-traversal-data.html b/devtools/server/tests/browser/inspector-traversal-data.html new file mode 100644 index 0000000000..6f025747ec --- /dev/null +++ b/devtools/server/tests/browser/inspector-traversal-data.html @@ -0,0 +1,98 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <title>Inspector Traversal Test Data</title> + <style type="text/css"> + #pseudo::before { + content: "before"; + } + #pseudo::after { + content: "after"; + } + #pseudo-empty::before { + content: "before an empty element"; + } + #shadow::before { + content: "Testing ::before on a shadow host"; + } + </style> + <script type="text/javascript"> + "use strict"; + + window.onload = function() { + // Set up a basic shadow DOM + const host = document.querySelector("#shadow"); + const root = host.attachShadow({ mode: "open" }); + + const h3 = document.createElement("h3"); + h3.append("Shadow "); + + const em = document.createElement("em"); + em.append("DOM"); + + const select = document.createElement("select"); + select.setAttribute("multiple", ""); + h3.appendChild(em); + root.appendChild(h3); + root.appendChild(select); + + // Put a copy of the body in an iframe to test frame traversal. + const body = document.querySelector("body"); + const data = "data:text/html,<html>" + body.outerHTML + "<html>"; + const iframe = document.createElement("iframe"); + iframe.setAttribute("id", "childFrame"); + iframe.src = data; + body.appendChild(iframe); + }; + </script> +</head> +<body style="background-color:white"> + <h1>Inspector Actor Tests</h1> + <span id="longstring">longlonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglonglong</span> + <span id="shortstring">short</span> + <span id="empty"></span> + <div id="longlist" data-test="exists"> + <div id="a">a</div> + <div id="b">b</div> + <div id="c">c</div> + <div id="d">d</div> + <div id="e">e</div> + <div id="f">f</div> + <div id="g">g</div> + <div id="h">h</div> + <div id="i">i</div> + <div id="j">j</div> + <div id="k">k</div> + <div id="l">l</div> + <div id="m">m</div> + <div id="n">n</div> + <div id="o">o</div> + <div id="p">p</div> + <div id="q">q</div> + <div id="r">r</div> + <div id="s">s</div> + <div id="t">t</div> + <div id="u">u</div> + <div id="v">v</div> + <div id="w">w</div> + <div id="x">x</div> + <div id="y">y</div> + <div id="z">z</div> + </div> + <div id="longlist-sibling"> + <div id="longlist-sibling-firstchild"></div> + </div> + <p id="edit-html"></p> + + <select multiple><option>one</option><option>two</option></select> + <div id="pseudo"><span>middle</span></div> + <div id="pseudo-empty"></div> + <div id="shadow">light dom</div> + <object> + <div id="1"></div> + </object> + <div class="node-to-duplicate"></div> + <div id="scroll-into-view" style="margin-top: 1000px;">scroll</div> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-cookies-same-name.html b/devtools/server/tests/browser/storage-cookies-same-name.html new file mode 100644 index 0000000000..235c8a451f --- /dev/null +++ b/devtools/server/tests/browser/storage-cookies-same-name.html @@ -0,0 +1,29 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Storage inspector cookies with duplicate names</title> +</head> +<body onload="createCookies()"> +<script type="application/javascript"> +"use strict"; +// eslint-disable-next-line no-unused-vars +function createCookies() { + document.cookie = "name=value1;path=/;"; + document.cookie = "name=value2;path=/path2/;"; + document.cookie = "name=value3;path=/path3/;"; +} + +window.removeCookie = function (name) { + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"; +}; + +window.clearCookies = function () { + const cookies = document.cookie; + for (const cookie of cookies.split(";")) { + window.removeCookie(cookie.split("=")[0]); + } +}; +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-dynamic-windows.html b/devtools/server/tests/browser/storage-dynamic-windows.html new file mode 100644 index 0000000000..22df8a255e --- /dev/null +++ b/devtools/server/tests/browser/storage-dynamic-windows.html @@ -0,0 +1,117 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 965872 - Storage inspector actor with cookies, local storage and session storage. +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for listing hosts and storages</title> +</head> +<body> +<iframe src="http://sectest1.example.org/browser/devtools/server/tests/browser/storage-unsecured-iframe.html"></iframe> +<script type="application/javascript"> +"use strict"; +const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1]; +const cookieExpiresTime1 = 2000000000000; +const cookieExpiresTime2 = 2000000001000; +// Setting up some cookies to eat. +document.cookie = "c1=foobar; expires=" + + new Date(cookieExpiresTime1).toGMTString() + "; path=/browser"; +document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname; +document.cookie = "c3=foobar-2; expires=" + + new Date(cookieExpiresTime2).toGMTString() + "; path=/"; +// ... and some local storage items .. +localStorage.setItem("ls1", "foobar"); +localStorage.setItem("ls2", "foobar-2"); +// ... and finally some session storage items too +sessionStorage.setItem("ss1", "foobar-3"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const dbResult = event.target.result; + const store1 = dbResult.createObjectStore("obj1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + dbResult.createObjectStore("obj2", { keyPath: "id2" }); + store1.transaction.oncomplete = () => { + done(dbResult); + }; + }; + }); + + // Prevents AbortError + await new Promise(done => { + request.onsuccess = done; + }); + + const transaction = db.transaction(["obj1", "obj2"], "readwrite"); + const store1 = transaction.objectStore("obj1"); + const store2 = transaction.objectStore("obj2"); + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz" + }); + // Prevents AbortError during close() + await new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb2", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const db2Result = event.target.result; + const store3 = db2Result.createObjectStore("obj3", { keyPath: "id3" }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(db2Result); + } + }; + }); + // Prevents AbortError during close() + await new Promise(done => { + request.onsuccess = done; + }); + db2.close(); + + console.log("added cookies and stuff from main page"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = async function () { + await idbGenerator(); +}; + +window.clear = async function () { + document.cookie = "c1=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "c3=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + document.cookie = "cs2=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + + localStorage.clear(); + + await deleteDB("idb1"); + await deleteDB("idb2"); + + dump("removed cookies, localStorage and indexedDB data from " + + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-helpers.js b/devtools/server/tests/browser/storage-helpers.js new file mode 100644 index 0000000000..8c3840a1d2 --- /dev/null +++ b/devtools/server/tests/browser/storage-helpers.js @@ -0,0 +1,57 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +// This file assumes head.js is loaded in the global scope. +/* import-globals-from head.js */ + +/* exported openTabAndSetupStorage, clearStorage */ + +"use strict"; + +const LEGACY_ACTORS_PREF = "devtools.storage.test.forceLegacyActors"; + +/** + * This generator function opens the given url in a new tab, then sets up the + * page by waiting for all cookies, indexedDB items etc. to be created. + * + * @param url {String} The url to be opened in the new tab + * + * @return {Promise} A promise that resolves after storage inspector is ready + */ +async function openTabAndSetupStorage(url) { + // Enable testing prefs + SpecialPowers.pushPrefEnv({ + set: [[LEGACY_ACTORS_PREF, true]], + }); + + await addTab(url); + + // Setup the async storages in main window and for all its iframes + const browsingContexts = + gBrowser.selectedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + for (const browsingContext of browsingContexts) { + await SpecialPowers.spawn(browsingContext, [], async function () { + if (content.wrappedJSObject.setup) { + await content.wrappedJSObject.setup(); + } + }); + } + + // selected tab is set in addTab + const commands = await CommandsFactory.forTab(gBrowser.selectedTab); + await commands.targetCommand.startListening(); + const target = commands.targetCommand.targetFront; + return { commands, target }; +} + +async function clearStorage() { + const browsingContexts = + gBrowser.selectedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + for (const browsingContext of browsingContexts) { + await SpecialPowers.spawn(browsingContext, [], async function () { + if (content.wrappedJSObject.clear) { + await content.wrappedJSObject.clear(); + } + }); + } +} diff --git a/devtools/server/tests/browser/storage-listings.html b/devtools/server/tests/browser/storage-listings.html new file mode 100644 index 0000000000..98ac182bd0 --- /dev/null +++ b/devtools/server/tests/browser/storage-listings.html @@ -0,0 +1,123 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 965872 - Storage inspector actor with cookies, local storage and session storage. +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector test for listing hosts and storages</title> +</head> +<body> +<iframe src="http://sectest1.example.org/browser/devtools/server/tests/browser/storage-unsecured-iframe.html"></iframe> +<iframe src="https://sectest1.example.org:443/browser/devtools/server/tests/browser/storage-secured-iframe.html"></iframe> +<script type="application/javascript"> +"use strict"; +const partialHostname = location.hostname.match(/^[^.]+(\..*)$/)[1]; +const cookieExpiresTime1 = 2000000000000; +const cookieExpiresTime2 = 2000000001000; +// Setting up some cookies to eat. +document.cookie = "c1=foobar; expires=" + + new Date(cookieExpiresTime1).toGMTString() + "; path=/browser"; +document.cookie = "cs2=sessionCookie; path=/; domain=" + partialHostname; +document.cookie = "c3=foobar-2; secure=true; expires=" + + new Date(cookieExpiresTime2).toGMTString() + "; path=/"; +// ... and some local storage items .. +localStorage.setItem("ls1", "foobar"); +localStorage.setItem("ls2", "foobar-2"); +// ... and finally some session storage items too +sessionStorage.setItem("ss1", "foobar-3"); +console.log("added cookies and stuff from main page"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const dbResult = event.target.result; + const store1 = dbResult.createObjectStore("obj1", { keyPath: "id" }); + store1.createIndex("name", "name", { unique: false }); + store1.createIndex("email", "email", { unique: true }); + dbResult.createObjectStore("obj2", { keyPath: "id2" }); + store1.transaction.oncomplete = () => { + done(dbResult); + }; + }; + }); + + // Prevents AbortError + await new Promise(done => { + request.onsuccess = done; + }); + + const transaction = db.transaction(["obj1", "obj2"], "readwrite"); + const store1 = transaction.objectStore("obj1"); + const store2 = transaction.objectStore("obj2"); + store1.add({id: 1, name: "foo", email: "foo@bar.com"}); + store1.add({id: 2, name: "foo2", email: "foo2@bar.com"}); + store1.add({id: 3, name: "foo2", email: "foo3@bar.com"}); + store2.add({ + id2: 1, + name: "foo", + email: "foo@bar.com", + extra: "baz" + }); + // Prevents AbortError during close() + await new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb2", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const db2Result = event.target.result; + const store3 = db2Result.createObjectStore("obj3", { keyPath: "id3" }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(db2Result); + } + }; + }); + // Prevents AbortError during close() + await new Promise(done => { + request.onsuccess = done; + }); + db2.close(); + + dump("added cookies and stuff from main page\n"); +}; + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = async function () { + await idbGenerator(); +}; + +window.clear = async function () { + document.cookie = "c1=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/browser"; + document.cookie = + "c3=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; secure=true"; + document.cookie = + "cs2=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; domain=" + + partialHostname; + + localStorage.clear(); + sessionStorage.clear(); + + await deleteDB("idb1"); + await deleteDB("idb2"); + + dump("removed cookies, localStorage, sessionStorage and indexedDB data " + + "from " + document.location + "\n"); +}; +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-secured-iframe.html b/devtools/server/tests/browser/storage-secured-iframe.html new file mode 100644 index 0000000000..c2fe4ed485 --- /dev/null +++ b/devtools/server/tests/browser/storage-secured-iframe.html @@ -0,0 +1,94 @@ +<!DOCTYPE HTML> +<html> +<!-- +Iframe for testing multiple host detetion in storage actor +--> +<head> + <meta charset="utf-8"> +</head> +<body> +<script type="application/javascript"> +"use strict"; +document.cookie = "sc1=foobar;"; +localStorage.setItem("iframe-s-ls1", "foobar"); +sessionStorage.setItem("iframe-s-ss1", "foobar-2"); + +const idbGenerator = async function () { + let request = indexedDB.open("idb-s1", 1); + request.onerror = function() { + throw new Error("error opening db connection"); + }; + const db = await new Promise(done => { + request.onupgradeneeded = event => { + const dbResult = event.target.result; + const store1 = dbResult.createObjectStore("obj-s1", { keyPath: "id" }); + store1.transaction.oncomplete = () => { + done(dbResult); + }; + }; + }); + await new Promise(done => { + request.onsuccess = done; + }); + + let transaction = db.transaction(["obj-s1"], "readwrite"); + const store1 = transaction.objectStore("obj-s1"); + store1.add({id: 6, name: "foo", email: "foo@bar.com"}); + store1.add({id: 7, name: "foo2", email: "foo2@bar.com"}); + await new Promise(success => { + transaction.oncomplete = success; + }); + + db.close(); + + request = indexedDB.open("idb-s2", 1); + const db2 = await new Promise(done => { + request.onupgradeneeded = event => { + const db2Result = event.target.result; + const store3 = + db2Result.createObjectStore("obj-s2", { keyPath: "id3", autoIncrement: true }); + store3.createIndex("name2", "name2", { unique: true }); + store3.transaction.oncomplete = () => { + done(db2Result); + }; + }; + }); + await new Promise(done => { + request.onsuccess = done; + }); + + transaction = db2.transaction(["obj-s2"], "readwrite"); + const store3 = transaction.objectStore("obj-s2"); + store3.add({id3: 16, name2: "foo", email: "foo@bar.com"}); + await new Promise(success => { + transaction.oncomplete = success; + }); + + db2.close(); + dump("added cookies and stuff from secured iframe\n"); +} + +function deleteDB(dbName) { + return new Promise(resolve => { + dump("removing database " + dbName + " from " + document.location + "\n"); + indexedDB.deleteDatabase(dbName).onsuccess = resolve; + }); +} + +window.setup = async function () { + await idbGenerator(); +}; + +window.clear = async function () { + document.cookie = "sc1=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + + localStorage.clear(); + + await deleteDB("idb-s1"); + await deleteDB("idb-s2"); + + console.log("removed cookies and stuff from secured iframe"); +} +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-unsecured-iframe.html b/devtools/server/tests/browser/storage-unsecured-iframe.html new file mode 100644 index 0000000000..db70c9c692 --- /dev/null +++ b/devtools/server/tests/browser/storage-unsecured-iframe.html @@ -0,0 +1,28 @@ +<!DOCTYPE HTML> +<html> +<!-- +Iframe for testing multiple host detetion in storage actor +--> +<head> + <meta charset="utf-8"> +</head> +<body> +<script> +"use strict"; + +document.cookie = "uc1=foobar; domain=.example.org; path=/; secure=true"; +localStorage.setItem("iframe-u-ls1", "foobar"); +sessionStorage.setItem("iframe-u-ss1", "foobar1"); +sessionStorage.setItem("iframe-u-ss2", "foobar2"); +console.log("added cookies and stuff from unsecured iframe"); + +window.clear = function () { + document.cookie = "uc1=; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + localStorage.clear(); + sessionStorage.clear(); + console.log("removed cookies and stuff from unsecured iframe"); +}; + +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/storage-updates.html b/devtools/server/tests/browser/storage-updates.html new file mode 100644 index 0000000000..594c28ce0f --- /dev/null +++ b/devtools/server/tests/browser/storage-updates.html @@ -0,0 +1,47 @@ +<!DOCTYPE HTML> +<html> +<!-- +Bug 965872 - Storage inspector actor with cookies, local storage and session storage. +--> +<head> + <meta charset="utf-8"> + <title>Storage inspector blank html for tests</title> +</head> +<body> +<script type="application/javascript"> +"use strict"; +window.addCookie = function(name, value, path, domain, expires, secure) { + let cookieString = name + "=" + value + ";"; + if (path) { + cookieString += "path=" + path + ";"; + } + if (domain) { + cookieString += "domain=" + domain + ";"; + } + if (expires) { + cookieString += "expires=" + expires + ";"; + } + if (secure) { + cookieString += "secure=true;"; + } + document.cookie = cookieString; +}; + +window.removeCookie = function(name) { + document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT"; +}; + +window.clearLocalAndSessionStores = function() { + localStorage.clear(); + sessionStorage.clear(); +}; + +window.clearCookies = function() { + const cookies = document.cookie; + for (const cookie of cookies.split(";")) { + window.removeCookie(cookie.split("=")[0]); + } +}; +</script> +</body> +</html> diff --git a/devtools/server/tests/browser/test-errors-actor.js b/devtools/server/tests/browser/test-errors-actor.js new file mode 100644 index 0000000000..e476324be4 --- /dev/null +++ b/devtools/server/tests/browser/test-errors-actor.js @@ -0,0 +1,72 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const protocol = require("resource://devtools/shared/protocol.js"); + +const testErrorsSpec = protocol.generateActorSpec({ + typeName: "testErrors", + + methods: { + throwsComponentsException: { + request: {}, + response: {}, + }, + throwsException: { + request: {}, + response: {}, + }, + throwsJSError: { + request: {}, + response: {}, + }, + throwsString: { + request: {}, + response: {}, + }, + throwsObject: { + request: {}, + response: {}, + }, + }, +}); + +class TestErrorsActor extends protocol.Actor { + constructor(conn) { + super(conn, testErrorsSpec); + } + + throwsComponentsException() { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + throwsException() { + return this.a.b.c; + } + + throwsJSError() { + throw new Error("JSError"); + } + + throwsString() { + // eslint-disable-next-line no-throw-literal + throw "ErrorString"; + } + + throwsObject() { + // eslint-disable-next-line no-throw-literal + throw { + error: "foo", + }; + } +} +exports.TestErrorsActor = TestErrorsActor; + +class TestErrorsFront extends protocol.FrontClassWithSpec(testErrorsSpec) { + constructor(client) { + super(client); + this.formAttributeName = "testErrorsActor"; + } +} +protocol.registerFront(TestErrorsFront); diff --git a/devtools/server/tests/browser/test-window.xhtml b/devtools/server/tests/browser/test-window.xhtml new file mode 100644 index 0000000000..33e70e2dee --- /dev/null +++ b/devtools/server/tests/browser/test-window.xhtml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<xul:window xmlns="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="Test page"> +</xul:window> |