mattermost-mobile-upstream/detox/e2e/support/ui/screen/thread.ts
cursor[bot] ffbc767505
Some checks are pending
ci / lint-typecheck (push) Waiting to run
ci / detox-lint-typecheck (push) Waiting to run
ci / test (push) Waiting to run
fix(e2e): Android sync toggle race in Browse Channels; add E2E flake-verify and autofix CI tooling (#10307)
* fix(e2e): hold Android sync off across plus-menu open and Browse Channels tap

Re-enabling Detox synchronization after the plus tap and disabling it again
immediately for the menu-item tap lets Espresso release the RN JS looper
idling resource before the busy looper has initialised it. Release then throws
(Looper.mQueue NPE) and leaves the resource reporting busy until the app
process restarts, which failed MM-T4932_3 and timed out the rest of
archive_channel_from_settings on main.

* ci(e2e): add flake-verify verdict for repeated baseline/candidate runs

Counts per test how often N fresh-device, no-retry runs of a main commit
(baseline) and a PR commit (candidate) failed. A clean candidate counts as
fixed only when the baseline reproduced the failure and a one-sided Fisher
exact test says the candidate fails less (p < 0.05); otherwise the result is
UNPROVEN with the 95% upper bound on the failure rate. Renders a sanitised PR
comment table.

* ci(e2e): let a Detox Android re-run turn off its retry and keep separate results; keep the run's server topology

e2e-android-template.yml gets max_attempts (default "2") and report-suffix
(default ""); e2e-detox.yml forwards max-attempts and results-suffix to the
Android job. Defaults leave every current run unchanged. e2e-detox-pr.yml
uploads the five server URLs as e2e-topology-<run id> so a later workflow can
re-run specs against the PR's servers.

* ci(e2e): add e2e-flake-verify workflow

workflow_dispatch from main: runs named Detox Android specs REPEATS times on a
PR commit and on a main commit, alternating, each on a fresh device with no
retry, reusing the PR's finished E2E run (app build and the five servers from
its e2e-topology artifact). Posts one verdict comment on the PR and the
informational e2e-test/flake-verify status on the candidate. Inputs are
validated through env only; forks, drafts, a moving head, a baseline off main
and a concurrent E2E run on the PR's servers are refused.

* ci(e2e): add e2e-tests-autofix-on-merge trigger (inert without secrets)

After each main E2E run, mattermost-test-automation-toolkit's e2e-test-watch
picks the Detox/Maestro tests that keep failing on main (TSIO groups
mobile-main-*), and each request is sent with the tested main commit as base
to the Cursor automation webhook. Without CURSOR_MOBILE_E2E_AUTOFIX_WEBHOOK_URL
and _KEY it only writes the job summary; E2E_ENABLE_AUTOFIX=false turns it off.
actionlint.yaml ignores the job key cache-mode, which actionlint does not know yet.

* docs(e2e): add the E2E autofix automation prompt, a dry-run request, and fix the fixed-wait advice

.cursor/automations/e2e-autofix.md is the self-contained prompt for the
Cursor automation the autofix workflow calls (CI-artifact evidence only, no
Detox in the VM, verification through e2e-flake-verify.yml, binding lessons
L-001..L-008). dry-run-example.json replays the MM-T4932_3 failure of run
42193984868. detox/CLAUDE.md no longer tells readers to add
wait(timeouts.ONE_SEC) for CI-only failures, and describes the two new
workflows.

* ci(e2e): flake-verify does not call a fix proven while a candidate run has no result

A candidate iteration with no result can be the flake itself (a crashed
shard), so a significant p-value alone no longer makes the verdict fixed.

* ci(e2e): autofix uses the tested commit as base and skips runs from other repositories

Matterwick dispatches main runs on ref main with the commit to test in
MOBILE_VERSION, so workflow_run.head_sha can be a newer main commit; the
request's own commit (what the run reported to TSIO) is the base, head_sha
only the fallback. The job also requires the run to come from this
repository. The prompt header says exactly what to paste.

* ci(e2e): autofix records its ledger under main

e2e-test-watch/record defaults its branch to master, which is part of the
ledger's cache key; the plan step restores under main, so without this every
run would send the same requests again.

* ci(e2e): flake-verify gives a selected spec with no results a no-data row

A mistyped spec, or one whose runs all crashed, could otherwise be dropped
silently while another spec's fixed row made the verdict PASS.

* ci(e2e): flake-verify requires the baseline to be an ancestor of the candidate and reads a single result

Metro bundles the app JavaScript from each arm's own commit, so the arms must
differ by the PR's commits only; the header and the prompt now say that only
a native change cannot be verified. download-artifact extracts a single
matching artifact without its folder, which the verdict reader needs, so
that file is moved into a folder named after its artifact.

