diff --git a/packages/@expo/agent-cli/CHANGELOG.md b/packages/@expo/agent-cli/CHANGELOG.md index ea4e7e229..08493c516 100644 --- a/packages/@expo/agent-cli/CHANGELOG.md +++ b/packages/@expo/agent-cli/CHANGELOG.md @@ -48,6 +48,7 @@ - `smoke` and `navigate` no longer target Expo Go for a project that cannot run in it. The deep-link decision now reads the same Expo Go compatibility the plan engine and `status` read, and `smoke` will not report `passed` when the app that answered is an Expo Go the project's native code is not in. ([#2](https://github.com/expo/expo-agent-cli/pull/2) by [@kudo](https://github.com/kudo)) - `smoke` now reloads an app that was already running onto the code on disk before it reads it, so the gate no longer passes an app whose error window and screenshot are from before the edit. A reload it cannot prove is `inconclusive` rather than `passed`, and `--no-reload` opts out. ([#2](https://github.com/expo/expo-agent-cli/pull/2) by [@kudo](https://github.com/kudo)) - `dev --detach` no longer reports a detached dev server for a plan step that opens the app and is then refused by macOS after the lock appeared. The grace window that catches that refusal no longer requires `--wait-ready`, so a `dev --detach --ios` now spends it before reporting. ([#2](https://github.com/expo/expo-agent-cli/pull/2) by [@kudo](https://github.com/kudo)) +- `runtime:tree` and the interaction commands find the focused screen of an expo-router 58 app on the native stack, JS tabs and the drawer, so they read the screen on top, not every mounted screen. The JS stack, JS top tabs and headless `ui` tabs still report no focused screen. ([#199](https://github.com/expo/expo-agent-cli/pull/199) by [@vonovak](https://github.com/vonovak)) ### 💡 Others diff --git a/packages/@expo/agent-cli/llp/0018-interaction-commands.rfc.md b/packages/@expo/agent-cli/llp/0018-interaction-commands.rfc.md index 2707bc860..2f6ab8dd0 100644 --- a/packages/@expo/agent-cli/llp/0018-interaction-commands.rfc.md +++ b/packages/@expo/agent-cli/llp/0018-interaction-commands.rfc.md @@ -92,7 +92,14 @@ this section. 2. Focus is "no unfocused `Screen` ancestor", not "the one focused `Screen`". A nested navigator has two focused `Screen` fibers, one inside the other. A node is off the focused screen when any `Screen` ancestor of it is unfocused. `focusedScreen` reports - the deepest focused `Screen` whose ancestors are all focused. + the deepest focused `Screen` whose ancestors are all focused. A screen fiber has one of + two shapes in `expo-router` 58: expo-router native tabs render a `Screen` with + `isFocused` and `name`; React Navigation renders a `Screen` (JS tabs, drawer) or a + `SceneView` (native stack) with `focused` and `route`. So focus is read on the native + stack, JS tabs, drawer and native tabs (test: "the focus filter in an expo-router 58 + app"). JS stack, JS top tabs and headless `ui` tabs are not covered yet: they report + `focusedScreen: null` and the whole tree (test: "reports no focused screen and the whole + tree under the JS stack"). 3. `runtime:type` takes `--index` and `--force`. It matches the same way `tap` does, so it meets `ambiguous` the same way. `editable={false}` is what a `TextInput` uses for diff --git a/packages/@expo/agent-cli/src/runtime/interact/__tests__/expression-test.ts b/packages/@expo/agent-cli/src/runtime/interact/__tests__/expression-test.ts index a838eeb7a..30554284c 100644 --- a/packages/@expo/agent-cli/src/runtime/interact/__tests__/expression-test.ts +++ b/packages/@expo/agent-cli/src/runtime/interact/__tests__/expression-test.ts @@ -21,8 +21,9 @@ import { evaluateExpression, installHook, installHookWithoutRoots, + nativeTabsScreen, removeHook, - screen, + routerScreen, type FiberSpec, } from './fiberTree'; @@ -550,7 +551,7 @@ describe('the focus filter', () => { name: 'RNSTabsHostIOS', props: {}, children: recorded.map((node) => - screen(String(node.scalarProps.name), node.scalarProps.isFocused === true, [ + nativeTabsScreen(String(node.scalarProps.name), node.scalarProps.isFocused === true, [ { name: 'Pressable', props: { testID: `${node.scalarProps.name}-button`, onPress: () => {} }, @@ -623,16 +624,16 @@ describe('the focus filter', () => { name: 'Root', props: {}, children: [ - screen('tab-a', true, [ - screen('detail', true, [ + nativeTabsScreen('tab-a', true, [ + nativeTabsScreen('detail', true, [ { name: 'Pressable', props: { testID: 'deep', onPress: () => {} } }, ]), - screen('list', false, [ + nativeTabsScreen('list', false, [ { name: 'Pressable', props: { testID: 'behind', onPress: () => {} } }, ]), ]), - screen('tab-b', false, [ - screen('other', true, [ + nativeTabsScreen('tab-b', false, [ + nativeTabsScreen('other', true, [ { name: 'Pressable', props: { testID: 'other-tab', onPress: () => {} } }, ]), ]), @@ -675,8 +676,12 @@ describe('the focus filter', () => { name: 'Root', props: {}, children: [ - screen('one', false, [{ name: 'Pressable', props: { testID: 'a', onPress: () => {} } }]), - screen('two', false, [{ name: 'Pressable', props: { testID: 'b', onPress: () => {} } }]), + nativeTabsScreen('one', false, [ + { name: 'Pressable', props: { testID: 'a', onPress: () => {} } }, + ]), + nativeTabsScreen('two', false, [ + { name: 'Pressable', props: { testID: 'b', onPress: () => {} } }, + ]), ], }; @@ -691,6 +696,73 @@ describe('the focus filter', () => { }); }); +// @ref llp/0018-interaction-commands.rfc.md §Eight shipped decisions 2. React Navigation's screen fibers +// carry `focused` and `route`, and each route also renders a core `SceneView` that has no `focused`. +describe('the focus filter in an expo-router 58 app', () => { + const street = (testID: string) => + ({ name: 'Text', props: { testID, children: testID } }) satisfies FiberSpec; + + /** The core `SceneView` every navigator renders inside each route: `route`, no `focused`. */ + const coreSceneView = (routeName: string, children: FiberSpec[]) => + ({ + name: 'SceneView', + props: { route: { key: `${routeName}-key`, name: routeName }, navigation: {} }, + children, + }) satisfies FiberSpec; + + const route = ( + component: 'Screen' | 'SceneView', + routeName: string, + focused: boolean, + children: FiberSpec[] = [] + ) => routerScreen(component, routeName, focused, [coreSceneView(routeName, children)]); + + /** Root stack: (tabs) under three pushed street screens, the last one on top. */ + function app(): FiberSpec { + return { + name: 'Root', + props: {}, + children: [ + route('SceneView', '(tabs)', false, [ + route('Screen', 'index', true, [ + { name: 'Pressable', props: { testID: 'street-row', onPress: () => {} } }, + ]), + route('Screen', 'map', false), + ]), + route('SceneView', 'street/[name]', false, [street('street-length')]), + route('SceneView', 'street/[name]', false, [street('street-length')]), + route('SceneView', 'street/[name]', true, [street('street-length')]), + ], + }; + } + + const treeOf = (testID: string | null) => + buildTreeExpression({ full: false, allScreens: false, testID, maxNodes: 500 }); + + it(`finds the top stack screen as the focused one, and matches one element of three street screens`, () => { + const answer = against(app(), treeOf(null)); + + expect(answer.focusedScreen).toBe('street/[name]'); + expect(answer.screensSeen).toEqual(['(tabs)', 'index', 'map', 'street/[name]']); + expect(against(app(), treeOf('street-length')).matched).toBe(1); + }); + + it(`reports no focused screen and the whole tree under the JS stack, whose cards are not screen fibers`, () => { + const card = (focused: boolean) => + ({ + name: 'CardContainerInner', + props: { focused, scene: { route: { key: 'street-key', name: 'street/[name]' } } }, + children: [coreSceneView('street/[name]', [street('street-length')])], + }) satisfies FiberSpec; + const jsStack = { name: 'Root', props: {}, children: [card(false), card(false), card(true)] }; + + const answer = against(jsStack, treeOf(null)); + + expect(answer.focusedScreen).toBeNull(); + expect(against(jsStack, treeOf('street-length')).matched).toBe(3); + }); +}); + // @ref llp/0018-interaction-commands.rfc.md §Must not lose 6. The default is bounded by what it // keeps, not by a fiber depth: fiber depth on the spike's app was 152, and every visible element // sat between 128 and 152. @@ -1323,7 +1395,7 @@ describe('the type call', () => { // which would have failed for a different reason. it(`answers "no candidate takes text" before it answers "which one"`, () => { const shared = (screenName: string): FiberSpec => - screen(screenName, true, [ + nativeTabsScreen(screenName, true, [ { name: 'Pressable', props: { testID: 'shared-id', onPress: () => {} } }, ]); const answer = against( @@ -1344,7 +1416,7 @@ describe('the type call', () => { it(`still asks which one when the candidates do take text`, () => { const input = (screenName: string): FiberSpec => - screen(screenName, true, [ + nativeTabsScreen(screenName, true, [ { name: 'TextInput', props: { testID: 'shared-input', onChangeText: () => {} } }, ]); const answer = against( diff --git a/packages/@expo/agent-cli/src/runtime/interact/__tests__/fiberTree.ts b/packages/@expo/agent-cli/src/runtime/interact/__tests__/fiberTree.ts index 97870a45c..64b37d095 100644 --- a/packages/@expo/agent-cli/src/runtime/interact/__tests__/fiberTree.ts +++ b/packages/@expo/agent-cli/src/runtime/interact/__tests__/fiberTree.ts @@ -76,8 +76,12 @@ export function buildFiber(spec: FiberSpec, parent: FakeFiber | null = null): Fa return fiber; } -/** A React Navigation `Screen` fiber, which is where "which screen is on screen" is answerable. */ -export function screen(name: string, focused: boolean, children: FiberSpec[] = []): FiberSpec { +/** An expo-router native tabs `Screen` fiber: `isFocused` and `name`, one per tab. */ +export function nativeTabsScreen( + name: string, + focused: boolean, + children: FiberSpec[] = [] +): FiberSpec { return { name: 'Screen', props: { name, isFocused: focused, routeKey: `${name}-key` }, @@ -85,6 +89,23 @@ export function screen(name: string, focused: boolean, children: FiberSpec[] = [ }; } +/** + * A React Navigation screen fiber: `Screen` (JS tabs, drawer) or `SceneView` (native stack), with + * `focused` and `route`, not `isFocused`. + */ +export function routerScreen( + component: 'Screen' | 'SceneView', + routeName: string, + focused: boolean, + children: FiberSpec[] = [] +): FiberSpec { + return { + name: component, + props: { focused, route: { key: `${routeName}-key`, name: routeName } }, + children, + }; +} + /** * Install a fake DevTools hook over one or more root trees, and take it away again. * diff --git a/packages/@expo/agent-cli/src/runtime/interact/expression.ts b/packages/@expo/agent-cli/src/runtime/interact/expression.ts index cfc5fee05..cde2e836c 100644 --- a/packages/@expo/agent-cli/src/runtime/interact/expression.ts +++ b/packages/@expo/agent-cli/src/runtime/interact/expression.ts @@ -113,15 +113,23 @@ const PRELUDE = ` return found; } /** - * React Navigation's own \`Screen\`, which is where "which screen is the user looking at" is - * answerable. React itself does not know: with native tabs the switching happens natively and - * every \`Offscreen\` fiber still reports \`mode: "visible"\` (llp/0018-interaction-commands.rfc.md §Must not lose). + * The navigator's screen fiber, the only place that knows which screen the user is looking at. + * @ref llp/0018-interaction-commands.rfc.md §Eight shipped decisions 2 */ function screenOf(f) { - if (nameOf(f) !== 'Screen') return null; + var component = nameOf(f); + if (component !== 'Screen' && component !== 'SceneView') return null; var p = propsOf(f); - if (!p || typeof p.isFocused !== 'boolean' || typeof p.name !== 'string') return null; - return { name: p.name, focused: p.isFocused }; + if (!p) return null; + // expo-router native tabs: Screen with isFocused and name. + if (typeof p.isFocused === 'boolean' && typeof p.name === 'string') { + return { name: p.name, focused: p.isFocused }; + } + // React Navigation Screen (JS tabs, drawer) / SceneView (native stack): focused and route. + if (typeof p.focused === 'boolean' && p.route && typeof p.route.name === 'string') { + return { name: p.route.name, focused: p.focused }; + } + return null; } var roots = [];