@tamagui/native-ci 1.139.0
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/README.md +320 -0
- package/action.yml +98 -0
- package/actions/fingerprint/action.yml +110 -0
- package/actions/test-detox-android/action.yml +70 -0
- package/actions/test-detox-ios/action.yml +66 -0
- package/dist/cache.js +71 -0
- package/dist/cache.js.map +6 -0
- package/dist/cache.mjs +73 -0
- package/dist/cache.mjs.map +1 -0
- package/dist/cli.js +275 -0
- package/dist/cli.js.map +6 -0
- package/dist/cli.mjs +306 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/constants.js +12 -0
- package/dist/constants.js.map +6 -0
- package/dist/constants.mjs +10 -0
- package/dist/constants.mjs.map +1 -0
- package/dist/deps.js +44 -0
- package/dist/deps.js.map +6 -0
- package/dist/deps.mjs +53 -0
- package/dist/deps.mjs.map +1 -0
- package/dist/detox.js +49 -0
- package/dist/detox.js.map +6 -0
- package/dist/detox.mjs +55 -0
- package/dist/detox.mjs.map +1 -0
- package/dist/fingerprint.js +43 -0
- package/dist/fingerprint.js.map +6 -0
- package/dist/fingerprint.mjs +40 -0
- package/dist/fingerprint.mjs.map +1 -0
- package/dist/index.js +90 -0
- package/dist/index.js.map +6 -0
- package/dist/index.mjs +11 -0
- package/dist/index.mjs.map +1 -0
- package/dist/metro.js +79 -0
- package/dist/metro.js.map +6 -0
- package/dist/metro.mjs +75 -0
- package/dist/metro.mjs.map +1 -0
- package/dist/runner.js +73 -0
- package/dist/runner.js.map +6 -0
- package/dist/runner.mjs +73 -0
- package/dist/runner.mjs.map +1 -0
- package/package.json +50 -0
- package/src/android.ts +103 -0
- package/src/cache.ts +144 -0
- package/src/cli.ts +513 -0
- package/src/constants.ts +30 -0
- package/src/deps.ts +109 -0
- package/src/detox.ts +102 -0
- package/src/fingerprint.ts +77 -0
- package/src/index.ts +86 -0
- package/src/ios.ts +38 -0
- package/src/metro.ts +157 -0
- package/src/run-detox-android.ts +49 -0
- package/src/run-detox-ios.ts +40 -0
- package/src/runner.ts +123 -0
- package/types/android.d.ts +32 -0
- package/types/android.d.ts.map +1 -0
- package/types/cache.d.ts +41 -0
- package/types/cache.d.ts.map +1 -0
- package/types/cli.d.ts +11 -0
- package/types/cli.d.ts.map +1 -0
- package/types/constants.d.ts +18 -0
- package/types/constants.d.ts.map +1 -0
- package/types/deps.d.ts +32 -0
- package/types/deps.d.ts.map +1 -0
- package/types/detox.d.ts +39 -0
- package/types/detox.d.ts.map +1 -0
- package/types/fingerprint.d.ts +21 -0
- package/types/fingerprint.d.ts.map +1 -0
- package/types/index.d.ts +16 -0
- package/types/index.d.ts.map +1 -0
- package/types/ios.d.ts +18 -0
- package/types/ios.d.ts.map +1 -0
- package/types/metro.d.ts +51 -0
- package/types/metro.d.ts.map +1 -0
- package/types/run-detox-android.d.ts +15 -0
- package/types/run-detox-android.d.ts.map +1 -0
- package/types/run-detox-ios.d.ts +14 -0
- package/types/run-detox-ios.d.ts.map +1 -0
- package/types/runner.d.ts +35 -0
- package/types/runner.d.ts.map +1 -0
package/src/cache.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { DEFAULT_KV_TTL_SECONDS, type Platform } from './constants'
|
|
4
|
+
|
|
5
|
+
export interface CacheOptions {
|
|
6
|
+
platform: Platform
|
|
7
|
+
fingerprint: string
|
|
8
|
+
prefix?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RedisKVOptions {
|
|
12
|
+
url: string
|
|
13
|
+
token: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a cache key for the native build.
|
|
18
|
+
*/
|
|
19
|
+
export function createCacheKey(options: CacheOptions): string {
|
|
20
|
+
const { platform, fingerprint, prefix = 'native-build' } = options
|
|
21
|
+
return `${prefix}-${platform}-${fingerprint}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Save fingerprint mapping to Redis KV store.
|
|
26
|
+
* Used to map pre-fingerprint hash to actual fingerprint for faster lookups.
|
|
27
|
+
*/
|
|
28
|
+
export async function saveFingerprintToKV(
|
|
29
|
+
kv: RedisKVOptions,
|
|
30
|
+
key: string,
|
|
31
|
+
fingerprint: string,
|
|
32
|
+
ttlSeconds = DEFAULT_KV_TTL_SECONDS
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(`${kv.url}/SETEX/${key}/${ttlSeconds}/${fingerprint}`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${kv.token}`,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error instanceof TypeError) {
|
|
47
|
+
throw new Error(`Network error connecting to KV store: ${(error as Error).message}`)
|
|
48
|
+
}
|
|
49
|
+
throw new Error(`Failed to save fingerprint to KV: ${(error as Error).message}`)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get fingerprint from Redis KV store.
|
|
55
|
+
*/
|
|
56
|
+
export async function getFingerprintFromKV(
|
|
57
|
+
kv: RedisKVOptions,
|
|
58
|
+
key: string
|
|
59
|
+
): Promise<string | null> {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(`${kv.url}/get/${key}`, {
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: `Bearer ${kv.token}`,
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (!response.ok) {
|
|
68
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const data = (await response.json()) as { result: string | null }
|
|
72
|
+
return data.result === 'null' ? null : data.result
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof TypeError) {
|
|
75
|
+
throw new Error(`Network error connecting to KV store: ${(error as Error).message}`)
|
|
76
|
+
}
|
|
77
|
+
throw new Error(`Failed to get fingerprint from KV: ${(error as Error).message}`)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Extend TTL on a KV key.
|
|
83
|
+
*/
|
|
84
|
+
export async function extendKVTTL(
|
|
85
|
+
kv: RedisKVOptions,
|
|
86
|
+
key: string,
|
|
87
|
+
ttlSeconds = DEFAULT_KV_TTL_SECONDS
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
try {
|
|
90
|
+
await fetch(`${kv.url}/EXPIRE/${key}/${ttlSeconds}`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: {
|
|
93
|
+
Authorization: `Bearer ${kv.token}`,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Non-fatal - log but don't throw
|
|
98
|
+
console.warn(`Failed to extend KV TTL: ${(error as Error).message}`)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Local file-based cache for testing
|
|
103
|
+
|
|
104
|
+
export interface LocalCacheOptions {
|
|
105
|
+
cacheDir?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Save cache data locally (for testing).
|
|
110
|
+
* If filePath is a simple filename, it's saved directly in the current directory.
|
|
111
|
+
*/
|
|
112
|
+
export function saveCache(
|
|
113
|
+
filePath: string,
|
|
114
|
+
data: Record<string, unknown>,
|
|
115
|
+
options: LocalCacheOptions = {}
|
|
116
|
+
): void {
|
|
117
|
+
const { cacheDir } = options
|
|
118
|
+
const cachePath = cacheDir ? join(process.cwd(), cacheDir, filePath) : join(process.cwd(), filePath)
|
|
119
|
+
|
|
120
|
+
mkdirSync(dirname(cachePath), { recursive: true })
|
|
121
|
+
writeFileSync(cachePath, JSON.stringify(data, null, 2))
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Load cache data locally (for testing).
|
|
126
|
+
* If filePath is a simple filename, it's loaded from the current directory.
|
|
127
|
+
*/
|
|
128
|
+
export function loadCache<T extends Record<string, unknown>>(
|
|
129
|
+
filePath: string,
|
|
130
|
+
options: LocalCacheOptions = {}
|
|
131
|
+
): T | null {
|
|
132
|
+
const { cacheDir } = options
|
|
133
|
+
const cachePath = cacheDir ? join(process.cwd(), cacheDir, filePath) : join(process.cwd(), filePath)
|
|
134
|
+
|
|
135
|
+
if (!existsSync(cachePath)) {
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(readFileSync(cachePath, 'utf-8'))
|
|
141
|
+
} catch {
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI for @tamagui/native-ci
|
|
4
|
+
*
|
|
5
|
+
* Provides commands for:
|
|
6
|
+
* - Running native tests (Detox, Maestro)
|
|
7
|
+
* - Fingerprint generation and caching
|
|
8
|
+
* - Dependency management
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { generateFingerprint, generatePreFingerprintHash } from './fingerprint'
|
|
12
|
+
import { createCacheKey, saveFingerprintToKV, getFingerprintFromKV, saveCache, loadCache } from './cache'
|
|
13
|
+
import { setGitHubOutput, isGitHubActions, isCI } from './runner'
|
|
14
|
+
import { checkDeps, ensureIosDeps, ensureAndroidDeps, ensureMaestro, printDepsStatus } from './deps'
|
|
15
|
+
import { withMetro } from './metro'
|
|
16
|
+
import { parseDetoxArgs, runDetoxTests } from './detox'
|
|
17
|
+
import { ensureIOSFolder } from './ios'
|
|
18
|
+
import { setupAndroidDevice, ensureAndroidFolder } from './android'
|
|
19
|
+
import type { Platform } from './constants'
|
|
20
|
+
|
|
21
|
+
const HELP = `
|
|
22
|
+
native-ci - Native CI/CD helpers for Expo apps
|
|
23
|
+
|
|
24
|
+
COMMANDS:
|
|
25
|
+
|
|
26
|
+
Test Commands:
|
|
27
|
+
test ios [options] Run iOS Detox tests
|
|
28
|
+
test android [options] Run Android Detox tests
|
|
29
|
+
test maestro [flow] Run Maestro tests
|
|
30
|
+
test all Run all tests (iOS + Android)
|
|
31
|
+
|
|
32
|
+
Dependency Commands:
|
|
33
|
+
deps Show dependency status
|
|
34
|
+
deps install Install missing dependencies
|
|
35
|
+
deps install-ios Install iOS dependencies (macOS only)
|
|
36
|
+
deps install-android Install Android dependencies
|
|
37
|
+
deps install-maestro Install Maestro
|
|
38
|
+
|
|
39
|
+
Fingerprint Commands:
|
|
40
|
+
fingerprint <platform> Generate native build fingerprint
|
|
41
|
+
fingerprint-test Test fingerprint caching locally
|
|
42
|
+
pre-hash <files...> Generate quick pre-fingerprint hash
|
|
43
|
+
cache-key <platform> <fp> Generate cache key from fingerprint
|
|
44
|
+
|
|
45
|
+
KV Store Commands:
|
|
46
|
+
kv-get <key> Get value from KV store
|
|
47
|
+
kv-set <key> <value> Set value in KV store
|
|
48
|
+
|
|
49
|
+
OPTIONS:
|
|
50
|
+
--project-root <path> Project root directory (default: cwd)
|
|
51
|
+
--config <name> Detox configuration name
|
|
52
|
+
--record-logs <mode> Record logs: none, failing, all (default: all)
|
|
53
|
+
--retries <n> Number of retries for flaky tests (default: 0)
|
|
54
|
+
--headless Run in headless mode (Android only)
|
|
55
|
+
--prefix <prefix> Cache key prefix (default: native-build)
|
|
56
|
+
--github-output Output results for GitHub Actions
|
|
57
|
+
--json Output as JSON
|
|
58
|
+
--help Show this help message
|
|
59
|
+
|
|
60
|
+
ENVIRONMENT:
|
|
61
|
+
KV_STORE_REDIS_REST_URL Redis REST API URL for fingerprint caching
|
|
62
|
+
KV_STORE_REDIS_REST_TOKEN Redis REST API token
|
|
63
|
+
|
|
64
|
+
EXAMPLES:
|
|
65
|
+
native-ci test ios
|
|
66
|
+
native-ci test android --headless
|
|
67
|
+
native-ci test maestro
|
|
68
|
+
native-ci deps
|
|
69
|
+
native-ci deps install
|
|
70
|
+
native-ci fingerprint ios
|
|
71
|
+
native-ci fingerprint-test
|
|
72
|
+
`
|
|
73
|
+
|
|
74
|
+
interface ParsedArgs {
|
|
75
|
+
command: string
|
|
76
|
+
subcommand: string
|
|
77
|
+
args: string[]
|
|
78
|
+
options: {
|
|
79
|
+
projectRoot: string
|
|
80
|
+
config: string
|
|
81
|
+
recordLogs: string
|
|
82
|
+
retries: number
|
|
83
|
+
headless: boolean
|
|
84
|
+
prefix: string
|
|
85
|
+
githubOutput: boolean
|
|
86
|
+
json: boolean
|
|
87
|
+
help: boolean
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
92
|
+
const args: string[] = []
|
|
93
|
+
const options = {
|
|
94
|
+
projectRoot: process.cwd(),
|
|
95
|
+
config: '',
|
|
96
|
+
recordLogs: 'all',
|
|
97
|
+
retries: 0,
|
|
98
|
+
headless: false,
|
|
99
|
+
prefix: 'native-build',
|
|
100
|
+
githubOutput: false,
|
|
101
|
+
json: false,
|
|
102
|
+
help: false,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let i = 0
|
|
106
|
+
while (i < argv.length) {
|
|
107
|
+
const arg = argv[i]
|
|
108
|
+
|
|
109
|
+
if (arg === '--project-root' && argv[i + 1]) {
|
|
110
|
+
options.projectRoot = argv[++i]
|
|
111
|
+
} else if (arg === '--config' && argv[i + 1]) {
|
|
112
|
+
options.config = argv[++i]
|
|
113
|
+
} else if (arg === '--record-logs' && argv[i + 1]) {
|
|
114
|
+
options.recordLogs = argv[++i]
|
|
115
|
+
} else if (arg === '--retries' && argv[i + 1]) {
|
|
116
|
+
const val = Number.parseInt(argv[++i], 10)
|
|
117
|
+
if (!Number.isNaN(val) && val >= 0) {
|
|
118
|
+
options.retries = val
|
|
119
|
+
}
|
|
120
|
+
} else if (arg === '--prefix' && argv[i + 1]) {
|
|
121
|
+
options.prefix = argv[++i]
|
|
122
|
+
} else if (arg === '--github-output') {
|
|
123
|
+
options.githubOutput = true
|
|
124
|
+
} else if (arg === '--json') {
|
|
125
|
+
options.json = true
|
|
126
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
127
|
+
options.help = true
|
|
128
|
+
} else if (!arg.startsWith('-')) {
|
|
129
|
+
args.push(arg)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
i++
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
command: args[0] || '',
|
|
137
|
+
subcommand: args[1] || '',
|
|
138
|
+
args: args.slice(2),
|
|
139
|
+
options,
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validatePlatform(value: string): Platform {
|
|
144
|
+
if (value !== 'ios' && value !== 'android') {
|
|
145
|
+
console.error('Error: platform must be "ios" or "android"')
|
|
146
|
+
process.exit(1)
|
|
147
|
+
}
|
|
148
|
+
return value
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getKVCredentials(): { url: string; token: string } {
|
|
152
|
+
const url = process.env.KV_STORE_REDIS_REST_URL
|
|
153
|
+
const token = process.env.KV_STORE_REDIS_REST_TOKEN
|
|
154
|
+
|
|
155
|
+
if (!url || !token) {
|
|
156
|
+
console.error('Error: KV_STORE_REDIS_REST_URL and KV_STORE_REDIS_REST_TOKEN required')
|
|
157
|
+
process.exit(1)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { url, token }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Parse arguments
|
|
164
|
+
const { command, subcommand, args, options } = parseArgs(process.argv.slice(2))
|
|
165
|
+
|
|
166
|
+
if (options.help || !command) {
|
|
167
|
+
console.info(HELP)
|
|
168
|
+
process.exit(options.help ? 0 : 1)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
switch (command) {
|
|
173
|
+
// ========================================
|
|
174
|
+
// Test Commands
|
|
175
|
+
// ========================================
|
|
176
|
+
case 'test': {
|
|
177
|
+
// Skip in CI - native tests are run by separate workflows
|
|
178
|
+
if (isCI() && !process.env.NATIVE_CI_FORCE_RUN) {
|
|
179
|
+
console.info('Skipping native tests in CI (handled by separate workflow)')
|
|
180
|
+
console.info('Set NATIVE_CI_FORCE_RUN=1 to force run')
|
|
181
|
+
process.exit(0)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const platform = subcommand || 'ios'
|
|
185
|
+
|
|
186
|
+
if (platform === 'ios') {
|
|
187
|
+
// Ensure iOS dependencies
|
|
188
|
+
await ensureIosDeps()
|
|
189
|
+
|
|
190
|
+
const config = options.config || 'ios.sim.debug'
|
|
191
|
+
console.info('=== iOS Detox Test Runner ===')
|
|
192
|
+
console.info(`Config: ${config}`)
|
|
193
|
+
console.info(`Project root: ${options.projectRoot}`)
|
|
194
|
+
|
|
195
|
+
process.chdir(options.projectRoot)
|
|
196
|
+
await ensureIOSFolder()
|
|
197
|
+
|
|
198
|
+
const exitCode = await withMetro('ios', async () => {
|
|
199
|
+
return runDetoxTests({
|
|
200
|
+
config,
|
|
201
|
+
projectRoot: options.projectRoot,
|
|
202
|
+
recordLogs: options.recordLogs,
|
|
203
|
+
retries: options.retries,
|
|
204
|
+
})
|
|
205
|
+
})
|
|
206
|
+
process.exit(exitCode)
|
|
207
|
+
} else if (platform === 'android') {
|
|
208
|
+
// Ensure Android dependencies
|
|
209
|
+
await ensureAndroidDeps()
|
|
210
|
+
|
|
211
|
+
const config = options.config || 'android.emu.debug'
|
|
212
|
+
console.info('=== Android Detox Test Runner ===')
|
|
213
|
+
console.info(`Config: ${config}`)
|
|
214
|
+
console.info(`Project root: ${options.projectRoot}`)
|
|
215
|
+
console.info(`Headless: ${options.headless}`)
|
|
216
|
+
|
|
217
|
+
process.chdir(options.projectRoot)
|
|
218
|
+
await ensureAndroidFolder()
|
|
219
|
+
|
|
220
|
+
// Setup Android device (wait for emulator, ADB reverse)
|
|
221
|
+
await setupAndroidDevice()
|
|
222
|
+
|
|
223
|
+
const exitCode = await withMetro('android', async () => {
|
|
224
|
+
return runDetoxTests({
|
|
225
|
+
config,
|
|
226
|
+
projectRoot: options.projectRoot,
|
|
227
|
+
recordLogs: options.recordLogs,
|
|
228
|
+
retries: options.retries,
|
|
229
|
+
headless: options.headless,
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
process.exit(exitCode)
|
|
233
|
+
} else if (platform === 'maestro') {
|
|
234
|
+
// Ensure Maestro is installed
|
|
235
|
+
await ensureMaestro()
|
|
236
|
+
|
|
237
|
+
const flow = args[0] || ''
|
|
238
|
+
console.info('=== Maestro Test Runner ===')
|
|
239
|
+
console.info(`Flow: ${flow || 'all'}`)
|
|
240
|
+
console.info(`Project root: ${options.projectRoot}`)
|
|
241
|
+
|
|
242
|
+
process.chdir(options.projectRoot)
|
|
243
|
+
|
|
244
|
+
// Run Maestro with Metro for development builds
|
|
245
|
+
const exitCode = await withMetro('ios', async () => {
|
|
246
|
+
const { $ } = await import('bun')
|
|
247
|
+
// Flows are at ./flows/ in kitchen-sink, not .maestro/flows/
|
|
248
|
+
const flowArg = flow ? `./flows/${flow}` : './flows'
|
|
249
|
+
const result = await $`maestro test ${flowArg} --exclude-tags=util --no-ansi`.nothrow()
|
|
250
|
+
return result.exitCode
|
|
251
|
+
})
|
|
252
|
+
process.exit(exitCode)
|
|
253
|
+
} else if (platform === 'all') {
|
|
254
|
+
console.info('=== Running All Native Tests ===\n')
|
|
255
|
+
|
|
256
|
+
// Run iOS tests
|
|
257
|
+
await ensureIosDeps()
|
|
258
|
+
console.info('\n--- iOS Tests ---\n')
|
|
259
|
+
process.chdir(options.projectRoot)
|
|
260
|
+
await ensureIOSFolder()
|
|
261
|
+
|
|
262
|
+
let iosExit = 0
|
|
263
|
+
try {
|
|
264
|
+
iosExit = await withMetro('ios', async () => {
|
|
265
|
+
return runDetoxTests({
|
|
266
|
+
config: options.config || 'ios.sim.debug',
|
|
267
|
+
projectRoot: options.projectRoot,
|
|
268
|
+
recordLogs: options.recordLogs,
|
|
269
|
+
retries: options.retries,
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('iOS tests failed:', err)
|
|
274
|
+
iosExit = 1
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Run Android tests
|
|
278
|
+
await ensureAndroidDeps()
|
|
279
|
+
console.info('\n--- Android Tests ---\n')
|
|
280
|
+
await ensureAndroidFolder()
|
|
281
|
+
await setupAndroidDevice()
|
|
282
|
+
|
|
283
|
+
let androidExit = 0
|
|
284
|
+
try {
|
|
285
|
+
androidExit = await withMetro('android', async () => {
|
|
286
|
+
return runDetoxTests({
|
|
287
|
+
config: options.config || 'android.emu.debug',
|
|
288
|
+
projectRoot: options.projectRoot,
|
|
289
|
+
recordLogs: options.recordLogs,
|
|
290
|
+
retries: options.retries,
|
|
291
|
+
headless: options.headless,
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
} catch (err) {
|
|
295
|
+
console.error('Android tests failed:', err)
|
|
296
|
+
androidExit = 1
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const success = iosExit === 0 && androidExit === 0
|
|
300
|
+
console.info(`\n=== Test Results ===`)
|
|
301
|
+
console.info(`iOS: ${iosExit === 0 ? 'PASSED' : 'FAILED'}`)
|
|
302
|
+
console.info(`Android: ${androidExit === 0 ? 'PASSED' : 'FAILED'}`)
|
|
303
|
+
process.exit(success ? 0 : 1)
|
|
304
|
+
} else {
|
|
305
|
+
console.error(`Unknown test platform: ${platform}`)
|
|
306
|
+
console.info('Usage: native-ci test [ios|android|maestro|all]')
|
|
307
|
+
process.exit(1)
|
|
308
|
+
}
|
|
309
|
+
break
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ========================================
|
|
313
|
+
// Dependency Commands
|
|
314
|
+
// ========================================
|
|
315
|
+
case 'deps': {
|
|
316
|
+
if (!subcommand || subcommand === 'status') {
|
|
317
|
+
printDepsStatus()
|
|
318
|
+
} else if (subcommand === 'install') {
|
|
319
|
+
console.info('Installing all dependencies...\n')
|
|
320
|
+
await ensureIosDeps()
|
|
321
|
+
await ensureMaestro()
|
|
322
|
+
console.info('\nAll dependencies installed!')
|
|
323
|
+
} else if (subcommand === 'install-ios') {
|
|
324
|
+
await ensureIosDeps()
|
|
325
|
+
} else if (subcommand === 'install-android') {
|
|
326
|
+
await ensureAndroidDeps()
|
|
327
|
+
} else if (subcommand === 'install-maestro') {
|
|
328
|
+
await ensureMaestro()
|
|
329
|
+
} else {
|
|
330
|
+
console.error(`Unknown deps subcommand: ${subcommand}`)
|
|
331
|
+
process.exit(1)
|
|
332
|
+
}
|
|
333
|
+
break
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ========================================
|
|
337
|
+
// Fingerprint Commands
|
|
338
|
+
// ========================================
|
|
339
|
+
case 'fingerprint': {
|
|
340
|
+
const platform = validatePlatform(subcommand)
|
|
341
|
+
|
|
342
|
+
const result = await generateFingerprint({
|
|
343
|
+
platform,
|
|
344
|
+
projectRoot: options.projectRoot,
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
if (options.githubOutput || isGitHubActions()) {
|
|
348
|
+
setGitHubOutput('fingerprint', result.hash)
|
|
349
|
+
setGitHubOutput(
|
|
350
|
+
'cache-key',
|
|
351
|
+
createCacheKey({
|
|
352
|
+
platform,
|
|
353
|
+
fingerprint: result.hash,
|
|
354
|
+
prefix: options.prefix,
|
|
355
|
+
})
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (options.json) {
|
|
360
|
+
console.info(JSON.stringify(result, null, 2))
|
|
361
|
+
} else {
|
|
362
|
+
console.info(result.hash)
|
|
363
|
+
}
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
case 'fingerprint-test': {
|
|
368
|
+
const CACHE_FILE = '.fingerprint-cache.json'
|
|
369
|
+
|
|
370
|
+
console.info('Generating fingerprints...\n')
|
|
371
|
+
|
|
372
|
+
const iosResult = await generateFingerprint({ platform: 'ios', projectRoot: options.projectRoot })
|
|
373
|
+
const androidResult = await generateFingerprint({ platform: 'android', projectRoot: options.projectRoot })
|
|
374
|
+
|
|
375
|
+
const iosFingerprint = iosResult.hash
|
|
376
|
+
const androidFingerprint = androidResult.hash
|
|
377
|
+
|
|
378
|
+
console.info('Current fingerprints:')
|
|
379
|
+
console.info(` iOS: ${iosFingerprint}`)
|
|
380
|
+
console.info(` Android: ${androidFingerprint}`)
|
|
381
|
+
console.info('')
|
|
382
|
+
|
|
383
|
+
const cache = loadCache(CACHE_FILE)
|
|
384
|
+
|
|
385
|
+
if (cache?.ios && cache?.android) {
|
|
386
|
+
console.info('Previous fingerprints (from cache):')
|
|
387
|
+
console.info(` iOS: ${cache.ios}`)
|
|
388
|
+
console.info(` Android: ${cache.android}`)
|
|
389
|
+
console.info('')
|
|
390
|
+
|
|
391
|
+
const iosChanged = cache.ios !== iosFingerprint
|
|
392
|
+
const androidChanged = cache.android !== androidFingerprint
|
|
393
|
+
|
|
394
|
+
if (iosChanged || androidChanged) {
|
|
395
|
+
console.info('Fingerprints changed!')
|
|
396
|
+
if (iosChanged) console.info(' - iOS fingerprint changed (would trigger iOS rebuild)')
|
|
397
|
+
if (androidChanged) console.info(' - Android fingerprint changed (would trigger Android rebuild)')
|
|
398
|
+
} else {
|
|
399
|
+
console.info('Fingerprints match - no rebuild needed')
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
console.info('No previous fingerprints cached.')
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Save current fingerprints
|
|
406
|
+
saveCache(CACHE_FILE, { ios: iosFingerprint, android: androidFingerprint, timestamp: new Date().toISOString() })
|
|
407
|
+
console.info(`\nSaved fingerprints to ${CACHE_FILE}`)
|
|
408
|
+
|
|
409
|
+
console.info(`
|
|
410
|
+
To test cache invalidation:
|
|
411
|
+
1. Add a native dependency: yarn add react-native-mmkv
|
|
412
|
+
2. Run this script again: native-ci fingerprint-test
|
|
413
|
+
3. Fingerprints should change!
|
|
414
|
+
4. Remove the dependency: yarn remove react-native-mmkv
|
|
415
|
+
5. Run this script again - fingerprints should match original
|
|
416
|
+
`)
|
|
417
|
+
break
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
case 'pre-hash': {
|
|
421
|
+
const files = [subcommand, ...args].filter(Boolean)
|
|
422
|
+
if (files.length === 0) {
|
|
423
|
+
console.error('Error: at least one file required')
|
|
424
|
+
process.exit(1)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const hash = generatePreFingerprintHash(files, options.projectRoot)
|
|
428
|
+
|
|
429
|
+
if (options.githubOutput || isGitHubActions()) {
|
|
430
|
+
setGitHubOutput('pre-fingerprint-hash', hash)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (options.json) {
|
|
434
|
+
console.info(JSON.stringify({ hash, files }, null, 2))
|
|
435
|
+
} else {
|
|
436
|
+
console.info(hash)
|
|
437
|
+
}
|
|
438
|
+
break
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
case 'cache-key': {
|
|
442
|
+
const platform = validatePlatform(subcommand)
|
|
443
|
+
const fingerprint = args[0]
|
|
444
|
+
|
|
445
|
+
if (!fingerprint) {
|
|
446
|
+
console.error('Error: fingerprint is required')
|
|
447
|
+
process.exit(1)
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const cacheKey = createCacheKey({
|
|
451
|
+
platform,
|
|
452
|
+
fingerprint,
|
|
453
|
+
prefix: options.prefix,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
if (options.githubOutput || isGitHubActions()) {
|
|
457
|
+
setGitHubOutput('cache-key', cacheKey)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
console.info(cacheKey)
|
|
461
|
+
break
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ========================================
|
|
465
|
+
// KV Store Commands
|
|
466
|
+
// ========================================
|
|
467
|
+
case 'kv-get': {
|
|
468
|
+
const key = subcommand
|
|
469
|
+
if (!key) {
|
|
470
|
+
console.error('Error: key is required')
|
|
471
|
+
process.exit(1)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const kv = getKVCredentials()
|
|
475
|
+
const value = await getFingerprintFromKV(kv, key)
|
|
476
|
+
|
|
477
|
+
if (options.githubOutput || isGitHubActions()) {
|
|
478
|
+
setGitHubOutput('value', value || '')
|
|
479
|
+
setGitHubOutput('found', value ? 'true' : 'false')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (value) {
|
|
483
|
+
console.info(value)
|
|
484
|
+
} else {
|
|
485
|
+
process.exit(1)
|
|
486
|
+
}
|
|
487
|
+
break
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
case 'kv-set': {
|
|
491
|
+
const key = subcommand
|
|
492
|
+
const value = args[0]
|
|
493
|
+
|
|
494
|
+
if (!key || !value) {
|
|
495
|
+
console.error('Error: key and value are required')
|
|
496
|
+
process.exit(1)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const kv = getKVCredentials()
|
|
500
|
+
await saveFingerprintToKV(kv, key, value)
|
|
501
|
+
console.info('OK')
|
|
502
|
+
break
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
default:
|
|
506
|
+
console.error(`Unknown command: ${command}`)
|
|
507
|
+
console.info(HELP)
|
|
508
|
+
process.exit(1)
|
|
509
|
+
}
|
|
510
|
+
} catch (error) {
|
|
511
|
+
console.error('Error:', (error as Error).message)
|
|
512
|
+
process.exit(1)
|
|
513
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constants for @tamagui/native-ci
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Metro bundler configuration
|
|
6
|
+
export const METRO_HOST = '127.0.0.1'
|
|
7
|
+
export const METRO_PORT = 8081
|
|
8
|
+
export const METRO_URL = `http://${METRO_HOST}:${METRO_PORT}`
|
|
9
|
+
|
|
10
|
+
// Detox server port (used for test communication)
|
|
11
|
+
export const DETOX_SERVER_PORT = 8099
|
|
12
|
+
|
|
13
|
+
// Default timeouts and intervals
|
|
14
|
+
export const DEFAULT_METRO_WAIT_ATTEMPTS = 60
|
|
15
|
+
export const DEFAULT_METRO_WAIT_INTERVAL_MS = 2000
|
|
16
|
+
export const DEFAULT_METRO_TIMEOUT_MS =
|
|
17
|
+
DEFAULT_METRO_WAIT_ATTEMPTS * DEFAULT_METRO_WAIT_INTERVAL_MS // 120s
|
|
18
|
+
|
|
19
|
+
// KV cache configuration
|
|
20
|
+
export const DEFAULT_KV_TTL_SECONDS = 2592000 // 30 days
|
|
21
|
+
|
|
22
|
+
// Supported platforms
|
|
23
|
+
export type Platform = 'ios' | 'android'
|
|
24
|
+
|
|
25
|
+
// Expo manifest response type
|
|
26
|
+
export interface ExpoManifest {
|
|
27
|
+
launchAsset?: {
|
|
28
|
+
url?: string
|
|
29
|
+
}
|
|
30
|
+
}
|