Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { device, expect, element, by } from 'detox';
import { selectSingleFeatureTestsScreen } from '../../e2e-utils';

async function tapTab(label: string) {
if (device.getPlatform() === 'android') {
await element(by.text(`Tab ${label}`)).tap();
} else {
await element(by.id(`tab-${label.toLowerCase()}-item`)).tap();
}
}

async function dismissToast(message: string) {
await waitFor(element(by.label(message)))
.toBeVisible()
.withTimeout(3000);
await element(by.label(message)).tap();
}

describe('Tabs lifecycle events', () => {
beforeAll(async () => {
await device.reloadReactNative();
await selectSingleFeatureTestsScreen('Tabs', 'test-tabs-events');
});

it('should show Tab A content and fire onWillAppear + onDidAppear on launch', async () => {
await expect(element(by.id('tabContent-TabA'))).toBeVisible();
await dismissToast('2. TabA: onDidAppear');
await dismissToast('1. TabA: onWillAppear');
});

it('should fire four lifecycle events in order when switching from Tab A to Tab B', async () => {
await tapTab('B');

await expect(element(by.id('tabContent-TabB'))).toBeVisible();
if (device.getPlatform() === 'android') {
await dismissToast('4. TabB: onDidAppear');
await dismissToast('3. TabB: onWillAppear');
await dismissToast('2. TabA: onDidDisappear');
await dismissToast('1. TabA: onWillDisappear');
} else {
await dismissToast('4. TabA: onDidDisappear');
await dismissToast('3. TabB: onDidAppear');
await dismissToast('2. TabA: onWillDisappear');
await dismissToast('1. TabB: onWillAppear');
}
});

it('should fire four lifecycle events in order when switching from Tab B to Tab C', async () => {
await tapTab('C');

await expect(element(by.id('tabContent-TabC'))).toBeVisible();
if (device.getPlatform() === 'android') {
await dismissToast('4. TabC: onDidAppear');
await dismissToast('3. TabC: onWillAppear');
await dismissToast('2. TabB: onDidDisappear');
await dismissToast('1. TabB: onWillDisappear');
} else {
await dismissToast('4. TabB: onDidDisappear');
await dismissToast('3. TabC: onDidAppear');
await dismissToast('2. TabB: onWillDisappear');
await dismissToast('1. TabC: onWillAppear');
}
});

it('should fire four lifecycle events in order when switching from Tab C to Tab A', async () => {
await tapTab('A');

await expect(element(by.id('tabContent-TabA'))).toBeVisible();
if (device.getPlatform() === 'android') {
await dismissToast('4. TabA: onDidAppear');
await dismissToast('3. TabA: onWillAppear');
await dismissToast('2. TabC: onDidDisappear');
await dismissToast('1. TabC: onWillDisappear');
} else {
await dismissToast('4. TabC: onDidDisappear');
await dismissToast('3. TabA: onDidAppear');
await dismissToast('2. TabC: onWillDisappear');
await dismissToast('1. TabA: onWillAppear');
}
});

it('Android only: should not fire any lifecycle events when re-tapping the active tab', async () => {
if (device.getPlatform() === 'ios') {
return;
}
await tapTab('A');

await expect(element(by.id('tabContent-TabA'))).toBeVisible();

await expect(element(by.label('1. TabA: onWillAppear'))).not.toExist();
await expect(element(by.label('1. TabA: onDidAppear'))).not.toExist();
await expect(element(by.label('1. TabA: onWillDisappear'))).not.toExist();
await expect(element(by.label('1. TabA: onDidDisappear'))).not.toExist();
});
});
2 changes: 2 additions & 0 deletions apps/src/tests/single-feature-tests/tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import TestTabsPreventNativeSelection from './test-tabs-prevent-native-selection
import TestTabsStaleStateUpdateRejection from './test-tabs-stale-update-rejection';
import TestTabsTabBarMinimizeBehavior from './test-tabs-tab-bar-minimize-behavior-ios';
import TestTabsTabBarControllerMode from './test-tabs-tab-bar-controller-mode-ios';
import TestTabsEvents from './test-tabs-events';

