@buivietphi/skill-mobile-mt 1.4.1 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -4
- package/README.md +6 -2
- package/SKILL.md +7 -1
- package/bin/install.mjs +1 -1
- package/package.json +1 -1
- package/shared/ci-cd.md +423 -0
- package/shared/testing-strategy.md +332 -0
package/AGENTS.md
CHANGED
|
@@ -62,6 +62,8 @@ skill-mobile-mt/
|
|
|
62
62
|
├── release-checklist.md ← App Store/Play Store checklist (587 tokens)
|
|
63
63
|
│
|
|
64
64
|
├── offline-first.md ← Local-first + sync patterns (2,566 tokens)
|
|
65
|
+
├── testing-strategy.md ← Detox + Maestro + XCUITest + Espresso E2E (2,200 tokens)
|
|
66
|
+
├── ci-cd.md ← GitHub Actions CI templates (2,500 tokens)
|
|
65
67
|
│
|
|
66
68
|
├── ── TEMPLATES (copy to your project) ────────────────────
|
|
67
69
|
├── claude-md-template.md ← CLAUDE.md for Claude Code (copy to project root)
|
|
@@ -70,7 +72,7 @@ skill-mobile-mt/
|
|
|
70
72
|
|
|
71
73
|
**Token totals:**
|
|
72
74
|
- Smart load (1 platform + core shared): **~38,600 tokens** (30.2% of 128K)
|
|
73
|
-
- Full load (all files): **~
|
|
75
|
+
- Full load (all files): **~74,700 tokens** (58.4% of 128K)
|
|
74
76
|
|
|
75
77
|
---
|
|
76
78
|
|
|
@@ -109,6 +111,8 @@ The agent reads the task, then decides which extra file to load:
|
|
|
109
111
|
| "Install this package / upgrade SDK" | `shared/version-management.md` |
|
|
110
112
|
| "Prepare for App Store / Play Store" | `shared/release-checklist.md` |
|
|
111
113
|
| "Weird issue, not sure why" | `shared/common-pitfalls.md` |
|
|
114
|
+
| "Write / run E2E tests" | `shared/testing-strategy.md` |
|
|
115
|
+
| "Setup CI/CD / GitHub Actions" | `shared/ci-cd.md` |
|
|
112
116
|
|
|
113
117
|
**Load cost:** +500 to +3,500 tokens per on-demand file.
|
|
114
118
|
|
|
@@ -143,7 +147,7 @@ The agent reads the task, then decides which extra file to load:
|
|
|
143
147
|
```yaml
|
|
144
148
|
skill:
|
|
145
149
|
name: skill-mobile-mt
|
|
146
|
-
version: "1.4.
|
|
150
|
+
version: "1.4.2"
|
|
147
151
|
author: buivietphi
|
|
148
152
|
category: engineering
|
|
149
153
|
tags:
|
|
@@ -210,9 +214,9 @@ skill:
|
|
|
210
214
|
java: ".java files in app/src/"
|
|
211
215
|
|
|
212
216
|
context_budget:
|
|
213
|
-
max_tokens:
|
|
217
|
+
max_tokens: 74700
|
|
214
218
|
smart_load_tokens: 38600
|
|
215
|
-
savings: "~
|
|
219
|
+
savings: "~48%"
|
|
216
220
|
```
|
|
217
221
|
|
|
218
222
|
---
|
|
@@ -279,6 +283,8 @@ Priority 6 (ON-DEMAND): shared/observability.md — Sessions as 4th pillar
|
|
|
279
283
|
Priority 6 (ON-DEMAND): shared/document-analysis.md — Parse images/PDFs → code
|
|
280
284
|
Priority 6 (ON-DEMAND): shared/release-checklist.md — Pre-release verification
|
|
281
285
|
Priority 6 (ON-DEMAND): shared/common-pitfalls.md — Known issue patterns
|
|
286
|
+
Priority 6 (ON-DEMAND): shared/testing-strategy.md — Detox + Maestro + XCUITest + Espresso E2E
|
|
287
|
+
Priority 6 (ON-DEMAND): shared/ci-cd.md — GitHub Actions CI/CD templates
|
|
282
288
|
```
|
|
283
289
|
|
|
284
290
|
---
|
package/README.md
CHANGED
|
@@ -232,7 +232,7 @@ iOS only?
|
|
|
232
232
|
| SKILL.md only | ~13,200 | 10.3% | 6.6% |
|
|
233
233
|
| + 1 platform + core shared/ | ~38,600 | 30.2% | 19.3% |
|
|
234
234
|
| Cross-platform (RN/Flutter + iOS + Android) | ~53,000 | 41.4% | 26.5% |
|
|
235
|
-
| All files loaded | ~
|
|
235
|
+
| All files loaded | ~74,700 | 58.4% | 37.4% |
|
|
236
236
|
| **Smart load (recommended)** | **~38,600** | **30.2%** | **19.3%** |
|
|
237
237
|
|
|
238
238
|
### Per-file token breakdown
|
|
@@ -259,9 +259,11 @@ iOS only?
|
|
|
259
259
|
| `shared/version-management.md` | 3,500 |
|
|
260
260
|
| `shared/observability.md` | 3,000 |
|
|
261
261
|
| `shared/offline-first.md` | 2,566 |
|
|
262
|
+
| `shared/testing-strategy.md` | 2,200 |
|
|
263
|
+
| `shared/ci-cd.md` | 2,500 |
|
|
262
264
|
| `shared/claude-md-template.md` | ~500 |
|
|
263
265
|
| `shared/agent-rules-template.md` | ~2,500 |
|
|
264
|
-
| **Total** | **~
|
|
266
|
+
| **Total** | **~53,500** |
|
|
265
267
|
|
|
266
268
|
## Installed Structure
|
|
267
269
|
|
|
@@ -293,6 +295,8 @@ iOS only?
|
|
|
293
295
|
├── observability.md Sessions as 4th pillar
|
|
294
296
|
├── release-checklist.md Pre-release verification
|
|
295
297
|
├── offline-first.md Local-first + sync patterns
|
|
298
|
+
├── testing-strategy.md Detox + Maestro + XCUITest + Espresso E2E
|
|
299
|
+
├── ci-cd.md GitHub Actions CI/CD templates
|
|
296
300
|
├── claude-md-template.md CLAUDE.md template for projects
|
|
297
301
|
└── agent-rules-template.md Rules templates for all agents
|
|
298
302
|
```
|
package/SKILL.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: skill-mobile-mt
|
|
3
3
|
description: "Master Senior Mobile Engineer. Patterns from 30+ production repos (200k+ GitHub stars: Ignite, Expensify, Mattermost, Immich, AppFlowy, Now in Android, TCA). Use when: building mobile features, fixing mobile bugs, reviewing mobile code, mobile architecture, React Native, Flutter, iOS Swift, Android Kotlin, mobile performance, mobile security audit, mobile code review, app release. Two modes: (1) default = pre-built production patterns, (2) 'project' = reads current project and adapts."
|
|
4
|
-
version: "1.4.
|
|
4
|
+
version: "1.4.2"
|
|
5
5
|
author: buivietphi
|
|
6
6
|
priority: high
|
|
7
7
|
user-invocable: true
|
|
@@ -130,6 +130,12 @@ USER REQUEST → ACTION (Read tool required)
|
|
|
130
130
|
"Offline / cache / sync" → Read: shared/offline-first.md
|
|
131
131
|
then: implement local-first architecture
|
|
132
132
|
|
|
133
|
+
"Write/run E2E tests" → Read: shared/testing-strategy.md
|
|
134
|
+
then: Detox (RN) or Maestro (cross-platform) or XCUITest/Espresso
|
|
135
|
+
|
|
136
|
+
"Setup CI/CD / GitHub Actions" → Read: shared/ci-cd.md
|
|
137
|
+
then: test → build → distribute pipeline
|
|
138
|
+
|
|
133
139
|
```
|
|
134
140
|
|
|
135
141
|
**⛔ NEVER start coding without identifying the task type first.**
|
package/bin/install.mjs
CHANGED
|
@@ -70,7 +70,7 @@ const fail = m => log(` ${c.red}✗${c.reset} ${m}`);
|
|
|
70
70
|
|
|
71
71
|
function banner() {
|
|
72
72
|
log(`\n${c.bold}${c.cyan} ┌──────────────────────────────────────────────────┐`);
|
|
73
|
-
log(` │ 📱 @buivietphi/skill-mobile-mt v1.4.
|
|
73
|
+
log(` │ 📱 @buivietphi/skill-mobile-mt v1.4.2 │`);
|
|
74
74
|
log(` │ Master Senior Mobile Engineer │`);
|
|
75
75
|
log(` │ │`);
|
|
76
76
|
log(` │ Claude · Cline · Roo Code · Cursor · Windsurf │`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@buivietphi/skill-mobile-mt",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Master Senior Mobile Engineer skill for AI agents. Pre-built patterns from 18 production apps + local project adaptation. React Native, Flutter, iOS, Android. Supports Claude, Gemini, Kimi, Cursor, Copilot, Antigravity.",
|
|
5
5
|
"author": "buivietphi",
|
|
6
6
|
"license": "MIT",
|
package/shared/ci-cd.md
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
# CI/CD Pipelines — GitHub Actions for Mobile
|
|
2
|
+
|
|
3
|
+
> Automate: test → build → distribute. Never ship without CI.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## React Native — CI Pipeline
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# .github/workflows/rn-ci.yml
|
|
11
|
+
name: React Native CI
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
branches: [main, develop]
|
|
16
|
+
pull_request:
|
|
17
|
+
branches: [main]
|
|
18
|
+
|
|
19
|
+
jobs:
|
|
20
|
+
test:
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
steps:
|
|
23
|
+
- uses: actions/checkout@v4
|
|
24
|
+
|
|
25
|
+
- uses: actions/setup-node@v4
|
|
26
|
+
with:
|
|
27
|
+
node-version: '20'
|
|
28
|
+
cache: 'yarn' # or npm, pnpm
|
|
29
|
+
|
|
30
|
+
- name: Install dependencies
|
|
31
|
+
run: yarn install --frozen-lockfile
|
|
32
|
+
|
|
33
|
+
- name: TypeScript check
|
|
34
|
+
run: yarn tsc --noEmit
|
|
35
|
+
|
|
36
|
+
- name: Lint
|
|
37
|
+
run: yarn lint
|
|
38
|
+
|
|
39
|
+
- name: Unit tests
|
|
40
|
+
run: yarn test --ci --coverage --maxWorkers=2
|
|
41
|
+
|
|
42
|
+
- name: Upload coverage
|
|
43
|
+
uses: codecov/codecov-action@v4
|
|
44
|
+
with:
|
|
45
|
+
token: ${{ secrets.CODECOV_TOKEN }}
|
|
46
|
+
|
|
47
|
+
build-android:
|
|
48
|
+
runs-on: ubuntu-latest
|
|
49
|
+
needs: test
|
|
50
|
+
steps:
|
|
51
|
+
- uses: actions/checkout@v4
|
|
52
|
+
|
|
53
|
+
- uses: actions/setup-node@v4
|
|
54
|
+
with:
|
|
55
|
+
node-version: '20'
|
|
56
|
+
cache: 'yarn'
|
|
57
|
+
|
|
58
|
+
- uses: actions/setup-java@v4
|
|
59
|
+
with:
|
|
60
|
+
distribution: 'temurin'
|
|
61
|
+
java-version: '17'
|
|
62
|
+
|
|
63
|
+
- name: Cache Gradle
|
|
64
|
+
uses: actions/cache@v4
|
|
65
|
+
with:
|
|
66
|
+
path: |
|
|
67
|
+
~/.gradle/caches
|
|
68
|
+
~/.gradle/wrapper
|
|
69
|
+
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
|
|
70
|
+
|
|
71
|
+
- name: Install dependencies
|
|
72
|
+
run: yarn install --frozen-lockfile
|
|
73
|
+
|
|
74
|
+
- name: Build Android APK (debug)
|
|
75
|
+
run: cd android && ./gradlew assembleDebug
|
|
76
|
+
|
|
77
|
+
- name: Upload APK
|
|
78
|
+
uses: actions/upload-artifact@v4
|
|
79
|
+
with:
|
|
80
|
+
name: app-debug.apk
|
|
81
|
+
path: android/app/build/outputs/apk/debug/app-debug.apk
|
|
82
|
+
|
|
83
|
+
build-ios:
|
|
84
|
+
runs-on: macos-15
|
|
85
|
+
needs: test
|
|
86
|
+
steps:
|
|
87
|
+
- uses: actions/checkout@v4
|
|
88
|
+
|
|
89
|
+
- uses: actions/setup-node@v4
|
|
90
|
+
with:
|
|
91
|
+
node-version: '20'
|
|
92
|
+
cache: 'yarn'
|
|
93
|
+
|
|
94
|
+
- name: Cache CocoaPods
|
|
95
|
+
uses: actions/cache@v4
|
|
96
|
+
with:
|
|
97
|
+
path: ios/Pods
|
|
98
|
+
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
|
|
99
|
+
|
|
100
|
+
- name: Install dependencies
|
|
101
|
+
run: yarn install --frozen-lockfile && cd ios && pod install
|
|
102
|
+
|
|
103
|
+
- name: Build iOS (simulator)
|
|
104
|
+
run: |
|
|
105
|
+
xcodebuild -workspace ios/MyApp.xcworkspace \
|
|
106
|
+
-scheme MyApp \
|
|
107
|
+
-sdk iphonesimulator \
|
|
108
|
+
-configuration Debug \
|
|
109
|
+
-derivedDataPath ios/build \
|
|
110
|
+
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## React Native — E2E with Detox
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
# .github/workflows/rn-e2e.yml
|
|
119
|
+
name: E2E Tests (Detox)
|
|
120
|
+
|
|
121
|
+
on:
|
|
122
|
+
push:
|
|
123
|
+
branches: [main]
|
|
124
|
+
|
|
125
|
+
jobs:
|
|
126
|
+
e2e-ios:
|
|
127
|
+
runs-on: macos-15
|
|
128
|
+
steps:
|
|
129
|
+
- uses: actions/checkout@v4
|
|
130
|
+
|
|
131
|
+
- uses: actions/setup-node@v4
|
|
132
|
+
with:
|
|
133
|
+
node-version: '20'
|
|
134
|
+
cache: 'yarn'
|
|
135
|
+
|
|
136
|
+
- name: Install dependencies
|
|
137
|
+
run: yarn install --frozen-lockfile && cd ios && pod install
|
|
138
|
+
|
|
139
|
+
- name: Install Detox CLI
|
|
140
|
+
run: npm install -g detox-cli
|
|
141
|
+
|
|
142
|
+
- name: Build for Detox
|
|
143
|
+
run: detox build --configuration ios.sim.debug
|
|
144
|
+
|
|
145
|
+
- name: Run E2E tests
|
|
146
|
+
run: detox test --configuration ios.sim.debug --headless
|
|
147
|
+
|
|
148
|
+
- name: Upload Detox artifacts on failure
|
|
149
|
+
if: failure()
|
|
150
|
+
uses: actions/upload-artifact@v4
|
|
151
|
+
with:
|
|
152
|
+
name: detox-artifacts
|
|
153
|
+
path: artifacts/
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## Flutter — CI Pipeline
|
|
159
|
+
|
|
160
|
+
```yaml
|
|
161
|
+
# .github/workflows/flutter-ci.yml
|
|
162
|
+
name: Flutter CI
|
|
163
|
+
|
|
164
|
+
on:
|
|
165
|
+
push:
|
|
166
|
+
branches: [main, develop]
|
|
167
|
+
pull_request:
|
|
168
|
+
branches: [main]
|
|
169
|
+
|
|
170
|
+
jobs:
|
|
171
|
+
test:
|
|
172
|
+
runs-on: ubuntu-latest
|
|
173
|
+
steps:
|
|
174
|
+
- uses: actions/checkout@v4
|
|
175
|
+
|
|
176
|
+
- uses: subosito/flutter-action@v2
|
|
177
|
+
with:
|
|
178
|
+
flutter-version: '3.27.x'
|
|
179
|
+
cache: true
|
|
180
|
+
|
|
181
|
+
- name: Install dependencies
|
|
182
|
+
run: flutter pub get
|
|
183
|
+
|
|
184
|
+
- name: Analyze
|
|
185
|
+
run: flutter analyze
|
|
186
|
+
|
|
187
|
+
- name: Unit + Widget tests
|
|
188
|
+
run: flutter test --coverage
|
|
189
|
+
|
|
190
|
+
- name: Upload coverage
|
|
191
|
+
uses: codecov/codecov-action@v4
|
|
192
|
+
|
|
193
|
+
build-android:
|
|
194
|
+
runs-on: ubuntu-latest
|
|
195
|
+
needs: test
|
|
196
|
+
steps:
|
|
197
|
+
- uses: actions/checkout@v4
|
|
198
|
+
|
|
199
|
+
- uses: actions/setup-java@v4
|
|
200
|
+
with:
|
|
201
|
+
distribution: 'temurin'
|
|
202
|
+
java-version: '17'
|
|
203
|
+
|
|
204
|
+
- uses: subosito/flutter-action@v2
|
|
205
|
+
with:
|
|
206
|
+
flutter-version: '3.27.x'
|
|
207
|
+
cache: true
|
|
208
|
+
|
|
209
|
+
- name: Install dependencies
|
|
210
|
+
run: flutter pub get
|
|
211
|
+
|
|
212
|
+
- name: Build APK
|
|
213
|
+
run: flutter build apk --debug
|
|
214
|
+
|
|
215
|
+
- name: Upload APK
|
|
216
|
+
uses: actions/upload-artifact@v4
|
|
217
|
+
with:
|
|
218
|
+
name: flutter-debug.apk
|
|
219
|
+
path: build/app/outputs/flutter-apk/app-debug.apk
|
|
220
|
+
|
|
221
|
+
build-ios:
|
|
222
|
+
runs-on: macos-15
|
|
223
|
+
needs: test
|
|
224
|
+
steps:
|
|
225
|
+
- uses: actions/checkout@v4
|
|
226
|
+
|
|
227
|
+
- uses: subosito/flutter-action@v2
|
|
228
|
+
with:
|
|
229
|
+
flutter-version: '3.27.x'
|
|
230
|
+
cache: true
|
|
231
|
+
|
|
232
|
+
- name: Install dependencies
|
|
233
|
+
run: flutter pub get
|
|
234
|
+
|
|
235
|
+
- name: Build iOS (no codesign)
|
|
236
|
+
run: flutter build ios --debug --no-codesign
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
---
|
|
240
|
+
|
|
241
|
+
## iOS — Release to TestFlight (Fastlane)
|
|
242
|
+
|
|
243
|
+
```yaml
|
|
244
|
+
# .github/workflows/ios-release.yml
|
|
245
|
+
name: iOS Release
|
|
246
|
+
|
|
247
|
+
on:
|
|
248
|
+
push:
|
|
249
|
+
tags: ['v*']
|
|
250
|
+
|
|
251
|
+
jobs:
|
|
252
|
+
release:
|
|
253
|
+
runs-on: macos-15
|
|
254
|
+
steps:
|
|
255
|
+
- uses: actions/checkout@v4
|
|
256
|
+
|
|
257
|
+
- uses: actions/setup-node@v4
|
|
258
|
+
with:
|
|
259
|
+
node-version: '20'
|
|
260
|
+
cache: 'yarn'
|
|
261
|
+
|
|
262
|
+
- name: Install dependencies
|
|
263
|
+
run: yarn install --frozen-lockfile && cd ios && pod install
|
|
264
|
+
|
|
265
|
+
- name: Setup certificates
|
|
266
|
+
uses: apple-actions/import-codesign-certs@v2
|
|
267
|
+
with:
|
|
268
|
+
p12-file-base64: ${{ secrets.CERTIFICATES_P12 }}
|
|
269
|
+
p12-password: ${{ secrets.CERTIFICATES_P12_PASSWORD }}
|
|
270
|
+
|
|
271
|
+
- name: Setup provisioning profile
|
|
272
|
+
uses: apple-actions/download-provisioning-profiles@v1
|
|
273
|
+
with:
|
|
274
|
+
bundle-id: com.myapp
|
|
275
|
+
issuer-id: ${{ secrets.APPSTORE_ISSUER_ID }}
|
|
276
|
+
api-key-id: ${{ secrets.APPSTORE_KEY_ID }}
|
|
277
|
+
api-private-key: ${{ secrets.APPSTORE_PRIVATE_KEY }}
|
|
278
|
+
|
|
279
|
+
- name: Deploy to TestFlight
|
|
280
|
+
run: bundle exec fastlane ios beta
|
|
281
|
+
env:
|
|
282
|
+
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APPSTORE_KEY_ID }}
|
|
283
|
+
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APPSTORE_ISSUER_ID }}
|
|
284
|
+
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APPSTORE_PRIVATE_KEY }}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
```ruby
|
|
288
|
+
# ios/Fastfile
|
|
289
|
+
lane :beta do
|
|
290
|
+
build_app(
|
|
291
|
+
workspace: "MyApp.xcworkspace",
|
|
292
|
+
scheme: "MyApp",
|
|
293
|
+
configuration: "Release",
|
|
294
|
+
export_method: "app-store"
|
|
295
|
+
)
|
|
296
|
+
upload_to_testflight(skip_waiting_for_build_processing: true)
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
---
|
|
301
|
+
|
|
302
|
+
## Android — Release to Play Store (Fastlane)
|
|
303
|
+
|
|
304
|
+
```yaml
|
|
305
|
+
# .github/workflows/android-release.yml
|
|
306
|
+
name: Android Release
|
|
307
|
+
|
|
308
|
+
on:
|
|
309
|
+
push:
|
|
310
|
+
tags: ['v*']
|
|
311
|
+
|
|
312
|
+
jobs:
|
|
313
|
+
release:
|
|
314
|
+
runs-on: ubuntu-latest
|
|
315
|
+
steps:
|
|
316
|
+
- uses: actions/checkout@v4
|
|
317
|
+
|
|
318
|
+
- uses: actions/setup-java@v4
|
|
319
|
+
with:
|
|
320
|
+
distribution: 'temurin'
|
|
321
|
+
java-version: '17'
|
|
322
|
+
|
|
323
|
+
- uses: actions/setup-node@v4
|
|
324
|
+
with:
|
|
325
|
+
node-version: '20'
|
|
326
|
+
cache: 'yarn'
|
|
327
|
+
|
|
328
|
+
- name: Install dependencies
|
|
329
|
+
run: yarn install --frozen-lockfile
|
|
330
|
+
|
|
331
|
+
- name: Decode keystore
|
|
332
|
+
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/release.keystore
|
|
333
|
+
|
|
334
|
+
- name: Build release AAB
|
|
335
|
+
run: cd android && ./gradlew bundleRelease
|
|
336
|
+
env:
|
|
337
|
+
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
|
338
|
+
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
|
339
|
+
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
|
340
|
+
|
|
341
|
+
- name: Deploy to Play Store
|
|
342
|
+
uses: r0adkll/upload-google-play@v1
|
|
343
|
+
with:
|
|
344
|
+
serviceAccountJsonPlainText: ${{ secrets.PLAY_STORE_SERVICE_ACCOUNT_JSON }}
|
|
345
|
+
packageName: com.myapp
|
|
346
|
+
releaseFiles: android/app/build/outputs/bundle/release/*.aab
|
|
347
|
+
track: internal
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## Required GitHub Secrets
|
|
353
|
+
|
|
354
|
+
```
|
|
355
|
+
# iOS
|
|
356
|
+
CERTIFICATES_P12 ← base64-encoded .p12 file
|
|
357
|
+
CERTIFICATES_P12_PASSWORD ← password for .p12
|
|
358
|
+
APPSTORE_KEY_ID ← App Store Connect API key ID
|
|
359
|
+
APPSTORE_ISSUER_ID ← App Store Connect issuer ID
|
|
360
|
+
APPSTORE_PRIVATE_KEY ← App Store Connect private key (.p8 contents)
|
|
361
|
+
|
|
362
|
+
# Android
|
|
363
|
+
KEYSTORE_BASE64 ← base64-encoded release.keystore
|
|
364
|
+
KEYSTORE_PASSWORD ← keystore password
|
|
365
|
+
KEY_ALIAS ← key alias
|
|
366
|
+
KEY_PASSWORD ← key password
|
|
367
|
+
PLAY_STORE_SERVICE_ACCOUNT_JSON ← GCP service account JSON
|
|
368
|
+
|
|
369
|
+
# Shared
|
|
370
|
+
CODECOV_TOKEN ← coverage reporting
|
|
371
|
+
MAESTRO_API_KEY ← Maestro Cloud E2E (optional)
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
---
|
|
375
|
+
|
|
376
|
+
## Caching Strategy
|
|
377
|
+
|
|
378
|
+
```yaml
|
|
379
|
+
# Node modules — hash package-lock or yarn.lock
|
|
380
|
+
- uses: actions/cache@v4
|
|
381
|
+
with:
|
|
382
|
+
path: node_modules
|
|
383
|
+
key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }}
|
|
384
|
+
restore-keys: ${{ runner.os }}-node-
|
|
385
|
+
|
|
386
|
+
# Gradle — hash .gradle files
|
|
387
|
+
- uses: actions/cache@v4
|
|
388
|
+
with:
|
|
389
|
+
path: |
|
|
390
|
+
~/.gradle/caches
|
|
391
|
+
~/.gradle/wrapper
|
|
392
|
+
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
|
393
|
+
|
|
394
|
+
# CocoaPods — hash Podfile.lock
|
|
395
|
+
- uses: actions/cache@v4
|
|
396
|
+
with:
|
|
397
|
+
path: ios/Pods
|
|
398
|
+
key: ${{ runner.os }}-pods-${{ hashFiles('ios/Podfile.lock') }}
|
|
399
|
+
|
|
400
|
+
# Flutter pub — hash pubspec.lock
|
|
401
|
+
- uses: actions/cache@v4
|
|
402
|
+
with:
|
|
403
|
+
path: ~/.pub-cache
|
|
404
|
+
key: ${{ runner.os }}-pub-${{ hashFiles('**/pubspec.lock') }}
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
---
|
|
408
|
+
|
|
409
|
+
## Anti-Patterns
|
|
410
|
+
|
|
411
|
+
```
|
|
412
|
+
❌ Committing signing credentials to repo
|
|
413
|
+
❌ Running E2E on every PR (too slow — run on main only)
|
|
414
|
+
❌ No caching (3x slower builds)
|
|
415
|
+
❌ Skipping unit tests before build jobs
|
|
416
|
+
❌ Building on push to every branch
|
|
417
|
+
|
|
418
|
+
✅ Store all secrets in GitHub Secrets
|
|
419
|
+
✅ Cache node_modules + Gradle + CocoaPods + pub-cache
|
|
420
|
+
✅ Unit tests on PR, E2E on merge to main
|
|
421
|
+
✅ needs: test before build jobs
|
|
422
|
+
✅ Upload build artifacts for download/QA
|
|
423
|
+
```
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
# Mobile Testing Strategy — Unit + E2E
|
|
2
|
+
|
|
3
|
+
> Test the right things at the right layer. Don't test implementation details.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Testing Pyramid
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
╱ E2E ╲ ← Detox / Maestro / XCUITest / Espresso
|
|
11
|
+
╱───────╲ ← Few, slow, high-confidence
|
|
12
|
+
╱Integration╲ ← API mocking, navigation flows
|
|
13
|
+
╱─────────────╲ ← Medium count
|
|
14
|
+
╱ Unit Tests ╲ ← Jest / XCTest / JUnit
|
|
15
|
+
╱─────────────────╲ ← Many, fast, cheap
|
|
16
|
+
|
|
17
|
+
RULE: Most tests = unit. E2E = critical flows only (login, checkout, onboarding).
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Unit Tests (Jest — React Native / TypeScript)
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
// Test hooks, not components
|
|
26
|
+
describe('useCart', () => {
|
|
27
|
+
it('adds item and updates total', () => {
|
|
28
|
+
const { result } = renderHook(() => useCart(), { wrapper: ReduxProvider })
|
|
29
|
+
act(() => { result.current.addItem(mockProduct) })
|
|
30
|
+
expect(result.current.total).toBe(mockProduct.price)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('handles addToCart API error with rollback', async () => {
|
|
34
|
+
server.use(rest.post('/cart', (req, res, ctx) => res(ctx.status(500))))
|
|
35
|
+
const { result } = renderHook(() => useCart(), { wrapper: ReduxProvider })
|
|
36
|
+
await act(async () => { await result.current.addItem(mockProduct) })
|
|
37
|
+
expect(result.current.items).toHaveLength(0) // rolled back
|
|
38
|
+
expect(result.current.error).toBeTruthy()
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// Test Redux slices directly
|
|
43
|
+
describe('cartSlice', () => {
|
|
44
|
+
it('sets loading state on fetchCart.pending', () => {
|
|
45
|
+
const state = cartReducer(initialState, fetchCart.pending('', undefined))
|
|
46
|
+
expect(state.status).toBe('loading')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Rules:**
|
|
52
|
+
- Test business logic (hooks, services, slices) — NOT component layout
|
|
53
|
+
- Mock API calls with `msw` (Mock Service Worker)
|
|
54
|
+
- 4 states per feature: loading / success / error / empty
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## E2E Testing — Detox (React Native)
|
|
59
|
+
|
|
60
|
+
> Best for: React Native apps, full native bridge testing.
|
|
61
|
+
|
|
62
|
+
### Setup
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Install
|
|
66
|
+
npm install --save-dev detox @config/detox
|
|
67
|
+
|
|
68
|
+
# iOS build (required before tests)
|
|
69
|
+
detox build --configuration ios.sim.debug
|
|
70
|
+
|
|
71
|
+
# Run tests
|
|
72
|
+
detox test --configuration ios.sim.debug
|
|
73
|
+
detox test --configuration android.emu.debug
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Config (`detox.config.js`)
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
module.exports = {
|
|
80
|
+
testRunner: {
|
|
81
|
+
args: { '$0': 'jest', config: 'e2e/jest.config.js' },
|
|
82
|
+
jest: { setupTimeout: 120000 }
|
|
83
|
+
},
|
|
84
|
+
apps: {
|
|
85
|
+
'ios.debug': { type: 'ios.app', binaryPath: 'ios/build/Build/Products/Debug-iphonesimulator/MyApp.app', build: 'xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build' },
|
|
86
|
+
'android.debug': { type: 'android.apk', binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk', build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug' }
|
|
87
|
+
},
|
|
88
|
+
devices: {
|
|
89
|
+
simulator: { type: 'ios.simulator', device: { type: 'iPhone 15' } },
|
|
90
|
+
emulator: { type: 'android.emulator', device: { avdName: 'Pixel_7_API_34' } }
|
|
91
|
+
},
|
|
92
|
+
configurations: {
|
|
93
|
+
'ios.sim.debug': { device: 'simulator', app: 'ios.debug' },
|
|
94
|
+
'android.emu.debug': { device: 'emulator', app: 'android.debug' }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Write Detox Tests
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// e2e/login.test.ts
|
|
103
|
+
describe('Login Flow', () => {
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
await device.launchApp({ newInstance: true })
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
beforeEach(async () => {
|
|
109
|
+
await device.reloadReactNative()
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('shows error on wrong password', async () => {
|
|
113
|
+
await element(by.id('email-input')).typeText('user@test.com')
|
|
114
|
+
await element(by.id('password-input')).typeText('wrongpass')
|
|
115
|
+
await element(by.id('login-button')).tap()
|
|
116
|
+
await expect(element(by.text('Invalid credentials'))).toBeVisible()
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('navigates to home on success', async () => {
|
|
120
|
+
await element(by.id('email-input')).typeText('user@test.com')
|
|
121
|
+
await element(by.id('password-input')).typeText('correctpass')
|
|
122
|
+
await element(by.id('login-button')).tap()
|
|
123
|
+
await expect(element(by.id('home-screen'))).toBeVisible()
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**testID rules:**
|
|
129
|
+
```typescript
|
|
130
|
+
// Add testID to interactive elements
|
|
131
|
+
<TextInput testID="email-input" ... />
|
|
132
|
+
<TouchableOpacity testID="login-button" ... />
|
|
133
|
+
<View testID="home-screen" ... />
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**What to test with Detox:**
|
|
137
|
+
- Login / logout flow
|
|
138
|
+
- Onboarding (first-time user)
|
|
139
|
+
- Critical purchase / checkout path
|
|
140
|
+
- Push notification tap → navigation
|
|
141
|
+
- Deep link handling
|
|
142
|
+
|
|
143
|
+
**What NOT to test with Detox:** minor UI variations, loading spinners, animations.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## E2E Testing — Maestro (Cross-Platform)
|
|
148
|
+
|
|
149
|
+
> Best for: Simpler setup, works on React Native + Flutter + native iOS/Android.
|
|
150
|
+
|
|
151
|
+
### Setup
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
# macOS
|
|
155
|
+
brew tap mobile-dev-inc/tap
|
|
156
|
+
brew install maestro
|
|
157
|
+
|
|
158
|
+
# Run a flow
|
|
159
|
+
maestro test e2e/login.yaml
|
|
160
|
+
maestro test e2e/ # all flows in folder
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Write Maestro Flows (YAML)
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
# e2e/login.yaml
|
|
167
|
+
appId: com.myapp
|
|
168
|
+
---
|
|
169
|
+
- launchApp:
|
|
170
|
+
clearState: true
|
|
171
|
+
|
|
172
|
+
- assertVisible: "Sign In"
|
|
173
|
+
|
|
174
|
+
- tapOn:
|
|
175
|
+
id: "email-input"
|
|
176
|
+
- inputText: "user@test.com"
|
|
177
|
+
|
|
178
|
+
- tapOn:
|
|
179
|
+
id: "password-input"
|
|
180
|
+
- inputText: "wrongpass"
|
|
181
|
+
|
|
182
|
+
- tapOn:
|
|
183
|
+
id: "login-button"
|
|
184
|
+
|
|
185
|
+
- assertVisible: "Invalid credentials"
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
# e2e/checkout.yaml
|
|
190
|
+
appId: com.myapp
|
|
191
|
+
---
|
|
192
|
+
- launchApp
|
|
193
|
+
- tapOn: "Products"
|
|
194
|
+
- tapOn:
|
|
195
|
+
index: 0 # first product
|
|
196
|
+
- tapOn: "Add to Cart"
|
|
197
|
+
- tapOn: "Checkout"
|
|
198
|
+
- assertVisible: "Order Confirmed"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Maestro Cloud CI
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
# Run on real devices in Maestro Cloud
|
|
205
|
+
maestro cloud --apiKey $MAESTRO_API_KEY e2e/
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
**Maestro vs Detox:**
|
|
209
|
+
|
|
210
|
+
| | Maestro | Detox |
|
|
211
|
+
|--|---------|-------|
|
|
212
|
+
| Setup | Minutes | Hours |
|
|
213
|
+
| YAML / Code | YAML | TypeScript |
|
|
214
|
+
| Cross-platform | ✅ RN + Flutter + native | RN only |
|
|
215
|
+
| Speed | Slower | Faster |
|
|
216
|
+
| Power | Medium | High |
|
|
217
|
+
| CI integration | Maestro Cloud | Self-hosted |
|
|
218
|
+
|
|
219
|
+
**Use Maestro when:** simple flows, cross-platform team, quick setup.
|
|
220
|
+
**Use Detox when:** complex interactions, React Native only, full control.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Flutter Testing
|
|
225
|
+
|
|
226
|
+
### Unit + Widget Tests
|
|
227
|
+
|
|
228
|
+
```dart
|
|
229
|
+
// Unit test — business logic
|
|
230
|
+
test('CartBloc adds item correctly', () {
|
|
231
|
+
final bloc = CartBloc(cartRepository: MockCartRepository());
|
|
232
|
+
bloc.add(AddToCart(product: mockProduct));
|
|
233
|
+
expectLater(bloc.stream, emits(CartLoaded(items: [mockProduct])));
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Widget test — UI
|
|
237
|
+
testWidgets('ProductCard shows title and price', (tester) async {
|
|
238
|
+
await tester.pumpWidget(MaterialApp(
|
|
239
|
+
home: ProductCard(product: mockProduct),
|
|
240
|
+
));
|
|
241
|
+
expect(find.text(mockProduct.title), findsOneWidget);
|
|
242
|
+
expect(find.text('\$${mockProduct.price}'), findsOneWidget);
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Integration Test (Flutter Driver replacement)
|
|
247
|
+
|
|
248
|
+
```dart
|
|
249
|
+
// integration_test/login_test.dart
|
|
250
|
+
void main() {
|
|
251
|
+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
252
|
+
|
|
253
|
+
testWidgets('login flow', (tester) async {
|
|
254
|
+
app.main();
|
|
255
|
+
await tester.pumpAndSettle();
|
|
256
|
+
|
|
257
|
+
await tester.enterText(find.byKey(Key('email')), 'user@test.com');
|
|
258
|
+
await tester.enterText(find.byKey(Key('password')), 'pass123');
|
|
259
|
+
await tester.tap(find.byKey(Key('login-button')));
|
|
260
|
+
await tester.pumpAndSettle();
|
|
261
|
+
|
|
262
|
+
expect(find.byKey(Key('home-screen')), findsOneWidget);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
# Run on simulator
|
|
269
|
+
flutter test integration_test/
|
|
270
|
+
|
|
271
|
+
# Run on real device
|
|
272
|
+
flutter test integration_test/ -d <device-id>
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## iOS — XCUITest
|
|
278
|
+
|
|
279
|
+
```swift
|
|
280
|
+
func testLoginFlow() throws {
|
|
281
|
+
let app = XCUIApplication()
|
|
282
|
+
app.launch()
|
|
283
|
+
|
|
284
|
+
let emailField = app.textFields["email-input"]
|
|
285
|
+
emailField.tap()
|
|
286
|
+
emailField.typeText("user@test.com")
|
|
287
|
+
|
|
288
|
+
let passwordField = app.secureTextFields["password-input"]
|
|
289
|
+
passwordField.tap()
|
|
290
|
+
passwordField.typeText("pass123")
|
|
291
|
+
|
|
292
|
+
app.buttons["login-button"].tap()
|
|
293
|
+
|
|
294
|
+
XCTAssertTrue(app.otherElements["home-screen"].waitForExistence(timeout: 5))
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
---
|
|
299
|
+
|
|
300
|
+
## Android — Espresso
|
|
301
|
+
|
|
302
|
+
```kotlin
|
|
303
|
+
@Test
|
|
304
|
+
fun testLoginFlow() {
|
|
305
|
+
onView(withId(R.id.emailInput))
|
|
306
|
+
.perform(typeText("user@test.com"), closeSoftKeyboard())
|
|
307
|
+
onView(withId(R.id.passwordInput))
|
|
308
|
+
.perform(typeText("pass123"), closeSoftKeyboard())
|
|
309
|
+
onView(withId(R.id.loginButton)).perform(click())
|
|
310
|
+
onView(withId(R.id.homeScreen)).check(matches(isDisplayed()))
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
---
|
|
315
|
+
|
|
316
|
+
## Anti-Patterns
|
|
317
|
+
|
|
318
|
+
```
|
|
319
|
+
❌ Testing implementation details (internal state, private methods)
|
|
320
|
+
❌ Testing every UI pixel (visual regression belongs in Storybook/Percy)
|
|
321
|
+
❌ E2E for every edge case (unit test those)
|
|
322
|
+
❌ Skipping testID on interactive elements
|
|
323
|
+
❌ Running Detox without a stable test build
|
|
324
|
+
❌ Using sleep() instead of waitFor()
|
|
325
|
+
|
|
326
|
+
✅ Test user flows, not code internals
|
|
327
|
+
✅ E2E for critical paths: login, purchase, onboarding
|
|
328
|
+
✅ Unit test all business logic (hooks, slices, services)
|
|
329
|
+
✅ Mock API calls in unit tests
|
|
330
|
+
✅ Add testID to every tappable + input element
|
|
331
|
+
✅ waitFor() over sleep() in Detox
|
|
332
|
+
```
|