Skip to content
Open
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { expect as jestExpect } from '@jest/globals';
import { device, expect, element, by } from 'detox';
import { IosElementAttributes } from 'detox/detox';
import {
describeIfiOS,
forceTapByLabeliOS,
selectSingleFeatureTestsScreen,
} from '../../e2e-utils';

async function getElementAttributes(
testLabel: string,
): Promise<IosElementAttributes> {
const attrs = await element(by.label(testLabel)).getAttributes();
return attrs as IosElementAttributes;
}

Comment on lines +10 to +16
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getElementAttributes is defined but never used, and it duplicates the shared getElementAttributes helper in e2e-utils (which also guards against multiple matches). Remove this function or switch call sites to the shared helper to avoid dead code and inconsistent attribute handling.

Suggested change
async function getElementAttributes(
testLabel: string,
): Promise<IosElementAttributes> {
const attrs = await element(by.label(testLabel)).getAttributes();
return attrs as IosElementAttributes;
}

Copilot uses AI. Check for mistakes.
async function getTabBarFrame(): Promise<{
x: number;
y: number;
width: number;
height: number;
}> {
const attrs = await element(by.type('UITabBar')).getAttributes();
return (attrs as IosElementAttributes).frame;
}
async function getElementFrame(label: string): Promise<{
x: number;
y: number;
width: number;
height: number;
}> {
const attrs = await element(by.label(label)).getAttributes();
return (attrs as IosElementAttributes).frame;
}
Comment on lines +26 to +34
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getElementFrame assumes by.label(label) matches a single element and blindly casts getAttributes() to IosElementAttributes. In this scenario each tab renders identical labels (e.g. "Item 30"), and TabsContainer mounts all tab screens, so Detox can return a multi-match elements payload and this will crash or read the wrong frame. Scope queries to the active tab (e.g., withAncestor(by.id(scrollViewId))) and/or reuse getElementAttributes from e2e-utils which throws on multiple matches.

Copilot uses AI. Check for mistakes.

function isAboveTabBar(
itemFrame: { y: number; height: number },
tabBarFrame: { y: number },
): boolean {
return itemFrame.y + itemFrame.height < tabBarFrame.y;
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isAboveTabBar uses a strict < comparison; if the item’s bottom edge lands exactly on the tab bar’s y (common with pixel rounding), the helper will treat it as overlapping even though it’s fully visible. Use <= and consider a small epsilon tolerance to reduce flakiness.

Suggested change
return itemFrame.y + itemFrame.height < tabBarFrame.y;
const epsilon = 1;
const itemBottom = itemFrame.y + itemFrame.height;
return itemBottom <= tabBarFrame.y + epsilon;

Copilot uses AI. Check for mistakes.
}

async function scrollToMaxBottom(scrollViewId: string) {
await element(by.id(scrollViewId)).scrollTo('bottom', NaN, 0.5);
}
async function scrollToMaxTop(scrollViewId: string) {
await element(by.id(scrollViewId)).scrollTo('top', NaN, 0.5);
}