const scenarios = {
BottomAccessoryScenario,
Expand All @@ -30,6 +31,7 @@ const scenarios = {
TestTabsStaleStateUpdateRejection,
TestTabsTabBarMinimizeBehavior,
TestTabsTabBarControllerMode,
TestTabsEvents,
};

const TabsScenarioGroup: ScenarioGroup<keyof typeof scenarios> = {
Expand Down
128 changes: 128 additions & 0 deletions apps/src/tests/single-feature-tests/tabs/test-tabs-events/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React, { useCallback, useMemo } from 'react';
import { StyleSheet, Text } from 'react-native';
import type { ScenarioDescription } from '@apps/tests/shared/helpers';
import { createScenario } from '@apps/tests/shared/helpers';
import {
TabsContainer,
type TabRouteConfig,
DEFAULT_TAB_ROUTE_OPTIONS,
useTabsNavigationContext,
} from '@apps/shared/gamma/containers/tabs';
import { CenteredLayoutView } from '@apps/shared/CenteredLayoutView';
import { ToastProvider, useToast } from '@apps/shared/';
import { Colors } from '@apps/shared/styling';

const scenarioDescription: ScenarioDescription = {
name: 'Tabs lifecycle events',
key: 'test-tabs-events',
details:
'Verify lifecycle events (onWillAppear, etc.) fire on tab switch',
platforms: ['ios', 'android'],
};

function TabScreen() {
const { routeKey } = useTabsNavigationContext();

return (
<CenteredLayoutView testID={`tabContent-${routeKey}`}>
<Text style={styles.tabLabel} testID={`tabLabel-${routeKey}`}>
{routeKey}
</Text>
<Text style={styles.tabHint}>Switch tabs to trigger lifecycle events</Text>
</CenteredLayoutView>
);
}

function AppContents() {
const toast = useToast();

const makeCallbacks = useCallback(
(tabName: string) => ({
onWillAppear: () =>
toast.push({
message: `${tabName}: onWillAppear`,
backgroundColor: Colors.GreenLight100,
}),
onDidAppear: () =>
toast.push({
message: `${tabName}: onDidAppear`,
backgroundColor: Colors.BlueLight100,
}),
onWillDisappear: () =>
toast.push({
message: `${tabName}: onWillDisappear`,
backgroundColor: Colors.NavyLight60,
}),
onDidDisappear: () =>
toast.push({
message: `${tabName}: onDidDisappear`,
backgroundColor: Colors.NavyLight100,
}),
}),
[toast],
);

const TAB_CONFIGS = useMemo<TabRouteConfig[]>(
() => [
{
name: 'TabA',
Component: TabScreen,
options: {
...DEFAULT_TAB_ROUTE_OPTIONS,
tabBarItemTestID: 'tab-a-item',
tabBarItemAccessibilityLabel: 'Tab A',
title: 'Tab A',
...makeCallbacks('TabA'),
},
},
{
name: 'TabB',
Component: TabScreen,
options: {
...DEFAULT_TAB_ROUTE_OPTIONS,
tabBarItemTestID: 'tab-b-item',
tabBarItemAccessibilityLabel: 'Tab B',
title: 'Tab B',
...makeCallbacks('TabB'),
},
},
{
name: 'TabC',
Component: TabScreen,
options: {
...DEFAULT_TAB_ROUTE_OPTIONS,
tabBarItemTestID: 'tab-c-item',
tabBarItemAccessibilityLabel: 'Tab C',
title: 'Tab C',
...makeCallbacks('TabC'),
},
},
],
[makeCallbacks],
);

return <TabsContainer routeConfigs={TAB_CONFIGS} />;
}

export function App() {
return (
<ToastProvider>
<AppContents />
</ToastProvider>
);
}

export default createScenario(App, scenarioDescription);

const styles = StyleSheet.create({
tabLabel: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 8,
},
tabHint: {
color: '#666',
fontSize: 13,
textAlign: 'center',
},
});
157 changes: 157 additions & 0 deletions apps/src/tests/single-feature-tests/tabs/test-tabs-events/scenario.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Test Scenario: Tabs lifecycle events

## Details

**Description:** Verifies that `onWillAppear`, `onDidAppear`,
`onWillDisappear`, and `onDidDisappear` fire in the correct order on tab
switches, covering happy-path transitions, re-tapping the active tab, and
rapid switching.

**OS test creation version:** iOS: 18.6 and 26.2, Android: 18.6.

## E2E test

Yes: Partially automated. The E2E test covers steps 1–4 on both iPhone and
Android, verifying baseline appearance events, all three tab-switch transitions
(with platform-specific event ordering). The re-tap (step 5) is covered only
for Android as for iOS 26+ Detox is not able to re-tap a tab bar item.

Not automated:

- Rapid switching (step 6) — cannot be reliably triggered through Detox's
synchronous interaction model.
- Full 12-toast sequence (step 7) — too fragile due to shifting toast indices
on each dismiss.

## Prerequisites

- iOS simulator or device (iPhone)
- Android emulator or device

## Note

- All four events should fire on every tab switch. The expected order for
a switch between TabX and TabY depends on platform
For iOS:
1. `TabY: onWillAppear`
2. `TabX: onWillDisappear`
3. `TabY: onDidAppear`
4. `TabX: onDidDisappear`
For Android:
1. `TabX: onWillDisappear`
2. `TabX: onDidDisappear`
3. `TabY: onWillAppear`
4. `TabY: onDidAppear`
- Toasts stack and dismiss automatically. To dismiss a toast manually,
tap it. Toast background colors by event type:
`onWillAppear` — green, `onWillDisappear` — light navy,
`onDidAppear` — light blue, `onDidDisappear` — dark navy.
- Re-tapping the currently active tab must not fire any lifecycle events.

## Steps

### Baseline

1. Launch the app and navigate to **Tabs lifecycle events**.

- [ ] Expected: Three tabs are visible in the tab bar: **Tab A**, **Tab B**,
and **Tab C**. **Tab A** is selected. Two toasts
appear for the initial Tab A appearance:
- `TabA: onWillAppear`
- `TabA: onDidAppear`

---

### Tab A → Tab B transition

2. Tap **Tab B** in the tab bar.

- [ ] Expected: The content area switches to show "TabB". Four toasts
appear in the following platform-specific order:

**iOS:**
1. `TabB: onWillAppear`
2. `TabA: onWillDisappear`
3. `TabB: onDidAppear`
4. `TabA: onDidDisappear`

**Android:**
1. `TabA: onWillDisappear`
2. `TabA: onDidDisappear`
3. `TabB: onWillAppear`
4. `TabB: onDidAppear`

---

### Tab B → Tab C transition

3. Tap **Tab C** in the tab bar.

- [ ] Expected: The content area switches to show "TabC". Four toasts
appear in the following platform-specific order:

**iOS:**
1. `TabC: onWillAppear`
2. `TabB: onWillDisappear`
3. `TabC: onDidAppear`
4. `TabB: onDidDisappear`

**Android:**
1. `TabB: onWillDisappear`
2. `TabB: onDidDisappear`
3. `TabC: onWillAppear`
4. `TabC: onDidAppear`

---

### Tab C → Tab A transition

4. Tap **Tab A** in the tab bar.

- [ ] Expected: The content area switches to show "TabA". Four toasts
appear in the following platform-specific order:

**iOS:**
1. `TabA: onWillAppear`
2. `TabC: onWillDisappear`
3. `TabA: onDidAppear`
4. `TabC: onDidDisappear`

**Android:**
1. `TabC: onWillDisappear`
2. `TabC: onDidDisappear`
3. `TabA: onWillAppear`
4. `TabA: onDidAppear`

---

### Re-tapping the active tab (edge case)

5. With **Tab A** selected, tap **Tab A** again in the tab bar.

- [ ] Expected: The content area does not change. No toast notifications
appear. No lifecycle events fire for a tap on the already-active tab.

---

### Rapid tab switching (edge case)

6. Tap **Tab B**, then immediately tap **Tab C** before the toasts from
the previous step have finished dismissing.

- [ ] Expected: Both transitions complete. Toasts from the B→C transition
appear after the A→B toasts. The final selected
tab is **Tab C** and its content area shows "TabC". No events are
missing or duplicated — all eight toasts from both transitions are
eventually shown.

---

### Full round-trip verification

7. From **Tab C**, tap **Tab A**, then **Tab B**, then **Tab C**.

- [ ] Expected: Each tab switch produces exactly four toasts (will/did
disappear for the leaving tab, will/did appear for the arriving tab).
After three switches, twelve toasts in total have been fired. The final
selected tab is **Tab C**.
Loading