Skip to content
Merged
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
10 changes: 6 additions & 4 deletions .github/workflows/e2e-test-cron.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
name: Test iOS ${{ matrix.ios }}
if: github.event_name == 'schedule' || inputs.ios
runs-on: macos-15
timeout-minutes: 180
timeout-minutes: 360
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -197,10 +197,12 @@ jobs:
working-directory: examples/SampleApp
run: bundle exec fastlane allure_launch cron:true
continue-on-error: true
# Below the job timeout, so an overrunning suite fails instead of cancelling the job, and the
# Allure upload, the logs and the Slack report still run.
# A macos-15 runner is about 4x slower than an M-series Mac: one pass takes well over an hour,
# and the retries come on top (the Flutter nightly allows 360 minutes too). Below the job
# timeout, so an overrunning suite fails instead of cancelling the job, and the Allure upload,
# the logs and the Slack report still run.
- name: Run e2e tests
timeout-minutes: 120
timeout-minutes: 300
working-directory: examples/SampleApp
run: bundle exec fastlane test_e2e platform:ios skip_build:true mock_server_branch:"$MOCK_SERVER_BRANCH"
- name: Upload Allure results
Expand Down
3 changes: 3 additions & 0 deletions examples/E2ETestApp/e2e/pages/MessageListPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export const MessageListPage = {
},