describeIfiOS('Override Scroll View Content Inset (iOS)', () => {
beforeAll(async () => {
await device.reloadReactNative();
await selectSingleFeatureTestsScreen(
'Tabs',
'test-tabs-override-scroll-view-content-inset-ios',
);
});

describe('False tab (overrideScrollViewContentInsetAdjustmentBehavior: false)', () => {
beforeEach(async () => {
await forceTapByLabeliOS('override-inset-tab-false');
});

it('should display the false tab scrollview with the tab bar visible', async () => {
await expect(
element(by.id('override-inset-false-scrollview')),
).toBeVisible();
await expect(element(by.type('UITabBar'))).toBeVisible();
});

it('should render the last item overlapping or behind the tab bar (not fully above it)', async () => {
await scrollToMaxBottom('override-inset-false-scrollview');
const lastItemFrame = await getElementFrame('Item 30');
const tabFrame = await getTabBarFrame();
jestExpect(isAboveTabBar(lastItemFrame, tabFrame)).toBe(false);
});

it('should hide the information text behind the inset ', async () => {
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name has a trailing space and refers to "behind the inset", but the scenario is about being clipped behind the navigation bar/tab bar. Tightening the wording (and removing the trailing space) will make failures easier to interpret.

Suggested change
it('should hide the information text behind the inset ', async () => {
it('should render the information text clipped behind the navigation bar', async () => {

Copilot uses AI. Check for mistakes.
await expect(
element(by.id('override-inset-false-scrollview')),
).toBeVisible();
await scrollToMaxTop('override-inset-false-scrollview');

const informationFrame = await getElementFrame(
'overrideScrollViewContentInsetAdjustmentBehavior: false',
);
jestExpect(informationFrame.y).toEqual(16);
Comment on lines +84 to +87
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The assertion informationFrame.y === 16 hard-codes an absolute pixel position, which is very likely to vary across devices, orientations, Dynamic Type, and different navigation bar configurations. Instead, assert relative positioning (e.g., compare the header frame against the navigation bar/tab bar frames, or compare False vs True/Default header positions).

Suggested change
const informationFrame = await getElementFrame(
'overrideScrollViewContentInsetAdjustmentBehavior: false',
);
jestExpect(informationFrame.y).toEqual(16);
const falseInformationFrame = await getElementFrame(
'overrideScrollViewContentInsetAdjustmentBehavior: false',
);
await forceTapByLabeliOS('override-inset-tab-true');
await expect(
element(by.id('override-inset-true-scrollview')),
).toBeVisible();
await scrollToMaxTop('override-inset-true-scrollview');
const trueInformationFrame = await getElementFrame(
'overrideScrollViewContentInsetAdjustmentBehavior: true',
);
jestExpect(falseInformationFrame.y).toBeLessThan(
trueInformationFrame.y,
);

Copilot uses AI. Check for mistakes.
});
});

describe('True tab (overrideScrollViewContentInsetAdjustmentBehavior: true)', () => {
beforeAll(async () => {
await element(by.label('override-inset-tab-true')).tap();
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tab selection here uses element(...).tap() instead of the iOS coordinate-based forceTapByLabeliOS. Other tab e2e tests in this repo use forced taps on iOS to avoid tab bar items being obstructed (e.g. by the iOS 26 “Liquid Glass lens”), so these taps are likely to be flaky on iOS. Use the same forced-tap helper for consistency and stability.

Suggested change
await element(by.label('override-inset-tab-true')).tap();
await forceTapByLabeliOS('override-inset-tab-true');

Copilot uses AI. Check for mistakes.
});

it('should display the true tab scrollview with the tab bar visible', async () => {
await expect(
element(by.id('override-inset-true-scrollview')),
).toBeVisible();
await expect(element(by.type('UITabBar'))).toBeVisible();
});

it('should render the last item fully above the tab bar (proper inset applied)', async () => {
await scrollToMaxBottom('override-inset-true-scrollview');
const lastItemFrame = await getElementFrame('Item 30');
const tabFrame = await getTabBarFrame();
jestExpect(isAboveTabBar(lastItemFrame, tabFrame)).toBe(true);
});

it('should show the header label visible (not hidden behind inset)', async () => {
await scrollToMaxTop('override-inset-true-scrollview');
await expect(
element(
by.label('overrideScrollViewContentInsetAdjustmentBehavior: true'),
),
).toBeVisible();
});
});
describe('Default tab (prop omitted)', () => {
beforeAll(async () => {
await element(by.label('override-inset-tab-default')).tap();
});
Comment on lines +120 to +122
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above: use forceTapByLabeliOS (or a shared forceSelectTabByLabel wrapper) for tab selection on iOS to avoid flakiness from obstructed tab bar items.

Copilot uses AI. Check for mistakes.

it('should display the default tab scrollview with the tab bar visible', async () => {
await expect(
element(by.id('override-inset-default-scrollview')),
).toBeVisible();
await expect(element(by.type('UITabBar'))).toBeVisible();
});

it('should render the last item fully above the tab bar (proper inset applied)', async () => {
await scrollToMaxBottom('override-inset-default-scrollview');
const lastItemFrame = await getElementFrame('Item 30');
const tabFrame = await getTabBarFrame();
jestExpect(isAboveTabBar(lastItemFrame, tabFrame)).toBe(true);
});

it('should show the header label visible (not hidden behind inset)', async () => {
await scrollToMaxTop('override-inset-default-scrollview');
await expect(
element(
by.label(
'overrideScrollViewContentInsetAdjustmentBehavior: (not set, defaults to true)',
),
),
).toBeVisible();
});
});

describe('Cross-tab comparison', () => {
beforeAll(async () => {
await forceTapByLabeliOS('override-inset-tab-default');
});

it('should show the information text visible (not hidden behind inset)', async () => {
await element(by.label('override-inset-tab-true')).tap();
await expect(
element(by.id('override-inset-true-scrollview')),
).toBeVisible();
await expect(element(by.label('Item 1'))).toBeVisible();
await expect(
element(
by.label('overrideScrollViewContentInsetAdjustmentBehavior: true'),
),
).toBeVisible();

await element(by.label('override-inset-tab-default')).tap();
await expect(
element(by.id('override-inset-default-scrollview')),
).toBeVisible();
await expect(element(by.label('Item 1'))).toBeVisible();
await expect(
Comment on lines +160 to +172
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These expect(element(by.label('Item 1'))) assertions are vulnerable to ambiguous matches because every tab renders the same item labels (and inactive tabs remain mounted). Scope the matcher to the active scroll view (e.g. withAncestor(by.id('override-inset-true-scrollview'))) or give the items stable testIDs to ensure Detox targets the correct element.

Copilot uses AI. Check for mistakes.
element(
by.label(
'overrideScrollViewContentInsetAdjustmentBehavior: (not set, defaults to true)',
),
),
).toBeVisible();
await element(by.label('override-inset-tab-true')).tap();
await expect(
Comment on lines +156 to +180
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tab selection in this cross-tab flow uses tap() on iOS. For consistency with other tab e2e tests (and to reduce flakiness when tab bar items are partially obstructed), use forceTapByLabeliOS for these tab item interactions as well.

Copilot uses AI. Check for mistakes.
element(by.id('override-inset-true-scrollview')),
).toBeVisible();
await expect(element(by.label('Item 1'))).toBeVisible();
await expect(
element(
by.label('overrideScrollViewContentInsetAdjustmentBehavior: true'),
),
).toBeVisible();
});

it('should show the information text visible (not hidden behind inset)', async () => {
await element(by.label('override-inset-tab-false')).tap();
await expect(
Comment on lines +155 to +193
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two it(...) blocks in the same describe have identical titles, which makes it harder to identify which one failed in CI output. Rename one (e.g., distinguish True/Default comparison vs False→True switching).

Copilot uses AI. Check for mistakes.
element(by.id('override-inset-false-scrollview')),
).toBeVisible();
await expect(element(by.label('Item 1'))).toBeVisible();
const informationFrame = await getElementFrame(
'overrideScrollViewContentInsetAdjustmentBehavior: false',
);
jestExpect(informationFrame.y).toEqual(16);

Comment on lines +197 to +201
Copy link

Copilot AI Apr 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This second informationFrame.y === 16 assertion has the same hard-coded pixel fragility as the earlier one and can easily break on different devices/safe-area insets. Prefer a relative assertion (e.g., compare the False header frame to the True header frame after scrolling both to top, or compare against the navigation bar frame).

Copilot uses AI. Check for mistakes.
await element(by.label('override-inset-tab-true')).tap();
await expect(
element(by.id('override-inset-true-scrollview')),
).toBeVisible();
await expect(element(by.label('Item 1'))).toBeVisible();
await expect(
element(
by.label('overrideScrollViewContentInsetAdjustmentBehavior: true'),
),
).toBeVisible();
});
});
});
4 changes: 2 additions & 2 deletions apps/src/tests/single-feature-tests/tabs/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ScenarioGroup } from '@apps/tests/shared/helpers';

import BottomAccessoryScenario from './bottom-accessory-layout';
import OverrideScrollViewContentInsetScenario from './override-scroll-view-content-inset';
import TestTabsOverrideScrollViewContentInset from './test-tabs-override-scroll-view-content-inset-ios';
import TestTabsTabBarHidden from './test-tabs-tab-bar-hidden';
import TabsScreenOrientationScenario from './tabs-screen-orientation';
import TabBarAppearanceDefinedBySelectedTabScenario from './test-tabs-appearance-defined-by-selected-tab';
Expand All @@ -18,7 +18,7 @@ import TestTabsSpecialEffectsScrollToTop from './test-tabs-special-effects-scrol

const scenarios = {
BottomAccessoryScenario,
OverrideScrollViewContentInsetScenario,
TestTabsOverrideScrollViewContentInset,
TabBarAppearanceDefinedBySelectedTabScenario,
TestTabsTabBarHidden,
TabsScreenOrientationScenario,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { createScenario } from '@apps/tests/shared/helpers';

const scenarioDescription: ScenarioDescription = {
name: 'Override ScrollView Content Inset',
key: 'override-scroll-view-content-inset',
key: 'test-tabs-override-scroll-view-content-inset-ios',
details:
'Tests overrideScrollViewContentInsetAdjustmentBehavior with different static values per tab. ' +
'False: content scrolls behind bars. True/Default: content is inset from bars.',
Expand All @@ -19,9 +19,15 @@ const scenarioDescription: ScenarioDescription = {

const ITEM_COUNT = 30;

function ScrollContent({ label }: { label: string }) {
function ScrollContent({
label,
testID,
}: {
label: string;
testID: string;
}) {
return (
<ScrollView style={styles.scrollView}>
<ScrollView style={styles.scrollView} testID={testID}>
<View style={styles.header}>
<Text style={styles.headerText}>
overrideScrollViewContentInsetAdjustmentBehavior: {label}
Expand All @@ -37,15 +43,30 @@ function ScrollContent({ label }: { label: string }) {
}

function FalseTab() {
return <ScrollContent label="false" />;
return (
<ScrollContent
label="false"
testID="override-inset-false-scrollview"
/>
);
}

function TrueTab() {
return <ScrollContent label="true" />;
return (
<ScrollContent
label="true"
testID="override-inset-true-scrollview"
/>
);
}

function DefaultTab() {
return <ScrollContent label="(not set, defaults to true)" />;
return (
<ScrollContent
label="(not set, defaults to true)"
testID="override-inset-default-scrollview"
/>
);
}

export function App() {
Expand All @@ -59,6 +80,7 @@ export function App() {
Component: FalseTab,
options: {
title: 'False',
tabBarItemAccessibilityLabel: 'override-inset-tab-false',
ios: {
overrideScrollViewContentInsetAdjustmentBehavior: false,
icon: { type: 'sfSymbol', name: 'xmark.circle' },
Expand All @@ -70,6 +92,7 @@ export function App() {
Component: TrueTab,
options: {
title: 'True',
tabBarItemAccessibilityLabel: 'override-inset-tab-true',
ios: {
overrideScrollViewContentInsetAdjustmentBehavior: true,
icon: { type: 'sfSymbol', name: 'checkmark.circle' },
Expand All @@ -81,6 +104,7 @@ export function App() {
Component: DefaultTab,
options: {
title: 'Default',
tabBarItemAccessibilityLabel: 'override-inset-tab-default',
ios: { icon: { type: 'sfSymbol', name: 'circle.dashed' } },
},
},
Expand Down
Loading
Loading