* fix(e2e): keep Browse Channels plus-menu checks synchronized; flake-verify baseline is the branch point

openPlusMenu ran dismissKnownModals, toBeVisible and the plus-button wait inside the Android sync-off
window that b683001 widened to cover the Browse Channels tap, so those pre-tap checks no longer waited
for the app to settle. openPlusMenu now takes an optional afterMenuOpen callback that runs inside the
same window as the plus tap, so the menu item tap still shares one sync-off window without re-enabling
sync between the two taps. BrowseChannelsScreen.open passes its tap through that callback.

e2e-flake-verify now requires BASELINE_SHA to equal the merge base of CANDIDATE_SHA and main, not just an
ancestor of it, so an older main commit can no longer make main-side fixes count as the PR's.

* fix(e2e): restore main's Browse Channels sync sequence so archive_channel passes

The archive_channel MM-T4932_1 hang (run 42620618054) is Detox's idle wait on browse_channels.screen
after the browse tap: sync is re-enabled there and mqt_v_js never goes idle while Browse channels is
already rendered. On main the sync enable happens between the plus tap and the browse tap, and
archive_channel passes there. The single sync-off window from b683001 conflicts with that, so the
helper files are restored to main. This drops the Android looper race fix from b683001 (MM-T4932_3 / MM-T1719_1).

* fix(e2e): keep the Android Browse Channels sync window opt-in; archive_channel keeps main's sequence

The single sync-off window over the plus tap and the Browse Channels tap (b683001) is now opt-in via
BrowseChannelsScreen.open({holdSyncAcrossTap: true}), used only by openArchivedChannelViaBrowseChannels
(the MM-T1719_1 race path). Every other caller, including archive_channel, keeps main's sequence: sync is
re-enabled between the plus tap and the menu-item tap. Run 42620618054 showed archive_channel hanging on
browse_channels.screen with sync re-enabled after the browse tap, so the combined window cannot be the default.
openPlusMenu keeps the optional afterMenuOpen callback; without it, it behaves as on main.

* fix(e2e): route every Detox sync toggle and reload through one depth-counted owner

archive_channel.e2e.ts MM-T4932_1/2/3 failed both attempts of run 42521284188:
device.reloadReactNative() re-registers Espresso's looper idling resources
(Detox ReactNativeExtension.reloadReactNative) and answers without waiting
for them; 5s later bestEffortScroll disabled sync, release() met a looper
the busy JS thread had not set yet (Looper.mQueue NPE), bestEffortScroll's
empty catch swallowed it, and the app read busy until the 300s timeout.

detox/e2e/support/utils/synchronization.ts now owns sync: one depth counter
for disableSynchronization/enableSynchronization/withSynchronizationDisabled,
and reloadApp, which settles the new resources with one synchronized check
on Android before anything can disable them. All 74 raw
device.(enable|disable)Synchronization/reloadReactNative calls and
safeEnableSynchronization users go through it. bestEffortScroll, the
autocomplete iOS tap and ChannelScreen.back keep only the tap best effort,
so a toggle error surfaces; create_direct_message and the channel_info copy
actions hold one window across long press/plus tap and the item tap.

* fix(e2e): Browse Channels and Create DM taps always share openPlusMenu's sync-off window

The opt-in holdSyncAcrossTap (526a5ec) left the default path re-enabling sync
after the plus tap and disabling it again for the menu-item tap, the race of
MM-T4932_3. The archive_channel hang that made it opt-in (run 42620618054)
started before Browse Channels: 'Reloading React Native' at 08:05:50.8, then
setSynchronization(false) at 08:05:55.98 threw the Looper.mQueue NPE four
times and the poisoned resource kept the app busy; reloadApp now settles
first. Both screens pass their item tap through openPlusMenu's afterMenuOpen,
so the pre-tap checks stay synchronized (e00b0e0) and the taps share one window.

* ci(e2e): pin e2e-test-watch and its record action to a toolkit commit

The autofix job has cache-mode: write and receives github.token, so a moving
@main could change what it runs without review here. Both steps now use
mattermost-test-automation-toolkit fbf7509b (main, 2026-11-09); the comment
says how to bump them together.

* ci(e2e): flake-verify treats every test as a target when none are named; the workflow requires TARGETS

As CodeRabbit asked, an empty --targets now makes every observed test a target,
so a clean run whose baseline never failed is UNPROVEN. So that unrelated
stable tests cannot force UNPROVEN in CI, e2e-flake-verify.yml makes TARGETS a
required input validated non-empty (same character rule), and the automation
prompt always passes it; the empty case only exists when the script is run by
hand.

