diff --git a/.env.sample b/.env.sample index eeee7ecf7..5681ce4e2 100644 --- a/.env.sample +++ b/.env.sample @@ -7,18 +7,6 @@ AVD_MANAGER_FULL_PATH=/home/yougotthis/Android/Sdk/cmdline-tools/latest/bin/avdm EMULATOR_FULL_PATH=/home/yougotthis/Android/Sdk/emulator/emulator ANDROID_SYSTEM_IMAGE="system-images;android-35;google_atd;x86_64" APPIUM_ADB_FULL_PATH=/home/yougotthis/Android/sdk/platform-tools/adb -IOS_1_SIMULATOR=just_not_empty -IOS_2_SIMULATOR=just_not_empty -IOS_3_SIMULATOR=just_not_empty -IOS_4_SIMULATOR=just_not_empty -IOS_5_SIMULATOR=just_not_empty -IOS_6_SIMULATOR=just_not_empty -IOS_7_SIMULATOR=just_not_empty -IOS_8_SIMULATOR=just_not_empty -IOS_9_SIMULATOR=just_not_empty -IOS_10_SIMULATOR=just_not_empty -IOS_11_SIMULATOR=just_not_empty -IOS_12_SIMULATOR=just_not_empty PRINT_TEST_LOGS=true PRINT_ONGOING_TEST_LOGS = 1 PRINT_FAILED_TEST_LOGS=1 diff --git a/.github/workflows/android-regression.yml b/.github/workflows/android-regression.yml index b19dbcb3e..ac5d80671 100644 --- a/.github/workflows/android-regression.yml +++ b/.github/workflows/android-regression.yml @@ -71,18 +71,6 @@ jobs: _TESTING: 1 # Always hide webdriver logs (@appium/support/ flag) PRINT_FAILED_TEST_LOGS: ${{ github.event.inputs.LOG_LEVEL != 'minimal' && '1' || '0' }} # Show stdout/stderr if test fails (@session-foundation/playwright-reporter/ flag) PRINT_ONGOING_TEST_LOGS: ${{ github.event.inputs.LOG_LEVEL == 'verbose' && '1' || '0' }} # Show everything as it happens (@session-foundation/playwright-reporter/ flag) - IOS_1_SIMULATOR: '' - IOS_2_SIMULATOR: '' - IOS_3_SIMULATOR: '' - IOS_4_SIMULATOR: '' - IOS_5_SIMULATOR: '' - IOS_6_SIMULATOR: '' - IOS_7_SIMULATOR: '' - IOS_8_SIMULATOR: '' - IOS_9_SIMULATOR: '' - IOS_10_SIMULATOR: '' - IOS_11_SIMULATOR: '' - IOS_12_SIMULATOR: '' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ios-regression.yml b/.github/workflows/ios-regression.yml index 65002a275..e0f415a1e 100644 --- a/.github/workflows/ios-regression.yml +++ b/.github/workflows/ios-regression.yml @@ -86,18 +86,6 @@ jobs: AVD_MANAGER_FULL_PATH: '' ANDROID_SYSTEM_IMAGE: '' EMULATOR_FULL_PATH: '' - IOS_1_SIMULATOR: '4A75A0E1-9EDE-4169-93C3-DCE0F0C7664F' - IOS_2_SIMULATOR: 'ACB6A587-8556-4EA0-87CF-4326A9A22051' - IOS_3_SIMULATOR: 'D90B2AE2-FF30-49BE-9370-B789BAEED3BB' - IOS_4_SIMULATOR: '59BD1CA4-7A8D-40FB-BAC7-CC99500644E0' - IOS_5_SIMULATOR: '064F4F80-B81C-4B72-9715-43CD18975139' - IOS_6_SIMULATOR: '56D8BA2F-BA0C-4D8F-8E5B-FD928E2C7C66' - IOS_7_SIMULATOR: '012D6656-D6DE-4932-A460-72F5629EB2E0' - IOS_8_SIMULATOR: 'D66CBD9C-7550-4055-8504-95F0AE700617' - IOS_9_SIMULATOR: '84884861-F8EF-4481-A001-B403F2649FCF' - IOS_10_SIMULATOR: 'C0EE6A21-044D-4B6E-B9A5-7AB977ADF305' - IOS_11_SIMULATOR: 'B8E78B21-1432-41F3-A398-DE4FF8CF9DED' - IOS_12_SIMULATOR: '8214A3A2-D4E1-4AA8-BB0F-394E3A49BCFA' steps: - uses: actions/checkout@v4 diff --git a/.prettierignore b/.prettierignore index 405542119..a45fac514 100644 --- a/.prettierignore +++ b/.prettierignore @@ -31,4 +31,5 @@ test-results/ README.md package.json /avd/ -run/localizer/* \ No newline at end of file +run/localizer/* +ci-simulators.json \ No newline at end of file diff --git a/ci-simulators.json b/ci-simulators.json new file mode 100644 index 000000000..1b9e9aff8 --- /dev/null +++ b/ci-simulators.json @@ -0,0 +1,62 @@ +[ + { + "name": "Auto-16PM-0", + "udid": "4D0EDB6B-9517-4E9F-AD80-4853604401FB", + "wdaPort": 1253 + }, + { + "name": "Auto-16PM-1", + "udid": "145BA489-0AAB-473F-9238-57C8AD75576A", + "wdaPort": 1254 + }, + { + "name": "Auto-16PM-2", + "udid": "6F8F94E6-5623-4C8C-88A0-DAE34F343BCE", + "wdaPort": 1255 + }, + { + "name": "Auto-16PM-3", + "udid": "5CFFE21B-26BE-4636-99FE-B5D7B8DC76C4", + "wdaPort": 1256 + }, + { + "name": "Auto-16PM-4", + "udid": "570FEA9F-AFC2-4CCE-B637-290D0EE290C4", + "wdaPort": 1257 + }, + { + "name": "Auto-16PM-5", + "udid": "09D47861-AF97-4D56-9DC1-9839168AA3CA", + "wdaPort": 1258 + }, + { + "name": "Auto-16PM-6", + "udid": "3C7A031A-3224-40A9-86C7-BE64B8B6E0A2", + "wdaPort": 1259 + }, + { + "name": "Auto-16PM-7", + "udid": "BA458AF8-C3F9-41E7-8B76-61157EA5EDF3", + "wdaPort": 1260 + }, + { + "name": "Auto-16PM-8", + "udid": "5C799A8A-2AE0-4ED9-A077-BCC703ABF7E0", + "wdaPort": 1261 + }, + { + "name": "Auto-16PM-9", + "udid": "AEE0AE84-26FA-42FE-85CD-82780DF1154C", + "wdaPort": 1262 + }, + { + "name": "Auto-16PM-10", + "udid": "5B947D6C-DAE0-4066-9263-C2B3E1B4E970", + "wdaPort": 1263 + }, + { + "name": "Auto-16PM-11", + "udid": "662F717D-A26D-47C7-A47B-E5090B1C4239", + "wdaPort": 1264 + } +] \ No newline at end of file diff --git a/package.json b/package.json index a5b797bbb..45eb54c94 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,8 @@ "name": "session-appium", "version": "1.0.0", "scripts": { + "cleanup-simulators": "npx ts-node scripts/cleanup_ios_simulators.ts", + "create-simulators": "yarn cleanup-simulators && npx ts-node scripts/create_ios_simulators.ts", "lint": "yarn prettier . --write --cache && yarn eslint . --cache ", "lint-check": "yarn prettier . --check && yarn eslint .", "tsc": "tsc", diff --git a/run/test/specs/user_actions_share_to_session.spec.ts b/run/test/specs/user_actions_share_to_session.spec.ts index b53fa5dd4..a7bde2b7e 100644 --- a/run/test/specs/user_actions_share_to_session.spec.ts +++ b/run/test/specs/user_actions_share_to_session.spec.ts @@ -42,7 +42,7 @@ async function shareToSession(platform: SupportedPlatformsType, testInfo: TestIn await sleepFor(1000); await alice1.pressHome(); await sleepFor(2000); - await alice1.pushMediaToDevice(testImage); + await alice1.onAndroid().pushMediaToDevice(testImage); // iOS is preloaded // Photo app is on different page than Session await alice1.onIOS().swipeRightAny('Session'); await alice1.clickOnElementAll(new PhotoLibrary(alice1)); diff --git a/run/test/specs/utils/capabilities_ios.ts b/run/test/specs/utils/capabilities_ios.ts index 87bf203bd..713457a2a 100644 --- a/run/test/specs/utils/capabilities_ios.ts +++ b/run/test/specs/utils/capabilities_ios.ts @@ -1,9 +1,12 @@ import { AppiumXCUITestCapabilities } from '@wdio/types/build/Capabilities'; import { W3CCapabilities } from '@wdio/types/build/Capabilities'; import dotenv from 'dotenv'; +import { existsSync, readFileSync } from 'fs'; import { IntRange } from '../../../types/RangeType'; + dotenv.config(); + const iosPathPrefix = process.env.IOS_APP_PATH_PREFIX; if (!iosPathPrefix) { @@ -16,8 +19,8 @@ console.log(`iOS app full path: ${iosAppFullPath}`); const sharediOSCapabilities: AppiumXCUITestCapabilities = { 'appium:app': iosAppFullPath, 'appium:platformName': 'iOS', - 'appium:platformVersion': '17.2', - 'appium:deviceName': 'iPhone 15 Pro Max', + 'appium:platformVersion': '18.3', + 'appium:deviceName': 'iPhone 16 Pro Max', 'appium:automationName': 'XCUITest', 'appium:bundleId': 'com.loki-project.loki-messenger', 'appium:newCommandTimeout': 300000, @@ -31,62 +34,92 @@ const sharediOSCapabilities: AppiumXCUITestCapabilities = { communityPollLimit: 5, }, }, - // "appium:isHeadless": true, } as AppiumXCUITestCapabilities; -const envVars = [ - 'IOS_1_SIMULATOR', - 'IOS_2_SIMULATOR', - 'IOS_3_SIMULATOR', - 'IOS_4_SIMULATOR', - 'IOS_5_SIMULATOR', - 'IOS_6_SIMULATOR', - 'IOS_7_SIMULATOR', - 'IOS_8_SIMULATOR', - 'IOS_9_SIMULATOR', - 'IOS_10_SIMULATOR', - 'IOS_11_SIMULATOR', - 'IOS_12_SIMULATOR', -] as const; - -function getIOSSimulatorUUIDFromEnv(index: CapabilitiesIndexType): string { - const envVar = envVars[index]; - const uuid = process.env[envVar]; - - if (!uuid) { - throw new Error(`Environment variable ${envVar} is not set`); +export type Simulator = { + name: string; + udid: string; + wdaPort: number; +}; + +function loadSimulators(): Simulator[] { + const jsonPath = 'ci-simulators.json'; + + // Load from .env variables + const envVars = [ + 'IOS_1_SIMULATOR', + 'IOS_2_SIMULATOR', + 'IOS_3_SIMULATOR', + 'IOS_4_SIMULATOR', + 'IOS_5_SIMULATOR', + 'IOS_6_SIMULATOR', + 'IOS_7_SIMULATOR', + 'IOS_8_SIMULATOR', + 'IOS_9_SIMULATOR', + 'IOS_10_SIMULATOR', + 'IOS_11_SIMULATOR', + 'IOS_12_SIMULATOR', + ]; + + const simulators = envVars + .map((envVar, index) => { + const udid = process.env[envVar]; + if (!udid) return null; // No need for all 12 sim variables to be set + return { name: `Sim-${index + 1}`, udid, wdaPort: 1253 + index }; + }) + .filter((sim): sim is Simulator => sim !== null); + + // If we have simulators from env, use them (local dev) + if (simulators.length > 0) { + console.log(`Using ${simulators.length} simulators from .env file`); + return simulators; + } + + // No env simulators - check if we're on CI + if (process.env.CI === '1') { + // CI should use JSON + if (existsSync(jsonPath)) { + console.log('Using simulators from ios-simulators.json (CI)'); + const sims: Simulator[] = JSON.parse(readFileSync(jsonPath, 'utf-8')); + return sims; + } + throw new Error('CI mode: ios-simulators.json not found'); } - return uuid; + // Local dev with no .env entries + throw new Error( + 'No iOS simulators found in .env\n' + + 'Run: yarn create-simulators \n' + + 'Example: yarn create-simulators 4' + ); } -const MAX_CAPABILITIES_INDEX = envVars.length; +const simulators = loadSimulators(); + +const capabilities = simulators.map(sim => ({ + ...sharediOSCapabilities, + 'appium:udid': sim.udid, + 'appium:wdaLocalPort': sim.wdaPort, +})); + +// Use a constant max that matches the envVars array length for type safety +const _MAX_CAPABILITIES_INDEX = 12 as const; -export type CapabilitiesIndexType = IntRange<0, typeof MAX_CAPABILITIES_INDEX>; +// For runtime validation, check against actual loaded simulators +export const getMaxCapabilitiesIndex = () => capabilities.length; + +// Type is still based on the constant for compile-time safety +export type CapabilitiesIndexType = IntRange<0, typeof _MAX_CAPABILITIES_INDEX>; export function capabilityIsValid( capabilitiesIndex: number ): capabilitiesIndex is CapabilitiesIndexType { - if (capabilitiesIndex < 0 || capabilitiesIndex > MAX_CAPABILITIES_INDEX) { + // Runtime validation against actual loaded capabilities + if (capabilitiesIndex < 0 || capabilitiesIndex >= capabilities.length) { return false; } return true; } -interface CustomW3CCapabilities extends W3CCapabilities { - 'appium:wdaLocalPort': number; - 'appium:udid': string; -} - -const emulatorUUIDs = Array.from({ length: MAX_CAPABILITIES_INDEX }, (_, index) => - getIOSSimulatorUUIDFromEnv(index as CapabilitiesIndexType) -); - -const capabilities = emulatorUUIDs.map((udid, index) => ({ - ...sharediOSCapabilities, - 'appium:udid': udid, - 'appium:wdaLocalPort': 1253 + index, -})); - export function getIosCapabilities(capabilitiesIndex: CapabilitiesIndexType): W3CCapabilities { if (capabilitiesIndex >= capabilities.length) { throw new Error( @@ -102,11 +135,11 @@ export function getIosCapabilities(capabilitiesIndex: CapabilitiesIndexType): W3 }; } -export function getCapabilitiesForWorker(workerId: number): CustomW3CCapabilities { +export function getCapabilitiesForWorker(workerId: number) { const emulator = capabilities[workerId % capabilities.length]; return { ...sharediOSCapabilities, 'appium:udid': emulator['appium:udid'], 'appium:wdaLocalPort': emulator['appium:wdaLocalPort'], - } as CustomW3CCapabilities; + }; } diff --git a/run/test/specs/utils/open_app.ts b/run/test/specs/utils/open_app.ts index ac427e820..f523109ca 100644 --- a/run/test/specs/utils/open_app.ts +++ b/run/test/specs/utils/open_app.ts @@ -292,9 +292,27 @@ const openiOSApp = async ( }> => { console.info('openiOSApp'); - // Calculate the actual capabilities index for the current worker - const actualCapabilitiesIndex = - capabilitiesIndex + getDevicesPerTestCount() * parseInt(process.env.TEST_PARALLEL_INDEX || '0'); + const parallelIndex = parseInt(process.env.TEST_PARALLEL_INDEX || '0'); + + // NOTE: This assumes DEVICES_PER_TEST_COUNT=4 is set in CI for iOS (not applicable to Android) + // Worker pools are fixed at 4 devices each regardless of actual test size: + // Worker 0: devices 0-3, Worker 1: devices 4-7, Worker 2: devices 8-11 + const devicesPerWorker = getDevicesPerTestCount(); + const workerBaseOffset = devicesPerWorker * parallelIndex; + + // Apply retry offset, but wrap within the worker's device pool only + // This means when retrying, alice/bob etc won't be the same device as before within a worker's pool + // This is to avoid any issues where a device might be in a bad state for some reason + // (e.g. not accessing photo library on iOS) + const retryOffset = testInfo.retry || 0; + const deviceIndexWithinWorker = (capabilitiesIndex + retryOffset) % devicesPerWorker; + const actualCapabilitiesIndex = workerBaseOffset + deviceIndexWithinWorker; + + if (retryOffset > 0) { + console.info( + `Retry offset applied (#${retryOffset}), rotating device allocations within worker` + ); + } const opts: XCUITestDriverOpts = { address: `http://localhost:${APPIUM_PORT}`, diff --git a/run/types/DeviceWrapper.ts b/run/types/DeviceWrapper.ts index af1ededcf..201f5cac3 100644 --- a/run/types/DeviceWrapper.ts +++ b/run/types/DeviceWrapper.ts @@ -57,7 +57,6 @@ import { clickOnCoordinates, sleepFor } from '../test/specs/utils'; import { getAdbFullPath } from '../test/specs/utils/binaries'; import { parseDataImage } from '../test/specs/utils/check_colour'; import { isSameColor } from '../test/specs/utils/check_colour'; -import { copyFileToSimulator } from '../test/specs/utils/copy_file_to_simulator'; import { SupportedPlatformsType } from '../test/specs/utils/open_app'; import { isDeviceAndroid, isDeviceIOS, runScriptAndLog } from '../test/specs/utils/utilities'; import { @@ -333,6 +332,7 @@ export class DeviceWrapper { const blacklist = [ { from: 'Voice message', to: 'New voice message' }, { from: 'Message sent status: Sent', to: 'Message sent status: Sending' }, + { from: 'Done', to: 'Donate' }, ]; // System locators such as 'network.loki.messenger.qa:id' can cause false positives with too high similarity scores @@ -739,7 +739,7 @@ export class DeviceWrapper { } ); - return result; // or whatever you want to do with it + return result; } public async longPressConversation(userName: string) { @@ -1844,9 +1844,8 @@ export class DeviceWrapper { } public async sendImage(message: string, community?: boolean): Promise { + // iOS files are pre-loaded on simulator creation, no need to push if (this.isIOS()) { - // Push file first - await this.pushMediaToDevice(testImage); await this.clickOnElementAll(new AttachmentsButton(this)); await sleepFor(5000); const keyboard = await this.isKeyboardVisible(); @@ -1897,11 +1896,8 @@ export class DeviceWrapper { return sentTimestamp; } public async sendVideoiOS(message: string): Promise { - // Push first - await this.pushMediaToDevice(testVideo); + // iOS files are pre-loaded on simulator creation, no need to push await this.clickOnElementAll(new AttachmentsButton(this)); - // Select images button/tab - await sleepFor(5000); const keyboard = await this.isKeyboardVisible(); if (keyboard) { await clickOnCoordinates(this, InteractionPoints.ImagesFolderKeyboardOpen); @@ -1912,12 +1908,8 @@ export class DeviceWrapper { await this.modalPopup({ strategy: 'accessibility id', selector: 'Allow Full Access', - maxWait: 2_000, }); await sleepFor(2000); // Appium needs a moment, matchAndTapImage sometimes finds 0 elements otherwise - // For some reason video gets added to the top of the Recents folder so it's best to scroll up - await this.scrollUp(); - await sleepFor(2000); // Appium needs a moment, matchAndTapImage sometimes finds 0 elements otherwise // A video can't be matched by its thumbnail so we use a video thumbnail file await this.matchAndTapImage( { strategy: 'xpath', selector: `//XCUIElementTypeCell` }, @@ -1991,10 +1983,10 @@ export class DeviceWrapper { } public async sendDocument(): Promise { + // iOS files are pre-loaded on simulator creation, no need to push if (this.isIOS()) { const formattedFileName = 'test_file, pdf'; const testMessage = 'Testing documents'; - copyFileToSimulator(this, testFile); await this.clickOnElementAll(new AttachmentsButton(this)); const keyboard = await this.isKeyboardVisible(); if (keyboard) { @@ -2142,9 +2134,8 @@ export class DeviceWrapper { // Click on Profile picture await this.clickOnElementAll(new UserAvatar(this)); await this.clickOnElementAll(new ChangeProfilePictureButton(this)); + // iOS files are pre-loaded on simulator creation, no need to push if (this.isIOS()) { - // Push file first - await this.pushMediaToDevice(profilePicture); await this.modalPopup({ strategy: 'accessibility id', selector: 'Allow Full Access' }); await sleepFor(5000); // sometimes Appium doesn't recognize the XPATH immediately await this.matchAndTapImage( @@ -2452,13 +2443,12 @@ export class DeviceWrapper { // Execute the action in the home screen context const iosPermissions = await this.doesElementExist({ ...args, - maxWait: 1000, + maxWait: 3_000, }); - this.info('iosPermissions', iosPermissions); if (iosPermissions) { await this.clickOnElementAll({ ...args, maxWait }); } else { - this.info('No iosPermissions', iosPermissions); + this.info('No iOS Permissions modal visible to Appium'); } } catch (e) { this.info('FAILED WITH', e); diff --git a/scripts/cleanup_ios_simulators.ts b/scripts/cleanup_ios_simulators.ts new file mode 100644 index 000000000..0e6cb2501 --- /dev/null +++ b/scripts/cleanup_ios_simulators.ts @@ -0,0 +1,111 @@ +import { execSync } from 'child_process'; +import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; + +import type { Simulator } from '../run/test/specs/utils/capabilities_ios'; + +import { shutdownSimulator } from './ios_shared'; + +/** + * iOS Simulator Cleanup Script + * + * Deletes iOS Simulators created by create_ios_simulators.ts and cleans up configuration files. + * + * Environment-specific behavior: + * - Local dev: Deletes Simulators listed in .env and removes those entries + * - CI: Deletes Simulators listed in ios-simulators.json and removes the file + * + * Usage: + * yarn cleanup-simulators + */ +function deleteSimulator(udids: string[] | string): number { + const udidArray = Array.isArray(udids) ? udids : [udids]; + let deleted = 0; + + for (const udid of udidArray) { + shutdownSimulator(udid); + try { + execSync(`xcrun simctl delete ${udid}`, { stdio: 'pipe' }); + deleted++; + } catch { + console.warn(`Failed to delete: ${udid}`); + } + } + + return deleted; +} +function cleanupFromJSON(): number { + const jsonPath = 'ci-simulators.json'; + + // Only cleanup JSON on CI (it gets recreated there) + // On local dev, leave it alone (it's a tracked file for CI) + if (process.env.CI !== '1') { + return 0; + } + + if (!existsSync(jsonPath)) { + return 0; + } + + const simulators: Simulator[] = JSON.parse(readFileSync(jsonPath, 'utf-8')); + const deleted = deleteSimulator(simulators.map(sim => sim.udid)); + + unlinkSync(jsonPath); + console.log(`✓ Removed ${jsonPath}`); + + return deleted; +} + +function cleanupFromEnv(): number { + const envPath = '.env'; + + if (!existsSync(envPath)) { + return 0; + } + + const envContent = readFileSync(envPath, 'utf-8'); + const lines = envContent.split('\n'); + + const simulatorPattern = /^IOS_\d+_SIMULATOR=/; + const simulatorExtractPattern = /^IOS_\d+_SIMULATOR=(.+)/; + + const udids = lines + .map(line => { + const match = line.match(simulatorExtractPattern); + return match ? match[1].trim() : null; + }) + .filter((udid): udid is string => udid !== null); + + if (udids.length === 0) { + return 0; + } + + const deleted = deleteSimulator(udids); + + // Remove simulator lines from .env + const cleanedEnv = + lines + .filter(line => !simulatorPattern.test(line.trim())) + .join('\n') + .trim() + '\n'; + + writeFileSync(envPath, cleanedEnv); + console.log(`✓ Cleaned .env`); + + return deleted; +} + +function cleanupIOSSimulators(): void { + console.log('\nCleaning up iOS Simulators...\n'); + + const deletedFromJSON = cleanupFromJSON(); + const deletedFromEnv = cleanupFromEnv(); + const totalDeleted = deletedFromJSON + deletedFromEnv; + + if (totalDeleted === 0) { + console.log('No Simulators found to clean up'); + } else { + console.log(`\n✓ Deleted ${totalDeleted} Simulator${totalDeleted !== 1 ? 's' : ''}`); + } +} + +cleanupIOSSimulators(); diff --git a/scripts/create_ios_simulators.ts b/scripts/create_ios_simulators.ts new file mode 100644 index 000000000..81e63f3c8 --- /dev/null +++ b/scripts/create_ios_simulators.ts @@ -0,0 +1,219 @@ +import { execSync } from 'child_process'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import * as path from 'path'; + +import type { Simulator } from '../run/test/specs/utils/capabilities_ios'; +import type { DeviceWrapper } from '../run/types/DeviceWrapper'; + +import { copyFileToSimulator } from '../run/test/specs/utils/copy_file_to_simulator'; +import { bootSimulator, isSimulatorBooted, shutdownSimulator } from './ios_shared'; +import { sleepSync } from './shared'; + +/** + * iOS Simulator Creation Script + * + * Creates iOS Simulators with preloaded media (images, videos, PDFs) using a clone-based approach: + * 1. Creates one "template" Simulator (Simulator 0) + * 2. Boots it, loads media, then shuts it down + * 3. Clones it N-1 times to create remaining Simulators + * + * Note: You can't add media to shutdown Simulators and you can't clone booted Simulators, + * which is why we boot -> load -> shutdown -> clone. + * + * Environment-specific behavior: + * - Local dev: Updates .env with IOS_N_SIMULATOR variables + * - CI: Creates ios-simulators.json for persistent Simulator tracking + * + * Usage: + * yarn create-simulators 4 # Local: 4 Simulators + * CI=1 yarn create-simulators 12 # For CI: 12 Simulators + */ + +type SimulatorConfig = { + deviceType: string; + runtime: string; + totalSimulators: number; +}; + +// Define the device type and runtime to create +const DEVICE_CONFIG = { + type: 'iPhone 16 Pro Max', + name: '16PM', + runtime: 'com.apple.CoreSimulator.SimRuntime.iOS-18-3', // xcrun simctl list runtimes +}; + +const MEDIA_ROOT = path.join('run', 'test', 'specs', 'media'); +const MEDIA_FILES = { + images: ['profile_picture.jpg', 'test_image.jpg'], + videos: ['test_video.mp4'], + pdfs: ['test_file.pdf'], +}; + +function createSimulator(name: string, deviceType: string, runtime: string): string { + const output = execSync(`xcrun simctl create "${name}" "${deviceType}" "${runtime}"`, { + encoding: 'utf-8', + }).trim(); + return output; +} + +function cloneSimulator(sourceUdid: string, newName: string): string { + const output = execSync(`xcrun simctl clone ${sourceUdid} "${newName}"`, { + encoding: 'utf-8', + }).trim(); + return output; +} + +function waitForBoot(udid: string): boolean { + sleepSync(2); + for (let i = 0; i < 30; i++) { + if (isSimulatorBooted(udid)) { + return true; + } + sleepSync(1); + } + return false; +} + +function preloadMedia(udid: string): void { + // Add images and videos + const mediaFiles = [...MEDIA_FILES.images, ...MEDIA_FILES.videos]; + for (const filename of mediaFiles) { + const mediaPath = path.join(MEDIA_ROOT, filename); + if (!existsSync(mediaPath)) { + throw new Error(`Media file not found: ${filename}`); + } + execSync(`xcrun simctl addmedia ${udid} "${mediaPath}"`); + } + + // Copy PDFs to Files app Downloads folder + // copyFileToSimulator expects a DeviceWrapper with udid and log properties + // We create a minimal mock object since we're not in a test context + const mockDevice: Pick = { + udid, + log: () => {}, // Empty function (no need for logs during setup) + }; + + for (const filename of MEDIA_FILES.pdfs) { + const sourcePath = path.join(MEDIA_ROOT, filename); + if (!existsSync(sourcePath)) { + throw new Error(`PDF file not found: ${filename}`); + } + copyFileToSimulator(mockDevice as DeviceWrapper, filename); + } +} + +// Create N number of pre-loaded simulators by: +// Creating one "template" simulator, booting it, copying media over, shutting it down and then cloning it N-1 times +// (You can't copy to shutdown simulators and you can't clone booted simulators) +function createIOSSimulators(config: SimulatorConfig): Simulator[] { + console.log(`\nCreating ${config.totalSimulators} iOS simulators\n`); + + const startTime = Date.now(); + const simulators: Simulator[] = []; + + // Create Simulator 0 with preloaded media + console.log(`Creating Simulator 0 with preloaded media...`); + + const name0 = `Auto-${DEVICE_CONFIG.name}-0`; + const udid0 = createSimulator(name0, config.deviceType, config.runtime); + + if (!bootSimulator(udid0)) { + throw new Error('Failed to boot Simulator 0'); + } + + if (!waitForBoot(udid0)) { + throw new Error('Simulator 0 boot timeout'); + } + + preloadMedia(udid0); + shutdownSimulator(udid0); + + simulators.push({ + name: name0, + udid: udid0, + wdaPort: 1253, + }); + + console.log(`✓ ${name0} ready`); + + // Clone remaining simulators from Simulator 0 + console.log(`Cloning ${config.totalSimulators - 1} more Simulators...`); + + for (let index = 1; index < config.totalSimulators; index++) { + const name = `Auto-${DEVICE_CONFIG.name}-${index}`; + const udid = cloneSimulator(udid0, name); + + simulators.push({ + name, + udid, + wdaPort: 1253 + index, + }); + + console.log(` [${index}/${config.totalSimulators - 1}] ${name}`); + } + + const totalTime = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`\n✓ Created ${simulators.length} Simulators in ${totalTime}s`); + + saveSimulatorConfig(simulators); + return simulators; +} + +function updateLocalEnvFile(simulators: Simulator[]): void { + const envPath = '.env'; + let envContent = existsSync(envPath) ? readFileSync(envPath, 'utf-8') : ''; + + // Remove old simulator lines + const simulatorPattern = /^IOS_\d+_SIMULATOR=/; + envContent = envContent + .split('\n') + .filter(line => !simulatorPattern.test(line.trim())) + .join('\n'); + + // Add new simulator UDIDs + const simLines = simulators.map((sim, i) => `IOS_${i + 1}_SIMULATOR=${sim.udid}`).join('\n'); + + envContent = envContent.trim() + '\n\n# iOS Simulators (auto-generated)\n' + simLines + '\n'; + writeFileSync(envPath, envContent); +} + +function saveSimulatorConfig(simulators: Simulator[]): void { + // For running on CI, create a json file that GHA can read the UDIDs from + if (process.env.CI === '1') { + writeFileSync('ci-simulators.json', JSON.stringify(simulators, null, 2)); + console.log(`✓ Saved to ci-simulators.json`); + } else { + // For local development, update the IOS_N_SIMULATOR variables in .env + updateLocalEnvFile(simulators); + console.log(`✓ Updated .env`); + } +} + +// Main execution +const numSimulatorsArg = process.argv[2]; + +if (!numSimulatorsArg) { + console.error('Error: Number of Simulators required'); + console.error('Usage: yarn create-simulators '); + process.exit(1); +} + +const numSimulators = parseInt(numSimulatorsArg); + +if (isNaN(numSimulators) || numSimulators < 1) { + console.error('Error: Invalid number of Simulators'); + console.error('Usage: yarn create-simulators '); + process.exit(1); +} + +try { + createIOSSimulators({ + deviceType: DEVICE_CONFIG.type, + runtime: DEVICE_CONFIG.runtime, + totalSimulators: numSimulators, + }); +} catch (error) { + console.error('\n✗ Failed to create Simulators'); + console.error(error); + process.exit(1); +} diff --git a/scripts/ios_shared.ts b/scripts/ios_shared.ts index fdad5e3c1..fec18e79e 100644 --- a/scripts/ios_shared.ts +++ b/scripts/ios_shared.ts @@ -6,6 +6,27 @@ export function getSimulatorUDID(index: number) { return process.env[envVar]; } +export function bootSimulator(udid: string, label?: number | string): boolean { + try { + if (label !== undefined) { + console.log(`Booting simulator ${label}: ${udid}`); + } + execSync(`xcrun simctl boot ${udid}`, { stdio: 'pipe' }); + return true; + } catch (error: any) { + if (error.message?.includes('Unable to boot device in current state: Booted')) { + if (label !== undefined) { + console.log(`Simulator ${label} already booted: ${udid}`); + } + return true; + } + + console.error(`Failed to boot simulator ${label || udid}`); + console.error(error.stderr?.toString() || error.message); + return false; + } +} + export function isSimulatorBooted(udid: string) { try { const result = execSync(`xcrun simctl list devices booted`).toString(); @@ -43,3 +64,11 @@ export function getAllSimulators() { export function getChunkedSimulators(chunkSize: number) { return chunk(getAllSimulators(), chunkSize); } + +export function shutdownSimulator(udid: string): void { + try { + execSync(`xcrun simctl shutdown ${udid}`, { stdio: 'pipe' }); + } catch { + // Already shutdown or doesn't exist - this is fine + } +} diff --git a/scripts/start_ios.ts b/scripts/start_ios.ts index edfbd8917..f43bf78b9 100644 --- a/scripts/start_ios.ts +++ b/scripts/start_ios.ts @@ -1,23 +1,10 @@ -import { execSync, spawnSync } from 'child_process'; +import { spawnSync } from 'child_process'; -import { getChunkedSimulators, isSimulatorBooted } from './ios_shared'; +import { bootSimulator, getChunkedSimulators, isSimulatorBooted } from './ios_shared'; import { sleepSync } from './shared'; const START_CHUNK = 12; -function bootSimulator(udid: string, label: number) { - try { - console.log(`Booting simulator ${label}: ${udid}`); - execSync(`xcrun simctl boot ${udid}`, { stdio: 'inherit' }); - } catch (error: any) { - console.error(`Error: Boot command failed for ${udid}`); - console.error(error.stderr?.toString() || error.message); - return false; - } - - return true; -} - function startSimulatorsFromEnvIOS() { console.log('Starting iOS simulators from environment variables...');