Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@expo/agent-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import {
evaluateExpression,
installHook,
installHookWithoutRoots,
nativeTabsScreen,
removeHook,
screen,
routerScreen,
type FiberSpec,
} from './fiberTree';

Expand Down Expand Up @@ -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: () => {} },
Expand Down Expand Up @@ -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: () => {} } },
]),
]),
Expand Down Expand Up @@ -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: () => {} } },
]),
],
};

Expand All @@ -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<TreeAnswer>(app(), treeOf(null));

expect(answer.focusedScreen).toBe('street/[name]');
expect(answer.screensSeen).toEqual(['(tabs)', 'index', 'map', 'street/[name]']);
expect(against<TreeAnswer>(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<TreeAnswer>(jsStack, treeOf(null));

expect(answer.focusedScreen).toBeNull();
expect(against<TreeAnswer>(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.
Expand Down Expand Up @@ -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<CallAnswer>(
Expand All @@ -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<CallAnswer>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,36 @@ 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` },
children,
};
}

/**
* 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.
*
Expand Down
20 changes: 14 additions & 6 deletions packages/@expo/agent-cli/src/runtime/interact/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down