mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-13 22:50:19 +00:00
* 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 thatb683001widened 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 fromb683001conflicts with that, so the helper files are restored to main. This drops the Android looper race fix fromb683001(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 on4dc56ebf6: - 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 on912b9d05b: 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>
261 lines
11 KiB
TypeScript
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;
|