diff --git a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml
index dcd82215a6..32f558c6ef 100644
--- a/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml
+++ b/examples/E2ETestApp/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,9 @@
+
};
+/** A port free on the host's IPv4 and IPv6 loopback: `::` listens on both (dual stack). */
+const freePort = () =>
+ new Promise((resolve, reject) => {
+ const server = createServer();
+ server.once('error', reject);
+ server.listen({ host: '::', port: 0 }, () => {
+ const address = server.address();
+ server.close(() =>
+ address && typeof address === 'object'
+ ? resolve(address.port)
+ : reject(new Error('No free port')),
+ );
+ });
+ });
+
/**
* Client for `stream-chat-test-mock-server`. The driver (`driver.rb`) spawns one mock server per
* test on its own port; `start` asks it for one and `stop` shuts it down.
@@ -27,13 +44,32 @@ export class MockServer {
return this.url.replace('localhost', host);
}
- /** Never retried: every call to the driver's `/start` spawns a new server on a new port. */
- async start(testName: string): Promise {
- const response = await this.execute(
- `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}?api_version=v2×tamps=ns`,
- );
- this.port = (await response.text()).trim();
- await this.waitUntilReady();
+ /**
+ * Every call to the driver's `/start` spawns a new server, so a start is only retried after the
+ * server failed to answer, and always on a new port.
+ *
+ * The port is picked here rather than by the driver: on CI the driver's probe twice handed out a
+ * port the server then never answered on (41215, an hour apart, nightly 43138494994). This probe
+ * also checks IPv6, which `localhost` may resolve to.
+ */
+ async start(testName: string, attempts = 3): Promise {
+ for (let attempt = 1; ; attempt++) {
+ const port = await freePort();
+ const response = await this.execute(
+ `${MockServer.driverUrl}/start/${encodeURIComponent(testName)}` +
+ `?api_version=v2×tamps=ns&port=${port}`,
+ );
+ this.port = (await response.text()).trim();
+ try {
+ await this.waitUntilReady();
+ return;
+ } catch (error) {
+ await this.stop();
+ if (attempt >= attempts) {
+ throw error;
+ }
+ }
+ }
}
async stop(): Promise {
diff --git a/examples/E2ETestApp/e2e/robots/UserRobot.ts b/examples/E2ETestApp/e2e/robots/UserRobot.ts
index 26b206445f..9eea84db68 100644
--- a/examples/E2ETestApp/e2e/robots/UserRobot.ts
+++ b/examples/E2ETestApp/e2e/robots/UserRobot.ts
@@ -350,6 +350,12 @@ export class UserRobot extends Robot {
.perform(async () => {
await (await waitToAppearAndClick(CreatePoll.fields, { index: 0 })).replaceText(question);
for (const [index, option] of options.entries()) {
+ if (device.getPlatform() === 'android') {
+ // The keyboard, often already open from the composer, covers the option field that
+ // appears below the last filled one, so it never counts as visible. A tap outside
+ // the fields hides it (see below).
+ await element(CreatePoll.questionLabel).tap();
+ }
await (
await waitToAppearAndClick(CreatePoll.fields, { index: index + 1 })
).replaceText(option);
diff --git a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
index 278e33824c..aece96ded9 100644
--- a/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
+++ b/examples/E2ETestApp/e2e/robots/UserRobotMessageListAsserts.ts
@@ -104,6 +104,31 @@ declare module './UserRobot' {
}
}
+/**
+ * Waits until `selector` has been hidden for `stableMs` in a row. A single check can land before an
+ * element briefly shows: on iOS 17 the scroll-to-bottom button flashes while the list catches up
+ * with a message that just arrived, so "hidden" read once was a coin toss (nightly 43138494994).
+ */
+const waitUntilStaysHidden = async (
+ selector: Detox.NativeMatcher,
+ timeout: number,
+ stableMs = 1000,
+) => {
+ const deadline = Date.now() + timeout;
+ let hiddenSince: number | undefined;
+ while (Date.now() < deadline) {
+ if (await isDisplayed(selector, 0)) {
+ hiddenSince = undefined;
+ } else {
+ hiddenSince ??= Date.now();
+ if (Date.now() - hiddenSince >= stableMs) {
+ return;
+ }
+ }
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+};
+
export const assertVisibility = async (
selector: Detox.NativeMatcher,
shouldBeDisplayed: boolean,
@@ -123,6 +148,7 @@ export const assertVisibility = async (
}
} else {
await waitToDisappear(selector, { index: 0, timeout });
+ await waitUntilStaysHidden(selector, timeout);
jestExpect(await isDisplayed(selector, 0)).toBe(false);
}
};