diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 50604c65f1..46f606a3aa 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -19,6 +19,7 @@ It is intentionally small, but each surface is dense with durable accessibility - `Automation lab`: long-press, alert-result, app-event, app-state, appearance, orientation, permission-recovery, log canaries, a flattened (`accessible={true}`) text input, and an Apple Pay sheet hosted in `com.apple.PassbookUIService` - `WebView accessibility`: a deterministic semantic fixture plus live websites with varied HTML for native accessibility snapshot verification - `Perf lab`: seeded JavaScript render problems for `react-devtools profile`, one per scenario (see [Perf lab](#perf-lab)) +- `Heavy feed`: a Bluesky-shaped home feed whose accessibility tree is deeper than 65 levels (see [Heavy feed](#heavy-feed)) Navigation uses Expo Router native bottom tabs, so the tab bar itself is also part of the test surface. @@ -261,6 +262,23 @@ agent-device react-devtools profile stop `src/screens/PerfLabScreen.tsx` imports only React and React Native, so it can be copied into a bare React Native app to check the profiler on other React Native versions. +## Heavy feed + +**Settings → Open heavy feed** (`open-heavy-feed`) opens a JavaScript-only reproduction of a heavy +React Native home feed, shaped after Bluesky 1.136. Thirty nested containers stand in for its +navigation and provider layers. Below them a virtualized list renders posts with avatar, text, +image, link, quote, and quote-with-media embeds. Every level opts out of view flattening, so the +native accessibility tree is deeper than 65 levels and holds about 300 raw nodes. + +On this screen the XCTest tree capture fails with `kAXErrorIllegalArgument` and recovers through +private AX, while the simulator accessibility bridge still answers. Use it to reproduce deep-tree +capture, recovery, and gesture-routing behavior without a third-party app. `wrappers` (default 30) +and `posts` (default 60) route parameters change the shape. + +The stable ids are `heavy-feed-title`, `heavy-feed-liked-count`, `heavy-feed-last-action`, and per +post `heavy-post-`, `heavy-post--like`, `-reply`, `-repost`, and `-more`. A like press updates +`Liked posts: `, and a long press on a post sets `Last action: held post `. + ## Local Agent Device suites The repo includes two local suites for iterating on the fixture app: diff --git a/examples/test-app/app/(tabs)/settings.tsx b/examples/test-app/app/(tabs)/settings.tsx index 7f7ca57180..e8e39b4796 100644 --- a/examples/test-app/app/(tabs)/settings.tsx +++ b/examples/test-app/app/(tabs)/settings.tsx @@ -19,6 +19,7 @@ export default function SettingsRoute() { onLoadDiagnostics={state.loadDiagnostics} onOpenAccessorySetup={() => router.push('/accessory-setup')} onOpenAutomationLab={() => router.push('/automation')} + onOpenHeavyFeed={() => router.push('/heavy-feed')} onOpenInertSurface={() => router.push('/inert')} onOpenSnapshotDepth={() => router.push('/snapshot-depth')} onOpenWebViewLab={() => router.push('/webview')} diff --git a/examples/test-app/app/_layout.tsx b/examples/test-app/app/_layout.tsx index e64205f5f4..e452a411d2 100644 --- a/examples/test-app/app/_layout.tsx +++ b/examples/test-app/app/_layout.tsx @@ -28,6 +28,7 @@ function RootLayoutContent() { + diff --git a/examples/test-app/app/heavy-feed.tsx b/examples/test-app/app/heavy-feed.tsx new file mode 100644 index 0000000000..12dbd4d8fa --- /dev/null +++ b/examples/test-app/app/heavy-feed.tsx @@ -0,0 +1,5 @@ +import { HeavyFeedScreen } from '../src/screens/HeavyFeedScreen'; + +export default function HeavyFeedRoute() { + return ; +} diff --git a/examples/test-app/src/route-params.ts b/examples/test-app/src/route-params.ts new file mode 100644 index 0000000000..956f4c94df --- /dev/null +++ b/examples/test-app/src/route-params.ts @@ -0,0 +1,10 @@ +export function clampInteger( + value: string | undefined, + fallback: number, + min: number, + max: number, +): number { + const parsed = Number.parseInt(value ?? '', 10); + if (!Number.isFinite(parsed)) return fallback; + return Math.min(max, Math.max(min, parsed)); +} diff --git a/examples/test-app/src/screens/DeepTreeScreen.tsx b/examples/test-app/src/screens/DeepTreeScreen.tsx index c892c08c7d..03b2411ede 100644 --- a/examples/test-app/src/screens/DeepTreeScreen.tsx +++ b/examples/test-app/src/screens/DeepTreeScreen.tsx @@ -1,6 +1,7 @@ import { useLocalSearchParams } from 'expo-router'; import { StyleSheet, Text, View } from 'react-native'; +import { clampInteger } from '../route-params'; import { useAppColors, type AppColors } from '../theme'; /** @@ -62,12 +63,6 @@ function DeepLevel({ ); } -function clampInteger(value: string | undefined, fallback: number, min: number, max: number) { - const parsed = Number.parseInt(value ?? '', 10); - if (!Number.isFinite(parsed)) return fallback; - return Math.min(max, Math.max(min, parsed)); -} - function createStyles(colors: AppColors) { return StyleSheet.create({ frame: { diff --git a/examples/test-app/src/screens/HeavyFeedScreen.tsx b/examples/test-app/src/screens/HeavyFeedScreen.tsx new file mode 100644 index 0000000000..5b813fff2b --- /dev/null +++ b/examples/test-app/src/screens/HeavyFeedScreen.tsx @@ -0,0 +1,357 @@ +import { useLocalSearchParams } from 'expo-router'; +import { useCallback, useMemo, useState, type ReactNode } from 'react'; +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; + +import { clampInteger } from '../route-params'; +import { useAppColors, type AppColors } from '../theme'; + +/** + * A JavaScript-only reproduction of a heavy React Native home feed, shaped after Bluesky 1.136. + * `wrappers` nested containers stand in for its navigation, provider, and screen layers. Below them + * a virtualized list renders composite posts five to ten levels deep, and quote-with-media embeds + * add more. With the defaults the deepest post node sits more than 65 levels below the application, + * so the XCTest tree capture degrades as it does on Bluesky. Every level opts out of view + * flattening, so the native hierarchy is as deep as the React tree. + */ +export function HeavyFeedScreen() { + const colors = useAppColors(); + const styles = useMemo(() => createStyles(colors), [colors]); + const params = useLocalSearchParams<{ wrappers?: string; posts?: string }>(); + const wrappers = clampInteger(params.wrappers, 30, 0, 80); + const postCount = clampInteger(params.posts, 60, 1, 300); + const posts = useMemo(() => buildPosts(postCount), [postCount]); + const [liked, setLiked] = useState>(new Set()); + const [lastAction, setLastAction] = useState('none'); + const [tab, setTab] = useState<'following' | 'discover'>('following'); + + const toggleLike = useCallback((id: number) => { + setLiked((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + setLastAction(`${next.has(id) ? 'liked' : 'unliked'} post ${id}`); + return next; + }); + }, []); + const holdPost = useCallback((id: number) => setLastAction(`held post ${id}`), []); + + const feed = ( + + + + Heavy feed + + + {(['following', 'discover'] as const).map((name) => ( + { + setTab(name); + setLastAction(`tab ${name}`); + }} + style={[styles.tab, tab === name ? styles.tabSelected : null]} + testID={`heavy-feed-tab-${name}`} + > + {name === 'following' ? 'Following' : 'Discover'} + + ))} + + + Liked posts: {liked.size} + + + Last action: {lastAction} + + + String(post.id)} + renderItem={({ item }) => ( + + )} + testID="heavy-feed-list" + windowSize={5} + /> + + ); + + return ( + + {feed} + + ); +} + +type Embed = 'none' | 'image' | 'link' | 'quote' | 'quote-media'; + +type Post = { + id: number; + author: string; + handle: string; + age: string; + text: string; + embed: Embed; + replies: number; + reposts: number; + likes: number; +}; + +const AUTHORS = ['Ada Byron', 'Grace Hopper', 'Alan Turing', 'Edsger Dijkstra', 'Barbara Liskov']; +const EMBEDS: readonly Embed[] = ['quote-media', 'none', 'image', 'quote', 'link']; + +function buildPosts(count: number): Post[] { + return Array.from({ length: count }, (_, id) => { + const author = AUTHORS[id % AUTHORS.length]!; + return { + id, + author, + handle: `@${author.toLowerCase().replace(' ', '.')}.example`, + age: `${(id % 23) + 1}h`, + text: `Post ${id}: a deterministic feed entry with enough copy to wrap onto a second line.`, + embed: EMBEDS[id % EMBEDS.length]!, + replies: (id * 7) % 31, + reposts: (id * 5) % 17, + likes: (id * 11) % 97, + }; + }); +} + +function ShellLayers({ + count, + styles, + children, +}: { + count: number; + styles: Styles; + children: ReactNode; +}) { + let tree = children; + for (let level = count - 1; level >= 0; level -= 1) { + tree = ( + + {tree} + + ); + } + return tree; +} + +function PostCard({ + post, + liked, + onLike, + onHold, + styles, +}: { + post: Post; + liked: boolean; + onLike: (id: number) => void; + onHold: (id: number) => void; + styles: Styles; +}) { + const id = `heavy-post-${post.id}`; + return ( + + onHold(post.id)}> + + + + + {post.author} + + {post.handle} · {post.age} + + + + {post.text} + + + + + + onLike(post.id)} + selected={liked} + styles={styles} + testID={`${id}-like`} + /> + + + + + + + ); +} + +function ActionButton({ + label, + count, + onPress, + selected = false, + styles, + testID, +}: { + label: string; + count?: number; + onPress?: () => void; + selected?: boolean; + styles: Styles; + testID: string; +}) { + return ( + + + + {count === undefined ? '…' : count} + + + + ); +} + +function PostEmbed({ embed, postId, styles }: { embed: Embed; postId: number; styles: Styles }) { + if (embed === 'none') return null; + if (embed === 'image') return ; + if (embed === 'link') { + return ( + + + + example.com + A link card with a title and a description. + + + ); + } + return ( + + + + + + + Quoted author + + + + A quoted post embedded inside post {postId}. + + + {embed === 'quote-media' ? ( + + ) : null} + + + + + ); +} + +function MediaGrid({ postId, tiles, styles }: { postId: number; tiles: number; styles: Styles }) { + return ( + + + + {Array.from({ length: tiles }, (_, index) => ( + + + + + + + + ))} + + + + ); +} + +type Styles = ReturnType; + +function createStyles(colors: AppColors) { + return StyleSheet.create({ + shell: { flex: 1 }, + frame: { flex: 1, backgroundColor: colors.surface }, + header: { + borderBottomColor: colors.line, + borderBottomWidth: 1, + gap: 4, + paddingHorizontal: 16, + paddingVertical: 8, + }, + title: { color: colors.text, fontSize: 20, fontWeight: '700' }, + tabs: { flexDirection: 'row', gap: 8 }, + tab: { borderRadius: 14, paddingHorizontal: 12, paddingVertical: 6 }, + tabSelected: { backgroundColor: colors.accentSoft }, + tabLabel: { color: colors.text, fontWeight: '600' }, + status: { color: colors.textSoft, fontSize: 12 }, + post: { borderBottomColor: colors.line, borderBottomWidth: 1, padding: 12 }, + postRow: { flexDirection: 'row', gap: 10 }, + avatar: { backgroundColor: colors.accentSoft, borderRadius: 20, height: 40, width: 40 }, + postContent: { flex: 1, gap: 6 }, + postMeta: { alignItems: 'center', flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + author: { color: colors.text, fontWeight: '600' }, + soft: { color: colors.textSoft, fontSize: 12 }, + body: { color: colors.text }, + embed: { + borderColor: colors.lineStrong, + borderRadius: 10, + borderWidth: 1, + gap: 6, + padding: 8, + }, + quoteBody: { gap: 6 }, + quoteAvatar: { backgroundColor: colors.cardStrong, borderRadius: 8, height: 16, width: 16 }, + linkThumb: { backgroundColor: colors.cardStrong, borderRadius: 6, height: 48 }, + media: { borderRadius: 8, overflow: 'hidden' }, + mediaRow: { flexDirection: 'row', gap: 2 }, + mediaCell: { flex: 1 }, + mediaFrame: { aspectRatio: 1 }, + mediaImage: { backgroundColor: colors.accentSoft, flex: 1 }, + actions: { flexDirection: 'row', justifyContent: 'space-between' }, + action: { minWidth: 44, paddingVertical: 4 }, + actionInner: { alignItems: 'center' }, + actionSelected: { color: colors.accent, fontSize: 12, fontWeight: '700' }, + }); +} diff --git a/examples/test-app/src/screens/SettingsScreen.tsx b/examples/test-app/src/screens/SettingsScreen.tsx index c580c3f21a..994ff00cb9 100644 --- a/examples/test-app/src/screens/SettingsScreen.tsx +++ b/examples/test-app/src/screens/SettingsScreen.tsx @@ -20,6 +20,7 @@ export interface SettingsScreenProps { onLoadDiagnostics: () => void; onOpenAccessorySetup: () => void; onOpenAutomationLab: () => void; + onOpenHeavyFeed: () => void; onOpenInertSurface: () => void; onOpenSnapshotDepth: () => void; onOpenWebViewLab: () => void; @@ -203,6 +204,18 @@ export function SettingsScreen(props: SettingsScreenProps) { /> + + {/* Keep this card last: Maestro differential flows tap cards above it without scrolling (#1299). */} + + + ); } diff --git a/examples/test-app/src/screens/TickerScreen.tsx b/examples/test-app/src/screens/TickerScreen.tsx index 63bdd6d0e3..94ca96147c 100644 --- a/examples/test-app/src/screens/TickerScreen.tsx +++ b/examples/test-app/src/screens/TickerScreen.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; import { useLocalSearchParams } from 'expo-router'; +import { clampInteger } from '../route-params'; import { useAppColors, type AppColors } from '../theme'; /** @@ -71,12 +72,6 @@ export function TickerScreen() { ); } -function clampInteger(raw: string | undefined, fallback: number, min: number, max: number): number { - const parsed = Number.parseInt(raw ?? '', 10); - if (Number.isNaN(parsed)) return fallback; - return Math.min(max, Math.max(min, parsed)); -} - function createStyles(colors: AppColors) { return StyleSheet.create({ frame: { flex: 1, backgroundColor: colors.surface, padding: 12 },