From ad2958416e1b4abbd8bc3780786b591445e5a330 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Thu, 17 Sep 2026 12:30:54 +0200 Subject: [PATCH 1/3] feat: add reinstallApp option and terminate before auto launch --- README.md | 18 +++++- packages/mobilewright/src/config.ts | 4 +- packages/mobilewright/src/launchers.ts | 5 ++ packages/test/src/fixture-helpers.test.ts | 72 +++++++++++++++++++++++ packages/test/src/fixture-helpers.ts | 60 +++++++++++++++++++ packages/test/src/fixtures.ts | 34 +++++------ 6 files changed, 172 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index ec1ae2b..951f83a 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,8 @@ All options: | `deviceId` | `string` | Explicit device UDID (optional) | | `deviceName` | `RegExp` | RegExp to match device name (optional) | | `installApps` | `string \| string[]` | App paths (APK/IPA) to install before launching (optional) | -| `autoAppLaunch` | `boolean` | Automatically launch the app after connecting. Default: `true` | +| `autoAppLaunch` | `boolean` | Terminate and launch the app (`bundleId`) before each test. Default: `true` | +| `reinstallApp` | `boolean` | Uninstall the app (`bundleId`) and reinstall `installApps` before each test attempt, for a fresh install. Default: `false` | | `viewTree` | `'on-failure' \| 'off'` | Attach the accessibility tree as JSON to the report on failure. Default: `'off'` | | `timeout` | `number` | Per-test timeout in ms (optional) | | `globalTimeout` | `number` | Hard cap on the entire test suite run in ms (optional) | @@ -453,7 +454,8 @@ The `device` fixture connects once per worker (reading from `mobilewright.config | `deviceId` | `string` | Specific device identifier (local drivers only) | | `deviceName` | `RegExp` | RegExp to match the device name | | `installApps` | `string \| string[]` | App paths (APK/IPA) to install before the tests run | -| `autoAppLaunch` | `boolean` | Launch the app automatically before each test. Default: `true` | +| `autoAppLaunch` | `boolean` | Terminate and launch the app (`bundleId`) before each test. Default: `true` | +| `reinstallApp` | `boolean` | Uninstall the app (`bundleId`) and reinstall `installApps` before each test attempt. Requires both. Default: `false` | | `viewTree` | `'on-failure' \| 'off'` | Attach the accessibility tree as JSON when a test fails. Default: `'off'` | | `video` | `'on' \| 'retain-on-failure' \| 'off'` | Record video — always, only on failure, or never. Default: `'off'` | @@ -465,6 +467,18 @@ test.use({ }); ``` +Reinstall only where a fresh install matters, such as onboarding tests, and keep the fast default elsewhere: + +```typescript +test.describe('first launch', () => { + test.use({ reinstallApp: true }); + + test('shows onboarding', async ({ screen }) => { + await expect(screen.getByText('Welcome')).toBeVisible(); + }); +}); +``` + ## CLI ### `mobilewright init` diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index 6cac634..72d04b4 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -96,8 +96,10 @@ export interface MobilewrightConfig { bundleId?: string; /** App paths (APK/IPA) to install on the device before launching. */ installApps?: string | string[]; - /** Automatically launch the app after connecting. Default: true. */ + /** Terminate and launch the app (bundleId) before each test. Default: true. */ autoAppLaunch?: boolean; + /** Uninstall the app (bundleId) and reinstall installApps before each test attempt. Default: false. */ + reinstallApp?: boolean; /** Attach the accessibility tree as JSON to the test report. 'on-failure' attaches on test failure, 'off' disables. Default: 'off'. */ viewTree?: 'on-failure' | 'off'; /** Driver instance to use, e.g. `new MobileNextDriver({ apiKey })`. Default: `new MobilecliDriver()`. */ diff --git a/packages/mobilewright/src/launchers.ts b/packages/mobilewright/src/launchers.ts index 10a4e51..a6dc703 100644 --- a/packages/mobilewright/src/launchers.ts +++ b/packages/mobilewright/src/launchers.ts @@ -80,6 +80,11 @@ export async function installAndLaunchApps(device: Device, opts: LaunchOptions): await device.installApp(appPath); } if (opts.bundleId && opts.autoAppLaunch !== false) { + try { + await device.terminateApp(opts.bundleId); + } catch { + // app may not be running + } await device.launchApp(opts.bundleId); } } diff --git a/packages/test/src/fixture-helpers.test.ts b/packages/test/src/fixture-helpers.test.ts index 9b6df88..4c83e29 100644 --- a/packages/test/src/fixture-helpers.test.ts +++ b/packages/test/src/fixture-helpers.test.ts @@ -11,6 +11,8 @@ import { allocationTimeoutFor, videoPlan, parseViewTreeOption, + assertReinstallAppConfig, + prepareApp, } from './fixture-helpers.js'; function writeTempFile(name: string, bytes: Buffer): string { @@ -168,3 +170,73 @@ test.describe('parseViewTreeOption', () => { expect(() => parseViewTreeOption('always')).toThrow('Invalid viewTree value: "always"'); }); }); + +test.describe('assertReinstallAppConfig', () => { + test('passes when reinstallApp is off', () => { + expect(() => assertReinstallAppConfig({ reinstallApp: false, installApps: [] })).not.toThrow(); + }); + + test('requires bundleId when reinstallApp is on', () => { + expect(() => assertReinstallAppConfig({ reinstallApp: true, installApps: ['app.apk'] })).toThrow('reinstallApp requires bundleId'); + }); + + test('requires installApps when reinstallApp is on', () => { + expect(() => assertReinstallAppConfig({ reinstallApp: true, bundleId: 'com.example', installApps: [] })).toThrow('reinstallApp requires installApps'); + }); +}); + +test.describe('prepareApp', () => { + function createFakeDeviceThatRecordsCalls(failing: Set = new Set()) { + const calls: string[] = []; + const record = (name: string) => async (arg: string) => { + calls.push(`${name}:${arg}`); + if (failing.has(name)) { + throw new Error(`${name} failed`); + } + }; + const device = { installApp: record('install'), uninstallApp: record('uninstall'), launchApp: record('launch'), terminateApp: record('terminate') }; + return { device, calls }; + } + + function createFakeInstallerThatRemembers(alreadyInstalled: string[]) { + const installed = new Set(alreadyInstalled); + return { + isInstalled: async (path: string) => installed.has(path), + recordInstalled: async (path: string) => { installed.add(path); }, + installed, + }; + } + + test('skips apps the pool already installed and relaunches the app under test', async () => { + const { device, calls } = createFakeDeviceThatRecordsCalls(); + const installer = createFakeInstallerThatRemembers(['a.apk']); + await prepareApp(device, installer, { bundleId: 'com.a', installApps: ['a.apk', 'b.apk'] }); + expect(calls).toEqual(['install:b.apk', 'terminate:com.a', 'launch:com.a']); + expect([...installer.installed]).toEqual(['a.apk', 'b.apk']); + }); + + test('ignores a failing terminate before launch', async () => { + const { device, calls } = createFakeDeviceThatRecordsCalls(new Set(['terminate'])); + await prepareApp(device, createFakeInstallerThatRemembers([]), { bundleId: 'com.a', installApps: [] }); + expect(calls).toEqual(['terminate:com.a', 'launch:com.a']); + }); + + test('does not touch the app when autoAppLaunch is off', async () => { + const { device, calls } = createFakeDeviceThatRecordsCalls(); + await prepareApp(device, createFakeInstallerThatRemembers([]), { bundleId: 'com.a', installApps: [], autoAppLaunch: false }); + expect(calls).toEqual([]); + }); + + test('reinstallApp uninstalls only the app under test then reinstalls every app', async () => { + const { device, calls } = createFakeDeviceThatRecordsCalls(); + const installer = createFakeInstallerThatRemembers(['a.apk', 'b.apk']); + await prepareApp(device, installer, { bundleId: 'com.a', installApps: ['a.apk', 'b.apk'], reinstallApp: true }); + expect(calls).toEqual(['uninstall:com.a', 'install:a.apk', 'install:b.apk', 'terminate:com.a', 'launch:com.a']); + }); + + test('reinstallApp ignores a failing uninstall when the app is not installed', async () => { + const { device, calls } = createFakeDeviceThatRecordsCalls(new Set(['uninstall'])); + await prepareApp(device, createFakeInstallerThatRemembers([]), { bundleId: 'com.a', installApps: ['a.apk'], reinstallApp: true, autoAppLaunch: false }); + expect(calls).toEqual(['uninstall:com.a', 'install:a.apk']); + }); +}); diff --git a/packages/test/src/fixture-helpers.ts b/packages/test/src/fixture-helpers.ts index 8c4420c..f378dc6 100644 --- a/packages/test/src/fixture-helpers.ts +++ b/packages/test/src/fixture-helpers.ts @@ -15,6 +15,22 @@ type DeviceOptions = { installApps?: string | string[]; }; type Annotation = { type: string; description: string }; +type AppPreparation = { + bundleId?: string; + installApps: string[]; + autoAppLaunch?: boolean; + reinstallApp?: boolean; +}; +type AppDevice = { + installApp(path: string): Promise; + uninstallApp(bundleId: string): Promise; + launchApp(bundleId: string): Promise; + terminateApp(bundleId: string): Promise; +}; +type AppInstaller = { + isInstalled(path: string): Promise; + recordInstalled(path: string): Promise; +}; type VideoPlan = { shouldRecord: boolean; path: string; shouldAttach(failed: boolean): boolean }; const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); @@ -103,3 +119,47 @@ export function parseViewTreeOption(value: string | undefined): 'on-failure' | ' } return resolved; } + +export function assertReinstallAppConfig(opts: AppPreparation): void { + if (!opts.reinstallApp) { + return; + } + if (!opts.bundleId) { + throw new Error('reinstallApp requires bundleId (the app to uninstall before reinstalling)'); + } + if (opts.installApps.length === 0) { + throw new Error('reinstallApp requires installApps (the packages to reinstall)'); + } +} + +/** + * Brings the app under test to its starting state for a test attempt: + * reinstallApp uninstalls only bundleId and reinstalls every installApps entry (other apps are + * replaced in place); autoAppLaunch terminates bundleId (if running) and launches it. + */ +export async function prepareApp(device: AppDevice, installer: AppInstaller, opts: AppPreparation): Promise { + if (opts.reinstallApp && opts.bundleId) { + try { + await device.uninstallApp(opts.bundleId); + } catch { + // app may not be installed + } + } + + for (const appPath of opts.installApps) { + const installed = await installer.isInstalled(appPath); + if (opts.reinstallApp || !installed) { + await device.installApp(appPath); + await installer.recordInstalled(appPath); + } + } + + if (opts.bundleId && opts.autoAppLaunch !== false) { + try { + await device.terminateApp(opts.bundleId); + } catch { + // app may not be running + } + await device.launchApp(opts.bundleId); + } +} diff --git a/packages/test/src/fixtures.ts b/packages/test/src/fixtures.ts index 76d1548..ac64923 100644 --- a/packages/test/src/fixtures.ts +++ b/packages/test/src/fixtures.ts @@ -18,6 +18,8 @@ import type { Device, Screen } from '@mobilewright/core'; import { assertValidZipFile, mergeDeviceConfig, + assertReinstallAppConfig, + prepareApp, assertSupportedPlatform, annotationsForDevice, connectOptionsFor, @@ -53,6 +55,7 @@ type MobilewrightTestFixtures = { screen: Screen; bundleId: string | undefined; autoAppLaunch: boolean | undefined; + reinstallApp: boolean | undefined; platform: 'ios' | 'android' | undefined; deviceId: string | undefined; deviceName: RegExp | undefined; @@ -97,6 +100,11 @@ export const test = base.extend({ await use(config.autoAppLaunch); }, { option: true }], + reinstallApp: [async ({}, use, testInfo) => { + const config = await loadConfig(process.cwd(), testInfo.config.configFile); + await use(config.reinstallApp); + }, { option: true }], + platform: [undefined, { option: true }], deviceId: [undefined, { option: true }], deviceName: [undefined, { option: true }], @@ -112,12 +120,14 @@ export const test = base.extend({ // Setup runs outside the test timeout (timeout: 0): each stage carries its own bound instead — // allocationTimeout for queue + provisioning, installTimeout, appLaunchTimeout. A cloud queue // can hold a worker for many minutes, and that wait must not eat the test body's budget. - device: [async ({ platform, deviceId, deviceName, deviceType, osVersion, bundleId, autoAppLaunch, installApps }, use, testInfo) => { + device: [async ({ platform, deviceId, deviceName, deviceType, osVersion, bundleId, autoAppLaunch, reinstallApp, installApps }, use, testInfo) => { const config = await loadConfig(process.cwd(), testInfo.config.configFile); const merged = mergeDeviceConfig(config, { platform, deviceId, deviceName, deviceType, osVersion, installApps }, testInfo.project.name); const supportedPlatform = assertSupportedPlatform(merged.platform); - for (const appPath of toArray(merged.installApps)) { + const appPreparation = { bundleId, autoAppLaunch, reinstallApp, installApps: toArray(merged.installApps) }; + assertReinstallAppConfig(appPreparation); + for (const appPath of appPreparation.installApps) { assertValidZipFile(appPath); } @@ -139,22 +149,10 @@ export const test = base.extend({ debug('connected to device %s', handle.deviceId); try { - for (const appPath of toArray(merged.installApps)) { - const installed = await client.isAppInstalled(handle.allocationId, appPath); - if (!installed) { - await device.installApp(appPath); - await client.recordAppInstalled(handle.allocationId, appPath); - } - } - - if (bundleId && autoAppLaunch !== false) { - try { - await device.terminateApp(bundleId); - } catch { - // app may not be running - } - await device.launchApp(bundleId); - } + await prepareApp(device, { + isInstalled: (appPath) => client.isAppInstalled(handle.allocationId, appPath), + recordInstalled: (appPath) => client.recordAppInstalled(handle.allocationId, appPath), + }, appPreparation); device.setStepFn((title, fn, location) => (base.step as any)(title, fn, { location })); From 2bba4a80612558596038d50a57b5ff648661be59 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Thu, 17 Sep 2026 12:41:29 +0200 Subject: [PATCH 2/3] test: add android e2e proving reinstallApp wipes app data --- e2e/src/android/reinstall-app.test.ts | 47 +++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 e2e/src/android/reinstall-app.test.ts diff --git a/e2e/src/android/reinstall-app.test.ts b/e2e/src/android/reinstall-app.test.ts new file mode 100644 index 0000000..8f26d2c --- /dev/null +++ b/e2e/src/android/reinstall-app.test.ts @@ -0,0 +1,47 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { test, expect } from '@mobilewright/test'; +import type { Locator, Screen } from '@mobilewright/core'; + +// Proves reinstallApp gives a fresh install: the playground's SharedPref screen persists a +// username across relaunches, and only an uninstall wipes it. Android only — on iOS the +// playground stores these in the keychain, which survives an uninstall. +const PLAYGROUND_APK = join(homedir(), 'git/playground/android/app/build/outputs/apk/debug/app-debug.apk'); +const USERNAME = 'reinstall-me'; + +test.use({ platform: 'android', bundleId: 'com.mobilenext.playground', installApps: PLAYGROUND_APK }); +test.describe.configure({ mode: 'serial' }); + +async function openSharedPrefScreen(screen: Screen): Promise { + await screen.getByText('SharedPref / Keychain').tap(); + await expect(screen.getByLabel('load_button')).toBeVisible(); +} + +async function loadAndReadStatus(screen: Screen): Promise { + await screen.getByLabel('load_button').tap(); + return screen.getByLabel('status_message'); +} + +test('saves a username into shared preferences', async ({ screen }) => { + await openSharedPrefScreen(screen); + await screen.getByLabel('username_field').fill(USERNAME); + await screen.getByLabel('save_button').tap(); + await expect(screen.getByLabel('status_message')).toHaveText('Saved'); +}); + +test('a relaunch keeps the saved username', async ({ screen }) => { + await openSharedPrefScreen(screen); + const status = await loadAndReadStatus(screen); + await expect(status).toHaveText('Loaded'); + await expect(screen.getByLabel('username_field')).toHaveText(USERNAME); +}); + +test.describe('with reinstallApp', () => { + test.use({ reinstallApp: true }); + + test('a reinstall wipes the saved username', async ({ screen }) => { + await openSharedPrefScreen(screen); + const status = await loadAndReadStatus(screen); + await expect(status).toHaveText('No preferences found'); + }); +}); From e3bd167a4b5a5d817ec229eb5ed4317a8bb81d6e Mon Sep 17 00:00:00 2001 From: gmegidish Date: Thu, 17 Sep 2026 17:00:43 +0200 Subject: [PATCH 3/3] fix: uninstall only when installed and accept reinstallApp in project use --- e2e/src/android/reinstall-app.test.ts | 2 ++ packages/mobilewright/src/config.ts | 2 ++ packages/test/src/fixture-helpers.test.ts | 23 +++++++++++++++++------ packages/test/src/fixture-helpers.ts | 7 ++++--- packages/test/src/fixtures.ts | 2 +- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/e2e/src/android/reinstall-app.test.ts b/e2e/src/android/reinstall-app.test.ts index 8f26d2c..1304f88 100644 --- a/e2e/src/android/reinstall-app.test.ts +++ b/e2e/src/android/reinstall-app.test.ts @@ -10,6 +10,8 @@ const PLAYGROUND_APK = join(homedir(), 'git/playground/android/app/build/outputs const USERNAME = 'reinstall-me'; test.use({ platform: 'android', bundleId: 'com.mobilenext.playground', installApps: PLAYGROUND_APK }); +// Serial on one worker: the pool hands the released emulator back to the next test, so saved +// preferences carry across tests. This assumes a single matching Android device, as in CI. test.describe.configure({ mode: 'serial' }); async function openSharedPrefScreen(screen: Screen): Promise { diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index 72d04b4..d28eb42 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -16,6 +16,8 @@ type LegacyDriverConfig = { type: string } & Record; // ─── Project ────────────────────────────────────────────────────── export interface MobilewrightUseOptions { + /** Uninstall the app (bundleId) and reinstall installApps before each test attempt. Default: false. */ + reinstallApp?: boolean; /** Platform for this project. */ platform?: 'ios' | 'android'; /** Specific device identifier (local drivers only). Overrides the top-level deviceId for this project. */ diff --git a/packages/test/src/fixture-helpers.test.ts b/packages/test/src/fixture-helpers.test.ts index 4c83e29..698e351 100644 --- a/packages/test/src/fixture-helpers.test.ts +++ b/packages/test/src/fixture-helpers.test.ts @@ -186,7 +186,7 @@ test.describe('assertReinstallAppConfig', () => { }); test.describe('prepareApp', () => { - function createFakeDeviceThatRecordsCalls(failing: Set = new Set()) { + function createFakeDeviceThatRecordsCalls(failing: Set = new Set(), installedBundleIds: string[] = []) { const calls: string[] = []; const record = (name: string) => async (arg: string) => { calls.push(`${name}:${arg}`); @@ -194,7 +194,13 @@ test.describe('prepareApp', () => { throw new Error(`${name} failed`); } }; - const device = { installApp: record('install'), uninstallApp: record('uninstall'), launchApp: record('launch'), terminateApp: record('terminate') }; + const device = { + listApps: async () => installedBundleIds.map((bundleId) => ({ bundleId })), + installApp: record('install'), + uninstallApp: record('uninstall'), + launchApp: record('launch'), + terminateApp: record('terminate'), + }; return { device, calls }; } @@ -228,15 +234,20 @@ test.describe('prepareApp', () => { }); test('reinstallApp uninstalls only the app under test then reinstalls every app', async () => { - const { device, calls } = createFakeDeviceThatRecordsCalls(); + const { device, calls } = createFakeDeviceThatRecordsCalls(new Set(), ['com.a', 'com.b']); const installer = createFakeInstallerThatRemembers(['a.apk', 'b.apk']); await prepareApp(device, installer, { bundleId: 'com.a', installApps: ['a.apk', 'b.apk'], reinstallApp: true }); expect(calls).toEqual(['uninstall:com.a', 'install:a.apk', 'install:b.apk', 'terminate:com.a', 'launch:com.a']); }); - test('reinstallApp ignores a failing uninstall when the app is not installed', async () => { - const { device, calls } = createFakeDeviceThatRecordsCalls(new Set(['uninstall'])); + test('reinstallApp skips the uninstall when the app is not installed', async () => { + const { device, calls } = createFakeDeviceThatRecordsCalls(); await prepareApp(device, createFakeInstallerThatRemembers([]), { bundleId: 'com.a', installApps: ['a.apk'], reinstallApp: true, autoAppLaunch: false }); - expect(calls).toEqual(['uninstall:com.a', 'install:a.apk']); + expect(calls).toEqual(['install:a.apk']); + }); + + test('reinstallApp surfaces an uninstall failure instead of installing over old data', async () => { + const { device } = createFakeDeviceThatRecordsCalls(new Set(['uninstall']), ['com.a']); + await expect(prepareApp(device, createFakeInstallerThatRemembers([]), { bundleId: 'com.a', installApps: ['a.apk'], reinstallApp: true })).rejects.toThrow('uninstall failed'); }); }); diff --git a/packages/test/src/fixture-helpers.ts b/packages/test/src/fixture-helpers.ts index f378dc6..d758953 100644 --- a/packages/test/src/fixture-helpers.ts +++ b/packages/test/src/fixture-helpers.ts @@ -22,6 +22,7 @@ type AppPreparation = { reinstallApp?: boolean; }; type AppDevice = { + listApps(): Promise>; installApp(path: string): Promise; uninstallApp(bundleId: string): Promise; launchApp(bundleId: string): Promise; @@ -139,10 +140,10 @@ export function assertReinstallAppConfig(opts: AppPreparation): void { */ export async function prepareApp(device: AppDevice, installer: AppInstaller, opts: AppPreparation): Promise { if (opts.reinstallApp && opts.bundleId) { - try { + const apps = await device.listApps(); + const isInstalled = apps.some((app) => app.bundleId === opts.bundleId); + if (isInstalled) { await device.uninstallApp(opts.bundleId); - } catch { - // app may not be installed } } diff --git a/packages/test/src/fixtures.ts b/packages/test/src/fixtures.ts index ac64923..56b5af4 100644 --- a/packages/test/src/fixtures.ts +++ b/packages/test/src/fixtures.ts @@ -125,7 +125,7 @@ export const test = base.extend({ const merged = mergeDeviceConfig(config, { platform, deviceId, deviceName, deviceType, osVersion, installApps }, testInfo.project.name); const supportedPlatform = assertSupportedPlatform(merged.platform); - const appPreparation = { bundleId, autoAppLaunch, reinstallApp, installApps: toArray(merged.installApps) }; + const appPreparation = { bundleId, autoAppLaunch, reinstallApp: reinstallApp ?? merged.use?.reinstallApp, installApps: toArray(merged.installApps) }; assertReinstallAppConfig(appPreparation); for (const appPath of appPreparation.installApps) { assertValidZipFile(appPath);