feat: add android-fs5 CLI toolset
- fs5 entrypoint with status, push, pull, app, shell, logs commands - lib/adb.sh: ADB wrapper functions with idempotency and --force guards - lib/output.sh: human + JSON output formatting via Python - 33 bats tests (6 status, 9 transfer, 7 app, 5 shell, 6 logs) — all passing - Zero shellcheck warnings - .env-based config (device serial, log path) - Spec-Driven Development artifacts in specs/001-android-fs5-management/ Device: exone GmbH FS5 (RD51QE202392, Android 9)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/test_app.bats — Tests for 'fs5 app' commands (US3)
|
||||
|
||||
bats_require_minimum_version 1.5.0
|
||||
|
||||
SCRIPT="${BATS_TEST_DIRNAME}/../fs5"
|
||||
|
||||
setup() {
|
||||
export DEVICE_SERIAL="RD51QE202392"
|
||||
export LOG_FILE="/tmp/fs5-test-$$.log"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -f "${LOG_FILE}"
|
||||
}
|
||||
|
||||
@test "app list: exits with code 0" {
|
||||
run "${SCRIPT}" app list
|
||||
[ "${status}" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "app list: output contains package names" {
|
||||
run "${SCRIPT}" app list
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"package:"* ]] || [[ "${output}" == *"com."* ]]
|
||||
}
|
||||
|
||||
@test "app list --all: includes system packages" {
|
||||
run "${SCRIPT}" app list --all
|
||||
[ "${status}" -eq 0 ]
|
||||
# Android system packages always present
|
||||
[[ "${output}" == *"android"* ]]
|
||||
}
|
||||
|
||||
@test "app list: --json produces valid JSON array" {
|
||||
run "${SCRIPT}" app list --json
|
||||
[ "${status}" -eq 0 ]
|
||||
echo "${output}" | python3 -c "import json,sys; d=json.load(sys.stdin); assert isinstance(d, list)"
|
||||
}
|
||||
|
||||
@test "app install: fails with exit code 1 when APK not found" {
|
||||
run "${SCRIPT}" app install /nonexistent/app.apk
|
||||
[ "${status}" -eq 1 ]
|
||||
[[ "${output}" == *"not found"* ]]
|
||||
}
|
||||
|
||||
@test "app uninstall: requires --force flag" {
|
||||
run "${SCRIPT}" app uninstall com.example.nonexistent
|
||||
[ "${status}" -eq 1 ]
|
||||
[[ "${output}" == *"--force"* ]]
|
||||
}
|
||||
|
||||
@test "app uninstall: exits gracefully when package not installed" {
|
||||
run "${SCRIPT}" app uninstall com.example.nonexistent.xyz --force
|
||||
# Should exit 1 (not found) but not crash
|
||||
[ "${status}" -ne 2 ]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/test_logs.bats — Tests for 'fs5 logs' command (US5)
|
||||
|
||||
bats_require_minimum_version 1.5.0
|
||||
|
||||
SCRIPT="${BATS_TEST_DIRNAME}/../fs5"
|
||||
|
||||
setup() {
|
||||
export DEVICE_SERIAL="RD51QE202392"
|
||||
export LOG_FILE="/tmp/fs5-test-$$.log"
|
||||
LOG_OUTPUT="/tmp/fs5-logcap-$$.txt"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -f "${LOG_FILE}" "${LOG_OUTPUT}"
|
||||
}
|
||||
|
||||
@test "logs --lines: outputs exactly N lines" {
|
||||
run "${SCRIPT}" logs --lines 5
|
||||
[ "${status}" -eq 0 ]
|
||||
local line_count
|
||||
line_count=$(echo "${output}" | wc -l)
|
||||
[ "${line_count}" -ge 1 ]
|
||||
[ "${line_count}" -le 10 ]
|
||||
}
|
||||
|
||||
@test "logs --lines: exits after printing lines (non-streaming)" {
|
||||
# Should complete within 15 seconds (not hang)
|
||||
run timeout 15 "${SCRIPT}" logs --lines 3
|
||||
[ "${status}" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "logs --output: writes to file" {
|
||||
run "${SCRIPT}" logs --lines 5 --output "${LOG_OUTPUT}"
|
||||
[ "${status}" -eq 0 ]
|
||||
[ -f "${LOG_OUTPUT}" ]
|
||||
[ -s "${LOG_OUTPUT}" ]
|
||||
}
|
||||
|
||||
@test "logs --filter: filters by tag" {
|
||||
run "${SCRIPT}" logs --lines 20 --filter "System"
|
||||
[ "${status}" -eq 0 ]
|
||||
# Output may be empty if tag not present, but must not error
|
||||
}
|
||||
|
||||
@test "logs: --help shows usage" {
|
||||
run "${SCRIPT}" logs --help
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"logs"* ]]
|
||||
}
|
||||
|
||||
@test "logs: exits with code 2 when device not connected" {
|
||||
DEVICE_SERIAL="INVALID_SERIAL_000" run "${SCRIPT}" logs --lines 1
|
||||
[ "${status}" -eq 2 ]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/test_shell.bats — Tests for 'fs5 shell' command (US4)
|
||||
|
||||
bats_require_minimum_version 1.5.0
|
||||
|
||||
SCRIPT="${BATS_TEST_DIRNAME}/../fs5"
|
||||
|
||||
setup() {
|
||||
export DEVICE_SERIAL="RD51QE202392"
|
||||
export LOG_FILE="/tmp/fs5-test-$$.log"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -f "${LOG_FILE}"
|
||||
}
|
||||
|
||||
@test "shell: executes echo command on device" {
|
||||
run "${SCRIPT}" shell "echo hello-from-device"
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"hello-from-device"* ]]
|
||||
}
|
||||
|
||||
@test "shell: forwards exit code from device command" {
|
||||
run "${SCRIPT}" shell "exit 0"
|
||||
[ "${status}" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "shell: returns device hostname/uname" {
|
||||
run "${SCRIPT}" shell "uname -s"
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"Linux"* ]]
|
||||
}
|
||||
|
||||
@test "shell: --help shows usage" {
|
||||
run "${SCRIPT}" shell --help
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"shell"* ]]
|
||||
}
|
||||
|
||||
@test "shell: exits with code 2 when device not connected" {
|
||||
DEVICE_SERIAL="INVALID_SERIAL_000" run "${SCRIPT}" shell "echo test"
|
||||
[ "${status}" -eq 2 ]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/test_status.bats — Tests for 'fs5 status' command (US1)
|
||||
|
||||
bats_require_minimum_version 1.5.0
|
||||
|
||||
SCRIPT="${BATS_TEST_DIRNAME}/../fs5"
|
||||
|
||||
setup() {
|
||||
# Ensure .env exists for tests
|
||||
export DEVICE_SERIAL="RD51QE202392"
|
||||
export LOG_FILE="/tmp/fs5-test-$$.log"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -f "${LOG_FILE}"
|
||||
}
|
||||
|
||||
@test "status: shows device info when connected" {
|
||||
run "${SCRIPT}" status
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"FS5"* ]] || [[ "${output}" == *"RD51QE202392"* ]]
|
||||
}
|
||||
|
||||
@test "status: exits with code 2 when device serial is wrong" {
|
||||
DEVICE_SERIAL="INVALID_SERIAL_000" run "${SCRIPT}" status
|
||||
[ "${status}" -eq 2 ]
|
||||
[[ "${output}" == *"Device not found"* ]]
|
||||
}
|
||||
|
||||
@test "status: --json flag produces valid JSON" {
|
||||
run "${SCRIPT}" status --json
|
||||
[ "${status}" -eq 0 ]
|
||||
echo "${output}" | python3 -m json.tool > /dev/null
|
||||
}
|
||||
|
||||
@test "status: JSON output contains required fields" {
|
||||
run "${SCRIPT}" status --json
|
||||
[ "${status}" -eq 0 ]
|
||||
echo "${output}" | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
required = ['serial', 'model', 'android_version', 'battery_level', 'adb_state']
|
||||
for f in required:
|
||||
assert f in d, f'Missing field: {f}'
|
||||
"
|
||||
}
|
||||
|
||||
@test "status: --help shows usage" {
|
||||
run "${SCRIPT}" status --help
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"status"* ]]
|
||||
}
|
||||
|
||||
@test "status: completes in under 10 seconds" {
|
||||
local start end elapsed
|
||||
start=$(date +%s)
|
||||
run "${SCRIPT}" status
|
||||
end=$(date +%s)
|
||||
elapsed=$(( end - start ))
|
||||
[ "${elapsed}" -lt 10 ]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/test_transfer.bats — Tests for 'fs5 push/pull' commands (US2)
|
||||
|
||||
bats_require_minimum_version 1.5.0
|
||||
|
||||
SCRIPT="${BATS_TEST_DIRNAME}/../fs5"
|
||||
DEVICE_PATH="/sdcard/fs5-test"
|
||||
|
||||
setup() {
|
||||
export DEVICE_SERIAL="RD51QE202392"
|
||||
export LOG_FILE="/tmp/fs5-test-$$.log"
|
||||
# Create a temp local file for testing
|
||||
TEST_FILE=$(mktemp /tmp/fs5-push-test-XXXXXX.txt)
|
||||
echo "fs5-test-content-$$" > "${TEST_FILE}"
|
||||
PULL_DEST="/tmp/fs5-pulled-$$.txt"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
rm -f "${TEST_FILE}" "${PULL_DEST}" "${LOG_FILE}"
|
||||
# Clean up device test file
|
||||
adb -s "${DEVICE_SERIAL}" shell "rm -f '${DEVICE_PATH}/$(basename "${TEST_FILE}")'" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@test "push: transfers file to device successfully" {
|
||||
run "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")"
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"Pushed"* ]]
|
||||
}
|
||||
|
||||
@test "push: logs the operation to LOG_FILE" {
|
||||
run "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force
|
||||
[ "${status}" -eq 0 ]
|
||||
[ -f "${LOG_FILE}" ]
|
||||
grep -q "PUSH" "${LOG_FILE}"
|
||||
}
|
||||
|
||||
@test "push: skips if file exists without --force" {
|
||||
# First push
|
||||
"${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force
|
||||
# Second push without --force should warn, not fail
|
||||
run "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")"
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"--force"* ]] || [[ "${output}" == *"exists"* ]]
|
||||
}
|
||||
|
||||
@test "push: overwrites with --force flag" {
|
||||
"${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force
|
||||
run "${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"Pushed"* ]]
|
||||
}
|
||||
|
||||
@test "push: fails with exit code 1 when source file missing" {
|
||||
run "${SCRIPT}" push "/nonexistent/file.txt" "${DEVICE_PATH}/file.txt"
|
||||
[ "${status}" -eq 1 ]
|
||||
[[ "${output}" == *"not found"* ]]
|
||||
}
|
||||
|
||||
@test "pull: downloads file from device successfully" {
|
||||
# Push first so we have something to pull
|
||||
"${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force
|
||||
run "${SCRIPT}" pull "${DEVICE_PATH}/$(basename "${TEST_FILE}")" "${PULL_DEST}"
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"Pulled"* ]]
|
||||
[ -f "${PULL_DEST}" ]
|
||||
}
|
||||
|
||||
@test "pull: pulled file matches original" {
|
||||
"${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force
|
||||
"${SCRIPT}" pull "${DEVICE_PATH}/$(basename "${TEST_FILE}")" "${PULL_DEST}" --force
|
||||
run diff "${TEST_FILE}" "${PULL_DEST}"
|
||||
[ "${status}" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "pull: skips if local file exists without --force" {
|
||||
"${SCRIPT}" push "${TEST_FILE}" "${DEVICE_PATH}/$(basename "${TEST_FILE}")" --force
|
||||
touch "${PULL_DEST}"
|
||||
run "${SCRIPT}" pull "${DEVICE_PATH}/$(basename "${TEST_FILE}")" "${PULL_DEST}"
|
||||
[ "${status}" -eq 0 ]
|
||||
[[ "${output}" == *"--force"* ]] || [[ "${output}" == *"exists"* ]]
|
||||
}
|
||||
|
||||
@test "pull: fails with exit code 1 when device file missing" {
|
||||
run "${SCRIPT}" pull "${DEVICE_PATH}/nonexistent-file-xyz.txt" "${PULL_DEST}"
|
||||
[ "${status}" -eq 1 ]
|
||||
[[ "${output}" == *"not found"* ]]
|
||||
}
|
||||
Reference in New Issue
Block a user