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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Spawndroid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,108 @@
1
- # Temporary Holding Version
1
+ # spawndroid
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Run Android tests on hosted devices from any CI.
4
+
5
+ Upload a build, boot a real device, run your test command against it, and keep
6
+ the result along with the JUnit report, logcat and screen recording.
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ npx spawndroid init # detect the project, write the spec + workflow
12
+ export SPAWNDROID_API_KEY=spawn_live_… # API & CI/CD → API keys, upload scope
13
+ npx spawndroid check # name the walls before the first run
14
+ npx spawndroid run # upload → boot → test → report → teardown
15
+ ```
16
+
17
+ `init` reads the project (Gradle, Maven, WebdriverIO, pytest), prints what it
18
+ inferred, and writes `spawndroid.yaml` plus
19
+ `.github/workflows/spawndroid.yml`. It asks only for what it cannot infer.
20
+ Every flag is overridable for non-interactive setups:
21
+
22
+ ```bash
23
+ npx spawndroid init --app app/build/outputs/apk/debug/app-debug.apk \
24
+ --command "mvn test" --report target/surefire-reports/TEST-com.example.CheckoutTest.xml
25
+ ```
26
+
27
+ ## The spec
28
+
29
+ ```yaml
30
+ version: 1
31
+ app: app/build/outputs/apk/debug/app-debug.apk # APK path, or an uploaded app slug
32
+ command: [mvn, test] # string is split; list is exact argv
33
+ profile: android-14-phone # optional device profile
34
+ report: target/surefire-reports/TEST-com.example.CheckoutTest.xml
35
+ timeout: 600 # seconds to wait for the device
36
+ fixtures: # optional files pushed after readiness
37
+ - local: test/fixtures/sample.jpg
38
+ device: /sdcard/Pictures # absolute directory under /sdcard
39
+ ```
40
+
41
+ Unknown keys are errors — a misspelled `timeout` must not silently fall back
42
+ to the default.
43
+
44
+ ## Commands
45
+
46
+ ```bash
47
+ spawndroid init [--app <apk>] [--command <cmd>] [--report <xml>] [--profile <id>] [--force]
48
+ spawndroid check [--config <path>]
49
+ spawndroid run [--config <path>] [--profile <id>] [--timeout <s>] [-- <command…>]
50
+ spawndroid upload <app.apk> [--json]
51
+ spawndroid start --app <slug> [--profile <id>] [--json]
52
+ spawndroid wait <sessionId> [--timeout <s>] [--json]
53
+ spawndroid report <sessionId> --result passed|failed
54
+ ```
55
+
56
+ ## What `run` does
57
+
58
+ 1. Uploads the APK (or reuses the slug), creating a session with an
59
+ `Idempotency-Key`.
60
+ 2. Retries a *capacity* `429` up to 3 attempts over ~90 s; plan limits
61
+ (`PLAN_RUNNER_LIMIT`, `PLAN_DEVICE_MINUTES_EXHAUSTED`) fail immediately.
62
+ 3. Waits up to `timeout` for the device, then pushes fixtures.
63
+ 4. Runs `command` locally on the runner with the session in its environment.
64
+ 5. Posts the JUnit report, tears the device down, and prints the run URL.
65
+
66
+ Exported to the command:
67
+
68
+ | Variable | Meaning |
69
+ | ------------------------ | ------------------------- |
70
+ | `SPAWNDROID_WD_URL` | W3C WebDriver endpoint |
71
+ | `SPAWNDROID_UDID` | device serial |
72
+ | `SPAWNDROID_APP_PACKAGE` | installed app package |
73
+ | `SPAWNDROID_APP` | app slug |
74
+ | `SPAWNDROID_SESSION_ID` | session id |
75
+
76
+ The build is installed before your command runs — never set `appium:app`.
77
+
78
+ Exit codes: `0` tests passed, `1` tests failed, `2` infrastructure or setup
79
+ failed. CI retry logic can key off that instead of re-running real failures.
80
+
81
+ ## GitHub Actions
82
+
83
+ ```yaml
84
+ - uses: spawndroid/cli@v0.1.0
85
+ with:
86
+ api-key: ${{ secrets.SPAWNDROID_API_KEY }}
87
+ ```
88
+
89
+ The Action installs the CLI, runs `spawndroid run --config spawndroid.yaml`,
90
+ and exposes `session-id`, `run-url` and `result` as step outputs.
91
+
92
+ ## Limits
93
+
94
+ - Appium / WebDriver suites only: sessions expose a WebDriver endpoint, not adb.
95
+ - One APK per session; the APK is installed before the command starts.
96
+ - Per-run sessions. The stable hub is documented separately at
97
+ [docs.spawndroid.io](https://docs.spawndroid.io/docs/ci-tests).
98
+ - Hobby: 15-minute sessions, 7-day build expiry, live logcat only.
99
+
100
+ ## Examples
101
+
102
+ | Example | Language | Runner |
103
+ | ----------------------------------------------------- | ---------- | ------------------- |
104
+ | [webdriverio-android](./examples/webdriverio-android) | JavaScript | WebdriverIO + Mocha |
105
+ | [python-pytest](./examples/python-pytest) | Python | pytest |
106
+ | [java-testng](./examples/java-testng) | Java | TestNG + Maven |
107
+
108
+ MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "spawndroid",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Run Android tests on hosted devices from any CI.",
5
+ "type": "module",
6
+ "bin": {
7
+ "spawndroid": "src/spawndroid.mjs"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "scripts": {
16
+ "test": "node --test test/spawndroid.test.mjs test/config.test.mjs test/run.test.mjs test/check.test.mjs"
17
+ },
18
+ "license": "MIT",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/spawndroid/cli.git"
22
+ },
23
+ "homepage": "https://spawndroid.io",
24
+ "bugs": {
25
+ "url": "https://github.com/spawndroid/cli/issues"
26
+ },
27
+ "keywords": [
28
+ "android",
29
+ "appium",
30
+ "webdriver",
31
+ "emulator",
32
+ "testing",
33
+ "ci"
34
+ ],
35
+ "dependencies": {
36
+ "yaml": "^2.9.1"
37
+ }
38
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,130 @@
1
+ /**
2
+ * HTTP client for the Spawndroid console API.
3
+ *
4
+ * Thin on purpose: every method maps to one documented endpoint, and the only
5
+ * parsing it does is of problem+json error bodies so callers can branch on the
6
+ * machine-readable `code` instead of the message.
7
+ */
8
+
9
+ import { readFile } from "node:fs/promises"
10
+ import { basename } from "node:path"
11
+ import { randomUUID } from "node:crypto"
12
+
13
+ export class ApiError extends Error {
14
+ constructor(status, body, path) {
15
+ const detail =
16
+ (body && (body.detail || body.error || body.title)) || `HTTP ${status}`
17
+ super(`${status} ${detail}`)
18
+ this.name = "ApiError"
19
+ this.status = status
20
+ this.code = body?.code ?? null
21
+ this.legacyCode = body?.legacyCode ?? null
22
+ this.body = body ?? null
23
+ this.path = path
24
+ }
25
+ }
26
+
27
+ export function createApi({ baseUrl, apiKey, fetchImpl = globalThis.fetch }) {
28
+ const root = baseUrl.replace(/\/$/, "")
29
+
30
+ async function request(path, init = {}) {
31
+ const headers = { Accept: "application/json", ...(init.headers ?? {}) }
32
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`
33
+ const response = await fetchImpl(`${root}${path}`, { ...init, headers })
34
+ if (response.status === 204) return null
35
+ const body = await response.json().catch(() => null)
36
+ if (!response.ok) throw new ApiError(response.status, body, path)
37
+ return body
38
+ }
39
+
40
+ return {
41
+ baseUrl: root,
42
+
43
+ /**
44
+ * Validate the key without spending anything: an unknown session is a 404
45
+ * for a valid key and a 401/403 for a bad or under-scoped one.
46
+ */
47
+ async probe() {
48
+ try {
49
+ await request(`/api/v1/sessions/probe-${randomUUID()}`)
50
+ return { ok: true }
51
+ } catch (error) {
52
+ if (error instanceof ApiError && error.status === 404) return { ok: true }
53
+ if (
54
+ error instanceof ApiError &&
55
+ (error.status === 401 || error.status === 403)
56
+ ) {
57
+ return { ok: false, error }
58
+ }
59
+ throw error
60
+ }
61
+ },
62
+
63
+ async uploadApk(file) {
64
+ const bytes = await readFile(file)
65
+ const form = new FormData()
66
+ form.append("file", new Blob([bytes]), basename(file))
67
+ const body = await request("/api/apps", { method: "POST", body: form })
68
+ return body?.app
69
+ },
70
+
71
+ async createSession(fields, idempotencyKey) {
72
+ return request("/api/v1/sessions", {
73
+ method: "POST",
74
+ headers: {
75
+ "Content-Type": "application/json",
76
+ "Idempotency-Key": idempotencyKey,
77
+ },
78
+ body: JSON.stringify(fields),
79
+ })
80
+ },
81
+
82
+ async getSession(id) {
83
+ return request(`/api/v1/sessions/${encodeURIComponent(id)}`)
84
+ },
85
+
86
+ async endSession(id, result) {
87
+ return request(`/api/v1/sessions/${encodeURIComponent(id)}`, {
88
+ method: "DELETE",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify({ result }),
91
+ })
92
+ },
93
+
94
+ async presignFile(id, { filename, path, size }) {
95
+ return request(
96
+ `/api/v1/sessions/${encodeURIComponent(id)}/files/presign`,
97
+ {
98
+ method: "POST",
99
+ headers: { "Content-Type": "application/json" },
100
+ body: JSON.stringify({ filename, path, size }),
101
+ }
102
+ )
103
+ },
104
+
105
+ async putFile(url, bytes, contentType) {
106
+ const response = await fetchImpl(url, {
107
+ method: "PUT",
108
+ headers: { "Content-Type": contentType ?? "application/octet-stream" },
109
+ body: bytes,
110
+ })
111
+ if (!response.ok) throw new ApiError(response.status, null, "presigned PUT")
112
+ },
113
+
114
+ async commitFile(id, fields) {
115
+ return request(`/api/v1/sessions/${encodeURIComponent(id)}/files`, {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify(fields),
119
+ })
120
+ },
121
+
122
+ async postReport(id, xml) {
123
+ return request(`/api/v1/sessions/${encodeURIComponent(id)}/report`, {
124
+ method: "POST",
125
+ headers: { "Content-Type": "application/xml" },
126
+ body: xml,
127
+ })
128
+ },
129
+ }
130
+ }
package/src/check.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * `spawndroid check` — name every wall before the first run instead of
3
+ * returning a 403 halfway through CI.
4
+ */
5
+
6
+ import { accessSync, constants, statSync } from "node:fs"
7
+ import { resolve } from "node:path"
8
+ import { spawnSync } from "node:child_process"
9
+
10
+ import { DEVICE_FILE_MAX_BYTES } from "./config.mjs"
11
+
12
+ const HOST_BOOT_AND_INSTALL_SECONDS = 420
13
+
14
+ function isFile(path) {
15
+ try {
16
+ return statSync(path).isFile()
17
+ } catch {
18
+ return false
19
+ }
20
+ }
21
+
22
+ function isExecutableOnPath(command) {
23
+ if (command.includes("/")) {
24
+ try {
25
+ accessSync(command, constants.X_OK)
26
+ return true
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+ const probe = process.platform === "win32" ? "where" : "which"
32
+ return spawnSync(probe, [command], { stdio: "ignore", encoding: "utf8" }).status === 0
33
+ }
34
+
35
+ export async function collectChecks({ spec, specDir, api }) {
36
+ const problems = []
37
+ const warnings = []
38
+
39
+ try {
40
+ const probe = await api.probe()
41
+ if (!probe.ok) {
42
+ problems.push(
43
+ "the API key was rejected (401/403) — create a key with the upload scope in API & CI/CD → API keys"
44
+ )
45
+ }
46
+ } catch (error) {
47
+ problems.push(`could not reach ${api.baseUrl}: ${error.message}`)
48
+ }
49
+
50
+ const appPath = resolve(specDir, spec.app)
51
+ const looksLikePath = /\.apk$/i.test(spec.app) || spec.app.includes("/")
52
+ if (isFile(appPath)) {
53
+ if (!/\.apk$/i.test(appPath)) warnings.push(`${spec.app} does not end in .apk — only APKs install`)
54
+ } else if (looksLikePath) {
55
+ problems.push(`APK not found: ${appPath} — build the app before running tests`)
56
+ }
57
+
58
+ for (const fixture of spec.fixtures) {
59
+ const local = resolve(specDir, fixture.local)
60
+ if (!isFile(local)) {
61
+ problems.push(`fixture not found: ${fixture.local}`)
62
+ continue
63
+ }
64
+ const size = statSync(local).size
65
+ if (size > DEVICE_FILE_MAX_BYTES) {
66
+ problems.push(`fixture ${fixture.local} is larger than the 32 MB limit`)
67
+ }
68
+ }
69
+
70
+ if (spec.report && spec.report.includes("*")) {
71
+ problems.push(`"report" must be a single JUnit XML file, not a glob (${spec.report})`)
72
+ }
73
+
74
+ if (spec.timeout < HOST_BOOT_AND_INSTALL_SECONDS) {
75
+ warnings.push(
76
+ `timeout ${spec.timeout}s is below the host's ~${HOST_BOOT_AND_INSTALL_SECONDS}s boot + install ceiling`
77
+ )
78
+ }
79
+
80
+ if (!isExecutableOnPath(spec.command[0])) {
81
+ warnings.push(`test command "${spec.command[0]}" was not found on PATH`)
82
+ }
83
+
84
+ return { problems, warnings }
85
+ }
86
+
87
+ export function planNotes() {
88
+ return [
89
+ "limits: sessions stop at your plan's cap (Hobby: 15 minutes of device time);",
90
+ " Hobby builds expire 7 days after upload; Hobby logcat is live-only.",
91
+ " Logcat, video and JUnit history are kept for your plan's retention window.",
92
+ ]
93
+ }