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); } };