From 53c7d6091df4436fe297e624d6e9e76043bf20ca Mon Sep 17 00:00:00 2001 From: masaaki-hori Date: Thu, 15 Oct 2026 10:34:29 +0900 Subject: [PATCH] fix: pause newCommandTimeout during mobile: activateApp/launchApp The mobile:(activateApp|launchApp) branch of executeCommand bypasses BaseDriver.executeCommand, so the FlutterDriver newCommandTimeout kept running while reConnectFlutterDriver waited for the observatory URL. When that wait exceeded the timeout (60s by default), the session was deleted mid-command. Clear the timeout for the whole command and restart it in a finally block, as the proxy branch already does. Co-Authored-By: Claude Opus 5.5 --- driver/lib/driver.ts | 15 +++++-- driver/test/mobile-launch-app.test.mjs | 55 +++++++++++++++++++------- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/driver/lib/driver.ts b/driver/lib/driver.ts index 1a869e05..b4939e29 100644 --- a/driver/lib/driver.ts +++ b/driver/lib/driver.ts @@ -238,12 +238,19 @@ class FlutterDriver extends BaseDriver { public async executeCommand(cmd: string, ...args: [string, [{skipAttachObservatoryUrl: string; any: any}]]) { if (new RegExp(/^[\s]*mobile:[\s]*(activateApp|launchApp)$/).test(args[0])) { const {skipAttachObservatoryUrl = false} = args[1][0]; - await this.proxydriver?.executeCommand(cmd, ...args); - if (skipAttachObservatoryUrl) { + // This bypasses BaseDriver.executeCommand, so pause the FlutterDriver CommandTimeout here: + // the reconnect can wait for the observatory URL much longer than newCommandTimeout. + await this.clearNewCommandTimeout(); + try { + await this.proxydriver?.executeCommand(cmd, ...args); + if (skipAttachObservatoryUrl) { + return; + } + await reConnectFlutterDriver.bind(this)(this.internalCaps); return; + } finally { + await this.startNewCommandTimeout(); } - await reConnectFlutterDriver.bind(this)(this.internalCaps); - return; } else if (new RegExp(/^[\s]*mobile:[\s]*terminateApp$/).test(args[0])) { // to make the behavior as same as this.terminateApp return await this.proxydriver?.executeCommand(cmd, ...args); diff --git a/driver/test/mobile-launch-app.test.mjs b/driver/test/mobile-launch-app.test.mjs index 5e3e0a7e..f0368ebd 100644 --- a/driver/test/mobile-launch-app.test.mjs +++ b/driver/test/mobile-launch-app.test.mjs @@ -5,34 +5,59 @@ import {FlutterDriver} from '../build/lib/driver.js'; const noopLog = {info() {}, debug() {}, warn() {}, error() {}, errorWithException() {}}; +// Records the order of proxy calls and newCommandTimeout changes. +function fakeDriver() { + const events = []; + return { + events, + log: noopLog, + currentContext: 'FLUTTER', + internalCaps: {platformName: 'iOS'}, + proxydriver: {executeCommand: mock.fn(async () => events.push('proxy'))}, + clearNewCommandTimeout: async () => events.push('clearTimeout'), + startNewCommandTimeout: async () => events.push('startTimeout'), + }; +} + // The reconnect that follows the launch fails right away here because no device-log monitor // was started, which shows that the driver tried to re-attach to the Dart VM. it('forwards mobile: launchApp to XCUITest from the FLUTTER context and reconnects', async () => { - const proxydriver = {executeCommand: mock.fn(async () => {})}; + const driver = fakeDriver(); const params = {bundleId: 'com.example.app', arguments: ['--vm-service-port=9123']}; await assert.rejects( - FlutterDriver.prototype.executeCommand.call( - {log: noopLog, currentContext: 'FLUTTER', internalCaps: {platformName: 'iOS'}, proxydriver}, - 'execute', - 'mobile: launchApp', - [params], - ), + FlutterDriver.prototype.executeCommand.call(driver, 'execute', 'mobile: launchApp', [params]), /mandatory syslog service/, ); - assert.deepEqual(proxydriver.executeCommand.mock.calls[0].arguments, ['execute', 'mobile: launchApp', [params]]); + assert.deepEqual(driver.proxydriver.executeCommand.mock.calls[0].arguments, [ + 'execute', + 'mobile: launchApp', + [params], + ]); }); it('skips the reconnect for mobile: launchApp when skipAttachObservatoryUrl is set', async () => { - const proxydriver = {executeCommand: mock.fn(async () => {})}; + const driver = fakeDriver(); - await FlutterDriver.prototype.executeCommand.call( - {log: noopLog, currentContext: 'FLUTTER', internalCaps: {platformName: 'iOS'}, proxydriver}, - 'execute', - 'mobile: launchApp', - [{bundleId: 'com.example.app', skipAttachObservatoryUrl: true}], + await FlutterDriver.prototype.executeCommand.call(driver, 'execute', 'mobile: launchApp', [ + {bundleId: 'com.example.app', skipAttachObservatoryUrl: true}, + ]); + + assert.equal(driver.proxydriver.executeCommand.mock.callCount(), 1); +}); + +// The reconnect can wait for the observatory URL far longer than newCommandTimeout, so the +// timeout must be paused for the whole command and restarted even when the reconnect fails. +it('pauses newCommandTimeout while launching and reconnecting', async () => { + const driver = fakeDriver(); + + await assert.rejects( + FlutterDriver.prototype.executeCommand.call(driver, 'execute', 'mobile: activateApp', [ + {bundleId: 'com.example.app'}, + ]), + /mandatory syslog service/, ); - assert.equal(proxydriver.executeCommand.mock.callCount(), 1); + assert.deepEqual(driver.events, ['clearTimeout', 'proxy', 'startTimeout']); });