/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*- * Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ package org.mozilla.geckoview.test import android.graphics.SurfaceTexture import android.net.Uri import android.view.PointerIcon import android.view.Surface import androidx.annotation.AnyThread import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import org.hamcrest.Matchers.* // ktlint-disable no-wildcard-imports import org.json.JSONObject import org.junit.Assume.assumeThat import org.junit.Test import org.junit.runner.RunWith import org.mozilla.geckoview.* // ktlint-disable no-wildcard-imports import org.mozilla.geckoview.ContentBlocking.CookieBannerMode import org.mozilla.geckoview.GeckoDisplay.SurfaceInfo import org.mozilla.geckoview.GeckoSession.ContentDelegate import org.mozilla.geckoview.GeckoSession.NavigationDelegate import org.mozilla.geckoview.GeckoSession.NavigationDelegate.LoadRequest import org.mozilla.geckoview.GeckoSession.ProgressDelegate import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.AssertCalled import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.IgnoreCrash import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.NullDelegate import org.mozilla.geckoview.test.rule.GeckoSessionTestRule.WithDisplay import java.io.ByteArrayInputStream @RunWith(AndroidJUnit4::class) @MediumTest class ContentDelegateTest : BaseSessionTest() { @Test fun titleChange() { mainSession.loadTestPath(TITLE_CHANGE_HTML_PATH) sessionRule.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 2) override fun onTitleChange(session: GeckoSession, title: String?) { assertThat( "Title should match", title, equalTo(forEachCall("Title1", "Title2")), ) } }) } @Test fun openInAppRequest() { // Testing WebResponse behavior val data = "Hello, World.".toByteArray() val fileHeader = "attachment; filename=\"hello-world.txt\"" val requestExternal = true val skipConfirmation = true var response = WebResponse.Builder(HELLO_HTML_PATH) .statusCode(200) .body(ByteArrayInputStream(data)) .addHeader("Content-Type", "application/txt") .addHeader("Content-Length", data.size.toString()) .addHeader("Content-Disposition", fileHeader) .requestExternalApp(requestExternal) .skipConfirmation(skipConfirmation) .build() assertThat( "Filename matches as expected", response.headers["Content-Disposition"], equalTo(fileHeader), ) assertThat( "Request external response matches as expected.", requestExternal, equalTo(response.requestExternalApp), ) assertThat( "Skipping the confirmation matches as expected.", skipConfirmation, equalTo(response.skipConfirmation), ) } @Test fun downloadOneRequest() { // disable test on pgo for frequently failing Bug 1543355 assumeThat(sessionRule.env.isDebugBuild, equalTo(true)) mainSession.loadTestPath(DOWNLOAD_HTML_PATH) sessionRule.waitUntilCalled(object : NavigationDelegate, ContentDelegate { @AssertCalled(count = 2) override fun onLoadRequest(session: GeckoSession, request: LoadRequest): GeckoResult? { return null } @AssertCalled(false) override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { return null } @AssertCalled(count = 1) override fun onExternalResponse(session: GeckoSession, response: WebResponse) { assertThat("Uri should start with data:", response.uri, startsWith("blob:")) assertThat("We should download the thing", String(response.body?.readBytes()!!), equalTo("Downloaded Data")) // The headers below are special headers that we try to get for responses of any kind (http, blob, etc.) // Note the case of the header keys. In the WebResponse object, all of them are lower case. assertThat("Content type should match", response.headers.get("content-type"), equalTo("text/plain")) assertThat("Content length should be non-zero", response.headers.get("Content-Length")!!.toLong(), greaterThan(0L)) assertThat("Filename should match", response.headers.get("cONTent-diSPOsiTion"), equalTo("attachment; filename=\"download.txt\"")) assertThat("Request external response should not be set.", response.requestExternalApp, equalTo(false)) assertThat("Should not skip the confirmation on a regular download.", response.skipConfirmation, equalTo(false)) } }) } @IgnoreCrash @Test fun crashContent() { // TODO: bug 1710940 assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) mainSession.loadUri(CONTENT_CRASH_URL) mainSession.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onCrash(session: GeckoSession) { assertThat( "Session should be closed after a crash", session.isOpen, equalTo(false), ) } }) // Recover immediately mainSession.open() mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitUntilCalled(object : ProgressDelegate { @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat("Page should load successfully", success, equalTo(true)) } }) } @IgnoreCrash @WithDisplay(width = 10, height = 10) @Test fun crashContent_tapAfterCrash() { // TODO: bug 1710940 assumeThat(sessionRule.env.isIsolatedProcess, equalTo(false)) mainSession.delegateUntilTestEnd(object : ContentDelegate { override fun onCrash(session: GeckoSession) { mainSession.open() mainSession.loadTestPath(HELLO_HTML_PATH) } }) mainSession.synthesizeTap(5, 5) mainSession.loadUri(CONTENT_CRASH_URL) mainSession.waitForPageStop() mainSession.synthesizeTap(5, 5) mainSession.reload() mainSession.waitForPageStop() } @AnyThread fun killAllContentProcesses() { val contentProcessPids = sessionRule.getAllSessionPids() for (pid in contentProcessPids) { sessionRule.killContentProcess(pid) } } @IgnoreCrash @Test fun killContent() { killAllContentProcesses() mainSession.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onKill(session: GeckoSession) { assertThat( "Session should be closed after being killed", session.isOpen, equalTo(false), ) } }) mainSession.open() mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitUntilCalled(object : ProgressDelegate { @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat("Page should load successfully", success, equalTo(true)) } }) } private fun goFullscreen() { sessionRule.setPrefsUntilTestEnd(mapOf("full-screen-api.allow-trusted-requests-only" to false)) mainSession.loadTestPath(FULLSCREEN_PATH) mainSession.waitForPageStop() val promise = mainSession.evaluatePromiseJS("document.querySelector('#fullscreen').requestFullscreen()") sessionRule.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) { assertThat("Div went fullscreen", fullScreen, equalTo(true)) } }) promise.value } private fun waitForFullscreenExit() { sessionRule.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onFullScreen(session: GeckoSession, fullScreen: Boolean) { assertThat("Div left fullscreen", fullScreen, equalTo(false)) } }) } @Test fun fullscreen() { goFullscreen() val promise = mainSession.evaluatePromiseJS("document.exitFullscreen()") waitForFullscreenExit() promise.value } @Test fun sessionExitFullscreen() { goFullscreen() mainSession.exitFullScreen() waitForFullscreenExit() } @Test fun firstComposite() { val display = mainSession.acquireDisplay() val texture = SurfaceTexture(0) texture.setDefaultBufferSize(100, 100) val surface = Surface(texture) display.surfaceChanged(SurfaceInfo.Builder(surface).size(100, 100).build()) mainSession.loadTestPath(HELLO_HTML_PATH) sessionRule.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onFirstComposite(session: GeckoSession) { } }) display.surfaceDestroyed() display.surfaceChanged(SurfaceInfo.Builder(surface).size(100, 100).build()) sessionRule.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onFirstComposite(session: GeckoSession) { } }) display.surfaceDestroyed() mainSession.releaseDisplay(display) } @WithDisplay(width = 10, height = 10) @Test fun firstContentfulPaint() { mainSession.loadTestPath(HELLO_HTML_PATH) sessionRule.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onFirstContentfulPaint(session: GeckoSession) { } }) } @Test fun webAppManifestPref() { val initialState = sessionRule.runtime.settings.getWebManifestEnabled() val jsToRun = "document.querySelector('link[rel=manifest]').relList.supports('manifest');" // Check pref'ed off sessionRule.runtime.settings.setWebManifestEnabled(false) mainSession.loadTestPath(HELLO_HTML_PATH) sessionRule.waitForPageStop(mainSession) var result = equalTo(mainSession.evaluateJS(jsToRun) as Boolean) assertThat("Disabling pref makes relList.supports('manifest') return false", false, result) // Check pref'ed on sessionRule.runtime.settings.setWebManifestEnabled(true) mainSession.loadTestPath(HELLO_HTML_PATH) sessionRule.waitForPageStop(mainSession) result = equalTo(mainSession.evaluateJS(jsToRun) as Boolean) assertThat("Enabling pref makes relList.supports('manifest') return true", true, result) sessionRule.runtime.settings.setWebManifestEnabled(initialState) } @Test fun webAppManifest() { mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat("Page load should succeed", success, equalTo(true)) } @AssertCalled(count = 1) override fun onWebAppManifest(session: GeckoSession, manifest: JSONObject) { // These values come from the manifest at assets/www/manifest.webmanifest assertThat("name should match", manifest.getString("name"), equalTo("App")) assertThat("short_name should match", manifest.getString("short_name"), equalTo("app")) assertThat("display should match", manifest.getString("display"), equalTo("standalone")) // The color here is "cadetblue" converted to #aarrggbb. assertThat("theme_color should match", manifest.getString("theme_color"), equalTo("#ff5f9ea0")) assertThat("background_color should match", manifest.getString("background_color"), equalTo("#eec0ffee")) assertThat("start_url should match", manifest.getString("start_url"), endsWith("/assets/www/start/index.html")) val icon = manifest.getJSONArray("icons").getJSONObject(0) val iconSrc = Uri.parse(icon.getString("src")) assertThat("icon should have a valid src", iconSrc, notNullValue()) assertThat("icon src should be absolute", iconSrc.isAbsolute, equalTo(true)) assertThat("icon should have sizes", icon.getString("sizes"), not(isEmptyOrNullString())) assertThat("icon type should match", icon.getString("type"), equalTo("image/gif")) } }) } @Test fun previewImage() { mainSession.loadTestPath(METATAGS_PATH) mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { @AssertCalled(count = 1) override fun onPreviewImage(session: GeckoSession, previewImageUrl: String) { assertThat("Preview image should match", previewImageUrl, equalTo("https://test.com/og-image-url")) } }) } @Test fun viewportFit() { mainSession.loadTestPath(VIEWPORT_PATH) mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat("Page load should succeed", success, equalTo(true)) } @AssertCalled(count = 1) override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) { assertThat("viewport-fit should match", viewportFit, equalTo("cover")) } }) mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat("Page load should succeed", success, equalTo(true)) } @AssertCalled(count = 1) override fun onMetaViewportFitChange(session: GeckoSession, viewportFit: String) { assertThat("viewport-fit should match", viewportFit, equalTo("auto")) } }) } @Test fun closeRequest() { if (!sessionRule.env.isAutomation) { sessionRule.setPrefsUntilTestEnd(mapOf("dom.allow_scripts_to_close_windows" to true)) } mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitForPageStop() mainSession.evaluateJS("window.close()") mainSession.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onCloseRequest(session: GeckoSession) { } }) } @Test fun windowOpenClose() { sessionRule.setPrefsUntilTestEnd(mapOf("dom.disable_open_during_load" to false)) mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitForPageStop() val newSession = sessionRule.createClosedSession() mainSession.delegateDuringNextWait(object : NavigationDelegate { @AssertCalled(count = 1) override fun onNewSession(session: GeckoSession, uri: String): GeckoResult? { return GeckoResult.fromValue(newSession) } }) mainSession.evaluateJS("const w = window.open('about:blank'); w.close()") newSession.waitUntilCalled(object : ContentDelegate, ProgressDelegate { @AssertCalled(count = 1) override fun onCloseRequest(session: GeckoSession) { } @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { } }) } @Test fun cookieBannerDetectedEvent() { sessionRule.setPrefsUntilTestEnd( mapOf( "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT, ), ) val detectHandled = GeckoResult() mainSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate { override fun onCookieBannerDetected( session: GeckoSession, ) { detectHandled.complete(null) } }) mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitForPageStop() mainSession.triggerCookieBannerDetected() sessionRule.waitForResult(detectHandled) } @Test fun cookieBannerHandledEvent() { sessionRule.setPrefsUntilTestEnd( mapOf( "cookiebanners.service.mode" to CookieBannerMode.COOKIE_BANNER_MODE_REJECT, ), ) val handleHandled = GeckoResult() mainSession.delegateUntilTestEnd(object : GeckoSession.ContentDelegate { override fun onCookieBannerHandled( session: GeckoSession, ) { handleHandled.complete(null) } }) mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitForPageStop() mainSession.triggerCookieBannerHandled() sessionRule.waitForResult(handleHandled) } @WithDisplay(width = 100, height = 100) @Test fun setCursor() { mainSession.loadTestPath(HELLO_HTML_PATH) mainSession.waitForPageStop() mainSession.evaluateJS("document.body.style.cursor = 'wait'") mainSession.synthesizeMouseMove(50, 50) mainSession.waitUntilCalled(object : ContentDelegate { @AssertCalled(count = 1) override fun onPointerIconChange(session: GeckoSession, icon: PointerIcon) { // PointerIcon has no compare method. } }) val delegate = mainSession.contentDelegate mainSession.contentDelegate = null mainSession.evaluateJS("document.body.style.cursor = 'text'") for (i in 51..70) { mainSession.synthesizeMouseMove(i, 50) // No wait function since we remove content delegate. mainSession.waitForJS("new Promise(resolve => window.setTimeout(resolve, 100))") } mainSession.contentDelegate = delegate } /** * Preferences to induce wanted behaviour. */ private fun setHangReportTestPrefs(timeout: Int = 20000) { sessionRule.setPrefsUntilTestEnd( mapOf( "dom.max_script_run_time" to 1, "dom.max_chrome_script_run_time" to 1, "dom.max_ext_content_script_run_time" to 1, "dom.ipc.cpow.timeout" to 100, "browser.hangNotification.waitPeriod" to timeout, ), ) } /** * With no delegate set, the default behaviour is to stop hung scripts. */ @NullDelegate(ContentDelegate::class) @Test fun stopHungProcessDefault() { setHangReportTestPrefs() mainSession.loadTestPath(HUNG_SCRIPT) sessionRule.delegateUntilTestEnd(object : ProgressDelegate { @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat( "The script did not complete.", mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, equalTo("Started"), ) } }) sessionRule.waitForPageStop(mainSession) } /** * With no overriding implementation for onSlowScript, the default behaviour is to stop hung * scripts. */ @Test fun stopHungProcessNull() { setHangReportTestPrefs() sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { // default onSlowScript returns null @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat( "The script did not complete.", mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, equalTo("Started"), ) } }) mainSession.loadTestPath(HUNG_SCRIPT) sessionRule.waitForPageStop(mainSession) } /** * Test that, with a 'do nothing' delegate, the hung process completes after its delay */ @Test fun stopHungProcessDoNothing() { setHangReportTestPrefs() var scriptHungReportCount = 0 sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { @AssertCalled() override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult { scriptHungReportCount += 1 return GeckoResult.fromValue(null) } @AssertCalled(count = 1) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat("The delegate was informed of the hang repeatedly", scriptHungReportCount, greaterThan(1)) assertThat( "The script did complete.", mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, equalTo("Finished"), ) } }) mainSession.loadTestPath(HUNG_SCRIPT) sessionRule.waitForPageStop(mainSession) } /** * Test that the delegate is called and can stop a hung script */ @Test fun stopHungProcess() { setHangReportTestPrefs() sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { @AssertCalled(count = 1, order = [1]) override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult { return GeckoResult.fromValue(SlowScriptResponse.STOP) } @AssertCalled(count = 1, order = [2]) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat( "The script did not complete.", mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, equalTo("Started"), ) } }) mainSession.loadTestPath(HUNG_SCRIPT) sessionRule.waitForPageStop(mainSession) } /** * Test that the delegate is called and can continue executing hung scripts */ @Test fun stopHungProcessWait() { setHangReportTestPrefs() sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { @AssertCalled(count = 1, order = [1]) override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult { return GeckoResult.fromValue(SlowScriptResponse.CONTINUE) } @AssertCalled(count = 1, order = [2]) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat( "The script did complete.", mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, equalTo("Finished"), ) } }) mainSession.loadTestPath(HUNG_SCRIPT) sessionRule.waitForPageStop(mainSession) } /** * Test that the delegate is called and paused scripts re-notify after the wait period */ @Test fun stopHungProcessWaitThenStop() { setHangReportTestPrefs(500) var scriptWaited = false sessionRule.delegateUntilTestEnd(object : ContentDelegate, ProgressDelegate { @AssertCalled(count = 2, order = [1, 2]) override fun onSlowScript(geckoSession: GeckoSession, scriptFileName: String): GeckoResult { return if (!scriptWaited) { scriptWaited = true GeckoResult.fromValue(SlowScriptResponse.CONTINUE) } else { GeckoResult.fromValue(SlowScriptResponse.STOP) } } @AssertCalled(count = 1, order = [3]) override fun onPageStop(session: GeckoSession, success: Boolean) { assertThat( "The script did not complete.", mainSession.evaluateJS("document.getElementById(\"content\").innerHTML") as String, equalTo("Started"), ) } }) mainSession.loadTestPath(HUNG_SCRIPT) sessionRule.waitForPageStop(mainSession) } /** * Test that the display mode is applied to CSS media query */ @Test fun displayMode() { val pwaSession = sessionRule.createOpenSession( GeckoSessionSettings.Builder(mainSession.settings) .displayMode(GeckoSessionSettings.DISPLAY_MODE_FULLSCREEN) .build(), ) pwaSession.loadTestPath(HELLO_HTML_PATH) pwaSession.waitForPageStop() val matches = pwaSession.evaluateJS("window.matchMedia('(display-mode: fullscreen)').matches") as Boolean assertThat( "display-mode should be fullscreen", matches, equalTo(true), ) } }