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/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
+ }