diff options
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js')
-rw-r--r-- | toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js | 1111 |
1 files changed, 1111 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js new file mode 100644 index 0000000000..cb08a70151 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js @@ -0,0 +1,1111 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals chrome */ + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = + "webextensions.native-messaging.max-output-message-bytes"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +const ECHO_BODY = String.raw` + import struct + import sys + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const INFO_BODY = String.raw` + import json + import os + import struct + import sys + + msg = json.dumps({"args": sys.argv, "cwd": os.getcwd()}) + if sys.version_info >= (3,): + sys.stdout.buffer.write(struct.pack('@I', len(msg))) + else: + sys.stdout.write(struct.pack('@I', len(msg))) + sys.stdout.write(msg) + sys.exit(0) +`; + +const DELAYED_ECHO_BODY = String.raw` + import atexit + import json + import os + import struct + import sys + import time + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + pid = os.getpid() + + sys.stderr.write("nativeapp with pid %d is running\n" % pid) + + def onexit(): + sys.stderr.write("nativeapp with pid %d is exiting\n" % pid) + + atexit.register(onexit) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + sys.stderr.write( + "nativeapp with pid %d delaying echoing message '%s'\n" % + (pid, str(msg, 'utf-8')) + ) + + time.sleep(5) + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) + + sys.stderr.write( + "nativeapp with pid %d replied to message '%s'\n" % + (pid, str(msg, 'utf-8')) + ) +`; + +const STDERR_LINES = ["hello stderr", "this should be a separate line"]; +let STDERR_MSG = STDERR_LINES.join("\\n"); + +const STDERR_BODY = String.raw` + import sys + sys.stderr.write("${STDERR_MSG}") +`; + +const PLATFORM_PATH_SEP = AppConstants.platform == "win" ? "\\" : "/"; + +let SCRIPTS = [ + { + name: "echo", + description: "a native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "relative.echo", + description: "a native app that echoes; relative path instead of absolute", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + manifest.path = PathUtils.filename(manifest.path); + }, + }, + { + name: "relative_dotdot.echo", + description: "a native app that echos; relative path with dot dot", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // Set to ..\NativeMessagingHosts\relative_dotdot.bat (Windows) + manifest.path = [ + "..", + ...manifest.path.split(PLATFORM_PATH_SEP).slice(-2), + ].join(PLATFORM_PATH_SEP); + }, + }, + { + name: "renamed.echo", + description: "invalid manifest due to name mismatch", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + manifest.name = "renamed_name_mismatch"; + }, + }, + { + name: "nonstdio.echo", + description: "invalid manifest due to non-stdio type", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // schema only permits "stdio" or "pkcs11". Change from "stdio": + manifest.type = "pkcs11"; + }, + }, + { + name: "forwardslash.echo", + description: "a native app that echos; with forward slash in path", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // On Linux/macOS, this doesn't change anything. + // On Windows, this turns C:\Program Files\... in C:/Program Files/... + manifest.path = manifest.path.replaceAll("\\", "/"); + }, + }, + { + name: "dot.echo", + description: "a native app that echos; with dot slash in path", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // Replace / with /./ (or \ with \.\ on Windows). + manifest.path = manifest.path.replaceAll( + PLATFORM_PATH_SEP, + PLATFORM_PATH_SEP + "." + PLATFORM_PATH_SEP + ); + }, + }, + { + name: "dotdot.echo", + description: "a native app that echos; with dot dot slash in path", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // The binary is in a directory called "TYPE_SLUG". Turn + // /TYPE_SLUG/ in /TYPE_SLUG/../TYPE_SLUG/ to have equivalent directories + // that ought to be considered a valid absolute path. + const dirWithSlashes = PLATFORM_PATH_SEP + TYPE_SLUG + PLATFORM_PATH_SEP; + manifest.path = manifest.path.replace( + dirWithSlashes, + dirWithSlashes + ".." + dirWithSlashes + ); + }, + }, + { + name: "delayedecho", + description: + "a native app that echo messages received with a small artificial delay", + script: DELAYED_ECHO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "info", + description: "a native app that gives some info about how it was started", + script: INFO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "stderr", + description: "a native app that writes to stderr and then exits", + script: STDERR_BODY.replace(/^ {2}/gm, ""), + }, +]; + +if (AppConstants.platform == "win") { + SCRIPTS.push({ + name: "echocmd", + description: "echo but using a .cmd file", + scriptExtension: "cmd", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }); +} + +add_setup(async function setup() { + optionalPermissionsPromptHandler.init(); + optionalPermissionsPromptHandler.acceptPrompt = true; + await AddonTestUtils.promiseStartupManager(); + + await setupHosts(SCRIPTS); +}); + +// Test the basic operation of native messaging with a simple +// script that echoes back whatever message is sent to it. +add_task(async function test_happy_path() { + async function background() { + let port; + browser.test.onMessage.addListener(async (what, payload) => { + if (what == "request") { + await browser.permissions.request({ permissions: ["nativeMessaging"] }); + // connectNative requires permission + port = browser.runtime.connectNative("echo"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("message", msg); + }); + browser.test.sendMessage("ready"); + } else if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + optional_permissions: ["nativeMessaging"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + await extension.awaitMessage("ready"); + }); + const tests = [ + { + data: "this is a string", + what: "simple string", + }, + { + data: "Это юникода", + what: "unicode string", + }, + { + data: { test: "hello" }, + what: "simple object", + }, + { + data: { + what: "An object with a few properties", + number: 123, + bool: true, + nested: { what: "another object" }, + }, + what: "object with several properties", + }, + + { + data: { + ignoreme: true, + _json: { data: "i have a tojson method" }, + }, + expected: { data: "i have a tojson method" }, + what: "object with toJSON() method", + }, + ]; + for (let test of tests) { + extension.sendMessage("send", test.data); + let response = await extension.awaitMessage("message"); + let expected = test.expected || test.data; + deepEqual(response, expected, `Echoed a message of type ${test.what}`); + } + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +// Just test that the given app (which should be the echo script above) +// can be started. Used to test corner cases in how the native application +// is located/launched. +async function simpleTest(app) { + function background(appname) { + let port = browser.runtime.connectNative(appname); + let MSG = "test"; + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + browser.test.sendMessage("done"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${JSON.stringify(app)});`, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +} + +async function testBrokenApp({ + extensionId = ID, + appname, + expectedError, + expectedConsoleMessages, +}) { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(async (appname, expectedError) => { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage(appname, "dummymsg"), + expectedError, + "Expected sendNativeMessage error" + ); + browser.test.sendMessage("done"); + }); + }, + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + let { messages } = await promiseConsoleOutput(async () => { + extension.sendMessage(appname, expectedError); + await extension.awaitMessage("done"); + }); + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); + + // Because we're using forbidUnexpected:true below, we have to account for + // all logged messages. RemoteSettings may try (and fail) to load remote + // settings - ignore the "NetworkError: Network request failed" error. + // To avoid having to update this filter all the time, select the specific + // modules relevant to native messaging from where we expect errors. + messages = messages.filter(m => { + return /NativeMessaging|NativeManifests|Subprocess/.test(m.message); + }); + + // On Linux/macOS, the setupHosts helper registers the same manifest file in + // multiple locations, which can result in the same error being printed + // multiple times. We de-duplicate that here. + let deduplicatedMessages = messages.filter( + (msg, i) => i === messages.findIndex(m => m.message === msg.message) + ); + + // Now check that all the log messages exist, in the expected order too. + AddonTestUtils.checkMessages( + deduplicatedMessages, + { + expected: expectedConsoleMessages.map(message => ({ message })), + forbidUnexpected: true, + }, + "Expected messages in the console" + ); +} + +if (AppConstants.platform == "win") { + // "relative.echo" has a relative path in the host manifest. + add_task(function test_relative_path() { + // Note: relative paths only supported on Windows. + // For non-Windows, see test_relative_path_unsupported instead. + return simpleTest("relative.echo"); + }); + + add_task(function test_relative_dotdot_path() { + // Note: relative paths only supported on Windows. + // For non-Windows, see test_relative_dotdot_path_unsupported instead. + return simpleTest("relative_dotdot.echo"); + }); + + // "echocmd" uses a .cmd file instead of a .bat file + add_task(function test_cmd_file() { + return simpleTest("echocmd"); + }); +} else { + // On non-Windows, relative paths are not supported. + add_task(function test_relative_path_unsupported() { + return testBrokenApp({ + appname: "relative.echo", + expectedError: "An unexpected error occurred", + expectedConsoleMessages: [ + /NativeApp requires absolute path to command on this platform/, + ], + }); + }); + add_task(function test_relative_dotdot_path_unsupported() { + return testBrokenApp({ + appname: "relative_dotdot.echo", + expectedError: "An unexpected error occurred", + expectedConsoleMessages: [ + /NativeApp requires absolute path to command on this platform/, + ], + }); + }); +} + +add_task(async function test_absolute_path_dot_one() { + return simpleTest("dot.echo"); +}); + +add_task(async function test_absolute_path_dotdot() { + return simpleTest("dotdot.echo"); +}); + +add_task(async function test_error_name_mismatch() { + await testBrokenApp({ + appname: "renamed.echo", + expectedError: "No such native application renamed.echo", + expectedConsoleMessages: [ + /Native manifest .+ has name property renamed_name_mismatch \(expected renamed\.echo\)/, + /No such native application renamed\.echo/, + ], + }); +}); + +add_task(async function test_invalid_manifest_type_not_stdio() { + await testBrokenApp({ + appname: "nonstdio.echo", + expectedError: "No such native application nonstdio.echo", + expectedConsoleMessages: [ + /Native manifest .+ has type property pkcs11 \(expected stdio\)/, + /No such native application nonstdio\.echo/, + ], + }); +}); + +add_task(async function test_forward_slashes_in_path_works() { + await simpleTest("forwardslash.echo"); +}); + +// Test sendNativeMessage() +add_task(async function test_sendNativeMessage() { + async function background() { + let MSG = { test: "hello world" }; + + // Check error handling + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("nonexistent", MSG), + "No such native application nonexistent", + "sendNativeMessage() to a nonexistent app failed" + ); + + // Check regular message exchange + let reply = await browser.runtime.sendNativeMessage("echo", MSG); + + let expected = JSON.stringify(MSG); + let received = JSON.stringify(reply); + browser.test.assertEq(expected, received, "Received echoed native message"); + + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + + // With sendNativeMessage(), the subprocess should be disconnected + // after exchanging a single message. + await waitForSubprocessExit(); + + await extension.unload(); +}); + +// Test calling Port.disconnect() +add_task(async function test_disconnect() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onMessage.addListener((msg, msgPort) => { + browser.test.assertEq( + port, + msgPort, + "onMessage handler should receive the port as the second argument" + ); + browser.test.sendMessage("message", msg); + }); + port.onDisconnect.addListener(msgPort => { + browser.test.fail("onDisconnect should not be called for disconnect()"); + }); + browser.test.onMessage.addListener((what, payload) => { + if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } else if (what == "disconnect") { + try { + port.disconnect(); + browser.test.assertThrows( + () => port.postMessage("void"), + "Attempt to postMessage on disconnected port" + ); + browser.test.sendMessage("disconnect-result", { success: true }); + } catch (err) { + browser.test.sendMessage("disconnect-result", { + success: false, + errmsg: err.message, + }); + } + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("send", "test"); + let response = await extension.awaitMessage("message"); + equal(response, "test", "Echoed a string"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "disconnect succeeded"); + + info("waiting for subprocess to exit"); + await waitForSubprocessExit(); + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess is no longer running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "second call to disconnect silently ignored"); + + await extension.unload(); +}); + +// Test the limit on message size for writing +add_task(async function test_write_limit() { + Services.prefs.setIntPref(PREF_MAX_WRITE, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_WRITE); + } + registerCleanupFunction(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + try { + port.postMessage(PAYLOAD); + browser.test.sendMessage("result", null); + } catch (ex) { + browser.test.sendMessage("result", ex.message); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let errmsg = await extension.awaitMessage("result"); + notEqual( + errmsg, + null, + "native postMessage() failed for overly large message" + ); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test the limit on message size for reading +add_task(async function test_read_limit() { + Services.prefs.setIntPref(PREF_MAX_READ, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_READ); + } + registerCleanupFunction(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "Native application tried to send a message of 13 bytes, which exceeds the limit of 10 bytes.", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage(PAYLOAD); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let result = await extension.awaitMessage("result"); + equal( + result, + "disconnected", + "native port disconnected on receiving large message" + ); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test that an extension without the nativeMessaging permission cannot +// use native messaging. +add_task(async function test_ext_permission() { + function background() { + browser.test.assertEq( + chrome.runtime.connectNative, + undefined, + "chrome.runtime.connectNative does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + browser.runtime.connectNative, + undefined, + "browser.runtime.connectNative does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + chrome.runtime.sendNativeMessage, + undefined, + "chrome.runtime.sendNativeMessage does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + browser.runtime.sendNativeMessage, + undefined, + "browser.runtime.sendNativeMessage does not exist without nativeMessaging permission" + ); + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: {}, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); +}); + +// Test that an extension that is not listed in allowed_extensions for +// a native application cannot use that application. +add_task(async function test_app_permission() { + await testBrokenApp({ + extensionId: "@id-that-is-not-in-the-allowed_extensions-list", + appname: "echo", + expectedError: "No such native application echo", + expectedConsoleMessages: [ + /This extension does not have permission to use native manifest .+echo\.json/, + /No such native application echo/, + ], + }); +}); + +// Test that the command-line arguments and working directory for the +// native application are as expected. +add_task(async function test_child_process() { + function background() { + let port = browser.runtime.connectNative("info"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", msg); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let msg = await extension.awaitMessage("result"); + equal(msg.args.length, 3, "Received two command line arguments"); + equal( + msg.args[1], + getPath("info.json"), + "Command line argument is the path to the native host manifest" + ); + equal( + msg.args[2], + ID, + "Second command line argument is the ID of the calling extension" + ); + equal( + msg.cwd.replace(/^\/private\//, "/"), + PathUtils.join(tmpDir.path, TYPE_SLUG), + "Working directory is the directory containing the native appliation" + ); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +add_task(async function test_stderr() { + function background() { + let port = browser.runtime.connectNative("stderr"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + null, + port.error, + "Normal application exit is not an error" + ); + browser.test.sendMessage("finished"); + }); + } + + let { messages } = await promiseConsoleOutput(async function () { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); + + await waitForSubprocessExit(); + }); + + let lines = STDERR_LINES.map(line => + messages.findIndex(msg => msg.message.includes(line)) + ); + notEqual(lines[0], -1, "Saw first line of stderr output on the console"); + notEqual(lines[1], -1, "Saw second line of stderr output on the console"); + notEqual( + lines[0], + lines[1], + "Stderr output lines are separated in the console" + ); +}); + +// Test that calling connectNative() multiple times works +// (see bug 1313980 for a previous regression in this area) +add_task(async function test_multiple_connects() { + async function background() { + function once() { + return new Promise(resolve => { + let MSG = "hello"; + let port = browser.runtime.connectNative("echo"); + + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + port.disconnect(); + resolve(); + }); + port.postMessage(MSG); + }); + } + + await once(); + await once(); + browser.test.notifyPass("multiple-connect"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("multiple-connect"); + await extension.unload(); +}); + +// Test that native messaging is always rejected on content scripts +add_task(async function test_connect_native_from_content_script() { + async function testScript() { + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "An unexpected error occurred", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage({ test: "test" }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/dummy"], + }, + ], + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + files: { + "test.js": testScript, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + let result = await extension.awaitMessage("result"); + equal(result, "disconnected", "connectNative() failed from content script"); + + await page.close(); + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); +}); + +// Testing native app messaging against idle timeout. +async function startupExtensionAndRequestPermission() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + optional_permissions: ["nativeMessaging"], + background: { persistent: false }, + }, + async background() { + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("bgpage:suspending"); + }); + + let port; + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "request-permission": { + await browser.permissions.request({ + permissions: ["nativeMessaging"], + }); + break; + } + case "delayedecho-sendmessage": { + browser.runtime + .sendNativeMessage("delayedecho", args[0]) + .then(msg => + browser.test.sendMessage( + `delayedecho-sendmessage:got-reply`, + msg + ) + ); + break; + } + case "connectNative": { + if (port) { + browser.test.fail(`Unexpected already connected NativeApp port`); + } else { + port = browser.runtime.connectNative("echo"); + } + break; + } + case "disconnectNative": { + if (!port) { + browser.test.fail(`Unexpected undefined NativeApp port`); + } + port?.disconnect(); + break; + } + default: + browser.test.fail(`Got an unexpected test message: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + browser.test.sendMessage("bg:ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("bg:ready"); + const contextId = extension.extension.backgroundContext.contextId; + notEqual(contextId, undefined, "Got a contextId for the background context"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request-permission"); + await extension.awaitMessage("request-permission:done"); + }); + + return { extension, contextId }; +} + +async function expectTerminateBackgroundToResetIdle({ extension, contextId }) { + info("Wait for hasActiveNativeAppPorts to become true"); + await TestUtils.waitForCondition( + () => extension.extension.backgroundContext, + "Parent proxy context should be active" + ); + + await TestUtils.waitForCondition( + () => extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should have active native app ports tracked" + ); + + clearHistograms(); + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + + info("Trigger background script idle timeout and expect to be reset"); + const promiseResetIdle = promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + await extension.terminateBackground(); + info("Wait for 'background-script-reset-idle' event to be emitted"); + await promiseResetIdle; + equal( + extension.extension.backgroundContext.contextId, + contextId, + "Initial background context is still available as expected" + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "reset_nativeapp", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: extension.id, + category: "reset_nativeapp", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); +} + +async function testSendNativeMessage({ extension, contextId }) { + extension.sendMessage("delayedecho-sendmessage", "delayed-echo"); + await extension.awaitMessage("delayedecho-sendmessage:done"); + + await expectTerminateBackgroundToResetIdle({ extension, contextId }); + + // We expect exactly two replies (one for the previous queued message + // and one more for the last message sent right above). + equal( + await extension.awaitMessage("delayedecho-sendmessage:got-reply"), + "delayed-echo", + "Got the expected reply for the first message sent" + ); + + await TestUtils.waitForCondition( + () => !extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should not have any active native app ports tracked" + ); + + info("terminating the background script"); + await extension.terminateBackground(); + info("wait for runtime.onSuspend listener to have been called"); + await extension.awaitMessage("bgpage:suspending"); +} + +async function testConnectNative({ extension, contextId }) { + extension.sendMessage("connectNative"); + await extension.awaitMessage("connectNative:done"); + + await expectTerminateBackgroundToResetIdle({ extension, contextId }); + + // Disconnect the NativeApp and confirm that the background page + // will be suspending as expected. + extension.sendMessage("disconnectNative"); + await extension.awaitMessage("disconnectNative:done"); + + await TestUtils.waitForCondition( + () => !extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should not have any active native app ports tracked" + ); + + info("terminating the background script"); + await extension.terminateBackground(); + info("wait for runtime.onSuspend listener to have been called"); + await extension.awaitMessage("bgpage:suspending"); +} + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_pending_sendNativeMessageReply_resets_bgscript_idle_timeout() { + const { extension, contextId } = + await startupExtensionAndRequestPermission(); + await testSendNativeMessage({ extension, contextId }); + await waitForSubprocessExit(); + await extension.unload(); + } +); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_open_connectNativePort_resets_bgscript_idle_timeout() { + const { extension, contextId } = + await startupExtensionAndRequestPermission(); + await testConnectNative({ extension, contextId }); + await waitForSubprocessExit(); + await extension.unload(); + } +); |