diff options
Diffstat (limited to 'devtools/client/netmonitor/test/head.js')
-rw-r--r-- | devtools/client/netmonitor/test/head.js | 1493 |
1 files changed, 1493 insertions, 0 deletions
diff --git a/devtools/client/netmonitor/test/head.js b/devtools/client/netmonitor/test/head.js new file mode 100644 index 0000000000..d1c3112f02 --- /dev/null +++ b/devtools/client/netmonitor/test/head.js @@ -0,0 +1,1493 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file (head.js) is injected into all other test contexts within + * this directory, allowing one to utilize the functions here in said + * tests without referencing head.js explicitly. + */ + +/* exported Toolbox, restartNetMonitor, teardown, waitForExplicitFinish, + verifyRequestItemTarget, waitFor, waitForDispatch, testFilterButtons, + performRequestsInContent, waitForNetworkEvents, selectIndexAndWaitForSourceEditor, + testColumnsAlignment, hideColumn, showColumn, performRequests, waitForRequestData, + toggleBlockedUrl, registerFaviconNotifier, clickOnSidebarTab */ + +"use strict"; + +// The below file (shared-head.js) handles imports, constants, and +// utility functions, and is loaded into this context. +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/devtools/client/shared/test/shared-head.js", + this +); + +const { LinkHandlerParent } = ChromeUtils.importESModule( + "resource:///actors/LinkHandlerParent.sys.mjs" +); + +const { + getFormattedIPAndPort, + getFormattedTime, +} = require("resource://devtools/client/netmonitor/src/utils/format-utils.js"); + +const { + getSortedRequests, + getRequestById, +} = require("resource://devtools/client/netmonitor/src/selectors/index.js"); + +const { + getUnicodeUrl, + getUnicodeHostname, +} = require("resource://devtools/client/shared/unicode-url.js"); +const { + getFormattedProtocol, + getUrlHost, + getUrlScheme, +} = require("resource://devtools/client/netmonitor/src/utils/request-utils.js"); +const { + EVENTS, + TEST_EVENTS, +} = require("resource://devtools/client/netmonitor/src/constants.js"); +const { + L10N, +} = require("resource://devtools/client/netmonitor/src/utils/l10n.js"); + +/* eslint-disable no-unused-vars, max-len */ +const EXAMPLE_URL = + "http://example.com/browser/devtools/client/netmonitor/test/"; +const EXAMPLE_ORG_URL = + "http://example.org/browser/devtools/client/netmonitor/test/"; +const HTTPS_EXAMPLE_URL = + "https://example.com/browser/devtools/client/netmonitor/test/"; +const HTTPS_EXAMPLE_ORG_URL = + "https://example.org/browser/devtools/client/netmonitor/test/"; +/* Since the test server will proxy `ws://example.com` to websocket server on 9988, +so we must sepecify the port explicitly */ +const WS_URL = "ws://127.0.0.1:8888/browser/devtools/client/netmonitor/test/"; +const WS_HTTP_URL = + "http://127.0.0.1:8888/browser/devtools/client/netmonitor/test/"; + +const WS_BASE_URL = + "http://mochi.test:8888/browser/devtools/client/netmonitor/test/"; +const WS_PAGE_URL = WS_BASE_URL + "html_ws-test-page.html"; +const WS_PAGE_EARLY_CONNECTION_URL = + WS_BASE_URL + "html_ws-early-connection-page.html"; +const API_CALLS_URL = HTTPS_EXAMPLE_URL + "html_api-calls-test-page.html"; +const SIMPLE_URL = EXAMPLE_URL + "html_simple-test-page.html"; +const HTTPS_SIMPLE_URL = HTTPS_EXAMPLE_URL + "html_simple-test-page.html"; +const NAVIGATE_URL = EXAMPLE_URL + "html_navigate-test-page.html"; +const CONTENT_TYPE_WITHOUT_CACHE_URL = + EXAMPLE_URL + "html_content-type-without-cache-test-page.html"; +const CONTENT_TYPE_WITHOUT_CACHE_REQUESTS = 8; +const CYRILLIC_URL = EXAMPLE_URL + "html_cyrillic-test-page.html"; +const STATUS_CODES_URL = EXAMPLE_URL + "html_status-codes-test-page.html"; +const HTTPS_STATUS_CODES_URL = + HTTPS_EXAMPLE_URL + "html_status-codes-test-page.html"; +const POST_DATA_URL = EXAMPLE_URL + "html_post-data-test-page.html"; +const POST_ARRAY_DATA_URL = EXAMPLE_URL + "html_post-array-data-test-page.html"; +const POST_JSON_URL = EXAMPLE_URL + "html_post-json-test-page.html"; +const POST_RAW_URL = EXAMPLE_URL + "html_post-raw-test-page.html"; +const POST_RAW_URL_WITH_HASH = EXAMPLE_URL + "html_header-test-page.html"; +const POST_RAW_WITH_HEADERS_URL = + EXAMPLE_URL + "html_post-raw-with-headers-test-page.html"; +const PARAMS_URL = EXAMPLE_URL + "html_params-test-page.html"; +const JSONP_URL = EXAMPLE_URL + "html_jsonp-test-page.html"; +const JSON_LONG_URL = EXAMPLE_URL + "html_json-long-test-page.html"; +const JSON_MALFORMED_URL = EXAMPLE_URL + "html_json-malformed-test-page.html"; +const JSON_CUSTOM_MIME_URL = + EXAMPLE_URL + "html_json-custom-mime-test-page.html"; +const JSON_TEXT_MIME_URL = EXAMPLE_URL + "html_json-text-mime-test-page.html"; +const JSON_B64_URL = EXAMPLE_URL + "html_json-b64.html"; +const JSON_BASIC_URL = EXAMPLE_URL + "html_json-basic.html"; +const JSON_EMPTY_URL = EXAMPLE_URL + "html_json-empty.html"; +const JSON_XSSI_PROTECTION_URL = EXAMPLE_URL + "html_json-xssi-protection.html"; +const FONTS_URL = EXAMPLE_URL + "html_fonts-test-page.html"; +const SORTING_URL = EXAMPLE_URL + "html_sorting-test-page.html"; +const FILTERING_URL = EXAMPLE_URL + "html_filter-test-page.html"; +const HTTPS_FILTERING_URL = HTTPS_EXAMPLE_URL + "html_filter-test-page.html"; +const INFINITE_GET_URL = EXAMPLE_URL + "html_infinite-get-page.html"; +const CUSTOM_GET_URL = EXAMPLE_URL + "html_custom-get-page.html"; +const HTTPS_CUSTOM_GET_URL = HTTPS_EXAMPLE_URL + "html_custom-get-page.html"; +const SINGLE_GET_URL = EXAMPLE_URL + "html_single-get-page.html"; +const HTTPS_SINGLE_GET_URL = HTTPS_EXAMPLE_URL + "html_single-get-page.html"; +const STATISTICS_URL = EXAMPLE_URL + "html_statistics-test-page.html"; +const STATISTICS_EDGE_CASE_URL = + EXAMPLE_URL + "html_statistics-edge-case-page.html"; +const CURL_URL = EXAMPLE_URL + "html_copy-as-curl.html"; +const HTTPS_CURL_URL = HTTPS_EXAMPLE_URL + "html_copy-as-curl.html"; +const HTTPS_CURL_UTILS_URL = HTTPS_EXAMPLE_URL + "html_curl-utils.html"; +const SEND_BEACON_URL = EXAMPLE_URL + "html_send-beacon.html"; +const CORS_URL = EXAMPLE_URL + "html_cors-test-page.html"; +const HTTPS_CORS_URL = HTTPS_EXAMPLE_URL + "html_cors-test-page.html"; +const PAUSE_URL = EXAMPLE_URL + "html_pause-test-page.html"; +const OPEN_REQUEST_IN_TAB_URL = EXAMPLE_URL + "html_open-request-in-tab.html"; +const CSP_URL = EXAMPLE_URL + "html_csp-test-page.html"; +const CSP_RESEND_URL = EXAMPLE_URL + "html_csp-resend-test-page.html"; +const IMAGE_CACHE_URL = HTTPS_EXAMPLE_URL + "html_image-cache.html"; +const SLOW_REQUESTS_URL = EXAMPLE_URL + "html_slow-requests-test-page.html"; + +const SIMPLE_SJS = EXAMPLE_URL + "sjs_simple-test-server.sjs"; +const HTTPS_SIMPLE_SJS = HTTPS_EXAMPLE_URL + "sjs_simple-test-server.sjs"; +const SIMPLE_UNSORTED_COOKIES_SJS = + EXAMPLE_URL + "sjs_simple-unsorted-cookies-test-server.sjs"; +const CONTENT_TYPE_SJS = EXAMPLE_URL + "sjs_content-type-test-server.sjs"; +const WS_CONTENT_TYPE_SJS = WS_HTTP_URL + "sjs_content-type-test-server.sjs"; +const WS_WS_CONTENT_TYPE_SJS = WS_URL + "sjs_content-type-test-server.sjs"; +const HTTPS_CONTENT_TYPE_SJS = + HTTPS_EXAMPLE_URL + "sjs_content-type-test-server.sjs"; +const SERVER_TIMINGS_TYPE_SJS = + HTTPS_EXAMPLE_URL + "sjs_timings-test-server.sjs"; +const STATUS_CODES_SJS = EXAMPLE_URL + "sjs_status-codes-test-server.sjs"; +const SORTING_SJS = EXAMPLE_URL + "sjs_sorting-test-server.sjs"; +const HTTPS_REDIRECT_SJS = EXAMPLE_URL + "sjs_https-redirect-test-server.sjs"; +const CORS_SJS_PATH = + "/browser/devtools/client/netmonitor/test/sjs_cors-test-server.sjs"; +const HSTS_SJS = EXAMPLE_URL + "sjs_hsts-test-server.sjs"; +const METHOD_SJS = EXAMPLE_URL + "sjs_method-test-server.sjs"; +const HTTPS_SLOW_SJS = HTTPS_EXAMPLE_URL + "sjs_slow-test-server.sjs"; +const SET_COOKIE_SAME_SITE_SJS = EXAMPLE_URL + "sjs_set-cookie-same-site.sjs"; +const SEARCH_SJS = EXAMPLE_URL + "sjs_search-test-server.sjs"; +const HTTPS_SEARCH_SJS = HTTPS_EXAMPLE_URL + "sjs_search-test-server.sjs"; + +const HSTS_BASE_URL = EXAMPLE_URL; +const HSTS_PAGE_URL = CUSTOM_GET_URL; + +const TEST_IMAGE = EXAMPLE_URL + "test-image.png"; +const TEST_IMAGE_DATA_URI = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; + +const SETTINGS_MENU_ITEMS = { + "persist-logs": ".netmonitor-settings-persist-item", + "import-har": ".netmonitor-settings-import-har-item", + "save-har": ".netmonitor-settings-import-save-item", + "copy-har": ".netmonitor-settings-import-copy-item", +}; + +/* eslint-enable no-unused-vars, max-len */ + +// All tests are asynchronous. +waitForExplicitFinish(); + +const gEnableLogging = Services.prefs.getBoolPref("devtools.debugger.log"); +// To enable logging for try runs, just set the pref to true. +Services.prefs.setBoolPref("devtools.debugger.log", false); + +// Uncomment this pref to dump all devtools emitted events to the console. +// Services.prefs.setBoolPref("devtools.dump.emit", true); + +// Always reset some prefs to their original values after the test finishes. +const gDefaultFilters = Services.prefs.getCharPref( + "devtools.netmonitor.filters" +); + +// Reveal many columns for test +Services.prefs.setCharPref( + "devtools.netmonitor.visibleColumns", + '["initiator","contentSize","cookies","domain","duration",' + + '"endTime","file","url","latency","method","protocol",' + + '"remoteip","responseTime","scheme","setCookies",' + + '"startTime","status","transferred","type","waterfall"]' +); + +Services.prefs.setCharPref( + "devtools.netmonitor.columnsData", + '[{"name":"status","minWidth":30,"width":5},' + + '{"name":"method","minWidth":30,"width":5},' + + '{"name":"domain","minWidth":30,"width":10},' + + '{"name":"file","minWidth":30,"width":25},' + + '{"name":"url","minWidth":30,"width":25},' + + '{"name":"initiator","minWidth":30,"width":20},' + + '{"name":"type","minWidth":30,"width":5},' + + '{"name":"transferred","minWidth":30,"width":10},' + + '{"name":"contentSize","minWidth":30,"width":5},' + + '{"name":"waterfall","minWidth":150,"width":15}]' +); + +registerCleanupFunction(() => { + info("finish() was called, cleaning up..."); + + Services.prefs.setBoolPref("devtools.debugger.log", gEnableLogging); + Services.prefs.setCharPref("devtools.netmonitor.filters", gDefaultFilters); + Services.prefs.clearUserPref("devtools.cache.disabled"); + Services.prefs.clearUserPref("devtools.netmonitor.columnsData"); + Services.prefs.clearUserPref("devtools.netmonitor.visibleColumns"); + Services.cookies.removeAll(); +}); + +async function disableCacheAndReload(toolbox, waitForLoad) { + // Disable the cache for any toolbox that it is opened from this point on. + Services.prefs.setBoolPref("devtools.cache.disabled", true); + + await toolbox.commands.targetConfigurationCommand.updateConfiguration({ + cacheDisabled: true, + }); + + // If the page which is reloaded is not found, this will likely cause + // reloadTopLevelTarget to not return so let not wait for it. + if (waitForLoad) { + await toolbox.commands.targetCommand.reloadTopLevelTarget(); + } else { + toolbox.commands.targetCommand.reloadTopLevelTarget(); + } +} + +/** + * Wait for 2 markers during document load. + */ +function waitForTimelineMarkers(monitor) { + return new Promise(resolve => { + const markers = []; + + function handleTimelineEvent(marker) { + info(`Got marker: ${marker.name}`); + markers.push(marker); + if (markers.length == 2) { + monitor.panelWin.api.off( + TEST_EVENTS.TIMELINE_EVENT, + handleTimelineEvent + ); + info("Got two timeline markers, done waiting"); + resolve(markers); + } + } + + monitor.panelWin.api.on(TEST_EVENTS.TIMELINE_EVENT, handleTimelineEvent); + }); +} + +let finishedQueue = {}; +const updatingTypes = [ + "NetMonitor:NetworkEventUpdating:RequestCookies", + "NetMonitor:NetworkEventUpdating:ResponseCookies", + "NetMonitor:NetworkEventUpdating:RequestHeaders", + "NetMonitor:NetworkEventUpdating:ResponseHeaders", + "NetMonitor:NetworkEventUpdating:RequestPostData", + "NetMonitor:NetworkEventUpdating:ResponseContent", + "NetMonitor:NetworkEventUpdating:SecurityInfo", + "NetMonitor:NetworkEventUpdating:EventTimings", +]; +const updatedTypes = [ + "NetMonitor:NetworkEventUpdated:RequestCookies", + "NetMonitor:NetworkEventUpdated:ResponseCookies", + "NetMonitor:NetworkEventUpdated:RequestHeaders", + "NetMonitor:NetworkEventUpdated:ResponseHeaders", + "NetMonitor:NetworkEventUpdated:RequestPostData", + "NetMonitor:NetworkEventUpdated:ResponseContent", + "NetMonitor:NetworkEventUpdated:SecurityInfo", + "NetMonitor:NetworkEventUpdated:EventTimings", +]; + +// Start collecting all networkEventUpdate event when panel is opened. +// removeTab() should be called once all corresponded RECEIVED_* events finished. +function startNetworkEventUpdateObserver(panelWin) { + updatingTypes.forEach(type => + panelWin.api.on(type, actor => { + const key = actor + "-" + updatedTypes[updatingTypes.indexOf(type)]; + finishedQueue[key] = finishedQueue[key] ? finishedQueue[key] + 1 : 1; + }) + ); + + updatedTypes.forEach(type => + panelWin.api.on(type, payload => { + const key = payload.from + "-" + type; + finishedQueue[key] = finishedQueue[key] ? finishedQueue[key] - 1 : -1; + }) + ); + + panelWin.api.on("clear-network-resources", () => { + finishedQueue = {}; + }); +} + +async function waitForAllNetworkUpdateEvents() { + function checkNetworkEventUpdateState() { + for (const key in finishedQueue) { + if (finishedQueue[key] > 0) { + return false; + } + } + return true; + } + info("Wait for completion of all NetworkUpdateEvents packets..."); + await waitUntil(() => checkNetworkEventUpdateState()); + finishedQueue = {}; +} + +function initNetMonitor( + url, + { + requestCount, + expectedEventTimings, + waitForLoad = true, + enableCache = false, + } +) { + info("Initializing a network monitor pane."); + + if (!requestCount && !enableCache) { + ok( + false, + "initNetMonitor should be given a number of requests the page will perform" + ); + } + + return (async function () { + await SpecialPowers.pushPrefEnv({ + set: [ + // Capture all stacks so that the timing of devtools opening + // doesn't affect the stack trace results. + ["javascript.options.asyncstack_capture_debuggee_only", false], + ], + }); + + const tab = await addTab(url, { waitForLoad }); + info("Net tab added successfully: " + url); + + const toolbox = await gDevTools.showToolboxForTab(tab, { + toolId: "netmonitor", + }); + info("Network monitor pane shown successfully."); + + const monitor = toolbox.getCurrentPanel(); + + startNetworkEventUpdateObserver(monitor.panelWin); + + if (!enableCache) { + info("Disabling cache and reloading page."); + + const allComplete = []; + allComplete.push( + waitForNetworkEvents(monitor, requestCount, { + expectedEventTimings, + }) + ); + + if (waitForLoad) { + allComplete.push(waitForTimelineMarkers(monitor)); + } + await disableCacheAndReload(toolbox, waitForLoad); + await Promise.all(allComplete); + await clearNetworkEvents(monitor); + } + + return { tab, monitor, toolbox }; + })(); +} + +function restartNetMonitor(monitor, { requestCount }) { + info("Restarting the specified network monitor."); + + return (async function () { + const tab = monitor.commands.descriptorFront.localTab; + const url = tab.linkedBrowser.currentURI.spec; + + await waitForAllNetworkUpdateEvents(); + info("All pending requests finished."); + + const onDestroyed = monitor.once("destroyed"); + await removeTab(tab); + await onDestroyed; + + return initNetMonitor(url, { requestCount }); + })(); +} + +/** + * Clears the network requests in the UI + * @param {Object} monitor + * The netmonitor instance used for retrieving a context menu element. + */ +async function clearNetworkEvents(monitor) { + const { store, windowRequire } = monitor.panelWin; + const Actions = windowRequire("devtools/client/netmonitor/src/actions/index"); + + await waitForAllNetworkUpdateEvents(); + + info("Clearing the network requests in the UI"); + store.dispatch(Actions.clearRequests()); +} + +function teardown(monitor) { + info("Destroying the specified network monitor."); + + return (async function () { + const tab = monitor.commands.descriptorFront.localTab; + + await waitForAllNetworkUpdateEvents(); + info("All pending requests finished."); + + await monitor.toolbox.destroy(); + await removeTab(tab); + })(); +} + +/** + * Wait for the request(s) to be fully notified to the frontend. + * + * @param {Object} monitor + * The netmonitor instance used for retrieving a context menu element. + * @param {Number} getRequests + * The number of request to wait for + * @param {Object} options (optional) + * - expectedEventTimings {Number} Number of EVENT_TIMINGS events to wait for. + * In case of filtering, we get less of such events. + */ +function waitForNetworkEvents(monitor, getRequests, options = {}) { + return new Promise(resolve => { + const panel = monitor.panelWin; + let networkEvent = 0; + let nonBlockedNetworkEvent = 0; + let payloadReady = 0; + let eventTimings = 0; + + function onNetworkEvent(resource) { + networkEvent++; + if (!resource.blockedReason) { + nonBlockedNetworkEvent++; + } + maybeResolve(TEST_EVENTS.NETWORK_EVENT, resource.actor); + } + + function onPayloadReady(resource) { + payloadReady++; + maybeResolve(EVENTS.PAYLOAD_READY, resource.actor); + } + + function onEventTimings(response) { + eventTimings++; + maybeResolve(EVENTS.RECEIVED_EVENT_TIMINGS, response.from); + } + + function onClearNetworkResources() { + // Reset all counters. + networkEvent = 0; + nonBlockedNetworkEvent = 0; + payloadReady = 0; + eventTimings = 0; + } + + function maybeResolve(event, actor) { + const { document } = monitor.panelWin; + // Wait until networkEvent, payloadReady and event timings finish for each request. + // The UI won't fetch timings when: + // * hidden in background, + // * for any blocked request, + let expectedEventTimings = + document.visibilityState == "hidden" ? 0 : nonBlockedNetworkEvent; + let expectedPayloadReady = getRequests; + // Typically ignore this option if it is undefined or null + if (typeof options?.expectedEventTimings == "number") { + expectedEventTimings = options.expectedEventTimings; + } + if (typeof options?.expectedPayloadReady == "number") { + expectedPayloadReady = options.expectedPayloadReady; + } + info( + "> Network event progress: " + + "NetworkEvent: " + + networkEvent + + "/" + + getRequests + + ", " + + "PayloadReady: " + + payloadReady + + "/" + + expectedPayloadReady + + ", " + + "EventTimings: " + + eventTimings + + "/" + + expectedEventTimings + + ", " + + "got " + + event + + " for " + + actor + ); + + if ( + networkEvent >= getRequests && + payloadReady >= expectedPayloadReady && + eventTimings >= expectedEventTimings + ) { + panel.api.off(TEST_EVENTS.NETWORK_EVENT, onNetworkEvent); + panel.api.off(EVENTS.PAYLOAD_READY, onPayloadReady); + panel.api.off(EVENTS.RECEIVED_EVENT_TIMINGS, onEventTimings); + panel.api.off("clear-network-resources", onClearNetworkResources); + executeSoon(resolve); + } + } + + panel.api.on(TEST_EVENTS.NETWORK_EVENT, onNetworkEvent); + panel.api.on(EVENTS.PAYLOAD_READY, onPayloadReady); + panel.api.on(EVENTS.RECEIVED_EVENT_TIMINGS, onEventTimings); + panel.api.on("clear-network-resources", onClearNetworkResources); + }); +} + +function verifyRequestItemTarget( + document, + requestList, + requestItem, + method, + url, + data = {} +) { + info("> Verifying: " + method + " " + url + " " + data.toSource()); + + const visibleIndex = requestList.findIndex( + needle => needle.id === requestItem.id + ); + + isnot(visibleIndex, -1, "The requestItem exists"); + info("Visible index of item: " + visibleIndex); + + const { + fuzzyUrl, + status, + statusText, + cause, + type, + fullMimeType, + transferred, + size, + time, + displayedStatus, + } = data; + + const target = document.querySelectorAll(".request-list-item")[visibleIndex]; + + // Bug 1414981 - Request URL should not show #hash + const unicodeUrl = getUnicodeUrl(url.split("#")[0]); + const ORIGINAL_FILE_URL = L10N.getFormatStr( + "netRequest.originalFileURL.tooltip", + url + ); + const DECODED_FILE_URL = L10N.getFormatStr( + "netRequest.decodedFileURL.tooltip", + unicodeUrl + ); + const fileToolTip = + url === unicodeUrl ? url : ORIGINAL_FILE_URL + "\n\n" + DECODED_FILE_URL; + const requestedFile = requestItem.urlDetails.baseNameWithQuery; + const host = getUnicodeHostname(getUrlHost(url)); + const scheme = getUrlScheme(url); + const { + remoteAddress, + remotePort, + totalTime, + eventTimings = { timings: {} }, + } = requestItem; + const formattedIPPort = getFormattedIPAndPort(remoteAddress, remotePort); + const remoteIP = remoteAddress ? `${formattedIPPort}` : "unknown"; + const duration = getFormattedTime(totalTime); + const latency = getFormattedTime(eventTimings.timings.wait); + const protocol = getFormattedProtocol(requestItem); + + if (fuzzyUrl) { + ok( + requestItem.method.startsWith(method), + "The attached method is correct." + ); + ok(requestItem.url.startsWith(url), "The attached url is correct."); + } else { + is(requestItem.method, method, "The attached method is correct."); + is(requestItem.url, url.split("#")[0], "The attached url is correct."); + } + + is( + target.querySelector(".requests-list-method").textContent, + method, + "The displayed method is correct." + ); + + if (fuzzyUrl) { + ok( + target + .querySelector(".requests-list-file") + .textContent.startsWith(requestedFile), + "The displayed file is correct." + ); + ok( + target + .querySelector(".requests-list-file") + .getAttribute("title") + .startsWith(fileToolTip), + "The tooltip file is correct." + ); + } else { + is( + target.querySelector(".requests-list-file").textContent, + requestedFile, + "The displayed file is correct." + ); + is( + target.querySelector(".requests-list-file").getAttribute("title"), + fileToolTip, + "The tooltip file is correct." + ); + } + + is( + target.querySelector(".requests-list-protocol").textContent, + protocol, + "The displayed protocol is correct." + ); + + is( + target.querySelector(".requests-list-protocol").getAttribute("title"), + protocol, + "The tooltip protocol is correct." + ); + + is( + target.querySelector(".requests-list-domain").textContent, + host, + "The displayed domain is correct." + ); + + const domainTooltip = + host + (remoteAddress ? " (" + formattedIPPort + ")" : ""); + is( + target.querySelector(".requests-list-domain").getAttribute("title"), + domainTooltip, + "The tooltip domain is correct." + ); + + is( + target.querySelector(".requests-list-remoteip").textContent, + remoteIP, + "The displayed remote IP is correct." + ); + + is( + target.querySelector(".requests-list-remoteip").getAttribute("title"), + remoteIP, + "The tooltip remote IP is correct." + ); + + is( + target.querySelector(".requests-list-scheme").textContent, + scheme, + "The displayed scheme is correct." + ); + + is( + target.querySelector(".requests-list-scheme").getAttribute("title"), + scheme, + "The tooltip scheme is correct." + ); + + is( + target.querySelector(".requests-list-duration-time").textContent, + duration, + "The displayed duration is correct." + ); + + is( + target.querySelector(".requests-list-duration-time").getAttribute("title"), + duration, + "The tooltip duration is correct." + ); + + is( + target.querySelector(".requests-list-latency-time").textContent, + latency, + "The displayed latency is correct." + ); + + is( + target.querySelector(".requests-list-latency-time").getAttribute("title"), + latency, + "The tooltip latency is correct." + ); + + if (status !== undefined) { + const value = target + .querySelector(".requests-list-status-code") + .getAttribute("data-status-code"); + const codeValue = target.querySelector( + ".requests-list-status-code" + ).textContent; + const tooltip = target + .querySelector(".requests-list-status-code") + .getAttribute("title"); + info("Displayed status: " + value); + info("Displayed code: " + codeValue); + info("Tooltip status: " + tooltip); + is( + `${value}`, + displayedStatus ? `${displayedStatus}` : `${status}`, + "The displayed status is correct." + ); + is(`${codeValue}`, `${status}`, "The displayed status code is correct."); + is(tooltip, status + " " + statusText, "The tooltip status is correct."); + } + if (cause !== undefined) { + const value = Array.from( + target.querySelector(".requests-list-initiator").childNodes + ) + .filter(node => node.nodeType === Node.ELEMENT_NODE) + .map(({ textContent }) => textContent) + .join(""); + const tooltip = target + .querySelector(".requests-list-initiator") + .getAttribute("title"); + info("Displayed cause: " + value); + info("Tooltip cause: " + tooltip); + ok(value.includes(cause.type), "The displayed cause is correct."); + ok(tooltip.includes(cause.type), "The tooltip cause is correct."); + } + if (type !== undefined) { + const value = target.querySelector(".requests-list-type").textContent; + let tooltip = target + .querySelector(".requests-list-type") + .getAttribute("title"); + info("Displayed type: " + value); + info("Tooltip type: " + tooltip); + is(value, type, "The displayed type is correct."); + if (Object.is(tooltip, null)) { + tooltip = undefined; + } + is(tooltip, fullMimeType, "The tooltip type is correct."); + } + if (transferred !== undefined) { + const value = target.querySelector( + ".requests-list-transferred" + ).textContent; + const tooltip = target + .querySelector(".requests-list-transferred") + .getAttribute("title"); + info("Displayed transferred size: " + value); + info("Tooltip transferred size: " + tooltip); + is(value, transferred, "The displayed transferred size is correct."); + is(tooltip, transferred, "The tooltip transferred size is correct."); + } + if (size !== undefined) { + const value = target.querySelector(".requests-list-size").textContent; + const tooltip = target + .querySelector(".requests-list-size") + .getAttribute("title"); + info("Displayed size: " + value); + info("Tooltip size: " + tooltip); + is(value, size, "The displayed size is correct."); + is(tooltip, size, "The tooltip size is correct."); + } + if (time !== undefined) { + const value = target.querySelector( + ".requests-list-timings-total" + ).textContent; + const tooltip = target + .querySelector(".requests-list-timings-total") + .getAttribute("title"); + info("Displayed time: " + value); + info("Tooltip time: " + tooltip); + ok(~~value.match(/[0-9]+/) >= 0, "The displayed time is correct."); + ok(~~tooltip.match(/[0-9]+/) >= 0, "The tooltip time is correct."); + } + + if (visibleIndex !== -1) { + if (visibleIndex % 2 === 0) { + ok(target.classList.contains("even"), "Item should have 'even' class."); + ok(!target.classList.contains("odd"), "Item shouldn't have 'odd' class."); + } else { + ok( + !target.classList.contains("even"), + "Item shouldn't have 'even' class." + ); + ok(target.classList.contains("odd"), "Item should have 'odd' class."); + } + } +} + +/** + * Tests if a button for a filter of given type is the only one checked. + * + * @param string filterType + * The type of the filter that should be the only one checked. + */ +function testFilterButtons(monitor, filterType) { + const doc = monitor.panelWin.document; + const target = doc.querySelector( + ".requests-list-filter-" + filterType + "-button" + ); + ok(target, `Filter button '${filterType}' was found`); + const buttons = [ + ...doc.querySelectorAll(".requests-list-filter-buttons button"), + ]; + ok(!!buttons.length, "More than zero filter buttons were found"); + + // Only target should be checked. + const checkStatus = buttons.map(button => (button == target ? 1 : 0)); + testFilterButtonsCustom(monitor, checkStatus); +} + +/** + * Tests if filter buttons have 'checked' attributes set correctly. + * + * @param array aIsChecked + * An array specifying if a button at given index should have a + * 'checked' attribute. For example, if the third item of the array + * evaluates to true, the third button should be checked. + */ +function testFilterButtonsCustom(monitor, isChecked) { + const doc = monitor.panelWin.document; + const buttons = doc.querySelectorAll(".requests-list-filter-buttons button"); + for (let i = 0; i < isChecked.length; i++) { + const button = buttons[i]; + if (isChecked[i]) { + is( + button.getAttribute("aria-pressed"), + "true", + "The " + button.id + " button should set 'aria-pressed' = true." + ); + } else { + is( + button.getAttribute("aria-pressed"), + "false", + "The " + button.id + " button should set 'aria-pressed' = false." + ); + } + } +} + +/** + * Performs a single XMLHttpRequest and returns a promise that resolves once + * the request has loaded. + * + * @param Object data + * { method: the request method (default: "GET"), + * url: the url to request (default: content.location.href), + * body: the request body to send (default: ""), + * nocache: append an unique token to the query string (default: true), + * requestHeaders: set request headers (default: none) + * } + * + * @return Promise A promise that's resolved with object + * { status: XMLHttpRequest.status, + * response: XMLHttpRequest.response } + * + */ +function promiseXHR(data) { + return new Promise((resolve, reject) => { + const xhr = new content.XMLHttpRequest(); + + const method = data.method || "GET"; + let url = data.url || content.location.href; + const body = data.body || ""; + + if (data.nocache) { + url += "?devtools-cachebust=" + Math.random(); + } + + xhr.addEventListener( + "loadend", + function (event) { + resolve({ status: xhr.status, response: xhr.response }); + }, + { once: true } + ); + + xhr.open(method, url); + + // Set request headers + if (data.requestHeaders) { + data.requestHeaders.forEach(header => { + xhr.setRequestHeader(header.name, header.value); + }); + } + + xhr.send(body); + }); +} + +/** + * Performs a single websocket request and returns a promise that resolves once + * the request has loaded. + * + * @param Object data + * { url: the url to request (default: content.location.href), + * nocache: append an unique token to the query string (default: true), + * } + * + * @return Promise A promise that's resolved with object + * { status: websocket status(101), + * response: empty string } + * + */ +function promiseWS(data) { + return new Promise((resolve, reject) => { + let url = data.url; + + if (data.nocache) { + url += "?devtools-cachebust=" + Math.random(); + } + + /* Create websocket instance */ + const socket = new content.WebSocket(url); + + /* Since we only use HTTP server to mock websocket, so just ignore the error */ + socket.onclose = e => { + socket.close(); + resolve({ + status: 101, + response: "", + }); + }; + + socket.onerror = e => { + socket.close(); + resolve({ + status: 101, + response: "", + }); + }; + }); +} + +/** + * Perform the specified requests in the context of the page content. + * + * @param Array requests + * An array of objects specifying the requests to perform. See + * shared/test/frame-script-utils.js for more information. + * + * @return A promise that resolves once the requests complete. + */ +async function performRequestsInContent(requests) { + if (!Array.isArray(requests)) { + requests = [requests]; + } + + const responses = []; + + info("Performing requests in the context of the content."); + + for (const request of requests) { + const requestFn = request.ws ? promiseWS : promiseXHR; + const response = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [request], + requestFn + ); + responses.push(response); + } +} + +function testColumnsAlignment(headers, requestList) { + const firstRequestLine = requestList.childNodes[0]; + + // Find number of columns + const numberOfColumns = headers.childElementCount; + for (let i = 0; i < numberOfColumns; i++) { + const headerColumn = headers.childNodes[i]; + const requestColumn = firstRequestLine.childNodes[i]; + is( + headerColumn.getBoundingClientRect().left, + requestColumn.getBoundingClientRect().left, + "Headers for columns number " + i + " are aligned." + ); + } +} + +async function hideColumn(monitor, column) { + const { document } = monitor.panelWin; + + info(`Clicking context-menu item for ${column}`); + EventUtils.sendMouseEvent( + { type: "contextmenu" }, + document.querySelector(".requests-list-headers") + ); + + const onHeaderRemoved = waitForDOM( + document, + `#requests-list-${column}-button`, + 0 + ); + await selectContextMenuItem(monitor, `request-list-header-${column}-toggle`); + await onHeaderRemoved; + + ok( + !document.querySelector(`#requests-list-${column}-button`), + `Column ${column} should be hidden` + ); +} + +async function showColumn(monitor, column) { + const { document } = monitor.panelWin; + + info(`Clicking context-menu item for ${column}`); + EventUtils.sendMouseEvent( + { type: "contextmenu" }, + document.querySelector(".requests-list-headers") + ); + + const onHeaderAdded = waitForDOM( + document, + `#requests-list-${column}-button`, + 1 + ); + await selectContextMenuItem(monitor, `request-list-header-${column}-toggle`); + await onHeaderAdded; + + ok( + document.querySelector(`#requests-list-${column}-button`), + `Column ${column} should be visible` + ); +} + +/** + * Select a request and switch to its response panel. + * + * @param {Number} index The request index to be selected + */ +async function selectIndexAndWaitForSourceEditor(monitor, index) { + const { document } = monitor.panelWin; + const onResponseContent = monitor.panelWin.api.once( + TEST_EVENTS.RECEIVED_RESPONSE_CONTENT + ); + // Select the request first, as it may try to fetch whatever is the current request's + // responseContent if we select the ResponseTab first. + EventUtils.sendMouseEvent( + { type: "mousedown" }, + document.querySelectorAll(".request-list-item")[index] + ); + // We may already be on the ResponseTab, so only select it if needed. + const editor = document.querySelector("#response-panel .CodeMirror-code"); + if (!editor) { + const waitDOM = waitForDOM(document, "#response-panel .CodeMirror-code"); + document.querySelector("#response-tab").click(); + await waitDOM; + } + await onResponseContent; +} + +/** + * Helper function for executing XHRs on a test page. + * + * @param {Number} count Number of requests to be executed. + */ +async function performRequests(monitor, tab, count) { + const wait = waitForNetworkEvents(monitor, count); + await ContentTask.spawn(tab.linkedBrowser, count, requestCount => { + content.wrappedJSObject.performRequests(requestCount); + }); + await wait; +} + +/** + * Helper function for retrieving `.CodeMirror` content + */ +function getCodeMirrorValue(monitor) { + const { document } = monitor.panelWin; + return document.querySelector(".CodeMirror").CodeMirror.getValue(); +} + +/** + * Helper function opening the options menu + */ +function openSettingsMenu(monitor) { + const { document } = monitor.panelWin; + document.querySelector(".netmonitor-settings-menu-button").click(); +} + +function clickSettingsMenuItem(monitor, itemKey) { + openSettingsMenu(monitor); + const node = getSettingsMenuItem(monitor, itemKey); + node.click(); +} + +function getSettingsMenuItem(monitor, itemKey) { + // The settings menu is injected into the toolbox document, + // so we must use the panelWin parent to query for items + const { parent } = monitor.panelWin; + const { document } = parent; + + return document.querySelector(SETTINGS_MENU_ITEMS[itemKey]); +} + +/** + * Wait for lazy fields to be loaded in a request. + * + * @param Object Store redux store containing request list. + * @param array fields array of strings which contain field names to be checked + * on the request. + */ +function waitForRequestData(store, fields, id) { + return waitUntil(() => { + let item; + if (id) { + item = getRequestById(store.getState(), id); + } else { + item = getSortedRequests(store.getState())[0]; + } + if (!item) { + return false; + } + for (const field of fields) { + if (!item[field]) { + return false; + } + } + return true; + }); +} + +// Telemetry + +/** + * Helper for verifying telemetry event. + * + * @param Object expectedEvent object representing expected event data. + * @param Object query fields specifying category, method and object + * of the target telemetry event. + */ +function checkTelemetryEvent(expectedEvent, query) { + const events = queryTelemetryEvents(query); + is(events.length, 1, "There was only 1 event logged"); + + const [event] = events; + ok(event.session_id > 0, "There is a valid session_id in the logged event"); + + const f = e => JSON.stringify(e, null, 2); + is( + f(event), + f({ + ...expectedEvent, + session_id: event.session_id, + }), + "The event has the expected data" + ); +} + +function queryTelemetryEvents(query) { + const ALL_CHANNELS = Ci.nsITelemetry.DATASET_ALL_CHANNELS; + const snapshot = Services.telemetry.snapshotEvents(ALL_CHANNELS, true); + const category = query.category || "devtools.main"; + const object = query.object || "netmonitor"; + + const filtersChangedEvents = snapshot.parent.filter( + event => + event[1] === category && event[2] === query.method && event[3] === object + ); + + // Return the `extra` field (which is event[5]e). + return filtersChangedEvents.map(event => event[5]); +} +/** + * Check that the provided requests match the requests displayed in the netmonitor. + * + * @param {array} requests + * The expected requests. + * @param {object} monitor + * The netmonitor instance. + * @param {object=} options + * @param {boolean} allowDifferentOrder + * When set to true, requests are allowed to be in a different order in the + * netmonitor than in the expected requests array. Defaults to false. + */ +function validateRequests(requests, monitor, options = {}) { + const { allowDifferentOrder } = options; + const { document, store, windowRequire } = monitor.panelWin; + + const { getDisplayedRequests } = windowRequire( + "devtools/client/netmonitor/src/selectors/index" + ); + const sortedRequests = getSortedRequests(store.getState()); + + requests.forEach((spec, i) => { + const { method, url, causeType, causeUri, stack } = spec; + + let requestItem; + if (allowDifferentOrder) { + requestItem = sortedRequests.find(r => r.url === url); + } else { + requestItem = sortedRequests[i]; + } + + verifyRequestItemTarget( + document, + getDisplayedRequests(store.getState()), + requestItem, + method, + url, + { cause: { type: causeType, loadingDocumentUri: causeUri } } + ); + + const { stacktrace } = requestItem; + const stackLen = stacktrace ? stacktrace.length : 0; + + if (stack) { + ok(stacktrace, `Request #${i} has a stacktrace`); + ok( + stackLen > 0, + `Request #${i} (${causeType}) has a stacktrace with ${stackLen} items` + ); + + // if "stack" is array, check the details about the top stack frames + if (Array.isArray(stack)) { + stack.forEach((frame, j) => { + // If the `fn` is "*", it means the request is triggered from chrome + // resources, e.g. `resource:///modules/XX.jsm`, so we skip checking + // the function name for now (bug 1280266). + if (frame.file.startsWith("resource:///")) { + todo(false, "Requests from chrome resource should not be included"); + } else { + let value = stacktrace[j].functionName; + if (Object.is(value, null)) { + value = undefined; + } + is( + value, + frame.fn, + `Request #${i} has the correct function on JS stack frame #${j}` + ); + is( + stacktrace[j].filename.split("/").pop(), + frame.file.split("/").pop(), + `Request #${i} has the correct file on JS stack frame #${j}` + ); + is( + stacktrace[j].lineNumber, + frame.line, + `Request #${i} has the correct line number on JS stack frame #${j}` + ); + value = stacktrace[j].asyncCause; + if (Object.is(value, null)) { + value = undefined; + } + is( + value, + frame.asyncCause, + `Request #${i} has the correct async cause on JS stack frame #${j}` + ); + } + }); + } + } else { + is(stackLen, 0, `Request #${i} (${causeType}) has an empty stacktrace`); + } + }); +} + +/** + * Retrieve the context menu element corresponding to the provided id, for the provided + * netmonitor instance. + * @param {Object} monitor + * The network monnitor object + * @param {String} id + * The id of the context menu item + */ +function getContextMenuItem(monitor, id) { + const Menu = require("resource://devtools/client/framework/menu.js"); + return Menu.getMenuElementById(id, monitor.panelWin.document); +} + +async function maybeOpenAncestorMenu(menuItem) { + const parentPopup = menuItem.parentNode; + if (parentPopup.state == "shown") { + return; + } + const shown = BrowserTestUtils.waitForEvent(parentPopup, "popupshown"); + if (parentPopup.state == "showing") { + await shown; + return; + } + const parentMenu = parentPopup.parentNode; + await maybeOpenAncestorMenu(parentMenu); + parentMenu.openMenu(true); + await shown; +} + +/* + * Selects and clicks the context menu item, it should + * also wait for the popup to close. + * @param {Object} monitor + * The network monnitor object + * @param {String} id + * The id of the context menu item + */ +async function selectContextMenuItem(monitor, id) { + const contextMenuItem = getContextMenuItem(monitor, id); + + const popup = contextMenuItem.parentNode; + await maybeOpenAncestorMenu(contextMenuItem); + const hidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + popup.activateItem(contextMenuItem); + await hidden; +} + +/** + * Wait for DOM being in specific state. But, do not wait + * for change if it's in the expected state already. + */ +async function waitForDOMIfNeeded(target, selector, expectedLength = 1) { + return new Promise(resolve => { + const elements = target.querySelectorAll(selector); + if (elements.length == expectedLength) { + resolve(elements); + } else { + waitForDOM(target, selector, expectedLength).then(elems => { + resolve(elems); + }); + } + }); +} + +/** + * Helper for blocking or unblocking a request via the list item's context menu. + * + * @param {Element} element + * Target request list item to be right clicked to bring up its context menu. + * @param {Object} monitor + * The netmonitor instance used for retrieving a context menu element. + * @param {Object} store + * The redux store (wait-service middleware required). + * @param {String} action + * The action, block or unblock, to construct a corresponding context menu id. + */ +async function toggleBlockedUrl(element, monitor, store, action = "block") { + EventUtils.sendMouseEvent({ type: "contextmenu" }, element); + const contextMenuId = `request-list-context-${action}-url`; + const onRequestComplete = waitForDispatch( + store, + "REQUEST_BLOCKING_UPDATE_COMPLETE" + ); + await selectContextMenuItem(monitor, contextMenuId); + + info(`Wait for selected request to be ${action}ed`); + await onRequestComplete; + info(`Selected request is now ${action}ed`); +} + +/** + * Find and click an element + * + * @param {Element} element + * Target element to be clicked + * @param {Object} monitor + * The netmonitor instance used for retrieving the window. + */ + +function clickElement(element, monitor) { + EventUtils.synthesizeMouseAtCenter(element, {}, monitor.panelWin); +} + +/** + * Register a listener to be notified when a favicon finished loading and + * dispatch a "devtools:test:favicon" event to the favicon's link element. + * + * @param {Browser} browser + * Target browser to observe the favicon load. + */ +function registerFaviconNotifier(browser) { + const listener = async (name, data) => { + if (name == "SetIcon" || name == "SetFailedIcon") { + await SpecialPowers.spawn(browser, [], async () => { + content.document + .querySelector("link[rel='icon']") + .dispatchEvent(new content.CustomEvent("devtools:test:favicon")); + }); + LinkHandlerParent.removeListenerForTests(listener); + } + }; + LinkHandlerParent.addListenerForTests(listener); +} + +/** + * Predicates used when sorting items. + * + * @param object first + * The first item used in the comparison. + * @param object second + * The second item used in the comparison. + * @return number + * <0 to sort first to a lower index than second + * =0 to leave first and second unchanged with respect to each other + * >0 to sort second to a lower index than first + */ + +function compareValues(first, second) { + if (first === second) { + return 0; + } + return first > second ? 1 : -1; +} + +/** + * Click on the "Response" tab to open "Response" panel in the sidebar. + * @param {Document} doc + * Network panel document. + * @param {String} name + * Network panel sidebar tab name. + */ +const clickOnSidebarTab = (doc, name) => { + AccessibilityUtils.setEnv({ + // Keyboard accessibility is handled on the sidebar tabs container level + // (nav). Users can use arrow keys to navigate between and select tabs. + nonNegativeTabIndexRule: false, + }); + EventUtils.sendMouseEvent( + { type: "click" }, + doc.querySelector(`#${name}-tab`) + ); + AccessibilityUtils.resetEnv(); +}; + +/** + * Add a new blocked request URL pattern. The request blocking sidepanel should + * already be opened. + * + * @param {string} pattern + * The URL pattern to add to block requests. + * @param {Object} monitor + * The netmonitor instance. + */ +async function addBlockedRequest(pattern, monitor) { + info("Add a blocked request for the URL pattern " + pattern); + const doc = monitor.panelWin.document; + + const addRequestForm = await waitFor(() => + doc.querySelector( + "#network-action-bar-blocked-panel .request-blocking-add-form" + ) + ); + ok(!!addRequestForm, "The request blocking side panel is not available"); + + info("Wait for the add input to get focus"); + await waitFor(() => + addRequestForm.querySelector("input.devtools-searchinput:focus") + ); + + typeInNetmonitor(pattern, monitor); + EventUtils.synthesizeKey("KEY_Enter"); +} + +/** + * Check if the provided .request-list-item element corresponds to a blocked + * request. + * + * @param {Element} + * The request's DOM element. + * @returns {boolean} + * True if the request is displayed as blocked, false otherwise. + */ +function checkRequestListItemBlocked(item) { + return item.className.includes("blocked"); +} + +/** + * Type the provided string the netmonitor window. The correct input should be + * focused prior to using this helper. + * + * @param {string} string + * The string to type. + * @param {Object} monitor + * The netmonitor instance used to type the string. + */ +function typeInNetmonitor(string, monitor) { + for (const ch of string) { + EventUtils.synthesizeKey(ch, {}, monitor.panelWin); + } +} |