From 2440b5c5dfbdd3ad26b3a365221e7f7cc79af706 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Wed, 7 Oct 2026 12:08:16 -0700 Subject: [PATCH 1/5] Skip android-release-only Maestro flows on iOS RNTester The iOS Maestro runner ignored flow tags, so the maintainVisibleContentPosition flows tagged android-release-only also ran on iOS. Since #58900 changed the RNTester tab bar insets, flatlist-append-maintainvisible fails deterministically on iOS and blocks the iOS RNTester E2E job on every PR. Add exclude-tags support to the iOS runner (reusing the Android tag filter) and skip android-release-only flows in the iOS RNTester workflow until the inset regression is fixed. --- .github/actions/maestro-ios/action.yml | 7 ++++- .../__tests__/maestro-ios-test.js | 24 +++++++++++++++++ .github/workflow-scripts/maestro-ios.js | 26 +++++++++++++++---- .github/workflows/e2e-ios-rntester.yml | 3 +++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/.github/actions/maestro-ios/action.yml b/.github/actions/maestro-ios/action.yml index b5e4036d6280..87a52d569524 100644 --- a/.github/actions/maestro-ios/action.yml +++ b/.github/actions/maestro-ios/action.yml @@ -26,6 +26,10 @@ inputs: required: false default: '' description: Maestro device OS name, such as iOS-26-2 + exclude-tags: + required: false + default: '' + description: Comma-separated flow tags to exclude from the test run runs: using: composite @@ -73,7 +77,8 @@ runs: "${{ inputs.flavor }}" \ "${{ inputs.working-directory }}" \ "${{ inputs.device-model }}" \ - "${{ inputs.device-os }}" + "${{ inputs.device-os }}" \ + "${{ inputs.exclude-tags }}" - name: Store video record if: always() uses: ./.github/actions/upload-artifact diff --git a/.github/workflow-scripts/__tests__/maestro-ios-test.js b/.github/workflow-scripts/__tests__/maestro-ios-test.js index f5f9686e4e09..ab3885cfbba6 100644 --- a/.github/workflow-scripts/__tests__/maestro-ios-test.js +++ b/.github/workflow-scripts/__tests__/maestro-ios-test.js @@ -14,6 +14,7 @@ jest.mock('child_process', () => ({ jest.mock('fs', () => ({ existsSync: jest.fn(), lstatSync: jest.fn(), + readFileSync: jest.fn(), readdirSync: jest.fn(), })); @@ -55,6 +56,29 @@ describe('Maestro iOS runner', () => { ); }); + it('skips flows with excluded tags', async () => { + jest.spyOn(console, 'info').mockImplementation(() => {}); + fs.existsSync.mockReturnValue(true); + fs.lstatSync.mockImplementation(path => ({ + isDirectory: () => path === 'flows/', + })); + fs.readdirSync.mockReturnValue(['excluded.yml', 'regular.yml']); + fs.readFileSync.mockImplementation(path => + path === 'flows/excluded.yml' + ? 'appId: x\ntags:\n - android-release-only\n---\n- launchApp\n' + : 'appId: x\n---\n- launchApp\n', + ); + + await executeFlows('com.example', 'device-id', 'flows/', 'Hermes', [ + 'android-release-only', + ]); + + expect(childProcess.execSync).toHaveBeenCalledTimes(1); + expect(childProcess.execSync.mock.calls[0][0]).toContain( + 'test "flows/regular.yml"', + ); + }); + it('retries only the failing flow', async () => { fs.existsSync.mockReturnValue(false); childProcess.execSync.mockImplementationOnce(() => { diff --git a/.github/workflow-scripts/maestro-ios.js b/.github/workflow-scripts/maestro-ios.js index a9782ec6506f..02626c457b3a 100644 --- a/.github/workflow-scripts/maestro-ios.js +++ b/.github/workflow-scripts/maestro-ios.js @@ -9,10 +9,11 @@ const childProcess = require('child_process'); const fs = require('fs'); +const {filterFlowsByTags} = require('./maestro-android'); const usage = ` === Usage === -node maestro-ios.js [device_model] [device_os] +node maestro-ios.js [device_model] [device_os] [exclude_tags] @param {string} appPath - Path to the app APK @param {string} appId - App ID that needs to be launched @@ -22,6 +23,7 @@ node maestro-ios.js 8) { + if (args.length < 6 || args.length > 9) { throw new Error(`Invalid number of arguments.\n${usage}`); } @@ -249,6 +260,10 @@ async function main(args = process.argv.slice(2)) { const workingDirectory = args[5]; const deviceModel = args[6] || null; const deviceOS = args[7] || null; + const excludeTags = (args[8] ?? '') + .split(',') + .map(tag => tag.trim()) + .filter(Boolean); console.info('\n=============================='); console.info('Running tests for iOS with the following parameters:'); @@ -260,6 +275,7 @@ async function main(args = process.argv.slice(2)) { console.info(`WORKING_DIRECTORY: ${workingDirectory}`); console.info(`DEVICE_MODEL: ${deviceModel ?? ''}`); console.info(`DEVICE_OS: ${deviceOS ?? ''}`); + console.info(`EXCLUDE_TAGS: ${excludeTags.join(',') || ''}`); console.info('==============================\n'); const simulator = findAvailableSimulator(deviceModel, deviceOS); @@ -267,7 +283,7 @@ async function main(args = process.argv.slice(2)) { installAppOnSimulator(appPath, simulator.udid); bringSimulatorInForeground(); await launchAppOnSimulator(appId, simulator.udid, isDebug); - await executeFlows(appId, simulator.udid, maestroFlow, jsengine); + await executeFlows(appId, simulator.udid, maestroFlow, jsengine, excludeTags); console.log('Test finished'); } diff --git a/.github/workflows/e2e-ios-rntester.yml b/.github/workflows/e2e-ios-rntester.yml index 0660257487bf..c20acdeee835 100644 --- a/.github/workflows/e2e-ios-rntester.yml +++ b/.github/workflows/e2e-ios-rntester.yml @@ -46,6 +46,9 @@ jobs: flavor: ${{ matrix.flavor }} device-model: iPhone-17-Pro device-os: iOS-26-2 + # The maintainVisibleContentPosition flows fail on iOS since the + # RNTester tab bar inset change in #58900. Skip them until it's fixed. + exclude-tags: android-release-only - name: Report status id: report-status if: ${{ always() && steps.run-tests.outcome == 'failure' }} From 1e63505ae58124c5cd7dd05a0994115b98bc1f2f Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Wed, 7 Oct 2026 12:11:29 -0700 Subject: [PATCH 2/5] Save Maestro debug output for iOS E2E flows Drop the android-release-only skip: the failing flow needs to run so its debug output can show where Maestro taps. Write each attempt's debug output to its own folder under /tmp/MaestroLogs, which the maestro-ios action already uploads on failure. --- .github/actions/maestro-ios/action.yml | 7 +--- .../__tests__/maestro-ios-test.js | 24 ------------ .github/workflow-scripts/maestro-ios.js | 37 ++++++++----------- .github/workflows/e2e-ios-rntester.yml | 3 -- 4 files changed, 16 insertions(+), 55 deletions(-) diff --git a/.github/actions/maestro-ios/action.yml b/.github/actions/maestro-ios/action.yml index 87a52d569524..b5e4036d6280 100644 --- a/.github/actions/maestro-ios/action.yml +++ b/.github/actions/maestro-ios/action.yml @@ -26,10 +26,6 @@ inputs: required: false default: '' description: Maestro device OS name, such as iOS-26-2 - exclude-tags: - required: false - default: '' - description: Comma-separated flow tags to exclude from the test run runs: using: composite @@ -77,8 +73,7 @@ runs: "${{ inputs.flavor }}" \ "${{ inputs.working-directory }}" \ "${{ inputs.device-model }}" \ - "${{ inputs.device-os }}" \ - "${{ inputs.exclude-tags }}" + "${{ inputs.device-os }}" - name: Store video record if: always() uses: ./.github/actions/upload-artifact diff --git a/.github/workflow-scripts/__tests__/maestro-ios-test.js b/.github/workflow-scripts/__tests__/maestro-ios-test.js index ab3885cfbba6..f5f9686e4e09 100644 --- a/.github/workflow-scripts/__tests__/maestro-ios-test.js +++ b/.github/workflow-scripts/__tests__/maestro-ios-test.js @@ -14,7 +14,6 @@ jest.mock('child_process', () => ({ jest.mock('fs', () => ({ existsSync: jest.fn(), lstatSync: jest.fn(), - readFileSync: jest.fn(), readdirSync: jest.fn(), })); @@ -56,29 +55,6 @@ describe('Maestro iOS runner', () => { ); }); - it('skips flows with excluded tags', async () => { - jest.spyOn(console, 'info').mockImplementation(() => {}); - fs.existsSync.mockReturnValue(true); - fs.lstatSync.mockImplementation(path => ({ - isDirectory: () => path === 'flows/', - })); - fs.readdirSync.mockReturnValue(['excluded.yml', 'regular.yml']); - fs.readFileSync.mockImplementation(path => - path === 'flows/excluded.yml' - ? 'appId: x\ntags:\n - android-release-only\n---\n- launchApp\n' - : 'appId: x\n---\n- launchApp\n', - ); - - await executeFlows('com.example', 'device-id', 'flows/', 'Hermes', [ - 'android-release-only', - ]); - - expect(childProcess.execSync).toHaveBeenCalledTimes(1); - expect(childProcess.execSync.mock.calls[0][0]).toContain( - 'test "flows/regular.yml"', - ); - }); - it('retries only the failing flow', async () => { fs.existsSync.mockReturnValue(false); childProcess.execSync.mockImplementationOnce(() => { diff --git a/.github/workflow-scripts/maestro-ios.js b/.github/workflow-scripts/maestro-ios.js index 02626c457b3a..418c932ebc78 100644 --- a/.github/workflow-scripts/maestro-ios.js +++ b/.github/workflow-scripts/maestro-ios.js @@ -9,11 +9,11 @@ const childProcess = require('child_process'); const fs = require('fs'); -const {filterFlowsByTags} = require('./maestro-android'); +const path = require('path'); const usage = ` === Usage === -node maestro-ios.js [device_model] [device_os] [exclude_tags] +node maestro-ios.js [device_model] [device_os] @param {string} appPath - Path to the app APK @param {string} appId - App ID that needs to be launched @@ -23,11 +23,11 @@ node maestro-ios.js 9) { + if (args.length < 6 || args.length > 8) { throw new Error(`Invalid number of arguments.\n${usage}`); } @@ -260,10 +258,6 @@ async function main(args = process.argv.slice(2)) { const workingDirectory = args[5]; const deviceModel = args[6] || null; const deviceOS = args[7] || null; - const excludeTags = (args[8] ?? '') - .split(',') - .map(tag => tag.trim()) - .filter(Boolean); console.info('\n=============================='); console.info('Running tests for iOS with the following parameters:'); @@ -275,7 +269,6 @@ async function main(args = process.argv.slice(2)) { console.info(`WORKING_DIRECTORY: ${workingDirectory}`); console.info(`DEVICE_MODEL: ${deviceModel ?? ''}`); console.info(`DEVICE_OS: ${deviceOS ?? ''}`); - console.info(`EXCLUDE_TAGS: ${excludeTags.join(',') || ''}`); console.info('==============================\n'); const simulator = findAvailableSimulator(deviceModel, deviceOS); @@ -283,7 +276,7 @@ async function main(args = process.argv.slice(2)) { installAppOnSimulator(appPath, simulator.udid); bringSimulatorInForeground(); await launchAppOnSimulator(appId, simulator.udid, isDebug); - await executeFlows(appId, simulator.udid, maestroFlow, jsengine, excludeTags); + await executeFlows(appId, simulator.udid, maestroFlow, jsengine); console.log('Test finished'); } diff --git a/.github/workflows/e2e-ios-rntester.yml b/.github/workflows/e2e-ios-rntester.yml index c20acdeee835..0660257487bf 100644 --- a/.github/workflows/e2e-ios-rntester.yml +++ b/.github/workflows/e2e-ios-rntester.yml @@ -46,9 +46,6 @@ jobs: flavor: ${{ matrix.flavor }} device-model: iPhone-17-Pro device-os: iOS-26-2 - # The maintainVisibleContentPosition flows fail on iOS since the - # RNTester tab bar inset change in #58900. Skip them until it's fixed. - exclude-tags: android-release-only - name: Report status id: report-status if: ${{ always() && steps.run-tests.outcome == 'failure' }} From a57e24c0bef3f6720006e34fab28692f3849ee5c Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Wed, 7 Oct 2026 12:44:10 -0700 Subject: [PATCH 3/5] Keep RNTester content clear of the iOS tab bar #58900 capped the bottom content inset at 65pt for bottom tab bars, but the tab bar is 83pt tall on current iPhones. The bottom 18pt of every RNTester screen sat under the tab bar, so taps there switched tabs. flatlist-append-maintainvisible taps "Reset" at y=874 and landed on the Playground tab. Use the full bottom safe-area inset instead. --- .../RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm b/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm index d169fdcd4149..b239d37ce03f 100644 --- a/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm +++ b/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm @@ -22,9 +22,8 @@ using namespace facebook::react; -// Preserve RNTester's existing example bounds on compact bottom-tab layouts. +// Preserve RNTester's existing example top bound on compact bottom-tab layouts. static constexpr CGFloat LegacyTopInset = 50; -static constexpr CGFloat LegacyBottomInset = 65; /** * A tab's view controller, which reports when the tab bar or window changes @@ -221,7 +220,7 @@ - (void)tabSafeAreaInsetsDidChange:(UIEdgeInsets)insets CGRectGetMaxY(tabBarFrame) >= CGRectGetMaxY(bounds); if (hasBottomTabBar) { insets.top = MAX(insets.top, LegacyTopInset); - insets.bottom = MIN(insets.bottom, LegacyBottomInset); + // Keep the full bottom inset: anything less leaves content under the tab bar, where taps switch tabs. } else { insets.bottom = 0; // A window with no status bar along the top, such as the iPhone Duo's, reports no top inset. Keep From 239db25cd149a0cbdd0a770e5dd0062d93867343 Mon Sep 17 00:00:00 2001 From: Riccardo Cipolleschi Date: Wed, 7 Oct 2026 12:44:46 -0700 Subject: [PATCH 4/5] Drop Maestro debug output change from this PR --- .github/workflow-scripts/maestro-ios.js | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/.github/workflow-scripts/maestro-ios.js b/.github/workflow-scripts/maestro-ios.js index 418c932ebc78..a9782ec6506f 100644 --- a/.github/workflow-scripts/maestro-ios.js +++ b/.github/workflow-scripts/maestro-ios.js @@ -9,7 +9,6 @@ const childProcess = require('child_process'); const fs = require('fs'); -const path = require('path'); const usage = ` === Usage === @@ -27,7 +26,6 @@ node maestro-ios.js Date: Wed, 7 Oct 2026 12:46:23 -0700 Subject: [PATCH 5/5] Use MAX for the bottom inset so it never drops below the tab bar --- .../RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm b/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm index b239d37ce03f..8ea5809a7d4a 100644 --- a/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm +++ b/packages/rn-tester/RNTesterTabsIOS/ios/RNTesterTabsIOSComponentView.mm @@ -22,8 +22,9 @@ using namespace facebook::react; -// Preserve RNTester's existing example top bound on compact bottom-tab layouts. +// Preserve RNTester's existing example bounds on compact bottom-tab layouts. static constexpr CGFloat LegacyTopInset = 50; +static constexpr CGFloat LegacyBottomInset = 65; /** * A tab's view controller, which reports when the tab bar or window changes @@ -220,7 +221,8 @@ - (void)tabSafeAreaInsetsDidChange:(UIEdgeInsets)insets CGRectGetMaxY(tabBarFrame) >= CGRectGetMaxY(bounds); if (hasBottomTabBar) { insets.top = MAX(insets.top, LegacyTopInset); - // Keep the full bottom inset: anything less leaves content under the tab bar, where taps switch tabs. + // Never go below the system inset: anything less leaves content under the tab bar, where taps switch tabs. + insets.bottom = MAX(insets.bottom, LegacyBottomInset); } else { insets.bottom = 0; // A window with no status bar along the top, such as the iPhone Duo's, reports no top inset. Keep