* docs(e2e): JSDoc for every function the PR adds or changes in flake-verify and the sync owner

Satisfies the Docstring Coverage pre-merge check: flake-verify.js helpers
(choose, matchesSpec, verdictFor, cell, rateCell, verdictCell, splitList,
main), its test helpers, the synchronization test driver, bestEffortScroll and
ChannelScreen.back's tap.

* docs(e2e): detox/CLAUDE.md: route sync and reloads through the owner; flake-verify TARGETS is required

* docs(e2e): verify's baseline is the PR's current merge base, recomputed after a rebase or main merge

CodeRabbit: after the PR is rebased or updated from main, request base.commit
is no longer the merge base and e2e-flake-verify refuses the dispatch. The
prompt and detox/CLAUDE.md now compute git merge-base origin/main <PR head>
and require main to still fail the test at or after it before dispatching.

* docs(e2e): flake-verify credits the PR as a whole, and only the baseline arm proves reproduction

CodeRabbit: with the merge-base baseline, a PR that changes app JavaScript is
compared as a whole, so a result cannot credit the test change alone; and a
failure on a later main commit does not show that the merge base reproduces
it. The workflow header and the prompt now say both.

* fix(e2e): close every sync window on failure in the smoke messaging and threads specs

CodeRabbit: with the depth-counted owner, a raw disable whose enable is
skipped by a failing assertion leaves a window open, and later balanced
windows only return to that depth, so sync stays off for the rest of the file.
The emoji-selection window and the six thread windows now use
withSynchronizationDisabled; every other raw disable in detox/e2e is already
followed by try/finally.

* ci(e2e): flake-verify escapes Markdown links and breaks autolinks in test titles

