feat: device.resetAppState([...bundleIds]) (#4819)

This commit is contained in:
Yaroslav Serhieiev
2025-09-24 16:02:39 +03:00
committed by GitHub
parent bd49ff1a9b
commit 8d72af44dd
24 changed files with 831 additions and 38 deletions
+29
View File
@@ -773,6 +773,28 @@ declare global {
*/
reloadReactNative(): Promise<void>;
/**
* Resets the app state by clearing app data and restoring it to a clean state.
*
* On Android, this command clears the app's data using the `pm clear` command,
* effectively resetting the app to its initial installed state without uninstalling it.
*
* On iOS, Detox uses a fallback mechanism - it backs up, deletes and installs the app from cache.
* This process ensures the app is returned to a clean state.
*
* @param bundleIds Optional bundle IDs to reset. If none provided, resets the currently selected app.
* @example
* // Reset current app state
* await device.resetAppState();
* @example
* // Reset specific app state
* await device.resetAppState('com.example.app');
* @example
* // Reset multiple apps
* await device.resetAppState('com.app1', 'com.app2');
*/
resetAppState(...bundleIds: string[]): Promise<void>;
/**
* By default, installApp() with no params will install the app file defined in the current configuration.
* To install another app, specify its path
@@ -2014,6 +2036,13 @@ declare global {
* Launch with user activity
*/
userActivity?: any;
/**
* Similar to {@link Detox.DeviceLaunchAppConfig.delete | { delete: true }}, but instead of uninstalling and installing the app,
* it runs {@link Detox.Device.resetAppState | device.resetAppState()} instead.
* @example
* await device.launchApp({resetAppState: true});
*/
resetAppState?: boolean;
/**
* Launch into a fresh installation
* A flag that enables relaunching into a fresh installation of the app (it will uninstall and install the binary again), default is false.
@@ -8,6 +8,7 @@ const _ = require('lodash');
const { DetoxRuntimeError } = require('../../../../errors');
const log = require('../../../../utils/logger').child({ cat: 'device,device-allocation' });
const SimulatorAppCache = require('../../../common/drivers/ios/tools/SimulatorAppCache');
const SimulatorQuery = require('./SimulatorQuery');
@@ -24,6 +25,7 @@ class SimulatorAllocDriver {
constructor({ detoxConfig, deviceRegistry, applesimutils }) {
this._deviceRegistry = deviceRegistry;
this._applesimutils = applesimutils;
this._appCache = new SimulatorAppCache({ applesimutils });
this._launchInfo = {};
this._shouldShutdown = detoxConfig.behavior.cleanup.shutdownDevice;
}
@@ -58,7 +60,9 @@ class SimulatorAllocDriver {
async postAllocate(deviceCookie) {
const { udid } = deviceCookie;
const { deviceConfig } = this._launchInfo[udid];
await this._applesimutils.boot(udid, deviceConfig.bootArgs, deviceConfig.headless);
await this._appCache.cleanupOnce(udid);
return {
id: udid,
@@ -86,12 +90,15 @@ class SimulatorAllocDriver {
}
async cleanup() {
const sessionDevices = await this._deviceRegistry.readSessionDevices();
const deviceIds = sessionDevices.getIds();
if (this._shouldShutdown) {
const sessionDevices = await this._deviceRegistry.readSessionDevices();
const shutdownPromises = sessionDevices.getIds().map((udid) => this._doShutdown(udid));
const shutdownPromises = deviceIds.map((udid) => this._doShutdown(udid));
await Promise.all(shutdownPromises);
}
await Promise.all(deviceIds.map((udid) => this._appCache.cleanup(udid)));
await this._deviceRegistry.unregisterSessionDevices();
}
@@ -148,6 +148,18 @@ class ADB {
await this.shell(deviceId, `am force-stop ${appId}`);
}
async clearAppData(deviceId, packageId) {
try {
return await this.shell(deviceId, `pm clear ${packageId}`);
} catch (reason) {
throw new DetoxRuntimeError({
message: `Failed to clear ${packageId} app data on ${deviceId}`,
hint: `Please verify that the package is installed on the device:\nadb -s ${deviceId} shell pm list packages ${packageId}`,
debugInfo: reason,
});
}
}
async setLocation(deviceId, lat, lon) {
// NOTE: QEMU for Android for the telnet part relies on C stdlib
// function `strtod` which is locale-sensitive, meaning that depending
@@ -414,4 +414,19 @@ describe('ADB', () => {
expect(execWithRetriesAndLogs).toHaveBeenCalledWith(`"${adbBinPath}" -s ${deviceId} shell "svc wifi disable"`, { retries: 1 });
});
});
describe('clearAppData', () => {
it('should invoke pm clear for given package', async () => {
await adb.clearAppData(deviceId, 'com.example.app');
expect(execWithRetriesAndLogs).toHaveBeenCalledWith(
expect.stringContaining(`"${adbBinPath}" -s ${deviceId} shell "pm clear com.example.app"`),
expect.any(Object)
);
});
it('should throw an error if the package is not installed', async () => {
execWithRetriesAndLogs.mockRejectedValue('Command failed');
await expect(adb.clearAppData(deviceId, 'com.example.app')).rejects.toThrowErrorMatchingSnapshot();
});
});
});
@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`ADB clearAppData should throw an error if the package is not installed 1`] = `
"Failed to clear com.example.app app data on mockEmulator
HINT: Please verify that the package is installed on the device:
adb -s mockEmulator shell pm list packages com.example.app
Command failed"
`;
@@ -0,0 +1,148 @@
const path = require('path');
const { DetoxInternalError, DetoxRuntimeError } = require('../../../../../errors');
const { getDetoxAppsCachePath } = require('../../../../../utils/environment');
const fse = require('../../../../../utils/fsext');
const log = require('../../../../../utils/logger').child({ cat: 'device,app-cache' });
class SimulatorAppCache {
constructor({ applesimutils, rootDir = getDetoxAppsCachePath() }) {
this.applesimutils = applesimutils;
this.rootDir = rootDir;
this.cleanDeviceIds = new Set();
}
/**
* Add an app to cache from a binary path
* @param {string} deviceId - The device identifier
* @param {string} bundleId - The app's bundle identifier
* @param {string} binaryPath - Path to the app binary
*/
async add(deviceId, bundleId, binaryPath) {
log.trace({ deviceId }, `Caching app (${bundleId}) from binary path: ${binaryPath}`);
const cacheAppPath = this._getAppCachePath(deviceId, bundleId);
await fse.ensureDir(path.dirname(cacheAppPath));
await fse.remove(cacheAppPath);
await fse.copy(binaryPath, cacheAppPath);
}
/**
* Back up an app to cache, if it is not already cached
* @param {string} deviceId - The device identifier
* @param {string} bundleId - The app's bundle identifier
*/
async backup(deviceId, bundleId) {
const appContainerPath = await this.applesimutils.getAppContainer(deviceId, bundleId).catch((reason) => {
throw new DetoxRuntimeError({
message: `App with bundle ID '${bundleId}' is not installed on this device (${deviceId}). Please install the app first before attempting to reset its state.`,
hint: `To check apps installed on the device, use: xcrun simctl listapps ${deviceId}`,
debugInfo: reason,
});
});
if (!await this.exists(deviceId, bundleId)) {
await this.add(deviceId, bundleId, appContainerPath);
}
}
/**
* Restore an app from cache
* @param {string} deviceId - The device identifier
* @param {string} bundleId - The app's bundle identifier
* @returns {Promise<void>}
*/
async restore(deviceId, bundleId) {
log.trace(`Restoring app (${bundleId}) from cache to device (${deviceId})`);
const cacheAppPath = this._getAppCachePath(deviceId, bundleId);
if (!await fse.exists(cacheAppPath)) {
throw new DetoxInternalError(`No backup found for bundle ID '${bundleId}' on device ${deviceId}. This should not happen unless you're calling SimulatorAppCache#restore() directly.`);
}
await this.applesimutils.install(deviceId, cacheAppPath);
}
/**
* Check if an app is cached
* @param {string} deviceId - The device identifier
* @param {string} bundleId - The app's bundle identifier
* @returns {Promise<boolean>} True if the app is cached
*/
async exists(deviceId, bundleId) {
const cacheAppPath = this._getAppCachePath(deviceId, bundleId);
return await fse.exists(cacheAppPath);
}
/**
* Remove cache for a specific app
* @param {string} deviceId - The device identifier
* @param {string} bundleId - The app's bundle identifier
* @returns {Promise<void>}
*/
async remove(deviceId, bundleId) {
try {
const cacheAppPath = this._getAppCachePath(deviceId, bundleId);
if (await fse.remove(cacheAppPath)) {
log.trace({ path: cacheAppPath }, `Removed cached app (${bundleId}) for device (${deviceId})`);
}
} catch (err) {
log.warn({ err }, `Failed to remove app cache for ${bundleId} on device ${deviceId}`);
}
}
/**
* Clean up cache for a device
* @param {string} deviceId - The device identifier
* @returns {Promise<void>}
*/
async cleanup(deviceId) {
try {
const deviceCachePath = this._getDeviceCachePath(deviceId);
if (await fse.remove(deviceCachePath)) {
log.trace({ path: deviceCachePath }, `Cleaned up the app cache for device (${deviceId})`);
}
} catch (err) {
log.warn({ err }, `Failed to cleanup app cache for device ${deviceId}`);
}
this.cleanDeviceIds.add(deviceId);
}
/**
* Clean up cache for a device only once per instance.
* If the device has already been cleaned, this is a no-op.
*
* @param {string} deviceId - The device identifier
* @returns {Promise<void>}
*/
async cleanupOnce(deviceId) {
if (!this.cleanDeviceIds.has(deviceId)) {
await this.cleanup(deviceId);
}
}
/**
* Get the cache path for a specific app on a device
* @param {string} deviceId - The device identifier
* @param {string} bundleId - The app's bundle identifier
* @returns {string} The full path to the cached app
* @private
*/
_getAppCachePath(deviceId, bundleId) {
return path.join(this._getDeviceCachePath(deviceId), `${bundleId}.app`);
}
/**
* Get the device cache directory path
* @param {string} deviceId - The device identifier
* @returns {string} The device cache directory path
* @private
*/
_getDeviceCachePath(deviceId) {
return path.join(this.rootDir, deviceId);
}
}
module.exports = SimulatorAppCache;
@@ -0,0 +1,171 @@
const cp = require('child_process');
jest.mock('../../../../../utils/fsext');
jest.mock('../../../../../utils/logger');
jest.mock('../../../../common/drivers/ios/tools/AppleSimUtils');
/** @type {*} */
const { getDetoxAppsCachePath } = require('../../../../../utils/environment');
/** @type {*} */
const AppleSimUtils = require('../../../../common/drivers/ios/tools/AppleSimUtils');
describe('SimulatorAppCache', () => {
let applesimutils;
let appCache;
let fse;
let SimulatorAppCache;
const deviceId = 'test-device-123';
const bundleId = 'com.test.app';
const mockAppPath = '/mock/app/path';
beforeEach(() => {
applesimutils = new AppleSimUtils();
applesimutils.install.mockResolvedValue();
// Mock fsext functions
fse = jest.requireMock('../../../../../utils/fsext');
fse.ensureDir.mockResolvedValue();
fse.copy.mockResolvedValue();
fse.remove.mockResolvedValue();
SimulatorAppCache = require('./SimulatorAppCache');
appCache = new SimulatorAppCache({ applesimutils });
});
describe('constructor', () => {
it('should use default cache path when rootDir not provided', () => {
expect(appCache.rootDir).toBe(getDetoxAppsCachePath());
});
it('should use custom rootDir when provided', () => {
const customPath = '/custom/cache/path';
const customCache = new SimulatorAppCache({ applesimutils, rootDir: customPath });
expect(customCache.rootDir).toBe(customPath);
});
});
describe('add', () => {
const binaryPath = '/path/to/app/binary.app';
it('should successfully add app to cache from binary path', async () => {
await appCache.add(deviceId, bundleId, binaryPath);
expect(fse.ensureDir).toHaveBeenCalled();
expect(fse.remove).toHaveBeenCalledWith(expect.stringContaining(`${bundleId}.app`));
expect(fse.copy).toHaveBeenCalledWith(binaryPath, expect.stringContaining(`${bundleId}.app`));
});
it('should remove existing cache before copying', async () => {
await appCache.add(deviceId, bundleId, binaryPath);
expect(fse.remove).toHaveBeenCalledWith(expect.stringContaining(`${bundleId}.app`));
expect(fse.copy).toHaveBeenCalledWith(binaryPath, expect.stringContaining(`${bundleId}.app`));
});
});
describe('backup', () => {
beforeEach(() => {
applesimutils.getAppContainer.mockResolvedValue(mockAppPath);
});
it('should successfully back up app to cache', async () => {
await appCache.backup(deviceId, bundleId);
expect(applesimutils.getAppContainer).toHaveBeenCalledWith(deviceId, bundleId);
expect(fse.ensureDir).toHaveBeenCalled();
expect(fse.copy).toHaveBeenCalledWith(mockAppPath, expect.stringContaining(`${bundleId}.app`));
});
it('should remove existing cache before copying', async () => {
await appCache.backup(deviceId, bundleId);
expect(fse.remove).toHaveBeenCalledWith(expect.stringContaining(`${bundleId}.app`));
expect(fse.copy).toHaveBeenCalled();
});
it('should throw error when app not found on device', async () => {
applesimutils.getAppContainer.mockImplementation(async () => cp.execSync('node -e process.exitCode=1'));
await expect(appCache.backup(deviceId, bundleId)).rejects.toMatchSnapshot();
});
});
describe('restore', () => {
it('should successfully restore app from cache', async () => {
fse.exists.mockResolvedValue(true);
await appCache.restore(deviceId, bundleId);
expect(fse.exists).toHaveBeenCalledWith(expect.stringContaining(`${bundleId}.app`));
expect(applesimutils.install).toHaveBeenCalledWith(deviceId, expect.stringContaining(`${bundleId}.app`));
});
it('should throw error when cache not found', async () => {
fse.exists.mockResolvedValue(false);
await expect(appCache.restore(deviceId, bundleId)).rejects.toMatchSnapshot();
});
});
describe('exists', () => {
it('should return true when app exists in cache', async () => {
fse.exists.mockResolvedValue(true);
const result = await appCache.exists(deviceId, bundleId);
expect(fse.exists).toHaveBeenCalledWith(expect.stringContaining(`${bundleId}.app`));
expect(result).toBe(true);
});
it('should return false when app does not exist in cache', async () => {
fse.exists.mockResolvedValue(false);
const result = await appCache.exists(deviceId, bundleId);
expect(result).toBe(false);
});
});
describe('remove', () => {
it('should remove app from cache when it exists', async () => {
await appCache.remove(deviceId, bundleId);
expect(fse.remove).toHaveBeenCalledWith(expect.stringContaining(`${bundleId}.app`));
});
it('should handle removal errors gracefully', async () => {
fse.remove.mockRejectedValue(new Error('Removal failed'));
await expect(appCache.remove(deviceId, bundleId)).resolves.not.toThrow();
});
});
describe('cleanup', () => {
it('should clean up entire device cache', async () => {
await appCache.cleanup(deviceId);
expect(fse.remove).toHaveBeenCalledWith(expect.stringContaining(deviceId));
});
it('should handle cleanup errors gracefully', async () => {
fse.remove.mockRejectedValue(new Error('Cleanup failed'));
await expect(appCache.cleanup(deviceId)).resolves.not.toThrow();
});
});
describe('cleanupOnce', () => {
it('should clean up device cache on first call', async () => {
await appCache.cleanupOnce(deviceId);
expect(fse.remove).toHaveBeenCalled();
});
it('should not clean up device cache on subsequent calls', async () => {
await appCache.cleanupOnce(deviceId);
await appCache.cleanupOnce(deviceId);
expect(fse.remove).toHaveBeenCalledTimes(1);
});
});
});
@@ -0,0 +1,15 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`SimulatorAppCache backup should throw error when app not found on device 1`] = `
[DetoxRuntimeError: App with bundle ID 'com.test.app' is not installed on this device (test-device-123). Please install the app first before attempting to reset its state.
HINT: To check apps installed on the device, use: xcrun simctl listapps test-device-123
Command failed: node -e process.exitCode=1]
`;
exports[`SimulatorAppCache restore should throw error when cache not found 1`] = `
[DetoxInternalError: No backup found for bundle ID 'com.test.app' on device test-device-123. This should not happen unless you're calling SimulatorAppCache#restore() directly.
Please report this issue on our GitHub tracker:
https://github.com/wix/Detox/issues]
`;
+10 -1
View File
@@ -31,6 +31,7 @@ class RuntimeDevice {
'pressBack',
'relaunchApp',
'reloadReactNative',
'resetAppState',
'resetContentAndSettings',
'resetStatusBar',
'reverseTcpPort',
@@ -119,7 +120,10 @@ class RuntimeDevice {
? params.newInstance
: this._processes[bundleId] == null;
if (params.delete) {
if (params.resetAppState) {
await this.terminateApp(bundleId);
await this.resetAppState(bundleId);
} else if (params.delete) {
await this.terminateApp(bundleId);
await this.uninstallApp();
await this.installApp();
@@ -261,6 +265,11 @@ class RuntimeDevice {
await this.deviceDriver.uninstallApp(_bundleId);
}
async resetAppState(...bundleIds) {
const _bundleIds = bundleIds.length > 0 ? bundleIds : [this._bundleId];
await this.deviceDriver.resetAppState(..._bundleIds);
}
async installUtilBinaries() {
const paths = this._deviceConfig.utilBinaryPaths;
if (paths) {
@@ -347,7 +347,7 @@ describe('Device', () => {
it(`(relaunch) with delete=true`, async () => {
const expectedArgs = expectedDriverArgs;
const device = await aValidDevice();
const device = await aValidDevice({});
await device.relaunchApp({ delete: true });
@@ -355,6 +355,18 @@ describe('Device', () => {
driverMock.expectLaunchCalledWithArgs(bundleId, expectedArgs);
});
it(`(relaunch) with resetAppState=true`, async () => {
const expectedArgs = expectedDriverArgs;
const device = await aValidDevice({});
await device.relaunchApp({ resetAppState: true });
expect(driverMock.driver.resetAppState).toHaveBeenCalledWith(bundleId);
expect(driverMock.driver.uninstallApp).not.toHaveBeenCalled();
expect(driverMock.driver.installApp).not.toHaveBeenCalled();
driverMock.expectLaunchCalledWithArgs(bundleId, expectedArgs);
});
it(`(relaunch) with delete=false when reuse is enabled should not uninstall and install`, async () => {
const expectedArgs = expectedDriverArgs;
const device = await aValidDevice();
@@ -1022,4 +1034,25 @@ describe('Device', () => {
return driverMock.driver.installApp.mock.calls[0][0];
}
it('should call resetAppState on the driver', async () => {
const device = await aValidDevice();
await device.resetAppState();
expect(driverMock.driver.resetAppState).toHaveBeenCalledWith(bundleId);
});
it('should call resetAppState with custom bundleId', async () => {
const device = await aValidDevice();
const customBundleId = 'com.custom.app';
await device.resetAppState(customBundleId);
expect(driverMock.driver.resetAppState).toHaveBeenCalledWith(customBundleId);
});
it('should call resetAppState with multiple bundleIds', async () => {
const device = await aValidDevice();
const bundleId1 = 'com.app1';
const bundleId2 = 'com.app2';
await device.resetAppState(bundleId1, bundleId2);
expect(driverMock.driver.resetAppState).toHaveBeenCalledWith(bundleId1, bundleId2);
});
});
@@ -91,6 +91,10 @@ class DeviceDriverBase {
return '';
}
async resetAppState(..._bundleIds) {
return undefined;
}
async uninstallApp() {
return '';
}
@@ -84,6 +84,12 @@ class AndroidDriver extends DeviceDriverBase {
await this.appUninstallHelper.uninstall(this.adbName, bundleId);
}
async resetAppState(...bundleIds) {
for (const bundleId of bundleIds) {
await this.adb.clearAppData(this.adbName, bundleId);
}
}
async installUtilBinaries(paths) {
for (const path of paths) {
const packageId = await this.getBundleIdFromBinary(path);
@@ -481,6 +481,23 @@ describe('Android driver', () => {
});
});
describe('resetAppState', () => {
it('should clear app data for a single bundleId', async () => {
await uut.resetAppState(bundleId);
expect(adb.clearAppData).toHaveBeenCalledWith(adbName, bundleId);
});
it('should clear app data for multiple bundleIds', async () => {
const bundleId1 = 'com.example.app1';
const bundleId2 = 'com.example.app2';
await uut.resetAppState(bundleId1, bundleId2);
expect(adb.clearAppData).toHaveBeenCalledWith(adbName, bundleId1);
expect(adb.clearAppData).toHaveBeenCalledWith(adbName, bundleId2);
});
});
describe('text-typing (global)', () => {
const text = 'text to type';
@@ -0,0 +1,69 @@
const SimulatorAppCache = require('../../../common/drivers/ios/tools/SimulatorAppCache');
/**
* AppStateResetFallback provides app state reset functionality for iOS simulators.
*
* This class serves as a fallback implementation because iOS does not provide a native
* way to reset an app's state (unlike Android's `pm clear` command). To achieve app
* state reset on iOS, this class implements a backup-uninstall-reinstall strategy:
*
* 1. Backs up the app installation to cache
* 2. Uninstalls the current app instance
* 3. Restores the app installation from cache
*
* This approach ensures the app is returned to a clean state without requiring
* a full app reinstallation from the original bundle, making it more friendly
* to local development flows.
*
* @class AppStateResetFallback
*/
class AppStateResetFallback {
/**
* Creates an instance of AppStateResetFallback.
* @param {Object} config
* @param {import('../../../common/drivers/ios/tools/AppleSimUtils')} config.applesimutils - AppleSimUtils instance
* @param {SimulatorAppCache} [config.appCache] - Optional SimulatorAppCache instance (for testing/mocking)
*/
constructor({ applesimutils, appCache }) {
this.applesimutils = applesimutils;
this.appCache = appCache ?? new SimulatorAppCache({ applesimutils });
}
/**
* Resets the app state for multiple apps by backing them up and restoring them.
* This effectively clears the app state while preserving the app installation.
*
* @param {string} udid - The device identifier
* @param {string[]} bundleIds - Array of app bundle identifiers to reset
* @returns {Promise<void>}
*/
async resetAppState(udid, bundleIds) {
for (const bundleId of bundleIds) {
await this.appCache.backup(udid, bundleId);
await this.applesimutils.uninstall(udid, bundleId);
}
for (const bundleId of bundleIds) {
await this.appCache.restore(udid, bundleId);
}
}
/**
* Ensures removal of cached app(s) after a potentially
* destructive action (such as install or uninstall),
* so {@link resetAppState} can't use a stale backup from cache.
*
* @param {string} udid - The device identifier
* @param {string} [bundleId] - Optional bundle identifier for specific app invalidation
* @returns {Promise<void>}
*/
async invalidate(udid, bundleId) {
if (bundleId) {
await this.appCache.remove(udid, bundleId);
} else {
await this.appCache.cleanup(udid);
}
}
}
module.exports = AppStateResetFallback;
@@ -0,0 +1,83 @@
// @ts-nocheck
jest.mock('../../../common/drivers/ios/tools/AppleSimUtils');
jest.mock('../../../common/drivers/ios/tools/SimulatorAppCache');
describe('AppStateResetFallback', () => {
let AppStateResetFallback;
let appStateResetFallback;
let AppleSimUtils;
let applesimutils;
let SimulatorAppCache;
let simulatorAppCache;
beforeEach(() => {
AppleSimUtils = jest.requireMock('../../../common/drivers/ios/tools/AppleSimUtils');
applesimutils = new AppleSimUtils();
applesimutils.uninstall.mockResolvedValue();
SimulatorAppCache = jest.requireMock('../../../common/drivers/ios/tools/SimulatorAppCache');
simulatorAppCache = new SimulatorAppCache({ applesimutils });
simulatorAppCache.backup.mockResolvedValue();
simulatorAppCache.restore.mockResolvedValue();
AppStateResetFallback = require('./AppStateResetFallback');
appStateResetFallback = new AppStateResetFallback({ applesimutils, appCache: simulatorAppCache });
});
it('should create SimulatorAppCache with applesimutils', () => {
expect(() => new AppStateResetFallback({ applesimutils })).not.toThrow();
expect(SimulatorAppCache).toHaveBeenCalledWith({ applesimutils });
});
it('should reset app state by backing up then restoring all apps', async () => {
const udid = 'test-device-123';
const bundleIds = ['com.app1', 'com.app2'];
await appStateResetFallback.resetAppState(udid, bundleIds);
expect(simulatorAppCache.backup).toHaveBeenCalledTimes(2);
expect(simulatorAppCache.backup).toHaveBeenCalledWith(udid, 'com.app1');
expect(simulatorAppCache.backup).toHaveBeenCalledWith(udid, 'com.app2');
expect(applesimutils.uninstall).toHaveBeenCalledTimes(2);
expect(applesimutils.uninstall).toHaveBeenCalledWith(udid, 'com.app1');
expect(applesimutils.uninstall).toHaveBeenCalledWith(udid, 'com.app2');
expect(simulatorAppCache.restore).toHaveBeenCalledTimes(2);
expect(simulatorAppCache.restore).toHaveBeenCalledWith(udid, 'com.app1');
expect(simulatorAppCache.restore).toHaveBeenCalledWith(udid, 'com.app2');
});
it('should handle empty bundle IDs array', async () => {
await appStateResetFallback.resetAppState('udid', []);
expect(simulatorAppCache.backup).not.toHaveBeenCalled();
expect(applesimutils.uninstall).not.toHaveBeenCalled();
expect(simulatorAppCache.restore).not.toHaveBeenCalled();
});
describe('invalidate', () => {
const udid = 'test-device-123';
const bundleId = 'com.test.app';
const binaryPath = '/path/to/app/binary.app';
beforeEach(() => {
simulatorAppCache.remove.mockResolvedValue();
simulatorAppCache.cleanup.mockResolvedValue();
});
it('should invalidate specific app cache when bundleId is provided', async () => {
await appStateResetFallback.invalidate(udid, bundleId);
expect(simulatorAppCache.remove).toHaveBeenCalledWith(udid, bundleId);
expect(simulatorAppCache.cleanup).not.toHaveBeenCalled();
});
it('should invalidate entire device cache when bundleId is not provided', async () => {
await appStateResetFallback.invalidate(udid);
expect(simulatorAppCache.cleanup).toHaveBeenCalledWith(udid);
expect(simulatorAppCache.remove).not.toHaveBeenCalled();
});
});
});
@@ -15,6 +15,7 @@ const log = require('../../../../utils/logger').child({ cat: 'device' });
const pressAnyKey = require('../../../../utils/pressAnyKey');
const traceInvocationCall = require('../../../../utils/traceInvocationCall').bind(null, log);
const AppStateResetFallback = require('./AppStateResetFallback');
const IosDriver = require('./IosDriver');
/**
@@ -37,12 +38,15 @@ class SimulatorDriver extends IosDriver {
constructor(deps, { udid, type, bootArgs, headless }) {
super(deps);
this.getBundleIdFromBinary = _.memoize(this.getBundleIdFromBinary.bind(this));
this.udid = udid;
this._type = type;
this._bootArgs = bootArgs;
this._headless = headless;
this._deviceName = `${udid} (${this._type})`;
this._applesimutils = deps.applesimutils;
this._appStateResetFallback = new AppStateResetFallback({ applesimutils: this._applesimutils });
}
withAction(xcuitestRunner, action, traceDescription, ...params) {
@@ -80,13 +84,24 @@ class SimulatorDriver extends IosDriver {
}
async installApp(binaryPath) {
await this._applesimutils.install(this.udid, getAbsoluteBinaryPath(binaryPath));
const absoluteBinaryPath = getAbsoluteBinaryPath(binaryPath);
await this._applesimutils.install(this.udid, absoluteBinaryPath);
const bundleId = await this.getBundleIdFromBinary(binaryPath);
await this._appStateResetFallback.invalidate(this.udid, bundleId);
}
async uninstallApp(bundleId) {
const { udid } = this;
await this.emitter.emit('beforeUninstallApp', { deviceId: udid, bundleId });
await this._applesimutils.uninstall(udid, bundleId);
await this._appStateResetFallback.invalidate(udid, bundleId);
}
async resetAppState(...bundleIds) {
const { udid } = this;
const _bundleIds = bundleIds.length > 0 ? bundleIds : [this._bundleId];
await this._appStateResetFallback.resetAppState(udid, _bundleIds);
}
async launchApp(bundleId, launchArgs, languageAndLocale) {
@@ -186,6 +201,7 @@ class SimulatorDriver extends IosDriver {
await this._applesimutils.shutdown(this.udid);
await this.emitter.emit('shutdownDevice', { deviceId: this.udid });
await this._applesimutils.resetContentAndSettings(this.udid);
await this._appStateResetFallback.invalidate(this.udid);
await this._applesimutils.boot(this.udid, this._bootArgs, this._headless);
await this.emitter.emit('bootDevice', { deviceId: this.udid });
}
@@ -1,6 +1,7 @@
jest.mock('../../../../ios/XCUITestRunner');
// @ts-nocheck
jest.mock('../../../../ios/XCUITestRunner');
jest.mock('./AppStateResetFallback');
describe('IOS simulator driver', () => {
const udid = 'UD-1D-MOCK';
const type = 'Chika';
@@ -12,6 +13,9 @@ describe('IOS simulator driver', () => {
let eventEmitter;
let applesimutils;
let uut;
let AppStateResetFallbackMock;
let appStateResetFallbackInstance;
beforeEach(() => {
const AsyncEmitter = require('../../../../utils/AsyncEmitter');
eventEmitter = new AsyncEmitter({
@@ -25,6 +29,10 @@ describe('IOS simulator driver', () => {
const AppleSimUtils = jest.createMockFromModule('../../../common/drivers/ios/tools/AppleSimUtils');
applesimutils = new AppleSimUtils();
AppStateResetFallbackMock = jest.requireMock('./AppStateResetFallback');
appStateResetFallbackInstance = new AppStateResetFallbackMock();
AppStateResetFallbackMock.mockImplementation(() => appStateResetFallbackInstance);
const SimulatorDriver = require('./SimulatorDriver');
uut = new SimulatorDriver(
{ applesimutils, client, eventEmitter },
@@ -191,6 +199,29 @@ describe('IOS simulator driver', () => {
});
});
describe('.installApp', () => {
it('should install via AppleSimUtils and invalidate cache with bundleId and binary path', async () => {
const binaryPath = '/tmp/MyApp.app';
const resolvedBundleId = 'com.example.myapp';
uut.getBundleIdFromBinary = jest.fn().mockResolvedValue(resolvedBundleId);
await uut.installApp(binaryPath);
expect(applesimutils.install).toHaveBeenCalledWith(udid, binaryPath);
expect(appStateResetFallbackInstance.invalidate).toHaveBeenCalledWith(udid, resolvedBundleId);
});
});
describe('.uninstallApp', () => {
it('should uninstall via AppleSimUtils and invalidate specific app cache', async () => {
await uut.uninstallApp(bundleId);
expect(applesimutils.uninstall).toHaveBeenCalledWith(udid, bundleId);
expect(appStateResetFallbackInstance.invalidate).toHaveBeenCalledWith(udid, bundleId);
});
});
describe('.resetContentAndSettings', () => {
it('should shut the device down', async () => {
await uut.resetContentAndSettings();
@@ -206,6 +237,11 @@ describe('IOS simulator driver', () => {
await uut.resetContentAndSettings();
expect(applesimutils.boot).toHaveBeenCalledWith(udid, bootArgs, true);
});
it('should invalidate the apps cache', async () => {
await uut.resetContentAndSettings();
expect(appStateResetFallbackInstance.invalidate).toHaveBeenCalledWith(udid);
});
});
describe('.captureViewHierarchy', () => {
@@ -246,19 +282,40 @@ describe('IOS simulator driver', () => {
expect(applesimutils.matchBiometric).toHaveBeenCalledWith(udid, 'Face');
});
it('fails to match a face by passing to AppleSimUtils', async () => {
it('unmatches a face by passing to AppleSimUtils', async () => {
await uut.unmatchFace();
expect(applesimutils.unmatchBiometric).toHaveBeenCalledWith(udid, 'Face');
});
it('matches a face by passing to AppleSimUtils', async () => {
it('matches a finger by passing to AppleSimUtils', async () => {
await uut.matchFinger();
expect(applesimutils.matchBiometric).toHaveBeenCalledWith(udid, 'Finger');
});
it('fails to match a face by passing to AppleSimUtils', async () => {
it('unmatches a finger by passing to AppleSimUtils', async () => {
await uut.unmatchFinger();
expect(applesimutils.unmatchBiometric).toHaveBeenCalledWith(udid, 'Finger');
});
});
describe('.resetAppState', () => {
it('should reset app state using the shim', async () => {
await uut.resetAppState(bundleId);
expect(appStateResetFallbackInstance.resetAppState).toHaveBeenCalledWith(udid, [bundleId]);
});
it('should use default bundleId when none provided', async () => {
uut._bundleId = 'default.bundle';
await uut.resetAppState();
expect(appStateResetFallbackInstance.resetAppState).toHaveBeenCalledWith(udid, ['default.bundle']);
});
it('should handle multiple bundleIds', async () => {
const bundleId1 = 'com.app1';
const bundleId2 = 'com.app2';
await uut.resetAppState(bundleId1, bundleId2);
expect(appStateResetFallbackInstance.resetAppState).toHaveBeenCalledWith(udid, [bundleId1, bundleId2]);
});
});
});
+12
View File
@@ -20,6 +20,7 @@ function which(executable, path) {
const DETOX_LIBRARY_ROOT_PATH = path.join(appdatapath.appDataPath(), 'Detox');
const MISSING_SDK_ERROR = `$ANDROID_SDK_ROOT is not defined, set the path to the SDK installation directory into $ANDROID_SDK_ROOT,
Go to https://developer.android.com/studio/command-line/variables.html for more details`;
const DETOX_APPS_CACHE_PATH = path.join(DETOX_LIBRARY_ROOT_PATH, 'apps-cache');
const DETOX_LOCK_FILE_PATH = path.join(DETOX_LIBRARY_ROOT_PATH, 'global-context.json');
const DEVICE_REGISTRY_PATH = path.join(DETOX_LIBRARY_ROOT_PATH, 'device.registry.json');
const LAST_FAILED_TESTS_PATH = path.join(DETOX_LIBRARY_ROOT_PATH, 'last-failed.txt');
@@ -217,6 +218,16 @@ function getDetoxLibraryRootPath() {
return DETOX_LIBRARY_ROOT_PATH;
}
function getDetoxAppsCachePath(udid, bundleId) {
if (udid && bundleId) {
return path.join(DETOX_APPS_CACHE_PATH, udid, bundleId);
} else if (udid) {
return path.join(DETOX_APPS_CACHE_PATH, udid);
} else {
return DETOX_APPS_CACHE_PATH;
}
}
function getDetoxLockFilePath() {
return DETOX_LOCK_FILE_PATH;
}
@@ -249,6 +260,7 @@ module.exports = {
getXCUITestRunnerPath,
getAndroidSDKPath,
getAndroidEmulatorPath,
getDetoxAppsCachePath,
getDetoxLibraryRootPath,
getDetoxLockFilePath,
getDeviceRegistryPath,
+13
View File
@@ -19,8 +19,21 @@ async function getDirectories (rootPath) {
return dirs.sort();
}
async function remove(filePath) {
if (await fs.exists(filePath)) {
await fs.remove(filePath);
return true;
}
return false;
}
module.exports = {
copy: fs.copy,
ensureDir: fs.ensureDir,
exists: fs.exists,
getDirectories,
isDirEmptySync,
readdirSync: fs.readdirSync,
remove,
};
+18
View File
@@ -5,6 +5,17 @@ const fs = require('fs-extra');
const fsext = require('./fsext');
const tempfile = require('./tempfile');
test('copy', () => {
expect(fsext.copy).toBe(fs.copy);
});
test('ensureDir', () => {
expect(fsext.ensureDir).toBe(fs.ensureDir);
});
test('exists', () => {
expect(fsext.exists).toBe(fs.exists);
});
test('isDirEmptySync', async () => {
const tempDir = tempfile();
@@ -24,3 +35,10 @@ test('isDirEmptySync', async () => {
test('readdirSync', async () => {
expect(fsext.readdirSync).toBe(fs.readdirSync);
});
test('remove', async () => {
const tempDir = tempfile();
await fs.ensureDir(tempDir);
expect(await fsext.remove(tempDir)).toBe(true);
expect(await fsext.remove(tempDir)).toBe(false);
});
+1 -1
View File
@@ -13,7 +13,7 @@ describe('tempfile', () => {
const path = require('node:path');
const expectedFile = path.join(tmpdir, `detox-${process.pid}-[a-zA-Z0-9]+.log`).replace(/\\/g, '\\\\');
const expectedResult = new RegExp(`^${expectedFile}$`);
const expectedResult = new RegExp(`^(?:/private)?${expectedFile}$`);
const result = tempfile('.log');
expect(result).toMatch(expectedResult);
+17
View File
@@ -29,6 +29,23 @@ describe('Device', () => {
await expect(element(by.text('Hello!!!'))).toBeVisible();
});
it('resetAppState() + relaunch() - should tap successfully', async () => {
await device.resetAppState();
await device.relaunchApp();
await element(by.text('Sanity')).tap();
await element(by.text('Say Hello')).tap();
await expect(element(by.text('Hello!!!'))).toBeVisible();
});
it.failing('uninstall() + resetAppState() - should fail', async () => {
try {
await device.uninstallApp();
await device.resetAppState();
} finally {
await device.installApp();
}
});
it('launchApp({newInstance: true}) + sendToHome() + launchApp() - should bring up previous instance', async () => {
await device.launchApp({newInstance: true});
await element(by.text('Sanity')).tap();
+1
View File
@@ -17,6 +17,7 @@ describe("Test", () => {
});
beforeAll(async () => {
await device.launchApp({ resetAppState: true });
await device.reloadReactNative();
await device.setStatusBar({ time: "12:34" });
+58 -27
View File
@@ -104,24 +104,24 @@ Please make sure you have the most recent version of both tools installed, since
##### Supported Permissions
| Permission | Values | Notes |
|------------|-------------------------|---------------------------------------------------------------|
| location | always / inuse / never / unset | inuse - provides location access only when the app is in use |
| contacts | YES / NO / unset / limited | limited - grants limited access to contacts |
| photos | YES / NO / unset / limited | limited - grants limited access to photos |
| calendar | YES / NO / unset | |
| camera | YES / NO / unset | |
| medialibrary | YES / NO / unset | |
| microphone | YES / NO / unset | |
| motion | YES / NO / unset | |
| reminders | YES / NO / unset | |
| siri | YES / NO / unset | |
| notifications | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| health | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| homekit | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| speech | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| faceid | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| userTracking | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| Permission | Values | Notes |
| ------------- | ------------------------------ | ------------------------------------------------------------ |
| location | always / inuse / never / unset | inuse - provides location access only when the app is in use |
| contacts | YES / NO / unset / limited | limited - grants limited access to contacts |
| photos | YES / NO / unset / limited | limited - grants limited access to photos |
| calendar | YES / NO / unset | |
| camera | YES / NO / unset | |
| medialibrary | YES / NO / unset | |
| microphone | YES / NO / unset | |
| motion | YES / NO / unset | |
| reminders | YES / NO / unset | |
| siri | YES / NO / unset | |
| notifications | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| health | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| homekit | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| speech | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| faceid | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
| userTracking | YES / NO / unset | Requires AppleSimUtils; unsupported by simctl |
Check Detox's [own test suite](https://github.com/wix/Detox/blob/master/detox/test/e2e/13.permissions.test.js) for usage examples.
@@ -161,17 +161,27 @@ await device.launchApp({userActivity: activity, newInstance: false}); //Reuse ex
Read more in [here](../guide/mocking-user-activity.md). Go back to subsection 1 to read about the full effect of the `newInstance` argument.
#### 6. `delete`—Delete and Reinstall Application Before Launching
#### 6. `resetAppState`—Reset App State Before Launching
Before launching the application, it is uninstalled and then reinstalled.
Resets the app’s state and launches without necessarily performing a full uninstall/reinstall cycle.
A flag that enables relaunching into a fresh installation of the app (it will uninstall and install the binary). Default is `false`.
- Android: clears app data using `pm clear`, which is typically faster than reinstalling.
- iOS: uninstalls and reinstalls the app to restore a clean state.
```js
await device.launchApp({resetAppState: true});
```
#### 7. `delete`—Delete and Reinstall Application Before Launching
Uninstalls the app and then reinstalls it before launching. Slower than `resetAppState`,
but guarantees a completely fresh install of the app and all its data.
```js
await device.launchApp({delete: true});
```
#### 7. `launchArgs`—Additional Process Launch Arguments
#### 8. `launchArgs`—Additional Process Launch Arguments
Launches the app on the device with on-site, user-specified launch arguments:
@@ -186,7 +196,7 @@ await device.launchApp({
This is the most explicit and straightforward way of setting launch arguments. Refer to the [launch arguments guide](../guide/launch-args.md) for a complete overview on app launch arguments.
#### 8. `disableTouchIndicators`—Disable Touch Indicators (iOS Only)
#### 9. `disableTouchIndicators`—Disable Touch Indicators (iOS Only)
Disables touch indicators on iOS. Default is `false`.
@@ -194,7 +204,7 @@ Disables touch indicators on iOS. Default is `false`.
await device.launchApp({disableTouchIndicators: true});
```
#### 9. `languageAndLocale`—Launch with a Specific Language and/or Local (iOS Only)
#### 10. `languageAndLocale`—Launch with a Specific Language and/or Local (iOS Only)
Launch the app with a specific system language
@@ -231,7 +241,7 @@ describe.each([
});
```
#### 10. `detoxEnableSynchronization`—Initialize Detox with synchronization enabled or disabled at app launch
#### 11. `detoxEnableSynchronization`—Initialize Detox with synchronization enabled or disabled at app launch
Launches the app with the synchronization mechanism enabled or disabled. Useful if the app cannot be synchronized during the launch process. Synchronization can later be enabled using `device.enableSynchronization()`.
@@ -242,7 +252,7 @@ await device.launchApp({
});
```
#### 11. `detoxURLBlacklistRegex`—Initialize the URL Blacklist at app launch
#### 12. `detoxURLBlacklistRegex`—Initialize the URL Blacklist at app launch
Launches the app with a URL blacklist to disable network synchronization on certain endpoints.
Useful if the app makes frequent network calls to blacklisted endpoints upon startup.
@@ -257,7 +267,7 @@ await device.launchApp({
});
```
#### 12. `detoxDisableWebKitSecurity`—Disable WebKit Security (iOS Only)
#### 13. `detoxDisableWebKitSecurity`—Disable WebKit Security (iOS Only)
Disables WebKit security on iOS. Default is `false`.
@@ -326,6 +336,8 @@ To uninstall another app, specify its bundle id
await device.uninstallApp('other.bundle.id');
```
**Note:** For app state reset operations, consider using [`device.resetAppState()`](#deviceresetappstatebundleids) which is more efficient than uninstall/install cycles.
### `device.openURL({url, sourceApp[optional]})`
Mock opening the app from URL. `sourceApp` is an optional **iOS-only** parameter to specify source application bundle id.
@@ -397,6 +409,25 @@ Exclude synchronization with respect to network activity (i.e. don’t wait for
await device.setURLBlacklist(['.*127.0.0.1.*', '.*my.ignored.endpoint.*']);
```
### `device.resetAppState([...bundleIds])`
Resets the app state by clearing app data and restoring it to a clean state.
On Android, this command clears the app's data using the `pm clear` command, effectively resetting the app to its initial installed state without uninstalling it.
On iOS, Detox uses a fallback that uninstalls and installs your app again to achieve a clean state.
```js
// Reset current app state
await device.resetAppState();
// Reset specific app state
await device.resetAppState('com.example.app');
// Reset multiple apps
await device.resetAppState('com.app1', 'com.app2');
```
### `device.resetContentAndSettings()` **iOS Only**
Resets the Simulator to clean state (like the Simulator > Reset Content and Settings... menu item), especially removing