diff --git a/accessible/base/NotificationController.h b/accessible/base/NotificationController.h index c6aa1cf44c8ba339704a18ebe92fe5a7751e52f5..cfe64bdda54d49ee5b11b2368a2f9856cc9ea3cf 100644 --- a/accessible/base/NotificationController.h +++ b/accessible/base/NotificationController.h @@ -270,6 +270,8 @@ class NotificationController final : public EventQueue, } #endif + bool IsUpdatePendingForJugglerAccessibility() { return IsUpdatePending(); } + protected: virtual ~NotificationController(); diff --git a/accessible/interfaces/nsIAccessibleDocument.idl b/accessible/interfaces/nsIAccessibleDocument.idl index a91df31c96afda66f478a5a38eaa4352039c2a0b..ee777c1746284027fb3aa2f1686f8082af9d89ee 100644 --- a/accessible/interfaces/nsIAccessibleDocument.idl +++ b/accessible/interfaces/nsIAccessibleDocument.idl @@ -72,4 +72,9 @@ interface nsIAccessibleDocument : nsISupports * Return the child document accessible at the given index. */ nsIAccessibleDocument getChildDocumentAt(in unsigned long index); + + /** + * Return whether it is updating. + */ + readonly attribute boolean isUpdatePendingForJugglerAccessibility; }; diff --git a/accessible/xpcom/xpcAccessibleDocument.cpp b/accessible/xpcom/xpcAccessibleDocument.cpp index e3dbe73f22252f11080c3f266b2309f842eba9dc..87f50fe3df7cc8f9bc26dabd5ee571cae270912a 100644 --- a/accessible/xpcom/xpcAccessibleDocument.cpp +++ b/accessible/xpcom/xpcAccessibleDocument.cpp @@ -143,6 +143,15 @@ xpcAccessibleDocument::GetVirtualCursor(nsIAccessiblePivot** aVirtualCursor) { return NS_OK; } + +NS_IMETHODIMP +xpcAccessibleDocument::GetIsUpdatePendingForJugglerAccessibility(bool* updating) { + NS_ENSURE_ARG_POINTER(updating); + *updating = Intl()->Controller()->IsUpdatePendingForJugglerAccessibility(); + return NS_OK; +} + + //////////////////////////////////////////////////////////////////////////////// // xpcAccessibleDocument diff --git a/accessible/xpcom/xpcAccessibleDocument.h b/accessible/xpcom/xpcAccessibleDocument.h index f042cc1081850ac60e329b70b5569f8b97d4e4dc..65bcff9b41b9471ef1427e3ea330481c194409bc 100644 --- a/accessible/xpcom/xpcAccessibleDocument.h +++ b/accessible/xpcom/xpcAccessibleDocument.h @@ -48,6 +48,8 @@ class xpcAccessibleDocument : public xpcAccessibleHyperText, nsIAccessibleDocument** aDocument) final; NS_IMETHOD GetVirtualCursor(nsIAccessiblePivot** aVirtualCursor) final; + NS_IMETHOD GetIsUpdatePendingForJugglerAccessibility(bool* aUpdating) final; + /** * Return XPCOM wrapper for the internal accessible. */ diff --git a/browser/installer/allowed-dupes.mn b/browser/installer/allowed-dupes.mn index cf0ae812a9f9741128fac124db03fb158ca54c30..7a0657ae0784e13929daf301549151236f1e53c1 100644 --- a/browser/installer/allowed-dupes.mn +++ b/browser/installer/allowed-dupes.mn @@ -139,6 +139,11 @@ browser/chrome/browser/res/payments/formautofill/autofillEditForms.js # Bug 1451050 - Remote settings empty dumps (will be populated with data eventually) browser/defaults/settings/pinning/pins.json browser/defaults/settings/main/example.json +# Juggler/marionette files +chrome/juggler/content/content/floating-scrollbars.css +browser/chrome/devtools/skin/floating-scrollbars-responsive-design.css +chrome/juggler/content/server/stream-utils.js +chrome/marionette/content/stream-utils.js #ifdef MOZ_EME_WIN32_ARTIFACT gmp-clearkey/0.1/manifest.json i686/gmp-clearkey/0.1/manifest.json diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in index 7054749357ec13f175be8022852b42fcfeda9134..c9064880ecf7e70290c6a84bfc209e084aa37ddf 100644 --- a/browser/installer/package-manifest.in +++ b/browser/installer/package-manifest.in @@ -211,6 +211,11 @@ @RESPATH@/components/marionette.js #endif +@RESPATH@/chrome/juggler@JAREXT@ +@RESPATH@/chrome/juggler.manifest +@RESPATH@/components/juggler.manifest +@RESPATH@/components/juggler.js + #if defined(ENABLE_TESTS) && defined(MOZ_DEBUG) @RESPATH@/components/TestInterfaceJS.js @RESPATH@/components/TestInterfaceJS.manifest diff --git a/devtools/server/socket/websocket-server.js b/devtools/server/socket/websocket-server.js index 040c7b124dec6bb254563bbe74fe50012cb077a3..b4e6b8132786af70e8ad0dce88b67c2835307f88 100644 --- a/devtools/server/socket/websocket-server.js +++ b/devtools/server/socket/websocket-server.js @@ -133,13 +133,12 @@ function writeHttpResponse(output, response) { * Process the WebSocket handshake headers and return the key to be sent in * Sec-WebSocket-Accept response header. */ -function processRequest({ requestLine, headers }) { +function processRequest({ requestLine, headers }, expectedPath) { const [method, path] = requestLine.split(" "); if (method !== "GET") { throw new Error("The handshake request must use GET method"); } - - if (path !== "/") { + if (path !== expectedPath) { throw new Error("The handshake request has unknown path"); } @@ -189,13 +188,13 @@ function computeKey(key) { /** * Perform the server part of a WebSocket opening handshake on an incoming connection. */ -const serverHandshake = async function(input, output) { +const serverHandshake = async function(input, output, expectedPath) { // Read the request const request = await readHttpRequest(input); try { // Check and extract info from the request - const { acceptKey } = processRequest(request); + const { acceptKey } = processRequest(request, expectedPath); // Send response headers await writeHttpResponse(output, [ @@ -217,8 +216,8 @@ const serverHandshake = async function(input, output) { * Performs the WebSocket handshake and waits for the WebSocket to open. * Returns Promise with a WebSocket ready to send and receive messages. */ -const accept = async function(transport, input, output) { - await serverHandshake(input, output); +const accept = async function(transport, input, output, expectedPath) { + await serverHandshake(input, output, expectedPath || "/"); const transportProvider = { setListener(upgradeListener) { diff --git a/docshell/base/nsDocShell.cpp b/docshell/base/nsDocShell.cpp index b30c186c88daa7dd62f69e452dedc9e968511bb5..3a9bda87d5c577fd578bf3a523854d46c2a8db6a 100644 --- a/docshell/base/nsDocShell.cpp +++ b/docshell/base/nsDocShell.cpp @@ -95,6 +95,7 @@ #include "nsIDocShellTreeItem.h" #include "nsIDocShellTreeOwner.h" #include "mozilla/dom/Document.h" +#include "mozilla/dom/Element.h" #include "nsIDocumentLoaderFactory.h" #include "nsIDOMWindow.h" #include "nsIEditingSession.h" @@ -351,6 +352,8 @@ nsDocShell::nsDocShell(BrowsingContext* aBrowsingContext, mUseStrictSecurityChecks(false), mObserveErrorPages(true), mCSSErrorReportingEnabled(false), + mFileInputInterceptionEnabled(false), + mBypassCSPEnabled(false), mAllowAuth(mItemType == typeContent), mAllowKeywordFixup(false), mIsOffScreenBrowser(false), @@ -1213,6 +1216,7 @@ bool nsDocShell::SetCurrentURI(nsIURI* aURI, nsIRequest* aRequest, isSubFrame = mLSHE->GetIsSubFrame(); } + FireOnFrameLocationChange(this, aRequest, aURI, aLocationFlags); if (!isSubFrame && !isRoot) { /* * We don't want to send OnLocationChange notifications when @@ -3347,6 +3351,54 @@ nsDocShell::GetContentBlockingLog(Promise** aPromise) { return NS_OK; } +nsDocShell* nsDocShell::GetRootDocShell() { + nsCOMPtr rootAsItem; + GetInProcessSameTypeRootTreeItem(getter_AddRefs(rootAsItem)); + nsCOMPtr rootShell = do_QueryInterface(rootAsItem); + return nsDocShell::Cast(rootShell); +} + +NS_IMETHODIMP +nsDocShell::GetBypassCSPEnabled(bool* aEnabled) { + MOZ_ASSERT(aEnabled); + *aEnabled = mBypassCSPEnabled; + return NS_OK; +} + +NS_IMETHODIMP +nsDocShell::SetBypassCSPEnabled(bool aEnabled) { + mBypassCSPEnabled = aEnabled; + return NS_OK; +} + +bool nsDocShell::IsBypassCSPEnabled() { + return GetRootDocShell()->mBypassCSPEnabled; +} + +NS_IMETHODIMP +nsDocShell::GetFileInputInterceptionEnabled(bool* aEnabled) { + MOZ_ASSERT(aEnabled); + *aEnabled = mFileInputInterceptionEnabled; + return NS_OK; +} + +NS_IMETHODIMP +nsDocShell::SetFileInputInterceptionEnabled(bool aEnabled) { + mFileInputInterceptionEnabled = aEnabled; + return NS_OK; +} + +bool nsDocShell::IsFileInputInterceptionEnabled() { + return GetRootDocShell()->mFileInputInterceptionEnabled; +} + +void nsDocShell::FilePickerShown(mozilla::dom::Element* element) { + nsCOMPtr observerService = + mozilla::services::GetObserverService(); + observerService->NotifyObservers( + ToSupports(element), "juggler-file-picker-shown", nullptr); +} + NS_IMETHODIMP nsDocShell::GetIsNavigating(bool* aOut) { *aOut = mIsNavigating; diff --git a/docshell/base/nsDocShell.h b/docshell/base/nsDocShell.h index e88da0c1e9adcc6f50ca4b3cb4a55d12430736e8..1544de7726143464e204532dae12dd2ad7373a0f 100644 --- a/docshell/base/nsDocShell.h +++ b/docshell/base/nsDocShell.h @@ -18,6 +18,7 @@ #include "mozilla/WeakPtr.h" #include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/Element.h" #include "mozilla/dom/ProfileTimelineMarkerBinding.h" #include "mozilla/gfx/Matrix.h" #include "mozilla/dom/ChildSHistory.h" @@ -485,6 +486,11 @@ class nsDocShell final : public nsDocLoader, mSkipBrowsingContextDetachOnDestroy = true; } + bool IsFileInputInterceptionEnabled(); + void FilePickerShown(mozilla::dom::Element* element); + + bool IsBypassCSPEnabled(); + // Create a content viewer within this nsDocShell for the given // `WindowGlobalChild` actor. nsresult CreateContentViewerForActor( @@ -1036,6 +1042,8 @@ class nsDocShell final : public nsDocLoader, bool CSSErrorReportingEnabled() const { return mCSSErrorReportingEnabled; } + nsDocShell* GetRootDocShell(); + // Handles retrieval of subframe session history for nsDocShell::LoadURI. If a // load is requested in a subframe of the current DocShell, the subframe // loadType may need to reflect the loadType of the parent document, or in @@ -1292,6 +1300,8 @@ class nsDocShell final : public nsDocLoader, bool mUseStrictSecurityChecks : 1; bool mObserveErrorPages : 1; bool mCSSErrorReportingEnabled : 1; + bool mFileInputInterceptionEnabled: 1; + bool mBypassCSPEnabled : 1; bool mAllowAuth : 1; bool mAllowKeywordFixup : 1; bool mIsOffScreenBrowser : 1; diff --git a/docshell/base/nsIDocShell.idl b/docshell/base/nsIDocShell.idl index 267856626bdf2e7411b4ac975d4f64b824c826a1..03b7a44b39b5b3303c2519614dd39863b595fac6 100644 --- a/docshell/base/nsIDocShell.idl +++ b/docshell/base/nsIDocShell.idl @@ -1142,4 +1142,8 @@ interface nsIDocShell : nsIDocShellTreeItem * @see nsISHEntry synchronizeLayoutHistoryState(). */ void synchronizeLayoutHistoryState(); + + attribute boolean fileInputInterceptionEnabled; + + attribute boolean bypassCSPEnabled; }; diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp index 9eac6924968638025556b8e97546ed3d91bfe28d..8839c880b75f00cb475bee7f68c48112e5263a1c 100644 --- a/dom/base/Document.cpp +++ b/dom/base/Document.cpp @@ -3267,6 +3267,9 @@ void Document::SendToConsole(nsCOMArray& aMessages) { } void Document::ApplySettingsFromCSP(bool aSpeculative) { + if (mDocumentContainer && mDocumentContainer->IsBypassCSPEnabled()) + return; + nsresult rv = NS_OK; if (!aSpeculative) { // 1) apply settings from regular CSP @@ -3316,6 +3319,11 @@ nsresult Document::InitCSP(nsIChannel* aChannel) { return NS_OK; } + nsCOMPtr shell(mDocumentContainer); + if (shell && nsDocShell::Cast(shell)->IsBypassCSPEnabled()) { + return NS_OK; + } + // If this is a data document - no need to set CSP. if (mLoadedAsData) { return NS_OK; diff --git a/dom/html/HTMLInputElement.cpp b/dom/html/HTMLInputElement.cpp index 5fda32ce01630bb9151e47cb4a8cdb1180d120bd..5e4cfe38dc69f257f3057dbf1197f1f3e12fe654 100644 --- a/dom/html/HTMLInputElement.cpp +++ b/dom/html/HTMLInputElement.cpp @@ -45,6 +45,7 @@ #include "nsMappedAttributes.h" #include "nsIFormControl.h" #include "mozilla/dom/Document.h" +#include "nsDocShell.h" #include "nsIFormControlFrame.h" #include "nsITextControlFrame.h" #include "nsIFrame.h" @@ -730,6 +731,12 @@ nsresult HTMLInputElement::InitFilePicker(FilePickerType aType) { return NS_ERROR_FAILURE; } + nsDocShell* docShell = static_cast(win->GetDocShell()); + if (docShell && docShell->IsFileInputInterceptionEnabled()) { + docShell->FilePickerShown(this); + return NS_OK; + } + if (IsPopupBlocked()) { return NS_OK; } diff --git a/dom/ipc/BrowserChild.cpp b/dom/ipc/BrowserChild.cpp index edda707be08292a767f66d20f2abca98af113796..f7031a8e1fd813a9371b8f6d3a987a32e47b1dac 100644 --- a/dom/ipc/BrowserChild.cpp +++ b/dom/ipc/BrowserChild.cpp @@ -3632,6 +3632,13 @@ NS_IMETHODIMP BrowserChild::OnStateChange(nsIWebProgress* aWebProgress, return NS_OK; } +NS_IMETHODIMP BrowserChild::OnFrameLocationChange(nsIWebProgress *aWebProgress, + nsIRequest *aRequest, + nsIURI *aLocation, + uint32_t aFlags) { + return NS_OK; +} + NS_IMETHODIMP BrowserChild::OnProgressChange(nsIWebProgress* aWebProgress, nsIRequest* aRequest, int32_t aCurSelfProgress, diff --git a/dom/script/ScriptSettings.cpp b/dom/script/ScriptSettings.cpp index 9e3c1a56d10394d98de9e84fb8cd6ee8e3be5870..8c661de349d6cb64fd8d81d5db9c28f2a2af9138 100644 --- a/dom/script/ScriptSettings.cpp +++ b/dom/script/ScriptSettings.cpp @@ -140,6 +140,30 @@ ScriptSettingsStackEntry::~ScriptSettingsStackEntry() { MOZ_ASSERT_IF(mGlobalObject, mGlobalObject->HasJSGlobal()); } +static nsIGlobalObject* UnwrapSandboxGlobal(nsIGlobalObject* global) { + if (!global) + return global; + JSObject* globalObject = global->GetGlobalJSObject(); + if (!globalObject) + return global; + JSContext* cx = nsContentUtils::GetCurrentJSContext(); + if (!cx) + return global; + JS::Rooted proto(cx); + JS::RootedObject rootedGlobal(cx, globalObject); + if (!JS_GetPrototype(cx, rootedGlobal, &proto)) + return global; + if (!proto || !xpc::IsSandboxPrototypeProxy(proto)) + return global; + // If this is a sandbox associated with a DOMWindow via a + // sandboxPrototype, use that DOMWindow. This supports GreaseMonkey + // and JetPack content scripts. + proto = js::CheckedUnwrapDynamic(proto, cx, /* stopAtWindowProxy = */ false); + if (!proto) + return global; + return xpc::WindowGlobalOrNull(proto); +} + // If the entry or incumbent global ends up being something that the subject // principal doesn't subsume, we don't want to use it. This never happens on // the web, but can happen with asymmetric privilege relationships (i.e. @@ -167,7 +191,7 @@ static nsIGlobalObject* ClampToSubject(nsIGlobalObject* aGlobalOrNull) { NS_ENSURE_TRUE(globalPrin, GetCurrentGlobal()); if (!nsContentUtils::SubjectPrincipalOrSystemIfNativeCaller() ->SubsumesConsideringDomain(globalPrin)) { - return GetCurrentGlobal(); + return UnwrapSandboxGlobal(GetCurrentGlobal()); } return aGlobalOrNull; diff --git a/dom/security/nsCSPUtils.cpp b/dom/security/nsCSPUtils.cpp index f0c28cfdae1c9ac33013e9688e0142d161763543..a38ab106e37dbab58e91ef5a873f8954c35881e7 100644 --- a/dom/security/nsCSPUtils.cpp +++ b/dom/security/nsCSPUtils.cpp @@ -121,6 +121,11 @@ void CSP_ApplyMetaCSPToDoc(mozilla::dom::Document& aDoc, return; } + if (aDoc.GetDocShell() && + nsDocShell::Cast(aDoc.GetDocShell())->IsBypassCSPEnabled()) { + return; + } + nsAutoString policyStr( nsContentUtils::TrimWhitespace( aPolicyStr)); diff --git a/extensions/permissions/nsPermissionManager.cpp b/extensions/permissions/nsPermissionManager.cpp index 9b667d3a4c29e71297dc0bd33bfe30ab670a9f36..0971b5ca7930cfd6d7ac6e21f7187718bfc2499b 100644 --- a/extensions/permissions/nsPermissionManager.cpp +++ b/extensions/permissions/nsPermissionManager.cpp @@ -167,7 +167,7 @@ void MaybeStripOAs(OriginAttributes& aOriginAttributes) { } if (flags != 0) { - aOriginAttributes.StripAttributes(flags); + // aOriginAttributes.StripAttributes(flags); } } @@ -199,6 +199,8 @@ nsresult GetOriginFromPrincipal(nsIPrincipal* aPrincipal, nsACString& aOrigin) { OriginAppendOASuffix(attrs, aOrigin); + // Disable userContext for permissions. + // attrs.StripAttributes(mozilla::OriginAttributes::STRIP_USER_CONTEXT_ID); return NS_OK; } @@ -317,7 +319,7 @@ already_AddRefed GetNextSubDomainPrincipal( if (!StaticPrefs::permissions_isolateBy_userContext()) { // Disable userContext for permissions. - attrs.StripAttributes(mozilla::OriginAttributes::STRIP_USER_CONTEXT_ID); + // attrs.StripAttributes(mozilla::OriginAttributes::STRIP_USER_CONTEXT_ID); } nsCOMPtr principal = diff --git a/parser/html/nsHtml5TreeOpExecutor.cpp b/parser/html/nsHtml5TreeOpExecutor.cpp index d99e7f91503e84690d711bca2c2d916e34e56214..b63e9d96219420f5e4fb58797e4c3d901cba77cc 100644 --- a/parser/html/nsHtml5TreeOpExecutor.cpp +++ b/parser/html/nsHtml5TreeOpExecutor.cpp @@ -1065,9 +1065,12 @@ void nsHtml5TreeOpExecutor::AddSpeculationCSP(const nsAString& aCSP) { if (!StaticPrefs::security_csp_enable()) { return; } - NS_ASSERTION(NS_IsMainThread(), "Wrong thread!"); + if (mDocShell && static_cast(mDocShell.get())->IsBypassCSPEnabled()) { + return; + } + nsresult rv = NS_OK; nsCOMPtr preloadCsp = mDocument->GetPreloadCsp(); if (!preloadCsp) { diff --git a/security/manager/ssl/nsCertOverrideService.cpp b/security/manager/ssl/nsCertOverrideService.cpp index 6dca2b78830edc1ddbd66264bd332853729dac71..fbe89c9682834e11b9d9219d9eb056ede084435a 100644 --- a/security/manager/ssl/nsCertOverrideService.cpp +++ b/security/manager/ssl/nsCertOverrideService.cpp @@ -634,7 +634,7 @@ static bool IsDebugger() { NS_IMETHODIMP nsCertOverrideService:: SetDisableAllSecurityChecksAndLetAttackersInterceptMyData(bool aDisable) { - if (!(PR_GetEnv("XPCSHELL_TEST_PROFILE_DIR") || IsDebugger())) { + if (false /* juggler hacks */ && !(PR_GetEnv("XPCSHELL_TEST_PROFILE_DIR") || IsDebugger())) { return NS_ERROR_NOT_AVAILABLE; } diff --git a/testing/juggler/BrowserContextManager.js b/testing/juggler/BrowserContextManager.js new file mode 100644 index 0000000000000000000000000000000000000000..8f031b3f9afbb357a6bebc9938fca50a04d0421c --- /dev/null +++ b/testing/juggler/BrowserContextManager.js @@ -0,0 +1,180 @@ +"use strict"; + +const {ContextualIdentityService} = ChromeUtils.import("resource://gre/modules/ContextualIdentityService.jsm"); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const helper = new Helper(); + +const IDENTITY_NAME = 'JUGGLER '; +const HUNDRED_YEARS = 60 * 60 * 24 * 365 * 100; + +const ALL_PERMISSIONS = [ + 'geo', + 'microphone', + 'camera', + 'desktop-notifications', +]; + +class BrowserContextManager { + static instance() { + return BrowserContextManager._instance || null; + } + + static initialize() { + if (BrowserContextManager._instance) + return; + BrowserContextManager._instance = new BrowserContextManager(); + } + + constructor() { + this._browserContextIdToBrowserContext = new Map(); + this._userContextIdToBrowserContext = new Map(); + + // Cleanup containers from previous runs (if any) + for (const identity of ContextualIdentityService.getPublicIdentities()) { + if (identity.name && identity.name.startsWith(IDENTITY_NAME)) { + ContextualIdentityService.remove(identity.userContextId); + ContextualIdentityService.closeContainerTabs(identity.userContextId); + } + } + + this._defaultContext = new BrowserContext(this, undefined, undefined); + } + + createBrowserContext(options) { + return new BrowserContext(this, helper.generateId(), options); + } + + browserContextForId(browserContextId) { + return this._browserContextIdToBrowserContext.get(browserContextId); + } + + browserContextForUserContextId(userContextId) { + return this._userContextIdToBrowserContext.get(userContextId); + } + + getBrowserContexts() { + return Array.from(this._browserContextIdToBrowserContext.values()); + } +} + +class BrowserContext { + constructor(manager, browserContextId, options) { + this._manager = manager; + this.browserContextId = browserContextId; + this.userContextId = undefined; + if (browserContextId !== undefined) { + const identity = ContextualIdentityService.create(IDENTITY_NAME + browserContextId); + this.userContextId = identity.userContextId; + } + this._principals = []; + this._manager._browserContextIdToBrowserContext.set(this.browserContextId, this); + this._manager._userContextIdToBrowserContext.set(this.userContextId, this); + this.options = options || {}; + } + + destroy() { + if (this.userContextId !== undefined) { + ContextualIdentityService.remove(this.userContextId); + ContextualIdentityService.closeContainerTabs(this.userContextId); + } + this._manager._browserContextIdToBrowserContext.delete(this.browserContextId); + this._manager._userContextIdToBrowserContext.delete(this.userContextId); + } + + grantPermissions(origin, permissions) { + const attrs = {userContextId: this.userContextId}; + const principal = Services.scriptSecurityManager.createContentPrincipal(NetUtil.newURI(origin), attrs); + this._principals.push(principal); + for (const permission of ALL_PERMISSIONS) { + const action = permissions.includes(permission) ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION; + Services.perms.addFromPrincipal(principal, permission, action); + } + } + + resetPermissions() { + for (const principal of this._principals) { + for (const permission of ALL_PERMISSIONS) + Services.perms.removeFromPrincipal(principal, permission); + } + this._principals = []; + } + + setCookies(cookies) { + const protocolToSameSite = { + [undefined]: Ci.nsICookie.SAMESITE_NONE, + 'Lax': Ci.nsICookie.SAMESITE_LAX, + 'Strict': Ci.nsICookie.SAMESITE_STRICT, + }; + for (const cookie of cookies) { + const uri = cookie.url ? NetUtil.newURI(cookie.url) : null; + let domain = cookie.domain; + if (!domain) { + if (!uri) + throw new Error('At least one of the url and domain needs to be specified'); + domain = uri.host; + } + let path = cookie.path; + if (!path) + path = uri ? dirPath(uri.filePath) : '/'; + let secure = false; + if (cookie.secure !== undefined) + secure = cookie.secure; + else if (uri && uri.scheme === 'https') + secure = true; + Services.cookies.add( + domain, + path, + cookie.name, + cookie.value, + secure, + cookie.httpOnly || false, + cookie.expires === undefined || cookie.expires === -1 /* isSession */, + cookie.expires === undefined ? Date.now() + HUNDRED_YEARS : cookie.expires, + { userContextId: this.userContextId } /* originAttributes */, + protocolToSameSite[cookie.sameSite], + ); + } + } + + clearCookies() { + Services.cookies.removeCookiesWithOriginAttributes(JSON.stringify({ userContextId: this.userContextId })); + } + + getCookies() { + const result = []; + const sameSiteToProtocol = { + [Ci.nsICookie.SAMESITE_NONE]: 'None', + [Ci.nsICookie.SAMESITE_LAX]: 'Lax', + [Ci.nsICookie.SAMESITE_STRICT]: 'Strict', + }; + for (let cookie of Services.cookies.cookies) { + if (cookie.originAttributes.userContextId !== (this.userContextId || 0)) + continue; + if (cookie.host === 'addons.mozilla.org') + continue; + result.push({ + name: cookie.name, + value: cookie.value, + domain: cookie.host, + path: cookie.path, + expires: cookie.isSession ? -1 : cookie.expiry, + size: cookie.name.length + cookie.value.length, + httpOnly: cookie.isHttpOnly, + secure: cookie.isSecure, + session: cookie.isSession, + sameSite: sameSiteToProtocol[cookie.sameSite], + }); + } + return result; + } +} + +function dirPath(path) { + return path.substring(0, path.lastIndexOf('/') + 1); +} + +var EXPORTED_SYMBOLS = ['BrowserContextManager']; +this.BrowserContextManager = BrowserContextManager; + diff --git a/testing/juggler/Helper.js b/testing/juggler/Helper.js new file mode 100644 index 0000000000000000000000000000000000000000..862c680198bbb503a5f04c19bdb8fdf2cd8c9cef --- /dev/null +++ b/testing/juggler/Helper.js @@ -0,0 +1,102 @@ +const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +class Helper { + addObserver(handler, topic) { + Services.obs.addObserver(handler, topic); + return () => Services.obs.removeObserver(handler, topic); + } + + addMessageListener(receiver, eventName, handler) { + receiver.addMessageListener(eventName, handler); + return () => receiver.removeMessageListener(eventName, handler); + } + + addEventListener(receiver, eventName, handler) { + receiver.addEventListener(eventName, handler); + return () => receiver.removeEventListener(eventName, handler); + } + + on(receiver, eventName, handler) { + // The toolkit/modules/EventEmitter.jsm dispatches event name as a first argument. + // Fire event listeners without it for convenience. + const handlerWrapper = (_, ...args) => handler(...args); + receiver.on(eventName, handlerWrapper); + return () => receiver.off(eventName, handlerWrapper); + } + + addProgressListener(progress, listener, flags) { + progress.addProgressListener(listener, flags); + return () => progress.removeProgressListener(listener); + } + + removeListeners(listeners) { + for (const tearDown of listeners) + tearDown.call(null); + listeners.splice(0, listeners.length); + } + + generateId() { + const string = uuidGen.generateUUID().toString(); + return string.substring(1, string.length - 1); + } + + getNetworkErrorStatusText(status) { + if (!status) + return null; + for (const key of Object.keys(Cr)) { + if (Cr[key] === status) + return key; + } + // Security module. The following is taken from + // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/How_to_check_the_secruity_state_of_an_XMLHTTPRequest_over_SSL + if ((status & 0xff0000) === 0x5a0000) { + // NSS_SEC errors (happen below the base value because of negative vals) + if ((status & 0xffff) < Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE)) { + // The bases are actually negative, so in our positive numeric space, we + // need to subtract the base off our value. + const nssErr = Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff); + switch (nssErr) { + case 11: + return 'SEC_ERROR_EXPIRED_CERTIFICATE'; + case 12: + return 'SEC_ERROR_REVOKED_CERTIFICATE'; + case 13: + return 'SEC_ERROR_UNKNOWN_ISSUER'; + case 20: + return 'SEC_ERROR_UNTRUSTED_ISSUER'; + case 21: + return 'SEC_ERROR_UNTRUSTED_CERT'; + case 36: + return 'SEC_ERROR_CA_CERT_INVALID'; + case 90: + return 'SEC_ERROR_INADEQUATE_KEY_USAGE'; + case 176: + return 'SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED'; + default: + return 'SEC_ERROR_UNKNOWN'; + } + } + const sslErr = Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff); + switch (sslErr) { + case 3: + return 'SSL_ERROR_NO_CERTIFICATE'; + case 4: + return 'SSL_ERROR_BAD_CERTIFICATE'; + case 8: + return 'SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE'; + case 9: + return 'SSL_ERROR_UNSUPPORTED_VERSION'; + case 12: + return 'SSL_ERROR_BAD_CERT_DOMAIN'; + default: + return 'SSL_ERROR_UNKNOWN'; + } + } + return ''; + } +} + +var EXPORTED_SYMBOLS = [ "Helper" ]; +this.Helper = Helper; + diff --git a/testing/juggler/NetworkObserver.js b/testing/juggler/NetworkObserver.js new file mode 100644 index 0000000000000000000000000000000000000000..8fe6a596bda3f58e6f93ba943fbbc0819bf0fc01 --- /dev/null +++ b/testing/juggler/NetworkObserver.js @@ -0,0 +1,689 @@ +"use strict"; + +const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); +const {CommonUtils} = ChromeUtils.import("resource://services-common/utils.js"); +const {TargetRegistry} = ChromeUtils.import('chrome://juggler/content/TargetRegistry.js'); +const {BrowserContextManager} = ChromeUtils.import('chrome://juggler/content/BrowserContextManager.js'); + + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; +const Cm = Components.manager; +const CC = Components.Constructor; +const helper = new Helper(); + +const BinaryInputStream = CC('@mozilla.org/binaryinputstream;1', 'nsIBinaryInputStream', 'setInputStream'); +const BinaryOutputStream = CC('@mozilla.org/binaryoutputstream;1', 'nsIBinaryOutputStream', 'setOutputStream'); +const StorageStream = CC('@mozilla.org/storagestream;1', 'nsIStorageStream', 'init'); + +// Cap response storage with 100Mb per tracked tab. +const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024; + +/** + * This is a nsIChannelEventSink implementation that monitors channel redirects. + */ +const SINK_CLASS_DESCRIPTION = "Juggler NetworkMonitor Channel Event Sink"; +const SINK_CLASS_ID = Components.ID("{c2b4c83e-607a-405a-beab-0ef5dbfb7617}"); +const SINK_CONTRACT_ID = "@mozilla.org/network/monitor/channeleventsink;1"; +const SINK_CATEGORY_NAME = "net-channel-event-sinks"; + +class NetworkObserver { + static instance() { + return NetworkObserver._instance || null; + } + + static initialize() { + if (NetworkObserver._instance) + return; + NetworkObserver._instance = new NetworkObserver(); + } + + constructor() { + EventEmitter.decorate(this); + this._browserSessionCount = new Map(); + this._activityDistributor = Cc["@mozilla.org/network/http-activity-distributor;1"].getService(Ci.nsIHttpActivityDistributor); + this._activityDistributor.addObserver(this); + + this._redirectMap = new Map(); // oldId => newId + this._resumedRequestIdToHeaders = new Map(); // requestId => { headers } + this._postResumeChannelIdToRequestId = new Map(); // post-resume channel id => pre-resume request id + + this._channelSink = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIChannelEventSink]), + asyncOnChannelRedirect: (oldChannel, newChannel, flags, callback) => { + this._onRedirect(oldChannel, newChannel, flags); + callback.onRedirectVerifyCallback(Cr.NS_OK); + }, + }; + this._channelSinkFactory = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIFactory]), + createInstance: (aOuter, aIID) => this._channelSink.QueryInterface(aIID), + }; + // Register self as ChannelEventSink to track redirects. + const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory(SINK_CLASS_ID, SINK_CLASS_DESCRIPTION, SINK_CONTRACT_ID, this._channelSinkFactory); + Services.catMan.addCategoryEntry(SINK_CATEGORY_NAME, SINK_CONTRACT_ID, SINK_CONTRACT_ID, false, true); + + this._browserInterceptors = new Map(); // Browser => (requestId => interceptor). + this._extraHTTPHeaders = new Map(); + this._browserResponseStorages = new Map(); + this._browserAuthCredentials = new Map(); + + this._eventListeners = [ + helper.addObserver(this._onRequest.bind(this), 'http-on-modify-request'), + helper.addObserver(this._onResponse.bind(this, false /* fromCache */), 'http-on-examine-response'), + helper.addObserver(this._onResponse.bind(this, true /* fromCache */), 'http-on-examine-cached-response'), + helper.addObserver(this._onResponse.bind(this, true /* fromCache */), 'http-on-examine-merged-response'), + ]; + } + + setExtraHTTPHeaders(browser, headers) { + if (!headers) + this._extraHTTPHeaders.delete(browser); + else + this._extraHTTPHeaders.set(browser, headers); + } + + enableRequestInterception(browser) { + if (!this._browserInterceptors.has(browser)) + this._browserInterceptors.set(browser, new Map()); + } + + disableRequestInterception(browser) { + const interceptors = this._browserInterceptors.get(browser); + if (!interceptors) + return; + this._browserInterceptors.delete(browser); + for (const interceptor of interceptors.values()) + interceptor._resume(); + } + + _takeInterceptor(browser, requestId) { + const interceptors = this._browserInterceptors.get(browser); + if (!interceptors) + throw new Error(`Request interception is not enabled`); + const interceptor = interceptors.get(requestId); + if (!interceptor) + throw new Error(`Cannot find request "${requestId}"`); + interceptors.delete(requestId); + return interceptor; + } + + resumeInterceptedRequest(browser, requestId, method, headers, postData) { + this._takeInterceptor(browser, requestId)._resume(method, headers, postData); + } + + getResponseBody(browser, requestId) { + const responseStorage = this._browserResponseStorages.get(browser); + if (!responseStorage) + throw new Error('Responses are not tracked for the given browser'); + return responseStorage.getBase64EncodedResponse(requestId); + } + + fulfillInterceptedRequest(browser, requestId, status, statusText, headers, base64body) { + this._takeInterceptor(browser, requestId)._fulfill(status, statusText, headers, base64body); + } + + abortInterceptedRequest(browser, requestId, errorCode) { + this._takeInterceptor(browser, requestId)._abort(errorCode); + } + + setAuthCredentials(browser, username, password) { + this._browserAuthCredentials.set(browser, { username, password }); + } + + _requestId(httpChannel) { + const id = httpChannel.channelId + ''; + return this._postResumeChannelIdToRequestId.get(id) || id; + } + + _onRedirect(oldChannel, newChannel, flags) { + if (!(oldChannel instanceof Ci.nsIHttpChannel) || !(newChannel instanceof Ci.nsIHttpChannel)) + return; + const oldHttpChannel = oldChannel.QueryInterface(Ci.nsIHttpChannel); + const newHttpChannel = newChannel.QueryInterface(Ci.nsIHttpChannel); + const browser = this._getBrowserForChannel(oldHttpChannel); + if (!browser) + return; + const oldRequestId = this._requestId(oldHttpChannel); + const newRequestId = this._requestId(newHttpChannel); + if (this._resumedRequestIdToHeaders.has(oldRequestId)) { + // When we call resetInterception on a request, we get a new "redirected" request for it. + const { method, headers, postData } = this._resumedRequestIdToHeaders.get(oldRequestId); + if (headers) { + // Apply new request headers from interception resume. + for (const header of requestHeaders(newChannel)) + newChannel.setRequestHeader(header.name, '', false /* merge */); + for (const header of headers) + newChannel.setRequestHeader(header.name, header.value, false /* merge */); + } + if (method) + newChannel.requestMethod = method; + if (postData && newChannel instanceof Ci.nsIUploadChannel) { + const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); + synthesized.data = atob(postData); + newChannel.setUploadStream(synthesized, 'application/octet-stream', -1); + } + // Use the old request id for the new "redirected" request for protocol consistency. + this._resumedRequestIdToHeaders.delete(oldRequestId); + this._postResumeChannelIdToRequestId.set(newRequestId, oldRequestId); + } else if (!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL)) { + // Regular (non-internal) redirect. + this._redirectMap.set(newRequestId, oldRequestId); + } + } + + observeActivity(channel, activityType, activitySubtype, timestamp, extraSizeData, extraStringData) { + if (activityType !== Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION) + return; + if (!(channel instanceof Ci.nsIHttpChannel)) + return; + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) + return; + if (activitySubtype !== Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE) + return; + if (this._isResumedChannel(httpChannel)) + return; + this._sendOnRequestFinished(httpChannel); + } + + _getBrowserForChannel(httpChannel) { + let loadContext = null; + try { + if (httpChannel.notificationCallbacks) + loadContext = httpChannel.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (e) {} + try { + if (!loadContext && httpChannel.loadGroup) + loadContext = httpChannel.loadGroup.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (e) { } + if (!loadContext || !this._browserSessionCount.has(loadContext.topFrameElement)) + return; + return loadContext.topFrameElement; + } + + _isResumedChannel(httpChannel) { + return this._postResumeChannelIdToRequestId.has(httpChannel.channelId + ''); + } + + _onRequest(channel, topic) { + if (!(channel instanceof Ci.nsIHttpChannel)) + return; + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) + return; + if (this._isResumedChannel(httpChannel)) { + // Ignore onRequest for resumed requests, but listen to their response. + new ResponseBodyListener(this, browser, httpChannel); + return; + } + const browserContext = TargetRegistry.instance().browserContextForBrowser(browser); + if (browserContext) + this._appendExtraHTTPHeaders(httpChannel, browserContext.options.extraHTTPHeaders); + this._appendExtraHTTPHeaders(httpChannel, this._extraHTTPHeaders.get(browser)); + const requestId = this._requestId(httpChannel); + const isRedirect = this._redirectMap.has(requestId); + const interceptors = this._browserInterceptors.get(browser); + if (!interceptors) { + new NotificationCallbacks(this, browser, httpChannel, false); + this._sendOnRequest(httpChannel, false); + new ResponseBodyListener(this, browser, httpChannel); + } else if (isRedirect) { + // We pretend that redirect is interceptable in the protocol, although it's actually not + // and therefore we do not instantiate the interceptor. + // TODO: look into REDIRECT_MODE_MANUAL. + interceptors.set(requestId, { + _resume: () => {}, + _abort: () => {}, + _fulfill: () => {}, + }); + new NotificationCallbacks(this, browser, httpChannel, false); + this._sendOnRequest(httpChannel, true); + new ResponseBodyListener(this, browser, httpChannel); + } else { + new NotificationCallbacks(this, browser, httpChannel, true); + // We'll issue onRequest once it's intercepted. + } + } + + _appendExtraHTTPHeaders(httpChannel, headers) { + if (!headers) + return; + for (const header of headers) + httpChannel.setRequestHeader(header.name, header.value, false /* merge */); + } + + _onIntercepted(httpChannel, interceptor) { + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) { + interceptor._resume(); + return; + } + const interceptors = this._browserInterceptors.get(browser); + this._sendOnRequest(httpChannel, !!interceptors); + if (interceptors) + interceptors.set(this._requestId(httpChannel), interceptor); + else + interceptor._resume(); + } + + _sendOnRequest(httpChannel, isIntercepted) { + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) + return; + const causeType = httpChannel.loadInfo ? httpChannel.loadInfo.externalContentPolicyType : Ci.nsIContentPolicy.TYPE_OTHER; + const requestId = this._requestId(httpChannel); + const redirectedFrom = this._redirectMap.get(requestId); + this._redirectMap.delete(requestId); + this.emit('request', httpChannel, { + url: httpChannel.URI.spec, + isIntercepted, + requestId, + redirectedFrom, + postData: readRequestPostData(httpChannel), + headers: requestHeaders(httpChannel), + method: httpChannel.requestMethod, + navigationId: httpChannel.isMainDocumentChannel ? this._requestId(httpChannel) : undefined, + cause: causeTypeToString(causeType), + }); + } + + _sendOnRequestFinished(httpChannel) { + this.emit('requestfinished', httpChannel, { + requestId: this._requestId(httpChannel), + }); + this._postResumeChannelIdToRequestId.delete(httpChannel.channelId + ''); + } + + _sendOnRequestFailed(httpChannel, error) { + this.emit('requestfailed', httpChannel, { + requestId: this._requestId(httpChannel), + errorCode: helper.getNetworkErrorStatusText(error), + }); + this._postResumeChannelIdToRequestId.delete(httpChannel.channelId + ''); + } + + _onResponse(fromCache, httpChannel, topic) { + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) + return; + httpChannel.QueryInterface(Ci.nsIHttpChannelInternal); + const headers = []; + httpChannel.visitResponseHeaders({ + visitHeader: (name, value) => headers.push({name, value}), + }); + + let remoteIPAddress = undefined; + let remotePort = undefined; + try { + remoteIPAddress = httpChannel.remoteAddress; + remotePort = httpChannel.remotePort; + } catch (e) { + // remoteAddress is not defined for cached requests. + } + this.emit('response', httpChannel, { + requestId: this._requestId(httpChannel), + securityDetails: getSecurityDetails(httpChannel), + fromCache, + headers, + remoteIPAddress, + remotePort, + status: httpChannel.responseStatus, + statusText: httpChannel.responseStatusText, + }); + } + + _onResponseFinished(browser, httpChannel, body) { + const responseStorage = this._browserResponseStorages.get(browser); + if (!responseStorage) + return; + responseStorage.addResponseBody(httpChannel, body); + this._sendOnRequestFinished(httpChannel); + } + + startTrackingBrowserNetwork(browser) { + const value = this._browserSessionCount.get(browser) || 0; + this._browserSessionCount.set(browser, value + 1); + if (value === 0) + this._browserResponseStorages.set(browser, new ResponseStorage(this, MAX_RESPONSE_STORAGE_SIZE, MAX_RESPONSE_STORAGE_SIZE / 10)); + return () => this.stopTrackingBrowserNetwork(browser); + } + + stopTrackingBrowserNetwork(browser) { + const value = this._browserSessionCount.get(browser); + if (value) { + this._browserSessionCount.set(browser, value - 1); + } else { + this._browserSessionCount.delete(browser); + this._browserResponseStorages.delete(browser); + this._browserAuthCredentials.delete(browser); + this._browserInterceptors.delete(browser); + } + } + + dispose() { + this._activityDistributor.removeObserver(this); + const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(SINK_CLASS_ID, this._channelSinkFactory); + Services.catMan.deleteCategoryEntry(SINK_CATEGORY_NAME, SINK_CONTRACT_ID, false); + helper.removeListeners(this._eventListeners); + } +} + +const protocolVersionNames = { + [Ci.nsITransportSecurityInfo.TLS_VERSION_1]: 'TLS 1', + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_1]: 'TLS 1.1', + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_2]: 'TLS 1.2', + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_3]: 'TLS 1.3', +}; + +function getSecurityDetails(httpChannel) { + const securityInfo = httpChannel.securityInfo; + if (!securityInfo) + return null; + securityInfo.QueryInterface(Ci.nsITransportSecurityInfo); + if (!securityInfo.serverCert) + return null; + return { + protocol: protocolVersionNames[securityInfo.protocolVersion] || '', + subjectName: securityInfo.serverCert.commonName, + issuer: securityInfo.serverCert.issuerCommonName, + // Convert to seconds. + validFrom: securityInfo.serverCert.validity.notBefore / 1000 / 1000, + validTo: securityInfo.serverCert.validity.notAfter / 1000 / 1000, + }; +} + +function readRequestPostData(httpChannel) { + if (!(httpChannel instanceof Ci.nsIUploadChannel)) + return undefined; + const iStream = httpChannel.uploadStream; + if (!iStream) + return undefined; + const isSeekableStream = iStream instanceof Ci.nsISeekableStream; + + let prevOffset; + if (isSeekableStream) { + prevOffset = iStream.tell(); + iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + } + + // Read data from the stream. + let text = undefined; + try { + text = NetUtil.readInputStreamToString(iStream, iStream.available()); + const converter = Cc['@mozilla.org/intl/scriptableunicodeconverter'] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = 'UTF-8'; + text = converter.ConvertToUnicode(text); + } catch (err) { + text = undefined; + } + + // Seek locks the file, so seek to the beginning only if necko hasn't + // read it yet, since necko doesn't seek to 0 before reading (at lest + // not till 459384 is fixed). + if (isSeekableStream && prevOffset == 0) + iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + return text; +} + +function requestHeaders(httpChannel) { + const headers = []; + httpChannel.visitRequestHeaders({ + visitHeader: (name, value) => headers.push({name, value}), + }); + return headers; +} + +function causeTypeToString(causeType) { + for (let key in Ci.nsIContentPolicy) { + if (Ci.nsIContentPolicy[key] === causeType) + return key; + } + return 'TYPE_OTHER'; +} + +class ResponseStorage { + constructor(networkObserver, maxTotalSize, maxResponseSize) { + this._networkObserver = networkObserver; + this._totalSize = 0; + this._maxResponseSize = maxResponseSize; + this._maxTotalSize = maxTotalSize; + this._responses = new Map(); + } + + addResponseBody(httpChannel, body) { + if (body.length > this._maxResponseSize) { + this._responses.set(requestId, { + evicted: true, + body: '', + }); + return; + } + let encodings = []; + if ((httpChannel instanceof Ci.nsIEncodedChannel) && httpChannel.contentEncodings && !httpChannel.applyConversion) { + const encodingHeader = httpChannel.getResponseHeader("Content-Encoding"); + encodings = encodingHeader.split(/\s*\t*,\s*\t*/); + } + this._responses.set(this._networkObserver._requestId(httpChannel), {body, encodings}); + this._totalSize += body.length; + if (this._totalSize > this._maxTotalSize) { + for (let [requestId, response] of this._responses) { + this._totalSize -= response.body.length; + response.body = ''; + response.evicted = true; + if (this._totalSize < this._maxTotalSize) + break; + } + } + } + + getBase64EncodedResponse(requestId) { + const response = this._responses.get(requestId); + if (!response) + throw new Error(`Request "${requestId}" is not found`); + if (response.evicted) + return {base64body: '', evicted: true}; + let result = response.body; + if (response.encodings && response.encodings.length) { + for (const encoding of response.encodings) + result = CommonUtils.convertString(result, encoding, 'uncompressed'); + } + return {base64body: btoa(result)}; + } +} + +class ResponseBodyListener { + constructor(networkObserver, browser, httpChannel) { + this._networkObserver = networkObserver; + this._browser = browser; + this._httpChannel = httpChannel; + this._chunks = []; + this.QueryInterface = ChromeUtils.generateQI([Ci.nsIStreamListener]); + httpChannel.QueryInterface(Ci.nsITraceableChannel); + this.originalListener = httpChannel.setNewListener(this); + } + + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + const iStream = new BinaryInputStream(aInputStream); + const sStream = new StorageStream(8192, aCount, null); + const oStream = new BinaryOutputStream(sStream.getOutputStream(0)); + + // Copy received data as they come. + const data = iStream.readBytes(aCount); + this._chunks.push(data); + + oStream.writeBytes(data, aCount); + this.originalListener.onDataAvailable(aRequest, sStream.newInputStream(0), aOffset, aCount); + } + + onStartRequest(aRequest) { + this.originalListener.onStartRequest(aRequest); + } + + onStopRequest(aRequest, aStatusCode) { + this.originalListener.onStopRequest(aRequest, aStatusCode); + const body = this._chunks.join(''); + delete this._chunks; + this._networkObserver._onResponseFinished(this._browser, this._httpChannel, body); + } +} + +class NotificationCallbacks { + constructor(networkObserver, browser, httpChannel, shouldIntercept) { + this._networkObserver = networkObserver; + this._browser = browser; + this._shouldIntercept = shouldIntercept; + this._httpChannel = httpChannel; + this._previousCallbacks = httpChannel.notificationCallbacks; + httpChannel.notificationCallbacks = this; + + const qis = [ + Ci.nsIAuthPrompt2, + Ci.nsIAuthPromptProvider, + Ci.nsIInterfaceRequestor, + ]; + if (shouldIntercept) + qis.push(Ci.nsINetworkInterceptController); + this.QueryInterface = ChromeUtils.generateQI(qis); + } + + getInterface(iid) { + if (iid.equals(Ci.nsIAuthPrompt2) || iid.equals(Ci.nsIAuthPromptProvider)) + return this; + if (this._shouldIntercept && iid.equals(Ci.nsINetworkInterceptController)) + return this; + if (iid.equals(Ci.nsIAuthPrompt)) // Block nsIAuthPrompt - we want nsIAuthPrompt2 to be used instead. + throw Cr.NS_ERROR_NO_INTERFACE; + if (this._previousCallbacks) + return this._previousCallbacks.getInterface(iid); + throw Cr.NS_ERROR_NO_INTERFACE; + } + + _forward(iid, method, args) { + if (!this._previousCallbacks) + return; + try { + const impl = this._previousCallbacks.getInterface(iid); + impl[method].apply(impl, args); + } catch (e) { + if (e.result != Cr.NS_ERROR_NO_INTERFACE) + throw e; + } + } + + // nsIAuthPromptProvider + getAuthPrompt(aPromptReason, iid) { + return this; + } + + // nsIAuthPrompt2 + asyncPromptAuth(aChannel, aCallback, aContext, level, authInfo) { + let canceled = false; + Promise.resolve().then(() => { + if (canceled) + return; + const hasAuth = this.promptAuth(aChannel, level, authInfo); + if (hasAuth) + aCallback.onAuthAvailable(aContext, authInfo); + else + aCallback.onAuthCancelled(aContext, true); + }); + return { + QueryInterface: ChromeUtils.generateQI([Ci.nsICancelable]), + cancel: () => { + aCallback.onAuthCancelled(aContext, false); + canceled = true; + } + }; + } + + // nsIAuthPrompt2 + promptAuth(aChannel, level, authInfo) { + if (authInfo.flags & Ci.nsIAuthInformation.PREVIOUS_FAILED) + return false; + const credentials = this._networkObserver._browserAuthCredentials.get(this._browser); + if (!credentials || credentials.username === null) + return false; + authInfo.username = credentials.username; + authInfo.password = credentials.password; + return true; + } + + // nsINetworkInterceptController + shouldPrepareForIntercept(aURI, channel) { + if (!(channel instanceof Ci.nsIHttpChannel)) + return false; + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + return httpChannel.channelId === this._httpChannel.channelId; + } + + // nsINetworkInterceptController + channelIntercepted(intercepted) { + this._intercepted = intercepted.QueryInterface(Ci.nsIInterceptedChannel); + const httpChannel = this._intercepted.channel.QueryInterface(Ci.nsIHttpChannel); + this._networkObserver._onIntercepted(httpChannel, this); + } + + _resume(method, headers, postData) { + this._networkObserver._resumedRequestIdToHeaders.set(this._networkObserver._requestId(this._httpChannel), { method, headers, postData }); + this._intercepted.resetInterception(); + } + + _fulfill(status, statusText, headers, base64body) { + this._intercepted.synthesizeStatus(status, statusText); + for (const header of headers) + this._intercepted.synthesizeHeader(header.name, header.value); + const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); + if (base64body) + synthesized.data = atob(base64body); + else + synthesized.data = ''; + this._intercepted.startSynthesizedResponse(synthesized, null, null, '', false); + this._intercepted.finishSynthesizedResponse(); + this._networkObserver.emit('response', this._httpChannel, { + requestId: this._networkObserver._requestId(this._httpChannel), + securityDetails: null, + fromCache: false, + headers, + status, + statusText, + }); + this._networkObserver._sendOnRequestFinished(this._httpChannel); + } + + _abort(errorCode) { + const error = errorMap[errorCode] || Cr.NS_ERROR_FAILURE; + this._intercepted.cancelInterception(error); + this._networkObserver._sendOnRequestFailed(this._httpChannel, error); + } +} + +const errorMap = { + 'aborted': Cr.NS_ERROR_ABORT, + 'accessdenied': Cr.NS_ERROR_PORT_ACCESS_NOT_ALLOWED, + 'addressunreachable': Cr.NS_ERROR_UNKNOWN_HOST, + 'blockedbyclient': Cr.NS_ERROR_FAILURE, + 'blockedbyresponse': Cr.NS_ERROR_FAILURE, + 'connectionaborted': Cr.NS_ERROR_NET_INTERRUPT, + 'connectionclosed': Cr.NS_ERROR_FAILURE, + 'connectionfailed': Cr.NS_ERROR_FAILURE, + 'connectionrefused': Cr.NS_ERROR_CONNECTION_REFUSED, + 'connectionreset': Cr.NS_ERROR_NET_RESET, + 'internetdisconnected': Cr.NS_ERROR_OFFLINE, + 'namenotresolved': Cr.NS_ERROR_UNKNOWN_HOST, + 'timedout': Cr.NS_ERROR_NET_TIMEOUT, + 'failed': Cr.NS_ERROR_FAILURE, +}; + +var EXPORTED_SYMBOLS = ['NetworkObserver']; +this.NetworkObserver = NetworkObserver; diff --git a/testing/juggler/TargetRegistry.js b/testing/juggler/TargetRegistry.js new file mode 100644 index 0000000000000000000000000000000000000000..4de911fc5bc9c93b961b0e70474ddbe10e4af8c6 --- /dev/null +++ b/testing/juggler/TargetRegistry.js @@ -0,0 +1,239 @@ +const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +const helper = new Helper(); + +class TargetRegistry { + static instance() { + return TargetRegistry._instance || null; + } + + static initialize(mainWindow, contextManager) { + if (TargetRegistry._instance) + return; + TargetRegistry._instance = new TargetRegistry(mainWindow, contextManager); + } + + constructor(mainWindow, contextManager) { + EventEmitter.decorate(this); + + this._mainWindow = mainWindow; + this._contextManager = contextManager; + this._targets = new Map(); + + this._browserTarget = new BrowserTarget(); + this._targets.set(this._browserTarget.id(), this._browserTarget); + this._tabToTarget = new Map(); + + for (const tab of this._mainWindow.gBrowser.tabs) + this._createTargetForTab(tab); + this._mainWindow.gBrowser.tabContainer.addEventListener('TabOpen', event => { + const target = this._createTargetForTab(event.target); + // If we come here, content will have juggler script from the start, + // and we should wait for initial navigation, unless the tab was window.open'ed. + target._waitForInitialNavigation = !event.target.linkedBrowser.hasContentOpener; + // For pages created before we attach to them, we don't wait for initial + // navigation (target._waitForInitialNavigation is false by default). + }); + this._mainWindow.gBrowser.tabContainer.addEventListener('TabClose', event => { + const tab = event.target; + const target = this._tabToTarget.get(tab); + if (!target) + return; + this._targets.delete(target.id()); + this._tabToTarget.delete(tab); + target.dispose(); + this.emit(TargetRegistry.Events.TargetDestroyed, target.info()); + }); + Services.obs.addObserver(this, 'oop-frameloader-crashed'); + } + + async newPage({browserContextId}) { + const browserContext = this._contextManager.browserContextForId(browserContextId); + const tab = this._mainWindow.gBrowser.addTab('about:blank', { + userContextId: browserContext.userContextId, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + this._mainWindow.gBrowser.selectedTab = tab; + const target = this._tabToTarget.get(tab); + await target._contentReadyPromise; + return target.id(); + } + + async closePage(targetId, runBeforeUnload = false) { + const tab = this.tabForTarget(targetId); + await this._mainWindow.gBrowser.removeTab(tab, { + skipPermitUnload: !runBeforeUnload, + }); + } + + targetInfos() { + return Array.from(this._targets.values()).map(target => target.info()); + } + + targetInfo(targetId) { + const target = this._targets.get(targetId); + return target ? target.info() : null; + } + + browserTargetInfo() { + return this._browserTarget.info(); + } + + tabForTarget(targetId) { + const target = this._targets.get(targetId); + if (!target) + throw new Error(`Target "${targetId}" does not exist!`); + if (!(target instanceof PageTarget)) + throw new Error(`Target "${targetId}" is not a page!`); + return target._tab; + } + + targetForId(targetId) { + return this._targets.get(targetId); + } + + targetForBrowser(browser) { + const tab = this._mainWindow.gBrowser.getTabForBrowser(browser); + return tab ? this._tabToTarget.get(tab) : undefined; + } + + browserContextForBrowser(browser) { + const tab = this._mainWindow.gBrowser.getTabForBrowser(browser); + return tab ? this._contextManager.browserContextForUserContextId(tab.userContextId) : undefined; + } + + _createTargetForTab(tab) { + if (this._tabToTarget.has(tab)) + throw new Error(`Internal error: two targets per tab`); + const openerTarget = tab.openerTab ? this._tabToTarget.get(tab.openerTab) : null; + const target = new PageTarget(this, tab, this._contextManager.browserContextForUserContextId(tab.userContextId), openerTarget); + this._targets.set(target.id(), target); + this._tabToTarget.set(tab, target); + this.emit(TargetRegistry.Events.TargetCreated, target.info()); + return target; + } + + observe(subject, topic, data) { + if (topic === 'oop-frameloader-crashed') { + const browser = subject.ownerElement; + if (!browser) + return; + const target = this.targetForBrowser(browser); + if (!target) + return; + this.emit(TargetRegistry.Events.TargetCrashed, target.id()); + return; + } + } +} + +class PageTarget { + constructor(registry, tab, browserContext, opener) { + this._targetId = helper.generateId(); + this._registry = registry; + this._tab = tab; + this._browserContext = browserContext; + this._openerId = opener ? opener.id() : undefined; + this._url = tab.linkedBrowser.currentURI.spec; + + const navigationListener = { + QueryInterface: ChromeUtils.generateQI([ Ci.nsIWebProgressListener]), + onLocationChange: (aWebProgress, aRequest, aLocation) => this._onNavigated(aLocation), + }; + this._eventListeners = [ + helper.addProgressListener(tab.linkedBrowser, navigationListener, Ci.nsIWebProgress.NOTIFY_LOCATION), + helper.addMessageListener(tab.linkedBrowser.messageManager, 'juggler:content-ready', { + receiveMessage: () => this._onContentReady() + }), + ]; + + this._contentReadyPromise = new Promise(f => this._contentReadyCallback = f); + this._waitForInitialNavigation = false; + + if (browserContext && browserContext.options.viewport) + this.setViewportSize(browserContext.options.viewport.viewportSize); + } + + setViewportSize(viewportSize) { + if (viewportSize) { + const {width, height} = viewportSize; + this._tab.linkedBrowser.style.setProperty('min-width', width + 'px'); + this._tab.linkedBrowser.style.setProperty('min-height', height + 'px'); + this._tab.linkedBrowser.style.setProperty('max-width', width + 'px'); + this._tab.linkedBrowser.style.setProperty('max-height', height + 'px'); + } else { + this._tab.linkedBrowser.style.removeProperty('min-width'); + this._tab.linkedBrowser.style.removeProperty('min-height'); + this._tab.linkedBrowser.style.removeProperty('max-width'); + this._tab.linkedBrowser.style.removeProperty('max-height'); + } + const rect = this._tab.linkedBrowser.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + } + + _onContentReady() { + const sessionIds = []; + const data = { sessionIds, targetInfo: this.info() }; + this._registry.emit(TargetRegistry.Events.PageTargetReady, data); + this._contentReadyCallback(); + return { + browserContextOptions: this._browserContext ? this._browserContext.options : {}, + waitForInitialNavigation: this._waitForInitialNavigation, + sessionIds + }; + } + + id() { + return this._targetId; + } + + info() { + return { + targetId: this.id(), + type: 'page', + url: this._url, + browserContextId: this._browserContext ? this._browserContext.browserContextId : undefined, + openerId: this._openerId, + }; + } + + _onNavigated(aLocation) { + this._url = aLocation.spec; + this._registry.emit(TargetRegistry.Events.TargetChanged, this.info()); + } + + dispose() { + helper.removeListeners(this._eventListeners); + } +} + +class BrowserTarget { + id() { + return 'target-browser'; + } + + info() { + return { + targetId: this.id(), + type: 'browser', + url: '', + } + } +} + +TargetRegistry.Events = { + TargetCreated: Symbol('TargetRegistry.Events.TargetCreated'), + TargetDestroyed: Symbol('TargetRegistry.Events.TargetDestroyed'), + TargetChanged: Symbol('TargetRegistry.Events.TargetChanged'), + TargetCrashed: Symbol('TargetRegistry.Events.TargetCrashed'), + PageTargetReady: Symbol('TargetRegistry.Events.PageTargetReady'), +}; + +var EXPORTED_SYMBOLS = ['TargetRegistry']; +this.TargetRegistry = TargetRegistry; diff --git a/testing/juggler/components/juggler.js b/testing/juggler/components/juggler.js new file mode 100644 index 0000000000000000000000000000000000000000..055b032beff4b7d66a9f33d600dd8d2926867a34 --- /dev/null +++ b/testing/juggler/components/juggler.js @@ -0,0 +1,116 @@ +const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {Dispatcher} = ChromeUtils.import("chrome://juggler/content/protocol/Dispatcher.js"); +const {BrowserContextManager} = ChromeUtils.import("chrome://juggler/content/BrowserContextManager.js"); +const {NetworkObserver} = ChromeUtils.import("chrome://juggler/content/NetworkObserver.js"); +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const helper = new Helper(); + +const Cc = Components.classes; +const Ci = Components.interfaces; + +const FRAME_SCRIPT = "chrome://juggler/content/content/main.js"; + +// Command Line Handler +function CommandLineHandler() { + this._port = -1; +}; + +CommandLineHandler.prototype = { + classDescription: "Sample command-line handler", + classID: Components.ID('{f7a74a33-e2ab-422d-b022-4fb213dd2639}'), + contractID: "@mozilla.org/remote/juggler;1", + _xpcom_categories: [{ + category: "command-line-handler", + entry: "m-juggler" + }], + + /* nsICommandLineHandler */ + handle: async function(cmdLine) { + const jugglerFlag = cmdLine.handleFlagWithParam("juggler", false); + if (!jugglerFlag || isNaN(jugglerFlag)) + return; + this._port = parseInt(jugglerFlag, 10); + Services.obs.addObserver(this, 'sessionstore-windows-restored'); + }, + + observe: async function(subject, topic) { + Services.obs.removeObserver(this, 'sessionstore-windows-restored'); + + const win = await waitForBrowserWindow(); + BrowserContextManager.initialize(); + NetworkObserver.initialize(); + TargetRegistry.initialize(win, BrowserContextManager.instance()); + + const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); + const WebSocketServer = require('devtools/server/socket/websocket-server'); + this._server = Cc["@mozilla.org/network/server-socket;1"].createInstance(Ci.nsIServerSocket); + this._server.initSpecialConnection(this._port, Ci.nsIServerSocket.KeepWhenOffline | Ci.nsIServerSocket.LoopbackOnly, 4); + + const token = helper.generateId(); + + this._server.asyncListen({ + onSocketAccepted: async(socket, transport) => { + const input = transport.openInputStream(0, 0, 0); + const output = transport.openOutputStream(0, 0, 0); + const webSocket = await WebSocketServer.accept(transport, input, output, "/" + token); + new Dispatcher(webSocket); + } + }); + + Services.mm.loadFrameScript(FRAME_SCRIPT, true /* aAllowDelayedLoad */); + dump(`Juggler listening on ws://127.0.0.1:${this._server.port}/${token}\n`); + }, + + QueryInterface: ChromeUtils.generateQI([ Ci.nsICommandLineHandler ]), + + // CHANGEME: change the help info as appropriate, but + // follow the guidelines in nsICommandLineHandler.idl + // specifically, flag descriptions should start at + // character 24, and lines should be wrapped at + // 72 characters with embedded newlines, + // and finally, the string should end with a newline + helpInfo : " --juggler Enable Juggler automation\n" +}; + +var NSGetFactory = XPCOMUtils.generateNSGetFactory([CommandLineHandler]); + +/** + * @return {!Promise} + */ +async function waitForBrowserWindow() { + const windowsIt = Services.wm.getEnumerator('navigator:browser'); + if (windowsIt.hasMoreElements()) + return waitForWindowLoaded(windowsIt.getNext()); + + let fulfill; + let promise = new Promise(x => fulfill = x); + + const listener = { + onOpenWindow: window => { + if (window instanceof Ci.nsIDOMChromeWindow) { + Services.wm.removeListener(listener); + fulfill(waitForWindowLoaded(window)); + } + }, + onCloseWindow: () => {} + }; + Services.wm.addListener(listener); + return promise; + + /** + * @param {!Ci.nsIDOMChromeWindow} window + * @return {!Promise} + */ + function waitForWindowLoaded(window) { + if (window.document.readyState === 'complete') + return window; + return new Promise(fulfill => { + window.addEventListener('load', function listener() { + window.removeEventListener('load', listener); + fulfill(window); + }); + }); + } +} diff --git a/testing/juggler/components/juggler.manifest b/testing/juggler/components/juggler.manifest new file mode 100644 index 0000000000000000000000000000000000000000..50f8930207563e0d6b8a7878fc602dbca54d77fc --- /dev/null +++ b/testing/juggler/components/juggler.manifest @@ -0,0 +1,3 @@ +component {f7a74a33-e2ab-422d-b022-4fb213dd2639} juggler.js +contract @mozilla.org/remote/juggler;1 {f7a74a33-e2ab-422d-b022-4fb213dd2639} +category command-line-handler m-juggler @mozilla.org/remote/juggler;1 diff --git a/testing/juggler/components/moz.build b/testing/juggler/components/moz.build new file mode 100644 index 0000000000000000000000000000000000000000..268fbc361d8053182bb6c27f626e853dd7aeb254 --- /dev/null +++ b/testing/juggler/components/moz.build @@ -0,0 +1,9 @@ +# 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/. + +EXTRA_COMPONENTS += [ + "juggler.js", + "juggler.manifest", +] + diff --git a/testing/juggler/content/ContentSession.js b/testing/juggler/content/ContentSession.js new file mode 100644 index 0000000000000000000000000000000000000000..3891da101e6906ae2a3888e256aefd03f724ab4b --- /dev/null +++ b/testing/juggler/content/ContentSession.js @@ -0,0 +1,68 @@ +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {RuntimeAgent} = ChromeUtils.import('chrome://juggler/content/content/RuntimeAgent.js'); +const {PageAgent} = ChromeUtils.import('chrome://juggler/content/content/PageAgent.js'); + +const helper = new Helper(); + +class ContentSession { + /** + * @param {string} sessionId + * @param {!ContentFrameMessageManager} messageManager + * @param {!FrameTree} frameTree + * @param {!NetworkMonitor} networkMonitor + */ + constructor(sessionId, messageManager, frameTree, networkMonitor) { + this._sessionId = sessionId; + this._messageManager = messageManager; + const runtimeAgent = new RuntimeAgent(this); + const pageAgent = new PageAgent(this, runtimeAgent, frameTree, networkMonitor); + this._agents = { + Page: pageAgent, + Runtime: runtimeAgent, + }; + this._eventListeners = [ + helper.addMessageListener(messageManager, this._sessionId, this._onMessage.bind(this)), + ]; + runtimeAgent.enable(); + pageAgent.enable(); + } + + emitEvent(eventName, params) { + this._messageManager.sendAsyncMessage(this._sessionId, {eventName, params}); + } + + mm() { + return this._messageManager; + } + + async handleMessage({ id, methodName, params }) { + try { + const [domain, method] = methodName.split('.'); + const agent = this._agents[domain]; + if (!agent) + throw new Error(`unknown domain: ${domain}`); + const handler = agent[method]; + if (!handler) + throw new Error(`unknown method: ${domain}.${method}`); + const result = await handler.call(agent, params); + return {id, result}; + } catch (e) { + return {id, error: e.message + '\n' + e.stack}; + } + } + + async _onMessage(msg) { + const response = await this.handleMessage(msg.data); + this._messageManager.sendAsyncMessage(this._sessionId, response); + } + + dispose() { + helper.removeListeners(this._eventListeners); + for (const agent of Object.values(this._agents)) + agent.dispose(); + } +} + +var EXPORTED_SYMBOLS = ['ContentSession']; +this.ContentSession = ContentSession; + diff --git a/testing/juggler/content/FrameTree.js b/testing/juggler/content/FrameTree.js new file mode 100644 index 0000000000000000000000000000000000000000..6735dd39c6cfb6f945d5d094cfb7902f4eb9cefd --- /dev/null +++ b/testing/juggler/content/FrameTree.js @@ -0,0 +1,266 @@ +"use strict"; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); + +const helper = new Helper(); + +class FrameTree { + constructor(rootDocShell, waitForInitialNavigation) { + EventEmitter.decorate(this); + + this._browsingContextGroup = rootDocShell.browsingContext.group; + if (!this._browsingContextGroup.__jugglerFrameTrees) + this._browsingContextGroup.__jugglerFrameTrees = new Set(); + this._browsingContextGroup.__jugglerFrameTrees.add(this); + + this._docShellToFrame = new Map(); + this._frameIdToFrame = new Map(); + this._pageReady = !waitForInitialNavigation; + this._mainFrame = this._createFrame(rootDocShell); + const webProgress = rootDocShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this.QueryInterface = ChromeUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsIWebProgressListener2, + Ci.nsISupportsWeakReference, + ]); + + const flags = Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | + Ci.nsIWebProgress.NOTIFY_FRAME_LOCATION; + this._eventListeners = [ + helper.addObserver(subject => this._onDocShellCreated(subject.QueryInterface(Ci.nsIDocShell)), 'webnavigation-create'), + helper.addObserver(subject => this._onDocShellDestroyed(subject.QueryInterface(Ci.nsIDocShell)), 'webnavigation-destroy'), + helper.addProgressListener(webProgress, this, flags), + ]; + } + + allFramesInBrowsingContextGroup(group) { + const frames = []; + for (const frameTree of (group.__jugglerFrameTrees || [])) + frames.push(...frameTree.frames()); + return frames; + } + + isPageReady() { + return this._pageReady; + } + + frameForDocShell(docShell) { + return this._docShellToFrame.get(docShell) || null; + } + + frame(frameId) { + return this._frameIdToFrame.get(frameId) || null; + } + + frames() { + let result = []; + collect(this._mainFrame); + return result; + + function collect(frame) { + result.push(frame); + for (const subframe of frame._children) + collect(subframe); + } + } + + mainFrame() { + return this._mainFrame; + } + + dispose() { + this._browsingContextGroup.__jugglerFrameTrees.delete(this); + helper.removeListeners(this._eventListeners); + } + + onStateChange(progress, request, flag, status) { + if (!(request instanceof Ci.nsIChannel)) + return; + const channel = request.QueryInterface(Ci.nsIChannel); + const docShell = progress.DOMWindow.docShell; + const frame = this._docShellToFrame.get(docShell); + if (!frame) { + dump(`ERROR: got a state changed event for un-tracked docshell!\n`); + return; + } + + const isStart = flag & Ci.nsIWebProgressListener.STATE_START; + const isTransferring = flag & Ci.nsIWebProgressListener.STATE_TRANSFERRING; + const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + + if (isStart) { + // Starting a new navigation. + frame._pendingNavigationId = this._channelId(channel); + frame._pendingNavigationURL = channel.URI.spec; + this.emit(FrameTree.Events.NavigationStarted, frame); + } else if (isTransferring || (isStop && frame._pendingNavigationId && !status)) { + // Navigation is committed. + for (const subframe of frame._children) + this._detachFrame(subframe); + const navigationId = frame._pendingNavigationId; + frame._pendingNavigationId = null; + frame._pendingNavigationURL = null; + frame._lastCommittedNavigationId = navigationId; + frame._url = channel.URI.spec; + this.emit(FrameTree.Events.NavigationCommitted, frame); + if (frame === this._mainFrame && !this._pageReady) { + this._pageReady = true; + this.emit(FrameTree.Events.PageReady); + } + } else if (isStop && frame._pendingNavigationId && status) { + // Navigation is aborted. + const navigationId = frame._pendingNavigationId; + frame._pendingNavigationId = null; + frame._pendingNavigationURL = null; + this.emit(FrameTree.Events.NavigationAborted, frame, navigationId, helper.getNetworkErrorStatusText(status)); + } + } + + onFrameLocationChange(progress, request, location, flags) { + const docShell = progress.DOMWindow.docShell; + const frame = this._docShellToFrame.get(docShell); + const sameDocumentNavigation = !!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT); + if (frame && sameDocumentNavigation) { + frame._url = location.spec; + this.emit(FrameTree.Events.SameDocumentNavigation, frame); + } + } + + _channelId(channel) { + if (channel instanceof Ci.nsIHttpChannel) { + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + return String(httpChannel.channelId); + } + return helper.generateId(); + } + + _onDocShellCreated(docShell) { + // Bug 1142752: sometimes, the docshell appears to be immediately + // destroyed, bailout early to prevent random exceptions. + if (docShell.isBeingDestroyed()) + return; + // If this docShell doesn't belong to our frame tree - do nothing. + let root = docShell; + while (root.parent) + root = root.parent; + if (root === this._mainFrame._docShell) + this._createFrame(docShell); + } + + _createFrame(docShell) { + const parentFrame = this._docShellToFrame.get(docShell.parent) || null; + const frame = new Frame(this, docShell, parentFrame); + this._docShellToFrame.set(docShell, frame); + this._frameIdToFrame.set(frame.id(), frame); + this.emit(FrameTree.Events.FrameAttached, frame); + return frame; + } + + _onDocShellDestroyed(docShell) { + const frame = this._docShellToFrame.get(docShell); + if (frame) + this._detachFrame(frame); + } + + _detachFrame(frame) { + // Detach all children first + for (const subframe of frame._children) + this._detachFrame(subframe); + this._docShellToFrame.delete(frame._docShell); + this._frameIdToFrame.delete(frame.id()); + if (frame._parentFrame) + frame._parentFrame._children.delete(frame); + frame._parentFrame = null; + this.emit(FrameTree.Events.FrameDetached, frame); + } +} + +FrameTree.Events = { + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + NavigationStarted: 'navigationstarted', + NavigationCommitted: 'navigationcommitted', + NavigationAborted: 'navigationaborted', + SameDocumentNavigation: 'samedocumentnavigation', + PageReady: 'pageready', +}; + +class Frame { + constructor(frameTree, docShell, parentFrame) { + this._frameTree = frameTree; + this._docShell = docShell; + this._children = new Set(); + this._frameId = helper.generateId(); + this._parentFrame = null; + this._url = ''; + if (docShell.domWindow && docShell.domWindow.location) + this._url = docShell.domWindow.location.href; + if (parentFrame) { + this._parentFrame = parentFrame; + parentFrame._children.add(this); + } + + this._lastCommittedNavigationId = null; + this._pendingNavigationId = null; + this._pendingNavigationURL = null; + + this._textInputProcessor = null; + } + + textInputProcessor() { + if (!this._textInputProcessor) { + this._textInputProcessor = Cc["@mozilla.org/text-input-processor;1"].createInstance(Ci.nsITextInputProcessor); + this._textInputProcessor.beginInputTransactionForTests(this._docShell.DOMWindow); + } + return this._textInputProcessor; + } + + pendingNavigationId() { + return this._pendingNavigationId; + } + + pendingNavigationURL() { + return this._pendingNavigationURL; + } + + lastCommittedNavigationId() { + return this._lastCommittedNavigationId; + } + + docShell() { + return this._docShell; + } + + domWindow() { + return this._docShell.domWindow; + } + + name() { + const frameElement = this._docShell.domWindow.frameElement; + let name = ''; + if (frameElement) + name = frameElement.getAttribute('name') || frameElement.getAttribute('id') || ''; + return name; + } + + parentFrame() { + return this._parentFrame; + } + + id() { + return this._frameId; + } + + url() { + return this._url; + } +} + +var EXPORTED_SYMBOLS = ['FrameTree']; +this.FrameTree = FrameTree; + diff --git a/testing/juggler/content/NetworkMonitor.js b/testing/juggler/content/NetworkMonitor.js new file mode 100644 index 0000000000000000000000000000000000000000..be70ea364f9534bb3b344f64970366c32e8c11be --- /dev/null +++ b/testing/juggler/content/NetworkMonitor.js @@ -0,0 +1,62 @@ +"use strict"; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); + +const helper = new Helper(); + +class NetworkMonitor { + constructor(rootDocShell, frameTree) { + this._frameTree = frameTree; + this._requestDetails = new Map(); + + this._eventListeners = [ + helper.addObserver(this._onRequest.bind(this), 'http-on-opening-request'), + ]; + } + + _onRequest(channel) { + if (!(channel instanceof Ci.nsIHttpChannel)) + return; + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + const loadContext = getLoadContext(httpChannel); + if (!loadContext) + return; + const window = loadContext.associatedWindow; + const frame = this._frameTree.frameForDocShell(window.docShell); + if (!frame) + return; + this._requestDetails.set(httpChannel.channelId, { + frameId: frame.id(), + }); + } + + requestDetails(channelId) { + return this._requestDetails.get(channelId) || null; + } + + dispose() { + this._requestDetails.clear(); + helper.removeListeners(this._eventListeners); + } +} + +function getLoadContext(httpChannel) { + let loadContext = null; + try { + if (httpChannel.notificationCallbacks) + loadContext = httpChannel.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (e) {} + try { + if (!loadContext && httpChannel.loadGroup) + loadContext = httpChannel.loadGroup.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (e) { } + return loadContext; +} + + +var EXPORTED_SYMBOLS = ['NetworkMonitor']; +this.NetworkMonitor = NetworkMonitor; + diff --git a/testing/juggler/content/PageAgent.js b/testing/juggler/content/PageAgent.js new file mode 100644 index 0000000000000000000000000000000000000000..994b683a78c43f8fb072cc185f2e5f8d2badec3c --- /dev/null +++ b/testing/juggler/content/PageAgent.js @@ -0,0 +1,912 @@ +"use strict"; +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); + +const helper = new Helper(); + +const registeredWorkerListeners = new Map(); +const workerListener = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIWorkerDebuggerListener]), + onMessage: (wrapped) => { + const message = JSON.parse(wrapped); + const listener = registeredWorkerListeners.get(message.workerId); + if (listener) + listener(message); + }, + onClose: () => { + }, + onError: (filename, lineno, message) => { + dump(`Error in worker: ${message} @${filename}:${lineno}\n`); + }, +}; + +class FrameData { + constructor(agent, frame) { + this._agent = agent; + this._frame = frame; + this._isolatedWorlds = new Map(); + this._workers = new Map(); + this.reset(); + } + + reset() { + if (this.mainContext) + this._agent._runtime.destroyExecutionContext(this.mainContext); + for (const world of this._isolatedWorlds.values()) + this._agent._runtime.destroyExecutionContext(world); + this._isolatedWorlds.clear(); + + this.mainContext = this._agent._runtime.createExecutionContext(this._frame.domWindow(), this._frame.domWindow(), { + frameId: this._frame.id(), + name: '', + }); + + for (const bindingName of this._agent._bindingsToAdd.values()) + this.exposeFunction(bindingName); + for (const {script, worldName} of this._agent._scriptsToEvaluateOnNewDocument.values()) { + const context = worldName ? this.createIsolatedWorld(worldName) : this.mainContext; + try { + let result = context.evaluateScript(script); + if (result && result.objectId) + context.disposeObject(result.objectId); + } catch (e) { + } + } + } + + exposeFunction(name) { + Cu.exportFunction((...args) => { + this._agent._session.emitEvent('Page.bindingCalled', { + executionContextId: this.mainContext.id(), + name, + payload: args[0] + }); + }, this._frame.domWindow(), { + defineAs: name, + }); + } + + createIsolatedWorld(name) { + const principal = [this._frame.domWindow()]; // extended principal + const sandbox = Cu.Sandbox(principal, { + sandboxPrototype: this._frame.domWindow(), + wantComponents: false, + wantExportHelpers: false, + wantXrays: true, + }); + const world = this._agent._runtime.createExecutionContext(this._frame.domWindow(), sandbox, { + frameId: this._frame.id(), + name, + }); + this._isolatedWorlds.set(world.id(), world); + return world; + } + + unsafeObject(objectId) { + if (this.mainContext) { + const result = this.mainContext.unsafeObject(objectId); + if (result) + return result.object; + } + for (const world of this._isolatedWorlds.values()) { + const result = world.unsafeObject(objectId); + if (result) + return result.object; + } + throw new Error('Cannot find object with id = ' + objectId); + } + + workerCreated(workerDebugger) { + const workerId = helper.generateId(); + this._workers.set(workerId, workerDebugger); + this._agent._session.emitEvent('Page.workerCreated', { + workerId, + frameId: this._frame.id(), + url: workerDebugger.url, + }); + // Note: this does not interoperate with firefox devtools. + if (!workerDebugger.isInitialized) { + workerDebugger.initialize('chrome://juggler/content/content/WorkerMain.js'); + workerDebugger.addListener(workerListener); + } + registeredWorkerListeners.set(workerId, message => { + if (message.command === 'dispatch') { + this._agent._session.emitEvent('Page.dispatchMessageFromWorker', { + workerId, + message: message.message, + }); + } + if (message.command === 'console') + this._agent._runtime.filterConsoleMessage(message.hash); + }); + workerDebugger.postMessage(JSON.stringify({command: 'connect', workerId})); + } + + workerDestroyed(wd) { + for (const [workerId, workerDebugger] of this._workers) { + if (workerDebugger === wd) { + this._agent._session.emitEvent('Page.workerDestroyed', { + workerId, + }); + this._workers.delete(workerId); + registeredWorkerListeners.delete(workerId); + } + } + } + + sendMessageToWorker(workerId, message) { + const workerDebugger = this._workers.get(workerId); + if (!workerDebugger) + throw new Error('Cannot find worker with id "' + workerId + '"'); + workerDebugger.postMessage(JSON.stringify({command: 'dispatch', workerId, message})); + } + + dispose() { + for (const [workerId, workerDebugger] of this._workers) { + workerDebugger.postMessage(JSON.stringify({command: 'disconnect', workerId})); + registeredWorkerListeners.delete(workerId); + } + this._workers.clear(); + } +} + +class PageAgent { + constructor(session, runtimeAgent, frameTree, networkMonitor) { + this._session = session; + this._runtime = runtimeAgent; + this._frameTree = frameTree; + this._networkMonitor = networkMonitor; + + this._frameData = new Map(); + this._scriptsToEvaluateOnNewDocument = new Map(); + this._bindingsToAdd = new Set(); + + this._eventListeners = []; + this._enabled = false; + + const docShell = frameTree.mainFrame().docShell(); + this._docShell = docShell; + this._initialDPPX = docShell.contentViewer.overrideDPPX; + this._customScrollbars = null; + + this._wdm = Cc["@mozilla.org/dom/workers/workerdebuggermanager;1"].createInstance(Ci.nsIWorkerDebuggerManager); + this._wdmListener = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIWorkerDebuggerManagerListener]), + onRegister: this._onWorkerCreated.bind(this), + onUnregister: this._onWorkerDestroyed.bind(this), + }; + + this._runtime.setOnErrorFromWorker((domWindow, message, stack) => { + const frame = this._frameTree.frameForDocShell(domWindow.docShell); + if (!frame) + return; + this._session.emitEvent('Page.uncaughtError', { + frameId: frame.id(), + message, + stack, + }); + }); + } + + async awaitViewportDimensions({width, height}) { + const win = this._frameTree.mainFrame().domWindow(); + if (win.innerWidth === width && win.innerHeight === height) + return; + await new Promise(resolve => { + const listener = helper.addEventListener(win, 'resize', () => { + if (win.innerWidth === width && win.innerHeight === height) { + helper.removeListeners([listener]); + resolve(); + } + }); + }); + } + + requestDetails({channelId}) { + return this._networkMonitor.requestDetails(channelId); + } + + async setEmulatedMedia({type, colorScheme}) { + const docShell = this._frameTree.mainFrame().docShell(); + const cv = docShell.contentViewer; + if (type === '') + cv.stopEmulatingMedium(); + else if (type) + cv.emulateMedium(type); + switch (colorScheme) { + case 'light': cv.emulatePrefersColorScheme(cv.PREFERS_COLOR_SCHEME_LIGHT); break; + case 'dark': cv.emulatePrefersColorScheme(cv.PREFERS_COLOR_SCHEME_DARK); break; + case 'no-preference': cv.emulatePrefersColorScheme(cv.PREFERS_COLOR_SCHEME_NO_PREFERENCE); break; + } + } + + addScriptToEvaluateOnNewDocument({script, worldName}) { + const scriptId = helper.generateId(); + this._scriptsToEvaluateOnNewDocument.set(scriptId, {script, worldName}); + if (worldName) { + for (const frameData of this._frameData.values()) + frameData.createIsolatedWorld(worldName); + } + return {scriptId}; + } + + removeScriptToEvaluateOnNewDocument({scriptId}) { + this._scriptsToEvaluateOnNewDocument.delete(scriptId); + } + + setCacheDisabled({cacheDisabled}) { + const enable = Ci.nsIRequest.LOAD_NORMAL; + const disable = Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING; + + const docShell = this._frameTree.mainFrame().docShell(); + docShell.defaultLoadFlags = cacheDisabled ? disable : enable; + } + + enable() { + if (this._enabled) + return; + + this._enabled = true; + // Dispatch frameAttached events for all initial frames + for (const frame of this._frameTree.frames()) { + this._onFrameAttached(frame); + if (frame.url()) + this._onNavigationCommitted(frame); + if (frame.pendingNavigationId()) + this._onNavigationStarted(frame); + } + + this._eventListeners = [ + helper.addObserver(this._filePickerShown.bind(this), 'juggler-file-picker-shown'), + helper.addObserver(this._onDOMWindowCreated.bind(this), 'content-document-global-created'), + helper.addEventListener(this._session.mm(), 'DOMContentLoaded', this._onDOMContentLoaded.bind(this)), + helper.addEventListener(this._session.mm(), 'pageshow', this._onLoad.bind(this)), + helper.addObserver(this._onDocumentOpenLoad.bind(this), 'juggler-document-open-loaded'), + helper.addEventListener(this._session.mm(), 'error', this._onError.bind(this)), + helper.on(this._frameTree, 'frameattached', this._onFrameAttached.bind(this)), + helper.on(this._frameTree, 'framedetached', this._onFrameDetached.bind(this)), + helper.on(this._frameTree, 'navigationstarted', this._onNavigationStarted.bind(this)), + helper.on(this._frameTree, 'navigationcommitted', this._onNavigationCommitted.bind(this)), + helper.on(this._frameTree, 'navigationaborted', this._onNavigationAborted.bind(this)), + helper.on(this._frameTree, 'samedocumentnavigation', this._onSameDocumentNavigation.bind(this)), + helper.on(this._frameTree, 'pageready', () => this._session.emitEvent('Page.ready', {})), + ]; + + this._wdm.addListener(this._wdmListener); + for (const workerDebugger of this._wdm.getWorkerDebuggerEnumerator()) + this._onWorkerCreated(workerDebugger); + + if (this._frameTree.isPageReady()) + this._session.emitEvent('Page.ready', {}); + } + + setInterceptFileChooserDialog({enabled}) { + this._docShell.fileInputInterceptionEnabled = !!enabled; + } + + _frameForWorker(workerDebugger) { + if (workerDebugger.type !== Ci.nsIWorkerDebugger.TYPE_DEDICATED) + return null; + const docShell = workerDebugger.window.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + return frame ? this._frameData.get(frame) : null; + } + + _onWorkerCreated(workerDebugger) { + const frameData = this._frameForWorker(workerDebugger); + if (frameData) + frameData.workerCreated(workerDebugger); + } + + _onWorkerDestroyed(workerDebugger) { + const frameData = this._frameForWorker(workerDebugger); + if (frameData) + frameData.workerDestroyed(workerDebugger); + } + + sendMessageToWorker({frameId, workerId, message}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + this._frameData.get(frame).sendMessageToWorker(workerId, message); + } + + _filePickerShown(inputElement) { + if (inputElement.ownerGlobal.docShell !== this._docShell) + return; + const frameData = this._findFrameForNode(inputElement); + this._session.emitEvent('Page.fileChooserOpened', { + executionContextId: frameData.mainContext.id(), + element: frameData.mainContext.rawValueToRemoteObject(inputElement) + }); + } + + _findFrameForNode(node) { + return Array.from(this._frameData.values()).find(data => { + const doc = data._frame.domWindow().document; + return node === doc || node.ownerDocument === doc; + }); + } + + _onDOMContentLoaded(event) { + const docShell = event.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._session.emitEvent('Page.eventFired', { + frameId: frame.id(), + name: 'DOMContentLoaded', + }); + } + + _onError(errorEvent) { + const docShell = errorEvent.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._session.emitEvent('Page.uncaughtError', { + frameId: frame.id(), + message: errorEvent.message, + stack: errorEvent.error.stack + }); + } + + _onDocumentOpenLoad(document) { + const docShell = document.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._session.emitEvent('Page.eventFired', { + frameId: frame.id(), + name: 'load' + }); + } + + _onLoad(event) { + const docShell = event.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._session.emitEvent('Page.eventFired', { + frameId: frame.id(), + name: 'load' + }); + } + + _onNavigationStarted(frame) { + this._session.emitEvent('Page.navigationStarted', { + frameId: frame.id(), + navigationId: frame.pendingNavigationId(), + url: frame.pendingNavigationURL(), + }); + } + + _onNavigationAborted(frame, navigationId, errorText) { + this._session.emitEvent('Page.navigationAborted', { + frameId: frame.id(), + navigationId, + errorText, + }); + } + + _onSameDocumentNavigation(frame) { + this._session.emitEvent('Page.sameDocumentNavigation', { + frameId: frame.id(), + url: frame.url(), + }); + } + + _onNavigationCommitted(frame) { + this._session.emitEvent('Page.navigationCommitted', { + frameId: frame.id(), + navigationId: frame.lastCommittedNavigationId() || undefined, + url: frame.url(), + name: frame.name(), + }); + } + + _onDOMWindowCreated(window) { + const docShell = window.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._frameData.get(frame).reset(); + } + + _onFrameAttached(frame) { + this._session.emitEvent('Page.frameAttached', { + frameId: frame.id(), + parentFrameId: frame.parentFrame() ? frame.parentFrame().id() : undefined, + }); + this._frameData.set(frame, new FrameData(this, frame)); + } + + _onFrameDetached(frame) { + this._frameData.delete(frame); + this._session.emitEvent('Page.frameDetached', { + frameId: frame.id(), + }); + } + + dispose() { + for (const frameData of this._frameData.values()) + frameData.dispose(); + helper.removeListeners(this._eventListeners); + this._wdm.removeListener(this._wdmListener); + } + + async navigate({frameId, url, referer}) { + try { + const uri = NetUtil.newURI(url); + } catch (e) { + throw new Error(`Invalid url: "${url}"`); + } + let referrerURI = null; + let referrerInfo = null; + if (referer) { + try { + referrerURI = NetUtil.newURI(referer); + const ReferrerInfo = Components.Constructor( + '@mozilla.org/referrer-info;1', + 'nsIReferrerInfo', + 'init' + ); + referrerInfo = new ReferrerInfo(Ci.nsIHttpChannel.REFERRER_POLICY_UNSET, true, referrerURI); + } catch (e) { + throw new Error(`Invalid referer: "${referer}"`); + } + } + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell().QueryInterface(Ci.nsIWebNavigation); + docShell.loadURI(url, { + flags: Ci.nsIWebNavigation.LOAD_FLAGS_NONE, + referrerInfo, + postData: null, + headers: null, + }); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async reload({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell().QueryInterface(Ci.nsIWebNavigation); + docShell.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async goBack({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell(); + if (!docShell.canGoBack) + return {navigationId: null, navigationURL: null}; + docShell.goBack(); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async goForward({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell(); + if (!docShell.canGoForward) + return {navigationId: null, navigationURL: null}; + docShell.goForward(); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + addBinding({name}) { + if (this._bindingsToAdd.has(name)) + throw new Error(`Binding with name ${name} already exists`); + this._bindingsToAdd.add(name); + for (const frameData of this._frameData.values()) + frameData.exposeFunction(name); + } + + async adoptNode({frameId, objectId, executionContextId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + const context = this._runtime.findExecutionContext(executionContextId); + const fromPrincipal = unsafeObject.nodePrincipal; + const toFrame = this._frameTree.frame(context.auxData().frameId); + const toPrincipal = toFrame.domWindow().document.nodePrincipal; + if (!toPrincipal.subsumes(fromPrincipal)) + return { remoteObject: null }; + return { remoteObject: context.rawValueToRemoteObject(unsafeObject) }; + } + + async setFileInputFiles({objectId, frameId, files}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + if (!unsafeObject) + throw new Error('Object is not input!'); + const nsFiles = await Promise.all(files.map(filePath => File.createFromFileName(filePath))); + unsafeObject.mozSetFileArray(nsFiles); + } + + getContentQuads({objectId, frameId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + if (!unsafeObject.getBoxQuads) + throw new Error('RemoteObject is not a node'); + const quads = unsafeObject.getBoxQuads({relativeTo: this._frameTree.mainFrame().domWindow().document}).map(quad => { + return { + p1: {x: quad.p1.x, y: quad.p1.y}, + p2: {x: quad.p2.x, y: quad.p2.y}, + p3: {x: quad.p3.x, y: quad.p3.y}, + p4: {x: quad.p4.x, y: quad.p4.y}, + }; + }); + return {quads}; + } + + describeNode({objectId, frameId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + const browsingContextGroup = frame.docShell().browsingContext.group; + const frames = this._frameTree.allFramesInBrowsingContextGroup(browsingContextGroup); + let contentFrame; + let ownerFrame; + for (const frame of frames) { + if (unsafeObject.contentWindow && frame.docShell() === unsafeObject.contentWindow.docShell) + contentFrame = frame; + const document = frame.domWindow().document; + if (unsafeObject === document || unsafeObject.ownerDocument === document) + ownerFrame = frame; + } + return { + contentFrameId: contentFrame ? contentFrame.id() : undefined, + ownerFrameId: ownerFrame ? ownerFrame.id() : undefined, + }; + } + + async scrollIntoViewIfNeeded({objectId, frameId, rect}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + if (!unsafeObject.isConnected) + throw new Error('Node is detached from document'); + await this._scrollNodeIntoViewIfNeeded(unsafeObject); + const box = this._getBoundingBox(unsafeObject); + if (rect) { + box.x += rect.x; + box.y += rect.y; + box.width = rect.width; + box.height = rect.height; + } + this._scrollRectIntoViewIfNeeded(unsafeObject, box); + } + + async _scrollNodeIntoViewIfNeeded(node) { + if (node.nodeType !== 1) + node = node.parentElement; + if (!node.ownerDocument || !node.ownerDocument.defaultView) + return; + const global = node.ownerDocument.defaultView; + const visibleRatio = await new Promise(resolve => { + const observer = new global.IntersectionObserver(entries => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(node); + // Firefox doesn't call IntersectionObserver callback unless + // there are rafs. + global.requestAnimationFrame(() => {}); + }); + if (visibleRatio !== 1.0) + node.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + } + + _scrollRectIntoViewIfNeeded(node, rect) { + // TODO: implement. + } + + _getBoundingBox(unsafeObject) { + if (!unsafeObject.getBoxQuads) + throw new Error('RemoteObject is not a node'); + const quads = unsafeObject.getBoxQuads({relativeTo: this._frameTree.mainFrame().domWindow().document}); + if (!quads.length) + return; + let x1 = Infinity; + let y1 = Infinity; + let x2 = -Infinity; + let y2 = -Infinity; + for (const quad of quads) { + const boundingBox = quad.getBounds(); + x1 = Math.min(boundingBox.x, x1); + y1 = Math.min(boundingBox.y, y1); + x2 = Math.max(boundingBox.x + boundingBox.width, x2); + y2 = Math.max(boundingBox.y + boundingBox.height, y2); + } + return {x: x1, y: y1, width: x2 - x1, height: y2 - y1}; + } + + async getBoundingBox({frameId, objectId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + const box = this._getBoundingBox(unsafeObject); + if (!box) + return {boundingBox: null}; + return {boundingBox: {x: box.x + frame.domWindow().scrollX, y: box.y + frame.domWindow().scrollY, width: box.width, height: box.height}}; + } + + async screenshot({mimeType, fullPage, clip}) { + const content = this._session.mm().content; + if (clip) { + const data = takeScreenshot(content, clip.x, clip.y, clip.width, clip.height, mimeType); + return {data}; + } + if (fullPage) { + const rect = content.document.documentElement.getBoundingClientRect(); + const width = content.innerWidth + content.scrollMaxX - content.scrollMinX; + const height = content.innerHeight + content.scrollMaxY - content.scrollMinY; + const data = takeScreenshot(content, 0, 0, width, height, mimeType); + return {data}; + } + const data = takeScreenshot(content, content.scrollX, content.scrollY, content.innerWidth, content.innerHeight, mimeType); + return {data}; + } + + async dispatchKeyEvent({type, keyCode, code, key, repeat, location, text}) { + const frame = this._frameTree.mainFrame(); + const tip = frame.textInputProcessor(); + if (key === 'Meta' && Services.appinfo.OS !== 'Darwin') + key = 'OS'; + else if (key === 'OS' && Services.appinfo.OS === 'Darwin') + key = 'Meta'; + let keyEvent = new (frame.domWindow().KeyboardEvent)("", { + key, + code, + location, + repeat, + keyCode + }); + if (type === 'keydown') { + if (text && text !== key) { + tip.commitCompositionWith(text, keyEvent); + } else { + const flags = 0; + tip.keydown(keyEvent, flags); + } + } else if (type === 'keyup') { + if (text) + throw new Error(`keyup does not support text option`); + const flags = 0; + tip.keyup(keyEvent, flags); + } else { + throw new Error(`Unknown type ${type}`); + } + } + + async dispatchTouchEvent({type, touchPoints, modifiers}) { + const frame = this._frameTree.mainFrame(); + const defaultPrevented = frame.domWindow().windowUtils.sendTouchEvent( + type.toLowerCase(), + touchPoints.map((point, id) => id), + touchPoints.map(point => point.x), + touchPoints.map(point => point.y), + touchPoints.map(point => point.radiusX === undefined ? 1.0 : point.radiusX), + touchPoints.map(point => point.radiusY === undefined ? 1.0 : point.radiusY), + touchPoints.map(point => point.rotationAngle === undefined ? 0.0 : point.rotationAngle), + touchPoints.map(point => point.force === undefined ? 1.0 : point.force), + touchPoints.length, + modifiers); + return {defaultPrevented}; + } + + async dispatchMouseEvent({type, x, y, button, clickCount, modifiers, buttons}) { + const frame = this._frameTree.mainFrame(); + frame.domWindow().windowUtils.sendMouseEvent( + type, + x, + y, + button, + clickCount, + modifiers, + false /*aIgnoreRootScrollFrame*/, + undefined /*pressure*/, + undefined /*inputSource*/, + undefined /*isDOMEventSynthesized*/, + undefined /*isWidgetEventSynthesized*/, + buttons); + if (type === 'mousedown' && button === 2) { + frame.domWindow().windowUtils.sendMouseEvent( + 'contextmenu', + x, + y, + button, + clickCount, + modifiers, + false /*aIgnoreRootScrollFrame*/, + undefined /*pressure*/, + undefined /*inputSource*/, + undefined /*isDOMEventSynthesized*/, + undefined /*isWidgetEventSynthesized*/, + buttons); + } + } + + async insertText({text}) { + const frame = this._frameTree.mainFrame(); + frame.textInputProcessor().commitCompositionWith(text); + } + + async crash() { + dump(`Crashing intentionally\n`); + // This is to intentionally crash the frame. + // We crash by using js-ctypes and dereferencing + // a bad pointer. The crash should happen immediately + // upon loading this frame script. + const { ctypes } = ChromeUtils.import('resource://gre/modules/ctypes.jsm'); + ChromeUtils.privateNoteIntentionalCrash(); + const zero = new ctypes.intptr_t(8); + const badptr = ctypes.cast(zero, ctypes.PointerType(ctypes.int32_t)); + badptr.contents; + } + + async getFullAXTree({objectId}) { + let unsafeObject = null; + if (objectId) { + unsafeObject = this._frameData.get(this._frameTree.mainFrame()).unsafeObject(objectId); + if (!unsafeObject) + throw new Error(`No object found for id "${objectId}"`); + } + + const service = Cc["@mozilla.org/accessibilityService;1"] + .getService(Ci.nsIAccessibilityService); + const document = this._frameTree.mainFrame().domWindow().document; + const docAcc = service.getAccessibleFor(document); + + while (docAcc.document.isUpdatePendingForJugglerAccessibility) + await new Promise(x => this._frameTree.mainFrame().domWindow().requestAnimationFrame(x)); + + async function waitForQuiet() { + let state = {}; + docAcc.getState(state, {}); + if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) + return; + let resolve, reject; + const promise = new Promise((x, y) => {resolve = x, reject = y}); + let eventObserver = { + observe(subject, topic) { + if (topic !== "accessible-event") { + return; + } + + // If event type does not match expected type, skip the event. + let event = subject.QueryInterface(Ci.nsIAccessibleEvent); + if (event.eventType !== Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE) { + return; + } + + // If event's accessible does not match expected accessible, + // skip the event. + if (event.accessible !== docAcc) { + return; + } + + Services.obs.removeObserver(this, "accessible-event"); + resolve(); + }, + }; + Services.obs.addObserver(eventObserver, "accessible-event"); + return promise; + } + function buildNode(accElement) { + let a = {}, b = {}; + accElement.getState(a, b); + const tree = { + role: service.getStringRole(accElement.role), + name: accElement.name || '', + }; + if (unsafeObject && unsafeObject === accElement.DOMNode) + tree.foundObject = true; + for (const userStringProperty of [ + 'value', + 'description' + ]) { + tree[userStringProperty] = accElement[userStringProperty] || undefined; + } + + const states = {}; + for (const name of service.getStringStates(a.value, b.value)) + states[name] = true; + for (const name of ['selected', + 'focused', + 'pressed', + 'focusable', + 'haspopup', + 'required', + 'invalid', + 'modal', + 'editable', + 'busy', + 'checked', + 'multiselectable']) { + if (states[name]) + tree[name] = true; + } + + if (states['multi line']) + tree['multiline'] = true; + if (states['editable'] && states['readonly']) + tree['readonly'] = true; + if (states['checked']) + tree['checked'] = true; + if (states['mixed']) + tree['checked'] = 'mixed'; + if (states['expanded']) + tree['expanded'] = true; + else if (states['collapsed']) + tree['expanded'] = false; + if (!states['enabled']) + tree['disabled'] = true; + + const attributes = {}; + if (accElement.attributes) { + for (const { key, value } of accElement.attributes.enumerate()) { + attributes[key] = value; + } + } + for (const numericalProperty of ['level']) { + if (numericalProperty in attributes) + tree[numericalProperty] = parseFloat(attributes[numericalProperty]); + } + for (const stringProperty of ['tag', 'roledescription', 'valuetext', 'orientation', 'autocomplete', 'keyshortcuts']) { + if (stringProperty in attributes) + tree[stringProperty] = attributes[stringProperty]; + } + const children = []; + + for (let child = accElement.firstChild; child; child = child.nextSibling) { + children.push(buildNode(child)); + } + if (children.length) + tree.children = children; + return tree; + } + await waitForQuiet(); + return { + tree: buildNode(docAcc) + }; + } +} + +function takeScreenshot(win, left, top, width, height, mimeType) { + const MAX_SKIA_DIMENSIONS = 32767; + + const scale = win.devicePixelRatio; + const canvasWidth = width * scale; + const canvasHeight = height * scale; + + if (canvasWidth > MAX_SKIA_DIMENSIONS || canvasHeight > MAX_SKIA_DIMENSIONS) + throw new Error('Cannot take screenshot larger than ' + MAX_SKIA_DIMENSIONS); + + const canvas = win.document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + let ctx = canvas.getContext('2d'); + ctx.scale(scale, scale); + ctx.drawWindow(win, left, top, width, height, 'rgb(255,255,255)', ctx.DRAWWINDOW_DRAW_CARET); + const dataURL = canvas.toDataURL(mimeType); + return dataURL.substring(dataURL.indexOf(',') + 1); +}; + +var EXPORTED_SYMBOLS = ['PageAgent']; +this.PageAgent = PageAgent; + diff --git a/testing/juggler/content/RuntimeAgent.js b/testing/juggler/content/RuntimeAgent.js new file mode 100644 index 0000000000000000000000000000000000000000..c6568d0e98cb3405877f711a3242ab31b55ce7c1 --- /dev/null +++ b/testing/juggler/content/RuntimeAgent.js @@ -0,0 +1,549 @@ +"use strict"; +// Note: this file should be loadabale with eval() into worker environment. +// Avoid Components.*, ChromeUtils and global const variables. + +if (!this.Debugger) { + // Worker has a Debugger defined already. + const {addDebuggerToGlobal} = ChromeUtils.import("resource://gre/modules/jsdebugger.jsm", {}); + addDebuggerToGlobal(Components.utils.getGlobalForObject(this)); +} + +let lastId = 0; +function generateId() { + return 'id-' + (++lastId); +} + +const consoleLevelToProtocolType = { + 'dir': 'dir', + 'log': 'log', + 'debug': 'debug', + 'info': 'info', + 'error': 'error', + 'warn': 'warning', + 'dirxml': 'dirxml', + 'table': 'table', + 'trace': 'trace', + 'clear': 'clear', + 'group': 'startGroup', + 'groupCollapsed': 'startGroupCollapsed', + 'groupEnd': 'endGroup', + 'assert': 'assert', + 'profile': 'profile', + 'profileEnd': 'profileEnd', + 'count': 'count', + 'countReset': 'countReset', + 'time': null, + 'timeLog': 'timeLog', + 'timeEnd': 'timeEnd', + 'timeStamp': 'timeStamp', +}; + +const disallowedMessageCategories = new Set([ + 'XPConnect JavaScript', + 'component javascript', + 'chrome javascript', + 'chrome registration', + 'XBL', + 'XBL Prototype Handler', + 'XBL Content Sink', + 'xbl javascript', +]); + +class RuntimeAgent { + constructor(session, onWorkerConsoleMessage) { + this._debugger = new Debugger(); + this._pendingPromises = new Map(); + this._session = session; + this._executionContexts = new Map(); + this._windowToExecutionContext = new Map(); + this._eventListeners = []; + this._enabled = false; + this._filteredConsoleMessageHashes = new Set(); + this._onErrorFromWorker = null; + this._onWorkerConsoleMessage = onWorkerConsoleMessage; + } + + enable() { + if (this._enabled) + return; + this._enabled = true; + for (const executionContext of this._executionContexts.values()) + this._notifyExecutionContextCreated(executionContext); + + const isWorker = !!this._onWorkerConsoleMessage; + if (isWorker) { + this._registerConsoleEventHandler(); + } else { + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + this._registerConsoleServiceListener(Services); + this._registerConsoleObserver(Services); + } + } + + _registerConsoleServiceListener(Services) { + const Ci = Components.interfaces; + const consoleServiceListener = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIConsoleListener]), + + observe: message => { + if (!(message instanceof Ci.nsIScriptError) || !message.outerWindowID || + !message.category || disallowedMessageCategories.has(message.category)) { + return; + } + const errorWindow = Services.wm.getOuterWindowWithId(message.outerWindowID); + if (message.category === 'Web Worker' && (message.flags & Ci.nsIScriptError.exceptionFlag)) { + if (this._onErrorFromWorker) + this._onErrorFromWorker(errorWindow, message.message, '' + message.stack); + return; + } + const executionContext = this._windowToExecutionContext.get(errorWindow); + if (!executionContext) + return; + const typeNames = { + [Ci.nsIConsoleMessage.debug]: 'debug', + [Ci.nsIConsoleMessage.info]: 'info', + [Ci.nsIConsoleMessage.warn]: 'warn', + [Ci.nsIConsoleMessage.error]: 'error', + }; + this._session.emitEvent('Runtime.console', { + args: [{ + value: message.message, + }], + type: typeNames[message.logLevel], + executionContextId: executionContext.id(), + location: { + lineNumber: message.lineNumber, + columnNumber: message.columnNumber, + url: message.sourceName, + }, + }); + }, + }; + Services.console.registerListener(consoleServiceListener); + this._eventListeners.push(() => Services.console.unregisterListener(consoleServiceListener)); + } + + _registerConsoleObserver(Services) { + const consoleObserver = ({wrappedJSObject}, topic, data) => { + const hash = this._consoleMessageHash(wrappedJSObject); + if (this._filteredConsoleMessageHashes.has(hash)) { + this._filteredConsoleMessageHashes.delete(hash); + return; + } + const executionContext = Array.from(this._executionContexts.values()).find(context => { + const domWindow = context._domWindow; + return domWindow && domWindow.windowUtils.currentInnerWindowID === wrappedJSObject.innerID; + }); + if (!executionContext) + return; + this._onConsoleMessage(executionContext, wrappedJSObject); + }; + Services.obs.addObserver(consoleObserver, "console-api-log-event"); + this._eventListeners.push(() => Services.obs.removeObserver(consoleObserver, "console-api-log-event")); + } + + _registerConsoleEventHandler() { + setConsoleEventHandler(message => { + this._onWorkerConsoleMessage(this._consoleMessageHash(message)); + const executionContext = Array.from(this._executionContexts.values())[0]; + this._onConsoleMessage(executionContext, message); + }); + this._eventListeners.push(() => setConsoleEventHandler(null)); + } + + filterConsoleMessage(messageHash) { + this._filteredConsoleMessageHashes.add(messageHash); + } + + setOnErrorFromWorker(onErrorFromWorker) { + this._onErrorFromWorker = onErrorFromWorker; + } + + _consoleMessageHash(message) { + return `${message.timeStamp}/${message.filename}/${message.lineNumber}/${message.columnNumber}/${message.sourceId}/${message.level}`; + } + + _onConsoleMessage(executionContext, message) { + const type = consoleLevelToProtocolType[message.level]; + if (!type) + return; + const args = message.arguments.map(arg => executionContext.rawValueToRemoteObject(arg)); + this._session.emitEvent('Runtime.console', { + args, + type, + executionContextId: executionContext.id(), + location: { + lineNumber: message.lineNumber - 1, + columnNumber: message.columnNumber - 1, + url: message.filename, + }, + }); + } + + _notifyExecutionContextCreated(executionContext) { + if (!this._enabled) + return; + this._session.emitEvent('Runtime.executionContextCreated', { + executionContextId: executionContext._id, + auxData: executionContext._auxData, + }); + } + + _notifyExecutionContextDestroyed(executionContext) { + if (!this._enabled) + return; + this._session.emitEvent('Runtime.executionContextDestroyed', { + executionContextId: executionContext._id, + }); + } + + dispose() { + for (const tearDown of this._eventListeners) + tearDown.call(null); + this._eventListeners = []; + } + + async _awaitPromise(executionContext, obj, exceptionDetails = {}) { + if (obj.promiseState === 'fulfilled') + return {success: true, obj: obj.promiseValue}; + if (obj.promiseState === 'rejected') { + const global = executionContext._global; + exceptionDetails.text = global.executeInGlobalWithBindings('e.message', {e: obj.promiseReason}).return; + exceptionDetails.stack = global.executeInGlobalWithBindings('e.stack', {e: obj.promiseReason}).return; + return {success: false, obj: null}; + } + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + this._pendingPromises.set(obj.promiseID, {resolve, reject, executionContext, exceptionDetails}); + if (this._pendingPromises.size === 1) + this._debugger.onPromiseSettled = this._onPromiseSettled.bind(this); + return await promise; + } + + _onPromiseSettled(obj) { + const pendingPromise = this._pendingPromises.get(obj.promiseID); + if (!pendingPromise) + return; + this._pendingPromises.delete(obj.promiseID); + if (!this._pendingPromises.size) + this._debugger.onPromiseSettled = undefined; + + if (obj.promiseState === 'fulfilled') { + pendingPromise.resolve({success: true, obj: obj.promiseValue}); + return; + }; + const global = pendingPromise.executionContext._global; + pendingPromise.exceptionDetails.text = global.executeInGlobalWithBindings('e.message', {e: obj.promiseReason}).return; + pendingPromise.exceptionDetails.stack = global.executeInGlobalWithBindings('e.stack', {e: obj.promiseReason}).return; + pendingPromise.resolve({success: false, obj: null}); + } + + createExecutionContext(domWindow, contextGlobal, auxData) { + // Note: domWindow is null for workers. + const context = new ExecutionContext(this, domWindow, contextGlobal, this._debugger.addDebuggee(contextGlobal), auxData); + this._executionContexts.set(context._id, context); + if (domWindow) + this._windowToExecutionContext.set(domWindow, context); + this._notifyExecutionContextCreated(context); + return context; + } + + findExecutionContext(executionContextId) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + return executionContext; + } + + destroyExecutionContext(destroyedContext) { + for (const [promiseID, {reject, executionContext}] of this._pendingPromises) { + if (executionContext === destroyedContext) { + reject(new Error('Execution context was destroyed!')); + this._pendingPromises.delete(promiseID); + } + } + if (!this._pendingPromises.size) + this._debugger.onPromiseSettled = undefined; + this._debugger.removeDebuggee(destroyedContext._contextGlobal); + this._executionContexts.delete(destroyedContext._id); + if (destroyedContext._domWindow) + this._windowToExecutionContext.delete(destroyedContext._domWindow); + this._notifyExecutionContextDestroyed(destroyedContext); + } + + async evaluate({executionContextId, expression, returnByValue}) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + const exceptionDetails = {}; + let result = await executionContext.evaluateScript(expression, exceptionDetails); + if (!result) + return {exceptionDetails}; + if (returnByValue) + result = executionContext.ensureSerializedToValue(result); + return {result}; + } + + async callFunction({executionContextId, functionDeclaration, args, returnByValue}) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + const exceptionDetails = {}; + let result = await executionContext.evaluateFunction(functionDeclaration, args, exceptionDetails); + if (!result) + return {exceptionDetails}; + if (returnByValue) + result = executionContext.ensureSerializedToValue(result); + return {result}; + } + + async getObjectProperties({executionContextId, objectId}) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + return {properties: executionContext.getObjectProperties(objectId)}; + } + + async disposeObject({executionContextId, objectId}) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + return executionContext.disposeObject(objectId); + } +} + +class ExecutionContext { + constructor(runtime, domWindow, contextGlobal, global, auxData) { + this._runtime = runtime; + this._domWindow = domWindow; + this._contextGlobal = contextGlobal; + this._global = global; + this._remoteObjects = new Map(); + this._id = generateId(); + this._auxData = auxData; + this._jsonStringifyObject = this._global.executeInGlobal(`((stringify, dateProto, object) => { + const oldToJson = dateProto.toJSON; + dateProto.toJSON = undefined; + let hasSymbol = false; + const result = stringify(object, (key, value) => { + if (typeof value === 'symbol') + hasSymbol = true; + return value; + }); + dateProto.toJSON = oldToJson; + return hasSymbol ? undefined : result; + }).bind(null, JSON.stringify.bind(JSON), Date.prototype)`).return; + } + + id() { + return this._id; + } + + auxData() { + return this._auxData; + } + + async evaluateScript(script, exceptionDetails = {}) { + const userInputHelper = this._domWindow ? this._domWindow.windowUtils.setHandlingUserInput(true) : null; + let {success, obj} = this._getResult(this._global.executeInGlobal(script), exceptionDetails); + userInputHelper && userInputHelper.destruct(); + if (!success) + return null; + if (obj && obj.isPromise) { + const awaitResult = await this._runtime._awaitPromise(this, obj, exceptionDetails); + if (!awaitResult.success) + return null; + obj = awaitResult.obj; + } + return this._createRemoteObject(obj); + } + + async evaluateFunction(functionText, args, exceptionDetails = {}) { + const funEvaluation = this._getResult(this._global.executeInGlobal('(' + functionText + ')'), exceptionDetails); + if (!funEvaluation.success) + return null; + if (!funEvaluation.obj.callable) + throw new Error('functionText does not evaluate to a function!'); + args = args.map(arg => { + if (arg.objectId) { + if (!this._remoteObjects.has(arg.objectId)) + throw new Error('Cannot find object with id = ' + arg.objectId); + return this._remoteObjects.get(arg.objectId); + } + switch (arg.unserializableValue) { + case 'Infinity': return Infinity; + case '-Infinity': return -Infinity; + case '-0': return -0; + case 'NaN': return NaN; + default: return this._toDebugger(arg.value); + } + }); + const userInputHelper = this._domWindow ? this._domWindow.windowUtils.setHandlingUserInput(true) : null; + let {success, obj} = this._getResult(funEvaluation.obj.apply(null, args), exceptionDetails); + userInputHelper && userInputHelper.destruct(); + if (!success) + return null; + if (obj && obj.isPromise) { + const awaitResult = await this._runtime._awaitPromise(this, obj, exceptionDetails); + if (!awaitResult.success) + return null; + obj = awaitResult.obj; + } + return this._createRemoteObject(obj); + } + + unsafeObject(objectId) { + if (!this._remoteObjects.has(objectId)) + return; + return { object: this._remoteObjects.get(objectId).unsafeDereference() }; + } + + rawValueToRemoteObject(rawValue) { + const debuggerObj = this._global.makeDebuggeeValue(rawValue); + return this._createRemoteObject(debuggerObj); + } + + _instanceOf(debuggerObj, rawObj, className) { + if (this._domWindow) + return rawObj instanceof this._domWindow[className]; + return this._global.executeInGlobalWithBindings('o instanceof this[className]', {o: debuggerObj, className: this._global.makeDebuggeeValue(className)}).return; + } + + _createRemoteObject(debuggerObj) { + if (debuggerObj instanceof Debugger.Object) { + const objectId = generateId(); + this._remoteObjects.set(objectId, debuggerObj); + const rawObj = debuggerObj.unsafeDereference(); + const type = typeof rawObj; + let subtype = undefined; + if (debuggerObj.isProxy) + subtype = 'proxy'; + else if (Array.isArray(rawObj)) + subtype = 'array'; + else if (Object.is(rawObj, null)) + subtype = 'null'; + else if (this._instanceOf(debuggerObj, rawObj, 'Node')) + subtype = 'node'; + else if (this._instanceOf(debuggerObj, rawObj, 'RegExp')) + subtype = 'regexp'; + else if (this._instanceOf(debuggerObj, rawObj, 'Date')) + subtype = 'date'; + else if (this._instanceOf(debuggerObj, rawObj, 'Map')) + subtype = 'map'; + else if (this._instanceOf(debuggerObj, rawObj, 'Set')) + subtype = 'set'; + else if (this._instanceOf(debuggerObj, rawObj, 'WeakMap')) + subtype = 'weakmap'; + else if (this._instanceOf(debuggerObj, rawObj, 'WeakSet')) + subtype = 'weakset'; + else if (this._instanceOf(debuggerObj, rawObj, 'Error')) + subtype = 'error'; + else if (this._instanceOf(debuggerObj, rawObj, 'Promise')) + subtype = 'promise'; + else if ((this._instanceOf(debuggerObj, rawObj, 'Int8Array')) || (this._instanceOf(debuggerObj, rawObj, 'Uint8Array')) || + (this._instanceOf(debuggerObj, rawObj, 'Uint8ClampedArray')) || (this._instanceOf(debuggerObj, rawObj, 'Int16Array')) || + (this._instanceOf(debuggerObj, rawObj, 'Uint16Array')) || (this._instanceOf(debuggerObj, rawObj, 'Int32Array')) || + (this._instanceOf(debuggerObj, rawObj, 'Uint32Array')) || (this._instanceOf(debuggerObj, rawObj, 'Float32Array')) || + (this._instanceOf(debuggerObj, rawObj, 'Float64Array'))) { + subtype = 'typedarray'; + } + return {objectId, type, subtype}; + } + if (typeof debuggerObj === 'symbol') { + const objectId = generateId(); + this._remoteObjects.set(objectId, debuggerObj); + return {objectId, type: 'symbol'}; + } + + let unserializableValue = undefined; + if (Object.is(debuggerObj, NaN)) + unserializableValue = 'NaN'; + else if (Object.is(debuggerObj, -0)) + unserializableValue = '-0'; + else if (Object.is(debuggerObj, Infinity)) + unserializableValue = 'Infinity'; + else if (Object.is(debuggerObj, -Infinity)) + unserializableValue = '-Infinity'; + return unserializableValue ? {unserializableValue} : {value: debuggerObj}; + } + + ensureSerializedToValue(protocolObject) { + if (!protocolObject.objectId) + return protocolObject; + const obj = this._remoteObjects.get(protocolObject.objectId); + this._remoteObjects.delete(protocolObject.objectId); + return {value: this._serialize(obj)}; + } + + _toDebugger(obj) { + if (typeof obj !== 'object') + return obj; + if (obj === null) + return obj; + const properties = {}; + for (let [key, value] of Object.entries(obj)) { + properties[key] = { + writable: true, + enumerable: true, + value: this._toDebugger(value), + }; + } + const baseObject = Array.isArray(obj) ? '([])' : '({})'; + const debuggerObj = this._global.executeInGlobal(baseObject).return; + debuggerObj.defineProperties(properties); + return debuggerObj; + } + + _serialize(obj) { + const result = this._global.executeInGlobalWithBindings('stringify(e)', {e: obj, stringify: this._jsonStringifyObject}); + if (result.throw) + throw new Error('Object is not serializable'); + return result.return === undefined ? undefined : JSON.parse(result.return); + } + + disposeObject(objectId) { + this._remoteObjects.delete(objectId); + } + + getObjectProperties(objectId) { + if (!this._remoteObjects.has(objectId)) + throw new Error('Cannot find object with id = ' + arg.objectId); + const result = []; + for (let obj = this._remoteObjects.get(objectId); obj; obj = obj.proto) { + for (const propertyName of obj.getOwnPropertyNames()) { + const descriptor = obj.getOwnPropertyDescriptor(propertyName); + if (!descriptor.enumerable) + continue; + result.push({ + name: propertyName, + value: this._createRemoteObject(descriptor.value), + }); + } + } + return result; + } + + _getResult(completionValue, exceptionDetails = {}) { + if (!completionValue) { + exceptionDetails.text = 'Evaluation terminated!'; + exceptionDetails.stack = ''; + return {success: false, obj: null}; + } + if (completionValue.throw) { + if (this._global.executeInGlobalWithBindings('e instanceof Error', {e: completionValue.throw}).return) { + exceptionDetails.text = this._global.executeInGlobalWithBindings('e.message', {e: completionValue.throw}).return; + exceptionDetails.stack = this._global.executeInGlobalWithBindings('e.stack', {e: completionValue.throw}).return; + } else { + exceptionDetails.value = this._serialize(completionValue.throw); + } + return {success: false, obj: null}; + } + return {success: true, obj: completionValue.return}; + } +} + +var EXPORTED_SYMBOLS = ['RuntimeAgent']; +this.RuntimeAgent = RuntimeAgent; diff --git a/testing/juggler/content/ScrollbarManager.js b/testing/juggler/content/ScrollbarManager.js new file mode 100644 index 0000000000000000000000000000000000000000..caee4df323d0a526ed7e38947c41c6430983568d --- /dev/null +++ b/testing/juggler/content/ScrollbarManager.js @@ -0,0 +1,85 @@ +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; +const Cc = Components.classes; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const HIDDEN_SCROLLBARS = Services.io.newURI('chrome://juggler/content/content/hidden-scrollbars.css'); +const FLOATING_SCROLLBARS = Services.io.newURI('chrome://juggler/content/content/floating-scrollbars.css'); + +const isHeadless = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless; +const helper = new Helper(); + +class ScrollbarManager { + constructor(docShell) { + this._docShell = docShell; + this._customScrollbars = null; + this._contentViewerScrollBars = new Map(); + + if (isHeadless) + this._setCustomScrollbars(HIDDEN_SCROLLBARS); + + const webProgress = this._docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + this.QueryInterface = ChromeUtils.generateQI(['nsIWebProgressListener', 'nsISupportsWeakReference']); + this._eventListeners = [ + helper.addProgressListener(webProgress, this, Ci.nsIWebProgress.NOTIFY_ALL), + ]; + } + + onLocationChange(webProgress, request, URI, flags) { + if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) + return; + this._updateAllDocShells(); + } + + setFloatingScrollbars(enabled) { + if (this._customScrollbars === HIDDEN_SCROLLBARS) + return; + this._setCustomScrollbars(enabled ? FLOATING_SCROLLBARS : null); + } + + _setCustomScrollbars(customScrollbars) { + if (this._customScrollbars === customScrollbars) + return; + this._customScrollbars = customScrollbars; + this._updateAllDocShells(); + } + + _updateAllDocShells() { + const allDocShells = [this._docShell]; + for (let i = 0; i < this._docShell.childCount; i++) + allDocShells.push(this._docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell)); + // At this point, a content viewer might not be loaded for certain docShells. + // Scrollbars will be updated in onLocationChange. + const contentViewers = allDocShells.map(docShell => docShell.contentViewer).filter(contentViewer => !!contentViewer); + + // Update scrollbar stylesheets. + for (const contentViewer of contentViewers) { + const oldScrollbars = this._contentViewerScrollBars.get(contentViewer); + if (oldScrollbars === this._customScrollbars) + continue; + const winUtils = contentViewer.DOMDocument.defaultView.windowUtils; + if (oldScrollbars) + winUtils.removeSheet(oldScrollbars, winUtils.AGENT_SHEET); + if (this._customScrollbars) + winUtils.loadSheet(this._customScrollbars, winUtils.AGENT_SHEET); + } + // Update state for all *existing* docShells. + this._contentViewerScrollBars.clear(); + for (const contentViewer of contentViewers) + this._contentViewerScrollBars.set(contentViewer, this._customScrollbars); + } + + dispose() { + this._setCustomScrollbars(null); + helper.removeListeners(this._eventListeners); + } +} + +var EXPORTED_SYMBOLS = ['ScrollbarManager']; +this.ScrollbarManager = ScrollbarManager; + diff --git a/testing/juggler/content/WorkerMain.js b/testing/juggler/content/WorkerMain.js new file mode 100644 index 0000000000000000000000000000000000000000..73cdce649608f068e59e1ff7808883c4482bff7e --- /dev/null +++ b/testing/juggler/content/WorkerMain.js @@ -0,0 +1,67 @@ +"use strict"; +loadSubScript('chrome://juggler/content/content/RuntimeAgent.js'); + +class WorkerSession { + constructor(workerId) { + this._workerId = workerId; + this._agents = { + Runtime: new RuntimeAgent(this, hash => this._send({command: 'console', hash})), + }; + this._agents.Runtime.enable(); + this._agents.Runtime.createExecutionContext(null /* domWindow */, global, {}); + } + + _send(command) { + postMessage(JSON.stringify({...command, workerId: this._workerId})); + } + + _dispatchProtocolMessage(protocolMessage) { + this._send({command: 'dispatch', message: JSON.stringify(protocolMessage)}); + } + + emitEvent(eventName, params) { + this._dispatchProtocolMessage({method: eventName, params}); + } + + async _onMessage(message) { + const object = JSON.parse(message); + const id = object.id; + try { + const [domainName, methodName] = object.method.split('.'); + const agent = this._agents[domainName]; + if (!agent) + throw new Error(`unknown domain: ${domainName}`); + const handler = agent[methodName]; + if (!handler) + throw new Error(`unknown method: ${domainName}.${methodName}`); + const result = await handler.call(agent, object.params); + this._dispatchProtocolMessage({id, result}); + } catch (e) { + this._dispatchProtocolMessage({id, error: e.message + '\n' + e.stack}); + } + } + + dispose() { + for (const agent of Object.values(this._agents)) + agent.dispose(); + } +} + +const workerSessions = new Map(); + +this.addEventListener('message', event => { + const data = JSON.parse(event.data); + if (data.command === 'connect') { + const session = new WorkerSession(data.workerId); + workerSessions.set(data.workerId, session); + } + if (data.command === 'disconnect') { + const session = workerSessions.get(data.workerId); + session.dispose(); + workerSessions.delete(data.workerId); + } + if (data.command === 'dispatch') { + const session = workerSessions.get(data.workerId); + session._onMessage(data.message); + } +}); diff --git a/testing/juggler/content/floating-scrollbars.css b/testing/juggler/content/floating-scrollbars.css new file mode 100644 index 0000000000000000000000000000000000000000..7709bdd34c65062fc63684ef17fc792d3991d965 --- /dev/null +++ b/testing/juggler/content/floating-scrollbars.css @@ -0,0 +1,47 @@ +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +@namespace html url("http://www.w3.org/1999/xhtml"); + +/* Restrict all styles to `*|*:not(html|select) > scrollbar` so that scrollbars + inside a . */ +*|*:not(html|select) > scrollbar { + -moz-appearance: none !important; + position: relative; + background-color: transparent; + background-image: none; + z-index: 2147483647; + padding: 2px; + border: none; +} + +/* Scrollbar code will reset the margin to the correct side depending on + where layout actually puts the scrollbar */ +*|*:not(html|select) > scrollbar[orient="vertical"] { + margin-left: -10px; + min-width: 10px; + max-width: 10px; +} + +*|*:not(html|select) > scrollbar[orient="horizontal"] { + margin-top: -10px; + min-height: 10px; + max-height: 10px; +} + +*|*:not(html|select) > scrollbar slider { + -moz-appearance: none !important; +} + +*|*:not(html|select) > scrollbar thumb { + -moz-appearance: none !important; + background-color: rgba(0,0,0,0.2); + border-width: 0px !important; + border-radius: 3px !important; +} + +*|*:not(html|select) > scrollbar scrollbarbutton, +*|*:not(html|select) > scrollbar gripper { + display: none; +} diff --git a/testing/juggler/content/hidden-scrollbars.css b/testing/juggler/content/hidden-scrollbars.css new file mode 100644 index 0000000000000000000000000000000000000000..3a386425d3796d0a6786dea193b3402dfd2ac4f6 --- /dev/null +++ b/testing/juggler/content/hidden-scrollbars.css @@ -0,0 +1,13 @@ +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +@namespace html url("http://www.w3.org/1999/xhtml"); + +/* Restrict all styles to `*|*:not(html|select) > scrollbar` so that scrollbars + inside a . */ +*|*:not(html|select) > scrollbar { + -moz-appearance: none !important; + display: none; +} + diff --git a/testing/juggler/content/main.js b/testing/juggler/content/main.js new file mode 100644 index 0000000000000000000000000000000000000000..556f48d627401b8507b8bbec6dbf7ca797644baf --- /dev/null +++ b/testing/juggler/content/main.js @@ -0,0 +1,76 @@ +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {ContentSession} = ChromeUtils.import('chrome://juggler/content/content/ContentSession.js'); +const {FrameTree} = ChromeUtils.import('chrome://juggler/content/content/FrameTree.js'); +const {NetworkMonitor} = ChromeUtils.import('chrome://juggler/content/content/NetworkMonitor.js'); +const {ScrollbarManager} = ChromeUtils.import('chrome://juggler/content/content/ScrollbarManager.js'); + +const sessions = new Map(); +const scrollbarManager = new ScrollbarManager(docShell); +let frameTree; +let networkMonitor; +const helper = new Helper(); +const messageManager = this; +let gListeners; + +function createContentSession(sessionId) { + sessions.set(sessionId, new ContentSession(sessionId, messageManager, frameTree, networkMonitor)); +} + +function disposeContentSession(sessionId) { + const session = sessions.get(sessionId); + if (!session) + return; + sessions.delete(sessionId); + session.dispose(); +} + +function initialize() { + let response = sendSyncMessage('juggler:content-ready', {})[0]; + if (!response) + response = { sessionIds: [], browserContextOptions: {}, waitForInitialNavigation: false }; + + const { sessionIds, browserContextOptions, waitForInitialNavigation } = response; + const { userAgent, bypassCSP, javaScriptDisabled, viewport} = browserContextOptions; + + if (userAgent !== undefined) + docShell.customUserAgent = userAgent; + if (bypassCSP !== undefined) + docShell.bypassCSPEnabled = bypassCSP; + if (javaScriptDisabled !== undefined) + docShell.allowJavascript = !javaScriptDisabled; + if (viewport !== undefined) { + docShell.contentViewer.overrideDPPX = viewport.deviceScaleFactor || this._initialDPPX; + docShell.deviceSizeIsPageSize = viewport.isMobile; + docShell.touchEventsOverride = viewport.hasTouch ? Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED : Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_NONE; + scrollbarManager.setFloatingScrollbars(viewport.isMobile); + } + + frameTree = new FrameTree(docShell, waitForInitialNavigation); + networkMonitor = new NetworkMonitor(docShell, frameTree); + for (const sessionId of sessionIds) + createContentSession(sessionId); + + gListeners = [ + helper.addMessageListener(messageManager, 'juggler:create-content-session', msg => { + const sessionId = msg.data; + createContentSession(sessionId); + }), + + helper.addMessageListener(messageManager, 'juggler:dispose-content-session', msg => { + const sessionId = msg.data; + disposeContentSession(sessionId); + }), + + helper.addEventListener(messageManager, 'unload', msg => { + helper.removeListeners(gListeners); + for (const session of sessions.values()) + session.dispose(); + sessions.clear(); + scrollbarManager.dispose(); + networkMonitor.dispose(); + frameTree.dispose(); + }), + ]; +} + +initialize(); diff --git a/testing/juggler/jar.mn b/testing/juggler/jar.mn new file mode 100644 index 0000000000000000000000000000000000000000..76377927a8c9af3cac3b028ff754491966d03ba3 --- /dev/null +++ b/testing/juggler/jar.mn @@ -0,0 +1,30 @@ +# 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/. + +juggler.jar: +% content juggler %content/ + content/Helper.js (Helper.js) + content/NetworkObserver.js (NetworkObserver.js) + content/BrowserContextManager.js (BrowserContextManager.js) + content/TargetRegistry.js (TargetRegistry.js) + content/protocol/PrimitiveTypes.js (protocol/PrimitiveTypes.js) + content/protocol/Protocol.js (protocol/Protocol.js) + content/protocol/Dispatcher.js (protocol/Dispatcher.js) + content/protocol/PageHandler.js (protocol/PageHandler.js) + content/protocol/RuntimeHandler.js (protocol/RuntimeHandler.js) + content/protocol/NetworkHandler.js (protocol/NetworkHandler.js) + content/protocol/BrowserHandler.js (protocol/BrowserHandler.js) + content/protocol/TargetHandler.js (protocol/TargetHandler.js) + content/protocol/AccessibilityHandler.js (protocol/AccessibilityHandler.js) + content/content/main.js (content/main.js) + content/content/ContentSession.js (content/ContentSession.js) + content/content/FrameTree.js (content/FrameTree.js) + content/content/NetworkMonitor.js (content/NetworkMonitor.js) + content/content/PageAgent.js (content/PageAgent.js) + content/content/RuntimeAgent.js (content/RuntimeAgent.js) + content/content/WorkerMain.js (content/WorkerMain.js) + content/content/ScrollbarManager.js (content/ScrollbarManager.js) + content/content/floating-scrollbars.css (content/floating-scrollbars.css) + content/content/hidden-scrollbars.css (content/hidden-scrollbars.css) + diff --git a/testing/juggler/moz.build b/testing/juggler/moz.build new file mode 100644 index 0000000000000000000000000000000000000000..1a0a3130bf9509829744fadc692a79754fddd351 --- /dev/null +++ b/testing/juggler/moz.build @@ -0,0 +1,15 @@ +# 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/. + +DIRS += ["components"] + +JAR_MANIFESTS += ["jar.mn"] +#JS_PREFERENCE_FILES += ["prefs/marionette.js"] + +#MARIONETTE_UNIT_MANIFESTS += ["harness/marionette_harness/tests/unit/unit-tests.ini"] +#XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] + +with Files("**"): + BUG_COMPONENT = ("Testing", "Juggler") + diff --git a/testing/juggler/protocol/AccessibilityHandler.js b/testing/juggler/protocol/AccessibilityHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..a2d3b79469566ca2edb7d864621f708537d48301 --- /dev/null +++ b/testing/juggler/protocol/AccessibilityHandler.js @@ -0,0 +1,15 @@ +class AccessibilityHandler { + constructor(chromeSession, contentSession) { + this._chromeSession = chromeSession; + this._contentSession = contentSession; + } + + async getFullAXTree(params) { + return await this._contentSession.send('Page.getFullAXTree', params); + } + + dispose() { } +} + +var EXPORTED_SYMBOLS = ['AccessibilityHandler']; +this.AccessibilityHandler = AccessibilityHandler; diff --git a/testing/juggler/protocol/BrowserHandler.js b/testing/juggler/protocol/BrowserHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..af071300faeb8018ec2e956743d0a619886248b8 --- /dev/null +++ b/testing/juggler/protocol/BrowserHandler.js @@ -0,0 +1,77 @@ +"use strict"; + +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { allowAllCerts } = ChromeUtils.import( + "chrome://marionette/content/cert.js" +); +const {BrowserContextManager} = ChromeUtils.import("chrome://juggler/content/BrowserContextManager.js"); + +class BrowserHandler { + /** + * @param {ChromeSession} session + */ + constructor() { + this._sweepingOverride = null; + this._contextManager = BrowserContextManager.instance(); + } + + async close() { + let browserWindow = Services.wm.getMostRecentWindow( + "navigator:browser" + ); + if (browserWindow && browserWindow.gBrowserInit) { + await browserWindow.gBrowserInit.idleTasksFinishedPromise; + } + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } + + async setIgnoreHTTPSErrors({enabled}) { + if (!enabled) { + allowAllCerts.disable() + Services.prefs.setBoolPref('security.mixed_content.block_active_content', true); + } else { + allowAllCerts.enable() + Services.prefs.setBoolPref('security.mixed_content.block_active_content', false); + } + } + + grantPermissions({browserContextId, origin, permissions}) { + this._contextManager.browserContextForId(browserContextId).grantPermissions(origin, permissions); + } + + resetPermissions({browserContextId}) { + this._contextManager.browserContextForId(browserContextId).resetPermissions(); + } + + setExtraHTTPHeaders({browserContextId, headers}) { + this._contextManager.browserContextForId(browserContextId).options.extraHTTPHeaders = headers; + } + + setCookies({browserContextId, cookies}) { + this._contextManager.browserContextForId(browserContextId).setCookies(cookies); + } + + clearCookies({browserContextId}) { + this._contextManager.browserContextForId(browserContextId).clearCookies(); + } + + getCookies({browserContextId}) { + const cookies = this._contextManager.browserContextForId(browserContextId).getCookies(); + return {cookies}; + } + + async getInfo() { + const version = Components.classes["@mozilla.org/xre/app-info;1"] + .getService(Components.interfaces.nsIXULAppInfo) + .version; + const userAgent = Components.classes["@mozilla.org/network/protocol;1?name=http"] + .getService(Components.interfaces.nsIHttpProtocolHandler) + .userAgent; + return {version: 'Firefox/' + version, userAgent}; + } + + dispose() { } +} + +var EXPORTED_SYMBOLS = ['BrowserHandler']; +this.BrowserHandler = BrowserHandler; diff --git a/testing/juggler/protocol/Dispatcher.js b/testing/juggler/protocol/Dispatcher.js new file mode 100644 index 0000000000000000000000000000000000000000..5c5a73b35cd178b51899ab3dd681d46b6c3e4770 --- /dev/null +++ b/testing/juggler/protocol/Dispatcher.js @@ -0,0 +1,265 @@ +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); +const {protocol, checkScheme} = ChromeUtils.import("chrome://juggler/content/protocol/Protocol.js"); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const helper = new Helper(); + +const PROTOCOL_HANDLERS = { + Page: ChromeUtils.import("chrome://juggler/content/protocol/PageHandler.js").PageHandler, + Network: ChromeUtils.import("chrome://juggler/content/protocol/NetworkHandler.js").NetworkHandler, + Browser: ChromeUtils.import("chrome://juggler/content/protocol/BrowserHandler.js").BrowserHandler, + Target: ChromeUtils.import("chrome://juggler/content/protocol/TargetHandler.js").TargetHandler, + Runtime: ChromeUtils.import("chrome://juggler/content/protocol/RuntimeHandler.js").RuntimeHandler, + Accessibility: ChromeUtils.import("chrome://juggler/content/protocol/AccessibilityHandler.js").AccessibilityHandler, +}; + +class Dispatcher { + /** + * @param {Connection} connection + */ + constructor(connection) { + this._connection = connection; + this._connection.onmessage = this._dispatch.bind(this); + this._connection.onclose = this._dispose.bind(this); + + this._targetSessions = new Map(); + this._sessions = new Map(); + this._rootSession = new ChromeSession(this, undefined, null /* contentSession */, TargetRegistry.instance().browserTargetInfo()); + + this._eventListeners = [ + helper.on(TargetRegistry.instance(), TargetRegistry.Events.TargetDestroyed, this._onTargetDestroyed.bind(this)), + ]; + } + + createSession(targetId, shouldConnect) { + const targetInfo = TargetRegistry.instance().targetInfo(targetId); + if (!targetInfo) + throw new Error(`Target "${targetId}" is not found`); + let targetSessions = this._targetSessions.get(targetId); + if (!targetSessions) { + targetSessions = new Map(); + this._targetSessions.set(targetId, targetSessions); + } + + const sessionId = helper.generateId(); + const contentSession = targetInfo.type === 'page' ? new ContentSession(this, sessionId, targetInfo) : null; + if (shouldConnect && contentSession) + contentSession.connect(); + const chromeSession = new ChromeSession(this, sessionId, contentSession, targetInfo); + targetSessions.set(sessionId, chromeSession); + this._sessions.set(sessionId, chromeSession); + this._emitEvent(this._rootSession._sessionId, 'Target.attachedToTarget', { + sessionId: sessionId, + targetInfo + }); + return sessionId; + } + + _dispose() { + helper.removeListeners(this._eventListeners); + this._connection.onmessage = null; + this._connection.onclose = null; + this._rootSession.dispose(); + this._rootSession = null; + for (const session of this._sessions.values()) + session.dispose(); + this._sessions.clear(); + this._targetSessions.clear(); + } + + _onTargetDestroyed({targetId}) { + const sessions = this._targetSessions.get(targetId); + if (!sessions) + return; + this._targetSessions.delete(targetId); + for (const [sessionId, session] of sessions) { + session.dispose(); + this._sessions.delete(sessionId); + } + } + + async _dispatch(event) { + const data = JSON.parse(event.data); + const id = data.id; + const sessionId = data.sessionId; + delete data.sessionId; + try { + const session = sessionId ? this._sessions.get(sessionId) : this._rootSession; + if (!session) + throw new Error(`ERROR: cannot find session with id "${sessionId}"`); + const method = data.method; + const params = data.params || {}; + if (!id) + throw new Error(`ERROR: every message must have an 'id' parameter`); + if (!method) + throw new Error(`ERROR: every message must have a 'method' parameter`); + + const [domain, methodName] = method.split('.'); + const descriptor = protocol.domains[domain] ? protocol.domains[domain].methods[methodName] : null; + if (!descriptor) + throw new Error(`ERROR: method '${method}' is not supported`); + let details = {}; + if (!checkScheme(descriptor.params || {}, params, details)) + throw new Error(`ERROR: failed to call method '${method}' with parameters ${JSON.stringify(params, null, 2)}\n${details.error}`); + + const result = await session.dispatch(method, params); + + details = {}; + if ((descriptor.returns || result) && !checkScheme(descriptor.returns, result, details)) + throw new Error(`ERROR: failed to dispatch method '${method}' result ${JSON.stringify(result, null, 2)}\n${details.error}`); + + this._connection.send(JSON.stringify({id, sessionId, result})); + } catch (e) { + this._connection.send(JSON.stringify({id, sessionId, error: { + message: e.message, + data: e.stack + }})); + } + } + + _emitEvent(sessionId, eventName, params) { + const [domain, eName] = eventName.split('.'); + const scheme = protocol.domains[domain] ? protocol.domains[domain].events[eName] : null; + if (!scheme) + throw new Error(`ERROR: event '${eventName}' is not supported`); + const details = {}; + if (!checkScheme(scheme, params || {}, details)) + throw new Error(`ERROR: failed to emit event '${eventName}' ${JSON.stringify(params, null, 2)}\n${details.error}`); + this._connection.send(JSON.stringify({method: eventName, params, sessionId})); + } +} + +class ChromeSession { + /** + * @param {Connection} connection + */ + constructor(dispatcher, sessionId, contentSession, targetInfo) { + this._dispatcher = dispatcher; + this._sessionId = sessionId; + this._contentSession = contentSession; + this._targetInfo = targetInfo; + + this._handlers = {}; + for (const [domainName, handlerFactory] of Object.entries(PROTOCOL_HANDLERS)) { + if (protocol.domains[domainName].targets.includes(targetInfo.type)) + this._handlers[domainName] = new handlerFactory(this, contentSession); + } + const pageHandler = this._handlers['Page']; + if (pageHandler) + pageHandler.enable(); + const networkHandler = this._handlers['Network']; + if (networkHandler) + networkHandler.enable(); + } + + dispatcher() { + return this._dispatcher; + } + + targetId() { + return this._targetInfo.targetId; + } + + dispose() { + if (this._contentSession) + this._contentSession.dispose(); + this._contentSession = null; + for (const [domainName, handler] of Object.entries(this._handlers)) { + if (!handler.dispose) + throw new Error(`Handler for "${domainName}" domain does not define |dispose| method!`); + handler.dispose(); + delete this._handlers[domainName]; + } + // Root session don't have sessionId and don't emit detachedFromTarget. + if (this._sessionId) { + this._dispatcher._emitEvent(this._sessionId, 'Target.detachedFromTarget', { + sessionId: this._sessionId, + }); + } + } + + emitEvent(eventName, params) { + this._dispatcher._emitEvent(this._sessionId, eventName, params); + } + + async dispatch(method, params) { + const [domainName, methodName] = method.split('.'); + if (!this._handlers[domainName]) + throw new Error(`Domain "${domainName}" does not exist`); + if (!this._handlers[domainName][methodName]) + throw new Error(`Handler for domain "${domainName}" does not implement method "${methodName}"`); + return await this._handlers[domainName][methodName](params); + } +} + +class ContentSession { + constructor(dispatcher, sessionId, targetInfo) { + this._dispatcher = dispatcher; + const tab = TargetRegistry.instance().tabForTarget(targetInfo.targetId); + this._browser = tab.linkedBrowser; + this._messageId = 0; + this._pendingMessages = new Map(); + this._sessionId = sessionId; + this._disposed = false; + this._eventListeners = [ + helper.addMessageListener(this._browser.messageManager, this._sessionId, { + receiveMessage: message => this._onMessage(message) + }), + ]; + } + + connect() { + this._browser.messageManager.sendAsyncMessage('juggler:create-content-session', this._sessionId); + } + + isDisposed() { + return this._disposed; + } + + dispose() { + if (this._disposed) + return; + this._disposed = true; + helper.removeListeners(this._eventListeners); + for (const {resolve, reject, methodName} of this._pendingMessages.values()) + reject(new Error(`Failed "${methodName}": Page closed.`)); + this._pendingMessages.clear(); + if (this._browser.messageManager) + this._browser.messageManager.sendAsyncMessage('juggler:dispose-content-session', this._sessionId); + } + + /** + * @param {string} methodName + * @param {*} params + * @return {!Promise<*>} + */ + send(methodName, params) { + const id = ++this._messageId; + const promise = new Promise((resolve, reject) => { + this._pendingMessages.set(id, {resolve, reject, methodName}); + }); + this._browser.messageManager.sendAsyncMessage(this._sessionId, {id, methodName, params}); + return promise; + } + + _onMessage({data}) { + if (data.id) { + const {resolve, reject} = this._pendingMessages.get(data.id); + this._pendingMessages.delete(data.id); + if (data.error) + reject(new Error(data.error)); + else + resolve(data.result); + } else { + const { + eventName, + params = {} + } = data; + this._dispatcher._emitEvent(this._sessionId, eventName, params); + } + } +} + + +this.EXPORTED_SYMBOLS = ['Dispatcher']; +this.Dispatcher = Dispatcher; + diff --git a/testing/juggler/protocol/NetworkHandler.js b/testing/juggler/protocol/NetworkHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..5d776ab6f28ccff44ef4663e8618ad9cf800f869 --- /dev/null +++ b/testing/juggler/protocol/NetworkHandler.js @@ -0,0 +1,166 @@ +"use strict"; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {NetworkObserver} = ChromeUtils.import('chrome://juggler/content/NetworkObserver.js'); +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; +const FRAME_SCRIPT = "chrome://juggler/content/content/ContentSession.js"; +const helper = new Helper(); + +class NetworkHandler { + constructor(chromeSession, contentSession) { + this._chromeSession = chromeSession; + this._contentSession = contentSession; + this._networkObserver = NetworkObserver.instance(); + this._httpActivity = new Map(); + this._enabled = false; + this._browser = TargetRegistry.instance().tabForTarget(this._chromeSession.targetId()).linkedBrowser; + this._requestInterception = false; + this._eventListeners = []; + this._pendingRequstWillBeSentEvents = new Set(); + this._requestIdToFrameId = new Map(); + } + + async enable() { + if (this._enabled) + return; + this._enabled = true; + this._eventListeners = [ + helper.on(this._networkObserver, 'request', this._onRequest.bind(this)), + helper.on(this._networkObserver, 'response', this._onResponse.bind(this)), + helper.on(this._networkObserver, 'requestfinished', this._onRequestFinished.bind(this)), + helper.on(this._networkObserver, 'requestfailed', this._onRequestFailed.bind(this)), + this._networkObserver.startTrackingBrowserNetwork(this._browser), + ]; + } + + async getResponseBody({requestId}) { + return this._networkObserver.getResponseBody(this._browser, requestId); + } + + async setExtraHTTPHeaders({headers}) { + this._networkObserver.setExtraHTTPHeaders(this._browser, headers); + } + + async setRequestInterception({enabled}) { + if (enabled) + this._networkObserver.enableRequestInterception(this._browser); + else + this._networkObserver.disableRequestInterception(this._browser); + // Right after we enable/disable request interception we need to await all pending + // requestWillBeSent events before successfully returning from the method. + await Promise.all(Array.from(this._pendingRequstWillBeSentEvents)); + } + + async resumeInterceptedRequest({requestId, method, headers, postData}) { + this._networkObserver.resumeInterceptedRequest(this._browser, requestId, method, headers, postData); + } + + async abortInterceptedRequest({requestId, errorCode}) { + this._networkObserver.abortInterceptedRequest(this._browser, requestId, errorCode); + } + + async fulfillInterceptedRequest({requestId, status, statusText, headers, base64body}) { + this._networkObserver.fulfillInterceptedRequest(this._browser, requestId, status, statusText, headers, base64body); + } + + async setAuthCredentials({username, password}) { + this._networkObserver.setAuthCredentials(this._browser, username, password); + } + + dispose() { + helper.removeListeners(this._eventListeners); + } + + _ensureHTTPActivity(requestId) { + let activity = this._httpActivity.get(requestId); + if (!activity) { + activity = { + _id: requestId, + _lastSentEvent: null, + request: null, + response: null, + complete: null, + failed: null, + }; + this._httpActivity.set(requestId, activity); + } + return activity; + } + + _reportHTTPAcitivityEvents(activity) { + // State machine - sending network events. + if (!activity._lastSentEvent && activity.request) { + this._chromeSession.emitEvent('Network.requestWillBeSent', activity.request); + activity._lastSentEvent = 'requestWillBeSent'; + } + if (activity._lastSentEvent === 'requestWillBeSent' && activity.response) { + this._chromeSession.emitEvent('Network.responseReceived', activity.response); + activity._lastSentEvent = 'responseReceived'; + } + if (activity._lastSentEvent === 'responseReceived' && activity.complete) { + this._chromeSession.emitEvent('Network.requestFinished', activity.complete); + activity._lastSentEvent = 'requestFinished'; + } + if (activity._lastSentEvent && activity.failed) { + this._chromeSession.emitEvent('Network.requestFailed', activity.failed); + activity._lastSentEvent = 'requestFailed'; + } + + // Clean up if request lifecycle is over. + if (activity._lastSentEvent === 'requestFinished' || activity._lastSentEvent === 'requestFailed') + this._httpActivity.delete(activity._id); + } + + async _onRequest(httpChannel, eventDetails) { + let pendingRequestCallback; + let pendingRequestPromise = new Promise(x => pendingRequestCallback = x); + this._pendingRequstWillBeSentEvents.add(pendingRequestPromise); + let details = null; + try { + details = await this._contentSession.send('Page.requestDetails', {channelId: httpChannel.channelId}); + } catch (e) { + if (this._contentSession.isDisposed()) { + pendingRequestCallback(); + this._pendingRequstWillBeSentEvents.delete(pendingRequestPromise); + return; + } + } + // Inherit frameId for redirects when details are not available. + const frameId = details ? details.frameId : (eventDetails.redirectedFrom ? this._requestIdToFrameId.get(eventDetails.redirectedFrom) : undefined); + this._requestIdToFrameId.set(eventDetails.requestId, frameId); + const activity = this._ensureHTTPActivity(eventDetails.requestId); + activity.request = { + frameId, + ...eventDetails, + }; + this._reportHTTPAcitivityEvents(activity); + pendingRequestCallback(); + this._pendingRequstWillBeSentEvents.delete(pendingRequestPromise); + } + + async _onResponse(httpChannel, eventDetails) { + const activity = this._ensureHTTPActivity(eventDetails.requestId); + activity.response = eventDetails; + this._reportHTTPAcitivityEvents(activity); + } + + async _onRequestFinished(httpChannel, eventDetails) { + const activity = this._ensureHTTPActivity(eventDetails.requestId); + activity.complete = eventDetails; + this._reportHTTPAcitivityEvents(activity); + } + + async _onRequestFailed(httpChannel, eventDetails) { + const activity = this._ensureHTTPActivity(eventDetails.requestId); + activity.failed = eventDetails; + this._reportHTTPAcitivityEvents(activity); + } +} + +var EXPORTED_SYMBOLS = ['NetworkHandler']; +this.NetworkHandler = NetworkHandler; diff --git a/testing/juggler/protocol/PageHandler.js b/testing/juggler/protocol/PageHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..8333a58253745298fa88f7ce17909699be43b3d4 --- /dev/null +++ b/testing/juggler/protocol/PageHandler.js @@ -0,0 +1,266 @@ +"use strict"; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; +const FRAME_SCRIPT = "chrome://juggler/content/content/ContentSession.js"; +const helper = new Helper(); + +class PageHandler { + constructor(chromeSession, contentSession) { + this._chromeSession = chromeSession; + this._contentSession = contentSession; + this._pageTarget = TargetRegistry.instance().targetForId(chromeSession.targetId()); + this._browser = TargetRegistry.instance().tabForTarget(chromeSession.targetId()).linkedBrowser; + this._dialogs = new Map(); + + this._eventListeners = []; + this._enabled = false; + } + + async close({runBeforeUnload}) { + // Postpone target close to deliver response in session. + Services.tm.dispatchToMainThread(() => { + TargetRegistry.instance().closePage(this._chromeSession.targetId(), runBeforeUnload); + }); + } + + async enable() { + if (this._enabled) + return; + this._enabled = true; + this._updateModalDialogs(); + + this._eventListeners = [ + helper.addEventListener(this._browser, 'DOMWillOpenModalDialog', async (event) => { + // wait for the dialog to be actually added to DOM. + await Promise.resolve(); + this._updateModalDialogs(); + }), + helper.addEventListener(this._browser, 'DOMModalDialogClosed', event => this._updateModalDialogs()), + helper.on(TargetRegistry.instance(), TargetRegistry.Events.TargetCrashed, targetId => { + if (targetId === this._chromeSession.targetId()) + this._chromeSession.emitEvent('Page.crashed', {}); + }), + ]; + } + + dispose() { + helper.removeListeners(this._eventListeners); + } + + async setViewportSize({viewportSize}) { + const size = this._pageTarget.setViewportSize(viewportSize); + await this._contentSession.send('Page.awaitViewportDimensions', { + width: size.width, + height: size.height + }); + } + + _updateModalDialogs() { + const prompts = new Set(this._browser.tabModalPromptBox ? this._browser.tabModalPromptBox.listPrompts() : []); + for (const dialog of this._dialogs.values()) { + if (!prompts.has(dialog.prompt())) { + this._dialogs.delete(dialog.id()); + this._chromeSession.emitEvent('Page.dialogClosed', { + dialogId: dialog.id(), + }); + } else { + prompts.delete(dialog.prompt()); + } + } + for (const prompt of prompts) { + const dialog = Dialog.createIfSupported(prompt); + if (!dialog) + continue; + this._dialogs.set(dialog.id(), dialog); + this._chromeSession.emitEvent('Page.dialogOpened', { + dialogId: dialog.id(), + type: dialog.type(), + message: dialog.message(), + defaultValue: dialog.defaultValue(), + }); + } + } + + async setFileInputFiles(options) { + return await this._contentSession.send('Page.setFileInputFiles', options); + } + + async setEmulatedMedia(options) { + return await this._contentSession.send('Page.setEmulatedMedia', options); + } + + async setCacheDisabled(options) { + return await this._contentSession.send('Page.setCacheDisabled', options); + } + + async addBinding(options) { + return await this._contentSession.send('Page.addBinding', options); + } + + async adoptNode(options) { + return await this._contentSession.send('Page.adoptNode', options); + } + + async screenshot(options) { + return await this._contentSession.send('Page.screenshot', options); + } + + async getBoundingBox(options) { + return await this._contentSession.send('Page.getBoundingBox', options); + } + + async getContentQuads(options) { + return await this._contentSession.send('Page.getContentQuads', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async navigate(options) { + return await this._contentSession.send('Page.navigate', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async goBack(options) { + return await this._contentSession.send('Page.goBack', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async goForward(options) { + return await this._contentSession.send('Page.goForward', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async reload(options) { + return await this._contentSession.send('Page.reload', options); + } + + async describeNode(options) { + return await this._contentSession.send('Page.describeNode', options); + } + + async scrollIntoViewIfNeeded(options) { + return await this._contentSession.send('Page.scrollIntoViewIfNeeded', options); + } + + async addScriptToEvaluateOnNewDocument(options) { + return await this._contentSession.send('Page.addScriptToEvaluateOnNewDocument', options); + } + + async removeScriptToEvaluateOnNewDocument(options) { + return await this._contentSession.send('Page.removeScriptToEvaluateOnNewDocument', options); + } + + async dispatchKeyEvent(options) { + return await this._contentSession.send('Page.dispatchKeyEvent', options); + } + + async dispatchTouchEvent(options) { + return await this._contentSession.send('Page.dispatchTouchEvent', options); + } + + async dispatchMouseEvent(options) { + return await this._contentSession.send('Page.dispatchMouseEvent', options); + } + + async insertText(options) { + return await this._contentSession.send('Page.insertText', options); + } + + async crash(options) { + return await this._contentSession.send('Page.crash', options); + } + + async handleDialog({dialogId, accept, promptText}) { + const dialog = this._dialogs.get(dialogId); + if (!dialog) + throw new Error('Failed to find dialog with id = ' + dialogId); + if (accept) + dialog.accept(promptText); + else + dialog.dismiss(); + } + + async setInterceptFileChooserDialog(options) { + return await this._contentSession.send('Page.setInterceptFileChooserDialog', options); + } + + async handleFileChooser(options) { + return await this._contentSession.send('Page.handleFileChooser', options); + } + + async sendMessageToWorker(options) { + return await this._contentSession.send('Page.sendMessageToWorker', options); + } +} + +class Dialog { + static createIfSupported(prompt) { + const type = prompt.args.promptType; + switch (type) { + case 'alert': + case 'prompt': + case 'confirm': + return new Dialog(prompt, type); + case 'confirmEx': + return new Dialog(prompt, 'beforeunload'); + default: + return null; + }; + } + + constructor(prompt, type) { + this._id = helper.generateId(); + this._type = type; + this._prompt = prompt; + } + + id() { + return this._id; + } + + message() { + return this._prompt.ui.infoBody.textContent; + } + + type() { + return this._type; + } + + prompt() { + return this._prompt; + } + + dismiss() { + if (this._prompt.ui.button1) + this._prompt.ui.button1.click(); + else + this._prompt.ui.button0.click(); + } + + defaultValue() { + return this._prompt.ui.loginTextbox.value; + } + + accept(promptValue) { + if (typeof promptValue === 'string' && this._type === 'prompt') + this._prompt.ui.loginTextbox.value = promptValue; + this._prompt.ui.button0.click(); + } +} + +var EXPORTED_SYMBOLS = ['PageHandler']; +this.PageHandler = PageHandler; diff --git a/testing/juggler/protocol/PrimitiveTypes.js b/testing/juggler/protocol/PrimitiveTypes.js new file mode 100644 index 0000000000000000000000000000000000000000..78b6601b91d0b7fcda61114e6846aa07f95a06fa --- /dev/null +++ b/testing/juggler/protocol/PrimitiveTypes.js @@ -0,0 +1,143 @@ +const t = {}; + +t.String = function(x, details = {}, path = ['']) { + if (typeof x === 'string' || typeof x === 'String') + return true; + details.error = `Expected "${path.join('.')}" to be |string|; found |${typeof x}| \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Number = function(x, details = {}, path = ['']) { + if (typeof x === 'number') + return true; + details.error = `Expected "${path.join('.')}" to be |number|; found |${typeof x}| \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Boolean = function(x, details = {}, path = ['']) { + if (typeof x === 'boolean') + return true; + details.error = `Expected "${path.join('.')}" to be |boolean|; found |${typeof x}| \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Null = function(x, details = {}, path = ['']) { + if (Object.is(x, null)) + return true; + details.error = `Expected "${path.join('.')}" to be \`null\`; found \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Undefined = function(x, details = {}, path = ['']) { + if (Object.is(x, undefined)) + return true; + details.error = `Expected "${path.join('.')}" to be \`undefined\`; found \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Any = x => true, + +t.Enum = function(values) { + return function(x, details = {}, path = ['']) { + if (values.indexOf(x) !== -1) + return true; + details.error = `Expected "${path.join('.')}" to be one of [${values.join(', ')}]; found \`${JSON.stringify(x)}\` (${typeof x}) instead.`; + return false; + } +} + +t.Nullable = function(scheme) { + return function(x, details = {}, path = ['']) { + if (Object.is(x, null)) + return true; + return checkScheme(scheme, x, details, path); + } +} + +t.Optional = function(scheme) { + return function(x, details = {}, path = ['']) { + if (Object.is(x, undefined)) + return true; + return checkScheme(scheme, x, details, path); + } +} + +t.Array = function(scheme) { + return function(x, details = {}, path = ['']) { + if (!Array.isArray(x)) { + details.error = `Expected "${path.join('.')}" to be an array; found \`${JSON.stringify(x)}\` (${typeof x}) instead.`; + return false; + } + const lastPathElement = path[path.length - 1]; + for (let i = 0; i < x.length; ++i) { + path[path.length - 1] = lastPathElement + `[${i}]`; + if (!checkScheme(scheme, x[i], details, path)) + return false; + } + path[path.length - 1] = lastPathElement; + return true; + } +} + +t.Recursive = function(types, schemeName) { + return function(x, details = {}, path = ['']) { + const scheme = types[schemeName]; + return checkScheme(scheme, x, details, path); + } +} + +function beauty(path, obj) { + if (path.length === 1) + return `object ${JSON.stringify(obj, null, 2)}`; + return `property "${path.join('.')}" - ${JSON.stringify(obj, null, 2)}`; +} + +function checkScheme(scheme, x, details = {}, path = ['']) { + if (!scheme) + throw new Error(`ILLDEFINED SCHEME: ${path.join('.')}`); + if (typeof scheme === 'object') { + if (!x) { + details.error = `Object "${path.join('.')}" is undefined, but has some scheme`; + return false; + } + for (const [propertyName, aScheme] of Object.entries(scheme)) { + path.push(propertyName); + const result = checkScheme(aScheme, x[propertyName], details, path); + path.pop(); + if (!result) + return false; + } + for (const propertyName of Object.keys(x)) { + if (!scheme[propertyName]) { + path.push(propertyName); + details.error = `Found ${beauty(path, x[propertyName])} which is not described in this scheme`; + return false; + } + } + return true; + } + return scheme(x, details, path); +} + +/* + +function test(scheme, obj) { + const details = {}; + if (!checkScheme(scheme, obj, details)) { + dump(`FAILED: ${JSON.stringify(obj)} + details.error: ${details.error} + `); + } else { + dump(`SUCCESS: ${JSON.stringify(obj)} +`); + } +} + +test(t.Array(t.String), ['a', 'b', 2, 'c']); +test(t.Either(t.String, t.Number), {}); + +*/ + +this.t = t; +this.checkScheme = checkScheme; +this.EXPORTED_SYMBOLS = ['t', 'checkScheme']; diff --git a/testing/juggler/protocol/Protocol.js b/testing/juggler/protocol/Protocol.js new file mode 100644 index 0000000000000000000000000000000000000000..b6da790c65f25363a5bb85d7525bf2185d884235 --- /dev/null +++ b/testing/juggler/protocol/Protocol.js @@ -0,0 +1,758 @@ +const {t, checkScheme} = ChromeUtils.import('chrome://juggler/content/protocol/PrimitiveTypes.js'); + +// Protocol-specific types. +const targetTypes = {}; +targetTypes.TargetInfo = { + type: t.Enum(['page', 'browser']), + targetId: t.String, + browserContextId: t.Optional(t.String), + url: t.String, + // PageId of parent tab, if any. + openerId: t.Optional(t.String), +}; + +const browserTypes = {}; + +browserTypes.CookieOptions = { + name: t.String, + value: t.String, + url: t.Optional(t.String), + domain: t.Optional(t.String), + path: t.Optional(t.String), + secure: t.Optional(t.Boolean), + httpOnly: t.Optional(t.Boolean), + sameSite: t.Optional(t.Enum(['Strict', 'Lax', 'None'])), + expires: t.Optional(t.Number), +}; + +browserTypes.Cookie = { + name: t.String, + domain: t.String, + path: t.String, + value: t.String, + expires: t.Number, + size: t.Number, + httpOnly: t.Boolean, + secure: t.Boolean, + session: t.Boolean, + sameSite: t.Enum(['Strict', 'Lax', 'None']), +}; + +const pageTypes = {}; +pageTypes.DOMPoint = { + x: t.Number, + y: t.Number, +}; + +pageTypes.Rect = { + x: t.Number, + y: t.Number, + width: t.Number, + height: t.Number, +}; + +pageTypes.Size = { + width: t.Number, + height: t.Number, +}; + +pageTypes.Viewport = { + viewportSize: pageTypes.Size, + deviceScaleFactor: t.Number, + isMobile: t.Boolean, + hasTouch: t.Boolean, +}; + +pageTypes.DOMQuad = { + p1: pageTypes.DOMPoint, + p2: pageTypes.DOMPoint, + p3: pageTypes.DOMPoint, + p4: pageTypes.DOMPoint, +}; + +pageTypes.TouchPoint = { + x: t.Number, + y: t.Number, + radiusX: t.Optional(t.Number), + radiusY: t.Optional(t.Number), + rotationAngle: t.Optional(t.Number), + force: t.Optional(t.Number), +}; + +pageTypes.Clip = { + x: t.Number, + y: t.Number, + width: t.Number, + height: t.Number, +}; + + +const runtimeTypes = {}; +runtimeTypes.RemoteObject = { + type: t.Optional(t.Enum(['object', 'function', 'undefined', 'string', 'number', 'boolean', 'symbol', 'bigint'])), + subtype: t.Optional(t.Enum(['array', 'null', 'node', 'regexp', 'date', 'map', 'set', 'weakmap', 'weakset', 'error', 'proxy', 'promise', 'typedarray'])), + objectId: t.Optional(t.String), + unserializableValue: t.Optional(t.Enum(['Infinity', '-Infinity', '-0', 'NaN'])), + value: t.Any +}; + +runtimeTypes.ObjectProperty = { + name: t.String, + value: runtimeTypes.RemoteObject, +}; + +runtimeTypes.ScriptLocation = { + columnNumber: t.Number, + lineNumber: t.Number, + url: t.String, +}; + +runtimeTypes.ExceptionDetails = { + text: t.Optional(t.String), + stack: t.Optional(t.String), + value: t.Optional(t.Any), +}; + +runtimeTypes.CallFunctionArgument = { + objectId: t.Optional(t.String), + unserializableValue: t.Optional(t.Enum(['Infinity', '-Infinity', '-0', 'NaN'])), + value: t.Any, +}; + +const axTypes = {}; +axTypes.AXTree = { + role: t.String, + name: t.String, + children: t.Optional(t.Array(t.Recursive(axTypes, 'AXTree'))), + + selected: t.Optional(t.Boolean), + focused: t.Optional(t.Boolean), + pressed: t.Optional(t.Boolean), + focusable: t.Optional(t.Boolean), + haspopup: t.Optional(t.Boolean), + required: t.Optional(t.Boolean), + invalid: t.Optional(t.Boolean), + modal: t.Optional(t.Boolean), + editable: t.Optional(t.Boolean), + busy: t.Optional(t.Boolean), + multiline: t.Optional(t.Boolean), + readonly: t.Optional(t.Boolean), + checked: t.Optional(t.Enum(['mixed', true])), + expanded: t.Optional(t.Boolean), + disabled: t.Optional(t.Boolean), + multiselectable: t.Optional(t.Boolean), + + value: t.Optional(t.String), + description: t.Optional(t.String), + + value: t.Optional(t.String), + roledescription: t.Optional(t.String), + valuetext: t.Optional(t.String), + orientation: t.Optional(t.String), + autocomplete: t.Optional(t.String), + keyshortcuts: t.Optional(t.String), + + level: t.Optional(t.Number), + + tag: t.Optional(t.String), + + foundObject: t.Optional(t.Boolean), +} + +const networkTypes = {}; + +networkTypes.HTTPHeader = { + name: t.String, + value: t.String, +}; + +networkTypes.SecurityDetails = { + protocol: t.String, + subjectName: t.String, + issuer: t.String, + validFrom: t.Number, + validTo: t.Number, +}; + +const Browser = { + targets: ['browser'], + + events: {}, + + types: browserTypes, + + methods: { + 'close': {}, + 'getInfo': { + returns: { + userAgent: t.String, + version: t.String, + }, + }, + 'setIgnoreHTTPSErrors': { + params: { + enabled: t.Boolean, + }, + }, + 'setExtraHTTPHeaders': { + params: { + browserContextId: t.Optional(t.String), + headers: t.Array(networkTypes.HTTPHeader), + }, + }, + 'grantPermissions': { + params: { + origin: t.String, + browserContextId: t.Optional(t.String), + permissions: t.Array(t.Enum([ + 'geo', 'microphone', 'camera', 'desktop-notifications' + ])), + }, + }, + 'resetPermissions': { + params: { + browserContextId: t.Optional(t.String), + } + }, + 'setCookies': { + params: { + browserContextId: t.Optional(t.String), + cookies: t.Array(browserTypes.CookieOptions), + } + }, + 'clearCookies': { + params: { + browserContextId: t.Optional(t.String), + } + }, + 'getCookies': { + params: { + browserContextId: t.Optional(t.String) + }, + returns: { + cookies: t.Array(browserTypes.Cookie), + }, + }, + }, +}; + +const Target = { + targets: ['browser'], + + types: targetTypes, + + events: { + 'attachedToTarget': { + sessionId: t.String, + targetInfo: targetTypes.TargetInfo, + }, + 'detachedFromTarget': { + sessionId: t.String, + }, + 'targetCreated': targetTypes.TargetInfo, + 'targetDestroyed': targetTypes.TargetInfo, + 'targetInfoChanged': targetTypes.TargetInfo, + }, + + methods: { + // Start emitting tagOpened/tabClosed events + 'enable': {}, + 'attachToTarget': { + params: { + targetId: t.String, + }, + returns: { + sessionId: t.String, + }, + }, + 'newPage': { + params: { + browserContextId: t.Optional(t.String), + }, + returns: { + targetId: t.String, + } + }, + 'createBrowserContext': { + params: { + removeOnDetach: t.Optional(t.Boolean), + userAgent: t.Optional(t.String), + bypassCSP: t.Optional(t.Boolean), + javaScriptDisabled: t.Optional(t.Boolean), + viewport: t.Optional(pageTypes.Viewport), + }, + returns: { + browserContextId: t.String, + }, + }, + 'removeBrowserContext': { + params: { + browserContextId: t.String, + }, + }, + 'getBrowserContexts': { + returns: { + browserContextIds: t.Array(t.String), + }, + }, + }, +}; + +const Network = { + targets: ['page'], + types: networkTypes, + events: { + 'requestWillBeSent': { + // frameId may be absent for redirected requests. + frameId: t.Optional(t.String), + requestId: t.String, + // RequestID of redirected request. + redirectedFrom: t.Optional(t.String), + postData: t.Optional(t.String), + headers: t.Array(networkTypes.HTTPHeader), + isIntercepted: t.Boolean, + url: t.String, + method: t.String, + navigationId: t.Optional(t.String), + cause: t.String, + }, + 'responseReceived': { + securityDetails: t.Nullable(networkTypes.SecurityDetails), + requestId: t.String, + fromCache: t.Boolean, + remoteIPAddress: t.Optional(t.String), + remotePort: t.Optional(t.Number), + status: t.Number, + statusText: t.String, + headers: t.Array(networkTypes.HTTPHeader), + }, + 'requestFinished': { + requestId: t.String, + }, + 'requestFailed': { + requestId: t.String, + errorCode: t.String, + }, + }, + methods: { + 'setRequestInterception': { + params: { + enabled: t.Boolean, + }, + }, + 'setExtraHTTPHeaders': { + params: { + headers: t.Array(networkTypes.HTTPHeader), + }, + }, + 'abortInterceptedRequest': { + params: { + requestId: t.String, + errorCode: t.String, + }, + }, + 'resumeInterceptedRequest': { + params: { + requestId: t.String, + method: t.Optional(t.String), + headers: t.Optional(t.Array(networkTypes.HTTPHeader)), + postData: t.Optional(t.String), + }, + }, + 'fulfillInterceptedRequest': { + params: { + requestId: t.String, + status: t.Number, + statusText: t.String, + headers: t.Array(networkTypes.HTTPHeader), + base64body: t.Optional(t.String), // base64-encoded + }, + }, + 'getResponseBody': { + params: { + requestId: t.String, + }, + returns: { + base64body: t.String, + evicted: t.Optional(t.Boolean), + }, + }, + 'setAuthCredentials': { + params: { + username: t.Nullable(t.String), + password: t.Nullable(t.String), + }, + }, + }, +}; + +const Runtime = { + targets: ['page'], + types: runtimeTypes, + events: { + 'executionContextCreated': { + executionContextId: t.String, + auxData: t.Any, + }, + 'executionContextDestroyed': { + executionContextId: t.String, + }, + 'console': { + executionContextId: t.String, + args: t.Array(runtimeTypes.RemoteObject), + type: t.String, + location: runtimeTypes.ScriptLocation, + }, + }, + methods: { + 'evaluate': { + params: { + // Pass frameId here. + executionContextId: t.String, + expression: t.String, + returnByValue: t.Optional(t.Boolean), + }, + + returns: { + result: t.Optional(runtimeTypes.RemoteObject), + exceptionDetails: t.Optional(runtimeTypes.ExceptionDetails), + } + }, + 'callFunction': { + params: { + // Pass frameId here. + executionContextId: t.String, + functionDeclaration: t.String, + returnByValue: t.Optional(t.Boolean), + args: t.Array(runtimeTypes.CallFunctionArgument), + }, + + returns: { + result: t.Optional(runtimeTypes.RemoteObject), + exceptionDetails: t.Optional(runtimeTypes.ExceptionDetails), + } + }, + 'disposeObject': { + params: { + executionContextId: t.String, + objectId: t.String, + }, + }, + + 'getObjectProperties': { + params: { + executionContextId: t.String, + objectId: t.String, + }, + + returns: { + properties: t.Array(runtimeTypes.ObjectProperty), + } + }, + }, +}; + +const Page = { + targets: ['page'], + + types: pageTypes, + events: { + 'ready': { + }, + 'crashed': { + }, + 'eventFired': { + frameId: t.String, + name: t.Enum(['load', 'DOMContentLoaded']), + }, + 'uncaughtError': { + frameId: t.String, + message: t.String, + stack: t.String, + }, + 'frameAttached': { + frameId: t.String, + parentFrameId: t.Optional(t.String), + }, + 'frameDetached': { + frameId: t.String, + }, + 'navigationStarted': { + frameId: t.String, + navigationId: t.String, + url: t.String, + }, + 'navigationCommitted': { + frameId: t.String, + // |navigationId| can only be null in response to enable. + navigationId: t.Optional(t.String), + url: t.String, + // frame.id or frame.name + name: t.String, + }, + 'navigationAborted': { + frameId: t.String, + navigationId: t.String, + errorText: t.String, + }, + 'sameDocumentNavigation': { + frameId: t.String, + url: t.String, + }, + 'dialogOpened': { + dialogId: t.String, + type: t.Enum(['prompt', 'alert', 'confirm', 'beforeunload']), + message: t.String, + defaultValue: t.Optional(t.String), + }, + 'dialogClosed': { + dialogId: t.String, + }, + 'bindingCalled': { + executionContextId: t.String, + name: t.String, + payload: t.Any, + }, + 'fileChooserOpened': { + executionContextId: t.String, + element: runtimeTypes.RemoteObject + }, + 'workerCreated': { + workerId: t.String, + frameId: t.String, + url: t.String, + }, + 'workerDestroyed': { + workerId: t.String, + }, + 'dispatchMessageFromWorker': { + workerId: t.String, + message: t.String, + }, + }, + + methods: { + 'close': { + params: { + runBeforeUnload: t.Optional(t.Boolean), + }, + }, + 'setFileInputFiles': { + params: { + frameId: t.String, + objectId: t.String, + files: t.Array(t.String), + }, + }, + 'addBinding': { + params: { + name: t.String, + }, + }, + 'setViewportSize': { + params: { + viewportSize: t.Nullable(pageTypes.Size), + }, + }, + 'setEmulatedMedia': { + params: { + type: t.Optional(t.Enum(['screen', 'print', ''])), + colorScheme: t.Optional(t.Enum(['dark', 'light', 'no-preference'])), + }, + }, + 'setCacheDisabled': { + params: { + cacheDisabled: t.Boolean, + }, + }, + 'describeNode': { + params: { + frameId: t.String, + objectId: t.String, + }, + returns: { + contentFrameId: t.Optional(t.String), + ownerFrameId: t.Optional(t.String), + }, + }, + 'scrollIntoViewIfNeeded': { + params: { + frameId: t.String, + objectId: t.String, + rect: t.Optional(pageTypes.Rect), + }, + }, + 'addScriptToEvaluateOnNewDocument': { + params: { + script: t.String, + worldName: t.Optional(t.String), + }, + returns: { + scriptId: t.String, + } + }, + 'removeScriptToEvaluateOnNewDocument': { + params: { + scriptId: t.String, + }, + }, + 'navigate': { + params: { + frameId: t.String, + url: t.String, + referer: t.Optional(t.String), + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'goBack': { + params: { + frameId: t.String, + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'goForward': { + params: { + frameId: t.String, + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'reload': { + params: { + frameId: t.String, + }, + returns: { + navigationId: t.String, + navigationURL: t.String, + } + }, + 'getBoundingBox': { + params: { + frameId: t.String, + objectId: t.String, + }, + returns: { + boundingBox: t.Nullable(pageTypes.Rect), + }, + }, + 'adoptNode': { + params: { + frameId: t.String, + objectId: t.String, + executionContextId: t.String, + }, + returns: { + remoteObject: t.Nullable(runtimeTypes.RemoteObject), + }, + }, + 'screenshot': { + params: { + mimeType: t.Enum(['image/png', 'image/jpeg']), + fullPage: t.Optional(t.Boolean), + clip: t.Optional(pageTypes.Clip), + }, + returns: { + data: t.String, + } + }, + 'getContentQuads': { + params: { + frameId: t.String, + objectId: t.String, + }, + returns: { + quads: t.Array(pageTypes.DOMQuad), + }, + }, + 'dispatchKeyEvent': { + params: { + type: t.String, + key: t.String, + keyCode: t.Number, + location: t.Number, + code: t.String, + repeat: t.Boolean, + text: t.Optional(t.String), + } + }, + 'dispatchTouchEvent': { + params: { + type: t.Enum(['touchStart', 'touchEnd', 'touchMove', 'touchCancel']), + touchPoints: t.Array(pageTypes.TouchPoint), + modifiers: t.Number, + }, + returns: { + defaultPrevented: t.Boolean, + } + }, + 'dispatchMouseEvent': { + params: { + type: t.String, + button: t.Number, + x: t.Number, + y: t.Number, + modifiers: t.Number, + clickCount: t.Optional(t.Number), + buttons: t.Number, + } + }, + 'insertText': { + params: { + text: t.String, + } + }, + 'crash': { + params: {} + }, + 'handleDialog': { + params: { + dialogId: t.String, + accept: t.Boolean, + promptText: t.Optional(t.String), + }, + }, + 'setInterceptFileChooserDialog': { + params: { + enabled: t.Boolean, + }, + }, + 'sendMessageToWorker': { + params: { + frameId: t.String, + workerId: t.String, + message: t.String, + }, + }, + }, +}; + + +const Accessibility = { + targets: ['page'], + types: axTypes, + events: {}, + methods: { + 'getFullAXTree': { + params: { + objectId: t.Optional(t.String), + }, + returns: { + tree: axTypes.AXTree + }, + } + } +} + +this.protocol = { + domains: {Browser, Target, Page, Runtime, Network, Accessibility}, +}; +this.checkScheme = checkScheme; +this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme']; diff --git a/testing/juggler/protocol/RuntimeHandler.js b/testing/juggler/protocol/RuntimeHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..089e66c617f114fcb32b3cea20abc6fb80e26a1e --- /dev/null +++ b/testing/juggler/protocol/RuntimeHandler.js @@ -0,0 +1,37 @@ +"use strict"; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const helper = new Helper(); + +class RuntimeHandler { + constructor(chromeSession, contentSession) { + this._chromeSession = chromeSession; + this._contentSession = contentSession; + } + + async evaluate(options) { + return await this._contentSession.send('Runtime.evaluate', options); + } + + async callFunction(options) { + return await this._contentSession.send('Runtime.callFunction', options); + } + + async getObjectProperties(options) { + return await this._contentSession.send('Runtime.getObjectProperties', options); + } + + async disposeObject(options) { + return await this._contentSession.send('Runtime.disposeObject', options); + } + + dispose() {} +} + +var EXPORTED_SYMBOLS = ['RuntimeHandler']; +this.RuntimeHandler = RuntimeHandler; diff --git a/testing/juggler/protocol/TargetHandler.js b/testing/juggler/protocol/TargetHandler.js new file mode 100644 index 0000000000000000000000000000000000000000..c0bab449971de13f993ac9825ac13368f8d8e226 --- /dev/null +++ b/testing/juggler/protocol/TargetHandler.js @@ -0,0 +1,100 @@ +"use strict"; + +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); +const {BrowserContextManager} = ChromeUtils.import("chrome://juggler/content/BrowserContextManager.js"); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const helper = new Helper(); + +class TargetHandler { + /** + * @param {ChromeSession} session + */ + constructor(session) { + this._session = session; + this._contextManager = BrowserContextManager.instance(); + this._targetRegistry = TargetRegistry.instance(); + this._enabled = false; + this._eventListeners = []; + this._createdBrowserContextIds = new Set(); + } + + async attachToTarget({targetId}) { + if (!this._enabled) + throw new Error('Target domain is not enabled'); + const sessionId = this._session.dispatcher().createSession(targetId, true /* shouldConnect */); + return {sessionId}; + } + + async createBrowserContext(options) { + if (!this._enabled) + throw new Error('Target domain is not enabled'); + const browserContext = this._contextManager.createBrowserContext(options); + this._createdBrowserContextIds.add(browserContext.browserContextId); + return {browserContextId: browserContext.browserContextId}; + } + + async removeBrowserContext({browserContextId}) { + if (!this._enabled) + throw new Error('Target domain is not enabled'); + this._createdBrowserContextIds.delete(browserContextId); + this._contextManager.browserContextForId(browserContextId).destroy(); + } + + async getBrowserContexts() { + const browserContexts = this._contextManager.getBrowserContexts(); + return {browserContextIds: browserContexts.map(bc => bc.browserContextId)}; + } + + async enable() { + if (this._enabled) + return; + this._enabled = true; + for (const targetInfo of this._targetRegistry.targetInfos()) + this._onTargetCreated(targetInfo); + + this._eventListeners = [ + helper.on(this._targetRegistry, TargetRegistry.Events.TargetCreated, this._onTargetCreated.bind(this)), + helper.on(this._targetRegistry, TargetRegistry.Events.TargetChanged, this._onTargetChanged.bind(this)), + helper.on(this._targetRegistry, TargetRegistry.Events.TargetDestroyed, this._onTargetDestroyed.bind(this)), + helper.on(this._targetRegistry, TargetRegistry.Events.PageTargetReady, this._onPageTargetReady.bind(this)), + ]; + } + + dispose() { + helper.removeListeners(this._eventListeners); + for (const browserContextId of this._createdBrowserContextIds) { + const browserContext = this._contextManager.browserContextForId(browserContextId); + if (browserContext.options.removeOnDetach) + browserContext.destroy(); + } + this._createdBrowserContextIds.clear(); + } + + _onTargetCreated(targetInfo) { + this._session.emitEvent('Target.targetCreated', targetInfo); + } + + _onTargetChanged(targetInfo) { + this._session.emitEvent('Target.targetInfoChanged', targetInfo); + } + + _onTargetDestroyed(targetInfo) { + this._session.emitEvent('Target.targetDestroyed', targetInfo); + } + + _onPageTargetReady({sessionIds, targetInfo}) { + if (!this._createdBrowserContextIds.has(targetInfo.browserContextId)) + return; + const sessionId = this._session.dispatcher().createSession(targetInfo.targetId, false /* shouldConnect */); + sessionIds.push(sessionId); + } + + async newPage({browserContextId}) { + const targetId = await this._targetRegistry.newPage({browserContextId}); + return {targetId}; + } +} + +var EXPORTED_SYMBOLS = ['TargetHandler']; +this.TargetHandler = TargetHandler; diff --git a/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp b/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp index 7e10920c900670949c2bef73715bde0eb4f0ea23..8c0f31c01bce59e24fa83faeae69813b3ee60de7 100644 --- a/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp +++ b/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp @@ -177,8 +177,16 @@ nsBrowserStatusFilter::OnStateChange(nsIWebProgress* aWebProgress, } NS_IMETHODIMP -nsBrowserStatusFilter::OnProgressChange(nsIWebProgress* aWebProgress, - nsIRequest* aRequest, +nsBrowserStatusFilter::OnFrameLocationChange(nsIWebProgress *aWebProgress, + nsIRequest *aRequest, + nsIURI *aLocation, + uint32_t aFlags) { + return NS_OK; +} + +NS_IMETHODIMP +nsBrowserStatusFilter::OnProgressChange(nsIWebProgress *aWebProgress, + nsIRequest *aRequest, int32_t aCurSelfProgress, int32_t aMaxSelfProgress, int32_t aCurTotalProgress, diff --git a/toolkit/toolkit.mozbuild b/toolkit/toolkit.mozbuild index 299230cb3bde5ecd111454ed6f59d1f0504b67a1..3edcf62eefcfbee366566beea4c0899a7a4b59fc 100644 --- a/toolkit/toolkit.mozbuild +++ b/toolkit/toolkit.mozbuild @@ -168,6 +168,7 @@ if CONFIG['ENABLE_MARIONETTE']: DIRS += [ '/testing/firefox-ui', '/testing/marionette', + '/testing/juggler', '/toolkit/components/telemetry/tests/marionette', ] diff --git a/uriloader/base/nsDocLoader.cpp b/uriloader/base/nsDocLoader.cpp index 23df86cd223bccc335f7b62621f463ce62919134..372626c7e9e3d194b940befa1444d555d45a8801 100644 --- a/uriloader/base/nsDocLoader.cpp +++ b/uriloader/base/nsDocLoader.cpp @@ -758,6 +758,13 @@ void nsDocLoader::DocLoaderIsEmpty(bool aFlushLayout) { ("DocLoader:%p: Firing load event for document.open\n", this)); + nsCOMPtr os = mozilla::services::GetObserverService(); + if (os) { + nsIPrincipal* principal = doc->NodePrincipal(); + if (!principal->IsSystemPrincipal()) + os->NotifyObservers(ToSupports(doc), "juggler-document-open-loaded", nullptr); + } + // This is a very cut-down version of // nsDocumentViewer::LoadComplete that doesn't do various things // that are not relevant here because this wasn't an actual @@ -1365,6 +1372,24 @@ void nsDocLoader::FireOnLocationChange(nsIWebProgress* aWebProgress, } } +void nsDocLoader::FireOnFrameLocationChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, + nsIURI *aUri, + uint32_t aFlags) { + NOTIFY_LISTENERS(nsIWebProgress::NOTIFY_FRAME_LOCATION, + nsCOMPtr listener2 = + do_QueryReferent(info.mWeakListener); + if (!listener2) + continue; + listener2->OnFrameLocationChange(aWebProgress, aRequest, aUri, aFlags); + ); + + // Pass the notification up to the parent... + if (mParent) { + mParent->FireOnFrameLocationChange(aWebProgress, aRequest, aUri, aFlags); + } +} + void nsDocLoader::FireOnStatusChange(nsIWebProgress* aWebProgress, nsIRequest* aRequest, nsresult aStatus, const char16_t* aMessage) { diff --git a/uriloader/base/nsDocLoader.h b/uriloader/base/nsDocLoader.h index 14d9d1052ef2a44ba2066572de306034d9f3a8c6..c9b2f0676e3010967b784eccb6f29ec6f50d926a 100644 --- a/uriloader/base/nsDocLoader.h +++ b/uriloader/base/nsDocLoader.h @@ -212,6 +212,11 @@ class nsDocLoader : public nsIDocumentLoader, void FireOnLocationChange(nsIWebProgress* aWebProgress, nsIRequest* aRequest, nsIURI* aUri, uint32_t aFlags); + void FireOnFrameLocationChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, + nsIURI *aUri, + uint32_t aFlags); + MOZ_MUST_USE bool RefreshAttempted(nsIWebProgress* aWebProgress, nsIURI* aURI, int32_t aDelay, bool aSameURI); diff --git a/uriloader/base/nsIWebProgress.idl b/uriloader/base/nsIWebProgress.idl index b0cde5026dc7c414e8f20300ac2b7d735dbd846e..09ebb0ef6799cf6a74fe529d4d000c6bed2c9497 100644 --- a/uriloader/base/nsIWebProgress.idl +++ b/uriloader/base/nsIWebProgress.idl @@ -87,6 +87,10 @@ interface nsIWebProgress : nsISupports * NOTIFY_REFRESH * Receive onRefreshAttempted events. * This is defined on nsIWebProgressListener2. + * + * NOTIFY_FRAME_LOCATION + * Receive onFrameLocationChange events. + * This is defined on nsIWebProgressListener2. */ const unsigned long NOTIFY_PROGRESS = 0x00000010; const unsigned long NOTIFY_STATUS = 0x00000020; @@ -94,11 +98,12 @@ interface nsIWebProgress : nsISupports const unsigned long NOTIFY_LOCATION = 0x00000080; const unsigned long NOTIFY_REFRESH = 0x00000100; const unsigned long NOTIFY_CONTENT_BLOCKING = 0x00000200; + const unsigned long NOTIFY_FRAME_LOCATION = 0x00000400; /** * This flag enables all notifications. */ - const unsigned long NOTIFY_ALL = 0x000003ff; + const unsigned long NOTIFY_ALL = 0x000007ff; /** * Registers a listener to receive web progress events. diff --git a/uriloader/base/nsIWebProgressListener2.idl b/uriloader/base/nsIWebProgressListener2.idl index 87701f8d2cfee8bd84acd28c62b3be4989c9474c..ae1aa85c019cb21d4f7e79c35e8afe72709468a1 100644 --- a/uriloader/base/nsIWebProgressListener2.idl +++ b/uriloader/base/nsIWebProgressListener2.idl @@ -66,4 +66,27 @@ interface nsIWebProgressListener2 : nsIWebProgressListener { in nsIURI aRefreshURI, in long aMillis, in boolean aSameURI); + + /** + * Called when the location of the window or its subframes changes. This is not + * when a load is requested, but rather once it is verified that the load is + * going to occur in the given window. For instance, a load that starts in a + * window might send progress and status messages for the new site, but it + * will not send the onLocationChange until we are sure that we are loading + * this new page here. + * + * @param aWebProgress + * The nsIWebProgress instance that fired the notification. + * @param aRequest + * The associated nsIRequest. This may be null in some cases. + * @param aLocation + * The URI of the location that is being loaded. + * @param aFlags + * This is a value which explains the situation or the reason why + * the location has changed. + */ + void onFrameLocationChange(in nsIWebProgress aWebProgress, + in nsIRequest aRequest, + in nsIURI aLocation, + [optional] in unsigned long aFlags); };