spawndroid 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +107 -2
- package/package.json +36 -4
- package/src/api.mjs +130 -0
- package/src/check.mjs +93 -0
- package/src/config.mjs +411 -0
- package/src/init.mjs +105 -0
- package/src/run.mjs +361 -0
- package/src/spawndroid.mjs +315 -0
package/src/config.mjs
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spawndroid.yaml` — parse it strictly, and detect enough of a project to
|
|
3
|
+
* write one without a questionnaire.
|
|
4
|
+
*
|
|
5
|
+
* Strict means unknown keys fail: a typo in CI config is silently expensive,
|
|
6
|
+
* so `timeout` misspelled as `timout` must not fall back to the default.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, readdirSync, statSync } from "node:fs"
|
|
10
|
+
import { basename, join, relative } from "node:path"
|
|
11
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml"
|
|
12
|
+
|
|
13
|
+
export const SPEC_FILE = "spawndroid.yaml"
|
|
14
|
+
export const DEVICE_ROOT = "/sdcard"
|
|
15
|
+
export const DEVICE_FILE_MAX_BYTES = 32 * 1024 * 1024
|
|
16
|
+
export const DEFAULT_TIMEOUT_SECONDS = 600
|
|
17
|
+
|
|
18
|
+
const SPEC_KEYS = [
|
|
19
|
+
"version",
|
|
20
|
+
"app",
|
|
21
|
+
"profile",
|
|
22
|
+
"command",
|
|
23
|
+
"fixtures",
|
|
24
|
+
"report",
|
|
25
|
+
"timeout",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
export class SpecError extends Error {
|
|
29
|
+
constructor(problems) {
|
|
30
|
+
super(problems.join("\n"))
|
|
31
|
+
this.name = "SpecError"
|
|
32
|
+
this.problems = problems
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `/sdcard` or a directory under it. Mirrors the console's device-path rule. */
|
|
37
|
+
export function devicePath(input) {
|
|
38
|
+
if (typeof input !== "string") return null
|
|
39
|
+
if (input.includes("\0") || input.includes("\\")) return null
|
|
40
|
+
const trimmed = input.trim()
|
|
41
|
+
if (!trimmed.startsWith("/")) return null
|
|
42
|
+
const body = trimmed.replace(/\/+/g, "/").replace(/\/$/, "") || "/"
|
|
43
|
+
if (body !== DEVICE_ROOT && !body.startsWith(`${DEVICE_ROOT}/`)) return null
|
|
44
|
+
for (const segment of body.split("/").slice(1)) {
|
|
45
|
+
if (!segment || segment === "." || segment === "..") return null
|
|
46
|
+
}
|
|
47
|
+
return body
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A string is split on whitespace for convenience; a list is exact argv.
|
|
52
|
+
* Commands with quoting or shell syntax belong in the list form, e.g.
|
|
53
|
+
* `command: [bash, -c, "mvn test && cp report.xml ."]`.
|
|
54
|
+
*/
|
|
55
|
+
function parseCommand(value, problems) {
|
|
56
|
+
if (Array.isArray(value)) {
|
|
57
|
+
if (value.length === 0) {
|
|
58
|
+
problems.push(`"command" must not be empty`)
|
|
59
|
+
return []
|
|
60
|
+
}
|
|
61
|
+
const argv = []
|
|
62
|
+
for (const item of value) {
|
|
63
|
+
if (typeof item !== "string" || item.trim() === "") {
|
|
64
|
+
problems.push(`"command" must contain only non-empty strings`)
|
|
65
|
+
return []
|
|
66
|
+
}
|
|
67
|
+
argv.push(item)
|
|
68
|
+
}
|
|
69
|
+
return argv
|
|
70
|
+
}
|
|
71
|
+
if (typeof value === "string") {
|
|
72
|
+
const argv = value.trim().split(/\s+/).filter(Boolean)
|
|
73
|
+
if (argv.length === 0) problems.push(`"command" must not be empty`)
|
|
74
|
+
return argv
|
|
75
|
+
}
|
|
76
|
+
problems.push(`"command" must be a string or a list of strings`)
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseFixtures(value, problems) {
|
|
81
|
+
if (value == null) return []
|
|
82
|
+
if (!Array.isArray(value)) {
|
|
83
|
+
problems.push(`"fixtures" must be a list`)
|
|
84
|
+
return []
|
|
85
|
+
}
|
|
86
|
+
const fixtures = []
|
|
87
|
+
for (const item of value) {
|
|
88
|
+
if (typeof item === "string") {
|
|
89
|
+
fixtures.push({ local: item, device: `${DEVICE_ROOT}/Download` })
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
|
93
|
+
problems.push(`each fixture must be a path or { local, device }`)
|
|
94
|
+
continue
|
|
95
|
+
}
|
|
96
|
+
const local = typeof item.local === "string" ? item.local.trim() : ""
|
|
97
|
+
const device = devicePath(item.device)
|
|
98
|
+
if (!local) problems.push(`a fixture is missing "local"`)
|
|
99
|
+
if (!device) {
|
|
100
|
+
problems.push(
|
|
101
|
+
`fixture "${local || "?"}" needs "device": an absolute directory under ${DEVICE_ROOT}`
|
|
102
|
+
)
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
if (local) fixtures.push({ local, device })
|
|
106
|
+
}
|
|
107
|
+
return fixtures
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Parse spec text into a normalized spec. Throws SpecError listing every
|
|
112
|
+
* problem at once, so one run of `check` can name them all.
|
|
113
|
+
*/
|
|
114
|
+
export function parseSpec(text, { file = SPEC_FILE } = {}) {
|
|
115
|
+
let doc
|
|
116
|
+
try {
|
|
117
|
+
doc = parseYaml(text)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
throw new SpecError([`${file}: ${error.message}`])
|
|
120
|
+
}
|
|
121
|
+
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
|
|
122
|
+
throw new SpecError([`${file}: expected a mapping with at least "version" and "app"`])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const problems = []
|
|
126
|
+
for (const key of Object.keys(doc)) {
|
|
127
|
+
if (!SPEC_KEYS.includes(key)) {
|
|
128
|
+
problems.push(`unknown key "${key}" (allowed: ${SPEC_KEYS.join(", ")})`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (doc.version !== 1) problems.push(`"version" must be 1`)
|
|
132
|
+
|
|
133
|
+
const app = typeof doc.app === "string" ? doc.app.trim() : ""
|
|
134
|
+
if (!app) problems.push(`"app" is required — an APK path or an uploaded app slug`)
|
|
135
|
+
|
|
136
|
+
const profile =
|
|
137
|
+
doc.profile == null
|
|
138
|
+
? null
|
|
139
|
+
: typeof doc.profile === "string" && doc.profile.trim()
|
|
140
|
+
? doc.profile.trim()
|
|
141
|
+
: (problems.push(`"profile" must be a non-empty string`), null)
|
|
142
|
+
|
|
143
|
+
const command = parseCommand(doc.command, problems)
|
|
144
|
+
const fixtures = parseFixtures(doc.fixtures, problems)
|
|
145
|
+
|
|
146
|
+
const report =
|
|
147
|
+
doc.report == null
|
|
148
|
+
? null
|
|
149
|
+
: typeof doc.report === "string" && doc.report.trim()
|
|
150
|
+
? doc.report.trim()
|
|
151
|
+
: (problems.push(`"report" must be a path to a JUnit XML file`), null)
|
|
152
|
+
|
|
153
|
+
let timeout = DEFAULT_TIMEOUT_SECONDS
|
|
154
|
+
if (doc.timeout != null) {
|
|
155
|
+
if (!Number.isInteger(doc.timeout) || doc.timeout <= 0 || doc.timeout > 3600) {
|
|
156
|
+
problems.push(`"timeout" must be whole seconds between 1 and 3600`)
|
|
157
|
+
} else {
|
|
158
|
+
timeout = doc.timeout
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (problems.length) throw new SpecError(problems)
|
|
163
|
+
return { version: 1, app, profile, command, fixtures, report, timeout }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function loadSpec(path) {
|
|
167
|
+
let text
|
|
168
|
+
try {
|
|
169
|
+
text = readFileSync(path, "utf8")
|
|
170
|
+
} catch {
|
|
171
|
+
throw new SpecError([
|
|
172
|
+
`no ${basename(path)} found — run \`spawndroid init\` to create one`,
|
|
173
|
+
])
|
|
174
|
+
}
|
|
175
|
+
return parseSpec(text, { file: basename(path) })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Render a spec object to YAML text with a stable key order. */
|
|
179
|
+
export function specText(spec) {
|
|
180
|
+
const doc = { version: 1, app: spec.app }
|
|
181
|
+
if (spec.profile) doc.profile = spec.profile
|
|
182
|
+
doc.command = spec.command.length === 1 ? spec.command[0] : spec.command
|
|
183
|
+
if (spec.report) doc.report = spec.report
|
|
184
|
+
if (spec.timeout !== DEFAULT_TIMEOUT_SECONDS) doc.timeout = spec.timeout
|
|
185
|
+
if (spec.fixtures.length) doc.fixtures = spec.fixtures
|
|
186
|
+
return stringifyYaml(doc, { lineWidth: 0 })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/* ------------------------------------------------------------------ */
|
|
190
|
+
/* Project detection */
|
|
191
|
+
/* ------------------------------------------------------------------ */
|
|
192
|
+
|
|
193
|
+
const file = (path) => {
|
|
194
|
+
try {
|
|
195
|
+
return statSync(path).isFile()
|
|
196
|
+
} catch {
|
|
197
|
+
return false
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const read = (path) => {
|
|
201
|
+
try {
|
|
202
|
+
return readFileSync(path, "utf8")
|
|
203
|
+
} catch {
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const list = (path) => {
|
|
208
|
+
try {
|
|
209
|
+
return readdirSync(path)
|
|
210
|
+
} catch {
|
|
211
|
+
return []
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `<module>/build/outputs/apk/debug/*.apk` for the project root and its children. */
|
|
216
|
+
function findBuiltApk(dir) {
|
|
217
|
+
const bases = [dir, ...list(dir).filter((entry) => !entry.startsWith(".")).map((entry) => join(dir, entry))]
|
|
218
|
+
for (const base of bases) {
|
|
219
|
+
const debugDir = join(base, "build", "outputs", "apk", "debug")
|
|
220
|
+
for (const name of list(debugDir)) {
|
|
221
|
+
if (name.endsWith(".apk")) return join(debugDir, name)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Gradle application module from settings.gradle(.kts) include() lines. */
|
|
228
|
+
function gradleAppModule(dir) {
|
|
229
|
+
const settings = read(join(dir, "settings.gradle.kts")) ?? read(join(dir, "settings.gradle"))
|
|
230
|
+
if (!settings) return null
|
|
231
|
+
const modules = [...settings.matchAll(/include\(\s*["']([^"']+)["']\s*\)/g)]
|
|
232
|
+
.map((match) => match[1].replace(/^:/, ""))
|
|
233
|
+
for (const modulePath of modules) {
|
|
234
|
+
const name = modulePath.split(":").pop()
|
|
235
|
+
for (const buildFile of ["build.gradle.kts", "build.gradle"]) {
|
|
236
|
+
if ((read(join(dir, modulePath, buildFile)) ?? "").includes("com.android.application")) {
|
|
237
|
+
return { module: modulePath, name }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function detectApp(dir) {
|
|
245
|
+
const built = findBuiltApk(dir)
|
|
246
|
+
if (built) return { app: relative(dir, built), note: null }
|
|
247
|
+
const gradle = gradleAppModule(dir)
|
|
248
|
+
if (gradle) {
|
|
249
|
+
return {
|
|
250
|
+
app: join(gradle.module, "build", "outputs", "apk", "debug", `${gradle.name}-debug.apk`),
|
|
251
|
+
note: `expected APK for Gradle module "${gradle.module}" — build it before the first run`,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { app: null, note: null }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function detectStack(dir) {
|
|
258
|
+
const packageJson = read(join(dir, "package.json"))
|
|
259
|
+
if (packageJson) {
|
|
260
|
+
try {
|
|
261
|
+
const pkg = JSON.parse(packageJson)
|
|
262
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
|
|
263
|
+
const conf = ["wdio.conf.mjs", "wdio.conf.js", "wdio.conf.ts", "wdio.conf.cjs"].find(
|
|
264
|
+
(name) => file(join(dir, name))
|
|
265
|
+
)
|
|
266
|
+
if (deps.webdriverio || conf) {
|
|
267
|
+
return { stack: "node", command: ["npx", "wdio", "run", conf ?? "wdio.conf.js"] }
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
/* malformed package.json is the user's build's problem, not init's */
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (file(join(dir, "pom.xml"))) {
|
|
274
|
+
return { stack: "maven", command: ["mvn", "test"] }
|
|
275
|
+
}
|
|
276
|
+
const requirements = read(join(dir, "requirements.txt")) ?? ""
|
|
277
|
+
if (/appium/i.test(requirements) || file(join(dir, "pytest.ini"))) {
|
|
278
|
+
return { stack: "python", command: ["pytest", "-q"] }
|
|
279
|
+
}
|
|
280
|
+
const gradle = read(join(dir, "settings.gradle.kts")) ?? read(join(dir, "settings.gradle"))
|
|
281
|
+
if (gradle) {
|
|
282
|
+
return { stack: "gradle", command: ["./gradlew", "test"] }
|
|
283
|
+
}
|
|
284
|
+
return { stack: null, command: null }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function detectReport(dir, stack, command) {
|
|
288
|
+
if (stack === "maven") {
|
|
289
|
+
const reports = list(join(dir, "target", "surefire-reports")).filter((name) =>
|
|
290
|
+
name.endsWith(".xml")
|
|
291
|
+
)
|
|
292
|
+
if (reports.length === 1) return { report: join("target", "surefire-reports", reports[0]), note: null }
|
|
293
|
+
if (reports.length > 1) {
|
|
294
|
+
return {
|
|
295
|
+
report: null,
|
|
296
|
+
note: `${reports.length} JUnit files in target/surefire-reports — set "report" to one of them`,
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (stack === "node" && file(join(dir, "reports", "junit.xml"))) {
|
|
301
|
+
return { report: join("reports", "junit.xml"), note: null }
|
|
302
|
+
}
|
|
303
|
+
if (stack === "python") {
|
|
304
|
+
return {
|
|
305
|
+
report: null,
|
|
306
|
+
note: "add --junitxml=junit.xml to the pytest command, then set \"report\": junit.xml",
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (stack === "gradle") {
|
|
310
|
+
return {
|
|
311
|
+
report: null,
|
|
312
|
+
note: "Gradle writes build/test-results/test — pick one TEST-*.xml and set \"report\"",
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
void command
|
|
316
|
+
return { report: null, note: null }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Setup steps the generated workflow needs before running the tests. */
|
|
320
|
+
function setupSteps(dir, stack) {
|
|
321
|
+
if (stack === "maven" || stack === "gradle") {
|
|
322
|
+
return [
|
|
323
|
+
{
|
|
324
|
+
uses: "actions/setup-java@v4",
|
|
325
|
+
with: { "distribution": "temurin", "java-version": "17" },
|
|
326
|
+
},
|
|
327
|
+
]
|
|
328
|
+
}
|
|
329
|
+
if (stack === "python") {
|
|
330
|
+
return [
|
|
331
|
+
{ uses: "actions/setup-python@v5", with: { "python-version": "3.12" } },
|
|
332
|
+
{ name: "Install dependencies", run: "pip install -r requirements.txt" },
|
|
333
|
+
]
|
|
334
|
+
}
|
|
335
|
+
if (stack === "node") {
|
|
336
|
+
const steps = [
|
|
337
|
+
{ uses: "actions/setup-node@v4", with: { "node-version": "22" } },
|
|
338
|
+
]
|
|
339
|
+
if (file(join(dir, "pnpm-lock.yaml"))) {
|
|
340
|
+
steps.push({ uses: "pnpm/action-setup@v4" })
|
|
341
|
+
steps.push({ name: "Install dependencies", run: "pnpm install --frozen-lockfile" })
|
|
342
|
+
} else if (file(join(dir, "yarn.lock"))) {
|
|
343
|
+
steps.push({ name: "Install dependencies", run: "yarn install --frozen-lockfile" })
|
|
344
|
+
} else {
|
|
345
|
+
steps.push({ name: "Install dependencies", run: "npm ci" })
|
|
346
|
+
}
|
|
347
|
+
return steps
|
|
348
|
+
}
|
|
349
|
+
return []
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Inspect a project to pre-fill the spec. Everything it returns is a guess;
|
|
354
|
+
* `init` prints each value before writing so the guess is visible.
|
|
355
|
+
*/
|
|
356
|
+
export function detectProject(dir) {
|
|
357
|
+
const app = detectApp(dir)
|
|
358
|
+
const { stack, command } = detectStack(dir)
|
|
359
|
+
const report = detectReport(dir, stack, command)
|
|
360
|
+
return {
|
|
361
|
+
stack,
|
|
362
|
+
app: app.app,
|
|
363
|
+
appNote: app.note,
|
|
364
|
+
command,
|
|
365
|
+
report: report.report,
|
|
366
|
+
reportNote: report.note,
|
|
367
|
+
setup: setupSteps(dir, stack),
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* ------------------------------------------------------------------ */
|
|
372
|
+
/* Workflow generation */
|
|
373
|
+
/* ------------------------------------------------------------------ */
|
|
374
|
+
|
|
375
|
+
function stepLines(step) {
|
|
376
|
+
if (step.uses) {
|
|
377
|
+
const lines = [` - uses: ${step.uses}`]
|
|
378
|
+
if (step.with) {
|
|
379
|
+
lines.push(" with:")
|
|
380
|
+
for (const [key, value] of Object.entries(step.with)) {
|
|
381
|
+
lines.push(` ${key}: ${value}`)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return lines
|
|
385
|
+
}
|
|
386
|
+
return [` - name: ${step.name}`, ` run: ${step.run}`]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function workflowText({ setup, version, specFile = SPEC_FILE }) {
|
|
390
|
+
const lines = [
|
|
391
|
+
"name: spawndroid",
|
|
392
|
+
"on:",
|
|
393
|
+
" pull_request:",
|
|
394
|
+
"",
|
|
395
|
+
"jobs:",
|
|
396
|
+
" test:",
|
|
397
|
+
" runs-on: ubuntu-latest",
|
|
398
|
+
" timeout-minutes: 30",
|
|
399
|
+
" steps:",
|
|
400
|
+
" - uses: actions/checkout@v4",
|
|
401
|
+
]
|
|
402
|
+
for (const step of setup ?? []) lines.push(...stepLines(step))
|
|
403
|
+
lines.push(
|
|
404
|
+
" - name: Run Android tests",
|
|
405
|
+
` run: npx --yes spawndroid@${version} run --config ${specFile}`,
|
|
406
|
+
" env:",
|
|
407
|
+
" SPAWNDROID_API_KEY: ${{ secrets.SPAWNDROID_API_KEY }}",
|
|
408
|
+
""
|
|
409
|
+
)
|
|
410
|
+
return lines.join("\n")
|
|
411
|
+
}
|
package/src/init.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spawndroid init` — detect the project, ask only for the gaps, write the
|
|
3
|
+
* spec and a workflow. Detection first, questionnaire never.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import { createInterface } from "node:readline/promises"
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
12
|
+
SpecError,
|
|
13
|
+
detectProject,
|
|
14
|
+
specText,
|
|
15
|
+
workflowText,
|
|
16
|
+
} from "./config.mjs"
|
|
17
|
+
|
|
18
|
+
const WORKFLOW_PATH = join(".github", "workflows", "spawndroid.yml")
|
|
19
|
+
|
|
20
|
+
function splitCommand(value) {
|
|
21
|
+
return value.trim().split(/\s+/).filter(Boolean)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeAsker(interactive) {
|
|
25
|
+
return async (question, fallback) => {
|
|
26
|
+
if (!interactive) return fallback ?? null
|
|
27
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr })
|
|
28
|
+
try {
|
|
29
|
+
const shown = fallback ? `${question} [${fallback}]: ` : `${question}: `
|
|
30
|
+
const answer = (await rl.question(shown)).trim()
|
|
31
|
+
return answer || fallback || null
|
|
32
|
+
} finally {
|
|
33
|
+
rl.close()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function runInit({
|
|
39
|
+
dir,
|
|
40
|
+
configPath,
|
|
41
|
+
flags,
|
|
42
|
+
version,
|
|
43
|
+
stdout = (...args) => console.log(...args),
|
|
44
|
+
stderr = (...args) => console.error(...args),
|
|
45
|
+
interactive = Boolean(process.stdin.isTTY),
|
|
46
|
+
}) {
|
|
47
|
+
const detection = detectProject(dir)
|
|
48
|
+
const ask = makeAsker(interactive)
|
|
49
|
+
|
|
50
|
+
const app =
|
|
51
|
+
flags.app ?? detection.app ?? (await ask("Path to the APK to upload", null))
|
|
52
|
+
const commandValue =
|
|
53
|
+
flags.command ?? (detection.command ? detection.command.join(" ") : null)
|
|
54
|
+
const command = commandValue ?? (await ask("Test command (e.g. mvn test)", null))
|
|
55
|
+
const report = flags.report ?? detection.report ?? ""
|
|
56
|
+
const profile = flags.profile ?? ""
|
|
57
|
+
const timeout = flags.timeout ? Number(flags.timeout) : DEFAULT_TIMEOUT_SECONDS
|
|
58
|
+
|
|
59
|
+
const problems = []
|
|
60
|
+
if (!app) problems.push("an APK path is required — pass --app <path>")
|
|
61
|
+
if (!command) problems.push('a test command is required — pass --command "mvn test"')
|
|
62
|
+
if (!Number.isInteger(timeout) || timeout <= 0) problems.push("--timeout must be whole seconds")
|
|
63
|
+
if (problems.length) throw new SpecError(problems)
|
|
64
|
+
|
|
65
|
+
const spec = {
|
|
66
|
+
version: 1,
|
|
67
|
+
app,
|
|
68
|
+
profile: profile || null,
|
|
69
|
+
command: splitCommand(command),
|
|
70
|
+
fixtures: [],
|
|
71
|
+
report: report || null,
|
|
72
|
+
timeout,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
stdout("spawndroid init")
|
|
76
|
+
stdout(` app ${app}${detection.appNote ? ` (${detection.appNote})` : ""}`)
|
|
77
|
+
stdout(` command ${spec.command.join(" ")}`)
|
|
78
|
+
stdout(` report ${spec.report ?? "none"}`)
|
|
79
|
+
stdout(` profile ${spec.profile ?? "default"}`)
|
|
80
|
+
if (detection.reportNote) stderr(`note: ${detection.reportNote}`)
|
|
81
|
+
|
|
82
|
+
if (existsSync(configPath) && !flags.force) {
|
|
83
|
+
throw new SpecError([`${configPath} already exists — pass --force to overwrite`])
|
|
84
|
+
}
|
|
85
|
+
writeFileSync(configPath, specText(spec))
|
|
86
|
+
stdout(`wrote ${configPath}`)
|
|
87
|
+
|
|
88
|
+
if (flags.workflow !== false) {
|
|
89
|
+
const workflowPath = join(dir, WORKFLOW_PATH)
|
|
90
|
+
if (existsSync(workflowPath) && !flags.force) {
|
|
91
|
+
stderr(`note: ${WORKFLOW_PATH} already exists — left untouched (use --force to rewrite)`)
|
|
92
|
+
} else {
|
|
93
|
+
mkdirSync(join(dir, ".github", "workflows"), { recursive: true })
|
|
94
|
+
writeFileSync(workflowPath, workflowText({ setup: detection.setup, version }))
|
|
95
|
+
stdout(`wrote ${WORKFLOW_PATH}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
stdout("")
|
|
100
|
+
stdout("Next:")
|
|
101
|
+
stdout(" export SPAWNDROID_API_KEY=… # API & CI/CD → API keys, upload scope")
|
|
102
|
+
stdout(" npx spawndroid check")
|
|
103
|
+
stdout(" npx spawndroid run")
|
|
104
|
+
return 0
|
|
105
|
+
}
|