diff --git a/package-lock.json b/package-lock.json index 92ccfdacf9..bd6e393d10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,6 @@ "tmp": "0.2.3", "update-notifier": "7.3.1", "watchpack": "2.4.4", - "ws": "8.18.2", "yargs": "17.7.2", "zip-dir": "2.0.0" }, @@ -10398,27 +10397,6 @@ "typedarray-to-buffer": "^3.1.5" } }, - "node_modules/ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", @@ -17833,12 +17811,6 @@ "typedarray-to-buffer": "^3.1.5" } }, - "ws": { - "version": "8.18.2", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", - "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", - "requires": {} - }, "xdg-basedir": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", diff --git a/package.json b/package.json index 8671a1a30d..ebd6bb9135 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "tmp": "0.2.3", "update-notifier": "7.3.1", "watchpack": "2.4.4", - "ws": "8.18.2", "yargs": "17.7.2", "zip-dir": "2.0.0" }, diff --git a/src/cmd/run.js b/src/cmd/run.js index c06c21fc77..d07d8df6dd 100644 --- a/src/cmd/run.js +++ b/src/cmd/run.js @@ -34,6 +34,7 @@ export default async function run( noReload = false, preInstall = false, sourceDir, + verbose = false, watchFile, watchIgnored, startUrl, @@ -188,6 +189,7 @@ export default async function run( if (target && target.includes('chromium')) { const chromiumRunnerParams = { ...commonRunnerParams, + verbose, chromiumBinary, chromiumProfile, }; diff --git a/src/extension-runners/chromium.js b/src/extension-runners/chromium.js index d8e782722c..047c7874bc 100644 --- a/src/extension-runners/chromium.js +++ b/src/extension-runners/chromium.js @@ -10,7 +10,6 @@ import { Launcher as ChromeLauncher, launch as defaultChromiumLaunch, } from 'chrome-launcher'; -import WebSocket, { WebSocketServer } from 'ws'; import { createLogger } from '../util/logger.js'; import { TempDir } from '../util/temp-dir.js'; @@ -29,6 +28,119 @@ export const DEFAULT_CHROME_FLAGS = ChromeLauncher.defaultFlags().filter( (flag) => !EXCLUDED_CHROME_FLAGS.includes(flag), ); +// This is a client for the Chrome Devtools protocol. The methods and results +// are documented at https://chromedevtools.github.io/devtools-protocol/tot/ +class ChromeDevtoolsProtocolClient { + #receivedData = ''; + #isProcessingMessage = false; + #lastId = 0; + #deferredResponses = new Map(); + #disconnected = false; + #disconnectedPromise; + #resolveDisconnectedPromise; + + // Print all exchanged CDP messages to ease debugging. + TEST_LOG_VERBOSE_CDP = process.env.TEST_LOG_VERBOSE_CDP; + + constructor(chromiumInstance) { + // remoteDebuggingPipes is from chrome-launcher, see + // https://github.com/GoogleChrome/chrome-launcher/pull/347 + const { incoming, outgoing } = chromiumInstance.remoteDebuggingPipes; + this.#disconnectedPromise = new Promise((resolve) => { + this.#resolveDisconnectedPromise = resolve; + }); + if (incoming.closed) { + // Strange. Did Chrome fail to start, or exit on startup? + log.warn('CDP already disconnected at initialization'); + this.#finalizeDisconnect(); + return; + } + incoming.on('data', (data) => { + this.#receivedData += data; + this.#processNextMessage(); + }); + incoming.on('error', (error) => { + log.error(error); + this.#finalizeDisconnect(); + }); + incoming.on('close', () => this.#finalizeDisconnect()); + this.outgoingPipe = outgoing; + } + + waitUntilDisconnected() { + return this.#disconnectedPromise; + } + + async sendCommand(method, params, sessionId = undefined) { + if (this.#disconnected) { + throw new Error(`CDP disconnected, cannot send: command ${method}`); + } + const message = { + id: ++this.#lastId, + method, + params, + sessionId, + }; + const rawMessage = `${JSON.stringify(message)}\x00`; + if (this.TEST_LOG_VERBOSE_CDP) { + process.stderr.write(`[CDP] [SEND] ${rawMessage}\n`); + } + return new Promise((resolve, reject) => { + // CDP will always send a response. + this.#deferredResponses.set(message.id, { method, resolve, reject }); + this.outgoingPipe.write(rawMessage); + }); + } + + #processNextMessage() { + if (this.#isProcessingMessage) { + return; + } + this.#isProcessingMessage = true; + let end = this.#receivedData.indexOf('\x00'); + while (end !== -1) { + const rawMessage = this.#receivedData.slice(0, end); + this.#receivedData = this.#receivedData.slice(end + 1); // +1 skips \x00. + try { + if (this.TEST_LOG_VERBOSE_CDP) { + process.stderr.write(`[CDP] [RECV] ${rawMessage}\n`); + } + const { id, error, result } = JSON.parse(rawMessage); + const deferredResponse = this.#deferredResponses.get(id); + if (deferredResponse) { + this.#deferredResponses.delete(id); + if (error) { + const err = new Error(error.message || 'Unexpected CDP response'); + deferredResponse.reject(err); + } else { + deferredResponse.resolve(result); + } + } else { + // Dropping events and non-response messages since we don't need it. + } + } catch (e) { + log.error(e); + } + end = this.#receivedData.indexOf('\x00'); + } + this.#isProcessingMessage = false; + if (this.#disconnected) { + for (const { method, reject } of this.#deferredResponses.values()) { + reject(new Error(`CDP connection closed before response to ${method}`)); + } + this.#deferredResponses.clear(); + this.#resolveDisconnectedPromise(); + } + } + + #finalizeDisconnect() { + if (!this.#disconnected) { + this.#disconnected = true; + this.#processNextMessage(); + } + } +} + /** * Implements an IExtensionRunner which manages a Chromium instance. */ @@ -37,8 +149,9 @@ export class ChromiumExtensionRunner { params; chromiumInstance; chromiumLaunch; - reloadManagerExtension; - wss; + // --load-extension is deprecated, but only supported in Chrome 126+, see: + // https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117 + forceUseDeprecatedLoadExtension; exiting; _promiseSetupDone; @@ -46,6 +159,9 @@ export class ChromiumExtensionRunner { const { chromiumLaunch = defaultChromiumLaunch } = params; this.params = params; this.chromiumLaunch = chromiumLaunch; + // We will try to use Extensions.loadUnpacked first (Chrome 126+), and if + // that does not work fall back to --load-extension. + this.forceUseDeprecatedLoadExtension = false; this.cleanupCallbacks = new Set(); } @@ -110,33 +226,14 @@ export class ChromiumExtensionRunner { * Setup the Chromium Profile and run a Chromium instance. */ async setupInstance() { - // Start a websocket server on a free localhost TCP port. - this.wss = await new Promise((resolve) => { - const server = new WebSocketServer( - // Use a ipv4 host so we don't need to escape ipv6 address - // https://github.com/mozilla/web-ext/issues/2331 - { port: 0, host: '127.0.0.1', clientTracking: true }, - // Wait the server to be listening (so that the extension - // runner can successfully retrieve server address and port). - () => resolve(server), - ); - }); - - // Prevent unhandled socket error (e.g. when chrome - // is exiting, See https://github.com/websockets/ws/issues/1256). - this.wss.on('connection', function (socket) { - socket.on('error', (err) => { - log.debug(`websocket connection error: ${err}`); - }); - }); - - // Create the extension that will manage the addon reloads - this.reloadManagerExtension = await this.createReloadManagerExtension(); + // NOTE: This function may be called twice, if the user is using an old + // Chrome version (before Chrome 126), because then we have to add a + // command-line flag (--load-extension) to load the extension. For details, + // see: + // https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117 // Start chrome pointing it to a given profile dir - const extensions = [this.reloadManagerExtension] - .concat(this.params.extensions.map(({ sourceDir }) => sourceDir)) - .join(','); + const extensions = this.params.extensions.map(({ sourceDir }) => sourceDir); const { chromiumBinary } = this.params; @@ -147,8 +244,13 @@ export class ChromiumExtensionRunner { } const chromeFlags = [...DEFAULT_CHROME_FLAGS]; + chromeFlags.push('--remote-debugging-pipe'); - chromeFlags.push(`--load-extension=${extensions}`); + if (!this.forceUseDeprecatedLoadExtension) { + chromeFlags.push('--enable-unsafe-extension-debugging'); + } else { + chromeFlags.push(`--load-extension=${extensions.join(',')}`); + } if (this.params.args) { chromeFlags.push(...this.params.args); @@ -207,16 +309,22 @@ export class ChromiumExtensionRunner { } this.chromiumInstance = await this.chromiumLaunch({ - enableExtensions: true, chromePath: chromiumBinary, chromeFlags, startingUrl, userDataDir, + logLevel: this.params.verbose ? 'verbose' : 'silent', // Ignore default flags to keep the extension enabled. ignoreDefaultFlags: true, }); + this.cdp = new ChromeDevtoolsProtocolClient(this.chromiumInstance); + const initialChromiumInstance = this.chromiumInstance; this.chromiumInstance.process.once('close', () => { + if (this.chromiumInstance !== initialChromiumInstance) { + // This happens when we restart Chrome to fall back to --load-extension. + return; + } this.chromiumInstance = null; if (!this.exiting) { @@ -224,113 +332,40 @@ export class ChromiumExtensionRunner { this.exit(); } }); - } - async wssBroadcast(data) { - return new Promise((resolve) => { - const clients = this.wss ? new Set(this.wss.clients) : new Set(); - - function cleanWebExtReloadComplete() { - const client = this; - client.removeEventListener('message', webExtReloadComplete); - client.removeEventListener('close', cleanWebExtReloadComplete); - clients.delete(client); - } - - const webExtReloadComplete = async (message) => { - const msg = JSON.parse(message.data); - - if (msg.type === 'webExtReloadExtensionComplete') { - for (const client of clients) { - cleanWebExtReloadComplete.call(client); + if (!this.forceUseDeprecatedLoadExtension) { + // Assume that the required Extensions.loadUnpacked CDP method is + // supported. If it is not, we will fall back to --load-extension. + let cdpSupportsExtensionsLoadUnpacked = true; + for (const sourceDir of extensions) { + try { + await this.cdp.sendCommand('Extensions.loadUnpacked', { + path: sourceDir, + }); + } catch (e) { + // Chrome 125- will emit the following message: + if (e.message === "'Extensions.loadUnpacked' wasn't found") { + cdpSupportsExtensionsLoadUnpacked = false; + break; } - resolve(); - } - }; - - for (const client of clients) { - if (client.readyState === WebSocket.OPEN) { - client.addEventListener('message', webExtReloadComplete); - client.addEventListener('close', cleanWebExtReloadComplete); - - client.send(JSON.stringify(data)); - } else { - clients.delete(client); + log.error(`Failed to load extension at ${sourceDir}: ${e.message}`); + // We do not have to throw - the extension can work again when + // auto-reload is used. But users may like a hard fail, and this is + // consistent with the firefox runner. + throw e; } } - - if (clients.size === 0) { - resolve(); + if (!cdpSupportsExtensionsLoadUnpacked) { + // Retry once, now with --load-extension. + log.warn('Cannot load extension via CDP, falling back to old method'); + this.forceUseDeprecatedLoadExtension = true; + this.chromiumInstance = null; + await initialChromiumInstance.kill(); + await this.cdp.waitUntilDisconnected(); + this.cdp = null; + return this.setupInstance(); } - }); - } - - async createReloadManagerExtension() { - const tmpDir = new TempDir(); - await tmpDir.create(); - this.registerCleanup(() => tmpDir.remove()); - - const extPath = path.join( - tmpDir.path(), - `reload-manager-extension-${Date.now()}`, - ); - - log.debug(`Creating reload-manager-extension in ${extPath}`); - - await fs.mkdir(extPath, { recursive: true }); - - await fs.writeFile( - path.join(extPath, 'manifest.json'), - JSON.stringify({ - manifest_version: 2, - name: 'web-ext Reload Manager Extension', - version: '1.0', - permissions: ['management', 'tabs'], - background: { - scripts: ['bg.js'], - }, - }), - ); - - const wssInfo = this.wss.address(); - - const bgPage = `(function bgPage() { - async function getAllDevExtensions() { - const allExtensions = await new Promise( - r => chrome.management.getAll(r)); - - return allExtensions.filter((extension) => { - return extension.enabled && - extension.installType === "development" && - extension.id !== chrome.runtime.id; - }); - } - - const setEnabled = (extensionId, value) => - chrome.runtime.id == extensionId ? - new Promise.resolve() : - new Promise(r => chrome.management.setEnabled(extensionId, value, r)); - - async function reloadExtension(extensionId) { - await setEnabled(extensionId, false); - await setEnabled(extensionId, true); - } - - const ws = new window.WebSocket( - "ws://${wssInfo.address}:${wssInfo.port}"); - - ws.onmessage = async (evt) => { - const msg = JSON.parse(evt.data); - if (msg.type === 'webExtReloadAllExtensions') { - const devExtensions = await getAllDevExtensions(); - await Promise.all(devExtensions.map(ext => reloadExtension(ext.id))); - ws.send(JSON.stringify({ type: 'webExtReloadExtensionComplete' })); - } - }; - })()`; - - await fs.writeFile(path.join(extPath, 'bg.js'), bgPage); - return extPath; + } } /** @@ -340,9 +375,19 @@ export class ChromiumExtensionRunner { async reloadAllExtensions() { const runnerName = this.getName(); - await this.wssBroadcast({ - type: 'webExtReloadAllExtensions', - }); + if (this.forceUseDeprecatedLoadExtension) { + this.reloadAllExtensionsFallbackForChrome125andEarlier(); + } else { + for (const { sourceDir } of this.params.extensions) { + try { + await this.cdp.sendCommand('Extensions.loadUnpacked', { + path: sourceDir, + }); + } catch (e) { + log.error(`Failed to load extension at ${sourceDir}: ${e.message}`); + } + } + } process.stdout.write( `\rLast extension reload: ${new Date().toTimeString()}`, @@ -352,6 +397,136 @@ export class ChromiumExtensionRunner { return [{ runnerName }]; } + async reloadAllExtensionsFallbackForChrome125andEarlier() { + // Ideally, we'd like to use the "Extensions.loadUnpacked" CDP command to + // reload an extension, but that is unsupported in Chrome 125 and earlier. + // + // As a fallback, connect to chrome://extensions/ and reload from there. + // Since we are targeting old Chrome versions, we can safely use the + // chrome.developerPrivate APIs, because these are never going to change + // for the old browser versions. Do NOT use this for newer versions! + // + // Target.* CDP methods documented at: https://chromedevtools.github.io/devtools-protocol/tot/Target/ + // developerPrivate documented at: + // https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl + // + // Specific revision that exposed developerPrivate to chrome://extensions/: + // https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=69bf75316e7ae533c0a0dccc1a56ca019aa95a1e + // https://chromium.googlesource.com/chromium/src.git/+/69bf75316e7ae533c0a0dccc1a56ca019aa95a1e + // + // Specific revision that introduced developerPrivate.getExtensionsInfo: + // https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=69bf75316e7ae533c0a0dccc1a56ca019aa95a1e + // + // The above changes are from 2015; The --remote-debugging-pipe feature + // that we rely on for CDP was added in 2018; this is the version of the + // developerPrivate API at that time: + // https://source.chromium.org/chromium/chromium/src/+/main:chrome/common/extensions/api/developer_private.idl;drc=c9ae59c8f37d487f1f01c222deb6b7d1f51c99c2 + + // Find an existing chrome://extensions/ tab, if it exists. + let { targetInfos: targets } = await this.cdp.sendCommand( + 'Target.getTargets', + { filter: [{ type: 'tab' }] }, + ); + targets = targets.filter((t) => t.url.startsWith('chrome://extensions/')); + let targetId; + const hasExistingTarget = targets.length > 0; + if (hasExistingTarget) { + targetId = targets[0].targetId; + } else { + const result = await this.cdp.sendCommand('Target.createTarget', { + url: 'chrome://extensions/', + newWindow: true, + background: true, + windowState: 'minimized', + }); + targetId = result.targetId; + } + const codeToEvaluateInChrome = async () => { + // This function is serialized and executed in Chrome. Designed for + // compatibility with Chrome 69 - 125. Do not use JS syntax of functions + // that are not supported in these versions! + + // eslint-disable-next-line no-undef + const developerPrivate = chrome.developerPrivate; + if (!developerPrivate || !developerPrivate.getExtensionsInfo) { + // When chrome://extensions/ is still loading, its document URL may be + // about:blank and the chrome.developerPrivate API is not exposed. + return 'NOT_READY_PLEASE_RETRY'; + } + const extensionIds = []; + await new Promise((resolve) => { + developerPrivate.getExtensionsInfo((extensions) => { + for (const extension of extensions || []) { + if (extension.location === 'UNPACKED') { + // We only care about those loaded via --load-extension. + extensionIds.push(extension.id); + } + } + resolve(); + }); + }); + const reloadPromises = extensionIds.map((extensionId) => { + return new Promise((resolve, reject) => { + developerPrivate.reload( + extensionId, + // Suppress alert dialog when load fails. + { failQuietly: true, populateErrorForUnpacked: true }, + (loadError) => { + if (loadError) { + reject(new Error(loadError.error)); + } else { + resolve(); + } + }, + ); + }); + }); + await Promise.all(reloadPromises); + return reloadPromises.length; + }; + try { + const targetResult = await this.cdp.sendCommand('Target.attachToTarget', { + targetId, + flatten: true, + }); + if (!targetResult.sessionId) { + throw new Error('Unexpectedly, no sessionId from attachToTarget'); + } + // In practice, we're going to run the logic only once. But if we are + // unlucky, chrome://extensions is still loading, so we will then retry. + for (let i = 0; i < 3; ++i) { + const evalResult = await this.cdp.sendCommand( + 'Runtime.evaluate', + { + expression: `(${codeToEvaluateInChrome})();`, + awaitPromise: true, + }, + targetResult.sessionId, + ); + const evalResultReturnValue = evalResult.result?.value; + if (evalResultReturnValue === 'NOT_READY_PLEASE_RETRY') { + await new Promise((r) => setTimeout(r, 200 * i)); + continue; + } + if (evalResult.exceptionDetails) { + log.error(`Failed to reload: ${evalResult.exceptionDetails.text}`); + } + if (evalResultReturnValue !== this.params.extensions.length) { + log.warn(`Failed to reload extensions: ${evalResultReturnValue}`); + } + break; + } + } finally { + if (!hasExistingTarget && targetId) { + try { + await this.cdp.sendCommand('Target.closeTarget', { targetId }); + } catch (e) { + log.error(e); + } + } + } + } + /** * Reloads a single extension, collect any reload error and resolves to * an array composed by a single ExtensionRunnerReloadResult object. @@ -394,19 +569,9 @@ export class ChromiumExtensionRunner { this.chromiumInstance = null; } - if (this.wss) { - // Close all websocket clients, closing the WebSocketServer - // does not terminate the existing connection and it wouldn't - // resolve until all of the existing connections are closed. - for (const wssClient of this.wss?.clients || []) { - if (wssClient.readyState === WebSocket.OPEN) { - wssClient.terminate(); - } - } - await new Promise((resolve) => - this.wss ? this.wss.close(resolve) : resolve(), - ); - this.wss = null; + if (this.cdp) { + await this.cdp.waitUntilDisconnected(); + this.cdp = null; } // Call all the registered cleanup callbacks. diff --git a/tests/fixtures/chrome-extension-mv3/background.js b/tests/fixtures/chrome-extension-mv3/background.js new file mode 100644 index 0000000000..1c43964a66 --- /dev/null +++ b/tests/fixtures/chrome-extension-mv3/background.js @@ -0,0 +1,12 @@ +/* globals chrome */ + +chrome.runtime.onInstalled.addListener(() => { + // Although this URL is hard-coded, it is easy to set up a test server with a + // random port, and let Chrome resolve this host to that local server with: + // --host-resolver-rules='MAP localhost:1337 localhost:12345' + // (where 12345 is the actual port of the local server) + // + // We are intentionally using localhost instead of another domain, to make + // sure that the browser does not upgrade the http:-request to https. + chrome.tabs.create({ url: 'http://localhost:1337/hello_from_extension' }); +}); diff --git a/tests/fixtures/chrome-extension-mv3/manifest.json b/tests/fixtures/chrome-extension-mv3/manifest.json new file mode 100644 index 0000000000..fd64c95f04 --- /dev/null +++ b/tests/fixtures/chrome-extension-mv3/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "Test extension opens tab on install/reload", + "description": "Opens localhost:1337 tab from runtime.onInstalled. Extension ID in Chrome is hgobbjbpnmemikbdbflmolpneekpflab", + "version": "1", + "manifest_version": 3, + "background": { + "service_worker": "background.js" + }, + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmr/8tpL+H1zEIEzrOhC8tkheWH7N7FOfiSnLq9D8Uja+b4W69rPIatslIPd4a53IgLT5qv1UZz03kETaKWDYhxoWbaKWiPtJcHoWKAROZ4Ydk2eC7jDcCKYkhXSgTpDzZBhHkyKZoh10O0AEYeJ+xfCmUJT3PT/CzxrSI4rjXFOBie5FMTgjusLwjiwVSBnO9haE7HqwekGVFvixG5Ao6rXRaZx+mm2+7PLTgMSxaXk1OqBtKmz4da67zZGrnHjiz4a0JHkyRuNkyamT5HFjD9neAWjgSwWD5yXKgJKkwGHePsq2WgSZlWIaKFafcbnoIr91hk5+mRCYVm2l/wgqvwIDAQAB" +} diff --git a/tests/functional/common.js b/tests/functional/common.js index 8c94f4f6f7..7ff1d42fe9 100644 --- a/tests/functional/common.js +++ b/tests/functional/common.js @@ -1,3 +1,4 @@ +import { createServer } from 'http'; import path from 'path'; import { spawn } from 'child_process'; import { promisify } from 'util'; @@ -31,6 +32,13 @@ export const fakeServerPath = path.join( 'fake-amo-server.js', ); +export const chromeExtPath = path.join(fixturesDir, 'chrome-extension-mv3'); +// NOTE: Depends on preload_on_windows.cjs to load this! +export const fakeChromePath = path.join( + functionalTestsDir, + 'fake-chrome-binary.js', +); + // withTempAddonDir helper const copyDirAsPromised = promisify(copyDir); @@ -80,7 +88,21 @@ export function execWebExt(argv, spawnOptions) { ...process.env, ...spawnOptions.env, }; + } else { + spawnOptions.env = { ...process.env }; + } + + if (process.platform === 'win32') { + // See preload_on_windows.cjs for an explanation. + const preloadPath = path.join(functionalTestsDir, 'preload_on_windows.cjs'); + // NODE_OPTIONS allows values to be quoted, and anything within to be escaped + // with a backslash: https://nodejs.org/api/cli.html#node_optionsoptions + // https://github.com/nodejs/node/blob/411495ee9326096e88d12d3f3efae161cbd19efd/src/node_options.cc#L1717-L1741 + const escapedAbsolutePath = preloadPath.replace(/\\|"/g, '\\$&'); + spawnOptions.env.NODE_OPTIONS ||= ''; + spawnOptions.env.NODE_OPTIONS += ` --require "${escapedAbsolutePath}"`; } + const spawnedProcess = spawn( process.execPath, [webExt, ...argv], @@ -105,3 +127,79 @@ export function execWebExt(argv, spawnOptions) { return { argv, waitForExit, spawnedProcess }; } + +export function monitorOutput(spawnedProcess) { + const callbacks = new Set(); + let outputData = ''; + let errorData = ''; + function checkCallbacks() { + for (const callback of callbacks) { + const { outputTestFunc, resolve } = callback; + if (outputTestFunc(outputData, errorData)) { + callbacks.delete(callback); + resolve(); + } + } + } + spawnedProcess.stdout.on('data', (data) => { + outputData += data; + checkCallbacks(); + }); + spawnedProcess.stderr.on('data', (data) => { + errorData += data; + checkCallbacks(); + }); + + const waitUntilOutputMatches = (outputTestFunc) => { + return new Promise((resolve) => { + callbacks.add({ outputTestFunc, resolve }); + checkCallbacks(); + }); + }; + + return { waitUntilOutputMatches }; +} + +// Test server to receive request from chrome-extension-mv3, once loaded. +export async function startServerReceivingHelloFromExtension() { + let requestCount = 0; + let lastSeenUserAgent; + let resolveWaitingForHelloFromExtension; + const server = createServer((req, res) => { + if (req.url !== '/hello_from_extension') { + res.writeHead(404); + res.end('test server only handles /hello_from_extension'); + return; + } + res.writeHead(200); + res.end('test server received /hello_from_extension'); + lastSeenUserAgent = req.headers['user-agent']; + ++requestCount; + resolveWaitingForHelloFromExtension?.(); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve()); + }); + const testServerHost = `127.0.0.1:${server.address().port}`; + return { + get requestCount() { + return requestCount; + }, + get lastSeenUserAgent() { + return lastSeenUserAgent; + }, + getHostResolverRulesArgForChromeBinary() { + // chrome-extension-mv3 sends requests to http://localhost:1337, but our + // test server uses a free port to make sure that it does not conflict + // with an existing local server. Pass --host-resolver-rules to Chrome so + // that it sends requests targeting localhost:1337 to this test server. + return `--host-resolver-rules=MAP localhost:1337 ${testServerHost}`; + }, + waitForHelloFromExtension: () => { + return new Promise((resolve) => { + resolveWaitingForHelloFromExtension = resolve; + }); + }, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} diff --git a/tests/functional/fake-chrome-binary.js b/tests/functional/fake-chrome-binary.js new file mode 100755 index 0000000000..2490d53a3a --- /dev/null +++ b/tests/functional/fake-chrome-binary.js @@ -0,0 +1,329 @@ +#!/usr/bin/env node + +import { createReadStream, createWriteStream, existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import http from 'node:http'; +import net from 'node:net'; +import path from 'node:path'; + +// This is a minimal simulation of a Chrome binary to test extension loading. +// For more detail on behaviors across Chrome versions, see +// https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117 + +// Chrome 69 and later supports --remote-debugging-pipe. +// Chrome 126 and later supports Extensions.loadUnpacked via the pipe, provided +// that --enable-unsafe-extension-debugging is passed. +// Chrome 137 and later disables --load-extension support in official builds. + +// This minimal program simulates Chrome by accepting the following args: +// - When --remote-debugging-port is passed, it starts accepting connections +// on that port, because chrome-launcher uses successful connection as a +// signal of the binary being ready (unless --remote-debugging-pipe is used). +// +// - --remote-debugging-pipe is always supported. When passed, the caller must +// pass pipes at file descriptor 3 and 4. The data exchanged over these pipes +// should be JSON terminated by a NULL byte, with the message content defined +// by the Chrome Devtools Protocol (CDP). By default, none of the CDP methods +// are supported. +// +// - --enable-unsafe-extension-debugging is supported unless +// TEST_SIMULATE_DISABLE_CDP_EXTENSION is set. When supported, the CDP method +// Extensions.loadUnpacked method is supported, which simulates an extension +// load (see below). +// +// - --load-extension is disabled unless TEST_SIMULATE_ENABLE_LOAD_EXTENSION +// is set. When passed, an extension load is simulated (see below). +// +// When an extension load is simulated, only the bare minimum is simulated +// (loading fixtures/chrome-extension-mv3). +const TEST_SIMULATE_CHROME_VERSION = + parseInt(process.env.TEST_SIMULATE_CHROME_VERSION) || 137; +const TEST_SIMULATE_ENABLE_LOAD_EXTENSION = TEST_SIMULATE_CHROME_VERSION <= 136; +const TEST_SIMULATE_DISABLE_CDP_EXTENSION = TEST_SIMULATE_CHROME_VERSION < 126; + +process.stdout.write(` +This is a minimal simulation of Chrome. + +TEST_SIMULATE_CHROME_VERSION=${TEST_SIMULATE_CHROME_VERSION} +--load-extension supported: ${TEST_SIMULATE_ENABLE_LOAD_EXTENSION} +--enable-unsafe-extension-debugging supported: ${!TEST_SIMULATE_DISABLE_CDP_EXTENSION} +`); + +let ARG_REMOTE_DEBUGGING_PORT; +let ARG_REMOTE_DEBUGGING_PIPE = false; +let ARG_ALLOW_EXTENSION_DEBUGGING = false; +let ARG_LOAD_EXTENSION; +let testServerHost = 'localhost:1337'; +for (const arg of process.argv) { + const argVal = arg.split('=').slice(1).join('='); + // getHostResolverRulesArgForChromeBinary() in common.js sets the actual port + // that the extension should report to. + const hostResolverPrefix = '--host-resolver-rules=MAP localhost:1337 '; + if (arg.startsWith(hostResolverPrefix)) { + testServerHost = arg.slice(hostResolverPrefix.length); + } else if (arg === '--remote-debugging-pipe') { + // --remote-debugging-pipe can take arguments to change the wire format; + // we only support the default (JSON) format. + ARG_REMOTE_DEBUGGING_PIPE = true; + } else if (arg === '--enable-unsafe-extension-debugging') { + ARG_ALLOW_EXTENSION_DEBUGGING = true; + } else if (arg.startsWith('--remote-debugging-port=')) { + ARG_REMOTE_DEBUGGING_PORT = parseInt(argVal); + } else if (arg.startsWith('--load-extension')) { + ARG_LOAD_EXTENSION = argVal; + } +} + +const loadedFakeExtensions = new Set(); + +async function fakeLoadChromExtension(dir) { + process.stderr.write(`[DEBUG] Loading Chrome extension from ${dir}\n`); + + // A very minimal simulation of loading fixtures/chrome-extension-mv3. + const manifestPath = path.join(dir, 'manifest.json'); + const manifestData = await readFile(manifestPath, { encoding: 'utf-8' }); + const manifest = JSON.parse(manifestData); + if (manifest.manifest_version !== 3) { + throw new Error('Chrome only supports Manifest Version 3'); + } + if (manifest.background.service_worker !== 'background.js') { + throw new Error('Test extension should have script at background.js'); + } + const bgScriptPath = path.join(dir, 'background.js'); + const bgScriptData = await readFile(bgScriptPath, { encoding: 'utf-8' }); + + // In theory we could simulate a whole JS execution environment (with vm), + // but for simplicity, just read the URL from the background script and + // assume that the script would trigger a request to the destination. + if (!bgScriptData.includes('http://localhost:1337/hello_from_extension')) { + throw new Error('background.js is missing hello_from_extension'); + } + // Fire and forget. Verify that we get the expected response from the + // test server (startServerReceivingHelloFromExtension in common.js). + const url = `http://${testServerHost}/hello_from_extension`; + // Allow tests that expect a fake binary to verify that the request indeed + // came from the fake binary. + const headers = { 'user-agent': 'fake-chrome-binary' }; + http.get(url, { headers }, (res) => { + if (res.statusCode !== 200) { + throw new Error(`Unexpected status code ${res.statusCode}`); + } + let responseString = ''; + res.on('data', (chunk) => { + responseString += chunk; + }); + res.on('end', () => { + if (responseString !== 'test server received /hello_from_extension') { + throw new Error(`Unexpected response: ${responseString}`); + } + }); + }); + loadedFakeExtensions.add(dir); + return { extensionId: 'hgobbjbpnmemikbdbflmolpneekpflab' }; +} + +async function handleChromeDevtoolsProtocolMessage(rawRequest) { + // For protocol messages and behaviors across Chrome versions, see + // https://github.com/mozilla/web-ext/issues/3388#issuecomment-2906982117 + let request; + try { + request = JSON.parse(rawRequest); + } catch (e) { + return { error: { code: -32700, message: `JSON: ${e.message}` } }; + } + + const { id, method } = request || {}; + // Sanity check: Strictly validate the input to make sure that web-ext is + // not going to send anything that Chrome would reject. + if (!Number.isSafeInteger(id)) { + return { + error: { + code: -32600, + message: "Message must have integer 'id' property", + }, + }; + } + if (typeof method !== 'string') { + return { + id, + error: { + code: -32600, + message: "Message must have string 'method' property", + }, + }; + } + for (const k of Object.keys(request)) { + if (k !== 'id' && k !== 'method' && k !== 'sessionId' && k !== 'params') { + return { + id, + error: { + code: -32600, + message: + "Message has property other than 'id', 'method', 'sessionId', 'params'", + }, + }; + } + } + + if ( + request.method === 'Extensions.loadUnpacked' && + !TEST_SIMULATE_DISABLE_CDP_EXTENSION + ) { + if (!ARG_ALLOW_EXTENSION_DEBUGGING) { + return { id, error: { code: -32000, message: 'Method not available.' } }; + } + if (typeof request.params?.path !== 'string') { + // The actual message differs, but we mainly care about it being a string. + return { + id, + error: { + code: -32602, + message: 'Invalid parameters', + data: 'Failed to deserialize params.path - BINDINGS: string value expected (...)', + }, + }; + } + // No further validation: Unknown keys in params are accepted by Chrome. + + if (!existsSync(path.join(request.params.path, 'manifest.json'))) { + return { + id, + error: { + code: -32600, + message: 'Manifest file is missing or unreadable', + }, + }; + } + + const { extensionId } = await fakeLoadChromExtension(request.params.path); + return { id, result: { id: extensionId } }; + } + + const maybeRes = await simulateResponsesForReloadViaDeveloperPrivate(request); + if (maybeRes) { + return maybeRes; + } + + return { id, error: { code: -32601, message: `'${method}' wasn't found` } }; +} + +let isAttachingToTarget; +let isDisabledDueToLoadFailure; +async function simulateResponsesForReloadViaDeveloperPrivate(request) { + // Supports reloadAllExtensionsFallbackForChrome125andEarlier. This is NOT + // the full real protocol response; just enough for the simulation to work. + // There is no validation whatsoever, since the code is designed for old + // Chrome versions, and not going to be updated / maintained in the future. + const { id } = request; + + if (request.method === 'Target.getTargets') { + return { id, result: { targetInfos: [{ url: 'chrome://newtab' }] } }; + } + + if (request.method === 'Target.createTarget') { + return { id, result: { targetId: 'FAKE_TARGET_ID' } }; + } + + if (request.method === 'Target.attachToTarget') { + isAttachingToTarget = true; + return { id, result: { sessionId: 'FAKE_SESSION_ID' } }; + } + + if (request.method === 'Runtime.evaluate') { + if (!request.params.expression.includes('developerPrivate.reload')) { + return { id, error: { message: 'Unsupported fake code!' } }; + } + // The actual code is more elaborate, but it is equivalent to looking up + // all extensions, reloading them, and returning the number of extensions. + // It is possible for the first execution to be too early, in which case + // the caller should retry. + if (isAttachingToTarget) { + isAttachingToTarget = false; + return { id, result: { result: { value: 'NOT_READY_PLEASE_RETRY' } } }; + } + const extensionsToReload = Array.from(loadedFakeExtensions); + for (const dir of extensionsToReload) { + if (!existsSync(path.join(dir, 'manifest.json'))) { + isDisabledDueToLoadFailure = true; + // In reality an exception has many more fields, but for simplicity we + // omit anything we don't already rely on. + return { + id, + result: { + exceptionDetails: { + text: 'Uncaught (in promise) Error: Manifest file is missing or unreadable', + }, + }, + }; + } + if (isDisabledDueToLoadFailure) { + // In theory, one would expect to be able to reload an extension that + // failed to load. In practice, that is not the case, the developer + // has to manually enter chrome://extensions/, use the file picker to + // select the original extension. This weird issue is tracked at + // https://crbug.com/792277 (but even if it were to be fixed, we are + // never going to encounter the fix, since this code path is only + // taken for Chrome 125 and earlier, which will never be fixed). + continue; + } + await fakeLoadChromExtension(dir); + } + return { id, result: { result: { value: extensionsToReload.length } } }; + } + + if (request.method === 'Target.closeTarget') { + return { id }; + } + + // Unrecognized methods - fall through to caller. + return null; +} + +if (ARG_REMOTE_DEBUGGING_PIPE) { + const pipe3 = createReadStream(null, { fd: 3 }); + const pipe4 = createWriteStream(null, { fd: 4 }); + // If either pipe is not specified, Chrome exits immediately with: + // "Remote debugging pipe file descriptors are not open." + // (and somehow exit code 0 instead of non-zero) + // We rely on Node.js raising an uncaught error on either pipe. + + let receivedData = ''; + pipe3.on('data', (chunk) => { + receivedData += chunk; + let end = receivedData.indexOf('\x00'); + while (end !== -1) { + const rawRequest = receivedData.slice(0, end); + receivedData = receivedData.slice(end + 1); // +1 = skip \x00. + end = receivedData.indexOf('\x00'); + + handleChromeDevtoolsProtocolMessage(rawRequest).then((res) => { + const response = JSON.stringify(res); + pipe4.write(`${response}\x00`); + }); + } + }); +} +if (ARG_REMOTE_DEBUGGING_PORT != null) { + // The real Chrome has a http + WebSocket server at --remote-debugging-port. + // chrome-launcher does not rely on any commands; it just expects the server + // to be listening, so we accept connections without doing anything. + const server = net.createServer(() => { + process.stderr.write('Received connection to fake DevTools server\n'); + }); + server.listen(ARG_REMOTE_DEBUGGING_PORT); + server.on('listening', () => { + process.stderr.write(`DevTools listening on ${server.address().port}\n`); + }); +} + +if (ARG_LOAD_EXTENSION) { + if (TEST_SIMULATE_ENABLE_LOAD_EXTENSION) { + for (const ext of ARG_LOAD_EXTENSION.split(',')) { + // We have very limited support for loading extensions. The following + // may reject when an unsupported extension is encountered. + fakeLoadChromExtension(ext); + } + } else { + process.stderr.write('--load-extension is not allowed\n'); + } +} diff --git a/tests/functional/preload_on_windows.cjs b/tests/functional/preload_on_windows.cjs new file mode 100644 index 0000000000..66de52a3d5 --- /dev/null +++ b/tests/functional/preload_on_windows.cjs @@ -0,0 +1,29 @@ +// A commonly used trick to load Node.js scripts on Windows is via a .cmd or +// .bat script that launches it, but that does not work any more: +// +// - Due to CVE-2024-27980, Node.js rejects spawn() with EINVAL when a cmd or +// bat files is passed, unless "shell: true" is set. +// For example, see: https://github.com/mozilla/web-ext/issues/3435 +// +// - When a batch script calls a program, file descriptors are not inherit by +// that program. This is a problem for fake-chrome-binary.js, which expects +// file descriptors 3 and 4. +// +// This file provides a work-around: The test script preloads this module with: +// NODE_OPTIONS='--require [this absolute path]' +// +// It monkey-patches child_process.spawn to make sure that it can spawn .js +// files as binaries, just like other platforms. + +const child_process = require('node:child_process'); + +const orig_spawn = child_process.spawn; +child_process.spawn = function(command, args, options, ...remainingArgs) { + if (typeof command === 'string') { + if (command.endsWith('fake-chrome-binary.js')) { + args = args ? [command, ...args] : [command]; + command = process.execPath; + } + } + return orig_spawn.call(this, command, args, options, ...remainingArgs); +}; diff --git a/tests/functional/test.cli.run-target-chromium.js b/tests/functional/test.cli.run-target-chromium.js new file mode 100644 index 0000000000..f832041665 --- /dev/null +++ b/tests/functional/test.cli.run-target-chromium.js @@ -0,0 +1,275 @@ +// This verifies that web-ext can be used to launch Chrome. +// +// To debug: +// 1. Run `npm run build` whenever web-ext source changes. +// 2. Run the test: +// +// With fake Chrome binary only: +// +// ./node_modules/.bin/mocha tests/functional/test.cli.run-target-chromium.js +// +// To also test with the default Chrome binary: +// +// TEST_WEBEXT_USE_REAL_CHROME=1 ./node_modules/.bin/mocha tests/functional/test.cli.run-target-chromium.js +// +// To only test with a specific Chrome binary: +// +// TEST_WEBEXT_USE_REAL_CHROME=1 CHROME_PATH=/path/to/dir/chromium ./node_modules/.bin/mocha tests/functional/test.cli.run-target-chromium.js --grep='real Chrome' +// +// Set TEST_LOG_VERBOSE=1 if you need extra verbose debugging information. +// If the test is timing out due to the binary not exiting, look at a +// recently created directory in /tmp starting with "tmp-web-ext-", +// and read its chrome-err.log and chrome-out.log files. +// +// Example of showing stderr from the last minute: +// find /tmp/ -mmin 1 -name chrome-err.log -exec cat {} \; ;echo + +import path from 'node:path'; +import { existsSync } from 'node:fs'; +import { writeFile, rename } from 'node:fs/promises'; + +import { describe, it, beforeEach, afterEach } from 'mocha'; +import { assert, expect } from 'chai'; + +import { + chromeExtPath, + fakeChromePath, + startServerReceivingHelloFromExtension, + withTempAddonDir, + execWebExt, + monitorOutput, +} from './common.js'; + +// We use a fake Chrome binary below to simulate various Chrome versions. +// One test (skipped by default) verifies a real Chrome binary, optionally +// customizable via CHROME_PATH environment variable. +// This MUST be an absolute path! +const REAL_CHROME_PATH = process.env.CHROME_PATH; + +const TEST_LOG_VERBOSE = Boolean(process.env.TEST_LOG_VERBOSE); + +describe('web-ext run -t chromium', () => { + let testServer; + beforeEach(async () => { + testServer = await startServerReceivingHelloFromExtension(); + }); + afterEach(async () => { + testServer.close(); + testServer = null; + }); + + async function testWebExtRun({ + chromeVersion, + useRealChrome = false, + noReload = false, + expectReload = false, + }) { + const cwd = process.cwd(); + await withTempAddonDir({ addonPath: chromeExtPath }, async (srcDir) => { + process.chdir(srcDir); + + const argv = [ + 'run', + '-t', // -t is short for --target + 'chromium', + `--args=${testServer.getHostResolverRulesArgForChromeBinary()}`, + // Real Chrome may crash when sandbox is enabled. + '--args=--no-sandbox', + // When using non-official builds, make sure that it behaves like an + // official build in terms of --load-extension restrictions. + '--args=--enable-features=DisableLoadExtensionCommandLineSwitch', + ]; + if (noReload) { + argv.push('--no-reload'); + } + if (TEST_LOG_VERBOSE) { + argv.push('--verbose'); + } + const env = { + // web-ext uses chrome-launcher to launch Chrome. The default Chrome + // location can be changed with the CHROME_PATH environment variable + // (which is a documented feature of chrome-launcher). + // + // If useRealChrome is true, allow whoever who runs the test to pass + // a specific Chrome binary via CHROME_PATH environment variable. + // If not specified, chrome-launcher looks for the default Chrome. + CHROME_PATH: useRealChrome ? REAL_CHROME_PATH : fakeChromePath, + TEST_SIMULATE_CHROME_VERSION: chromeVersion, + }; + + if (useRealChrome && REAL_CHROME_PATH) { + // Sanity check, to make sure that chrome-launcher will use what we + // tell it to. Otherwise it would fall back to something else. + assert( + existsSync(REAL_CHROME_PATH), + `CHROME_PATH must exist: ${REAL_CHROME_PATH}`, + ); + } + + const cmd = execWebExt(argv, { env }); + const outputMonitor = monitorOutput(cmd.spawnedProcess); + if (TEST_LOG_VERBOSE) { + cmd.spawnedProcess.stderr.on('data', (d) => process.stderr.write(d)); + cmd.spawnedProcess.stdout.on('data', (d) => process.stdout.write(d)); + } + + assert.equal(testServer.requestCount, 0, 'Extension did not run yet'); + await testServer.waitForHelloFromExtension(); + assert.equal(testServer.requestCount, 1, 'Extension ran in Chrome'); + + if (useRealChrome) { + expect(testServer.lastSeenUserAgent).to.contain('Chrome/'); + } else { + expect(testServer.lastSeenUserAgent).to.equal('fake-chrome-binary'); + } + + await outputMonitor.waitUntilOutputMatches((output) => { + return ( + // Output of web-ext run when auto-reload is available (default). + output.includes( + 'The extension will reload if any source file changes', + ) || + // Output of web-ext run when --no-reload is passed. + output.includes('Automatic extension reloading has been disabled') + ); + }); + + // web-ext watches for changes in the source directory, and auto-reloads + // the extension unless --no-reload is passed to web-ext run. + const watchedFile = path.join(srcDir, 'watchedFile.txt'); + await writeFile(watchedFile, 'Touched content', 'utf-8'); + + if (expectReload) { + if (testServer.requestCount === 1) { + await testServer.waitForHelloFromExtension(); + } + assert.equal(testServer.requestCount, 2, 'Extension reloaded'); + } else { + assert.equal(testServer.requestCount, 1, 'No reload with --no-reload'); + } + + // Now test what happens if the extension becomes invalid, + // then valid again. + let isUsingFallbackForChrome125andEarlier = false; + if (expectReload) { + const manifestPathOld = path.join(srcDir, 'manifest.json'); + const manifestPathNew = path.join(srcDir, 'manifest.json.deleted'); + await rename(manifestPathOld, manifestPathNew); + await outputMonitor.waitUntilOutputMatches((output, errout) => { + if (errout.includes('Cannot load extension via CDP, falling back')) { + isUsingFallbackForChrome125andEarlier = true; + return true; + } + return errout.includes('Manifest file is missing or unreadable'); + }); + await rename(manifestPathNew, manifestPathOld); + if (isUsingFallbackForChrome125andEarlier) { + // When an extension is disabled due to a bad manifest, there is + // unfortunately no way to revive it, due to https://crbug.com/792277 + assert.equal( + testServer.requestCount, + 2, + 'No reload after bad reload when using --load-extension', + ); + } else { + if (testServer.requestCount === 2) { + await testServer.waitForHelloFromExtension(); + } + assert.equal(testServer.requestCount, 3, 'Reloaded after bad load'); + } + // Sanity check: When using fake-chrome-binary, verify that it falls + // back to --load-extension when we expect it to. + if (!useRealChrome) { + assert.equal( + isUsingFallbackForChrome125andEarlier, + chromeVersion < 126, + 'isUsingFallbackForChrome125andEarlier only true in Chrome < 126', + ); + } + } + + // Must send SIGINT so that chrome-launcher (used by web-ext) has a + // chance to terminate the browser that it spawned. Using plain kill + // can cause Chrome processes to be left behind. + cmd.spawnedProcess.kill('SIGINT'); + + // exitCode, stderr and stderr are not useful in this test: + // - exitCode may be null, 137 or whatever because we initiate kill. + // - stdout and stderr do not contain any info about Chrome. + // + // If the test is timing out due to the binary not exiting, look at a + // recently created directory in /tmp starting with "tmp-web-ext-", + // and read its chrome-err.log and chrome-out.log files (created by + // chrome-launcher). These files are erased by chrome-launcher when the + // binary exits. + // + // Example of showing stderr from the last minute: + // find /tmp/ -mmin 1 -name chrome-err.log -exec cat {} \; ;echo + await cmd.waitForExit; + if (expectReload && !isUsingFallbackForChrome125andEarlier) { + assert.equal(testServer.requestCount, 3, 'No unexpected requests'); + } else if (expectReload && isUsingFallbackForChrome125andEarlier) { + assert.equal(testServer.requestCount, 2, 'No unexpected requests'); + } else { + assert.equal(testServer.requestCount, 1, 'No unexpected requests'); + } + }); + process.chdir(cwd); + } + + describe('--no-reload', () => { + it('simulate Chrome 125 (--load-extension only)', async function () { + // Chrome 125 and earlier can only load extensions via --load-extension. + await testWebExtRun({ noReload: true, chromeVersion: 125 }); + }); + + it('simulate Chrome 126 (--load-extension or --enable-unsafe-extension-debugging)', async function () { + // Chrome 126 until 136 can load extensions via --load-extension or + // --enable-unsafe-extension-debugging. + await testWebExtRun({ noReload: true, chromeVersion: 126 }); + }); + + it('simulate Chrome 137 (--enable-unsafe-extension-debugging only)', async function () { + // Chrome 137 and later can only load extensions via + // --enable-unsafe-extension-debugging plus --remote-debugging-pipe. + await testWebExtRun({ noReload: true, chromeVersion: 137 }); + }); + + it(`run real Chrome ${REAL_CHROME_PATH || ''}`, async function () { + if (!process.env.TEST_WEBEXT_USE_REAL_CHROME) { + // Skip by default so that we do not launch a full-blown Chrome for + // real. To run with the real Chrome, after `npm run build`, run + // ./node_modules/.bin/mocha tests/functional/test.cli.run-target-chromium.js --grep=real + // + // with the following environment variable in front of it: + // TEST_WEBEXT_USE_REAL_CHROME=1 + // + // to run with a specific Chrome binary, + // TEST_WEBEXT_USE_REAL_CHROME=1 CHROME_PATH=/path/to/dir/chromium + this.skip(); + } + await testWebExtRun({ noReload: true, useRealChrome: true }); + }); + }); + + describe('with auto-reload', () => { + it('simulate Chrome 125 (--load-extension only)', async function () { + await testWebExtRun({ expectReload: true, chromeVersion: 125 }); + }); + + it('simulate Chrome 126 (--load-extension or --enable-unsafe-extension-debugging)', async function () { + await testWebExtRun({ expectReload: true, chromeVersion: 126 }); + }); + + it('simulate Chrome 137 (--enable-unsafe-extension-debugging only)', async function () { + await testWebExtRun({ expectReload: true, chromeVersion: 137 }); + }); + + it(`run real Chrome ${REAL_CHROME_PATH || ''}`, async function () { + if (!process.env.TEST_WEBEXT_USE_REAL_CHROME) { + this.skip(); + } + await testWebExtRun({ expectReload: true, useRealChrome: true }); + }); + }); +}); diff --git a/tests/unit/test-extension-runners/test.chromium.js b/tests/unit/test-extension-runners/test.chromium.js index cc763d16ab..084d62891e 100644 --- a/tests/unit/test-extension-runners/test.chromium.js +++ b/tests/unit/test-extension-runners/test.chromium.js @@ -2,13 +2,11 @@ import path from 'path'; import EventEmitter from 'events'; import { assert } from 'chai'; -import { describe, it, beforeEach, afterEach } from 'mocha'; +import { describe, it, beforeEach } from 'mocha'; import deepcopy from 'deepcopy'; import fs from 'fs-extra'; import * as sinon from 'sinon'; -import WebSocket from 'ws'; -import getValidatedManifest from '../../../src/util/manifest.js'; import { basicManifest, StubChildProcess } from '../helpers.js'; import { ChromiumExtensionRunner, @@ -21,10 +19,33 @@ import { TempDir, withTempDir } from '../../../src/util/temp-dir.js'; import fileExists from '../../../src/util/file-exists.js'; import isDirectory from '../../../src/util/is-directory.js'; -function prepareExtensionRunnerParams({ params } = {}) { +function prepareExtensionRunnerParams( + { params } = {}, + { loadUnpackedUnsupported = false } = {}, +) { + // This is not even close to the real thing, but enough to get the tests to + // pass. See tests/functional/test.cli.run-target-chromium.js for full + // coverage. + const incoming = new EventEmitter(); + const outgoing = new EventEmitter(); + outgoing.write = (data) => { + const request = JSON.parse(data.slice(0, -1)); // trim trailing \x00. + const response = { id: request.id }; + if (request.method === 'Extensions.loadUnpacked') { + if (loadUnpackedUnsupported) { + response.error = { message: "'Extensions.loadUnpacked' wasn't found" }; + } else { + response.result = { extensionId: 'fakeExtensionId' }; + } + } + incoming.emit('data', `${JSON.stringify(response)}\x00`); + }; const fakeChromeInstance = { process: new StubChildProcess(), - kill: sinon.spy(async () => {}), + kill: sinon.spy(async () => { + incoming.emit('close'); + }), + remoteDebuggingPipes: { incoming, outgoing }, }; const runnerParams = { extensions: [ @@ -36,6 +57,9 @@ function prepareExtensionRunnerParams({ params } = {}) { keepProfileChanges: false, startUrl: undefined, chromiumLaunch: sinon.spy(async () => { + // Note: When loadUnpackedUnsupported is true, we expect this method + // to be called twice. We should ideally return a new instance, but for + // simplicity just return the same, and use resetHistory() as needed. return fakeChromeInstance; }), desktopNotifications: sinon.spy(() => {}), @@ -72,6 +96,10 @@ describe('util/extension-runners/chromium', async () => { ]; assert.deepEqual(DEFAULT_CHROME_FLAGS, expectedFlags); + + // Double-checking that we never include --disable-extensions, because that + // prevents extensions from loading. + assert.notInclude(DEFAULT_CHROME_FLAGS, '--disable-extensions'); }); it('installs and runs the extension', async () => { @@ -81,16 +109,14 @@ describe('util/extension-runners/chromium', async () => { await runnerInstance.run(); - const { reloadManagerExtension } = runnerInstance; - sinon.assert.calledOnce(params.chromiumLaunch); sinon.assert.calledWithMatch(params.chromiumLaunch, { ignoreDefaultFlags: true, - enableExtensions: true, chromePath: undefined, chromeFlags: [ ...DEFAULT_CHROME_FLAGS, - `--load-extension=${reloadManagerExtension},/fake/sourceDir`, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', ], startingUrl: undefined, }); @@ -99,109 +125,6 @@ describe('util/extension-runners/chromium', async () => { sinon.assert.calledOnce(fakeChromeInstance.kill); }); - it('installs a "reload manager" companion extension', async () => { - const { params } = prepareExtensionRunnerParams(); - const runnerInstance = new ChromiumExtensionRunner(params); - await runnerInstance.run(); - - const { reloadManagerExtension } = runnerInstance; - - assert.equal(await fs.exists(reloadManagerExtension), true); - const managerExtManifest = await getValidatedManifest( - reloadManagerExtension, - ); - assert.deepEqual(managerExtManifest.permissions, ['management', 'tabs']); - - await runnerInstance.exit(); - }); - - it('controls the "reload manager" from a websocket server', async () => { - const { params } = prepareExtensionRunnerParams(); - const runnerInstance = new ChromiumExtensionRunner(params); - await runnerInstance.run(); - - const wssInfo = runnerInstance.wss.address(); - const wsURL = `ws://${wssInfo.address}:${wssInfo.port}`; - const wsClient = new WebSocket(wsURL); - - await new Promise((resolve) => wsClient.on('open', resolve)); - - // Clear console stream from previous messages and start recording - consoleStream.stopCapturing(); - consoleStream.flushCapturedLogs(); - consoleStream.startCapturing(); - // Make verbose to capture debug logs. - consoleStream.makeVerbose(); - - // Emit a fake socket object as a new wss connection. - - const fakeSocket = new EventEmitter(); - sinon.spy(fakeSocket, 'on'); - runnerInstance.wss?.emit('connection', fakeSocket); - sinon.assert.calledOnce(fakeSocket.on); - - fakeSocket.emit('error', new Error('Fake wss socket ERROR')); - - // Retrieve captures logs and stop capturing. - const { capturedMessages } = consoleStream; - consoleStream.stopCapturing(); - - assert.ok( - capturedMessages.some( - (message) => - message.match('[debug]') && message.match('Fake wss socket ERROR'), - ), - ); - - const reload = (client, resolve, data) => { - client.send(JSON.stringify({ type: 'webExtReloadExtensionComplete' })); - resolve(data); - }; - - const waitForReloadAll = new Promise((resolve) => - wsClient.on('message', (data) => reload(wsClient, resolve, data)), - ); - await runnerInstance.reloadAllExtensions(); - assert.deepEqual(JSON.parse(await waitForReloadAll), { - type: 'webExtReloadAllExtensions', - }); - - // TODO(rpl): change this once we improve the manager extension to be able - // to reload a single extension. - const waitForReloadOne = new Promise((resolve) => - wsClient.on('message', (data) => reload(wsClient, resolve, data)), - ); - await runnerInstance.reloadExtensionBySourceDir('/fake/sourceDir'); - assert.deepEqual(JSON.parse(await waitForReloadOne), { - type: 'webExtReloadAllExtensions', - }); - - // Verify that if one websocket connection gets closed, a second websocket - // connection still receives the control messages. - const wsClient2 = new WebSocket(wsURL); - await new Promise((resolve) => wsClient2.on('open', resolve)); - wsClient.close(); - - const waitForReloadClient2 = new Promise((resolve) => - wsClient2.on('message', (data) => reload(wsClient2, resolve, data)), - ); - - await runnerInstance.reloadAllExtensions(); - assert.deepEqual(JSON.parse(await waitForReloadClient2), { - type: 'webExtReloadAllExtensions', - }); - - const waitForReloadAllAgain = new Promise((resolve) => - wsClient2.on('message', (data) => reload(wsClient2, resolve, data)), - ); - await runnerInstance.reloadAllExtensions(); - assert.deepEqual(JSON.parse(await waitForReloadAllAgain), { - type: 'webExtReloadAllExtensions', - }); - - await runnerInstance.exit(); - }); - it('exits if the chrome instance is shutting down', async () => { const { params, fakeChromeInstance } = prepareExtensionRunnerParams(); const runnerInstance = new ChromiumExtensionRunner(params); @@ -212,6 +135,7 @@ describe('util/extension-runners/chromium', async () => { ); fakeChromeInstance.process.emit('close'); + fakeChromeInstance.remoteDebuggingPipes.incoming.emit('close'); await onceExiting; }); @@ -242,6 +166,7 @@ describe('util/extension-runners/chromium', async () => { const exitDone = runnerInstance.exit(); fakeChromeInstance.process.emit('close'); + fakeChromeInstance.remoteDebuggingPipes.incoming.emit('close'); await exitDone; @@ -294,16 +219,14 @@ describe('util/extension-runners/chromium', async () => { const runnerInstance = new ChromiumExtensionRunner(params); await runnerInstance.run(); - const { reloadManagerExtension } = runnerInstance; - sinon.assert.calledOnce(params.chromiumLaunch); sinon.assert.calledWithMatch(params.chromiumLaunch, { ignoreDefaultFlags: true, - enableExtensions: true, chromePath: '/my/custom/chrome-bin', chromeFlags: [ ...DEFAULT_CHROME_FLAGS, - `--load-extension=${reloadManagerExtension},/fake/sourceDir`, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', ], startingUrl: undefined, }); @@ -319,16 +242,14 @@ describe('util/extension-runners/chromium', async () => { const runnerInstance = new ChromiumExtensionRunner(params); await runnerInstance.run(); - const { reloadManagerExtension } = runnerInstance; - sinon.assert.calledOnce(params.chromiumLaunch); sinon.assert.calledWithMatch(params.chromiumLaunch, { ignoreDefaultFlags: true, - enableExtensions: true, chromePath: undefined, chromeFlags: [ ...DEFAULT_CHROME_FLAGS, - `--load-extension=${reloadManagerExtension},/fake/sourceDir`, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', 'url2', 'url3', ], @@ -349,16 +270,14 @@ describe('util/extension-runners/chromium', async () => { const runnerInstance = new ChromiumExtensionRunner(params); await runnerInstance.run(); - const { reloadManagerExtension } = runnerInstance; - sinon.assert.calledOnce(params.chromiumLaunch); sinon.assert.calledWithMatch(params.chromiumLaunch, { ignoreDefaultFlags: true, - enableExtensions: true, chromePath: undefined, chromeFlags: [ ...DEFAULT_CHROME_FLAGS, - `--load-extension=${reloadManagerExtension},/fake/sourceDir`, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', '--arg1', 'arg2', '--arg3', @@ -381,7 +300,7 @@ describe('util/extension-runners/chromium', async () => { const runnerInstance = new ChromiumExtensionRunner(params); await runnerInstance.run(); - const usedTempPath = spy.returnValues[2]; + const usedTempPath = spy.returnValues[1]; sinon.assert.calledWithMatch(params.chromiumLaunch, { userDataDir: usedTempPath, @@ -404,19 +323,17 @@ describe('util/extension-runners/chromium', async () => { const runnerInstance = new ChromiumExtensionRunner(params); await runnerInstance.run(); - const usedTempPath = spy.returnValues[2]; - - const { reloadManagerExtension } = runnerInstance; + const usedTempPath = spy.returnValues[1]; sinon.assert.calledOnce(params.chromiumLaunch); sinon.assert.calledWithMatch(params.chromiumLaunch, { ignoreDefaultFlags: true, - enableExtensions: true, chromePath: undefined, userDataDir: usedTempPath, chromeFlags: [ ...DEFAULT_CHROME_FLAGS, - `--load-extension=${reloadManagerExtension},/fake/sourceDir`, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', ], startingUrl: undefined, }); @@ -449,17 +366,15 @@ describe('util/extension-runners/chromium', async () => { const runnerInstance = new ChromiumExtensionRunner(params); await runnerInstance.run(); - const { reloadManagerExtension } = runnerInstance; - sinon.assert.calledOnce(params.chromiumLaunch); sinon.assert.calledWithMatch(params.chromiumLaunch, { ignoreDefaultFlags: true, - enableExtensions: true, chromePath: undefined, userDataDir: path.join(tmpPath, 'userDataDir'), chromeFlags: [ ...DEFAULT_CHROME_FLAGS, - `--load-extension=${reloadManagerExtension},/fake/sourceDir`, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', '--profile-directory=profile', ], startingUrl: undefined, @@ -493,17 +408,15 @@ describe('util/extension-runners/chromium', async () => { const runnerInstance = new ChromiumExtensionRunner(params); await runnerInstance.run(); - const { reloadManagerExtension } = runnerInstance; - sinon.assert.calledOnce(params.chromiumLaunch); sinon.assert.calledWithMatch(params.chromiumLaunch, { ignoreDefaultFlags: true, - enableExtensions: true, chromePath: undefined, userDataDir: path.join(tmpPath, 'userDataDir'), chromeFlags: [ ...DEFAULT_CHROME_FLAGS, - `--load-extension=${reloadManagerExtension},/fake/sourceDir`, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', `--profile-directory=${profileDirName}`, ], startingUrl: undefined, @@ -588,19 +501,17 @@ describe('util/extension-runners/chromium', async () => { const runnerInstance = new ChromiumExtensionRunner(params); await runnerInstance.run(); - const usedTempPath = spy.returnValues[2]; - - const { reloadManagerExtension } = runnerInstance; + const usedTempPath = spy.returnValues[1]; sinon.assert.calledOnce(params.chromiumLaunch); sinon.assert.calledWithMatch(params.chromiumLaunch, { ignoreDefaultFlags: true, - enableExtensions: true, chromePath: undefined, userDataDir: usedTempPath, chromeFlags: [ ...DEFAULT_CHROME_FLAGS, - `--load-extension=${reloadManagerExtension},/fake/sourceDir`, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', '--profile-directory=profile', ], startingUrl: undefined, @@ -618,9 +529,45 @@ describe('util/extension-runners/chromium', async () => { }), ); + it('falls back to --load-extension when needed (old Chrome)', async () => { + const { params, fakeChromeInstance } = prepareExtensionRunnerParams( + {}, + { loadUnpackedUnsupported: true }, + ); + const runnerInstance = new ChromiumExtensionRunner(params); + + await runnerInstance.run(); + sinon.assert.calledOnce(fakeChromeInstance.kill); + fakeChromeInstance.kill.resetHistory(); + + sinon.assert.calledTwice(params.chromiumLaunch); + sinon.assert.calledWithMatch(params.chromiumLaunch.firstCall, { + ignoreDefaultFlags: true, + chromePath: undefined, + chromeFlags: [ + ...DEFAULT_CHROME_FLAGS, + '--remote-debugging-pipe', + '--enable-unsafe-extension-debugging', + ], + startingUrl: undefined, + }); + sinon.assert.calledWithMatch(params.chromiumLaunch.secondCall, { + ignoreDefaultFlags: true, + chromePath: undefined, + chromeFlags: [ + ...DEFAULT_CHROME_FLAGS, + '--remote-debugging-pipe', + '--load-extension=/fake/sourceDir', + ], + startingUrl: undefined, + }); + + await runnerInstance.exit(); + sinon.assert.calledOnce(fakeChromeInstance.kill); + }); + describe('reloadAllExtensions', () => { let runnerInstance; - let wsClient; beforeEach(async () => { const { params } = prepareExtensionRunnerParams(); @@ -628,68 +575,6 @@ describe('util/extension-runners/chromium', async () => { await runnerInstance.run(); }); - const connectClient = async () => { - if (!runnerInstance.wss) { - throw new Error('WebSocker server is not running'); - } - const wssInfo = runnerInstance.wss.address(); - const wsURL = `ws://${wssInfo.address}:${wssInfo.port}`; - wsClient = new WebSocket(wsURL); - await new Promise((resolve) => wsClient.on('open', resolve)); - }; - - afterEach(async () => { - if (wsClient && wsClient.readyState === WebSocket.OPEN) { - wsClient.close(); - wsClient = null; - } - await runnerInstance.exit(); - }); - - it('does not resolve before complete message from client', async () => { - let reloadMessage = false; - await connectClient(); - - wsClient.on('message', (message) => { - const msg = JSON.parse(message); - - if (msg.type === 'webExtReloadAllExtensions') { - assert.equal(reloadMessage, false); - - setTimeout(() => { - const respondMsg = JSON.stringify({ - type: 'webExtReloadExtensionComplete', - }); - wsClient.send(respondMsg); - reloadMessage = true; - }, 333); - } - }); - - await runnerInstance.reloadAllExtensions(); - assert.equal(reloadMessage, true); - }); - - it('resolve when any client send complete message', async () => { - await connectClient(); - wsClient.on('message', () => { - const msg = JSON.stringify({ type: 'webExtReloadExtensionComplete' }); - wsClient.send(msg); - }); - await runnerInstance.reloadAllExtensions(); - }); - - it('resolve when all client disconnect', async () => { - await connectClient(); - await new Promise((resolve) => { - wsClient.on('close', () => { - resolve(runnerInstance.reloadAllExtensions()); - }); - wsClient.close(); - }); - wsClient = null; - }); - it('resolve when not client connected', async () => { await runnerInstance.reloadAllExtensions(); });