frontend: add a Maestro smoke suite for the core journey
Covers login, registering a mood including the components sheet, and logout. Each flow clears state and logs in through a shared helper, so the suite does not depend on the order Maestro decides to run the flows in, which still takes a fucking long time, but as a PoC is more than enough
Pedro Lucas Porcellis porcellis@eletrotupi.com · 1 month ago
cd4e510347589b652e51c97c5db46b27392f76da Browse filesParents
be9c517Changed files
22
Additions
+474
Deletions
-13
/dev/null → frontend/.env.example
1 +
# Bundle-time env for Expo (inlined by Metro; keep values device-reachable).
2 +
# Copy to .env.local (gitignored) or export before `npx expo start`.
3 +
#
4 +
# Never put a secret here: EXPO_PUBLIC_* ends up inside the shipped JS bundle.
5 +
#
6 +
# LAN IP of the machine running the API (see frontend/scripts/e2e.sh print-env).
7 +
EXPO_PUBLIC_API_BASE_URL=http://192.168.1.10:3000
8 +
# SecureStore key under which the JWT is persisted.
9 +
EXPO_PUBLIC_TOKEN_KEY=orbit-jwt/dev/null → frontend/.maestro/config.yaml
1 +
name: nexo e2e
2 +
3 +
# NOTE: `appId` is deliberately NOT set here. Maestro 2.x requires appId in the
4 +
# config section of *every* flow file, subflows included; an appId in this
5 +
# workspace config is ignored. Each flow under ../e2e declares it itself
6 +
7 +
env:
8 +
PORT: "8081"
9 +
E2E_EMAIL: e2e@example.com
10 +
E2E_PASSWORD: e2e-password
11 +
# HOST_IP is intentionally NOT set here: it must be the machine's LAN IP
12 +
# `frontend/scripts/e2e.sh` detects it and passes `--env HOST_IP=...`;
13 +
# when running `maestro test` directly, pass it yourself:
14 +
# maestro test e2e --include-tags=smoke --env HOST_IP=192.168.x.y
frontend/app/(tabs)/history.tsx
1 1
import { useState } from "react";
2 2
import { ScrollView, View } from "react-native";
3 3
import { ScreenLayout } from "@/components/ui/ScreenLayout";
4 4
import { Section, SectionHeader } from "@/components/ui/Sections";
5 5
import { FilterPills } from "@/components/ui/FilterPills";
47 47
<ScreenLayout
48 48
userName={user.firstName}
49 49
userAvatar={user.avatarURL}
50 50
showNotificationBadge={true}
51 51
>
52 -
<ScrollView>
52 +
<ScrollView testID="history-list">
53 53
<FilterPills active={activeFilter} onChange={setActiveFilter} />
54 54
55 55
{Object.keys(grouped).length === 0 ? (
56 56
<EmptyState
57 57
title="Nenhum registro encontrado"
72 72
)}
73 73
</ScrollView>
74 74
</ScreenLayout>
75 75
);
76 76
}
frontend/app/(tabs)/new/entry.tsx
1 1
import { useState, useEffect } from "react";
2 2
import { Link, router, useLocalSearchParams } from "expo-router";
3 3
4 4
import { View, StyleSheet, ScrollView } from "react-native";
5 5
87 87
return (
88 88
<View>
89 89
<ScrollView scrollEventThrottle={16}>
90 90
<View style={styles.container}>
91 91
<Center>
92 -
<Text style={styles.statusPrefix}>
92 +
<Text style={styles.statusPrefix} testID="text-status-prefix">
93 93
Como você está se sentindo agora?
94 94
</Text>
95 95
96 96
<Text style={styles.statusText}>
97 97
{selectedMood?.label || "Neutro"}
148 148
onChangeText={(val) => setAnnotation(val)}
149 149
value={annotation}
150 150
minRows={4}
151 151
maxRows={6}
152 152
placeholder="Escreva uma nota rápida sobre o seu dia até o momento..."
153 +
testID="input-notes"
153 154
/>
154 155
</Card>
155 156
156 157
<Section>
157 158
<SectionHeader title="Também estou..." />
170 171
<Button
171 172
title="Adicionar estados & emoções"
172 173
onPress={editComponents}
173 174
textStyle={{ fontWeight: 500 }}
174 175
variant="dashed"
176 +
testID="button-add-mood-components"
175 177
/>
176 178
</Section>
177 179
178 180
<Button
179 181
title="Salvar Registro"
180 182
onPress={saveMoodEntry}
181 183
disabled={!components.length}
184 +
testID="button-save-entry"
182 185
/>
183 186
</View>
184 187
</ScrollView>
185 188
</View>
186 189
);
210 213
},
211 214
resumeCard: {
212 215
padding: Spacing.cardGap,
213 216
},
214 217
});
frontend/app/(tabs)/new/index.tsx
1 1
import { useEffect, useState } from "react";
2 2
import { StyleSheet, View, ActivityIndicator } from "react-native";
3 3
4 4
import { Text } from "@/components/ui/Text";
5 5
import { ThemedView } from "@/components/misc/themed-view";
33 33
const { user } = useAuth();
34 34
const [mood, setMood] = useState<string>("good");
35 35
const { data, isLoading } = useMoodEntries({ limit: 5 });
36 36
const recentEntries = data?.entries ?? [];
37 37
38 -
console.log("entries: ", data, recentEntries);
39 -
40 38
const initQuickRegister = () => {
41 39
// TODO: Set down on camelCase vs kebab case
42 40
router.navigate(`/new/entry?initialMood=${mood}`);
43 41
};
44 42
50 48
>
51 49
<Section>
52 50
<SectionHeader
53 51
title="Como você está hoje?"
54 52
subtitle="Sua jornada começa agora"
53 +
testID="text-welcome-quick-register"
55 54
></SectionHeader>
56 55
57 -
<Card style={{ minHeight: 200 }}>
56 +
<Card style={{ minHeight: 200 }} testID="card-quick-register">
58 57
<Col gap={16}>
59 58
<Between style={styles.quickRegisterHeader}>
60 59
<Text style={styles.quickRegisterTitle}>
61 60
Registro rápido
62 61
</Text>
73 72
74 73
<Button
75 74
title="Salvar Humor"
76 75
onPress={initQuickRegister}
77 76
style={styles.quickRegisterButton}
77 +
testID="button-save-mood"
78 78
/>
79 79
</Col>
80 80
</Card>
81 81
</Section>
82 82
161 161
fontSize: 11,
162 162
fontWeight: 600,
163 163
lineHeight: 16.5,
164 164
},
165 165
});
frontend/app/(tabs)/new/post-mood.tsx
1 1
import { useState } from "react";
2 2
import {
3 3
View,
4 4
StyleSheet,
5 5
TouchableOpacity,
56 56
<View style={styles.outerRing}>
57 57
<View style={styles.innerCircle}>
58 58
<Ionicons name="checkmark" size={32} color="#fff" />
59 59
</View>
60 60
</View>
61 -
<Text style={styles.confirmTitle}>Humor Salvo!</Text>
61 +
<Text style={styles.confirmTitle} testID="text-mood-saved">Humor Salvo!</Text>
62 62
<Text style={styles.confirmSubtitle}>
63 63
Seu registro foi armazenado com sucesso.
64 64
</Text>
65 65
</View>
66 66
198 198
<Button
199 199
title={linked ? 'Voltar para a tela inicial' : 'Pular essa etapa'}
200 200
variant="ghost"
201 201
onPress={() => router.replace("/(tabs)/new")}
202 202
style={styles.concludeButton}
203 +
testID="button-conclude"
203 204
/>
204 205
</ScreenLayout>
205 206
);
206 207
}
207 208
369 370
},
370 371
concludeButton: {
371 372
marginTop: 8,
372 373
},
373 374
});
frontend/app/(tabs)/settings.tsx
1 1
import {
2 2
TouchableOpacity,
3 3
StyleSheet,
4 4
SectionList,
5 5
View,
316 316
<Button
317 317
title="Sair da Conta"
318 318
onPress={handleLogout}
319 319
variant="outline"
320 320
style={{ borderColor: Colors.light.danger }}
321 +
testID="button-logout"
321 322
textStyle={{ color: Colors.light.danger }}
322 323
/>
323 324
</ScreenLayout>
324 325
);
325 326
}
359 360
height: StyleSheet.hairlineWidth,
360 361
backgroundColor: "#D1D5DB",
361 362
marginVertical: 16,
362 363
},
363 364
});
frontend/app/auth/login.tsx
1 1
import React, { useState, useEffect, useRef } from "react";
2 2
import {
3 3
View,
4 4
StyleSheet,
5 5
ScrollView,
127 127
/>
128 128
</Animated.View>
129 129
</View>
130 130
131 131
<View style={styles.header}>
132 -
<Text style={[styles.title, { color: textColor }]}>
132 +
<Text style={[styles.title, { color: textColor }]} testID="text-welcome">
133 133
Bem vindo de volta
134 134
</Text>
135 135
<Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
136 136
Logue na sua conta nexo
137 137
</Text>
154 154
value={password}
155 155
onChangeText={setPassword}
156 156
placeholder="Qual sua senha?"
157 157
error={errors.password}
158 158
showPasswordToggle
159 +
testID="input-password"
159 160
/>
160 161
161 162
<Button
162 163
title="Entrar"
163 164
onPress={handleLogin}
164 165
loading={loading}
165 166
style={styles.loginButton}
167 +
testID="button-login"
166 168
/>
167 169
168 170
<Link
169 171
href="/auth/forgot-password"
170 172
style={StyleSheet.flatten([styles.link, { color: tintColor }])}
259 261
},
260 262
footerText: {
261 263
fontSize: 14,
262 264
},
263 265
});
frontend/app/entry/mood-components.tsx
1 1
import React from "react";
2 2
import { View, ScrollView, Pressable, StyleSheet } from "react-native";
3 3
import { Text } from "@/components/ui/Text";
4 4
import { router } from "expo-router";
5 5
import { Ionicons } from "@expo/vector-icons";
33 33
const handleDone = () => {
34 34
router.back();
35 35
};
36 36
37 37
return (
38 -
<View style={styles.container}>
38 +
<View style={styles.container} testID="screen-mood-components">
39 39
{/* Header */}
40 40
<View style={styles.header}>
41 41
<Pressable onPress={() => router.back()} hitSlop={8}>
42 42
<Ionicons name="close" size={24} color={Theme.colors.text} />
43 43
</Pressable>
44 44
<Text style={styles.headerTitle}>Adicionar estados</Text>
45 -
<Pressable onPress={handleDone} hitSlop={8}>
45 +
<Pressable onPress={handleDone} hitSlop={8} testID="header-done">
46 46
<Text style={styles.headerAction}>Pronto</Text>
47 47
</Pressable>
48 48
</View>
49 49
50 50
<ScrollView
120 120
{available.map((def) => (
121 121
<Card key={def.id} style={styles.availableItem}>
122 122
<Pressable
123 123
onPress={() => addComponent(def.id)}
124 124
style={styles.availableItemInternalWrapper}
125 +
testID={`component-${def.id}`}
125 126
>
126 127
<Row gap={8} style={styles.availableInner}>
127 128
<View
128 129
style={[styles.dot, { backgroundColor: def.color }]}
129 130
/>
241 242
fontSize: Theme.typography.bodyMd.fontSize,
242 243
fontWeight: "500",
243 244
color: Theme.colors.light.text,
244 245
},
245 246
});
frontend/components/ui/Button.tsx
1 1
import React from 'react';
2 2
import {
3 3
TouchableOpacity,
4 4
StyleSheet,
5 5
ActivityIndicator,
8 8
TextStyle,
9 9
} from 'react-native';
10 10
import { Text } from '@/components/ui/Text';
11 11
import { useThemeColor } from '@/hooks/use-theme-color';
12 12
13 +
function toTestId(title: string): string {
14 +
return title
15 +
.normalize('NFD')
16 +
.replace(/[\u0300-\u036f]/g, '')
17 +
.toLowerCase()
18 +
.replace(/[^a-z0-9]+/g, '-')
19 +
.replace(/^-+|-+$/g, '');
20 +
}
21 +
13 22
interface ButtonProps extends TouchableOpacityProps {
14 23
title: string;
15 24
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'dashed' | 'danger';
16 25
size?: 'small' | 'medium' | 'large';
17 26
loading?: boolean;
139 148
}
140 149
};
141 150
142 151
return (
143 152
<TouchableOpacity
153 +
testID={props.testID ?? `button-${toTestId(title)}`}
144 154
style={[
145 155
styles.button,
146 156
{
147 157
backgroundColor: getBackgroundColor(),
148 158
borderWidth: getBorderWidth(),
188 198
},
189 199
text: {
190 200
fontWeight: '600',
191 201
},
192 202
});
frontend/components/ui/Cards.tsx
1 1
import React from 'react';
2 2
import {
3 3
View,
4 4
StyleSheet,
5 5
Pressable,
14 14
interface CardProps {
15 15
children: React.ReactNode;
16 16
onPress?: () => void;
17 17
variant?: 'default' | 'highlighted';
18 18
style?: ViewStyle;
19 +
testID?: string;
19 20
}
20 21
21 22
export const Card: React.FC<CardProps> = ({
22 23
children,
23 24
onPress,
24 25
variant = 'default',
25 26
style,
27 +
testID,
26 28
}) => {
27 29
const isHighlighted = variant === 'highlighted';
28 30
29 31
const surfaceColor = useThemeColor({}, 'surface');
30 32
const cardBorderColor = useThemeColor({}, 'cardBorder');
48 50
49 51
return (
50 52
<Pressable
51 53
onPress={onPress}
52 54
disabled={!onPress}
55 +
testID={testID}
53 56
style={({ pressed }) => [
54 57
styles.card,
55 58
pressed && onPress && {
56 59
opacity: 0.8,
57 60
},
100 103
{text.replace(/\s+/g, ' ').trim()}
101 104
</Text>
102 105
</View>
103 106
)
104 107
}
frontend/components/ui/EmojiSelectors.tsx
1 1
import React from "react";
2 2
import {
3 3
View,
4 4
Pressable,
5 5
StyleSheet,
94 94
return (
95 95
<View key={item.id} style={styles.itemWrapper}>
96 96
<Pressable
97 97
onPress={() => !disabled && onSelect(item.id)}
98 98
disabled={disabled}
99 +
testID={`mood-${item.id}`}
100 +
accessibilityLabel={item.label}
99 101
style={({ pressed }) => [
100 102
styles.itemButton,
101 103
isSelected && styles.itemButtonActive,
102 104
pressed && !disabled && { opacity: 0.7 },
103 105
disabled && { opacity: 0.5 },
121 123
);
122 124
})}
123 125
</View>
124 126
);
125 127
};
frontend/components/ui/Input.tsx
1 1
import React, { useState } from 'react';
2 2
import {
3 3
TextInput,
4 4
TextInputProps,
5 5
StyleSheet,
9 9
} from 'react-native';
10 10
import { Text } from '@/components/ui/Text';
11 11
import { useThemeColor } from '@/hooks/use-theme-color';
12 12
import { Ionicons } from '@expo/vector-icons';
13 13
14 +
function toTestId(label: string): string {
15 +
return label
16 +
.normalize('NFD')
17 +
.replace(/[\u0300-\u036f]/g, '')
18 +
.toLowerCase()
19 +
.replace(/[^a-z0-9]+/g, '-')
20 +
.replace(/^-+|-+$/g, '');
21 +
}
22 +
14 23
type InputVariant = 'default' | 'ghost' | 'darkGhost';
15 24
16 25
interface BaseInputProps {
17 26
label?: string;
18 27
error?: string;
73 82
inputStyle
74 83
} = useInputStyles(variant, error, showPasswordToggle);
75 84
76 85
const keyboardType = type === 'email' ? 'email-address' : 'default';
77 86
const secureTextEntry = type === 'password' && !showPassword;
87 +
const testID = props.testID ?? (label ? `input-${toTestId(label)}` : undefined);
78 88
79 89
return (
80 90
<View style={styles.container}>
81 91
{label && (
82 92
<Label text={label} />
83 93
)}
84 94
<View style={styles.inputContainer}>
85 95
<TextInput
86 96
key={`${type}-${showPassword}`}
97 +
testID={testID}
87 98
style={[styles.input, inputStyle, style]}
88 99
placeholderTextColor={placeholderColor}
89 100
keyboardType={keyboardType}
90 101
secureTextEntry={secureTextEntry}
91 102
autoCapitalize={type === 'email' ? 'none' : props.autoCapitalize}
134 145
inputStyle
135 146
} = useInputStyles(variant, error);
136 147
137 148
const minHeight = minRows * 24;
138 149
const maxHeight = maxRows ? maxRows * 24 : undefined;
150 +
const testID = props.testID ?? (label ? `input-${toTestId(label)}` : undefined);
139 151
140 152
return (
141 153
<View style={styles.container}>
142 154
{label && (
143 155
<Label text={label} />
144 156
)}
145 157
<TextInput
158 +
testID={testID}
146 159
style={[
147 160
styles.textArea,
148 161
inputStyle,
149 162
{ minHeight, maxHeight },
150 163
style,
214 227
errorText: {
215 228
fontSize: 12,
216 229
marginTop: 4,
217 230
},
218 231
});
frontend/components/ui/Sections.tsx
1 1
import React from 'react';
2 2
import {
3 3
View,
4 4
StyleSheet,
5 5
Pressable,
26 26
};
27 27
28 28
interface SectionHeaderProps {
29 29
title: string;
30 30
subtitle?: string;
31 +
info?: string;
31 32
actionLabel?: string;
32 33
onActionPress?: () => void;
33 34
style?: ViewStyle;
34 35
variant?: 'default' | 'subtle';
36 +
testID?: string;
35 37
}
36 38
37 39
// XXX: A Section must either have a label as a pill/informative thing
38 40
// or it should be a link
39 41
export const SectionHeader: React.FC<SectionHeaderProps> = ({
42 44
info,
43 45
actionLabel,
44 46
onActionPress,
45 47
variant,
46 48
style,
49 +
testID,
47 50
}) => {
48 51
const textColor = useThemeColor({}, 'text');
49 52
const tintColor = useThemeColor({}, 'tint');
50 53
const textSecondaryColor = useThemeColor({}, 'textSecondary')
51 54
102 105
103 106
// TODO: Rework these when we finally have chips/badges
104 107
return (
105 108
<View style={[styles.headerContainer, style]}>
106 109
<Between>
107 -
<Text style={styles.title}>
110 +
<Text style={styles.title} testID={testID}>
108 111
{title}
109 112
</Text>
110 113
111 114
{info && !actionLabel && (
112 115
<Text>
136 139
)}
137 140
</View>
138 141
);
139 142
};
140 143
/dev/null → frontend/e2e/README.md
1 +
# Mobile E2E (Maestro on a physical Android device)
2 +
3 +
Smoke coverage for the core journey: **login → register a mood → logout**. Runs
4 +
on a real Android device over `adb` (no emulator), against a dev build of the
5 +
app talking to the normal local API
6 +
7 +
## What you need
8 +
9 +
1. **API up, with the seeded user.**: the app points at whichever API
10 +
`EXPO_PUBLIC_API_BASE_URL` names
11 +
12 +
```sh
13 +
docker compose up -d db valkey api
14 +
cd api && npm run seed # idempotent; creates e2e@example.com / e2e-password
15 +
```
16 +
17 +
2. **Metro serving the dev build**, with the device-reachable API URL baked in
18 +
(`EXPO_PUBLIC_*` is inlined at bundle time, so Metro must be started *after*
19 +
`frontend/.env.local` is set: see `npm run test:e2e:env`)
20 +
21 +
3. **The dev build installed** (`com.eletrotupi.orbit`) and the device visible in
22 +
`adb devices`
23 +
24 +
4. **Maestro** on `PATH` (`https://get.maestro.mobile.dev`)
25 +
26 +
## Running
27 +
28 +
```sh
29 +
cd frontend
30 +
npm run test:e2e # adb prep + the tagged smoke flows
31 +
npm run test:e2e:env # print the EXPO_PUBLIC_* values Metro needs
32 +
```
33 +
34 +
A single flow, while iterating (a few minutes faster than the whole suite):
35 +
36 +
```sh
37 +
cd frontend
38 +
maestro test e2e/mood.yaml \
39 +
--env HOST_IP=$(ip route get 1 | awk '/src/{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1);exit}}') \
40 +
--env PORT=8081 --env E2E_EMAIL=e2e@example.com --env E2E_PASSWORD=e2e-password
41 +
```
42 +
43 +
Three flows take roughly 8 minutes: every flow clears app state, so each one
44 +
pays for a cold bundle load and the dev-client startup dance
45 +
46 +
## How the flows are structured
47 +
48 +
Every flow is **self-contained and order-independent**: Maestro runs them in an
49 +
arbitrary order, so none may assume another ran first. Each starts with:
50 +
51 +
```yaml
52 +
- runFlow:
53 +
file: helpers/_login.yaml
54 +
```
55 +
56 +
and `helpers/_login.yaml` does the whole reset-and-authenticate: `clearState`
57 +
=> deep-link the dev build => log in => dismiss the notification prompt. That
58 +
means `clearState` is what guarantees a logged-out start, which has three
59 +
consequences worth knowing:
60 +
61 +
- it resets **runtime permissions**, so the notification dialog shows up again
62 +
and must be allowed (see copy matches below);
63 +
- it resets **expo-dev-client**, so its first-run overlays appear ("Continue",
64 +
then the dev-menu panel's "Close");
65 +
- it discards the downloaded JS bundle, hence the cold start
66 +
67 +
The app is intentionally **not** launched with `exp://`: Expo Go owns that
68 +
scheme, and driving Expo Go while asserting on `com.eletrotupi.orbit` will fail
69 +
with "element not found" everywhere. The dev build owns
70 +
`orbit://expo-development-client/?url=...`, kinda hacky but I don't care
71 +
72 +
## testID convention
73 +
74 +
`id`s are English even though the UI is pt-BR. Flows only touch screens that
75 +
pass an explicit `testID`
76 +
77 +
Auto-derived fallbacks exist when a screen passes nothing: `Button` →
78 +
`button-<slug(title)>`, `Input`/`TextArea` → `input-<slug(label)>`,
79 +
`MoodSelector` → `mood-<id>`. The slug util lives in `Button.tsx` / `Input.tsx`
80 +
(NFD strip, lowercase, kebab-style)
81 +
82 +
Every flow file: **including subflows** — must declare `appId:
83 +
com.eletrotupi.orbit` in its config section. Maestro 2.x requires it per file;
84 +
the workspace `.maestro/config.yaml` cannot supply it
85 +
86 +
## The unavoidable copy matches
87 +
88 +
A handful of strings cannot be reached by `testID` because they belong to the
89 +
OS or to dev tooling. These are deliberate, and the only ones a flow may assert
90 +
on:
91 +
92 +
| Where | Selector | Why |
93 +
| --- | --- | --- |
94 +
| Logout confirm `Alert` | `^Sair$` with `index: 1` | RN native dialog, no ids, will be fixed eventually |
95 +
| History card annotation | `"e2e smoke register"` | the one *data* assertion; proves write-through |
96 +
| Notification permission | `com.android.permissioncontroller:id/permission_allow_button` | AOSP system dialog |
97 +
| expo-dev-client onboarding | `^Continue$` | dev tooling, not app UI |
98 +
| expo-dev-client dev menu | `^Close$` (content-desc) | dev tooling, not app UI |
99 +
100 +
Everything else is greeted via `testID`
101 +
102 +
## Known gotchas
103 +
104 +
- **The formSheet opens at its half detent.** `entry/mood-components` uses
105 +
`sheetInitialDetentIndex: 1` of `[0.25, 0.5, 1]`, so only the first rows of
106 +
the component list are mounted
107 +
- The soft keyboard covers `button-login`; the flow calls `hideKeyboard` first
/dev/null → frontend/e2e/debug-mood-components.yaml
1 +
# Debug helper (NOT part of the smoke suite): isolates the "add a mood
2 +
# component" interaction to reproduce the auto-navigation issue observed on
3 +
# 2026-09-15 (see docs/STACK.md). Run directly:
4 +
# maestro test frontend/e2e/debug-mood-components.yaml
5 +
appId: com.eletrotupi.orbit
6 +
name: Debug — mood components
7 +
---
8 +
- runFlow:
9 +
file: helpers/_login.yaml
10 +
- tapOn:
11 +
id: mood-great
12 +
- tapOn:
13 +
id: button-save-mood
14 +
- extendedWaitUntil:
15 +
visible:
16 +
id: text-status-prefix
17 +
timeout: 30000
18 +
- scrollUntilVisible:
19 +
element:
20 +
id: button-add-mood-components
21 +
direction: DOWN
22 +
- tapOn:
23 +
id: button-add-mood-components
24 +
- extendedWaitUntil:
25 +
visible:
26 +
id: screen-mood-components
27 +
timeout: 15000
28 +
- tapOn:
29 +
id: component-joy
30 +
# If the app pops back to the entry screen here, `screen-mood-components`
31 +
# disappears this wait fails fast and pinpoints the regression
32 +
- extendedWaitUntil:
33 +
visible:
34 +
id: screen-mood-components
35 +
timeout: 10000
36 +
- assertVisible:
37 +
id: header-done
/dev/null → frontend/e2e/helpers/_login.yaml
1 +
appId: com.eletrotupi.orbit
2 +
---
3 +
# Self-contained login: every flow starts from a clean, logged-out app so the
4 +
# suite is order-independent (Maestro runs the flows in an arbitrary order)
5 +
- clearState
6 +
- openLink: orbit://expo-development-client/?url=http%3A%2F%2F${HOST_IP}%3A${PORT}
7 +
# clearState resets expo-dev-client itself, so its first-run overlays can show
8 +
# up before the JS app loads: the "developer menu" intro ("Continue") and then
9 +
# the dev-menu panel ("Close"). Both are dev tooling, not app UI, two more
10 +
# unavoidable copy matches; both optional so they cost nothing when absent
11 +
- waitForAnimationToEnd:
12 +
timeout: 5000
13 +
- tapOn:
14 +
text: "^Continue$"
15 +
optional: true
16 +
- tapOn:
17 +
text: "^Close$"
18 +
optional: true
19 +
- extendedWaitUntil:
20 +
visible:
21 +
id: text-welcome
22 +
timeout: 90000
23 +
- tapOn:
24 +
id: input-email
25 +
- inputText: ${E2E_EMAIL}
26 +
- tapOn:
27 +
id: input-password
28 +
- inputText: ${E2E_PASSWORD}
29 +
# The soft keyboard covers the login button; without this the tap misses
30 +
- hideKeyboard
31 +
- tapOn:
32 +
id: button-login
33 +
# clearState resets runtime permissions, so the app asks for notifications
34 +
# after login. Allow it, otherwise the system dialog blocks the next step.
35 +
# System dialog, so no testID; the AOSP resource-id is the stable selector
36 +
- tapOn:
37 +
id: com.android.permissioncontroller:id/permission_allow_button
38 +
optional: true
39 +
- extendedWaitUntil:
40 +
visible:
41 +
id: text-welcome-quick-register
42 +
timeout: 15000
/dev/null → frontend/e2e/login.yaml
1 +
appId: com.eletrotupi.orbit
2 +
name: Smoke — Login
3 +
tags:
4 +
- smoke
5 +
---
6 +
- runFlow:
7 +
file: helpers/_login.yaml
8 +
- assertVisible:
9 +
id: card-quick-register
/dev/null → frontend/e2e/logout.yaml
1 +
appId: com.eletrotupi.orbit
2 +
name: Smoke — Logout
3 +
tags:
4 +
- smoke
5 +
---
6 +
- runFlow:
7 +
file: helpers/_login.yaml
8 +
- tapOn:
9 +
id: tab-settings
10 +
- scrollUntilVisible:
11 +
element:
12 +
id: button-logout
13 +
direction: DOWN
14 +
- tapOn:
15 +
id: button-logout
16 +
# RN Alert renders a native system dialog, the only screen that can't expose
17 +
# testIDs. These two text matches are a deliberate, unavoidable exception
18 +
# TODO: Fix that to use some other, more custom (and pretty) confirm dialog
19 +
- assertVisible: "Tem certeza que quer sair?"
20 +
- tapOn:
21 +
text: "^Sair$"
22 +
index: 1
23 +
- extendedWaitUntil:
24 +
visible:
25 +
id: text-welcome
26 +
timeout: 15000
/dev/null → frontend/e2e/mood.yaml
1 +
appId: com.eletrotupi.orbit
2 +
name: Smoke — Mood entry
3 +
tags:
4 +
- smoke
5 +
---
6 +
- runFlow:
7 +
file: helpers/_login.yaml
8 +
- tapOn:
9 +
id: mood-great
10 +
- tapOn:
11 +
id: button-save-mood
12 +
- extendedWaitUntil:
13 +
visible:
14 +
id: text-status-prefix
15 +
timeout: 30000
16 +
# NOTE: if `screen-mood-components` times out below, the app is likely
17 +
# auto-navigating back to the entry screen right after this push — see
18 +
# docs/STACK.md "Known issues" (investigated with hierarchy dumps).
19 +
- scrollUntilVisible:
20 +
element:
21 +
id: button-add-mood-components
22 +
direction: DOWN
23 +
- tapOn:
24 +
id: button-add-mood-components
25 +
- extendedWaitUntil:
26 +
visible:
27 +
id: screen-mood-components
28 +
timeout: 15000
29 +
# The sheet opens at its half detent and the component list scrolls, so only the
30 +
# first rows are mounted
31 +
- tapOn:
32 +
id: component-joy
33 +
- tapOn:
34 +
id: header-done
35 +
- tapOn:
36 +
id: input-notes
37 +
- inputText: "e2e smoke register"
38 +
- hideKeyboard
39 +
- tapOn:
40 +
id: button-save-entry
41 +
- extendedWaitUntil:
42 +
visible:
43 +
id: text-mood-saved
44 +
timeout: 30000
45 +
- tapOn:
46 +
id: button-conclude
47 +
- extendedWaitUntil:
48 +
visible:
49 +
id: text-welcome-quick-register
50 +
timeout: 15000
51 +
- tapOn:
52 +
id: tab-history
53 +
- extendedWaitUntil:
54 +
visible:
55 +
id: history-list
56 +
timeout: 15000
57 +
# Only content (data) assertion in the suite: checks the annotation we typed
58 +
# persisted on the history card. UI chrome is greeted via testIDs only
59 +
- assertVisible: "e2e smoke register"
frontend/package.json
1 1
{
2 2
"name": "orbit",
3 3
"main": "expo-router/entry",
4 4
"version": "0.0.0",
5 5
"scripts": {
7 7
"reset-project": "node ./scripts/reset-project.js",
8 8
"android": "expo run:android",
9 9
"lint": "expo lint",
10 10
"ios": "expo run:ios",
11 11
"test:e2e": "bash scripts/e2e.sh test",
12 -
"test:e2e:up": "bash scripts/e2e.sh up",
13 -
"test:e2e:down": "bash scripts/e2e.sh down",
14 -
"test:e2e:all": "bash scripts/e2e.sh all"
12 +
"test:e2e:env": "bash scripts/e2e.sh print-env"
15 13
},
16 14
"dependencies": {
17 15
"@expo-google-fonts/inter": "^0.4.2",
18 16
"@expo/vector-icons": "^15.0.3",
19 17
"@react-native-async-storage/async-storage": "2.2.0",
66 64
"eslint-config-expo": "~57.0.2",
67 65
"typescript": "~6.0.3"
68 66
},
69 67
"private": true
70 68
}
/dev/null → frontend/scripts/e2e.sh
1 +
#!/usr/bin/env bash
2 +
#
3 +
# E2E harness for the nexo frontend: Maestro flows on a physical Android
4 +
# device over adb (no emulator)
5 +
#
6 +
# The app talks to whatever API `EXPO_PUBLIC_API_BASE_URL` points at, so there is
7 +
# no second API service to boot here: bring the normal stack up and point Metro
8 +
# at it:
9 +
#
10 +
# docker compose up -d db valkey api
11 +
# cd api && npm run seed # known e2e user (idempotent)
12 +
# cd frontend && npx expo start # with .env.local set (see print-env)
13 +
#
14 +
# Usage: e2e.sh [test|print-env]
15 +
# test prep the device (adb) and run the tagged Maestro flows
16 +
# print-env print the EXPO_PUBLIC_* vars to set before `expo start`
17 +
#
18 +
# Optional env: HOST_IP (LAN IP of this machine) is auto-detected when unset
19 +
set -euo pipefail
20 +
21 +
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
22 +
FRONTEND="$ROOT/frontend"
23 +
24 +
# The app under test: the dev build (`eas build --profile development`)
25 +
APP_ID="${APP_ID:-com.eletrotupi.orbit}"
26 +
EXPO_PORT="${EXPO_PORT:-8081}"
27 +
E2E_EMAIL="${E2E_EMAIL:-e2e@example.com}"
28 +
E2E_PASSWORD="${E2E_PASSWORD:-e2e-password}"
29 +
30 +
die() { echo "e2e: $*" >&2; exit 1; }
31 +
32 +
detect_host_ip() {
33 +
if [[ -n "${HOST_IP:-}" ]]; then
34 +
echo "$HOST_IP"
35 +
return
36 +
fi
37 +
38 +
local ip=""
39 +
ip="$(ip route get 1 2>/dev/null | awk '/src/{for (i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')"
40 +
41 +
[[ -z "$ip" ]] && ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
42 +
[[ -z "$ip" ]] && die "could not detect LAN IP; export HOST_IP=<lan-ip>"
43 +
44 +
echo "$ip"
45 +
}
46 +
47 +
adb_prepare() {
48 +
command -v adb >/dev/null || die "adb not found; install android-tools"
49 +
echo "e2e: waiting for an Android device (up to 15s)..."
50 +
51 +
local i state=""
52 +
53 +
for i in $(seq 1 15); do
54 +
state="$(adb get-state 2>/dev/null)"
55 +
[[ "$state" == "device" ]] && break
56 +
sleep 1
57 +
done
58 +
59 +
if [[ "$state" != "device" ]]; then
60 +
adb devices >&2 || true
61 +
die "no Android device attached. Connect one over USB/wireless debugging (or set ANDROID_SERIAL when several are connected) and re-run"
62 +
fi
63 +
64 +
echo "e2e: adb devices:"
65 +
66 +
adb devices
67 +
68 +
# No `pm clear` here: each flow starts with Maestro `clearState`, which needs no
69 +
# host-side help, and clearing here only re-triggered the expo-dev-client
70 +
# first-run overlays. Granting the runtime permission is still worth it:
71 +
# clearState resets it and the app asks for notifications right after login
72 +
adb shell pm grant "$APP_ID" android.permission.POST_NOTIFICATIONS >/dev/null 2>&1 || true
73 +
# Disable animations for speed and stability
74 +
75 +
adb shell settings put global window_animation_scale 0 >/dev/null
76 +
adb shell settings put global transition_animation_scale 0 >/dev/null
77 +
adb shell settings put global animator_duration_scale 0 >/dev/null
78 +
}
79 +
80 +
run_maestro() {
81 +
command -v maestro >/dev/null || die "maestro not found; install via https://get.maestro.mobile.dev"
82 +
local host_ip
83 +
host_ip="$(detect_host_ip)"
84 +
85 +
(
86 +
cd "$FRONTEND"
87 +
maestro test e2e \
88 +
--include-tags=smoke \
89 +
--env HOST_IP="$host_ip" \
90 +
--env PORT="$EXPO_PORT" \
91 +
--env E2E_EMAIL="$E2E_EMAIL" \
92 +
--env E2E_PASSWORD="$E2E_PASSWORD"
93 +
)
94 +
}
95 +
96 +
print_env() {
97 +
local host_ip
98 +
host_ip="$(detect_host_ip)"
99 +
100 +
cat <<EOF
101 +
Put this in frontend/.env.local (gitignored) before starting Metro:
102 +
103 +
EXPO_PUBLIC_API_BASE_URL=http://$host_ip:3000
104 +
EXPO_PUBLIC_TOKEN_KEY=orbit-jwt
105 +
106 +
npx expo start --port $EXPO_PORT
107 +
EOF
108 +
}
109 +
110 +
case "${1:-}" in
111 +
test)
112 +
adb_prepare
113 +
run_maestro
114 +
;;
115 +
print-env)
116 +
print_env
117 +
;;
118 +
*)
119 +
echo "usage: $0 [test|print-env]" >&2
120 +
exit 1
121 +
;;
122 +
esac