diff --git a/.claude/settings.json b/.claude/settings.json index 27816aa5efb8..3a4e914bf1b0 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -14,7 +14,14 @@ "Bash(yarn tsc:*)", "Bash(yarn ios:pod*)", "Bash(yarn coverage:*)", - "Bash(yarn maestro*)" + "Bash(yarn maestro*)", + "mcp__codegraph__codegraph_search", + "mcp__codegraph__codegraph_context", + "mcp__codegraph__codegraph_callers", + "mcp__codegraph__codegraph_callees", + "mcp__codegraph__codegraph_impact", + "mcp__codegraph__codegraph_node", + "mcp__codegraph__codegraph_status" ] }, "hooks": { diff --git a/.gitignore b/.gitignore index a7e12fe22c49..e15aea4c0f76 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,5 @@ shared/tests/results/ CLAUDE.md .tsOuts docs +.codegraph +.mcp.json diff --git a/plans/flow-test.md b/plans/flow-test.md index 8f3bebedae58..7671ae6797c3 100644 --- a/plans/flow-test.md +++ b/plans/flow-test.md @@ -1,10 +1,12 @@ # E2E Flow Test Coverage — Page Checklist +**Skill:** Use the `keybase-e2e-tests` skill for testID conventions, Playwright gotchas, Maestro command patterns, and iOS navigation structure. + Each bucket is a logical group for one or more PRs. Items are ordered easiest-first within each bucket. Validate after each bucket before moving on. **Pairing rule:** Do Electron and iOS for each bucket together before moving to the next bucket. -**Branch scripts:** `yarn test:e2e:electron:branch` and `yarn test:e2e:ios:branch` run only the new flows being developed. When a flow is verified working on both platforms, remove it from the branch scripts. When adding a new bucket's test files, add them to both scripts. +**Branch scripts:** `yarn test:e2e:desktop:branch` and `yarn test:e2e:ios:branch` run only the new flows being developed. When a flow is verified working on both platforms, remove it from the branch scripts. When adding a new bucket's test files, add them to both scripts. Out of scope = screens that create, delete, add, invite, or remove something. Everything else is in scope even if it requires app state to reach. @@ -27,10 +29,10 @@ Navigate to each sub-tab in the Crypto section. Type something in each sub-tab and run it to see the output screen. Local-only operation, no server mutation. -- [ ] Encrypt → output screen renders -- [ ] Decrypt → output screen renders (with valid ciphertext) -- [ ] Sign → output screen renders -- [ ] Verify → output screen renders (with valid signed text) +- [x] Encrypt → output screen renders (Electron ✓, iOS written) +- [x] Decrypt → output screen renders — encrypt first, feed ciphertext to decrypt (Electron ✓, iOS: needs clipboard support, skipped) +- [x] Sign → output screen renders (Electron ✓, iOS written) +- [x] Verify → output screen renders — sign first, feed signed text to verify (Electron ✓, iOS: needs clipboard support, skipped) --- @@ -38,7 +40,9 @@ Type something in each sub-tab and run it to see the output screen. Local-only o Open an existing conversation. No sending. -- [ ] Open first inbox row → message list renders +- [x] Open first inbox row → message list renders (Electron ✓, iOS written) +- [x] Chat input visible in open conversation (Electron ✓, iOS: chat-send-message.yaml already covers this) +- [x] Return to inbox from conversation (Electron ✓, iOS written) --- @@ -64,12 +68,12 @@ From an open conversation, open each of these. Dismiss/cancel without submitting Navigate from the Settings nav. Confirm renders, go back. -- [ ] About -- [ ] Advanced -- [ ] Display -- [ ] Notifications -- [ ] Feedback -- [ ] Password (modal: `settingsTabs.password`) +- [x] About (Electron ✓, iOS written) +- [x] Advanced (Electron ✓, iOS written) +- [x] Display (Electron ✓, iOS written) +- [x] Notifications (Electron ✓, iOS written) +- [x] Feedback (Electron ✓, iOS written) +- [ ] Password (modal: `settingsTabs.password`) — needs Account settings navigation; no testID yet --- @@ -77,14 +81,14 @@ Navigate from the Settings nav. Confirm renders, go back. Same pattern. Devices and Git reuse their main tab screen components. -- [ ] Chat -- [ ] Files -- [ ] Git (reuses git root component) -- [ ] Devices (reuses devices root component) +- [x] Chat (Electron ✓, iOS written via settings-subpages.yaml) +- [x] Files (Electron ✓, iOS written via settings-subpages.yaml) +- [x] Git — reuses git root component (Electron ✓, iOS ✓ via git.yaml) +- [x] Devices — reuses devices root component (Electron ✓, iOS ✓ via devices-view.yaml) - [ ] Wallet -- [ ] Archive / Backup +- [x] Archive / Backup (Electron ✓, iOS written via settings-subpages.yaml) - [ ] Contacts (mobile only, `settingsTabs.contactsTab`) -- [ ] Screen Protector (mobile only, `settingsTabs.screenprotector`) +- [x] Screen Protector (mobile only, `settingsTabs.screenprotector`) (Electron ✓, iOS: Android only) --- @@ -103,7 +107,7 @@ Settings-adjacent modals that are viewable without mutating. From the Devices tab, click a device row. -- [ ] Device detail page renders +- [x] Device detail page renders (Electron ✓, iOS written) --- @@ -111,10 +115,10 @@ From the Devices tab, click a device row. Open a team, navigate each internal tab. -- [ ] Members tab renders -- [ ] Channels tab renders -- [ ] Bots tab renders -- [ ] Settings tab renders (team settings, not app settings) +- [x] Members tab renders (Electron ✓, iOS written) +- [x] Channels tab renders — conditional on big team/admin (Electron ✓, iOS written) +- [x] Bots tab renders (Electron ✓, iOS written) +- [x] Settings tab renders (Electron ✓, iOS written) --- @@ -122,7 +126,7 @@ Open a team, navigate each internal tab. From within a team. -- [ ] Team member page (click a member row from Members tab) +- [x] Team member page (Electron ✓, iOS written) — taps smoke user's username in member list - [ ] Edit channel modal — open, cancel (`teamEditChannel`) - [ ] Team description edit modal — open, cancel (`teamEditTeamDescription`) - [ ] Team info edit modal — open, cancel (`teamEditTeamInfo`) @@ -135,7 +139,7 @@ From within a team. ## Bucket 11 — Profile page and modals -- [ ] Profile page renders (via People feed item click) +- [x] Profile page renders (Electron ✓ via People tab header; iOS written — conditional on username in feed) - [ ] Proofs list modal (`profileProofsList`) — open from a profile, view, close - [ ] Showcase team offer (`profileShowcaseTeamOffer`) — open from own profile, view, cancel @@ -145,15 +149,18 @@ From within a team. From the Files root, tap each TLF type then back. -- [ ] Navigate into `public/` → browser renders -- [ ] Navigate into `private/` → browser renders -- [ ] Navigate into `team/` → browser renders +- [x] Navigate into `public/` → browser renders (Electron ✓, iOS written) +- [x] Navigate into `private/` → browser renders (Electron ✓, iOS written) +- [x] Navigate into `team/` → browser renders (Electron ✓, iOS written) +- [ ] Navigate back to files root from subfolder (Electron ✓, iOS written) - [ ] Destination picker (`destinationPicker`) — open move/copy flow, cancel --- ## Bucket 13 — Git +- [x] Git repo list renders (Electron ✓, iOS written) +- [x] Git repo row is visible (Electron ✓, iOS written) - [ ] Git repo detail (investigate first — clicking a row may open a mutation modal or nothing) - [ ] Git select channel (`gitSelectChannel`) — open from a repo to set a notification channel, cancel diff --git a/plans/todo.md b/plans/todo.md index f998c24bf379..1aac827ed9f7 100644 --- a/plans/todo.md +++ b/plans/todo.md @@ -1,6 +1,5 @@ go screen by screen and find cleanup legends to more desktop -move to clickablebox3 crypto screens button doesn't stick to keyboard well automated testing of all screens any leftover zustand store diff --git a/shared/.maestro/e2e/flows/chat-conversation.yaml b/shared/.maestro/e2e/flows/chat-conversation.yaml new file mode 100644 index 000000000000..a2150aa7d85c --- /dev/null +++ b/shared/.maestro/e2e/flows/chat-conversation.yaml @@ -0,0 +1,32 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +# Navigate via Teams first to avoid ambiguity: "Chat" also appears in the More tab menu +- tapOn: + text: "Teams" +- tapOn: + text: "Chat" +- extendedWaitUntil: + visible: + id: "chat-inbox-list" + timeout: 5000 +- takeScreenshot: tests/results/ios-debug/chat-conversation-inbox +- runFlow: + when: + visible: + id: "chat-inbox-row" + commands: + - tapOn: + id: "chat-inbox-row" + index: 0 + - extendedWaitUntil: + visible: + id: "chat-message-list" + timeout: 5000 + - takeScreenshot: tests/results/ios-debug/chat-conversation-open + - tapOn: + id: "backButton" + - extendedWaitUntil: + visible: + id: "chat-inbox-list" + timeout: 5000 diff --git a/shared/.maestro/e2e/flows/crypto-outputs.yaml b/shared/.maestro/e2e/flows/crypto-outputs.yaml new file mode 100644 index 000000000000..c64dccbd2468 --- /dev/null +++ b/shared/.maestro/e2e/flows/crypto-outputs.yaml @@ -0,0 +1,61 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +- tapOn: + text: "More" +- scrollUntilVisible: + element: + text: ".*Crypto" + direction: up + timeout: 3000 +- tapOn: + text: ".*Crypto" + retryTapIfNoChange: false +- extendedWaitUntil: + visible: + id: "crypto-input" + timeout: 3000 + +# Encrypt → output renders +- tapOn: + id: "crypto-nav-encryptTab" +- extendedWaitUntil: + visible: + id: "crypto-encrypt-input" + timeout: 3000 +- tapOn: + id: "crypto-encrypt-input" +- inputText: "hello e2e" +- tapOn: + id: "crypto-run-button" +- extendedWaitUntil: + visible: + id: "crypto-output" + timeout: 10000 +- takeScreenshot: tests/results/ios-debug/crypto-outputs-encrypt +- tapOn: + text: "Cancel" +- tapOn: + id: "backButton" + +# Sign → output renders +- tapOn: + id: "crypto-nav-signTab" +- extendedWaitUntil: + visible: + id: "crypto-sign-input" + timeout: 3000 +- tapOn: + id: "crypto-sign-input" +- inputText: "hello e2e" +- tapOn: + id: "crypto-run-button" +- extendedWaitUntil: + visible: + id: "crypto-output" + timeout: 10000 +- takeScreenshot: tests/results/ios-debug/crypto-outputs-sign +- tapOn: + text: "Cancel" +- tapOn: + id: "backButton" diff --git a/shared/.maestro/e2e/flows/device-detail.yaml b/shared/.maestro/e2e/flows/device-detail.yaml new file mode 100644 index 000000000000..678777d88b43 --- /dev/null +++ b/shared/.maestro/e2e/flows/device-detail.yaml @@ -0,0 +1,26 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +- tapOn: + text: "More" +- tapOn: + text: ".*Devices" + retryTapIfNoChange: false +- extendedWaitUntil: + visible: + id: "devices-list" + timeout: 3000 +- extendedWaitUntil: + visible: + id: "devices-row" + timeout: 3000 +- tapOn: + id: "devices-row" + index: 0 +- extendedWaitUntil: + visible: + id: "device-page" + timeout: 5000 +- takeScreenshot: tests/results/ios-debug/device-detail +- tapOn: + id: "backButton" diff --git a/shared/.maestro/e2e/flows/files-folders.yaml b/shared/.maestro/e2e/flows/files-folders.yaml new file mode 100644 index 000000000000..117ff805c95c --- /dev/null +++ b/shared/.maestro/e2e/flows/files-folders.yaml @@ -0,0 +1,62 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +# Navigate via Teams first to avoid ambiguity: "Files" also appears in the More tab menu +- tapOn: + text: "Teams" +- tapOn: + text: "Files" +- extendedWaitUntil: + visible: + id: "files-browser" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/files-folders-root + +# Navigate into private folder (index 0) +- tapOn: + id: "files-tlf-row" + index: 0 +- extendedWaitUntil: + visible: + id: "backButton" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/files-folders-private +- tapOn: + id: "backButton" +- extendedWaitUntil: + visible: + id: "files-tlf-row" + timeout: 3000 + +# Navigate into public folder (index 1) +- tapOn: + id: "files-tlf-row" + index: 1 +- extendedWaitUntil: + visible: + id: "backButton" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/files-folders-public +- tapOn: + id: "backButton" +- extendedWaitUntil: + visible: + id: "files-tlf-row" + timeout: 3000 + +# Navigate into team folder (index 2) +- tapOn: + id: "files-tlf-row" + index: 2 +- extendedWaitUntil: + visible: + id: "backButton" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/files-folders-team +- tapOn: + id: "backButton" +- extendedWaitUntil: + visible: + id: "files-tlf-row" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/files-folders-back-to-root diff --git a/shared/.maestro/e2e/flows/git.yaml b/shared/.maestro/e2e/flows/git.yaml new file mode 100644 index 000000000000..1f529f1269d7 --- /dev/null +++ b/shared/.maestro/e2e/flows/git.yaml @@ -0,0 +1,19 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +- tapOn: + text: "More" +- tapOn: + text: ".*Git" + retryTapIfNoChange: false +- extendedWaitUntil: + visible: + id: "git-repo-list" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/git +- runFlow: + when: + visible: + id: "git-repo-row" + commands: + - takeScreenshot: tests/results/ios-debug/git-repo-row diff --git a/shared/.maestro/e2e/flows/people-profile.yaml b/shared/.maestro/e2e/flows/people-profile.yaml new file mode 100644 index 000000000000..c75c4a8003f3 --- /dev/null +++ b/shared/.maestro/e2e/flows/people-profile.yaml @@ -0,0 +1,26 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +- tapOn: + text: "People" +- extendedWaitUntil: + visible: + id: "people-feed" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/people-feed +# Navigate to own profile — tap username in the feed if visible +- runFlow: + when: + visible: + text: "${KB_SMOKE_USER}" + commands: + - tapOn: + text: "${KB_SMOKE_USER}" + index: 0 + - extendedWaitUntil: + visible: + id: "profile-page" + timeout: 10000 + - takeScreenshot: tests/results/ios-debug/people-profile + - tapOn: + id: "backButton" diff --git a/shared/.maestro/e2e/flows/settings-subpages.yaml b/shared/.maestro/e2e/flows/settings-subpages.yaml new file mode 100644 index 000000000000..60f3673c07c6 --- /dev/null +++ b/shared/.maestro/e2e/flows/settings-subpages.yaml @@ -0,0 +1,109 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +- tapOn: + text: "More" +- extendedWaitUntil: + visible: + id: "settings-account" + timeout: 3000 + +# Items tested in top-to-bottom scroll order to avoid needing scroll-up mid-flow + +# Advanced +- tapOn: + text: "Advanced" +- extendedWaitUntil: + visible: + id: "settings-advanced" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/settings-subpages-advanced +- tapOn: + id: "backButton" + +# Backup (Archive) +- tapOn: + text: "Backup" +- extendedWaitUntil: + visible: + id: "settings-archive" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/settings-subpages-backup +- tapOn: + id: "backButton" + +# Chat +- tapOn: + text: "Chat" +- extendedWaitUntil: + visible: + id: "settings-chat" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/settings-subpages-chat +- tapOn: + id: "backButton" + +# Display +- tapOn: + text: "Display" +- extendedWaitUntil: + visible: + id: "settings-display" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/settings-subpages-display +- tapOn: + id: "backButton" + +# Feedback +- tapOn: + text: "Feedback" +- extendedWaitUntil: + visible: + id: "settings-feedback" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/settings-subpages-feedback +- tapOn: + id: "backButton" + +# Files +- tapOn: + text: "Files" +- extendedWaitUntil: + visible: + id: "settings-files" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/settings-subpages-files +- tapOn: + id: "backButton" + +# Notifications (below "Import phone contacts" — may need scrolling) +- scrollUntilVisible: + element: + text: "Notifications" + direction: down + timeout: 3000 +- tapOn: + text: "Notifications" +- extendedWaitUntil: + visible: + id: "settings-notifications" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/settings-subpages-notifications +- tapOn: + id: "backButton" + +# About (in the "More" sub-section below Settings — requires scrolling down) +- scrollUntilVisible: + element: + text: "About" + direction: down + timeout: 3000 +- tapOn: + text: "About" +- extendedWaitUntil: + visible: + id: "settings-about" + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/settings-subpages-about +- tapOn: + id: "backButton" diff --git a/shared/.maestro/e2e/flows/team-member.yaml b/shared/.maestro/e2e/flows/team-member.yaml new file mode 100644 index 000000000000..b8b9229a9870 --- /dev/null +++ b/shared/.maestro/e2e/flows/team-member.yaml @@ -0,0 +1,38 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +- tapOn: + text: "Teams" + retryTapIfNoChange: false +- extendedWaitUntil: + visible: + id: "teams-list" + timeout: 3000 +- runFlow: + when: + visible: + id: "teams-row" + commands: + - tapOn: + id: "teams-row" + index: 0 + - extendedWaitUntil: + visible: + id: "teams-member-list" + timeout: 5000 + # Tap own username in the member list to open the member page + - runFlow: + when: + visible: + text: "${KB_SMOKE_USER}" + commands: + - tapOn: + text: "${KB_SMOKE_USER}" + index: 0 + - extendedWaitUntil: + visible: + id: "teams-member-page" + timeout: 5000 + - takeScreenshot: tests/results/ios-debug/team-member + - tapOn: + id: "backButton" diff --git a/shared/.maestro/e2e/flows/teams-inner.yaml b/shared/.maestro/e2e/flows/teams-inner.yaml new file mode 100644 index 000000000000..6394d5a10e58 --- /dev/null +++ b/shared/.maestro/e2e/flows/teams-inner.yaml @@ -0,0 +1,59 @@ +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +- tapOn: + text: "Teams" + retryTapIfNoChange: false +- extendedWaitUntil: + visible: + id: "teams-list" + timeout: 3000 +- runFlow: + when: + visible: + id: "teams-row" + commands: + - tapOn: + id: "teams-row" + index: 0 + # Members tab is the default — wait for its content + - extendedWaitUntil: + visible: + id: "teams-member-list" + timeout: 5000 + - takeScreenshot: tests/results/ios-debug/teams-inner-members + + # Settings tab + - tapOn: + text: "Settings" + - extendedWaitUntil: + visible: + id: "teams-settings-tab" + timeout: 3000 + - takeScreenshot: tests/results/ios-debug/teams-inner-settings + + # Bots tab + - tapOn: + text: "Bots" + - extendedWaitUntil: + visible: + id: "teams-bots-tab" + timeout: 3000 + - takeScreenshot: tests/results/ios-debug/teams-inner-bots + + # Channels tab — only present for big teams or admins + - runFlow: + when: + visible: + text: "Channels" + commands: + - tapOn: + text: "Channels" + - extendedWaitUntil: + visible: + id: "teams-channel-list" + timeout: 3000 + - takeScreenshot: tests/results/ios-debug/teams-inner-channels + + - tapOn: + id: "backButton" diff --git a/shared/.maestro/e2e/subflows/escape-to-tabs.yaml b/shared/.maestro/e2e/subflows/escape-to-tabs.yaml index c0507e5c53ce..d42cfe2d6902 100644 --- a/shared/.maestro/e2e/subflows/escape-to-tabs.yaml +++ b/shared/.maestro/e2e/subflows/escape-to-tabs.yaml @@ -28,3 +28,7 @@ appId: keybase.ios commands: - tapOn: id: "backButton" +# Reset scroll position so each flow sees the top of any scrollable list +- swipe: + direction: DOWN + duration: 500 diff --git a/shared/common-adapters/button.tsx b/shared/common-adapters/button.tsx index 4a3a9d5a3bdc..70fe52aac88d 100644 --- a/shared/common-adapters/button.tsx +++ b/shared/common-adapters/button.tsx @@ -23,6 +23,7 @@ export type ButtonProps = { tooltip?: string style?: Styles.StylesCrossPlatform labelStyle?: Styles.StylesCrossPlatform + testID?: string } export const regularHeight = isMobile ? 40 : 32 @@ -149,7 +150,7 @@ const Progress = ({small, white}: {small?: boolean; white: boolean}) => { type FullProps = ButtonProps & {ref?: React.Ref} const ButtonDesktop = (props: FullProps) => { - const {children, label, onClick, ref: measureRef, type = 'Default', mode = 'Primary', small, fullWidth, disabled, waiting, tooltip, style, labelStyle: labelStyleOverride} = props + const {children, label, onClick, ref: measureRef, type = 'Default', mode = 'Primary', small, fullWidth, disabled, waiting, tooltip, style, labelStyle: labelStyleOverride, testID} = props const unclickable = disabled || waiting const isPrimary = mode === 'Primary' const hasChildrenOnly = !!children && !label @@ -188,7 +189,7 @@ const ButtonDesktop = (props: FullProps) => { const whiteSpinner = isPrimary && type !== 'Dim' const btn = ( -
}> +
} data-testid={testID}> {children} {!!label && ( @@ -208,7 +209,7 @@ const ButtonDesktop = (props: FullProps) => { const ButtonNative = (props: FullProps) => { const {Pressable, Text: RNText, View} = require('react-native') as {Pressable: typeof PressableType; Text: typeof RNTextType; View: typeof ViewType} - const {children, label, onClick, type = 'Default', mode = 'Primary', small, fullWidth, disabled, waiting, style, labelStyle: labelStyleOverride} = props + const {children, label, onClick, type = 'Default', mode = 'Primary', small, fullWidth, disabled, waiting, style, labelStyle: labelStyleOverride, testID} = props const unclickable = disabled || waiting const isPrimary = mode === 'Primary' const hasChildrenOnly = !!children && !label @@ -257,7 +258,7 @@ const ButtonNative = (props: FullProps) => { } return ( - + {inner} ) diff --git a/shared/common-adapters/list-item.tsx b/shared/common-adapters/list-item.tsx index e10ef10663ab..2a148bdc3bad 100644 --- a/shared/common-adapters/list-item.tsx +++ b/shared/common-adapters/list-item.tsx @@ -34,6 +34,7 @@ type Props = { innerStyle?: Styles.StylesCrossPlatform iconStyleOverride?: Styles.StylesCrossPlatform containerStyleOverride?: Styles.StylesCrossPlatform + testID?: string } const ListItem = (props: Props) => { @@ -52,6 +53,7 @@ const ListItem = (props: Props) => { props.style, ])} fullWidth={true} + testID={props.testID} > { const {params} = useRoute() as RootRouteProps<'decryptTab'> const controller = useDecryptState(params) const navigateAppend = C.Router2.navigateAppend + const insets = Kb.useSafeAreaInsets() + const stickyOffset = React.useMemo(() => ({closed: -insets.bottom, opened: 0}), [insets.bottom]) const onRun = () => { const f = async () => { @@ -149,8 +152,31 @@ export const DecryptInput = (_props: unknown) => { C.ignorePromise(f()) } - const contents = ( - <> + if (!isMobile) { + return ( + + + + + ) + } + + return ( + { onSetInput={controller.setInput} onClearInput={controller.clearInput} /> - - ) - - return isMobile ? ( - - {contents} - - - ) : ( - - {contents} + + + ) } diff --git a/shared/crypto/encrypt.tsx b/shared/crypto/encrypt.tsx index dd2d8921ded4..517ec4b39b01 100644 --- a/shared/crypto/encrypt.tsx +++ b/shared/crypto/encrypt.tsx @@ -6,6 +6,7 @@ import * as T from '@/constants/types' import Recipients from './recipients' import {openURL} from '@/util/misc' import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' +import {KeyboardStickyView} from 'react-native-keyboard-controller' import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender, OutputInfoBanner} from './output' import { type CommonOutputRouteParams, @@ -449,6 +450,8 @@ const EncryptInputBody = ({params}: {params?: EncryptRouteParams}) => { const blurCBRef = React.useRef(() => {}) const navigateAppend = C.Router2.navigateAppend const appendEncryptRecipientsBuilder = C.Router2.appendEncryptRecipientsBuilder + const insets = Kb.useSafeAreaInsets() + const stickyOffset = React.useMemo(() => ({closed: -insets.bottom, opened: 0}), [insets.bottom]) const onRun = () => { const f = async () => { @@ -460,32 +463,46 @@ const EncryptInputBody = ({params}: {params?: EncryptRouteParams}) => { C.ignorePromise(f()) } - const options = isMobile ? ( - - - - ) : ( - - ) + if (!isMobile) { + return ( + + + + + + + ) + } - const content = ( - <> + return ( + { onSetInput={controller.setInput} onClearInput={controller.clearInput} /> - {options} - - ) - - return isMobile ? ( - {content} - ) : ( - - {content} + + + + + ) } diff --git a/shared/crypto/input.tsx b/shared/crypto/input.tsx index bfbac3573e63..3a0cb2cbc10c 100644 --- a/shared/crypto/input.tsx +++ b/shared/crypto/input.tsx @@ -6,6 +6,7 @@ import * as Kb from '@/common-adapters' import * as FS from '@/constants/fs' import type {IconType} from '@/common-adapters/icon.constants-gen' import {pickFiles} from '@/util/misc' +import * as TestIDs from '@/tests/e2e/shared/test-ids' type CommonProps = { state: CommonState @@ -273,6 +274,7 @@ export const InputActionsBar = ({blurCBRef, children, onRun, runLabel}: RunActio label={runLabel} fullWidth={true} onClick={onClick} + testID={TestIDs.CRYPTO_RUN_BUTTON} /> ) : null diff --git a/shared/crypto/output.tsx b/shared/crypto/output.tsx index 93f13dd500b8..ba5349741a52 100644 --- a/shared/crypto/output.tsx +++ b/shared/crypto/output.tsx @@ -2,6 +2,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as Path from '@/util/path' import * as React from 'react' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import type {IconType} from '@/common-adapters/icon.constants-gen' import type {CommonState} from './helpers' import {pickFiles} from '@/util/misc' @@ -345,7 +346,7 @@ export const CryptoOutput = ({ } return ( - + { const controller = useSignState(params) const blurCBRef = React.useRef(() => {}) const navigateAppend = C.Router2.navigateAppend + const insets = Kb.useSafeAreaInsets() + const stickyOffset = React.useMemo(() => ({closed: -insets.bottom, opened: 0}), [insets.bottom]) const onRun = () => { const f = async () => { @@ -158,8 +161,31 @@ export const SignInput = (_props: unknown) => { C.ignorePromise(f()) } - const content = ( - <> + if (!isMobile) { + return ( + + + + + ) + } + + return ( + { onSetInput={controller.setInput} onClearInput={controller.clearInput} /> - {isMobile ? : null} - - ) - - return isMobile ? ( - {content} - ) : ( - - {content} + + + ) } diff --git a/shared/crypto/verify.tsx b/shared/crypto/verify.tsx index aaf851864728..9a0b68172b11 100644 --- a/shared/crypto/verify.tsx +++ b/shared/crypto/verify.tsx @@ -5,6 +5,7 @@ import * as React from 'react' import * as T from '@/constants/types' import * as TestIDs from '@/tests/e2e/shared/test-ids' import {CryptoBanner, DragAndDrop, Input, InputActionsBar} from './input' +import {KeyboardStickyView} from 'react-native-keyboard-controller' import {CryptoOutput, CryptoOutputActionsBar, CryptoSignedSender} from './output' import { beginRun, @@ -138,6 +139,8 @@ export const VerifyInput = (_props: unknown) => { const {params} = useRoute() as RootRouteProps<'verifyTab'> const controller = useVerifyState(params) const navigateAppend = C.Router2.navigateAppend + const insets = Kb.useSafeAreaInsets() + const stickyOffset = React.useMemo(() => ({closed: -insets.bottom, opened: 0}), [insets.bottom]) const onRun = () => { const f = async () => { @@ -149,8 +152,31 @@ export const VerifyInput = (_props: unknown) => { C.ignorePromise(f()) } - const content = ( - <> + if (!isMobile) { + return ( + + + + + ) + } + + return ( + { onSetInput={controller.setInput} onClearInput={controller.clearInput} /> - {isMobile ? : null} - - ) - - return isMobile ? ( - {content} - ) : ( - - {content} + + + ) } diff --git a/shared/devices/device-page.tsx b/shared/devices/device-page.tsx index b03cd22cefdf..cdef74571cde 100644 --- a/shared/devices/device-page.tsx +++ b/shared/devices/device-page.tsx @@ -1,6 +1,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {formatTimeForDeviceTimeline, formatTimeRelativeToNow} from '@/util/timestamp' import {getDeviceIconType} from './device-icon' @@ -128,6 +129,7 @@ const DevicePage = (ownProps: DevicePageProps) => { gapEnd={true} fullWidth={true} fullHeight={true} + testID={TestIDs.DEVICE_PAGE} > diff --git a/shared/fs/browser/rows/common.tsx b/shared/fs/browser/rows/common.tsx index 69e58e183bb5..2f80830b0080 100644 --- a/shared/fs/browser/rows/common.tsx +++ b/shared/fs/browser/rows/common.tsx @@ -8,6 +8,7 @@ export type StillCommonProps = { inDestinationPicker?: boolean onOpen?: () => void mixedMode?: boolean + testID?: string } export const StillCommon = ( @@ -22,6 +23,7 @@ export const StillCommon = ( ) => ( } icon={ { return ( - + &1", "rn:start:log": "npx expo start --clear 2>&1 | tee /tmp/metro.log", "rn:start:legacy": "./react-native/packageAndBuild.sh", "sync:kb-modules": "yarn run _helper sync-local-rnmodules", - "test:e2e:electron": "playwright test --config tests/e2e/electron/playwright.config.ts", - "test:e2e:electron:branch": "playwright test --config tests/e2e/electron/playwright.config.ts tests/e2e/electron/flows/crypto-subtabs.test.ts && yarn test:e2e:electron:report", - "test:e2e:electron:report": "node tests/e2e/generate-electron-report.mts && open tests/results/electron-report.html", - "test:e2e:electron:save-baseline": "node tests/e2e/generate-electron-report.mts --save-baseline", + "test:e2e:desktop": "playwright test --config tests/e2e/electron/playwright.config.ts", + "test:e2e:desktop:branch": "playwright test --config tests/e2e/electron/playwright.config.ts tests/e2e/electron/flows/team-member.test.ts && yarn test:e2e:desktop:report", + "test:e2e:desktop:report": "node tests/e2e/generate-electron-report.mts && open tests/results/electron-report.html", + "test:e2e:desktop:save-baseline": "node tests/e2e/generate-electron-report.mts --save-baseline", "test:e2e:ios": "rm -rf tests/results/ios-debug && MAESTRO_CLI_NO_ANALYTICS=1 maestro test --config .maestro/e2e/config.yaml --debug-output tests/results/ios-debug --flatten-debug-output --env KB_SMOKE_USER=${KB_SMOKE_USER} .maestro/e2e/setup.yaml .maestro/e2e/flows/ ; node tests/e2e/generate-ios-report.mts", - "test:e2e:ios:branch": "rm -rf tests/results/ios-debug && MAESTRO_CLI_NO_ANALYTICS=1 maestro test --config .maestro/e2e/config.yaml --debug-output tests/results/ios-debug --flatten-debug-output --env KB_SMOKE_USER=${KB_SMOKE_USER} .maestro/e2e/setup.yaml .maestro/e2e/flows/crypto-subtabs.yaml ; node tests/e2e/generate-ios-report.mts && yarn test:e2e:ios:report", + "test:e2e:ios:branch": "rm -rf tests/results/ios-debug && MAESTRO_CLI_NO_ANALYTICS=1 maestro test --config .maestro/e2e/config.yaml --debug-output tests/results/ios-debug --flatten-debug-output --env KB_SMOKE_USER=${KB_SMOKE_USER} .maestro/e2e/setup.yaml .maestro/e2e/flows/team-member.yaml ; node tests/e2e/generate-ios-report.mts && yarn test:e2e:ios:report", "test:e2e:ios:report": "open tests/results/ios-report.html", "test:e2e:ios:save-baseline": "node tests/e2e/generate-ios-report.mts --save-baseline", "test:unit": "napi-postinstall unrs-resolver 1.11.1 check; jest --runInBand", diff --git a/shared/perf/PERF-TESTING.md b/shared/perf/PERF-TESTING.md index 341f7bb3ef2b..57110bb52d28 100644 --- a/shared/perf/PERF-TESTING.md +++ b/shared/perf/PERF-TESTING.md @@ -186,13 +186,13 @@ Equivalent to `yarn perf:thread:ios` for desktop. Navigates to chat, opens the f ```bash # Capture (default 3 runs, picks median, saves baseline automatically) -cd shared && yarn perf:thread:electron +cd shared && yarn perf:thread:desktop # Single run (faster) -cd shared && yarn perf:thread:electron --runs 1 +cd shared && yarn perf:thread:desktop --runs 1 # Skip auto-navigation (if you already have a thread open) -cd shared && yarn perf:thread:electron --no-navigate +cd shared && yarn perf:thread:desktop --no-navigate # Compare two saved baselines (no app connection needed) cd shared && yarn perf:compare @@ -207,13 +207,13 @@ Baselines are saved to `shared/perf/baselines//` (gitignored): 1. Check out the base branch, run capture: ```bash git checkout master - cd shared && yarn perf:thread:electron + cd shared && yarn perf:thread:desktop ``` Note the saved baseline hash from the output. 2. Switch to your feature branch, run capture again: ```bash git checkout my-feature-branch - cd shared && yarn perf:thread:electron + cd shared && yarn perf:thread:desktop ``` 3. Compare: ```bash diff --git a/shared/profile/user/index.tsx b/shared/profile/user/index.tsx index 18d54ae8f16b..070979249ac5 100644 --- a/shared/profile/user/index.tsx +++ b/shared/profile/user/index.tsx @@ -14,6 +14,7 @@ import upperFirst from 'lodash/upperFirst' import {SiteIcon} from '../generic/shared' import useUserData from './hooks' import {LoadedTeamsListProvider} from '@/teams/use-teams-list' +import * as TestIDs from '@/tests/e2e/shared/test-ids' export type BackgroundColorType = 'red' | 'green' | 'blue' @@ -587,6 +588,7 @@ const User = (props: {username: string}) => { fullWidth={true} fullHeight={true} style={Kb.Styles.collapseStyles([containerStyle, colorTypeToStyle(p.backgroundColorType)])} + testID={TestIDs.PROFILE_PAGE} > { ) } +const tabTestIDs = new Map([ + [Tabs.peopleTab, TestIDs.NAV_TAB_PEOPLE], + [Tabs.chatTab, TestIDs.NAV_TAB_CHAT], + [Tabs.fsTab, TestIDs.NAV_TAB_FILES], + [Tabs.cryptoTab, TestIDs.NAV_TAB_CRYPTO], + [Tabs.teamsTab, TestIDs.NAV_TAB_TEAMS], + [Tabs.gitTab, TestIDs.NAV_TAB_GIT], + [Tabs.devicesTab, TestIDs.NAV_TAB_DEVICES], + [Tabs.settingsTab, TestIDs.NAV_TAB_SETTINGS], +]) + const keysMap = Tabs.desktopTabs.reduce<{[key: string]: (typeof Tabs.desktopTabs)[number]}>( (map, tab, index) => { map[`mod+${index + 1}`] = tab @@ -274,6 +286,7 @@ function Tab(props: TabProps) { onMouseLeave={onMouseLeave} direction="horizontal" fullWidth={true} + testID={tabTestIDs.get(tab)} className={Kb.Styles.classNames( isSelected ? 'tab-selected' : 'tab', 'tab-tooltip', diff --git a/shared/settings/about.tsx b/shared/settings/about.tsx index 3f3759b4c3d8..a790736fedf7 100644 --- a/shared/settings/about.tsx +++ b/shared/settings/about.tsx @@ -1,5 +1,6 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {openURL as openUrl} from '@/util/misc' const privacyPolicy = 'https://keybase.io/_/webview/privacypolicy' @@ -26,7 +27,7 @@ const About = () => { } return ( - + diff --git a/shared/settings/advanced.tsx b/shared/settings/advanced.tsx index 45d92f186240..1c0f3b451fb6 100644 --- a/shared/settings/advanced.tsx +++ b/shared/settings/advanced.tsx @@ -2,6 +2,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import * as React from 'react' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {ProxySettings} from './proxy' import {processorProfileInProgressKey, traceInProgressKey} from '@/constants/settings' import {useConfigState} from '@/stores/config' @@ -233,7 +234,7 @@ const Advanced = () => { return ( - + {settingLockdownMode && } diff --git a/shared/settings/archive/index.tsx b/shared/settings/archive/index.tsx index 089b26e8e8ff..1d72dc5ccbb4 100644 --- a/shared/settings/archive/index.tsx +++ b/shared/settings/archive/index.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as C from '@/constants' import * as T from '@/constants/types' import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {useEngineActionListener} from '@/engine/action-listener' import {formatTimeForConversationList, formatTimeForChat} from '@/util/timestamp' import * as FS from '@/constants/fs' @@ -439,7 +440,7 @@ const Archive = () => { const kbfsJobsList = [...kbfsJobs.values()] return ( - + {isMobile ? null : Archive} diff --git a/shared/settings/chat.tsx b/shared/settings/chat.tsx index 8c618ce1ca71..d3e549ee00b6 100644 --- a/shared/settings/chat.tsx +++ b/shared/settings/chat.tsx @@ -2,6 +2,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import * as React from 'react' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import Group from './group' import {loadSettings} from './load-settings' import useNotificationSettings from './notifications/use-notification-settings' @@ -492,7 +493,7 @@ const Misc = ({allowEdit, groups, toggle}: NotificationSettingsState) => { const Chat = () => { const notificationSettings = useNotificationSettings() return ( - + diff --git a/shared/settings/display.tsx b/shared/settings/display.tsx index 6ab48022dde2..065dce398c55 100644 --- a/shared/settings/display.tsx +++ b/shared/settings/display.tsx @@ -2,6 +2,7 @@ import * as C from '@/constants' import * as Kb from '@/common-adapters' import * as T from '@/constants/types' import logger from '@/logger' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {useConfigState} from '@/stores/config' import {useShellState} from '@/stores/shell' import * as DarkMode from '@/stores/darkmode' @@ -28,7 +29,7 @@ const Display = () => { ) } return ( - + diff --git a/shared/settings/feedback/index.tsx b/shared/settings/feedback/index.tsx index 93447f719008..994eff357c6e 100644 --- a/shared/settings/feedback/index.tsx +++ b/shared/settings/feedback/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' type Props = { feedback?: string @@ -55,7 +56,7 @@ const Feedback = (props: Props) => { } return ( - + {showSuccessBanner && ( diff --git a/shared/settings/files/index.tsx b/shared/settings/files/index.tsx index 85c6341d7788..dc8510d291a5 100644 --- a/shared/settings/files/index.tsx +++ b/shared/settings/files/index.tsx @@ -7,6 +7,7 @@ import * as Kbfs from '@/fs/common' import RefreshDriverStatusOnMount from '@/fs/common/refresh-driver-status-on-mount' import useFiles from './hooks' import * as FS from '@/constants/fs' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {openLocalPathInSystemFileManagerDesktop} from '@/util/fs-storeless-actions' type Props = ReturnType @@ -202,6 +203,7 @@ const FilesSettings = () => { fullWidth={true} alignItems={Kb.Styles.isTablet ? 'flex-start' : 'center'} gap="small" + testID={TestIDs.SETTINGS_FILES} > Sync @@ -241,7 +243,7 @@ const FilesSettings = () => { return ( <> - + diff --git a/shared/settings/notifications/render.tsx b/shared/settings/notifications/render.tsx index ef74d509b490..c1189773a29c 100644 --- a/shared/settings/notifications/render.tsx +++ b/shared/settings/notifications/render.tsx @@ -1,4 +1,5 @@ import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import Group from '../group' import {usePushState} from '@/stores/push' @@ -56,7 +57,7 @@ const Notifications = (props: Props) => { ) : ( - + {emailGroup ? ( ) : !props.showEmailSection ? ( diff --git a/shared/settings/screenprotector/index.tsx b/shared/settings/screenprotector/index.tsx index 684f63e330bc..89c65e949bd8 100644 --- a/shared/settings/screenprotector/index.tsx +++ b/shared/settings/screenprotector/index.tsx @@ -2,6 +2,7 @@ import * as C from '@/constants' import * as T from '@/constants/types' import * as React from 'react' import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {getSecureFlagSetting, setSecureFlagSetting} from '@/constants/platform' let disableScreenshotInitialValue: boolean | undefined @@ -93,7 +94,7 @@ const Screenprotector = () => { } return ( - + { flex={1} style={styles.container} relative={true} + testID={ + selectedTab === 'members' ? TestIDs.TEAMS_MEMBER_LIST : + selectedTab === 'channels' ? TestIDs.TEAMS_CHANNEL_LIST : + selectedTab === 'settings' ? TestIDs.TEAMS_SETTINGS_TAB : + selectedTab === 'bots' ? TestIDs.TEAMS_BOTS_TAB : + undefined + } > { sections={sections} contentContainerStyle={styles.listContentContainer} getItemHeight={() => 48} + testID={TestIDs.TEAMS_BODY} /> { const sections: Array
= nodesNotIn.length > 0 ? [nodesInSection, nodesNotInSection] : [nodesInSection] return ( - + {errors.length > 0 && ( {loading ? : <>} diff --git a/shared/teams/team/tabs.tsx b/shared/teams/team/tabs.tsx index 0a2e45b8007b..933bf5574a43 100644 --- a/shared/teams/team/tabs.tsx +++ b/shared/teams/team/tabs.tsx @@ -1,5 +1,6 @@ import type * as T from '@/constants/types' import * as Kb from '@/common-adapters' +import * as TestIDs from '@/tests/e2e/shared/test-ids' import {isBigTeam} from '@/constants/chat/helpers' import {useInboxLayoutState} from '@/chat/inbox/layout-state' import type {Tab as TabType} from '@/common-adapters/tabs' @@ -39,7 +40,7 @@ const TeamTabs = (props: TeamTabsProps) => { /> ) return ( - + {isMobile ? ( { + await navigateToChat(page) + const rows = page.getByTestId(CHAT_INBOX_ROW) + const count = await rows.count() + if (count === 0) { + test.skip() + return + } + await rows.first().click() + await expect(page.getByTestId(CHAT_MESSAGE_LIST).first()).toBeVisible({timeout: 5_000}) +}) + +test('chat input is visible in open conversation', async ({page}) => { + await navigateToChat(page) + const rows = page.getByTestId(CHAT_INBOX_ROW) + const count = await rows.count() + if (count === 0) { + test.skip() + return + } + await rows.first().click() + await expect(page.getByTestId(CHAT_MESSAGE_LIST).first()).toBeVisible({timeout: 5_000}) + await expect(page.getByTestId(CHAT_INPUT).first()).toBeVisible() +}) + +test('can return to inbox from conversation', async ({page}) => { + await navigateToChat(page) + const rows = page.getByTestId(CHAT_INBOX_ROW) + const count = await rows.count() + if (count === 0) { + test.skip() + return + } + await rows.first().click() + await expect(page.getByTestId(CHAT_MESSAGE_LIST).first()).toBeVisible({timeout: 5_000}) + await page.getByTestId(NAV_TAB_CHAT).click() + await expect(page.getByTestId(CHAT_INBOX_LIST).first()).toBeVisible({timeout: 5_000}) +}) diff --git a/shared/tests/e2e/electron/flows/crypto-outputs.test.ts b/shared/tests/e2e/electron/flows/crypto-outputs.test.ts new file mode 100644 index 000000000000..ff6b0e5461c2 --- /dev/null +++ b/shared/tests/e2e/electron/flows/crypto-outputs.test.ts @@ -0,0 +1,63 @@ +import type {Page} from '@playwright/test' +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToCrypto} from '@/tests/e2e/electron/helpers/navigate' +import * as T from '@/tests/e2e/shared/test-ids' + +// The crypto encrypt tab has two textboxes (recipients "Search people" + main input). +// Use .last() so the recipients search box (which comes first) is skipped. +// Scope CRYPTO_OUTPUT to the tab container since TabRouter keeps all tabs mounted. +async function fillCryptoInput(page: Page, containerTestID: string, text: string) { + await page.getByTestId(containerTestID).getByRole('textbox').last().fill(text) +} + +async function expectCryptoOutput(page: Page, containerTestID: string) { + await expect( + page.getByTestId(containerTestID).getByTestId(T.CRYPTO_OUTPUT) + ).toBeVisible({timeout: 10_000}) +} + +test('Encrypt → output renders', async ({page}) => { + await navigateToCrypto(page) + await page.getByTestId(T.CRYPTO_NAV_ENCRYPT).click() + await expect(page.getByTestId(T.CRYPTO_ENCRYPT_INPUT).first()).toBeVisible() + await fillCryptoInput(page, T.CRYPTO_ENCRYPT_INPUT, 'hello e2e') + await expectCryptoOutput(page, T.CRYPTO_ENCRYPT_INPUT) +}) + +test('Sign → output renders', async ({page}) => { + await navigateToCrypto(page) + await page.getByTestId(T.CRYPTO_NAV_SIGN).click() + await expect(page.getByTestId(T.CRYPTO_SIGN_INPUT).first()).toBeVisible() + await fillCryptoInput(page, T.CRYPTO_SIGN_INPUT, 'hello e2e') + await expectCryptoOutput(page, T.CRYPTO_SIGN_INPUT) +}) + +test('Decrypt → output renders', async ({page}) => { + await navigateToCrypto(page) + // Encrypt first to get valid ciphertext + await page.getByTestId(T.CRYPTO_NAV_ENCRYPT).click() + await expect(page.getByTestId(T.CRYPTO_ENCRYPT_INPUT).first()).toBeVisible() + await fillCryptoInput(page, T.CRYPTO_ENCRYPT_INPUT, 'hello e2e') + await expectCryptoOutput(page, T.CRYPTO_ENCRYPT_INPUT) + const ciphertext = await page.getByTestId(T.CRYPTO_ENCRYPT_INPUT).getByTestId(T.CRYPTO_OUTPUT).innerText() + // Decrypt it + await page.getByTestId(T.CRYPTO_NAV_DECRYPT).click() + await expect(page.getByTestId(T.CRYPTO_DECRYPT_INPUT).first()).toBeVisible() + await fillCryptoInput(page, T.CRYPTO_DECRYPT_INPUT, ciphertext) + await expectCryptoOutput(page, T.CRYPTO_DECRYPT_INPUT) +}) + +test('Verify → output renders', async ({page}) => { + await navigateToCrypto(page) + // Sign first to get a valid signed message + await page.getByTestId(T.CRYPTO_NAV_SIGN).click() + await expect(page.getByTestId(T.CRYPTO_SIGN_INPUT).first()).toBeVisible() + await fillCryptoInput(page, T.CRYPTO_SIGN_INPUT, 'hello e2e') + await expectCryptoOutput(page, T.CRYPTO_SIGN_INPUT) + const signedText = await page.getByTestId(T.CRYPTO_SIGN_INPUT).getByTestId(T.CRYPTO_OUTPUT).innerText() + // Verify it + await page.getByTestId(T.CRYPTO_NAV_VERIFY).click() + await expect(page.getByTestId(T.CRYPTO_VERIFY_INPUT).first()).toBeVisible() + await fillCryptoInput(page, T.CRYPTO_VERIFY_INPUT, signedText) + await expectCryptoOutput(page, T.CRYPTO_VERIFY_INPUT) +}) diff --git a/shared/tests/e2e/electron/flows/device-detail.test.ts b/shared/tests/e2e/electron/flows/device-detail.test.ts new file mode 100644 index 000000000000..13ba10c458bd --- /dev/null +++ b/shared/tests/e2e/electron/flows/device-detail.test.ts @@ -0,0 +1,9 @@ +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToDevices} from '@/tests/e2e/electron/helpers/navigate' +import {DEVICES_ROW, DEVICE_PAGE} from '@/tests/e2e/shared/test-ids' + +test('device detail page renders', async ({page}) => { + await navigateToDevices(page) + await page.getByTestId(DEVICES_ROW).first().click() + await expect(page.getByTestId(DEVICE_PAGE).first()).toBeVisible({timeout: 5_000}) +}) diff --git a/shared/tests/e2e/electron/flows/files-folders.test.ts b/shared/tests/e2e/electron/flows/files-folders.test.ts new file mode 100644 index 000000000000..691794a3c197 --- /dev/null +++ b/shared/tests/e2e/electron/flows/files-folders.test.ts @@ -0,0 +1,44 @@ +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToFiles} from '@/tests/e2e/electron/helpers/navigate' +import {FILES_TLF_ROW, NAV_TAB_FILES} from '@/tests/e2e/shared/test-ids' + +test('files browser shows top-level folders', async ({page}) => { + await navigateToFiles(page) + await expect(page.getByText('private', {exact: true}).first()).toBeVisible() + await expect(page.getByText('public', {exact: true}).first()).toBeVisible() + await expect(page.getByText('team', {exact: true}).first()).toBeVisible() +}) + +test('files browser has three TLF type rows', async ({page}) => { + await navigateToFiles(page) + const rows = page.getByTestId(FILES_TLF_ROW) + await expect(rows.first()).toBeVisible() + await expect(rows).toHaveCount(3) +}) + +test('can navigate into private folder', async ({page}) => { + await navigateToFiles(page) + await page.getByTestId(FILES_TLF_ROW).filter({hasText: 'private'}).click() + await expect(page.getByRole('textbox', {name: 'Filter (⌘F)'})).toBeVisible() +}) + +test('can navigate into public folder', async ({page}) => { + await navigateToFiles(page) + await page.getByTestId(FILES_TLF_ROW).filter({hasText: 'public'}).click() + await expect(page.getByRole('textbox', {name: 'Filter (⌘F)'})).toBeVisible() +}) + +test('can navigate into team folder', async ({page}) => { + await navigateToFiles(page) + await page.getByTestId(FILES_TLF_ROW).filter({hasText: 'team'}).click() + await expect(page.getByRole('textbox', {name: 'Filter (⌘F)'})).toBeVisible() +}) + +test('can navigate back to files root', async ({page}) => { + await navigateToFiles(page) + await page.getByTestId(FILES_TLF_ROW).filter({hasText: 'private'}).click() + await expect(page.getByRole('textbox', {name: 'Filter (⌘F)'})).toBeVisible() + // Clicking the Files tab again when already on Files pops back to root + await page.getByTestId(NAV_TAB_FILES).click() + await expect(page.getByTestId(FILES_TLF_ROW)).toHaveCount(3) +}) diff --git a/shared/tests/e2e/electron/flows/git.test.ts b/shared/tests/e2e/electron/flows/git.test.ts new file mode 100644 index 000000000000..442204c1bcb7 --- /dev/null +++ b/shared/tests/e2e/electron/flows/git.test.ts @@ -0,0 +1,20 @@ +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToGit} from '@/tests/e2e/electron/helpers/navigate' +import {GIT_REPO_LIST, GIT_REPO_ROW} from '@/tests/e2e/shared/test-ids' + +test('git repo list renders', async ({page}) => { + await navigateToGit(page) + await expect(page.getByTestId(GIT_REPO_LIST).first()).toBeVisible() +}) + +test('git repo row is visible', async ({page}) => { + await navigateToGit(page) + const rows = page.getByTestId(GIT_REPO_ROW) + try { + await rows.first().waitFor({timeout: 5_000}) + } catch { + test.skip() + return + } + await expect(rows.first()).toBeVisible() +}) diff --git a/shared/tests/e2e/electron/flows/people-profile.test.ts b/shared/tests/e2e/electron/flows/people-profile.test.ts new file mode 100644 index 000000000000..047014c45d74 --- /dev/null +++ b/shared/tests/e2e/electron/flows/people-profile.test.ts @@ -0,0 +1,23 @@ +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToPeople} from '@/tests/e2e/electron/helpers/navigate' +import {PEOPLE_FEED, PROFILE_PAGE} from '@/tests/e2e/shared/test-ids' + +test('people feed renders', async ({page}) => { + await navigateToPeople(page) + await expect(page.getByTestId(PEOPLE_FEED).first()).toBeVisible() +}) + +test('own profile page renders', async ({page}) => { + const smokeUser = process.env['KB_SMOKE_USER'] + if (!smokeUser) { + test.skip() + return + } + await navigateToPeople(page) + await page.click(`text=Hi ${smokeUser}!`) + await page.click('text=View/Edit profile') + await expect(page.getByTestId(PROFILE_PAGE).first()).toBeVisible({timeout: 10_000}) + // Close the account-switcher popup and return to a clean tab state + await page.keyboard.press('Escape') + await navigateToPeople(page) +}) diff --git a/shared/tests/e2e/electron/flows/settings-subpages.test.ts b/shared/tests/e2e/electron/flows/settings-subpages.test.ts new file mode 100644 index 000000000000..e719ba3eb1ab --- /dev/null +++ b/shared/tests/e2e/electron/flows/settings-subpages.test.ts @@ -0,0 +1,61 @@ +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToSettings} from '@/tests/e2e/electron/helpers/navigate' +import * as T from '@/tests/e2e/shared/test-ids' + +// All nav clicks are scoped to the settings left-nav container (SETTINGS_ACCOUNT testID) to: +// - avoid matching main navigation tabs (Chat, Files) which share the same label text +// - ensure items below the fold (About) are scrolled into view within the nav container + +test('Advanced page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=Advanced').click() + await expect(page.getByTestId(T.SETTINGS_ADVANCED)).toBeVisible({timeout: 5_000}) +}) + +test('About page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=About').click() + await expect(page.getByTestId(T.SETTINGS_ABOUT)).toBeVisible({timeout: 5_000}) +}) + +test('Backup page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=Backup').click() + await expect(page.getByTestId(T.SETTINGS_ARCHIVE)).toBeVisible({timeout: 5_000}) +}) + +test('Chat page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=Chat').click() + await expect(page.getByTestId(T.SETTINGS_CHAT)).toBeVisible({timeout: 5_000}) +}) + +test('Display page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=Display').click() + await expect(page.getByTestId(T.SETTINGS_DISPLAY)).toBeVisible({timeout: 5_000}) +}) + +test('Feedback page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=Feedback').click() + await expect(page.getByTestId(T.SETTINGS_FEEDBACK)).toBeVisible({timeout: 5_000}) +}) + +test('Files page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=Files').click() + await expect(page.getByTestId(T.SETTINGS_FILES)).toBeVisible({timeout: 5_000}) +}) + +test('Notifications page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=Notifications').click() + await expect(page.getByTestId(T.SETTINGS_NOTIFICATIONS)).toBeVisible({timeout: 5_000}) +}) + +test('Screen protector page renders', async ({page}) => { + await navigateToSettings(page) + await page.getByTestId(T.SETTINGS_ACCOUNT).locator('text=Screen protector').click() + await expect(page.getByTestId(T.SETTINGS_SCREENPROTECTOR)).toBeVisible({timeout: 5_000}) +}) diff --git a/shared/tests/e2e/electron/flows/team-member.test.ts b/shared/tests/e2e/electron/flows/team-member.test.ts new file mode 100644 index 000000000000..82b21364bb1e --- /dev/null +++ b/shared/tests/e2e/electron/flows/team-member.test.ts @@ -0,0 +1,22 @@ +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToTeams} from '@/tests/e2e/electron/helpers/navigate' +import {TEAMS_ROW, TEAMS_MEMBER_LIST, TEAMS_MEMBER_PAGE} from '@/tests/e2e/shared/test-ids' + +test('team member page renders', async ({page}) => { + const smokeUser = process.env['KB_SMOKE_USER'] + if (!smokeUser) { + test.skip() + return + } + await navigateToTeams(page) + const rows = page.getByTestId(TEAMS_ROW) + if ((await rows.count()) === 0) { + test.skip() + return + } + await rows.first().click() + await expect(page.getByTestId(TEAMS_MEMBER_LIST).first()).toBeVisible({timeout: 5_000}) + // Click the smoke user's username in the member list + await page.getByTestId(TEAMS_MEMBER_LIST).getByText(smokeUser, {exact: true}).first().click() + await expect(page.getByTestId(TEAMS_MEMBER_PAGE).first()).toBeVisible({timeout: 5_000}) +}) diff --git a/shared/tests/e2e/electron/flows/teams-inner.test.ts b/shared/tests/e2e/electron/flows/teams-inner.test.ts new file mode 100644 index 000000000000..31f3577fb2ce --- /dev/null +++ b/shared/tests/e2e/electron/flows/teams-inner.test.ts @@ -0,0 +1,64 @@ +import type {Page} from '@playwright/test' +import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' +import {navigateToTeams} from '@/tests/e2e/electron/helpers/navigate' +import * as T from '@/tests/e2e/shared/test-ids' + +async function openFirstTeam(page: Page): Promise { + await navigateToTeams(page) + const rows = page.getByTestId(T.TEAMS_ROW) + if ((await rows.count()) === 0) return false + await rows.first().click() + // Wait for team list rows to disappear — indicates we've entered the team detail + await rows.first().waitFor({state: 'hidden', timeout: 5_000}) + return true +} + +// Unique tab labels in the team tabs bar (capitalize(title) from Kb.Tabs) +// 'Settings' and 'Emoji' appear in the nav sidebar too, so we use .nth(1) for those. +// 'Members', 'Bots', 'Channels', 'Subteams' are unique to the team tabs bar. + +test('members tab renders', async ({page}) => { + const opened = await openFirstTeam(page) + if (!opened) { + test.skip() + return + } + await expect(page.getByTestId(T.TEAMS_MEMBER_LIST).first()).toBeVisible({timeout: 5_000}) +}) + +test('settings tab renders', async ({page}) => { + const opened = await openFirstTeam(page) + if (!opened) { + test.skip() + return + } + // 'Settings' appears in both nav sidebar and team tabs — use .nth(1) for team tab + await page.getByText('Settings', {exact: true}).nth(1).click() + // Verify we're still in the team view (Members tab still visible) + await expect(page.getByText('Members', {exact: true}).first()).toBeVisible({timeout: 5_000}) +}) + +test('bots tab renders', async ({page}) => { + const opened = await openFirstTeam(page) + if (!opened) { + test.skip() + return + } + await page.getByText('Bots', {exact: true}).first().click() + await expect(page.getByText('Members', {exact: true}).first()).toBeVisible({timeout: 5_000}) +}) + +test('channels tab renders (if big team or admin)', async ({page}) => { + const opened = await openFirstTeam(page) + if (!opened) { + test.skip() + return + } + const channelsTab = page.getByText('Channels', {exact: true}).first() + if (!(await channelsTab.isVisible())) { + test.skip() + return + } + await channelsTab.click() + await expect(page.getByText('Members', {exact: true}).first()).toBeVisible({timeout: 5_000}) +}) diff --git a/shared/tests/e2e/electron/helpers/fixtures.ts b/shared/tests/e2e/electron/helpers/fixtures.ts index e0da30bf9ec4..b305494a3ad8 100644 --- a/shared/tests/e2e/electron/helpers/fixtures.ts +++ b/shared/tests/e2e/electron/helpers/fixtures.ts @@ -1,5 +1,6 @@ import {test as base, type Page} from '@playwright/test' import {connectToElectron} from './connect' +import {NAV_TAB_CHAT} from '@/tests/e2e/shared/test-ids' type WorkerFixtures = {_electronPage: Page} @@ -10,6 +11,9 @@ export const test = base.extend<{page: Page}, WorkerFixtures>({ // eslint-disable-next-line no-empty-pattern async ({}, setup) => { const {page} = await connectToElectron() + // Reload to clear any in-memory state left over from previous test runs + await page.reload() + await page.getByTestId(NAV_TAB_CHAT).waitFor({timeout: 30_000}) await setup(page) // Do NOT close — that kills the Electron process }, diff --git a/shared/tests/e2e/electron/helpers/navigate.ts b/shared/tests/e2e/electron/helpers/navigate.ts index 45b8b3574170..ad311e66c6c3 100644 --- a/shared/tests/e2e/electron/helpers/navigate.ts +++ b/shared/tests/e2e/electron/helpers/navigate.ts @@ -1,47 +1,54 @@ import type {Page} from '@playwright/test' import * as T from '@/tests/e2e/shared/test-ids' +async function clickNavTab(page: Page, tabTestID: string): Promise { + await page.click(`[data-testid="${tabTestID}"]`) +} + export async function navigateToChat(page: Page): Promise { - await page.click('text=Chat') + await clickNavTab(page, T.NAV_TAB_CHAT) await page.waitForSelector(`[data-testid="${T.CHAT_INBOX_LIST}"]`, {timeout: 5_000}) } export async function navigateToTeams(page: Page): Promise { - await page.click('text=Teams') - // If the tab was left inside a team, clicking the active tab again pops to root - const list = page.locator(`[data-testid="${T.TEAMS_LIST}"]`) - if (!(await list.isVisible())) { - await page.click('text=Teams') + // Teams nav tab becomes non-actionable when the desktop header's WebkitAppRegion:drag + // region is active (e.g. when a team detail is open). force:true lets the click through, + // which triggers jumpTo to reset the tab stack back to teamsRoot. + await page.click(`[data-testid="${T.NAV_TAB_TEAMS}"]`, {force: true}) + try { + await page.waitForSelector(`[data-testid="${T.TEAMS_LIST}"]`, {timeout: 3_000}) + } catch { + await page.click(`[data-testid="${T.NAV_TAB_TEAMS}"]`, {force: true}) + await page.waitForSelector(`[data-testid="${T.TEAMS_LIST}"]`, {timeout: 5_000}) } - await page.waitForSelector(`[data-testid="${T.TEAMS_LIST}"]`, {timeout: 5_000}) } export async function navigateToFiles(page: Page): Promise { - await page.click('text=Files') + await clickNavTab(page, T.NAV_TAB_FILES) await page.waitForSelector(`[data-testid="${T.FILES_BROWSER}"]`, {timeout: 5_000}) } export async function navigateToDevices(page: Page): Promise { - await page.click('text=Devices') + await clickNavTab(page, T.NAV_TAB_DEVICES) await page.waitForSelector(`[data-testid="${T.DEVICES_LIST}"]`, {timeout: 5_000}) } export async function navigateToSettings(page: Page): Promise { - await page.click('text=Settings') + await clickNavTab(page, T.NAV_TAB_SETTINGS) await page.waitForSelector(`[data-testid="${T.SETTINGS_ACCOUNT}"]`, {timeout: 5_000}) } export async function navigateToPeople(page: Page): Promise { - await page.click('text=People') + await clickNavTab(page, T.NAV_TAB_PEOPLE) await page.waitForSelector(`[data-testid="${T.PEOPLE_FEED}"]`, {timeout: 5_000}) } export async function navigateToCrypto(page: Page): Promise { - await page.click('text=Crypto') + await clickNavTab(page, T.NAV_TAB_CRYPTO) await page.waitForSelector(`[data-testid="${T.CRYPTO_INPUT}"]`, {timeout: 5_000}) } export async function navigateToGit(page: Page): Promise { - await page.click('text=Git') + await clickNavTab(page, T.NAV_TAB_GIT) await page.waitForSelector(`[data-testid="${T.GIT_REPO_LIST}"]`, {timeout: 5_000}) } diff --git a/shared/tests/e2e/shared/test-ids.ts b/shared/tests/e2e/shared/test-ids.ts index 88545f5d4df4..2e46e46f610d 100644 --- a/shared/tests/e2e/shared/test-ids.ts +++ b/shared/tests/e2e/shared/test-ids.ts @@ -20,22 +20,43 @@ export const CHAT_SEND_BUTTON = 'chat-send-button' // Files export const FILES_BROWSER = 'files-browser' +export const FILES_TLF_ROW = 'files-tlf-row' // Teams -export const TEAMS_LIST = 'teams-list' -export const TEAMS_ROW = 'teams-row' +export const TEAMS_LIST = 'teams-list' +export const TEAMS_ROW = 'teams-row' +export const TEAMS_BODY = 'teams-body' +export const TEAMS_TABS = 'teams-tabs' +export const TEAMS_MEMBER_LIST = 'teams-member-list' +export const TEAMS_MEMBER_PAGE = 'teams-member-page' +export const TEAMS_CHANNEL_LIST = 'teams-channel-list' +export const TEAMS_SETTINGS_TAB = 'teams-settings-tab' +export const TEAMS_BOTS_TAB = 'teams-bots-tab' // Devices export const DEVICES_LIST = 'devices-list' export const DEVICES_ROW = 'devices-row' +export const DEVICE_PAGE = 'device-page' // Settings -export const SETTINGS_ACCOUNT = 'settings-account' -export const SETTINGS_NAV_ITEM = 'settings-nav-item' +export const SETTINGS_ACCOUNT = 'settings-account' +export const SETTINGS_NAV_ITEM = 'settings-nav-item' +export const SETTINGS_ADVANCED = 'settings-advanced' +export const SETTINGS_ABOUT = 'settings-about' +export const SETTINGS_ARCHIVE = 'settings-archive' +export const SETTINGS_CHAT = 'settings-chat' +export const SETTINGS_DISPLAY = 'settings-display' +export const SETTINGS_FEEDBACK = 'settings-feedback' +export const SETTINGS_FILES = 'settings-files' +export const SETTINGS_NOTIFICATIONS = 'settings-notifications' +export const SETTINGS_SCREENPROTECTOR = 'settings-screenprotector' // People export const PEOPLE_FEED = 'people-feed' +// Profile +export const PROFILE_PAGE = 'profile-page' + // Git export const GIT_REPO_LIST = 'git-repo-list' export const GIT_REPO_ROW = 'git-repo-row' @@ -51,6 +72,7 @@ export const CRYPTO_ENCRYPT_INPUT = 'crypto-encrypt-input' export const CRYPTO_DECRYPT_INPUT = 'crypto-decrypt-input' export const CRYPTO_SIGN_INPUT = 'crypto-sign-input' export const CRYPTO_VERIFY_INPUT = 'crypto-verify-input' +export const CRYPTO_RUN_BUTTON = 'crypto-run-button' // Common — keep value matching existing testID="backButton" in .maestro subflows export const COMMON_BACK_BUTTON = 'backButton' diff --git a/skill/keybase-e2e-tests/SKILL.md b/skill/keybase-e2e-tests/SKILL.md new file mode 100644 index 000000000000..61ffaa522689 --- /dev/null +++ b/skill/keybase-e2e-tests/SKILL.md @@ -0,0 +1,81 @@ +--- +name: keybase-e2e-tests +description: Use when writing, fixing, or adding e2e flow tests for the Keybase app — desktop (Playwright) or iOS (Maestro). Covers testID conventions, navigation patterns, common pitfalls, and the two-harness structure. +--- + +# Keybase E2E Flow Tests + +## Overview + +Two harnesses, one shared testID registry. Always implement Electron + iOS for each bucket together (pairing rule in `plans/flow-test.md`). + +## Shared testID Registry + +`shared/tests/e2e/shared/test-ids.ts` — single source of truth for all `testID` values. + +**Adding a testID to a component:** +```tsx +import * as TestIDs from '@/tests/e2e/shared/test-ids' +// ... + +``` + +**Rule:** Add `testID` prop to an **already-existing** element (input, scroll view, pre-existing wrapper). Never add a new container just to attach a testID. + +## Desktop — Playwright + +| What | Where | +|------|-------| +| Test files | `shared/tests/e2e/electron/flows/*.test.ts` | +| Nav helpers | `shared/tests/e2e/electron/helpers/navigate.ts` | +| Run all | `yarn test:e2e:desktop` | +| Run branch | `yarn test:e2e:desktop:branch` | + +**Navigation helpers:** `navigateToChat`, `navigateToFiles`, `navigateToTeams`, `navigateToGit`, `navigateToSettings`, `navigateToPeople`, `navigateToCrypto`, `navigateToDevices` + +**Common pitfall — hidden nav stack elements:** React Navigation keeps prior screens mounted but hidden. `getByTestId(X)` may match 2+ elements (one hidden, one visible), causing Playwright strict-mode failures. Fix: use a selector unique to the destination screen rather than one shared with the source screen. Example: after navigating into a Files subfolder, the root screen's `files-browser` is still in the DOM but hidden — check for the Filter textbox instead, which only exists in subfolder views. + +## iOS — Maestro + +| What | Where | +|------|-------| +| Flow files | `shared/.maestro/e2e/flows/*.yaml` | +| Subflows | `shared/.maestro/e2e/subflows/` | +| Config | `shared/.maestro/e2e/config.yaml` (60 s timeout) | +| Run all | `yarn test:e2e:ios` | +| Run branch | `yarn test:e2e:ios:branch` | + +**iOS tab structure:** +- Direct tabs: **People**, **Teams** +- Via Teams first: **Chat**, **Files** (both also appear in More menu — tap Teams first to avoid ambiguity) +- **More** tab contains: Crypto, Devices, Git, Settings, and settings sub-pages + +**Subflow — escape-to-tabs:** Taps backButton up to 4 times to reset any open screen back to the tab bar. Always run at the start of each flow. + +**Maestro command patterns:** +```yaml +appId: keybase.ios +--- +- runFlow: ../subflows/escape-to-tabs.yaml +- tapOn: + text: "Teams" # navigate past ambiguous tabs first +- tapOn: + text: "Files" +- extendedWaitUntil: + visible: + id: "files-browser" # testID value (not the constant name) + timeout: 3000 +- takeScreenshot: tests/results/ios-debug/flow-name-step +- runFlow: + when: + visible: + id: "some-id" # conditional block — skip gracefully if no data + commands: + - tapOn: + id: "some-id" + index: 0 +``` + +## Plan + +`plans/flow-test.md` — bucket checklist ordered easiest-first. Work one bucket at a time, both platforms together.