Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,3 @@ jobs:
release-staging/ios-automation-server.tar.gz.sha256
install.sh
run-visiontest.sh
AGENT_INSTRUCTIONS.md
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ The same operations available as MCP tools can be invoked directly from the comm
| `press_back` | android | — | — |
| `press_home` | android, ios | — | — |
| `launch_app` | android, ios | `id` (string) | — |
| `init` | (none) | — | `--agent` (required, comma-separated: `claude,opencode,codex`) |

### CLI Exit Codes

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,16 @@ Every command requires `--platform android` or `--platform ios` (alias `-p`). Ru

With no arguments, `visiontest` starts the MCP stdio server.

### Agent Setup

To set up AI agent skill files in your project (so agents discover VisionTest CLI instructions automatically):

```bash
visiontest init --agent claude,opencode,codex
```

This writes a `SKILL.md` file to each agent's project-level directory. Run it once per project.

### Exit Codes

| Code | Meaning |
Expand Down
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ tasks.named<Test>("test") {
useJUnitPlatform()
}

// Copy AGENT_INSTRUCTIONS.md from repo root into JAR resources so InitCommand can read it at runtime.
tasks.named<ProcessResources>("processResources") {
from(rootProject.file("AGENT_INSTRUCTIONS.md")) {
rename { "agent-instructions.md" }
}
}