CodeRabbit: a PR-controlled title such as [Review details](https://...) would
render as a link in the bot's verdict comment. cell() now escapes brackets and
breaks up :// and www. so neither links, images nor autolinks render; the
hostile-title test covers all three.

* ci(e2e): verify reports NO_DATA when an E2E run shared its servers; autofix queues its runs; prompt fixes

CodeRabbit round on 4dc56ebf6:
- e2e-flake-verify.yml: the verdict job lists E2E runs on the PR branch created
  after the verify run started; any such run shared the servers, so the verdict
  becomes NO_DATA with their run ids.
- e2e-tests-autofix-on-merge.yml: concurrency queue: max, so a pending run is
  queued instead of being replaced by a newer one (actionlint 1.7.12 does not
  know the key yet; ignored narrowly in .github/actionlint.yaml).
- prompt: a flaky request is not closed by recent green runs alone, only by a
  commit that explains the fix; verify is dispatched only when the failing
  suite is Detox Android.

* ci(e2e): verify's job summary shows the final verdict; already-fixed check includes app commits

CodeRabbit round on 912b9d05b: the summary was written before the server-
overlap check could turn a PASS into NO_DATA; it is now written by that step,
after any change. The prompt's already-fixed check also counts a later commit
to the app code the test exercises.

* chore(e2e): keep the autofix prompt in the Cursor automation, not in the repo

The automation prompt and the dry-run request are pasted into the Cursor
dashboard by hand, as in the server repo. Removes .cursor/automations/,
restores .gitignore to main, and points the autofix workflow header and
detox/CLAUDE.md at the "Mobile E2E Autofix" automation instead of a file.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
2026-11-13 15:46:01 +05:30

261 lines
11 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
CameraQuickAction,
FileQuickAction,
ImageQuickAction,
InputQuickAction,
NavigationHeader,
PostDraft,
PostList,
SendButton,
} from '@support/ui/component';
import {PostOptionsScreen} from '@support/ui/screen';
import {disableSynchronization, enableSynchronization, isAndroid, longPressWithScrollRetry, timeouts, wait, waitForElementToBeVisible, waitForElementToExist} from '@support/utils';
import {by, element, expect, waitFor} from 'detox';
class ThreadScreen {
testID = {
threadScreenPrefix: 'thread.',
threadScreen: 'thread.screen',
// App uses shared NavigationHeader (`navigation.header.back`), not a thread-prefixed id.
backButton: NavigationHeader.testID.backButton,
followButton: 'thread.follow_thread.button',
followingButton: 'thread.following_thread.button',
scheduledPostTooltipCloseButton: 'scheduled_post.tooltip.close.button',
scheduledPostTooltipCloseButtonAdminAccount: 'scheduled_post_tutorial_tooltip.close',
scheduledPostOptionsBottomSheet: 'scheduled_post_options_bottom_sheet.screen',
};
threadScreen = element(by.id(this.testID.threadScreen));
backButton = NavigationHeader.backButton;
followButton = element(by.id(this.testID.followButton));
followingButton = element(by.id(this.testID.followingButton));
scheduledPostTooltipCloseButton = element(by.id(this.testID.scheduledPostTooltipCloseButton));
scheduledPostTooltipCloseButtonAdminAccount = element(by.id(this.testID.scheduledPostTooltipCloseButtonAdminAccount));
// convenience props
atInputQuickAction = InputQuickAction.getAtInputQuickAction(this.testID.threadScreenPrefix);
atInputQuickActionDisabled = InputQuickAction.getAtInputQuickActionDisabled(this.testID.threadScreenPrefix);
slashInputQuickAction = InputQuickAction.getSlashInputQuickAction(this.testID.threadScreenPrefix);
slashInputQuickActionDisabled = InputQuickAction.getSlashInputQuickActionDisabled(this.testID.threadScreenPrefix);
fileQuickAction = FileQuickAction.getFileQuickAction(this.testID.threadScreenPrefix);
fileQuickActionDisabled = FileQuickAction.getFileQuickActionDisabled(this.testID.threadScreenPrefix);
imageQuickAction = ImageQuickAction.getImageQuickAction(this.testID.threadScreenPrefix);
imageQuickActionDisabled = ImageQuickAction.getImageQuickActionDisabled(this.testID.threadScreenPrefix);
cameraQuickAction = CameraQuickAction.getCameraQuickAction(this.testID.threadScreenPrefix);
cameraQuickActionDisabled = CameraQuickAction.getCameraQuickActionDisabled(this.testID.threadScreenPrefix);
postDraft = PostDraft.getPostDraft(this.testID.threadScreenPrefix);
postDraftArchived = PostDraft.getPostDraftArchived(this.testID.threadScreenPrefix);
postDraftReadOnly = PostDraft.getPostDraftReadOnly(this.testID.threadScreenPrefix);
postInput = PostDraft.getPostInput(this.testID.threadScreenPrefix);
sendButton = SendButton.getSendButton(this.testID.threadScreenPrefix);
sendButtonDisabled = SendButton.getSendButtonDisabled(this.testID.threadScreenPrefix);
postList = new PostList(this.testID.threadScreenPrefix);
getThreadOverview = () => {
return this.postList.getThreadOverview();
};
getThreadOverviewRepliesCount = () => {
return this.postList.getThreadOverviewRepliesCount();
};
getThreadOverviewNoReplies = () => {
return this.postList.getThreadOverviewNoReplies();
};
getThreadOverviewSaveButton = () => {
return this.postList.getThreadOverviewSaveButton();
};
getThreadOverviewUnsaveButton = () => {
return this.postList.getThreadOverviewUnsaveButton();
};
getThreadOverviewPostOptionsButton = () => {
return this.postList.getThreadOverviewPostOptionsButton();
};
getFlatPostList = () => {
return this.postList.getFlatList();
};
getPostListPostItem = (postId: string, text = '', postProfileOptions: any = {}) => {
return this.postList.getPost(postId, text, postProfileOptions);
};
getPostMessageAtIndex = (index: number) => {
return this.postList.getPostMessageAtIndex(index);
};
dismissScheduledPostTooltip = async () => {
// Try to close scheduled post tooltip if it exists (try both regular and admin account versions)
try {
await waitForElementToBeVisible(this.scheduledPostTooltipCloseButton, timeouts.FOUR_SEC);
await this.scheduledPostTooltipCloseButton.tap();
await waitFor(this.scheduledPostTooltipCloseButton).not.toExist().withTimeout(timeouts.FIVE_SEC);
} catch {
// Try admin account version
try {
await waitForElementToBeVisible(this.scheduledPostTooltipCloseButtonAdminAccount, timeouts.FOUR_SEC);
await this.scheduledPostTooltipCloseButtonAdminAccount.tap();
await waitFor(this.scheduledPostTooltipCloseButtonAdminAccount).not.toExist().withTimeout(timeouts.FIVE_SEC);
} catch {
// Tooltip not visible, continue
}
}
};
toBeVisible = async () => {
const timeout = isAndroid() ? timeouts.HALF_MIN : timeouts.TEN_SEC;
await waitForElementToExist(this.threadScreen, timeout);
await waitForElementToExist(this.postInput, timeouts.TEN_SEC);
return this.threadScreen;
};
back = async () => {
let navigated = false;
try {
const backTimeout = isAndroid() ? timeouts.HALF_MIN : timeouts.TEN_SEC;
await waitForElementToExist(this.backButton, backTimeout);
await NavigationHeader.tapTopmostBackButton();
await waitFor(this.threadScreen).not.toBeVisible().withTimeout(timeouts.FIVE_SEC);
navigated = true;
} catch {
// Back button not in hierarchy, tap failed, or thread remained visible — fall through.
}
if (!navigated && isAndroid()) {
// Thread-from-search can leave no hittable header back; a single
// system back sometimes only dismisses the keyboard or a transient overlay.
/* eslint-disable no-await-in-loop */
for (let attempt = 0; attempt < 2 && !navigated; attempt++) {
try {
await device.pressBack();
await wait(timeouts.TWO_SEC);
await waitFor(this.threadScreen).not.toBeVisible().withTimeout(timeouts.FIVE_SEC);
navigated = true;
} catch {
// still visible, retry
}
}
/* eslint-enable no-await-in-loop */
}
if (!navigated) {
throw new Error('ThreadScreen.back: could not navigate back');
}
await waitFor(this.threadScreen).not.toBeVisible().withTimeout(timeouts.TEN_SEC);
// Wait for the previous screen to be fully loaded and rendered
await wait(timeouts.TWO_SEC);
};
openPostOptionsFor = async (postId: string, text: string) => {
const {postListPostItem} = this.getPostListPostItem(postId, text);
// Poll for the post to become visible without waiting for idle bridge
await waitForElementToBeVisible(postListPostItem, timeouts.TEN_SEC);
if (isAndroid()) {
try {
await this.postList.getFlatList().swipe('up', 'fast', 0.3);
} catch { /* ignore — list may be too short */ }
await wait(timeouts.TWO_SEC);
}
try {
await this.postList.getFlatList().scrollTo('top');
await wait(timeouts.ONE_SEC);
} catch { /* ignore — list may be too short to scroll */ }
// On Android, long-press on the inner text element — more reliable than the
// compound-matched post container, which can silently swallow the gesture.
const longPressTarget = isAndroid()? element(by.text(text).withAncestor(by.id(`${this.testID.threadScreenPrefix}post_list.post.${postId}`))): postListPostItem;
await longPressWithScrollRetry(
longPressTarget,
by.id(this.postList.testID.flatList),
PostOptionsScreen.postOptionsScreen,
);
await wait(timeouts.TWO_SEC);
};
postMessage = async (message: string) => {
// # Post message
await this.postInput.tap();
await this.postInput.replaceText(`${message}\n`);
await this.tapSendButton();
// # Wait for message to be rendered
await wait(timeouts.FOUR_SEC);
};
enterMessageToSchedule = async (message: string) => {
await this.postInput.tap();
await this.postInput.clearText();
await this.postInput.replaceText(message);
};
longPressSendButton = async () => {
await this.dismissScheduledPostTooltip();
await waitForElementToBeVisible(this.sendButton, timeouts.FOUR_SEC);
if (isAndroid()) {
try {
await this.postList.getFlatList().swipe('up', 'fast', 0.3);
} catch { /* ignore — post list may be too short to scroll */ }
await wait(timeouts.ONE_SEC);
} else {
try {
await this.postList.getFlatList().swipe('down', 'slow', 0.1);
} catch {
try {
await element(by.id('navigation.header.title')).tap({x: 1, y: 1});
} catch { /* ignore */ }
}
await wait(timeouts.ONE_SEC);
}
await disableSynchronization();
try {
await this.sendButton.longPress(timeouts.TWO_SEC);
// Wait for the schedule picker bottom sheet using polling (no sync dependency).
await waitForElementToExist(
element(by.id(this.testID.scheduledPostOptionsBottomSheet)),
timeouts.HALF_MIN,
);
} finally {
await enableSynchronization();
}
};
tapSendButton = async () => {
// Existence + corner tap: even 40% visibility fails when the thread composer
// is clipped by the pinned-messages / keyboard stack (MM-T4918_3).
await waitForElementToExist(this.sendButton, timeouts.TEN_SEC);
await this.sendButton.tap({x: 1, y: 1});
await waitFor(this.sendButton).not.toExist().withTimeout(timeouts.FIVE_SEC);
await waitFor(this.sendButtonDisabled).toExist().withTimeout(timeouts.FIVE_SEC);
};
hasPostMessage = async (postId: string, postMessage: string) => {
const {postListPostItem} = this.getPostListPostItem(postId, postMessage);
await expect(postListPostItem).toBeVisible();
};
hasPostMessageAtIndex = async (index: number, postMessage: string) => {
await expect(
this.getPostMessageAtIndex(index),
).toHaveText(postMessage);
};
}
const threadScreen = new ThreadScreen();
export default threadScreen;