diff --git a/.github/workflows/e2e-detox-pr.yml b/.github/workflows/e2e-detox-pr.yml index 218bd454e..63554fa78 100644 --- a/.github/workflows/e2e-detox-pr.yml +++ b/.github/workflows/e2e-detox-pr.yml @@ -48,7 +48,7 @@ jobs: build-ios-simulator: if: contains(github.event.label.name, 'E2E iOS tests for PR') - runs-on: macos-15 + runs-on: macos-26 needs: - update-initial-status-ios steps: @@ -61,6 +61,10 @@ jobs: intune-enabled: ${{ env.INTUNE_ENABLED }} intune-ssh-private-key: ${{ secrets.MM_MOBILE_INTUNE_DEPLOY_KEY }} + - name: Set .env with RUNNING_E2E=true + run: | + echo "RUNNING_E2E=true" > .env + - name: Build iOS Simulator env: TAG: "${{ github.event.pull_request.head.sha }}" @@ -74,7 +78,7 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ios-build-simulator-${{ github.run_id }} - path: Mattermost-simulator-x86_64.app.zip + path: Mattermost-simulator-*.app.zip build-android-apk: runs-on: ubuntu-latest-8-cores diff --git a/.github/workflows/e2e-ios-template.yml b/.github/workflows/e2e-ios-template.yml index 5238fd964..e995acc6b 100644 --- a/.github/workflows/e2e-ios-template.yml +++ b/.github/workflows/e2e-ios-template.yml @@ -38,12 +38,12 @@ on: description: "iPhone simulator name" required: false type: string - default: "iPhone 15 Pro" + default: "iPhone 17 Pro" ios_device_os_name: description: "iPhone simulator OS version" required: false type: string - default: "iOS 17.2" + default: "iOS 26.2" low_bandwidth_mode: description: "Enable low bandwidth mode" required: false @@ -115,7 +115,7 @@ jobs: id: generate-specs uses: ./.github/actions/generate-specs with: - parallelism: 10 + parallelism: 5 search_path: detox/e2e/test device_name: ${{ env.DEVICE_NAME }} device_os_version: ${{ env.DEVICE_OS_VERSION }} @@ -173,6 +173,82 @@ jobs: - name: Install Detox Dependencies run: cd detox && npm i + - name: Pre-boot iOS Simulator + run: | + set -e + + echo "Looking for simulator: ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}" + + # Find or create the simulator + SIMULATOR_ID=$(xcrun simctl list devices | grep "${{ env.DEVICE_NAME }}" | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | grep -oE '([0-9A-F-]{36})' || true) + + if [ -z "$SIMULATOR_ID" ]; then + echo "Simulator not found. Creating simulator ${{ env.DEVICE_NAME }} with ${{ env.DEVICE_OS_VERSION }}" + + # Get device type (use exact match with parentheses to avoid partial matches) + DEVICE_TYPE=$(xcrun simctl list devicetypes | grep "${{ env.DEVICE_NAME }} (" | head -1 | awk -F'[()]' '{print $(NF-1)}') + if [ -z "$DEVICE_TYPE" ]; then + echo "Error: Could not find device type for ${{ env.DEVICE_NAME }}" + exit 1 + fi + echo "Found device type: $DEVICE_TYPE" + + # Get runtime (extract the identifier after the last " - ") + RUNTIME=$(xcrun simctl list runtimes | grep "${{ env.DEVICE_OS_VERSION }}" | head -1 | sed 's/.* - \(.*\)/\1/') + if [ -z "$RUNTIME" ]; then + echo "Error: Could not find runtime for ${{ env.DEVICE_OS_VERSION }}" + exit 1 + fi + echo "Found runtime: $RUNTIME" + + # Create simulator + SIMULATOR_ID=$(xcrun simctl create "CI-${{ env.DEVICE_NAME }}" "$DEVICE_TYPE" "$RUNTIME") + echo "Created simulator with ID: $SIMULATOR_ID" + else + echo "Found existing simulator: $SIMULATOR_ID" + fi + + # Boot simulator first to initialize directory structure + echo "Initializing simulator directories (first boot)..." + xcrun simctl boot "$SIMULATOR_ID" + xcrun simctl bootstatus "$SIMULATOR_ID" + + # Shut down to apply password autofill restrictions + echo "Shutting down to apply password autofill restrictions..." + xcrun simctl shutdown "$SIMULATOR_ID" + sleep 3 + + echo "Disabling password autofill (simulator is shut down)" + + # Ensure the directory exists (should now exist after first boot) + SETTINGS_DIR="$HOME/Library/Developer/CoreSimulator/Devices/$SIMULATOR_ID/data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles" + mkdir -p "$SETTINGS_DIR" + + # Use the JavaScript utility to disable password autofill + cd detox + node utils/disable_ios_autofill.js --simulator-id "$SIMULATOR_ID" + + if [ $? -ne 0 ]; then + echo "⚠️ Failed to disable password autofill" + exit 1 + fi + + cd .. + + echo "Booting simulator with password autofill disabled..." + xcrun simctl boot "$SIMULATOR_ID" + + # Wait for boot to complete + xcrun simctl bootstatus "$SIMULATOR_ID" + + # Open Simulator.app in background to speed up UI rendering + open -a Simulator --args -CurrentDeviceUDID "$SIMULATOR_ID" + + # Give Simulator.app time to fully initialize UI + sleep 3 + + echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV + - name: Start Proxy if: ${{ inputs.low_bandwidth_mode }} id: start-proxy @@ -182,35 +258,111 @@ jobs: - name: Set .env with RUNNING_E2E=true run: | - cat > .env <> .env + echo "RUNNING_E2E=true" > .env - - name: Run Detox E2E Tests + - name: Verify Simulator Health + run: | + echo "Verifying simulator health before running tests..." + + # Check if simulator is still responsive + if ! xcrun simctl bootstatus "$SIMULATOR_ID" -b 2>/dev/null; then + echo "⚠️ Simulator not responsive, attempting recovery..." + + # Try to shutdown and reboot + xcrun simctl shutdown "$SIMULATOR_ID" || true + sleep 3 + xcrun simctl boot "$SIMULATOR_ID" + xcrun simctl bootstatus "$SIMULATOR_ID" + fi + + echo "✅ Simulator is healthy and ready" + + - name: Start Metro Bundler and Run Detox E2E Tests continue-on-error: true # We want to run all the tests run: | - # Start the server - npm run start & - sleep 120 # Wait for watchman to finish querying the files + # Start Metro bundler in background + npm run start > metro.log 2>&1 & + METRO_PID=$! + echo "Metro PID: $METRO_PID" + echo "METRO_PID=$METRO_PID" >> $GITHUB_ENV + + # Wait for Metro to be ready with health check endpoint polling + timeout=120 + elapsed=0 + echo "Waiting for Metro bundler to be ready..." + while [ $elapsed -lt $timeout ]; do + # Try health check endpoint first (more reliable) + if curl -s http://localhost:8081/status 2>/dev/null | grep -q "packager-status:running"; then + echo "✅ Metro bundler is ready after ${elapsed}s (verified via health check)" + break + # Fallback to log checking + elif grep -q "Fast - Scalable - Integrated" metro.log 2>/dev/null; then + echo "✅ Metro bundler is ready after ${elapsed}s (verified via logs)" + break + fi + + # Show progress every 10 seconds + if [ $((elapsed % 10)) -eq 0 ] && [ $elapsed -gt 0 ]; then + echo "Still waiting for Metro... (${elapsed}s elapsed)" + fi + + sleep 2 + elapsed=$((elapsed + 2)) + done + + if [ $elapsed -ge $timeout ]; then + echo "⚠️ Warning: Metro bundler may not be fully ready after ${timeout}s, proceeding anyway" + echo "Metro process status:" + ps aux | grep -E "metro|node.*start" | grep -v grep || echo "Metro process not found" + echo "" + echo "Last 30 lines of metro.log:" + tail -30 metro.log + else + echo "Metro is serving on http://localhost:8081" + # Give Metro extra time to fully initialize for iOS 26.2 + echo "Allowing Metro to fully stabilize..." + sleep 3 + fi + cd detox - npm run clean-detox npm run detox:config-gen npm run e2e:ios-test -- ${{ matrix.specs }} env: DETOX_DISABLE_HIERARCHY_DUMP: "YES" - DETOX_DISABLE_SCREENSHOT_TRACKING: "YES" + DETOX_DISABLE_SCREENSHOT_TRACKING: "YES" DETOX_LOGLEVEL: "debug" DETOX_DEVICE_TYPE: ${{ env.DEVICE_NAME }} DETOX_OS_VERSION: ${{ env.DEVICE_OS_VERSION }} LOW_BANDWIDTH_MODE: ${{ inputs.low_bandwidth_mode }} + - name: Cleanup Simulator State + if: always() + run: | + echo "Cleaning up simulator state after tests..." + + # Stop Metro bundler if still running + if [ ! -z "$METRO_PID" ]; then + echo "Stopping Metro bundler (PID: $METRO_PID)..." + kill -9 "$METRO_PID" 2>/dev/null || true + fi + + # Terminate app gracefully + if [ ! -z "$SIMULATOR_ID" ]; then + echo "Terminating app on simulator..." + xcrun simctl terminate "$SIMULATOR_ID" com.mattermost.rnbeta 2>/dev/null || true + sleep 2 + fi + + echo "✅ Simulator cleanup complete" + - name: reset network settings if: ${{ inputs.low_bandwidth_mode || failure() }} run: | networksetup -setwebproxystate Ethernet "off" networksetup -setsecurewebproxystate Ethernet "off" - if (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi - if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi + if (sudo pfctl -q -sa | grep 'Status: Enabled') then sudo pfctl -d; fi + if (command -v pm2 &> /dev/null) then pm2 stop mitmdump; fi sleep 5; diff --git a/detox/.detoxrc.json b/detox/.detoxrc.json index 22eebbafd..5c627b0c4 100644 --- a/detox/.detoxrc.json +++ b/detox/.detoxrc.json @@ -82,16 +82,16 @@ }, "behavior": { "init": { - "reinstallApp": true, - "launchApp": false + "reinstallApp": false, + "launchApp": true }, "cleanup": { - "shutdownDevice": false + "shutdownDevice": true } }, "session": { "sessionId": "mobile-test-session", - "debugSynchronization": 5000, + "debugSynchronization": 20000, "autoStart": true }, "visibility": { diff --git a/detox/README.md b/detox/README.md index 6bad8ec14..e4823f4aa 100644 --- a/detox/README.md +++ b/detox/README.md @@ -79,6 +79,30 @@ npm run e2e:ios-test npm run e2e:android-test path to test file. ``` +### Disabling Password Autofill (Optional) + +iOS password autofill can interfere with login tests by automatically filling credentials. To disable this feature on your simulator: + +```sh +# Interactive mode - select simulator from list +npm run e2e:ios-disable-autofill + +# Or specify simulator ID directly +npm run e2e:ios-disable-autofill -- --simulator-id SIMULATOR_UDID +``` + +**When to use this:** +- Before running iOS E2E tests if you notice password fields being auto-filled +- When login tests fail unexpectedly due to autofill interference +- After creating a new iOS simulator for testing + +**Note:** CI environments automatically disable this setting, so this is only needed for local development. + +**Finding your simulator ID:** +```sh +xcrun simctl list devices | grep Booted +``` + #### TIP : For iOS, you can download the simulator from `~Mobile: Test build` or `~Release: Mobile Apps` channel in the community. ### Results diff --git a/detox/e2e/config.js b/detox/e2e/config.js index c8b4f9fae..c05375edf 100644 --- a/detox/e2e/config.js +++ b/detox/e2e/config.js @@ -8,7 +8,7 @@ module.exports = { setupFilesAfterEnv: ['./test/setup.ts'], maxWorkers: 1, testSequencer: './custom_sequencer.js', - testTimeout: 180000, + testTimeout: process.env.LOW_BANDWIDTH_MODE === 'true' ? 240000 : 180000, rootDir: '.', testMatch: ['/test/**/*.e2e.ts'], transform: { diff --git a/detox/e2e/support/ui/screen/server.ts b/detox/e2e/support/ui/screen/server.ts index 723cb15ef..fa71487c0 100644 --- a/detox/e2e/support/ui/screen/server.ts +++ b/detox/e2e/support/ui/screen/server.ts @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {Alert} from '@support/ui/component'; -import {isAndroid, isIos, timeouts, wait, waitForElementToBeVisible} from '@support/utils'; +import {isAndroid, isIos, timeouts, wait, waitForElementToBeVisible, waitForVisibilityWithRetry} from '@support/utils'; import {expect} from 'detox'; class ServerScreen { @@ -47,7 +47,7 @@ class ServerScreen { toBeVisible = async () => { await waitFor(this.serverScreen).toExist().withTimeout(timeouts.TEN_SEC); - await waitFor(this.serverUrlInput).toBeVisible().withTimeout(timeouts.TEN_SEC); + await waitForVisibilityWithRetry(this.serverUrlInput, timeouts.TEN_SEC); return this.serverScreen; }; diff --git a/detox/e2e/support/utils/detoxhelpers.ts b/detox/e2e/support/utils/detoxhelpers.ts index 2421543ad..74e8d9ba2 100644 --- a/detox/e2e/support/utils/detoxhelpers.ts +++ b/detox/e2e/support/utils/detoxhelpers.ts @@ -23,3 +23,86 @@ export async function scrollUntilVisible(testID: string, text: string): Promise< export async function waitForLoadingSpinner(testID: string, timeout = 10000): Promise { await waitFor(element(by.id(testID))).not.toBeVisible().withTimeout(timeout); } + +/** + * Safely dismiss keyboard with retry logic + * Prevents keyboard input session invalidation errors + * @param maxAttempts - Maximum number of dismissal attempts + */ +export async function dismissKeyboardSafely(maxAttempts = 3): Promise { + /* eslint-disable no-await-in-loop, no-console */ + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await device.pressBack(); // Works on both iOS (dismisses keyboard) and Android + // Wait a bit for keyboard to dismiss + await new Promise((resolve) => setTimeout(resolve, 300)); + return; + } catch (error) { + if (attempt === maxAttempts) { + // On final attempt, log warning but don't throw to prevent test failures + console.warn(`[dismissKeyboardSafely] Failed to dismiss keyboard after ${maxAttempts} attempts:`, error); + } else { + await new Promise((resolve) => setTimeout(resolve, 200)); + } + } + } + /* eslint-enable no-await-in-loop, no-console */ +} + +/** + * Perform text input with keyboard state verification + * Ensures text input session is valid before typing + * @param element - Detox element to type into + * @param text - Text to type + */ +export async function typeTextSafely(elementToType: Detox.IndexableNativeElement, text: string): Promise { + try { + // Tap element to ensure it's focused + await elementToType.tap(); + await new Promise((resolve) => setTimeout(resolve, 300)); + + // Type text + await elementToType.typeText(text); + } catch (error) { + // eslint-disable-next-line no-console + console.warn('[typeTextSafely] Text input failed, retrying:', error); + + // Retry once with dismissal and refocus + await dismissKeyboardSafely(); + await new Promise((resolve) => setTimeout(resolve, 500)); + await elementToType.tap(); + await new Promise((resolve) => setTimeout(resolve, 300)); + await elementToType.typeText(text); + } +} + +/** + * Retry an element visibility check with exponential backoff + * Helps handle race conditions during navigation and UI transitions + * @param elementToCheck - Detox element to check visibility + * @param timeout - Timeout for each attempt in milliseconds + * @param maxAttempts - Maximum number of retry attempts + */ +export async function waitForVisibilityWithRetry( + elementToCheck: Detox.NativeElement, + timeout = 10000, + maxAttempts = 3, +): Promise { + /* eslint-disable no-await-in-loop, no-console */ + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + await waitFor(elementToCheck).toBeVisible().withTimeout(timeout); + return; + } catch (error) { + if (attempt === maxAttempts) { + throw error; + } + + console.warn(`[waitForVisibilityWithRetry] Attempt ${attempt}/${maxAttempts} failed, retrying...`); + + // Exponential backoff: 1s, 2s, 4s + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)); + } + } + /* eslint-enable no-await-in-loop, no-console */ +} diff --git a/detox/e2e/test/products/channels/channels/unarchive_channel.e2e.ts b/detox/e2e/test/products/channels/channels/unarchive_channel.e2e.ts index ff2301840..1c42f2212 100644 --- a/detox/e2e/test/products/channels/channels/unarchive_channel.e2e.ts +++ b/detox/e2e/test/products/channels/channels/unarchive_channel.e2e.ts @@ -76,7 +76,6 @@ describe('Channels - Unarchive Channel', () => { await expect(ChannelScreen.introDisplayName).toHaveText(channelDisplayName); // # Go back to channel list screen by closing archived channel - await expect(ChannelScreen.archievedCloseChannelButton).toBeVisible(); // # Open channel info screen, tap on unarchive channel and confirm, close and re-open app to reload, and re-open unarchived public channel await ChannelInfoScreen.open(); diff --git a/detox/e2e/test/setup.ts b/detox/e2e/test/setup.ts index a918b230e..b1b621c17 100644 --- a/detox/e2e/test/setup.ts +++ b/detox/e2e/test/setup.ts @@ -15,6 +15,59 @@ const RETRY_DELAY = 5000; let isFirstLaunch = true; +/** + * Verify Detox connection to app is healthy + * @param maxAttempts - Maximum number of verification attempts + * @param delayMs - Delay between attempts in milliseconds + * @returns {Promise} + */ +async function verifyDetoxConnection(maxAttempts = 3, delayMs = 2000): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + // Simple health check: verify device is responsive + device.getPlatform(); + console.info(`✅ Detox connection verified on attempt ${attempt}`); + return; + } catch (error) { + console.warn(`❌ Detox connection check failed on attempt ${attempt}/${maxAttempts}: ${(error as Error).message}`); + + if (attempt < maxAttempts) { + await new Promise((resolve) => setTimeout(resolve, delayMs * attempt)); // Exponential backoff + } + } + } + + throw new Error('Detox connection verification failed after maximum attempts'); +} + +/** + * Wait for app to be ready (database initialized, bridge ready) + * @param timeoutMs - Maximum time to wait in milliseconds + * @returns {Promise} + */ +async function waitForAppReady(timeoutMs = 10000): Promise { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + try { + // Check if app is responsive by looking for a basic UI element + // Try server screen first, then channel list screen + try { + await waitFor(element(by.id('server.screen'))).toBeVisible().withTimeout(2000); + } catch { + await waitFor(element(by.id('channel_list.screen'))).toBeVisible().withTimeout(2000); + } + console.info('✅ App is ready'); + return; + } catch { + // App not ready yet, wait a bit + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } + + throw new Error(`App failed to become ready within ${timeoutMs}ms`); +} + /** * Launch the app with retry mechanism * @returns {Promise} @@ -123,11 +176,25 @@ beforeAll(async () => { await User.apiAdminLogin(siteOneUrl); await Plugin.apiDisableNonPrepackagedPlugins(siteOneUrl); await launchAppWithRetry(); + + // Verify Detox connection is healthy after app launch + await verifyDetoxConnection(); + + // Wait for app to be fully ready (database initialized, bridge ready) + await waitForAppReady(); }); // Add this to speed up test cleanup afterAll(async () => { try { + // Dismiss keyboard if visible to prevent session invalidation issues + try { + await device.sendToHome(); + await new Promise((resolve) => setTimeout(resolve, 500)); + } catch (error) { + console.warn('[Teardown] Could not send app to home:', error); + } + await device.terminateApp(); } catch (error) { console.error('[Teardown] Error terminating app:', error); diff --git a/detox/package-lock.json b/detox/package-lock.json index 73fc11a14..a4b0f5298 100644 --- a/detox/package-lock.json +++ b/detox/package-lock.json @@ -19,8 +19,8 @@ "@types/jest": "29.5.14", "@types/tough-cookie": "4.0.5", "@types/uuid": "10.0.0", - "@wix-pilot/core": "^3.4.0", - "@wix-pilot/detox": "^1.0.12", + "@wix-pilot/core": "3.4.2", + "@wix-pilot/detox": "1.0.13", "async": "3.2.6", "axios": "1.8.2", "axios-cookiejar-support": "5.0.4", @@ -28,7 +28,7 @@ "babel-plugin-module-resolver": "5.0.2", "client-oauth2": "4.3.3", "deepmerge": "4.3.1", - "detox": "20.37.0", + "detox": "20.46.3", "dotenv": "17.2.1", "form-data": "4.0.1", "jest": "29.7.0", @@ -2835,9 +2835,9 @@ } }, "node_modules/@flatten-js/interval-tree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.3.tgz", - "integrity": "sha512-xhFWUBoHJFF77cJO1D6REjdgJEMRf2Y2Z+eKEPav8evGKcLSnj1ud5pLXQSbGuxF3VSvT1rWhMfVpXEKJLTL+A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@flatten-js/interval-tree/-/interval-tree-1.1.4.tgz", + "integrity": "sha512-o4emRDDvGdkwX18BSVSXH8q27qAL7Z2WDHSN75C8xyRSE4A8UOkig0mWSGoT5M5KaTHZxoLmalFwOTQmbRusUg==", "dev": true, "license": "MIT" }, @@ -4334,9 +4334,9 @@ "license": "MIT" }, "node_modules/@wix-pilot/core": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@wix-pilot/core/-/core-3.4.0.tgz", - "integrity": "sha512-TH5iH0BISSkMSVBSRiTnh4oYEzlA4gErGiI14US+Ze17uSrWXyrcb4GBpjIuAFMvM3/UbmQBkEg2hXGcl9aDZQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@wix-pilot/core/-/core-3.4.2.tgz", + "integrity": "sha512-O8V2NLfPEKI2IviJXG4g/vNbMfsBZNBhzAKFUOOaOR9TTDUJYlZUttqjhjP/fPnaDky0/hrfGES15sO0N7zEkw==", "dev": true, "license": "MIT", "dependencies": { @@ -4354,12 +4354,12 @@ } }, "node_modules/@wix-pilot/detox": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@wix-pilot/detox/-/detox-1.0.12.tgz", - "integrity": "sha512-yMf9aUv7D+5rViGd7714P06bIgmcJklFFz4/Vjji6TH1gyChX+I/OwP0iK7L5Tob96bPTB9ApM6gR6Je66oIOA==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@wix-pilot/detox/-/detox-1.0.13.tgz", + "integrity": "sha512-/34lM25AfmHNMLOeEIhfKVnx2YZyn5VHC/R4Bs1uXQ2B+Yg0JbxAfvfXA3xqxBsdYqrYImILPO3Ih0c5UzoNxw==", "dev": true, "peerDependencies": { - "@wix-pilot/core": "^3.4.0", + "@wix-pilot/core": "^3.4.1", "detox": ">=20.33.0", "expect": "29.x.x || 28.x.x || ^27.2.5" } @@ -4886,9 +4886,9 @@ } }, "node_modules/bunyan-debug-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/bunyan-debug-stream/-/bunyan-debug-stream-3.1.0.tgz", - "integrity": "sha512-VaFYbDVdiSn3ZpdozrjZ8mFpxHXl26t11C1DKRQtbo0EgffqeFNrRLOGIESKVeGEvVu4qMxMSSxzNlSw7oTj7w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/bunyan-debug-stream/-/bunyan-debug-stream-3.1.1.tgz", + "integrity": "sha512-LfMcz4yKM6s9BP5dfT63Prb5B2hAjReLAfQzLbNQF7qBHtn3P1v+/yn0SZ6UAr4PC3VZRX/QzK7HYkkY0ytokQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4899,6 +4899,11 @@ }, "peerDependencies": { "bunyan": "*" + }, + "peerDependenciesMeta": { + "bunyan": { + "optional": true + } } }, "node_modules/byte-length": { @@ -5329,15 +5334,15 @@ } }, "node_modules/detox": { - "version": "20.37.0", - "resolved": "https://registry.npmjs.org/detox/-/detox-20.37.0.tgz", - "integrity": "sha512-OUI2p3z31Yku1USBia0jrlwoQoa6rANvVWuUSJyjBlRRKDSeOf/DG/ivk+R5FO+lYZZtA3pIbl6LSql/Bf6DpQ==", + "version": "20.46.3", + "resolved": "https://registry.npmjs.org/detox/-/detox-20.46.3.tgz", + "integrity": "sha512-9NlpWFu2/CHEMSkzdPcQ9XCGKAtRca2ZLIcVeQDjZvfqkcDOdBt7nj8SX2TNjB4oR6+5MsBg9m0ngQTm8RpNbQ==", "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { - "@wix-pilot/core": "^3.2.2", - "@wix-pilot/detox": "^1.0.11", + "@wix-pilot/core": "^3.4.2", + "@wix-pilot/detox": "^1.0.13", "ajv": "^8.6.3", "bunyan": "^1.8.12", "bunyan-debug-stream": "^3.1.0", @@ -5349,7 +5354,7 @@ "funpermaproxy": "^1.1.0", "glob": "^8.0.3", "ini": "^1.3.4", - "jest-environment-emit": "^1.0.8", + "jest-environment-emit": "^1.2.0", "json-cycle": "^1.3.0", "lodash": "^4.17.11", "multi-sort-stream": "^1.0.3", @@ -5366,7 +5371,7 @@ "stream-json": "^1.7.4", "strip-ansi": "^6.0.1", "telnet-client": "1.2.8", - "tempfile": "^2.0.0", + "tmp": "^0.2.1", "trace-event-lib": "^1.3.1", "which": "^1.3.1", "ws": "^7.0.0", @@ -5381,7 +5386,7 @@ "node": ">=14" }, "peerDependencies": { - "jest": "29.x.x || 28.x.x || ^27.2.5" + "jest": "30.x.x || 29.x.x || 28.x.x || ^27.2.5" }, "peerDependenciesMeta": { "jest": { @@ -6819,9 +6824,9 @@ } }, "node_modules/jest-environment-emit": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/jest-environment-emit/-/jest-environment-emit-1.0.8.tgz", - "integrity": "sha512-WNqvxBLH0yNojHJQ99Y21963aT7UTavxV3PgiBQFi8zwrlnKU6HvkB6LOvQrbk5I8mI8JEKvcoOrQOvBVMLIXQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-emit/-/jest-environment-emit-1.2.0.tgz", + "integrity": "sha512-dSFBrRuIiWbHK2LSUA6CutXpMcNGjjuhvxFLF+TVz5tYFAAH0eesrZgrQ3UtOptajDYNt/fIGRqtlHqGq/bLbA==", "dev": true, "license": "MIT", "dependencies": { @@ -7902,9 +7907,9 @@ } }, "node_modules/nan": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", - "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.24.0.tgz", + "integrity": "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg==", "dev": true, "license": "MIT", "optional": true @@ -8896,9 +8901,9 @@ } }, "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "optional": true, @@ -9440,41 +9445,6 @@ "url": "https://paypal.me/kozjak" } }, - "node_modules/temp-dir": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", - "integrity": "sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/tempfile": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/tempfile/-/tempfile-2.0.0.tgz", - "integrity": "sha512-ZOn6nJUgvgC09+doCEF3oB+r3ag7kUvlsXEGX069QRD60p+P3uP7XG9N2/at+EyIRGSN//ZY3LyEotA1YpmjuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "temp-dir": "^1.0.0", - "uuid": "^3.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/tempfile/node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "bin/uuid" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9569,6 +9539,16 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", diff --git a/detox/package.json b/detox/package.json index 13f0c7ac9..fca83bea9 100644 --- a/detox/package.json +++ b/detox/package.json @@ -14,8 +14,8 @@ "@types/jest": "29.5.14", "@types/tough-cookie": "4.0.5", "@types/uuid": "10.0.0", - "@wix-pilot/core": "^3.4.0", - "@wix-pilot/detox": "^1.0.12", + "@wix-pilot/core": "3.4.2", + "@wix-pilot/detox": "1.0.13", "async": "3.2.6", "axios": "1.8.2", "axios-cookiejar-support": "5.0.4", @@ -23,7 +23,7 @@ "babel-plugin-module-resolver": "5.0.2", "client-oauth2": "4.3.3", "deepmerge": "4.3.1", - "detox": "20.37.0", + "detox": "20.46.3", "dotenv": "17.2.1", "form-data": "4.0.1", "jest": "29.7.0", @@ -55,6 +55,7 @@ "e2e:ios-test": "IOS=true detox test -c ios.sim.debug --reuse --record-logs failing --take-screenshots failing", "e2e:ios-build-release": "detox build -c ios.sim.release", "e2e:ios-test-release": "IOS=true detox test -c ios.sim.release --record-logs failing --take-screenshots failing", + "e2e:ios-disable-autofill": "cd utils && node disable_ios_autofill.js", "detox:config-gen": "cd utils && node generate_detox_config_ci.js", "check": "npm run lint && npm run tsc", "clean-detox": "detox clean", diff --git a/detox/utils/disable_ios_autofill.js b/detox/utils/disable_ios_autofill.js new file mode 100644 index 000000000..739e3b236 --- /dev/null +++ b/detox/utils/disable_ios_autofill.js @@ -0,0 +1,324 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +/* eslint-disable no-console */ + +const os = require('os'); +const path = require('path'); + +const shell = require('shelljs'); + +// Parse command line arguments +const args = process.argv.slice(2); +let simulatorId = null; + +for (let i = 0; i < args.length; i++) { + if ((args[i] === '--simulator-id' || args[i] === '-s') && args[i + 1]) { + simulatorId = args[i + 1]; + break; + } +} + +function getSimulators() { + const result = shell.exec('xcrun simctl list devices', {silent: true}); + + if (result.code !== 0) { + console.error('Error: Failed to list iOS simulators'); + console.error('Make sure Xcode is installed and xcrun is available'); + process.exit(1); + } + + const simulators = []; + const lines = result.stdout.split('\n'); + let currentOS = ''; + + for (const line of lines) { + // Match iOS version headers like "-- iOS 17.2 --" + const osMatch = line.match(/-- (iOS [0-9.]+) --/); + if (osMatch) { + currentOS = osMatch[1]; + continue; + } + + // Match simulator lines like " iPhone 15 Pro (A9A6D652-D75B-4C3A-9CD4-C6BA5E76C6F4) (Shutdown)" + const simMatch = line.match(/^\s+(.+?)\s+\(([A-F0-9-]{36})\)\s+\((Booted|Shutdown|Creating|Booting)\)/); + if (simMatch && currentOS) { + simulators.push({ + name: simMatch[1], + udid: simMatch[2], + state: simMatch[3], + os: currentOS, + }); + } + } + + return simulators; +} + +function disablePasswordAutofill(udid, simulator) { + const settingsDir = path.join( + os.homedir(), + 'Library/Developer/CoreSimulator/Devices', + udid, + 'data/Containers/Shared/SystemGroup/systemgroup.com.apple.configurationprofiles/Library/ConfigurationProfiles', + ); + const settingsPlist = path.join(settingsDir, 'UserSettings.plist'); + + console.log(`\nDisabling password autofill for simulator ${udid}...`); + + // Check if file exists + if (!shell.test('-f', settingsPlist)) { + console.log('UserSettings.plist not found, creating it...'); + + // Ensure directory exists + shell.mkdir('-p', settingsDir); + + // Create empty plist file + const result = shell.exec(`plutil -create xml1 "${settingsPlist}"`, {silent: true}); + if (result.code !== 0) { + console.error(`Error: Failed to create UserSettings.plist at ${settingsPlist}`); + console.error(result.stderr); + return false; + } + } + + // Define all keys to set + const keysToSet = [ + { + path: 'restrictedBool.allowPasswordAutoFill.value', + type: 'bool', + value: 'NO', + description: 'allowPasswordAutoFill', + }, + { + path: 'restrictedBool.allowPasswordAutoFillWithStrongPassword.value', + type: 'bool', + value: 'NO', + description: 'allowPasswordAutoFillWithStrongPassword (iOS 26+)', + }, + { + path: 'restrictedBool.forceDisablePasswordAutoFill.value', + type: 'bool', + value: 'YES', + description: 'forceDisablePasswordAutoFill', + }, + { + path: 'restrictedBool.allowiCloudKeychain.value', + type: 'bool', + value: 'NO', + description: 'allowiCloudKeychain', + }, + { + path: 'restrictedValue.passwordAutoFillPasswords.value', + type: 'integer', + value: '0', + description: 'passwordAutoFillPasswords', + }, + { + path: 'restrictedValue.allowAutoFillAuthenticationUI.value', + type: 'integer', + value: '0', + description: 'allowAutoFillAuthenticationUI', + }, + ]; + + let successCount = 0; + + for (const key of keysToSet) { + console.log(`Setting ${key.description}...`); + + // Try to replace the value directly first + let result = shell.exec( + `plutil -replace ${key.path} -${key.type} ${key.value} "${settingsPlist}"`, + {silent: true}, + ); + + if (result.code === 0) { + successCount++; + continue; + } + + // Direct replace failed, need to create the key structure + const pathParts = key.path.split('.'); + const rootKey = pathParts[0]; // restrictedBool or restrictedValue + const middleKey = pathParts[1]; // e.g., allowPasswordAutoFill + + // Ensure root dictionary exists (restrictedBool or restrictedValue) + const checkRoot = shell.exec( + `plutil -extract ${rootKey} xml1 -o - "${settingsPlist}" 2>/dev/null`, + {silent: true}, + ); + + if (checkRoot.code !== 0) { + result = shell.exec(`plutil -insert ${rootKey} -dictionary "${settingsPlist}"`, {silent: true}); + if (result.code !== 0) { + console.error(`⚠️ Failed to insert ${rootKey} dictionary`); + console.error(result.stderr); + continue; + } + } + + // Ensure middle dictionary exists (e.g., allowPasswordAutoFill) + const checkMiddle = shell.exec( + `plutil -extract ${rootKey}.${middleKey} xml1 -o - "${settingsPlist}" 2>/dev/null`, + {silent: true}, + ); + + if (checkMiddle.code !== 0) { + result = shell.exec( + `plutil -insert ${rootKey}.${middleKey} -dictionary "${settingsPlist}"`, + {silent: true}, + ); + if (result.code !== 0) { + console.error(`⚠️ Failed to insert ${rootKey}.${middleKey} dictionary`); + console.error(result.stderr); + continue; + } + } + + // Set the value (insert or replace) + const checkValue = shell.exec( + `plutil -extract ${key.path} xml1 -o - "${settingsPlist}" 2>/dev/null`, + {silent: true}, + ); + + if (checkValue.code === 0) { + // Value exists, replace it + result = shell.exec( + `plutil -replace ${key.path} -${key.type} ${key.value} "${settingsPlist}"`, + {silent: true}, + ); + } else { + // Value doesn't exist, insert it + result = shell.exec( + `plutil -insert ${key.path} -${key.type} ${key.value} "${settingsPlist}"`, + {silent: true}, + ); + } + + if (result.code === 0) { + successCount++; + } else { + console.error(`⚠️ Failed to set ${key.description}`); + console.error(result.stderr); + } + } + + // Verify that all keys were actually written to the plist + console.log('\nVerifying restriction keys...'); + let verifiedCount = 0; + + for (const key of keysToSet) { + const checkResult = shell.exec( + `plutil -extract ${key.path} xml1 -o - "${settingsPlist}" 2>/dev/null`, + {silent: true}, + ); + + if (checkResult.code === 0) { + // Extract just the value for logging + const valueMatch = checkResult.stdout.match(/<(true|false|integer)>([^<]*)<\/(true|false|integer)>/); + if (valueMatch) { + console.log(` ✓ ${key.description}: ${valueMatch[0]}`); + verifiedCount++; + } + } else { + console.log(` ✗ ${key.description}: MISSING or FAILED`); + } + } + + if (verifiedCount === keysToSet.length) { + console.log(`\n✅ All ${keysToSet.length} restriction keys verified in plist`); + } else { + console.error(`\n⚠️ Only ${verifiedCount}/${keysToSet.length} keys verified`); + } + + if (successCount === keysToSet.length) { + console.log(`✅ Password autofill disabled successfully (${keysToSet.length} restriction keys set)`); + return true; + } else if (successCount > 0) { + console.log(`⚠️ Partially successful: ${successCount}/${keysToSet.length} keys set`); + + // Check if this is iOS 26+ + const isIOS26Plus = simulator && simulator.os && + parseFloat(simulator.os.replace('iOS ', '')) >= 26; + + if (isIOS26Plus) { + console.error('⚠️ CRITICAL: iOS 26+ requires ALL 6 keys to fully disable password autofill'); + console.error(' Missing keys may allow "Strong Password" or iCloud Keychain prompts'); + } + + return false; + } + console.error('⚠️ Failed to disable password autofill'); + return false; + +} + +async function main() { + console.log('iOS Simulator - Disable Password Autofill\n'); + console.log('This tool disables password autofill on iOS simulators,'); + console.log('which can interfere with Detox E2E test login flows.'); + console.log('Sets 6 iOS restriction keys to fully disable autofill prompts.\n'); + + // Check if running on macOS + if (process.platform !== 'darwin') { + console.error('Error: This tool only works on macOS'); + process.exit(1); + } + + // Check if xcrun is available + if (!shell.which('xcrun')) { + console.error('Error: xcrun not found. Please install Xcode and command line tools'); + process.exit(1); + } + + // Get list of simulators + const simulators = getSimulators(); + + if (simulators.length === 0) { + console.error('Error: No iOS simulators found'); + console.error('Create a simulator in Xcode first'); + process.exit(1); + } + + let selectedSimulator; + + if (simulatorId) { + // Use provided simulator ID + selectedSimulator = simulators.find((sim) => sim.udid === simulatorId); + + if (!selectedSimulator) { + console.error(`Error: Simulator with ID ${simulatorId} not found`); + process.exit(1); + } + + console.log(`Target: ${selectedSimulator.name} (${selectedSimulator.os})`); + console.log(`Using simulator: ${selectedSimulator.name} (${selectedSimulator.os})`); + } else { + // Automatically select iPhone 17 Pro with iOS 26.2 + selectedSimulator = simulators.find((sim) => + sim.name === 'iPhone 17 Pro' && + sim.os === 'iOS 26.2', + ); + + if (!selectedSimulator) { + console.error('Error: iPhone 17 Pro (iOS 26.2) simulator not found'); + console.error('Please create this simulator in Xcode first.'); + console.error('\nAvailable simulators:'); + simulators.forEach((sim) => { + const stateIndicator = sim.state === 'Booted' ? '🟢' : '⚪'; + console.error(` ${stateIndicator} ${sim.name} (${sim.os}) - ${sim.state}`); + }); + process.exit(1); + } + + console.log(`Target: ${selectedSimulator.name} (${selectedSimulator.os})`); + console.log(`Using simulator: ${selectedSimulator.name} (${selectedSimulator.os})`); + } + + // Apply the setting + const success = disablePasswordAutofill(selectedSimulator.udid, selectedSimulator); + + process.exit(success ? 0 : 1); +} + +main(); diff --git a/detox/utils/generate_detox_config_ci.js b/detox/utils/generate_detox_config_ci.js index da761a5bd..aeaabb6b2 100644 --- a/detox/utils/generate_detox_config_ci.js +++ b/detox/utils/generate_detox_config_ci.js @@ -4,8 +4,8 @@ /* eslint-disable no-process-env */ /* eslint-disable no-console */ const fs = require('fs'); -const deviceName = process.env.DEVICE_NAME || 'iPhone 15 Pro'; -const deviceOSVersion = process.env.DEVICE_OS_VERSION || 'iOS 17.2'; +const deviceName = process.env.DEVICE_NAME || 'iPhone 17 Pro'; +const deviceOSVersion = process.env.DEVICE_OS_VERSION || 'iOS 26.2'; const detoxConfigTemplate = fs.readFileSync('../.detoxrc.json', 'utf8'); const detoxConfig = detoxConfigTemplate.replace('__DEVICE_NAME__', deviceName).replace('__DEVICE_OS_VERSION__', deviceOSVersion); diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 4d68bcdf2..3f4dd4145 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -319,7 +319,7 @@ lane :upload_file_to_s3 do |options| }) end - if options[:file] == 'Mattermost-simulator-x86_64.app.zip' + if options[:file]&.start_with?('Mattermost-simulator-') && options[:file]&.end_with?('.app.zip') pretext = '#### New iOS build for VM/Simulator' msg = "Download link: #{links.first}" send_message_to_mattermost({ @@ -419,10 +419,22 @@ platform :ios do lane :simulator do UI.success('Building iOS app for simulator') - output_file = "Mattermost-simulator-x86_64.app.zip" + # Detect the host architecture + host_arch = `uname -m`.strip + + # Use arm64 for Apple Silicon (arm64), x86_64 for Intel + if host_arch == "arm64" + build_arch = "arm64" + output_file = "Mattermost-simulator-arm64.app.zip" + else + build_arch = "x86_64" + output_file = "Mattermost-simulator-x86_64.app.zip" + end + + UI.message("Building for #{build_arch} architecture on #{host_arch} host") build_ios_unsigned_or_simulator( - more_xc_args: "-sdk iphonesimulator -arch x86_64", + more_xc_args: "-sdk iphonesimulator -arch #{build_arch}", rel_build_dir: "Release-iphonesimulator", output_file: output_file, )