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/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
|
-
#
|
|
1
|
+
# spawndroid
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|