From d17b8ead563120a8461fa966bf1a0bbc773c9c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 21:33:03 +0200 Subject: [PATCH 1/2] feat(test-app): add a Bluesky-depth heavy feed screen A JavaScript-only route that reproduces a heavy React Native home feed: 30 nested containers above a virtualized list of composite posts with image, link, quote, and quote-with-media embeds. Its native accessibility tree is deeper than 65 levels, so XCTest tree capture fails with kAXErrorIllegalArgument and recovers through private AX, as on Bluesky 1.136. Settings links to it with open-heavy-feed. Co-Authored-By: Claude Opus 5.5 --- examples/test-app/README.md | 18 + examples/test-app/app/(tabs)/settings.tsx | 1 + examples/test-app/app/_layout.tsx | 1 + examples/test-app/app/heavy-feed.tsx | 5 + .../test-app/src/screens/HeavyFeedScreen.tsx | 362 ++++++++++++++++++ .../test-app/src/screens/SettingsScreen.tsx | 6 + 6 files changed, 393 insertions(+) create mode 100644 examples/test-app/app/heavy-feed.tsx create mode 100644 examples/test-app/src/screens/HeavyFeedScreen.tsx diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 50604c65f1..854a7fda64 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 360 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/screens/HeavyFeedScreen.tsx b/examples/test-app/src/screens/HeavyFeedScreen.tsx new file mode 100644 index 0000000000..0ac110a54a --- /dev/null +++ b/examples/test-app/src/screens/HeavyFeedScreen.tsx @@ -0,0 +1,362 @@ +import { useLocalSearchParams } from 'expo-router'; +import { useCallback, useMemo, useState, type ReactNode } from 'react'; +import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; + +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) => ( + + + + + + + + ))} + + + + ); +} + +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)); +} + +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..5d099275df 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; @@ -81,6 +82,11 @@ export function SettingsScreen(props: SettingsScreenProps) { onPress={props.onOpenSnapshotDepth} testID="open-snapshot-depth-lab" /> + Date: Thu, 22 Oct 2026 06:04:46 +0200 Subject: [PATCH 2/2] refactor(test-app): share one route-param parser and keep the heavy feed card last Review on #3884: HeavyFeedScreen, DeepTreeScreen and TickerScreen each carried a copy of clampInteger; they now import it from route-params.ts. The heavy feed entry moves to its own last Settings card so the cards the Maestro differential flows tap without scrolling keep their positions (#1299). The README node count now matches the measured 305-308 raw nodes. Co-Authored-By: Claude Opus 5.5 --- examples/test-app/README.md | 2 +- examples/test-app/src/route-params.ts | 10 ++++++++++ .../test-app/src/screens/DeepTreeScreen.tsx | 7 +------ .../test-app/src/screens/HeavyFeedScreen.tsx | 7 +------ .../test-app/src/screens/SettingsScreen.tsx | 17 ++++++++++++----- examples/test-app/src/screens/TickerScreen.tsx | 7 +------ 6 files changed, 26 insertions(+), 24 deletions(-) create mode 100644 examples/test-app/src/route-params.ts diff --git a/examples/test-app/README.md b/examples/test-app/README.md index 854a7fda64..46f606a3aa 100644 --- a/examples/test-app/README.md +++ b/examples/test-app/README.md @@ -268,7 +268,7 @@ agent-device react-devtools profile stop 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 360 raw nodes. +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 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 index 0ac110a54a..5b813fff2b 100644 --- a/examples/test-app/src/screens/HeavyFeedScreen.tsx +++ b/examples/test-app/src/screens/HeavyFeedScreen.tsx @@ -2,6 +2,7 @@ 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'; /** @@ -306,12 +307,6 @@ function MediaGrid({ postId, tiles, styles }: { postId: number; tiles: number; s ); } -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)); -} - type Styles = ReturnType; function createStyles(colors: AppColors) { diff --git a/examples/test-app/src/screens/SettingsScreen.tsx b/examples/test-app/src/screens/SettingsScreen.tsx index 5d099275df..994ff00cb9 100644 --- a/examples/test-app/src/screens/SettingsScreen.tsx +++ b/examples/test-app/src/screens/SettingsScreen.tsx @@ -82,11 +82,6 @@ export function SettingsScreen(props: SettingsScreenProps) { onPress={props.onOpenSnapshotDepth} testID="open-snapshot-depth-lab" /> - + + {/* 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 },