// Configurazione per il jar ombra (con tutte le dipendenze)
tasks {
shadowJar {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class VisionTestCli : NoOpCliktCommand(name = "visiontest") {
PressHomeCommand(lazy { components }),
// Apps
LaunchAppCommand(lazy { components }),
// Project setup
InitCommand(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.example.visiontest.cli.commands

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.CliktError
import com.github.ajalt.clikt.core.UsageError
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import com.github.ajalt.clikt.parameters.options.split
import java.nio.file.Path
import kotlin.io.path.createDirectories
import kotlin.io.path.writeText

/**
* `visiontest init --agent claude,opencode,codex`
*
* Writes a project-level SKILL.md for each specified agent so that AI coding
* assistants discover VisionTest CLI instructions automatically.
*
* Does NOT require `--platform` because this is not a device operation.
*/
class InitCommand(
private val workingDir: Path = Path.of(System.getProperty("user.dir")),
private val resourceLoader: (String) -> String? = Companion::loadClasspathResource,
) : CliktCommand(name = "init", help = "Set up AI agent skill files in the current project") {

companion object {
private const val RESOURCE_PATH = "agent-instructions.md"

/** Agent name → relative SKILL.md path from project root. */
val AGENT_PATHS: Map<String, String> = mapOf(
"claude" to ".claude/skills/visiontest/SKILL.md",
"opencode" to ".opencode/skills/visiontest/SKILL.md",
"codex" to ".agents/skills/visiontest/SKILL.md",
)

private val YAML_FRONTMATTER = """
|---
|name: visiontest
|description: VisionTest mobile automation CLI – commands, workflows, and examples for automating Android and iOS devices.
|---
|
""".trimMargin()

fun loadClasspathResource(name: String): String? =
InitCommand::class.java.classLoader.getResourceAsStream(name)
?.bufferedReader()
?.use { it.readText() }
}

private val agents by option("--agent", help = "Comma-separated agent names: claude, opencode, codex")
.split(",")
.required()

Comment on lines +50 to +53

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--agent values are split on commas but not trimmed, so inputs like --agent claude, opencode will produce an unexpected “unknown agent” error because of the leading space. Consider trimming each entry after splitting (and validating on the trimmed values) to make the flag more robust.

Copilot uses AI. Check for mistakes.
override fun run() {
// Validate agent names
val invalid = agents.filter { it.isBlank() || it !in AGENT_PATHS }
if (invalid.isNotEmpty()) {
throw UsageError(
"Unknown agent(s): ${invalid.joinToString()}. Valid agents: ${AGENT_PATHS.keys.joinToString()}"
)
}
Comment on lines +55 to +61

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the --agent list contains a blank entry (e.g., --agent ,claude or claude,), the error message currently renders as Unknown agent(s): . ... because the invalid value is an empty string. Handling blank entries separately (with an explicit message like “blank agent name in --agent list”) would make the failure actionable.

Copilot uses AI. Check for mistakes.

val instructions = resourceLoader(RESOURCE_PATH)
?: throw CliktError("Internal error: embedded resource '$RESOURCE_PATH' not found in JAR")

val content = YAML_FRONTMATTER + instructions

for (agent in agents) {
val relativePath = AGENT_PATHS.getValue(agent)
val target = workingDir.resolve(relativePath)
target.parent.createDirectories()
target.writeText(content)
echo(" wrote $target")
}

echo("Initialized ${agents.size} agent skill file(s).")
}
Comment on lines +54 to +77

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

InitCommand is the only CLI subcommand that doesn’t route its work through runCliCommand like the rest of the commands (e.g., AutomationServerStatusCommand), so it bypasses the project’s standard stdout/stderr + exit-code mapping layer. To keep CLI behavior consistent and make future error handling/testing uniform, consider wrapping the command body with runCliCommand and returning the combined output string (instead of calling echo directly).

Copilot uses AI. Check for mistakes.
}
119 changes: 119 additions & 0 deletions app/src/test/kotlin/com/example/visiontest/cli/InitCommandTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.example.visiontest.cli

import com.example.visiontest.cli.commands.InitCommand
import com.github.ajalt.clikt.core.MissingOption
import com.github.ajalt.clikt.core.UsageError
import org.junit.jupiter.api.io.TempDir
import java.nio.file.Path
import kotlin.io.path.readText
import kotlin.io.path.exists
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
import kotlin.test.assertContains

class InitCommandTest {

@TempDir
lateinit var tmp: Path

private val fakeInstructions = "# Fake Instructions\nSome content here."

private fun createCommand(dir: Path = tmp) = InitCommand(
workingDir = dir,
resourceLoader = { fakeInstructions },
)

// --- resource loads from classpath ---

@Test
fun `embedded resource loads from classpath`() {
val content = InitCommand.loadClasspathResource("agent-instructions.md")
assertTrue(content != null && content.contains("VisionTest"), "Resource should contain VisionTest content")
}

// --- single agent writes correct file ---

@Test
fun `single agent writes SKILL file to correct path`() {
val cmd = createCommand()
cmd.parse(listOf("--agent", "claude"))

val file = tmp.resolve(".claude/skills/visiontest/SKILL.md")
assertTrue(file.exists(), "SKILL.md should be created")
val content = file.readText()
assertTrue(content.startsWith("---\n"), "SKILL.md should start with YAML frontmatter delimiter")
assertContains(content, "\n---\n", message = "SKILL.md should have closing frontmatter delimiter")
assertContains(content, "name: visiontest")
assertContains(content, fakeInstructions)
}

// --- multiple agents ---

@Test
fun `comma-separated agents writes all files`() {
val cmd = createCommand()
cmd.parse(listOf("--agent", "claude,opencode,codex"))

for ((agent, path) in InitCommand.AGENT_PATHS) {
assertTrue(tmp.resolve(path).exists(), "SKILL.md should exist for $agent")
}
}

// --- invalid agent name ---

@Test
fun `invalid agent name produces UsageError`() {
val cmd = createCommand()
val ex = assertFailsWith<UsageError> {
cmd.parse(listOf("--agent", "gemini"))
}
assertContains(ex.message ?: "", "Unknown agent")
}

// --- blank agent name in comma list ---

@Test
fun `blank agent name in comma list produces UsageError`() {
val cmd = createCommand()
val ex = assertFailsWith<UsageError> {
cmd.parse(listOf("--agent", ",claude"))
}
assertContains(ex.message ?: "", "Unknown agent")
}

// --- missing --agent ---

@Test
fun `missing agent flag produces MissingOption`() {
val cmd = createCommand()
assertFailsWith<MissingOption> {
cmd.parse(emptyList())
}
}

// --- idempotent overwrite ---

@Test
fun `running init twice produces same content`() {
val cmd1 = createCommand()
cmd1.parse(listOf("--agent", "claude"))
val first = tmp.resolve(".claude/skills/visiontest/SKILL.md").readText()

val cmd2 = createCommand()
cmd2.parse(listOf("--agent", "claude"))
val second = tmp.resolve(".claude/skills/visiontest/SKILL.md").readText()

assertEquals(first, second)
}

// --- agent path mapping ---

@Test
fun `agent paths map correctly`() {
assertEquals(".claude/skills/visiontest/SKILL.md", InitCommand.AGENT_PATHS["claude"])
assertEquals(".opencode/skills/visiontest/SKILL.md", InitCommand.AGENT_PATHS["opencode"])
assertEquals(".agents/skills/visiontest/SKILL.md", InitCommand.AGENT_PATHS["codex"])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,16 +129,17 @@ class VisionTestCliTest {
// --- Subcommand routing ---

@Test
fun `root command lists all 13 subcommands`() {
fun `root command lists all 14 subcommands`() {
// We can't use the real VisionTestCli (ComponentHolder is lazy but still needs ADB),
// so we just verify the count expectation as a documentation test.
val expectedCommands = listOf(
"install_automation_server", "start_automation_server", "automation_server_status",
"get_interactive_elements", "get_ui_hierarchy", "get_device_info", "screenshot",
"tap_by_coordinates", "input_text", "swipe_direction",
"press_back", "press_home", "launch_app"
"press_back", "press_home", "launch_app",
"init"
)
assertEquals(13, expectedCommands.size)
assertEquals(14, expectedCommands.size)
}
Comment thread
docer1990 marked this conversation as resolved.

// --- SwipeDirection choice validation ---
Expand Down
11 changes: 2 additions & 9 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,13 @@ Users install with `curl -fsSL https://github.com/docer1990/visiontest/releases/
6. On macOS arm64: downloads `ios-automation-server.tar.gz` + checksum, extracts pre-built iOS XCUITest bundle to `ios-automation-server/` subdirectory (skipped on Linux and macOS x86_64)
7. Installs JAR, APKs, and iOS bundle to `~/.local/share/visiontest/` (customizable via `VISIONTEST_DIR` env var, must be under `$HOME`)
8. Creates wrapper script at `~/.local/bin/visiontest`, ensures PATH
9. Downloads `AGENT_INSTRUCTIONS.md` and installs VisionTest CLI instructions into detected AI coding agents:
- **Claude Code** (`claude`): creates skill at `~/.claude/skills/visiontest-mobile/SKILL.md`
- **OpenCode** (`opencode`): appends to `~/.config/opencode/AGENTS.md`
- **Codex** (`codex`): appends to `~/.codex/instructions.md`
- **Copilot CLI** (`gh copilot`): appends to `~/.github/copilot-instructions.md`
- Uses `<!-- BEGIN/END VISIONTEST -->` markers for idempotent updates
- Skip with `--skip-agent-setup` flag
10. Does not modify Claude Desktop configuration; use `run-visiontest.sh` or manual setup for Claude integration.
9. Prints instructions to run `visiontest init --agent <agents>` for project-level agent setup

**Security hardening:** `umask 077`, explicit `chmod` on all files/dirs, tag validation, checksum verification, install path restricted to `$HOME`.

## Release Workflow (`.github/workflows/release.yaml`)

Triggered by git tags matching `v*`. The workflow runs the test suite, builds the fat JAR via `shadowJar`, Android APKs, and the pre-built iOS XCUITest bundle (on a macOS runner), generates SHA-256 checksums, and creates a GitHub Release with the following assets: `visiontest.jar`, `visiontest.jar.sha256`, `automation-server.apk`, `automation-server.apk.sha256`, `automation-server-test.apk`, `automation-server-test.apk.sha256`, `ios-automation-server.tar.gz`, `ios-automation-server.tar.gz.sha256`, `AGENT_INSTRUCTIONS.md`, `install.sh`, `run-visiontest.sh`.
Triggered by git tags matching `v*`. The workflow runs the test suite, builds the fat JAR via `shadowJar`, Android APKs, and the pre-built iOS XCUITest bundle (on a macOS runner), generates SHA-256 checksums, and creates a GitHub Release with the following assets: `visiontest.jar`, `visiontest.jar.sha256`, `automation-server.apk`, `automation-server.apk.sha256`, `automation-server-test.apk`, `automation-server-test.apk.sha256`, `ios-automation-server.tar.gz`, `ios-automation-server.tar.gz.sha256`, `install.sh`, `run-visiontest.sh`.

All GitHub Actions in both workflows are pinned to commit SHAs for supply-chain security. When updating or adding actions, always use SHA-pinned references instead of floating version tags.

Expand Down
Loading
Loading