Alert: {
/**
* Matches twice from iOS 26 (a wrapper and the action view, with the same frame): use index 0.
*/
button(text: string) {
return device.getPlatform() === 'android'
? by.text(text).and(by.type('android.widget.Button'))
Expand Down
46 changes: 35 additions & 11 deletions examples/E2ETestApp/e2e/robots/UserRobot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
indexFromBottom,
isAnyDisplayed,
isDisplayed,
retryWhileNotHittable,
sleep,
waitForExistence,
waitToAppear,
Expand All @@ -33,9 +34,9 @@ const { Alert, Composer, ContextMenu, Header, MessageList } = MessageListPage;
*/
const stepScroll = async (direction: 'up' | 'down') => {
const ios = device.getPlatform() === 'ios';
await (
await waitToAppear(MessageList.scrollTarget, { index: 0 })
).swipe(direction, 'slow', ios ? 0.3 : 0.5);
const list = await waitToAppear(MessageList.scrollTarget, { index: 0 });
// A list still sliding in (a thread opening) is not hittable yet.
await retryWhileNotHittable(() => list.swipe(direction, 'slow', ios ? 0.3 : 0.5));
await sleep(ios ? 1000 : 300);
};

Expand Down Expand Up @@ -157,10 +158,31 @@ export class UserRobot extends Robot {
return this.perform(async () => {
if (device.getPlatform() === 'android') {
await device.pressBack();
} else {
await waitToAppearAndClick(ContextMenu.backdrop);
await waitToDisappear(ContextMenu.backdrop);
return;
}
// The backdrop is transparent: Detox iOS 17 never counts it as visible, so this waits for it
// to exist, then taps a point of it away from the menu (top left, below the header). A tap
// that lands while the menu still animates in is dropped, so it taps again until the backdrop
// is gone: until then its closing layer swallows the next tap or passes for a reopened menu.
await waitForExistence(ContextMenu.backdrop);
await sleep(1000);
for (let attempt = 1; attempt <= 3; attempt++) {
// A retry may find the backdrop already gone between the check and the tap.
await element(ContextMenu.backdrop)
.atIndex(0)
.tap({ x: 20, y: 150 })
.catch((error) => {
if (attempt === 1) throw error;
});
// waitForExistence returns whether the backdrop still exists.
if (
!(await waitForExistence(ContextMenu.backdrop, { shouldExist: false, timeout: 5000 }))
) {
break;
}
}
await waitToDisappear(ContextMenu.backdrop);
await sleep(500);
});
}

Expand Down Expand Up @@ -344,7 +366,8 @@ export class UserRobot extends Robot {
} else {
await element(CreatePoll.fields).atIndex(0).tapReturnKey();
}
await waitToAppearAndClick(CreatePoll.multipleVotesSwitch, { index: 0 });
// The sheet moves down as the keyboard hides: a tap during that is dropped on iOS.
await waitToSettleAndClick(CreatePoll.multipleVotesSwitch, { index: 0 });
}
// iOS reports the label on both the gesture-handler view and its inner button.
await waitToAppearAndClick(CreatePoll.createButton, { index: 0 });
Expand Down Expand Up @@ -372,8 +395,8 @@ export class UserRobot extends Robot {
async () => {
// Confirmation, where the SDK asks for one.
const confirm = MessageListPage.Alert.button('End');
if (await isDisplayed(confirm)) {
await waitToAppearAndClick(confirm);
if (await isDisplayed(confirm, 0)) {
await waitToAppearAndClick(confirm, { index: 0 });
}
},
);
Expand Down Expand Up @@ -520,7 +543,7 @@ export class UserRobot extends Robot {
}

tapOnAlertButton(text: string): this {
return this.perform(() => waitToAppearAndClick(Alert.button(text)));
return this.perform(() => waitToAppearAndClick(Alert.button(text), { index: 0 }));
}

/**
Expand All @@ -543,7 +566,8 @@ export class UserRobot extends Robot {
? this.openContextMenu(messageCellIndex).tapOnContextMenuAction('threadReply')
: this.perform(async () => {
const index = await indexFromBottom(MessageList.Message.threadRepliesLabel, 0);
await waitToAppearAndClick(MessageList.Message.threadRepliesLabel, { index });
// The label re-renders as the reply count changes; a tap during that is dropped on iOS.
await waitToSettleAndClick(MessageList.Message.threadRepliesLabel, { index });
});
return opened.perform(() => waitToAppear(Composer.alsoSendToChannelCheckbox));
}
Expand Down
12 changes: 11 additions & 1 deletion examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,17 @@ UserRobot.prototype.assertThreadReplyLabel = function (replies, inThread = false

UserRobot.prototype.assertMessages = function (text, expected) {
return this.perform(async () => {
jestExpect(await waitForCount(Message.text(text), expected)).toBe(expected);
if (device.getPlatform() !== 'ios') {
jestExpect(await waitForCount(Message.text(text), expected)).toBe(expected);
return;
}
// Only the ones on screen: iOS 17 still reports the hidden channel under an open thread.
let actual = 0;
await waitUntil(async () => {
actual = (await allAttributes(Message.text(text))).filter((match) => match.visible).length;
return actual === expected;
});
jestExpect(actual).toBe(expected);
});
};

Expand Down
5 changes: 4 additions & 1 deletion examples/E2ETestApp/e2e/support/Element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ export const waitToAppear = async (
* Retries `action` while Detox reports the element as not hittable (e.g. covered by the keyboard
* while it animates in). iOS runs without Detox's idle synchronization, which used to absorb that.
*/
const retryWhileNotHittable = async (action: () => Promise<void>, timeout = defaultTimeout) => {
export const retryWhileNotHittable = async (
action: () => Promise<void>,
timeout = defaultTimeout,
) => {
const deadline = Date.now() + timeout;
for (;;) {
try {
Expand Down
22 changes: 22 additions & 0 deletions examples/E2ETestApp/e2e/support/IOSSimulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,25 @@ export const grantIOSPermissions = () => {
]);
run('xcrun', ['simctl', 'spawn', device.id, 'launchctl', 'stop', 'com.apple.tccd']);
};

/**
* The major iOS version of the simulator under test, from its runtime
* (`com.apple.CoreSimulator.SimRuntime.iOS-17-5`); `undefined` off iOS or when it can't be found.
*/
export const iosMajorVersion = (): number | undefined => {
if (device.getPlatform() !== 'ios') {
return undefined;
}
try {
const { devices } = JSON.parse(
execFileSync('xcrun', ['simctl', 'list', 'devices', '-j'], { encoding: 'utf8' }),
) as { devices: Record<string, { udid: string }[]> };
const runtime = Object.keys(devices).find((key) =>
devices[key].some((simulator) => simulator.udid === device.id),
);
const major = runtime?.match(/iOS-(\d+)-/)?.[1];
return major === undefined ? undefined : Number(major);
} catch {
return undefined;
}
};
12 changes: 12 additions & 0 deletions examples/E2ETestApp/e2e/support/StreamTestCase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { dismissAnrDialog } from './AndroidSystemDialogs';
import { CoverageCollector } from './Coverage';
import { enableInternetConnection } from './Device';
import { iosMajorVersion } from './IOSSimulator';
import { grantIOSPermissions } from './IOSSimulator';

import { MockServer } from '../mock-server/MockServer';
Expand Down Expand Up @@ -118,3 +119,14 @@ export const itOnAndroid: typeof it = ((name: string, fn: () => Promise<void>, t
*/
export const itOnIOS: typeof it = ((name: string, fn: () => Promise<void>, timeout?: number) =>
device.getPlatform() === 'ios' ? it(name, fn, timeout) : it.skip(name, fn, timeout)) as typeof it;

/**
* `it`, except `it.skip` on iOS below 18: for tests that hit an SDK bug only older iOS versions
* show. Name the ticket in a comment above each one. Runs when the version can't be read.
*/
export const itExceptOnOldIOS: typeof it = ((
name: string,
fn: () => Promise<void>,
timeout?: number,
) =>
(iosMajorVersion() ?? 18) < 18 ? it.skip(name, fn, timeout) : it(name, fn, timeout)) as typeof it;
5 changes: 3 additions & 2 deletions examples/E2ETestApp/e2e/tests/MessageListTests.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
goToForeground,
} from '../support/Device';
import { allureId, step } from '../support/step';
import { itOnAndroid, setUpStreamTestCase } from '../support/StreamTestCase';
import { itExceptOnOldIOS, itOnAndroid, setUpStreamTestCase } from '../support/StreamTestCase';

describe('MessageListTests', () => {
const { backendRobot, participantRobot, userRobot } = setUpStreamTestCase();
Expand Down Expand Up @@ -413,7 +413,8 @@ describe('MessageListTests', () => {
);
});

it('test_reloadsSkippedMessages_whenScrolledToTheBottom', async () => {
// https://linear.app/stream/issue/RN-521 (iOS 17 only)
itExceptOnOldIOS('test_reloadsSkippedMessages_whenScrolledToTheBottom', async () => {
allureId(13165);
await step('GIVEN user opens the channel', async () => {
await backendRobot.generateChannels({ channelsCount: 1, messagesCount: 30 });
Expand Down
8 changes: 4 additions & 4 deletions examples/SampleApp/fastlane/Fastfile
Original file line number Diff line number Diff line change
Expand Up @@ -344,13 +344,13 @@ lane :e2e_check_required do
end

# `<test file>/<test>` ids of the tests that can run, e.g. `MessageListTests.e2e/test_x`: the coverage
# map's keys. `it.skip` tests are left out; `itOnAndroid` and `itOnIOS` ones are in (Jest skips them
# on the other platform).
# map's keys. `it.skip` tests are left out; `itOnAndroid`, `itOnIOS` and `itExceptOnOldIOS` ones are
# in (Jest skips them where they don't run).
def e2e_test_ids(tests_path)
Dir.glob(File.join(tests_path, '*.e2e.ts')).sort.flat_map do |file|
source = File.read(file)
calls = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS)(?:\.skip)?\(/).size
tests = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS)(\.skip)?\(\s*'(test_\w+)'/)
calls = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS|itExceptOnOldIOS)(?:\.skip)?\(/).size
tests = source.scan(/^\s*(?:it|itOnAndroid|itOnIOS|itExceptOnOldIOS)(\.skip)?\(\s*'(test_\w+)'/)
# A test this misses would never run on a pull request, so fail loudly instead.
UI.user_error!("#{file}: read #{tests.size} of its #{calls} tests; name each one with a `test_*` literal") if tests.size != calls

Expand Down
Loading