eletrotupi / tcc/ commit / cd4e510

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 files
Parents be9c517
Changed 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 modified+1 -3
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