diff --git a/.claude/skills/.gitignore b/.claude/skills/.gitignore new file mode 100644 index 00000000..e1237cdf --- /dev/null +++ b/.claude/skills/.gitignore @@ -0,0 +1,3 @@ +# Eval workspaces — local eval run artifacts, not part of the skills +*-workspace/ +evals/ diff --git a/.claude/skills/device-integration-testing/SKILL.md b/.claude/skills/device-integration-testing/SKILL.md new file mode 100644 index 00000000..f8bb00da --- /dev/null +++ b/.claude/skills/device-integration-testing/SKILL.md @@ -0,0 +1,96 @@ +# Skill: device-integration-testing + +# Device Integration Testing — shared harness guidance + +Common pitfalls and error-handling for **any** maestro-runner integration +test that drives a real device/emulator (Python `conftest.py`, TypeScript +`setup.ts`, or the `maestro-runner test` YAML runner). Both the +`python-test-runner` and `typescript-test-runner` skills reuse this; keep the +duplicated knowledge HERE, not in each runner. + +## The single-session device lock (uiautomator2) + +The uiautomator2 driver locks the device **per server process**. On session +creation it writes a host-side guard: + +- `/tmp/uia2-.sock` — Unix socket forward +- `/tmp/uia2-.pid` — owning process PID + +`IsOwnerAlive()` returns true (→ "device … already in use") when that PID +file exists **and** the process is still alive. Because of this, **only one +session may be active against a device at a time** within a single server. + +Consequences / rules: +- Don't open a *second* session in a test that already has one. The shared + harness fixtures already open one per test process: + - Python: the autouse `_track_client` → `client` fixture (session-scoped). + - TypeScript: `getClient()` in `setup.ts` (shared `MaestroClient`). +- If a test needs raw HTTP access, **reuse the existing session id** + (`client.session_id` / `client.sessionId`) instead of `POST /session`. +- Teardown of the session belongs to the shared fixture — tests must not + delete the session themselves. + +## Clean slate between runs + +Before starting a fresh server/run, clear any stale guard left by a crashed +or killed previous run: + +```sh +# Kill whatever holds the server port (do NOT use `pkill -f maestro-runner`, see below) +lsof -ti tcp:9999 2>/dev/null | xargs -r kill -9 + +# Remove BOTH guard files — a leftover .pid pointing at a reused/alive PID +# trips IsOwnerAlive and fails session creation even with no server running. +rm -f /tmp/uia2-.sock /tmp/uia2-.pid + +# Clear any adb forward leaked by the previous run +adb -s forward --remove-all +``` + +> **`pkill -f maestro-runner` is a trap.** The pattern matches the *shell* +> running the command (its argv contains "maestro-runner"), so it can kill its +> own shell and orphan the backgrounded server. Always kill **by port** +> (`lsof -ti tcp: | xargs kill`) instead. + +## Server auto-start + +Both harnesses auto-start the maestro-runner server when none is reachable +(`MAESTRO_SERVER_URL`), so you usually **don't** start one by hand: + +- Python: `maestro_server` session fixture. +- TypeScript: `ensureServer()` in `setup.ts`. + +If you do start a server manually, give it a unique port and don't also let +the harness start a second one — two servers on the same port conflict. + +## Emulator must be attached + +```sh +adb devices # expect " device" (e.g. emulator-5554) +``` + +## Teardown stream race (child subprocess logs) + +When the harness pipes a child server's stdout/stderr into a `WriteStream` +log file, **unpipe the child streams before ending the log stream**. +Killing the child and then immediately `stream.end()` lets the dying process +flush final output into an already-closed stream → `write after end` crash +*after tests passed*. Fix: + +```ts +child.stdout?.unpipe(logStream); +child.stderr?.unpipe(logStream); +child.kill(); +// …then later… +logStream.end(); +``` + +## Quick diagnostic checklist + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `device … already in use` | Second session, or stale `.pid` guard | Reuse shared session; clear `/tmp/uia2-*` guard files | +| `Connection refused` | No server up | Let harness auto-start, or start one on a free port | +| Server crashes/orphaned | `pkill -f maestro-runner` killed its own shell | Kill by port instead | +| Hang at session create | Stale `.pid` with alive (reused) PID | `rm -f /tmp/uia2-.sock /tmp/uia2-.pid` | +| `write after end` at exit | Child log stream closed before child flushed | Unpipe child stdout/stderr before `stream.end()` | diff --git a/.claude/skills/git-commit/SKILL.md b/.claude/skills/git-commit/SKILL.md new file mode 100644 index 00000000..500302cb --- /dev/null +++ b/.claude/skills/git-commit/SKILL.md @@ -0,0 +1,325 @@ +--- +name: git-commit +description: > + Creates a well-formed git commit for the current changes in the repository, + following Conventional Commits format. Use this skill whenever the user asks + to commit changes, stage and commit, write a commit message, "git commit this", + "commit my changes", "make a commit", or wants help describing what changed. + Handles staging and message generation. NEVER push unless the user explicitly + says "push" or "commit and push" — do not prompt or suggest pushing. +allowed-tools: "Bash(git:*) Bash(grep:*) Bash(echo:*) Bash(cat:*) Bash(head:*)" +metadata: + author: maestro-runner + version: 1.4.0 + category: git + tags: [git, commit, conventional-commits, staging] +--- + +# Git Commit + +Creates a well-formed git commit for the current working-tree changes, following +the Conventional Commits convention used in this repository. + +## Commit Message Convention + +This repo uses **Conventional Commits**: + +``` +(): + + + + +``` + +**Types:** + +| Type | When to use | +|------|-------------| +| `feat` | New feature or functionality | +| `fix` | Bug fix or issue resolution | +| `chore` | Maintenance, dependencies, tooling | +| `test` | Test additions or modifications | +| `docs` | Documentation updates | +| `refactor` | Code change that neither fixes a bug nor adds a feature | +| `perf` | Performance improvement | +| `ci` | CI/CD configuration changes | +| `style` | Code formatting, linting (non-functional) | +| `security` | Security vulnerability fixes or hardening | + +**Scope** (kebab-case, in parens): the subsystem affected, e.g. `ios`, `android`, +`typescript`, `python`, `server`, `cli`, `config`, `skills`. + +**Subject line rules:** +- Lowercase, imperative mood — "add", "fix", "implement", not "adds" or "added" +- No period at the end +- Max 50 characters after the colon +- Specific and descriptive — state WHAT, not just "update code" or "fix bug" + +## Workflow + +### Step 0: Check for project-specific conventions + +Before anything else, check if the project overrides these defaults: + +```sh +cat .claude/CLAUDE.md 2>/dev/null | grep -A 20 -i "commit" +``` + +If a project format is specified, **use that format** instead of the defaults above. + +### Step 1: Inspect the current state + +```sh +git status +git diff --stat HEAD +``` + +If there are already staged changes (green in `git status`), note them. If +everything is unstaged (red), you'll stage as part of this workflow. + +### Step 2: Understand what changed + +```sh +# Summary of changed files +git diff --stat + +# Per-file diff (for unstaged changes) +git diff + +# Per-file diff (for already-staged changes) +git diff --cached +``` + +Read enough of the diff to understand the intent of the changes — not just file +names. The commit message should describe *why* or *what*, not just *which files*. + +### Step 3: Stage the right files + +If the user specifies what to include, stage only those. Otherwise, stage all +tracked changes: + +```sh +# Stage specific files (preferred — be intentional) +git add ... + +# Stage all tracked changes (does not add new untracked files) +git add -u + +# Stage everything including new untracked files +git add . +``` + +After staging, verify: +```sh +git diff --cached --stat +``` + +**NEVER stage:** +- `.env`, `credentials.json`, API keys, tokens, secrets of any kind +- `node_modules/`, `__pycache__/`, `.venv/` — these should be in `.gitignore` +- Large binary files without explicit user approval + +### Step 4: Generate the commit message + +Based on what's staged: + +1. Pick the `type` from the table above +2. Pick a `scope` for the area affected +3. Write a concise imperative summary (≤50 chars after colon) + +Show the proposed message to the user and ask for quick confirmation before +committing, unless the user already said "just commit" or "commit and push". + +**NEVER suggest or offer to push after committing. Only push if the user +explicitly requested it (e.g. said "push", "commit and push", "push it").** + +**Good examples:** +``` +feat(ios): add UDID env var support to e2e test setup +fix(auth): use hmac.compare_digest for secure key comparison +test(typescript): separate unit and device test suites +chore(skills): improve all skills with iOS coverage and eval test cases +refactor(template): consolidate filename sanitization logic +security(api): block dangerous URL schemes in validator +``` + +**Bad examples (avoid):** +``` +update validation code # no type, no scope, vague +feat: add stuff # missing scope, too vague +fix(auth): fix bug # circular, not specific +chore: make changes. # missing scope, has period +``` + +### Step 5: Commit + +> **IMPORTANT — NEVER use heredoc (`< unreliable in terminal-based agents and cause quoting/apostrophe failures. +> **Always write the message to `/tmp/commit-msg.txt` first, then commit with +> `git commit -F /tmp/commit-msg.txt`.** + +Simple change (one line — `-m` is fine only when there is no body): +```sh +git commit -m "type(scope): summary" +``` + +Complex change (with body explaining HOW and WHY) — **write to a temp file**: +```sh +printf 'type(scope): summary\n\nExplain the motivation and approach taken.\n- Use bullet points for multiple items\n- Wrap at 72 characters per line\n\nFixes #123\n' > /tmp/commit-msg.txt +git commit -F /tmp/commit-msg.txt +``` + +Or equivalently using `echo -e` for readability on multi-line messages: +```sh +echo "type(scope): summary + +Explain the motivation and approach taken. +- Use bullet points for multiple items +- Wrap at 72 characters per line + +Fixes #123" > /tmp/commit-msg.txt +git commit -F /tmp/commit-msg.txt +``` + +#### Breaking changes + +For incompatible API/behavior changes, use `!` after the scope or a +`BREAKING CHANGE:` footer — always via temp file: + +```sh +printf 'feat(api)!: change session response format to JSON:API\n\nBREAKING CHANGE: Response envelope changed from { data } to\n{ data: { type, id, attributes } }.\n' > /tmp/commit-msg.txt +git commit -F /tmp/commit-msg.txt +``` + +### Step 5a: Verify commit success (CRITICAL) + +**BEFORE attempting any alternative commit method or retry:** + +```sh +git log -1 --oneline +``` + +**Check the output:** +- If you see a recent commit that matches the message you just created, **STOP**. The commit succeeded. +- If the exit code was 0 and the commit hash appears in logs, **DO NOT attempt to commit again**. +- Only retry with a different method if `git status` shows changes are still staged AND the commit truly failed (exit code non-zero or error message present). + +**This prevents duplicate commits.** If a previous attempt succeeded with exit code 0, trust that result. + +#### Git trailers (optional) + +Add at the end of the body after a blank line: + +| Trailer | Purpose | +|---------|---------| +| `Fixes #N` | Links and closes issue on merge | +| `Closes #N` | Same as Fixes | +| `Co-authored-by: Name ` | Credit co-contributors | + +### Step 6: Verify the commit + +```sh +git log -1 --format="%h %s" +git show --stat HEAD +``` + +### Step 6.5: Check for explicit push request (CRITICAL) + +**Before even considering a push, verify the user's intent:** + +``` +Check the user's original message for these exact keywords: +- "push" +- "commit and push" +- "push it" +- Any grammatically similar explicit push request +``` + +**Decision logic:** +``` +IF user message contains "push" keyword: + → Proceed to Step 7 (push) +ELSE: + → STOP. Report commit success and stop at Step 6. + → Wait for explicit user request before pushing. +``` + +**Why this matters:** Accidentally pushing commits violates the skill's core +principle. This check prevents defaulting to push after successful commit. +It forces an explicit wait-for-user-intent at every fork point. + +**Example responses (no push requested):** +``` +✓ "Commit 071b569 created. Ready to push when you ask." +✓ "Committed successfully. Branch is clean." +❌ "Pushing now..." (without explicit user request) +``` + +### Step 7: Push (ONLY if explicitly requested) + +> **NEVER push automatically. NEVER ask "should I push?". NEVER offer to push.** +> Only run the push commands below when the user's message explicitly includes +> "push", "commit and push", or "push it". + +```sh +git push +``` + +If the branch has no upstream yet: + +```sh +git push -u origin $(git rev-parse --abbrev-ref HEAD) +``` + +### Step 7.5: Verify push success (CRITICAL) + +**After running push, verify it succeeded:** + +```sh +git log -1 --format="%h %s (pushed to remote)" +git status # should show "Your branch is up to date with 'origin/...'" +``` + +**If git status shows "ahead of ... commit" after push:** +``` +→ STOP. Push may have failed. Check error messages above. +→ Retry or investigate before proceeding. +``` + +**If status shows synced/up-to-date:** +``` +→ Push succeeded. Report success. +→ Do not attempt alternative push methods. +``` + +### Mixed changes (only commit some of them) + +If `git status` shows changes across multiple unrelated concerns, ask the user +which files belong to this commit. Don't lump unrelated changes into one commit. + +```sh +git add +git diff --cached --stat # confirm what's going in +``` + +### Nothing to commit + +```sh +git status # shows "nothing to commit, working tree clean" +``` + +Report this clearly: "There's nothing to commit — the working tree is already clean." + +### Untracked files + +`git add -u` will not pick up brand new files. If `git status` shows untracked +files that belong to the commit, include them explicitly or use `git add .`. +Always tell the user which new files are being included. + +### Already-staged changes + +If the user already ran `git add`, respect what's staged — don't unstage or +re-stage unless asked. Skip straight to Step 4. + + \ No newline at end of file diff --git a/.claude/skills/go-test-runner/SKILL.md b/.claude/skills/go-test-runner/SKILL.md new file mode 100644 index 00000000..224dd5f9 --- /dev/null +++ b/.claude/skills/go-test-runner/SKILL.md @@ -0,0 +1,142 @@ +--- +name: go-test-runner +description: > + Runs Go tests, race checks, coverage checks, benchmarks, and quality checks + for the maestro-runner repository. Use this skill whenever the user asks to + run or debug Go tests, `go test`, `make test`, `make check`, CI-style Go + verification at repo root, or asks why a Go test is failing, how to fix a + test failure, how to run go vet or staticcheck, or how to check coverage. + DO NOT use for Python client tests or TypeScript client tests; use the + dedicated client skills for those. +allowed-tools: "Bash(go:*) Bash(make:*) Bash(grep:*) Bash(awk:*) Bash(sed:*) Bash(cat:*) Bash(ls:*) Bash(find:*) Bash(head:*) Bash(tail:*) Bash(tee:*)" +metadata: + author: maestro-runner + version: 1.0.0 + category: testing + tags: [go, gotest, race, coverage, benchmark, ci] +--- + +# Go Test Runner + +Runs Go test and quality workflows for this repo from the root directory. + +## Do NOT use this skill for + +- Python client tests in `client/python/` -> use `python-test-runner` +- TypeScript client tests in `client/typescript/` -> use `typescript-test-runner` +- Non-test tasks unrelated to Go validation + +## Prerequisites + +- Go installed (`go version`) +- Commands run from repository root +- For race/coverage and full checks: allow longer runtime + +## Quick Start + +```sh +# From repo root +make test +``` + +Equivalent direct command: + +```sh +go test -v ./... +``` + +## Core Workflows + +### 1) Run all Go tests + +```sh +make test +# or +go test -v ./... +``` + +### 2) Run race detector + +```sh +make test-race +# or +go test -v -race ./... +``` + +### 3) Generate coverage report + +```sh +make test-coverage +# Produces coverage.out and coverage.html +``` + +### 4) Enforce coverage threshold (CI-style) + +```sh +make test-coverage-check +# Fails if total coverage is below 80% +``` + +### 5) Run benchmarks + +```sh +make bench +# or +go test -v -bench=. -benchmem ./... +``` + +### 6) Run fuzz tests + +```sh +make test-fuzz +# or +go test -v -fuzz=. -fuzztime=30s ./... +``` + +## Full Validation + +### Local full check + +```sh +make check +``` + +Runs formatting, static analysis/security checks, and race tests. + +### CI-equivalent check + +```sh +make ci +``` + +Runs full quality checks plus coverage threshold enforcement. + +## Package-Specific / Focused Runs + +```sh +# Single package +go test -v ./pkg/cli + +# Single test by name pattern in a package +go test -v ./pkg/cli -run TestUnified + +# Re-run failed tests quickly (Go test cache aware) +go test -v ./... +``` + +## Troubleshooting + +| Problem | Fix | +|---------|-----| +| `go: command not found` | Install Go and ensure it is on PATH | +| Very slow first run | Run `go mod download` (or `make deps`) to prefetch modules | +| Race test timeout/flakes | Re-run package-level tests first to isolate: `go test -v -race ./pkg/...` | +| Coverage check fails | Inspect `coverage.out` with `go tool cover -func=coverage.out` and add tests | +| Linter tool missing in `make check` | Install dev tools via `make dev-setup` | + +## Useful Supporting Targets + +```sh +make deps # go mod download + go mod tidy +make dev-setup # installs static analysis tools used by make check/ci +``` diff --git a/.claude/skills/merge-upstream/SKILL.md b/.claude/skills/merge-upstream/SKILL.md new file mode 100644 index 00000000..e74b25ee --- /dev/null +++ b/.claude/skills/merge-upstream/SKILL.md @@ -0,0 +1,228 @@ +--- +name: merge-upstream +description: > + Merges the upstream main branch from https://github.com/devicelab-dev/maestro-runner + into the current branch. Use this skill when you need to sync your local branch + with the latest changes from the official maestro-runner repository, pull in + upstream changes, check if the branch is out of sync with upstream, upstream + has new commits you need, or to cherry-pick features from the official repo. + Handles conflict resolution, provides merge status, and ensures a clean merge workflow. +allowed-tools: "Bash(git:*) Bash(grep:*) Bash(echo:*) Bash(make:*) Bash(go:*) Bash(npm:*) Bash(python:*) Bash(pytest:*)" +metadata: + author: maestro-runner + version: 1.0.0 + category: git + tags: [git, merge, upstream, sync] +--- + +# Merge Upstream + +Syncs your local branch with the latest changes from the official maestro-runner +upstream repository at `https://github.com/devicelab-dev/maestro-runner`. + +## Prerequisites + +- Git installed and configured with credentials +- Local repository initialized with remote named `origin` +- No uncommitted changes in your working directory; stash them first if needed: + +```sh +git stash # save uncommitted changes +# ... run merge ... +git stash pop # restore uncommitted changes afterwards +``` + +## Quick Start + +### 1. Check Current Status + +```sh +# Show current branch and upstream status +git status + +# Show remote configuration +git remote -v +``` + +### 2. Configure Upstream Remote (if not already set up) + +```sh +# Add upstream remote if it doesn't exist +git remote add upstream https://github.com/devicelab-dev/maestro-runner + +# Or update existing upstream remote +git remote set-url upstream https://github.com/devicelab-dev/maestro-runner + +# Verify setup +git remote -v +``` + +### 3. Fetch Upstream Changes + +```sh +# Fetch all upstream branches +git fetch upstream + +# Show upstream main branch info +git log --oneline upstream/main -10 +``` + +### 4. Merge Upstream Main into Current Branch + +```sh +# Create a merge commit combining upstream/main with current branch +git merge upstream/main --no-ff + +# Or use rebase strategy (replays your commits on top of upstream/main) +# This creates a cleaner, linear history but should only be used if not yet pushed +git rebase upstream/main +``` + +## Handling Merge Conflicts + +If conflicts occur during merge: + +### 1. Check Conflict Status + +```sh +# Show files with conflicts +git status + +# Show detailed conflict diff +git diff --name-only --diff-filter=U +``` + +### 2. Resolve Conflicts + +```sh +# Open conflicted file in your editor and look for conflict markers: +# <<<<<<< HEAD +# (your current changes) +# ======= +# (upstream changes) +# >>>>>>> upstream/main + +# After manual resolution, stage the fixed files +git add ... + +# Or stage all resolved files +git add . +``` + +### 3. Complete the Merge + +```sh +# Finish the merge with a commit message +git commit -m "Merge upstream/main into $(git rev-parse --abbrev-ref HEAD)" + +# Or abort if you want to start over +git merge --abort +``` + +## Verify Merge + +```sh +# Show merge history +git log --oneline --graph -10 + +# Confirm changes were integrated +git diff origin/..HEAD +``` + +## Show What Changed From Upstream + +After merging, summarize which features/fixes were pulled in. + +```sh +# Files changed by the upstream merge (or latest upstream sync) +git diff --name-status HEAD@{1}..HEAD + +# Commit-level summary of what arrived from upstream +git log --oneline --no-merges HEAD@{1}..HEAD + +# Optional: richer summary grouped by commit and touched files +git log --stat --no-merges HEAD@{1}..HEAD +``` + +If the merge result is "Already up to date", use this comparison to inspect recent +upstream changes and verify there is nothing new missing on your branch: + +```sh +git log --oneline --no-merges HEAD..upstream/main +``` + +## Run Tests After Merge + +Always run tests after a merge to catch integration regressions early. If upstream added or updated dependencies, refresh them before running tests. + +```sh +# 1) Go server tests (from repo root) +go mod tidy # refresh Go deps if go.mod/go.sum changed upstream +make test # all Go tests +make test-race # optional: race detector +make test-coverage-check # optional: enforce 80% coverage threshold + +# 2) TypeScript client unit tests +cd client/typescript +npm install # refresh npm deps if package.json changed upstream +npm run test:unit + +# 3) Python client unit tests +cd client/python +pip install -e ".[dev]" # refresh Python deps if pyproject.toml changed upstream +./.venv/bin/python -m pytest tests/test_client.py tests/test_models.py -v +``` + +## Troubleshooting + +### Upstream Remote Not Found +```sh +# Ensure upstream is configured +git remote add upstream https://github.com/devicelab-dev/maestro-runner +git fetch upstream +``` + +### "Everything up-to-date" +```sh +# Your branch is already in sync with upstream/main +# No merge needed +git log --oneline upstream/main -5 +``` + +### Merge Conflict Too Complex +```sh +# Abort the merge and start fresh +git merge --abort + +# Try rebase instead (if branch not yet pushed) +git rebase upstream/main +``` + +### Stash Uncommitted Changes (Optional) + +If you have uncommitted work: + +```sh +# Stash your changes temporarily +git stash + +# Perform merge as above +git merge upstream/main --no-ff + +# Restore your changes after merge +git stash pop +``` + +## Upstream Repository + +**Official Repository:** `https://github.com/devicelab-dev/maestro-runner` + +**Main Branch:** `main` (stable, production-ready) + +**Clone URL:** `git clone https://github.com/devicelab-dev/maestro-runner` + +## References + +- [Git Remote Documentation](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes) +- [Git Merge Documentation](https://git-scm.com/docs/git-merge) +- [GitHub Syncing Fork](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork) diff --git a/.claude/skills/python-test-runner/SKILL.md b/.claude/skills/python-test-runner/SKILL.md new file mode 100644 index 00000000..64e74b3a --- /dev/null +++ b/.claude/skills/python-test-runner/SKILL.md @@ -0,0 +1,206 @@ +--- +name: python-test-runner +description: > + Runs tests, lint, and type checks for the maestro-runner Python client at + client/python/. Use this skill whenever the user mentions pytest, ruff, + mypy, the Python venv, or running/debugging tests for client/python/ — even + if they just say "run the tests", "why is the Python test failing", "test + won't pass", "module not found", "venv isn't working", or the context is + clearly Python. Use for any pytest or make lint-py command. Automatically + handles test sequencing and device lock conflicts for "run all tests" + requests. DO NOT use for TypeScript tests, Go tests, or server-side code — + use the typescript-test-runner skill or run Go tests directly. +allowed-tools: "Bash(python:*) Bash(python3:*) Bash(pip:*) Bash(pip3:*) Bash(pytest:*) Bash(ruff:*) Bash(mypy:*) Bash(make:*) Bash(adb:*) Bash(curl:*) Bash(source:*)" +metadata: + author: maestro-runner + version: 1.1.0 + category: testing + tags: [python, pytest, e2e, android, lint, mypy, ruff, test-sequencing, device-lock] +--- + +# Python Test Runner + +Runs tests, lint, and type checks for the Python client at `client/python/`. + +## Do NOT use this skill for + +- TypeScript tests → use the `typescript-test-runner` skill +- Go / server tests → run `go test ./...` directly +- General Python questions unrelated to running tests + +## Prerequisites + +- Python venv at `client/python/.venv/` — activate it before every command or + pytest will use the system Python and not find the project's dependencies +- Prefer the project-local venv (`client/python/.venv`) over repo-root venvs. + If `python -m pytest` says `No module named pytest`, you're likely using the + wrong interpreter. +- For e2e/Android tests: Android emulator running + `maestro-runner` binary built + +```sh +# Activate the venv first — all commands below assume it is active +cd client/python && source .venv/bin/activate + +# Or run directly without activating +cd client/python && ./.venv/bin/python -m pytest -v +``` + +## Step 1: Unit Tests (no device needed) + +```sh +# All unit tests +cd client/python && source .venv/bin/activate && python -m pytest tests/test_client.py tests/test_models.py -v + +# Single file +python -m pytest tests/test_client.py -v + +# Specific test +python -m pytest tests/test_client.py::TestSessionManagement::test_create_session -v + +# Parallel execution +python -m pytest tests/test_client.py tests/test_models.py -n auto -v +``` + +## Step 2: E2E Android Tests + +Requires the server to be running first. + +### 1. Check emulator is attached +```sh +adb devices +``` + +### 2. Start the server (if not already running) +```sh +# From repo root — runs in background +./maestro-runner --platform android server --port 9999 &>/tmp/maestro-server.log & + +# Verify it's up +curl -s http://localhost:9999/status + +# Optional: ensure no stale server process is holding the device +pgrep -af "maestro-runner.*server" || true +``` + +### 3. Run the tests +```sh +cd client/python && source .venv/bin/activate && python -m pytest tests/test_e2e_android.py -v +``` + +To target a different server URL or device: +```sh +MAESTRO_SERVER_URL=http://localhost:8888 python -m pytest tests/test_e2e_android.py -v +MAESTRO_DEVICE_ID=emulator-5554 python -m pytest tests/test_e2e_android.py -v +``` + +## Step 3: Page-Object / Integration Tests (need device + server) + +```sh +cd client/python && source .venv/bin/activate && \ + python -m pytest tests/test_add_contact.py tests/test_contact_persists.py -n auto -v +``` + +## Step 4: iOS Tests + +Requires iOS simulator running with the server started against that simulator. + +```sh +# Start the server targeting the iOS simulator +./maestro-runner --platform ios --device server --port 9999 &>/tmp/maestro-server.log & +curl -s http://localhost:9999/status + +# Run the iOS contact test +cd client/python && source .venv/bin/activate && \ + MAESTRO_PLATFORM=ios MAESTRO_DEVICE_ID= \ + python -m pytest tests/test_add_contact_ios.py -v +``` + +Environment variables for iOS: + +| Variable | Example | Description | +|----------|---------|-------------| +| `MAESTRO_PLATFORM` | `ios` | Must be set to `ios` | +| `MAESTRO_DEVICE_ID` | `E0E08E8A-29CC-4A5C-91D7-9799C245B140` | iOS simulator UDID | +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Server URL (default) | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to binary (auto-detected) | + +## Step 5: Lint + +```sh +# From repo root via Makefile +make lint-py + +# Or directly +cd client/python && source .venv/bin/activate +ruff check maestro_runner tests +mypy maestro_runner + +# Auto-fix +make lint-py-fix +``` + +## Run All Tests (Complete Suite) + +**Use this when asked to "run all tests"** — handles proper sequencing and device lock mitigation: + +```sh +# 1) Unit tests (no device needed — always run first, fastest) +cd client/python && ./.venv/bin/python -m pytest tests/test_client.py tests/test_models.py -v + +# 2) Page-object / integration tests (reuse existing server session) +./.venv/bin/python -m pytest tests/test_add_contact.py tests/test_contact_persists.py -v + +# 3) Clean up stale server processes to avoid device-lock conflicts +pkill -f "maestro-runner.*server" || true +sleep 2 + +# 4) Start fresh server for e2e tests (from repo root) +./maestro-runner --platform android server --port 9999 &>/tmp/maestro-server.log & +sleep 2 +curl -s http://localhost:9999/status + +# 5) E2E tests with exclusive device access +cd client/python && ./.venv/bin/python -m pytest tests/test_e2e_android.py -v +``` + +**Why this order:** +- Unit tests run first (fastest, no device needed) +- Integration tests share the server session +- Device lock is released before e2e tests to prevent "device already in use" errors +- E2E tests run last with a fresh server connection + +## Reports + +HTML and JUnit XML reports are written to `client/python/reports/` after every pytest run: +- `reports/report.html` +- `reports/junit-report.xml` + +## Common Issues + +See **`device-integration-testing`** for the shared harness pitfalls +(device lock, stale `/tmp/uia2-*` PID guard, `pkill -f maestro-runner` +self-match, teardown stream races). Python-specific issues: + +| Problem | Fix | +|---------|-----| +| `No module named pytest` | Wrong interpreter — use `client/python/.venv/bin/python` | +| `Connection refused` on e2e tests | Server not running — conftest auto-starts it, or set `MAESTRO_RUNNER_BIN` | +| `device ... is already in use` | Reuse the conftest `client` session (don't open a second). Details in `device-integration-testing`. | +| `pytest-html` plugin missing | `pip install pytest-html` (referenced by `pyproject.toml` addopts) | +| `adb: command not found` | Android SDK not on PATH; set `ANDROID_HOME` | +| Lint `E501` line-too-long | Line length limit is 100; wrap long lines | + +### Running a single integration file + +The conftest auto-starts the server when none is reachable, so you normally +just run pytest — no manual server needed: + +```sh +cd client/python && source .venv/bin/activate +MAESTRO_RUNNER_BIN=../../maestro-runner MAESTRO_DEVICE_ID=emulator-5554 \ + MAESTRO_PLATFORM=android python -m pytest tests/test_e2e_android.py -v +``` + +If you do start a server manually, use a unique port and don't also let +conftest start one; two servers on the same port conflict. + diff --git a/.claude/skills/typescript-test-runner/SKILL.md b/.claude/skills/typescript-test-runner/SKILL.md new file mode 100644 index 00000000..c6bfb96d --- /dev/null +++ b/.claude/skills/typescript-test-runner/SKILL.md @@ -0,0 +1,191 @@ +--- +name: typescript-test-runner +description: > + Runs tests, lint, and build for the maestro-runner TypeScript client at + client/typescript/. Use this skill whenever the user mentions TypeScript + tests, Jest, npm run test:unit, npm run test:device, e2e tests, linting, or + building the TS client — even if they don't say "TypeScript" explicitly, + apply this skill whenever the context is clearly client/typescript/. Use + this skill when the user says a TypeScript test is failing, "why is the Jest + test red", "module not found", "can't connect to server", or when running + iOS or Android device tests from the TypeScript client. Handles server + startup automatically via setup.ts. Automatically handles test sequencing + and device lock conflicts for "run all tests" requests. DO NOT use for + Python tests, Go tests, or server-side code — use the python-test-runner + skill or run Go tests directly. +allowed-tools: "Bash(npm:*) Bash(npx:*) Bash(node:*) Bash(adb:*) Bash(curl:*) Bash(make:*) Bash(pkill:*) Bash(sleep:*)" +metadata: + author: maestro-runner + version: 1.1.0 + category: testing + tags: [typescript, jest, e2e, android, lint, build, test-sequencing, device-lock] +--- + +# TypeScript Test Runner + +Runs tests, lint, and build for the TypeScript client at `client/typescript/`. + +## Do NOT use this skill for + +- Python tests → use the `python-test-runner` skill +- Go / server tests → run `go test ./...` directly +- General TypeScript questions unrelated to running tests + +## Prerequisites + +- **Node.js** ≥ 18 (`node --version`) +- Dependencies installed: `npm install` inside `client/typescript/` +- For e2e tests: Android emulator running + `maestro-runner` binary built + +## Step 0: Setup (first time only) + +```sh +cd client/typescript && npm install +``` +## Run All Tests (Complete Suite) + +**Use this when asked to "run all tests"** — handles proper sequencing and device lock mitigation: + +```sh +# 1) Clean up any stale maestro-runner server processes +pkill -f "maestro-runner.*server" || true +sleep 2 + +# 2) Run unit tests in parallel (no real device required) +cd client/typescript && npm run test:unit + +# 3) Run Android real-device tests in serial mode +cd client/typescript && npm run test:device:android + +# 4) Run iOS real-device tests in serial mode +cd client/typescript && \ + MAESTRO_PLATFORM=ios MAESTRO_DEVICE_ID= npm run test:device:ios +``` + +**Why this works:** +- Stale server processes are cleaned up before tests to prevent device lock conflicts +- `setup.ts` auto-starts a fresh maestro-runner server for the test suite +- Single-worker execution avoids session/device races on single-emulator setups + +To run only one group directly: +```sh +cd client/typescript && npm run test:unit +cd client/typescript && npm run test:device:android +cd client/typescript && MAESTRO_PLATFORM=ios MAESTRO_DEVICE_ID= npm run test:device:ios +``` + + +## Step 1: Unit Tests (parallel-safe) + +The test setup (`tests/setup.ts`) auto-starts the maestro-runner server if it isn't already running — no manual server startup needed. + +```sh +# Unit tests only (parallel-safe) +cd client/typescript && npm run test:unit + +# Android real-device tests (serial) +npm run test:device:android + +# iOS real-device tests (serial, requires MAESTRO_DEVICE_ID) +MAESTRO_PLATFORM=ios MAESTRO_DEVICE_ID= npm run test:device:ios + +# Specific test file +npx jest tests/test_add_contact.test.ts + +# Specific test by name pattern +npx jest -t "should add a contact" + +# Watch mode (re-runs on file change) +npx jest --watch +``` + +## Step 2: Real-Device Android Tests (serial) + +### 1. Check emulator is attached +```sh +adb devices +``` + +### 2. Run (server is auto-started by setup.ts) +```sh +cd client/typescript && npm run test:device:android +``` + +To target a different server: +```sh +MAESTRO_SERVER_URL=http://localhost:8888 MAESTRO_PLATFORM=android npm run test:device:android +``` + +## Step 2b: Real-Device iOS Tests (serial) + +### 1. Check simulator is running +```sh +xcrun simctl list devices booted +``` + +### 2. Run (server is auto-started by setup.ts with the iOS platform) +```sh +cd client/typescript && \ + MAESTRO_PLATFORM=ios MAESTRO_DEVICE_ID= npm run test:device:ios +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Server URL | +| `MAESTRO_PLATFORM` | `android` | Target platform (`android` / `ios`) | +| `MAESTRO_DEVICE_ID` | _(unset)_ | Device/simulator UDID (required for iOS; optional for Android) | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to maestro-runner binary | + +## Step 3 (optional): Manual Server Startup + +If `setup.ts` can't locate the binary or you want to manage the server yourself: + +```sh +# From repo root — Android +./maestro-runner --platform android server --port 9999 &>/tmp/maestro-server.log & + +# From repo root — iOS (provide simulator UDID) +./maestro-runner --platform ios --device server --port 9999 &>/tmp/maestro-server.log & + +# Verify +curl -s http://localhost:9999/status +``` + +## Step 4: Build + +```sh +cd client/typescript && npm run build +# Output: dist/ (JS + .d.ts + source maps) +``` + +## Step 5: Lint + +```sh +cd client/typescript && npm run lint # Check for issues +cd client/typescript && npm run lint:fix # Auto-fix what's possible +``` + +Key ESLint rules: `consistent-type-imports`, `no-explicit-any` (warn in `src/`), `no-unused-vars`, `eqeqeq`, `no-console` (warn in `src/`). + +## Reports + +HTML and JUnit XML reports are written to `client/typescript/reports/` after every Jest run: +- `reports/report.html` +- `reports/junit-report.xml` + +## Common Issues + +See **`device-integration-testing`** for the shared harness pitfalls +(device lock, stale `/tmp/uia2-*` PID guard, `pkill -f maestro-runner` +self-match, teardown stream races — including the `setup.ts` `write after end` +fix). TypeScript-specific issues: + +| Problem | Fix | +|---------|-----| +| `Connection refused` | Server failed to auto-start; check `MAESTRO_RUNNER_BIN` path or start manually | +| `Cannot find module` | Dependencies not installed: `npm install` | +| `adb: command not found` | Android SDK not on PATH; set `ANDROID_HOME` | +| TypeScript compile errors | Run `npm run build` to see full tsc diagnostics | +| Lint `no-explicit-any` error | Avoid `any` in `src/`; use proper types or `unknown` | diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 00000000..eb361caa --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,33 @@ +#!/usr/bin/env bash + +set -euo pipefail + +COMMIT_MSG_FILE="$1" +COMMIT_MSG="$(sed -E 's/^[[:space:]]+|[[:space:]]+$//g' "$COMMIT_MSG_FILE" | head -n 1)" + +# Allow Git-generated merge/revert messages. +if [[ "$COMMIT_MSG" =~ ^Merge\ ]] || [[ "$COMMIT_MSG" =~ ^Revert\ ]]; then + exit 0 +fi + +# Conventional Commits format: +# type(scope)!: subject +# type: subject +PATTERN='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?(!)?: .{1,72}$' + +if [[ ! "$COMMIT_MSG" =~ $PATTERN ]]; then + echo "" + echo "Invalid commit message format." + echo "Use Conventional Commits:" + echo " ()!: " + echo " : " + echo "" + echo "Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert" + echo "Examples:" + echo " feat(ios): add retry for session creation" + echo " fix: handle empty response body" + echo " chore!: remove deprecated flag" + echo "" + echo "First line must be 1-72 chars after ': '." + exit 1 +fi \ No newline at end of file diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..a41fd115 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,122 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Pre-push test policy: +# - Go changes: run Go unit tests, Python client unit tests, and TypeScript client unit tests. +# - Python-only changes under client/python/: run only Python client unit tests. +# - TypeScript-only changes under client/typescript/: run only TypeScript client unit tests. +# - Python + TypeScript changes without Go changes: run both client unit test suites. +# - No Go, Python, or TypeScript changes: run no tests. +# +# Explicit exclusions: +# - Go Chrome CDP tests are skipped by excluding pkg/driver/browser/cdp. +# - Python real-device tests are skipped by running only tests/test_client.py and tests/test_models.py. +# - TypeScript real-device tests are skipped by running npm run test:unit. + +ROOT_DIR="$(git rev-parse --show-toplevel)" +cd "$ROOT_DIR" + +CHANGED_FILES="" +HAS_PUSH_REFS=0 +while read -r LOCAL_REF LOCAL_SHA REMOTE_REF REMOTE_SHA; do + [[ -z "${LOCAL_SHA:-}" ]] && continue + HAS_PUSH_REFS=1 + + # Deleted refs have all-zero local sha; nothing to validate. + if [[ "$LOCAL_SHA" =~ ^0+$ ]]; then + continue + fi + + if [[ "$REMOTE_SHA" =~ ^0+$ ]]; then + # New branch/tag push: diff against merge-base with origin/main when available. + if git show-ref --verify --quiet refs/remotes/origin/main; then + BASE_SHA="$(git merge-base "$LOCAL_SHA" refs/remotes/origin/main)" + CHANGED_FILES+="$(git diff --name-only "$BASE_SHA" "$LOCAL_SHA")"$'\n' + else + CHANGED_FILES+="$(git diff-tree --no-commit-id --name-only -r "$LOCAL_SHA")"$'\n' + fi + else + CHANGED_FILES+="$(git diff --name-only "$REMOTE_SHA" "$LOCAL_SHA")"$'\n' + fi +done + +if [[ "$HAS_PUSH_REFS" -eq 0 ]]; then + exit 0 +fi + +echo "[pre-push] Running unit test checks (Go, Python, TypeScript)..." +echo "[pre-push] Skipping Chrome CDP tests and real-device tests." + +HAS_GO_CHANGES=0 +HAS_PYTHON_CHANGES=0 +HAS_TYPESCRIPT_CHANGES=0 + +if echo "$CHANGED_FILES" | grep -Eq '(^|/).+\.go$|^go\.mod$|^go\.sum$'; then + HAS_GO_CHANGES=1 +fi + +if echo "$CHANGED_FILES" | grep -Eq '^client/python/'; then + HAS_PYTHON_CHANGES=1 +fi + +if echo "$CHANGED_FILES" | grep -Eq '^client/typescript/'; then + HAS_TYPESCRIPT_CHANGES=1 +fi + +if [[ "$HAS_GO_CHANGES" -eq 1 ]]; then + echo "[pre-push] Go changes detected. Running Go unit tests (excluding pkg/driver/browser/cdp)..." + GO_TEST_PKGS="$(go list ./... | grep -Ev '/pkg/driver/browser/cdp$' || true)" + if [[ -z "$GO_TEST_PKGS" ]]; then + echo "[pre-push] No Go packages found to test." + else + # shellcheck disable=SC2086 + go test -count=1 $GO_TEST_PKGS + fi +else + echo "[pre-push] No Go changes detected. Skipping Go unit tests." +fi + +if [[ "$HAS_PYTHON_CHANGES" -eq 1 || "$HAS_GO_CHANGES" -eq 1 ]]; then + if [[ "$HAS_GO_CHANGES" -eq 1 && "$HAS_PYTHON_CHANGES" -eq 0 ]]; then + echo "[pre-push] Go changes detected. Running Python unit tests as part of cross-client validation (client/python/tests/test_client.py, test_models.py)..." + else + echo "[pre-push] Python changes detected. Running Python unit tests (client/python/tests/test_client.py, test_models.py)..." + fi + if [[ ! -x "client/python/.venv/bin/pytest" ]]; then + echo "[pre-push] Missing client/python/.venv/bin/pytest" + echo "[pre-push] Create venv and install deps: cd client/python && python3 -m venv .venv && .venv/bin/pip install -e '.[dev]'" + exit 1 + fi + ( + cd client/python + .venv/bin/pytest tests/test_client.py tests/test_models.py -q + ) +else + echo "[pre-push] No Python changes detected. Skipping Python unit tests." +fi + +if [[ "$HAS_TYPESCRIPT_CHANGES" -eq 1 || "$HAS_GO_CHANGES" -eq 1 ]]; then + if [[ "$HAS_GO_CHANGES" -eq 1 && "$HAS_TYPESCRIPT_CHANGES" -eq 0 ]]; then + echo "[pre-push] Go changes detected. Running TypeScript unit tests as part of cross-client validation (npm run test:unit)..." + else + echo "[pre-push] TypeScript changes detected. Running TypeScript unit tests (npm run test:unit)..." + fi + if [[ ! -d "client/typescript/node_modules" ]]; then + echo "[pre-push] Missing client/typescript/node_modules" + echo "[pre-push] Install deps: cd client/typescript && npm install" + exit 1 + fi + ( + cd client/typescript + npm run test:unit + ) +else + echo "[pre-push] No TypeScript changes detected. Skipping TypeScript unit tests." +fi + +if [[ "$HAS_GO_CHANGES" -eq 0 && "$HAS_PYTHON_CHANGES" -eq 0 && "$HAS_TYPESCRIPT_CHANGES" -eq 0 ]]; then + echo "[pre-push] No Go, Python, or TypeScript changes detected. No tests were run." +fi + +echo "[pre-push] All unit test checks passed." \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57cac56d..e6134e75 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,20 +5,17 @@ on: branches: [main] pull_request: branches: [main] - # Lets a run be re-fired without an empty commit — during the 2026-08-06 - # Actions outage a push produced no run at all and there was nothing to retry. - workflow_dispatch: jobs: - test: - name: Test + test-go: + name: Test Go runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v4 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: go-version: '1.25.6' cache: true @@ -26,78 +23,101 @@ jobs: - name: Download dependencies run: go mod download + - name: Check golangci-lint version + run: echo "golangci-lint version:" && golangci-lint --version || echo "golangci-lint not yet installed" + + - name: Install golangci-lint v2.11.3 + run: | + mkdir -p $HOME/bin + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $HOME/bin v2.11.3 + echo "$HOME/bin" >> $GITHUB_PATH + + - name: Lint + run: golangci-lint run ./... --output.json.path=/tmp/lint-report.json && echo "Lint passed" + - name: Run tests - run: go test -race -timeout 25m -coverprofile=coverage.out -covermode=atomic ./... + run: go test -race -coverprofile=coverage.out -covermode=atomic ./... - name: Upload coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v4 with: files: ./coverage.out token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: false - lint: - name: Lint + test-python: + name: Test Python Client runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v6 + - name: Setup Python + uses: actions/setup-python@v5 with: - go-version: '1.25.6' - cache: true + python-version: '3.11' - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v4 - with: - # Pinned deliberately. `latest` resolves at run time, so the same - # commit can pass today and fail tomorrow. golangci-lint v2 enables - # staticcheck's QF category and drops v1's default exclusions, which - # surfaces 61 pre-existing findings here (43 errcheck, 18 - # staticcheck) — a migration to do on purpose, not by surprise. - # Moving off action@v4 requires that migration: v7+ needs v2. - version: v1.64.8 + - name: Install dependencies + working-directory: client/python + run: | + python3 -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install -e ".[dev]" - build: - name: Build + - name: Run unit tests + working-directory: client/python + # --noconftest skips conftest.py so the autouse _track_client fixture + # (which requires a real maestro-runner server + device) is not loaded. + # Unit tests use their own requests_mock fixtures and need no server. + run: .venv/bin/python -m pytest tests/test_client.py tests/test_models.py -v --noconftest + + - name: Lint + working-directory: client/python + run: .venv/bin/ruff check maestro_runner tests + + - name: Type check + working-directory: client/python + run: .venv/bin/mypy maestro_runner + + test-typescript: + name: Test TypeScript Client runs-on: ubuntu-latest - needs: [test, lint] steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@v4 - - name: Setup Go - uses: actions/setup-go@v6 + - name: Setup Node.js + uses: actions/setup-node@v4 with: - go-version: '1.25.6' - cache: true + node-version: '20' + cache: 'npm' + cache-dependency-path: client/typescript/package-lock.json - - name: Build - run: go build -v ./... + - name: Install dependencies + working-directory: client/typescript + run: npm ci + + - name: Run unit tests + working-directory: client/typescript + run: npm run test:unit + + - name: Lint + working-directory: client/typescript + run: npm run lint - release: - name: Release + build: + name: Build runs-on: ubuntu-latest - needs: [build] - if: startsWith(github.ref, 'refs/tags/v') + needs: [test-go, test-python, test-typescript] steps: - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 + uses: actions/checkout@v4 - name: Setup Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v5 with: go-version: '1.25.6' cache: true - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6 - with: - version: latest - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Build + run: go build -v ./... diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml new file mode 100644 index 00000000..69a3cd5d --- /dev/null +++ b/.github/workflows/e2e-android.yml @@ -0,0 +1,168 @@ +name: E2E Android + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: e2e-android-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + e2e-android: + name: E2E Android (API 36) + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + # Required to run Android emulator with hardware acceleration on Linux + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.6' + cache: true + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: client/typescript/package-lock.json + + - name: Build maestro-runner + run: | + go mod download + go build -o maestro-runner . + + - name: Install Python client dependencies + working-directory: client/python + run: | + python3 -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install -e ".[dev]" + + - name: Install TypeScript client dependencies + working-directory: client/typescript + run: | + npm ci + npm install --no-save jest-junit + + - name: Write e2e test script + run: | + cat > /tmp/run-e2e.sh << 'SCRIPT' + #!/usr/bin/env bash + set +e + FAILED=0 + mkdir -p reports/yaml reports/python reports/typescript ctrf + + # Detect emulator serial + DEVICE=$(adb devices | grep -E "emulator-[0-9]+" | head -1 | awk '{print $1}') + export MAESTRO_DEVICE_ID="$DEVICE" + echo "Using device: $DEVICE" + adb devices + + # Install UIAutomator2 driver APKs (server before test harness) + adb install -r -g drivers/android/appium-uiautomator2-server-v9.11.1.apk + adb install -r -g drivers/android/appium-uiautomator2-server-debug-androidTest.apk + adb install -r -g drivers/android/settings_apk-debug.apk + + # --- YAML flow tests --- + echo "::group::YAML flow tests" + ./maestro-runner --platform android test --output reports/yaml e2e/workspaces/contacts/add_contact_android.yaml e2e/workspaces/contacts/contact_persists.yaml || FAILED=1 + echo "::endgroup::" + + # --- Python e2e tests (conftest.py auto-starts server) --- + echo "::group::Python e2e tests" + cd client/python + .venv/bin/python -m pytest tests/test_add_contact.py tests/test_contact_persists.py tests/test_wait_for_animation_to_end.py tests/test_wait_for_animation_never_ends.py -v --junit-xml=../../reports/python/junit.xml || FAILED=1 + cd ../.. + echo "::endgroup::" + + # --- TypeScript e2e tests (setup.ts auto-starts server) --- + echo "::group::TypeScript e2e tests" + cd client/typescript + JEST_JUNIT_OUTPUT_DIR=../../reports/typescript JEST_JUNIT_OUTPUT_NAME=junit.xml npx jest tests/test_add_contact.device.test.ts tests/test_contact_persists.device.test.ts tests/test_wait_for_animation_never_ends.device.test.ts --runInBand --reporters=default --reporters=jest-junit || FAILED=1 + cd ../.. + echo "::endgroup::" + + exit $FAILED + SCRIPT + chmod +x /tmp/run-e2e.sh + + - name: Run Android E2E tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 36 + target: google_apis + arch: x86_64 + profile: pixel_6 + emulator-options: -no-boot-anim -no-snapshot-load -netdelay none -netspeed full -no-window -gpu swiftshader_indirect + disable-animations: true + script: bash /tmp/run-e2e.sh + + - name: Generate CTRF reports + if: always() + run: | + mkdir -p ctrf + + # Convert YAML flow JUnit XMLs (one timestamped subdir per run) + find reports/yaml -name "*.xml" -type f | while read -r f; do + name=$(basename "$(dirname "$f")") + npx --yes junit-to-ctrf "$f" -o "ctrf/yaml-${name}-ctrf.json" || true + done + + # Convert Python e2e JUnit + if [ -f reports/python/junit.xml ]; then + npx --yes junit-to-ctrf reports/python/junit.xml -o ctrf/python-ctrf.json || true + fi + + # Convert TypeScript e2e JUnit + if [ -f reports/typescript/junit.xml ]; then + npx --yes junit-to-ctrf reports/typescript/junit.xml -o ctrf/ts-ctrf.json || true + fi + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-android-reports-${{ github.run_number }} + path: | + reports/ + ctrf/ + retention-days: 14 + + - name: Publish test results + if: always() + uses: ctrf-io/github-test-reporter@v1 + with: + report-path: 'ctrf/*.json' + pull-request-report: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e3e1aef5..53b4a756 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,8 @@ scripts/ docs_local/ research/ examples/ +# Server log +maestro-server.log # Internal planning / research files. Kept local to avoid creating # cross-repo backlinks via GitHub's automatic org/repo#N indexing. @@ -64,6 +66,13 @@ SAUCE-LABS-HOOKS.md ROADMAP.md SNAPSHOT_MODE_SPEC.md +# Track maestro-runner agent skills (exception to the `.claude/` ignore above). +# Re-include the dir tree, then keep local-only config ignored. +!.claude/ +.claude/settings.local.json +!.claude/skills/ +!.claude/skills/** + # npm platform packages are assembled from dist// by npm/build-npm.sh. # Each carries a ~50MB binary plus drivers; they are build output, not source. npm/platforms/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..dfa27f80 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,26 @@ +version: "2" + +linters: + exclusions: + rules: + # Test files: ignore unchecked error returns - HTTP handler helpers, + # mock servers, and deferred teardown are irrelevant in tests. + - path: "_test\\.go" + linters: + - errcheck + + # Deferred cleanup: fire-and-forget, errors not actionable. + - source: "^\\s+defer " + linters: + - errcheck + + # nhooyr.io/websocket is deprecated in favor of coder/websocket. + # Migration tracked separately; suppress SA1019 for now. + - text: "SA1019" + linters: + - staticcheck + + # QF* are optional style suggestions, not correctness issues. + - text: "QF[0-9]" + linters: + - staticcheck diff --git a/.playwright-mcp/console-2026-03-10T11-25-53-011Z.log b/.playwright-mcp/console-2026-03-10T11-25-53-011Z.log new file mode 100644 index 00000000..b05b9fbb --- /dev/null +++ b/.playwright-mcp/console-2026-03-10T11-25-53-011Z.log @@ -0,0 +1 @@ +[ 311ms] [ERROR] Failed to load resource: the server responded with a status of 404 () @ https://resources.anthropic.com/favicon.ico:0 diff --git a/.playwright-mcp/page-2026-03-10T11-26-05-870Z.png b/.playwright-mcp/page-2026-03-10T11-26-05-870Z.png new file mode 100644 index 00000000..3fc96f9f Binary files /dev/null and b/.playwright-mcp/page-2026-03-10T11-26-05-870Z.png differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 839ab9b0..31f83d78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -862,6 +862,16 @@ This release closes out the Flutter Web testing story. v1.1.13 fixed the *findin ## [1.0.9] - 2026-03-11 ### Added +- `sleep` command — pause execution for a given number of milliseconds. Supports scalar (`- sleep: 500`) and mapping syntax +- `isKeyboardVisible` command — query whether the soft keyboard is currently shown. Returns result in `CommandResult.Data` (boolean). Available via YAML, JSON, and REST API +- `hideKeyboard` strategy field — specify `strategy: appium|escape|esc|back` to force a specific dismissal method instead of trying all three +- `KeyboardVisible` field added to `StateSnapshot` for richer state introspection +- REST API server (`maestro-runner server`) — session-based HTTP server for executing Maestro steps via JSON instead of YAML flow files. Supports session management, screenshots, view hierarchy, and device info. Configurable port via `--port` flag or `MAESTRO_SERVER_PORT` env var + ```bash + maestro-runner --platform android server --port 9999 + ``` +- JSON step unmarshaling (`pkg/flow/json.go`) — all step types can now be deserialized from JSON, enabling the REST API execute endpoint +- JSON struct tags on all flow step types and Selector for proper serialization/deserialization - **Desktop browser testing** — new `--platform web` with built-in CDP driver for Chrome/Chromium. Headless by default, `--headed` for visible browser. Supports parallel browser execution ```bash maestro-runner --platform web test flow.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f05b752a..432dfae8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,32 @@ make check ### Commit Messages -Use clear, descriptive commit messages: +This repository enforces Conventional Commit style commit titles through a `commit-msg` hook. + +Install hooks once after cloning: + +```bash +make hooks-install +``` + +Required first-line format: + +```text +()!: +: +``` + +Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` + +Examples: + +```text +feat(ios): add retry for session creation +fix: handle empty response body +chore!: remove deprecated flag +``` + +Use clear and descriptive messages: ``` Add support for swipe gestures diff --git a/DESIGN.md b/DESIGN.md index 9cf7d233..fb49eca2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -21,12 +21,29 @@ All drivers implement the same interface. ### Three independent parts -**1. YAML Parser** (`pkg/flow`) — Parses Maestro flow files into typed step structures. Changes here don't affect drivers. +**1. Step Parser** (`pkg/flow`) — Parses Maestro steps from YAML flow files or JSON (for the REST API). Changes here don't affect drivers. **2. Driver** (`pkg/core`, `pkg/driver`) — Interface that all backends implement. Adding a new driver means implementing the interface — nothing else changes. **3. Report** (`pkg/report`) — Consumes execution results and generates reports (JSON, HTML). Changes here don't affect drivers. +### REST API Server + +The `server` package (`pkg/server`) provides an alternative entry point. Instead of parsing YAML files, it accepts JSON steps over HTTP and delegates them to a Driver session. This enables programmatic automation from any language. + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ YAML files │──────▶│ │ │ │ +│ (parser) │ │ Driver │──────▶│ Report │ +│ │ │ (contract) │ │ (generator) │ +│ JSON / HTTP │──────▶│ │ │ │ +│ (server) │ └──────┬───────┘ └──────────────┘ +└──────────────┘ │ + ┌───────────────┼───────────────┐ + │ │ │ + UIAutomator2 Appium WDA +``` + ### Impact matrix | Change | Parser | Driver | Report | diff --git a/DEVELOPER.md b/DEVELOPER.md index 91305830..3376a361 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -29,8 +29,9 @@ How the code is organized and how to extend it. | `pkg/driver/wda` | WebDriverAgent driver (iOS) | | `pkg/driver/mock` | Mock driver for testing | | `pkg/executor` | Flow runner — orchestrates step execution and callbacks | -| `pkg/flow` | YAML parsing, Step types, Selectors | +| `pkg/flow` | Step types, Selectors, YAML and JSON parsing | | `pkg/jsengine` | JavaScript evaluation engine (evalScript, assertTrue) | +| `pkg/server` | REST API server — session-based HTTP bridge to core.Driver | | `pkg/report` | JSON and HTML report generation | | `pkg/uiautomator2` | UIAutomator2 HTTP protocol client | | `pkg/validator` | Pre-execution flow validation and tag filtering | diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/ISSUE_TEMPLATE/bug_report.md similarity index 100% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/ISSUE_TEMPLATE/feature_request.md similarity index 100% rename from .github/ISSUE_TEMPLATE/feature_request.md rename to ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/question.md b/ISSUE_TEMPLATE/question.md similarity index 100% rename from .github/ISSUE_TEMPLATE/question.md rename to ISSUE_TEMPLATE/question.md diff --git a/Makefile b/Makefile index b21d7477..4778938e 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build clean test test-race test-coverage test-coverage-check test-fuzz bench install check ci fmt imports fumpt staticcheck revive vet errcheck nilaway gosec ineffassign deadcode govulncheck +.PHONY: build clean test test-race test-coverage test-coverage-check test-fuzz bench install check ci fmt imports fumpt staticcheck revive vet errcheck nilaway gosec ineffassign deadcode govulncheck lint-py lint-py-fix client-test client-test-ts client-test-py hooks-install # Build variables BINARY_NAME=maestro-runner @@ -140,6 +140,31 @@ run: validate: ./$(BINARY_NAME) validate $(FLOW) +# Python lint targets +lint-py: + cd client/python && .venv/bin/ruff check maestro_runner tests + cd client/python && .venv/bin/mypy maestro_runner + +lint-py-fix: + cd client/python && .venv/bin/ruff check --fix maestro_runner tests + cd client/python && .venv/bin/ruff format maestro_runner tests + +# Client unit test targets +client-test-ts: + cd client/typescript && npm run test:unit + +client-test-py: + cd client/python && .venv/bin/python -m pytest tests/test_client.py tests/test_models.py -v + +client-test: client-test-ts client-test-py + @echo "Client unit tests passed" + +hooks-install: + @git config core.hooksPath .githooks + @chmod +x .githooks/commit-msg + @chmod +x .githooks/pre-push + @echo "Installed git hooks from .githooks" + # Release release: clean build-all @echo "Release builds created:" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/PULL_REQUEST_TEMPLATE.md rename to PULL_REQUEST_TEMPLATE.md diff --git a/README.md b/README.md index 9c4ea9ad..1d45cc2f 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,89 @@ maestro-runner --driver devicelab --platform android test flows/ All existing Maestro YAML flows work as-is — no changes needed. The driver also includes bounds stabilization for animated elements and improved special character handling in text selectors. +## REST API Server + +maestro-runner includes an HTTP server for programmatic test execution via JSON, useful for building custom tooling, CI integrations, or language-specific clients. + +```bash +maestro-runner server # Start on default port 9999 +maestro-runner server --port 8080 # Custom port +maestro-runner --platform android server # Pre-select platform +``` + +**Endpoints:** + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/session` | Create a session (returns `sessionId`) | +| `POST` | `/session/{id}/execute` | Execute a step (JSON body) | +| `GET` | `/session/{id}/screenshot` | Take a screenshot (PNG) | +| `GET` | `/session/{id}/source` | Get view hierarchy (XML/JSON) | +| `GET` | `/session/{id}/device-info` | Get device info | +| `DELETE` | `/session/{id}` | Delete session | +| `GET` | `/status` | Server status | + +**Example — execute a tap via JSON:** + +```bash +# Create session +SID=$(curl -s -X POST http://localhost:9999/session \ + -d '{"platformName":"android"}' | jq -r .sessionId) + +# Execute a step +curl -X POST http://localhost:9999/session/$SID/execute \ + -d '{"type":"tapOn","selector":"Login"}' +``` + +## Language Clients (TypeScript & Python) + +If you prefer writing tests in code instead of YAML, maestro-runner ships official +clients that wrap the REST API above. You get the same drivers, selectors, and +assertions as YAML flows — with IDE autocomplete, type checking, and Page Object +Models. + +- **[TypeScript client](docs/clients/typescript.md)** — `MaestroClient` for Node.js / Jest / Vitest / Playwright test runners. +- **[Python client](docs/clients/python.md)** — `MaestroClient` for pytest-based E2E suites, with built-in `pytest-xdist` parallel support. + +Both clients talk to the server started with `maestro-runner server`, so the only +prerequisite is a running server: + +```bash +maestro-runner server --port 9999 +``` + +**TypeScript — quick taste:** + +```ts +import { MaestroClient } from "maestro-runner"; + +const client = new MaestroClient("http://localhost:9999"); +await client.createSession({ platformName: "android" }); +try { + await client.tap({ text: "Login" }); + await client.inputText("user@example.com"); + await client.assertVisible({ text: "Welcome" }); +} finally { + await client.close(); +} +``` + +**Python — quick taste:** + +```python +from maestro_runner import MaestroClient + +with MaestroClient("http://localhost:9999", + capabilities={"platformName": "android"}) as c: + c.tap(text="Login") + c.input_text("user@example.com") + c.assert_visible(text="Welcome") +``` + +See the [TypeScript tutorial](docs/clients/typescript.md) and +[Python tutorial](docs/clients/python.md) for full setup, the Page Object Model +pattern, parallel execution, and the complete method reference. + ## CI/CD Integration maestro-runner is built for CI/CD pipelines — single binary, no JVM startup, low memory footprint. Works with GitHub Actions, GitLab CI, Jenkins, CircleCI, and any CI system that supports Android emulators or iOS simulators. diff --git a/client/python/.gitignore b/client/python/.gitignore new file mode 100644 index 00000000..9ec5ad73 --- /dev/null +++ b/client/python/.gitignore @@ -0,0 +1,31 @@ +# Virtual environments +.venv/ +.venv*/ +venv/ +env/ + +# Python bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ +*.egg + +# pytest +.pytest_cache/ + +# mypy +.mypy_cache/ + +# Coverage +htmlcov/ +.coverage +coverage.xml +maestro-server.log + +# Local copied iOS driver sources +drivers/ diff --git a/client/python/DEVELOPER.md b/client/python/DEVELOPER.md new file mode 100644 index 00000000..754449b7 --- /dev/null +++ b/client/python/DEVELOPER.md @@ -0,0 +1,176 @@ +# Python Client — Developer Guide + +Development reference for the `client/python` package. + +## Prerequisites + +- **Python** ≥ 3.9 +- **venv** (ships with Python) + +## Setup + +```bash +cd client/python +python3 -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" +``` + +## Project Structure + +``` +client/python/ +├── maestro_runner/ +│ ├── __init__.py # Public API exports +│ ├── client.py # MaestroClient — main HTTP client class +│ ├── commands.py # Step builders (tap_on, input_text, swipe, …) +│ ├── models.py # Data models (ElementSelector, ExecutionResult, DeviceInfo) +│ └── exceptions.py # Error classes (MaestroError, SessionError, StepError) +├── tests/ +│ ├── conftest.py # Shared pytest fixtures — auto-starts maestro-runner server +│ ├── pages/ # Page Object Model base + page classes +│ │ ├── contact_list_page.py +│ │ └── edit_contact_page.py +│ ├── test_client.py # Unit tests (requests-mock) +│ ├── test_models.py # Model serialization tests +│ ├── test_add_contact.py +│ ├── test_contact_persists.py +│ └── test_e2e_android.py +├── pyproject.toml # Build, dependencies, tool config (ruff, mypy, pytest) +└── README.md +``` + +## Lint + +Linting uses **ruff** (style + import order + security) and **mypy** (strict type checking). + +```bash +# Check for issues +source .venv/bin/activate +ruff check maestro_runner tests +mypy maestro_runner + +# Auto-fix what's possible +ruff check --fix maestro_runner tests +ruff format maestro_runner tests +``` + +Or via the root Makefile: + +```bash +make lint-py # ruff check + mypy +make lint-py-fix # ruff check --fix + ruff format +``` + +### Key Ruff Rule Sets + +| Set | Description | +|-----|-------------| +| `E` / `W` | pycodestyle errors and warnings | +| `F` | pyflakes (undefined names, unused imports) | +| `I` | isort (import ordering) | +| `B` | flake8-bugbear (common bugs and design issues) | +| `UP` | pyupgrade (modern Python syntax) | +| `N` | pep8-naming conventions | +| `S` | flake8-bandit (security); `S101` (assert) ignored in tests | +| `RUF` | ruff-specific rules | + +### mypy + +Runs in `strict` mode on `maestro_runner/`. All public functions must be fully typed. + +## Test + +Tests use **pytest** (`pytest-xdist` for parallelism, `pytest-html` for reports) and run against a live maestro-runner server. + +```bash +# Run all tests (unit + e2e — requires emulator + server) +source .venv/bin/activate +pytest + +# Run unit tests only (no device needed) +pytest tests/test_client.py tests/test_models.py + +# Run e2e tests in parallel across connected devices +pytest tests/test_add_contact.py tests/test_contact_persists.py -n auto -v + +# Run a specific test +pytest tests/test_add_contact.py::test_add_contact -v +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Server URL | +| `MAESTRO_PLATFORM` | `android` | Target platform (`android` / `ios`) | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to maestro-runner binary | + +The `conftest.py` session fixture auto-starts the maestro-runner server if it isn't already running. In `pytest-xdist` parallel mode, each worker spawns its own server instance on a unique port targeting a specific device discovered via `adb devices`. + +### Test Reports + +HTML and JUnit XML reports are written automatically: + +``` +reports/report.html +reports/junit-report.xml +``` + +Additional analysis logs are written during test runs: + +``` +reports/pytest-run---.log +reports/server-run--.log +reports/server-latest.json +reports/artifact-summary-.json +``` + +- `pytest-run-...log` contains persisted Python log records with worker id. +- `server-run-...log` is the canonical server stdout/stderr log for that worker run. +- `server-latest.json` maps each worker id to its latest run metadata and log path. +- `artifact-summary-...json` captures artifact paths/sizes and includes failure-tail snippets when a run fails. +- Appium-style server traces appear as `[TRACE]` lines with per-command request/response, status, and duration. + +## Code Conventions + +### Architecture + +The client follows a thin layered design: + +1. **`commands.py`** — Pure functions that build step JSON payloads (`dict[str, Any]`) +2. **`client.py`** — `MaestroClient` wraps HTTP calls to the REST API; each convenience method delegates to a command builder then calls `_exec()` +3. **`models.py`** — Typed dataclasses (`ElementSelector`, `ExecutionResult`, `DeviceInfo`) with `from_dict()` / `to_dict()` for JSON serialization +4. **`exceptions.py`** — Error hierarchy (`MaestroError` → `SessionError` / `StepError`) + +### Adding a New Command + +1. Add a builder function in `maestro_runner/commands.py`: + +```python +def my_command(arg: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "myCommand", "arg": arg} + if label is not None: + step["label"] = label + return step +``` + +2. Add a convenience method in `maestro_runner/client.py`: + +```python +def my_command(self, arg: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.my_command(arg, label=label)) +``` + +3. Export any new public names from `maestro_runner/__init__.py`. + +### Page Object Model (Tests) + +Tests use the Page Object pattern to keep test logic decoupled from selectors: + +- Concrete pages expose domain actions (e.g., `contact_list.open_create_contact()`) +- Tests compose page methods; they never call `client.tap()` directly + +### Type Annotations + +All production code in `maestro_runner/` must be fully annotated. mypy runs in strict mode so partial annotations will fail CI. Use `from __future__ import annotations` at the top of each file to enable PEP 604 (`X | Y`) union syntax on Python 3.9. diff --git a/client/python/README.md b/client/python/README.md new file mode 100644 index 00000000..a71a2fa7 --- /dev/null +++ b/client/python/README.md @@ -0,0 +1,114 @@ +# maestro-runner Python Client + +Python client for the `maestro-runner` REST API server. + +## Installation + +```bash +pip install -e . +``` + +## Quick Start + +```python +from maestro_runner import MaestroClient + +# Start maestro-runner server first: +# maestro-runner server --port 9999 + +with MaestroClient( + "http://localhost:9999", + capabilities={"platformName": "android", "appId": "com.example.app"}, +) as c: + c.launch_app("com.example.app") + c.tap(text="Login") + c.input_text("user@example.com") + c.assert_visible(text="Dashboard", timeout_ms=10000) + + info = c.device_info() + print(f"Device: {info.device_name} ({info.platform} {info.os_version})") +``` + +## Running Tests + +### Sequential (single device) + +```bash +cd client/python +python3 -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" + +pytest tests/test_add_contact.py tests/test_contact_persists.py -v +``` + +### Parallel (multiple devices) + +Run tests in parallel across multiple Android emulators using +[pytest-xdist](https://pypi.org/project/pytest-xdist/). Each worker +automatically starts its own `maestro-runner` server on a unique port and +targets a specific device. + +**Prerequisites:** + +1. Two or more Android emulators running (`adb devices` shows them). +2. `pytest-xdist` installed: + + ```bash + pip install pytest-xdist + ``` + +**Run with `-n `:** + +```bash +# Run on 2 emulators in parallel +pytest tests/test_add_contact.py tests/test_contact_persists.py -n 2 -v +``` + +Worker `gw0` gets the first device (e.g. `emulator-5554`) on port 9999, +`gw1` gets the second device (e.g. `emulator-5556`) on port 10000, and so on. + +**Environment variables:** + +| Variable | Default | Description | +|----------------------|--------------------------|------------------------------------| +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Base URL (port used as starting port in parallel mode) | +| `MAESTRO_PLATFORM` | `android` | Target platform | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to the maestro-runner binary | + +## API + +See `maestro_runner/client.py` for the full API. Highlights beyond the basics above: + +```python +# Grant/deny app permissions (omitted appId falls back to the flow's appId) +c.set_permissions("com.example.app", {"camera": "allow", "microphone": "deny"}) + +# Reset browser permissions (web platform only) +c.reset_permissions() + +# Run JavaScript inside a mobile WebView via CDP +c.eval_webview_script("document.title", output="title") +c.run_webview_script("scripts/login.js", env={"USERNAME": "alice"}, output="result") +``` + +### Wrapped step types + +| Method | Server step | +|--------|-------------| +| `set_permissions` / `reset_permissions` | `setPermissions` / `resetPermissions` | +| `eval_webview_script` / `run_webview_script` | `evalWebViewScript` / `runWebViewScript` | +| `double_tap_on` / `long_press_on` | `doubleTapOn` / `longPressOn` | +| `drag_and_drop` / `scroll_until_visible` | `dragAndDrop` / `scrollUntilVisible` | +| `assert_screenshot` / `take_screenshot` | `assertScreenshot` / `takeScreenshot` | +| `copy_text_from` / `paste_text` / `set_clipboard` | `copyTextFrom` / `pasteText` / `setClipboard` | +| `assert_with_ai` / `eval_script` / `run_script` | `assertWithAI` / `evalScript` / `runScript` | +| `eval_browser_script` | `evalBrowserScript` | +| `set_location` / `set_airplane_mode` / `toggle_airplane_mode` | `setLocation` / `setAirplaneMode` / `toggleAirplaneMode` | +| `set_network_conditions` / `open_notifications` | `setNetworkConditions` / `openNotifications` | +| `set_dark_mode` / `set_orientation` | `setDarkMode` / `setOrientation` | +| `open_browser` / `switch_tab` / `close_tab` | `openBrowser` / `switchTab` / `closeTab` | +| `get_console_logs` / `clear_console_logs` / `assert_no_js_errors` | `getConsoleLogs` / `clearConsoleLogs` / `assertNoJSErrors` | +| `mock_network` | `mockNetwork` | + +Every method maps to a server step type; any step not yet wrapped as a typed method can +still be sent via `c.execute_step({"type": "...", ...})`. diff --git a/client/python/maestro_runner/__init__.py b/client/python/maestro_runner/__init__.py new file mode 100644 index 00000000..2101d6b5 --- /dev/null +++ b/client/python/maestro_runner/__init__.py @@ -0,0 +1,14 @@ +"""maestro_runner — Python client for maestro-runner REST API.""" + +from maestro_runner.client import MaestroClient +from maestro_runner.exceptions import MaestroError +from maestro_runner.models import DeviceInfo, ElementInfo, ElementSelector, ExecutionResult + +__all__ = [ + "DeviceInfo", + "ElementInfo", + "ElementSelector", + "ExecutionResult", + "MaestroClient", + "MaestroError", +] diff --git a/client/python/maestro_runner/client.py b/client/python/maestro_runner/client.py new file mode 100644 index 00000000..8ccc963f --- /dev/null +++ b/client/python/maestro_runner/client.py @@ -0,0 +1,575 @@ +"""MaestroClient — main client class for maestro-runner REST API.""" + +from __future__ import annotations + +import logging +from typing import Any + +import requests + +from maestro_runner import commands +from maestro_runner.exceptions import MaestroError, SessionError, StepError +from maestro_runner.models import DeviceInfo, ElementSelector, ExecutionResult + +logger = logging.getLogger("maestro_runner") + + +class MaestroClient: + """Client for the maestro-runner REST server. + + Usage:: + + with MaestroClient("http://localhost:9999", + capabilities={"platformName": "android"}) as c: + c.tap(text="Login") + c.input_text("user@example.com") + """ + + def __init__( + self, + base_url: str = "http://localhost:9999", + capabilities: dict[str, Any] | None = None, + timeout: float = 60.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self.timeout = timeout + self._session = requests.Session() + self._session_id: str | None = None + + if capabilities: + self._create_session(capabilities) + + # --- Context manager --- + + def __enter__(self) -> MaestroClient: + return self + + def __exit__(self, *_: Any) -> None: + self.close() + + def close(self) -> None: + """Delete the server session and release resources.""" + if self._session_id: + try: + self._session.delete( + f"{self.base_url}/session/{self._session_id}", + timeout=self.timeout, + ) + except requests.RequestException: + pass + self._session_id = None + + # --- Session management --- + + def _create_session(self, capabilities: dict[str, Any]) -> None: + resp = self._session.post( + f"{self.base_url}/session", + json=capabilities, + timeout=self.timeout, + ) + if resp.status_code != 200: + raise SessionError( + f"Failed to create session: {resp.text}", + status_code=resp.status_code, + ) + data = resp.json() + self._session_id = data["sessionId"] + logger.info("Session created: %s", self._session_id) + + @property + def session_id(self) -> str | None: + return self._session_id + + def _require_session(self) -> str: + if not self._session_id: + raise SessionError( + "No active session. Pass capabilities to __init__ or call close() first." + ) + return self._session_id + + # --- Low-level --- + + def execute_step(self, step: dict[str, Any]) -> ExecutionResult: + """Execute a raw step dict via POST /session/{id}/execute.""" + sid = self._require_session() + resp = self._session.post( + f"{self.base_url}/session/{sid}/execute", + json=step, + timeout=self.timeout, + ) + if resp.status_code != 200: + raise MaestroError(f"Execute failed: {resp.text}", status_code=resp.status_code) + return ExecutionResult.from_dict(resp.json()) + + def _exec(self, step: dict[str, Any]) -> ExecutionResult: + """Execute a step, raising StepError on failure unless optional.""" + result = self.execute_step(step) + if not result.success and not step.get("optional", False): + raise StepError(result.message or "step failed") + return result + + # --- App lifecycle --- + + def launch_app( + self, + app_id: str, + *, + clear_state: bool | None = None, + stop_app: bool | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec( + commands.launch_app(app_id, clear_state=clear_state, stop_app=stop_app, label=label) + ) + + def stop_app(self, app_id: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.stop_app(app_id, label=label)) + + def clear_state(self, app_id: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.clear_state(app_id, label=label)) + + def open_link(self, link: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.open_link(link, label=label)) + + def set_permissions( + self, app_id: str, permissions: dict[str, str], *, label: str | None = None + ) -> ExecutionResult: + return self._exec(commands.set_permissions(app_id, permissions, label=label)) + + def reset_permissions(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.reset_permissions(label=label)) + + # --- Tap --- + + def tap( + self, + *, + text: str | None = None, + id: str | None = None, + index: int | None = None, + selector: ElementSelector | None = None, + long_press: bool = False, + wait_until_visible: bool | None = None, + retry_if_no_change: bool | None = None, + enabled: bool | None = None, + checked: bool | None = None, + focused: bool | None = None, + selected: bool | None = None, + optional: bool = False, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.tap_on( + text=text, id=id, index=index, selector=selector, + long_press=long_press, wait_until_visible=wait_until_visible, + retry_if_no_change=retry_if_no_change, + enabled=enabled, checked=checked, focused=focused, selected=selected, + optional=optional, label=label, + )) + + def long_press( + self, + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec( + commands.tap_on(text=text, id=id, selector=selector, long_press=True, label=label) + ) + + def tap_on_point( + self, point: str, *, long_press: bool = False, label: str | None = None + ) -> ExecutionResult: + step: dict[str, Any] = {"type": "tapOnPoint", "point": point} + if long_press: + step["longPress"] = True + if label: + step["label"] = label + return self._exec(step) + + # --- Input --- + + def input_text(self, text: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.input_text(text, label=label)) + + def erase_text( + self, characters: int | None = None, *, label: str | None = None + ) -> ExecutionResult: + return self._exec(commands.erase_text(characters, label=label)) + + def press_key(self, code: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.press_key(code, label=label)) + + def back(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.back(label=label)) + + def hide_keyboard( + self, *, strategy: str | None = None, label: str | None = None + ) -> ExecutionResult: + return self._exec(commands.hide_keyboard(strategy=strategy, label=label)) + + def wait_for_animation_to_end( + self, + *, + sleep_ms: int | None = None, + threshold: float | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.wait_for_animation_to_end( + sleep_ms=sleep_ms, threshold=threshold, label=label + )) + + # --- Scroll / Swipe --- + + def scroll(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.scroll(label=label)) + + def swipe( + self, direction: str, *, duration_ms: int = 400, label: str | None = None + ) -> ExecutionResult: + return self._exec(commands.swipe(direction, duration_ms=duration_ms, label=label)) + + def swipe_on( + self, + *, + text: str | None = None, + id: str | None = None, + direction: str = "UP", + duration_ms: int = 400, + label: str | None = None, + ) -> ExecutionResult: + step: dict[str, Any] = { + "type": "swipe", + "direction": direction.upper(), + "duration": duration_ms, + } + if text is not None: + step["selector"] = {"text": text} + elif id is not None: + step["selector"] = {"id": id} + if label: + step["label"] = label + return self._exec(step) + + # --- Assertions --- + + def assert_visible( + self, + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + timeout_ms: int | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.assert_visible( + text=text, id=id, selector=selector, timeout_ms=timeout_ms, label=label, + )) + + def assert_not_visible( + self, + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + timeout_ms: int | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.assert_not_visible( + text=text, id=id, selector=selector, timeout_ms=timeout_ms, label=label, + )) + + def element_exists(self, *, text: str | None = None, id: str | None = None) -> bool: + """Check if an element exists without raising. Returns bool.""" + step = commands.assert_visible(text=text, id=id, optional=True) + result = self.execute_step(step) + return result.success + + # --- Self-healing multi-selector tap --- + + def tap_first_match( + self, selectors: list[dict[str, Any]], *, step: str = "" + ) -> ExecutionResult: + """Try each selector in order; return on the first successful tap.""" + last_result = None + for sel in selectors: + tap_step = {"type": "tapOn", "optional": True} + tap_step.update(sel) + result = self.execute_step(tap_step) + if result.success: + logger.info("tap_first_match: matched selector %s (step=%s)", sel, step) + return result + last_result = result + if last_result is None: + raise StepError("tap_first_match: no selectors provided") + raise StepError( + f"tap_first_match: none of {len(selectors)} selectors matched (step={step})" + ) + + # --- Device queries --- + + def device_info(self) -> DeviceInfo: + sid = self._require_session() + resp = self._session.get( + f"{self.base_url}/session/{sid}/device-info", + timeout=self.timeout, + ) + if resp.status_code != 200: + raise MaestroError(f"device-info failed: {resp.text}", status_code=resp.status_code) + return DeviceInfo.from_dict(resp.json()) + + def screenshot(self) -> bytes: + sid = self._require_session() + resp = self._session.get( + f"{self.base_url}/session/{sid}/screenshot", + timeout=self.timeout, + ) + if resp.status_code != 200: + raise MaestroError(f"screenshot failed: {resp.text}", status_code=resp.status_code) + return resp.content + + def view_hierarchy(self) -> str: + sid = self._require_session() + resp = self._session.get( + f"{self.base_url}/session/{sid}/source", + timeout=self.timeout, + ) + if resp.status_code != 200: + raise MaestroError(f"source failed: {resp.text}", status_code=resp.status_code) + return resp.text + + # --- WebView (mobile WebView via CDP) --- + + def eval_webview_script( + self, script: str, *, output: str | None = None, label: str | None = None + ) -> ExecutionResult: + return self._exec(commands.eval_webview_script(script, output=output, label=label)) + + def run_webview_script( + self, + file: str, + *, + env: dict[str, str] | None = None, + output: str | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec( + commands.run_webview_script(file, env=env, output=output, label=label) + ) + + # --- Gestures --- + + def double_tap_on( + self, + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + optional: bool = False, + retry_tap_if_no_change: bool | None = None, + wait_until_visible: bool | None = None, + wait_to_settle_timeout_ms: int | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.double_tap_on( + text=text, id=id, selector=selector, optional=optional, + retry_tap_if_no_change=retry_tap_if_no_change, + wait_until_visible=wait_until_visible, + wait_to_settle_timeout_ms=wait_to_settle_timeout_ms, label=label, + )) + + def long_press_on( + self, + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + duration_ms: int | None = None, + optional: bool = False, + retry_tap_if_no_change: bool | None = None, + wait_until_visible: bool | None = None, + wait_to_settle_timeout_ms: int | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.long_press_on( + text=text, id=id, selector=selector, duration_ms=duration_ms, optional=optional, + retry_tap_if_no_change=retry_tap_if_no_change, + wait_until_visible=wait_until_visible, + wait_to_settle_timeout_ms=wait_to_settle_timeout_ms, label=label, + )) + + def drag_and_drop( + self, + *, + from_: str | dict[str, Any] | ElementSelector, + to: str | dict[str, Any] | ElementSelector, + hold_duration: int | None = None, + duration: int | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.drag_and_drop( + from_=from_, to=to, hold_duration=hold_duration, duration=duration, label=label, + )) + + def scroll_until_visible( + self, + *, + element: str | dict[str, Any] | ElementSelector, + from_: str | dict[str, Any] | ElementSelector | None = None, + direction: str | None = None, + max_scrolls: int | None = None, + speed: int | None = None, + visibility_percentage: int | None = None, + center_element: bool | None = None, + wait_to_settle_timeout_ms: int | None = None, + optional: bool = False, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.scroll_until_visible( + element=element, from_=from_, direction=direction, max_scrolls=max_scrolls, + speed=speed, visibility_percentage=visibility_percentage, + center_element=center_element, wait_to_settle_timeout_ms=wait_to_settle_timeout_ms, + optional=optional, label=label, + )) + + # --- Assertions & media --- + + def assert_screenshot( + self, + *, + path: str | None = None, + crop_on: str | dict[str, Any] | ElementSelector | None = None, + threshold_percentage: float | None = None, + optional: bool = False, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.assert_screenshot( + path=path, crop_on=crop_on, threshold_percentage=threshold_percentage, + optional=optional, label=label, + )) + + def take_screenshot( + self, + *, + path: str | None = None, + crop_on: str | dict[str, Any] | ElementSelector | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.take_screenshot(path=path, crop_on=crop_on, label=label)) + + def copy_text_from( + self, + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.copy_text_from(text=text, id=id, selector=selector, label=label)) + + def paste_text(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.paste_text(label=label)) + + def set_clipboard(self, text: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.set_clipboard(text, label=label)) + + # --- AI & scripting --- + + def assert_with_ai(self, assertion: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.assert_with_ai(assertion, label=label)) + + def eval_script(self, script: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.eval_script(script, label=label)) + + def run_script( + self, + *, + script: str | None = None, + file: str | None = None, + env: dict[str, str] | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.run_script(script=script, file=file, env=env, label=label)) + + def eval_browser_script( + self, script: str, *, output: str | None = None, label: str | None = None + ) -> ExecutionResult: + return self._exec(commands.eval_browser_script(script, output=output, label=label)) + + # --- Device control --- + + def set_location( + self, latitude: str, longitude: str, *, label: str | None = None + ) -> ExecutionResult: + return self._exec(commands.set_location(latitude, longitude, label=label)) + + def set_airplane_mode(self, enabled: bool, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.set_airplane_mode(enabled, label=label)) + + def toggle_airplane_mode(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.toggle_airplane_mode(label=label)) + + def set_network_conditions( + self, + *, + offline: bool | None = None, + latency: float | None = None, + download_speed: float | None = None, + upload_speed: float | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.set_network_conditions( + offline=offline, latency=latency, download_speed=download_speed, + upload_speed=upload_speed, label=label, + )) + + def open_notifications(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.open_notifications(label=label)) + + def set_dark_mode(self, enabled: bool, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.set_dark_mode(enabled, label=label)) + + def set_orientation(self, orientation: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.set_orientation(orientation, label=label)) + + # --- Browser (web platform) --- + + def open_browser(self, url: str | None = None, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.open_browser(url, label=label)) + + def switch_tab( + self, + *, + tab_label: str | None = None, + index: int | None = None, + url: str | None = None, + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.switch_tab( + tab_label=tab_label, index=index, url=url, label=label, + )) + + def close_tab(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.close_tab(label=label)) + + def get_console_logs(self, output: str, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.get_console_logs(output, label=label)) + + def clear_console_logs(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.clear_console_logs(label=label)) + + def assert_no_js_errors(self, *, label: str | None = None) -> ExecutionResult: + return self._exec(commands.assert_no_js_errors(label=label)) + + def mock_network( + self, + *, + url: str, + method: str | None = None, + response: dict[str, Any], + label: str | None = None, + ) -> ExecutionResult: + return self._exec(commands.mock_network( + url=url, method=method, response=response, label=label, + )) diff --git a/client/python/maestro_runner/commands.py b/client/python/maestro_runner/commands.py new file mode 100644 index 00000000..9abecbd9 --- /dev/null +++ b/client/python/maestro_runner/commands.py @@ -0,0 +1,703 @@ +"""Command builders — produce Go step JSON for the REST API.""" + +from __future__ import annotations + +from typing import Any + +from maestro_runner.models import ElementSelector + + +def _selector_value( + *, + text: str | None = None, + id: str | None = None, + index: int | None = None, + selector: ElementSelector | None = None, + enabled: bool | None = None, + checked: bool | None = None, + focused: bool | None = None, + selected: bool | None = None, +) -> str | dict[str, Any]: + """Build a selector value (string for text-only, object otherwise).""" + d: dict[str, Any] = {} + if selector is not None: + d.update(selector.to_dict()) + if text is not None: + d["text"] = text + if id is not None: + d["id"] = id + if index is not None: + d["index"] = str(index) + if enabled is not None: + d["enabled"] = enabled + if checked is not None: + d["checked"] = checked + if focused is not None: + d["focused"] = focused + if selected is not None: + d["selected"] = selected + # Compact form: text-only selector → plain string + if list(d.keys()) == ["text"]: + return str(d["text"]) + return d + + +def tap_on( + *, + text: str | None = None, + id: str | None = None, + index: int | None = None, + selector: ElementSelector | None = None, + long_press: bool = False, + wait_until_visible: bool | None = None, + retry_if_no_change: bool | None = None, + enabled: bool | None = None, + checked: bool | None = None, + focused: bool | None = None, + selected: bool | None = None, + optional: bool = False, + timeout: int | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "tapOn"} + step["selector"] = _selector_value( + text=text, id=id, index=index, selector=selector, + enabled=enabled, checked=checked, focused=focused, selected=selected, + ) + if long_press: + step["longPress"] = True + if wait_until_visible is not None: + step["waitUntilVisible"] = wait_until_visible + if retry_if_no_change is not None: + step["retryTapIfNoChange"] = retry_if_no_change + if optional: + step["optional"] = True + if timeout is not None: + step["timeout"] = timeout + if label is not None: + step["label"] = label + return step + + +def input_text(text: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "inputText", "text": text} + if label is not None: + step["label"] = label + return step + + +def erase_text(characters: int | None = None, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "eraseText"} + if characters is not None: + step["charactersToErase"] = characters + if label is not None: + step["label"] = label + return step + + +def press_key(code: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "pressKey", "key": code} + if label is not None: + step["label"] = label + return step + + +def back(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "back"} + if label is not None: + step["label"] = label + return step + + +def scroll(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "scroll"} + if label is not None: + step["label"] = label + return step + + +def swipe(direction: str, *, duration_ms: int = 400, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = { + "type": "swipe", + "direction": direction.upper(), + "duration": duration_ms, + } + if label is not None: + step["label"] = label + return step + + +def assert_visible( + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + timeout_ms: int | None = None, + optional: bool = False, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "assertVisible"} + step["selector"] = _selector_value(text=text, id=id, selector=selector) + if timeout_ms is not None: + step["timeout"] = timeout_ms + if optional: + step["optional"] = True + if label is not None: + step["label"] = label + return step + + +def assert_not_visible( + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + timeout_ms: int | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "assertNotVisible"} + step["selector"] = _selector_value(text=text, id=id, selector=selector) + if timeout_ms is not None: + step["timeout"] = timeout_ms + if label is not None: + step["label"] = label + return step + + +def launch_app( + app_id: str, + *, + clear_state: bool | None = None, + stop_app: bool | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "launchApp", "appId": app_id} + if clear_state is not None: + step["clearState"] = clear_state + if stop_app is not None: + step["stopApp"] = stop_app + if label is not None: + step["label"] = label + return step + + +def stop_app(app_id: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "stopApp", "appId": app_id} + if label is not None: + step["label"] = label + return step + + +def clear_state(app_id: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "clearState", "appId": app_id} + if label is not None: + step["label"] = label + return step + + +def open_link(link: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "openLink", "link": link} + if label is not None: + step["label"] = label + return step + + +def set_permissions( + app_id: str, + permissions: dict[str, str], + *, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = { + "type": "setPermissions", + "appId": app_id, + "permissions": permissions, + } + if label is not None: + step["label"] = label + return step + + +def reset_permissions(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "resetPermissions"} + if label is not None: + step["label"] = label + return step + + +def eval_webview_script( + script: str, + *, + output: str | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "evalWebViewScript", "script": script} + if output is not None: + step["output"] = output + if label is not None: + step["label"] = label + return step + + +def run_webview_script( + file: str, + *, + env: dict[str, str] | None = None, + output: str | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "runWebViewScript", "file": file} + if env is not None: + step["env"] = env + if output is not None: + step["output"] = output + if label is not None: + step["label"] = label + return step + + +def _coord_or_selector( + value: str | dict[str, Any] | ElementSelector, +) -> str | dict[str, Any]: + if isinstance(value, str): + return value + if isinstance(value, ElementSelector): + return value.to_dict() + return _selector_value(**value) + + +# --------------------------------------------------------------------------- +# Gestures +# --------------------------------------------------------------------------- + + +def double_tap_on( + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + optional: bool = False, + retry_tap_if_no_change: bool | None = None, + wait_until_visible: bool | None = None, + wait_to_settle_timeout_ms: int | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "doubleTapOn"} + step["selector"] = _selector_value( + text=text, id=id, selector=selector, + ) + if optional: + step["optional"] = True + if retry_tap_if_no_change is not None: + step["retryTapIfNoChange"] = retry_tap_if_no_change + if wait_until_visible is not None: + step["waitUntilVisible"] = wait_until_visible + if wait_to_settle_timeout_ms is not None: + step["waitToSettleTimeoutMs"] = wait_to_settle_timeout_ms + if label is not None: + step["label"] = label + return step + + +def long_press_on( + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + duration_ms: int | None = None, + optional: bool = False, + retry_tap_if_no_change: bool | None = None, + wait_until_visible: bool | None = None, + wait_to_settle_timeout_ms: int | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "longPressOn"} + step["selector"] = _selector_value(text=text, id=id, selector=selector) + if duration_ms is not None: + step["duration"] = duration_ms + if optional: + step["optional"] = True + if retry_tap_if_no_change is not None: + step["retryTapIfNoChange"] = retry_tap_if_no_change + if wait_until_visible is not None: + step["waitUntilVisible"] = wait_until_visible + if wait_to_settle_timeout_ms is not None: + step["waitToSettleTimeoutMs"] = wait_to_settle_timeout_ms + if label is not None: + step["label"] = label + return step + + +def drag_and_drop( + *, + from_: str | dict[str, Any] | ElementSelector, + to: str | dict[str, Any] | ElementSelector, + hold_duration: int | None = None, + duration: int | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "dragAndDrop"} + step["from"] = _coord_or_selector(from_) + step["to"] = _coord_or_selector(to) + if hold_duration is not None: + step["holdDuration"] = hold_duration + if duration is not None: + step["duration"] = duration + if label is not None: + step["label"] = label + return step + + +def scroll_until_visible( + *, + element: str | dict[str, Any] | ElementSelector, + from_: str | dict[str, Any] | ElementSelector | None = None, + direction: str | None = None, + max_scrolls: int | None = None, + speed: int | None = None, + visibility_percentage: int | None = None, + center_element: bool | None = None, + wait_to_settle_timeout_ms: int | None = None, + optional: bool = False, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "scrollUntilVisible"} + step["element"] = _coord_or_selector(element) + if from_ is not None: + step["from"] = _coord_or_selector(from_) + if direction is not None: + step["direction"] = direction + if max_scrolls is not None: + step["maxScrolls"] = max_scrolls + if speed is not None: + step["speed"] = speed + if visibility_percentage is not None: + step["visibilityPercentage"] = visibility_percentage + if center_element is not None: + step["centerElement"] = center_element + if wait_to_settle_timeout_ms is not None: + step["waitToSettleTimeoutMs"] = wait_to_settle_timeout_ms + if optional: + step["optional"] = True + if label is not None: + step["label"] = label + return step + + +# --------------------------------------------------------------------------- +# Assertions & media +# --------------------------------------------------------------------------- + + +def _crop_on(value: str | dict[str, Any] | ElementSelector) -> str | dict[str, Any]: + if isinstance(value, str): + return value + if isinstance(value, ElementSelector): + return value.to_dict() + return _selector_value(**value) + + +def assert_screenshot( + *, + path: str | None = None, + crop_on: str | dict[str, Any] | ElementSelector | None = None, + threshold_percentage: float | None = None, + optional: bool = False, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "assertScreenshot"} + if path is not None: + step["path"] = path + if crop_on is not None: + step["cropOn"] = _crop_on(crop_on) + if threshold_percentage is not None: + step["thresholdPercentage"] = threshold_percentage + if optional: + step["optional"] = True + if label is not None: + step["label"] = label + return step + + +def take_screenshot( + *, + path: str | None = None, + crop_on: str | dict[str, Any] | ElementSelector | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "takeScreenshot"} + if path is not None: + step["path"] = path + if crop_on is not None: + step["cropOn"] = _crop_on(crop_on) + if label is not None: + step["label"] = label + return step + + +def copy_text_from( + *, + text: str | None = None, + id: str | None = None, + selector: ElementSelector | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "copyTextFrom"} + step["selector"] = _selector_value(text=text, id=id, selector=selector) + if label is not None: + step["label"] = label + return step + + +def paste_text(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "pasteText"} + if label is not None: + step["label"] = label + return step + + +def set_clipboard(text: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "setClipboard", "text": text} + if label is not None: + step["label"] = label + return step + + +# --------------------------------------------------------------------------- +# AI & scripting +# --------------------------------------------------------------------------- + + +def assert_with_ai(assertion: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "assertWithAI", "assertion": assertion} + if label is not None: + step["label"] = label + return step + + +def eval_script(script: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "evalScript", "script": script} + if label is not None: + step["label"] = label + return step + + +def run_script( + *, + script: str | None = None, + file: str | None = None, + env: dict[str, str] | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "runScript"} + if script is not None: + step["script"] = script + if file is not None: + step["file"] = file + if env is not None: + step["env"] = env + if label is not None: + step["label"] = label + return step + + +def eval_browser_script( + script: str, + *, + output: str | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "evalBrowserScript", "script": script} + if output is not None: + step["output"] = output + if label is not None: + step["label"] = label + return step + + +# --------------------------------------------------------------------------- +# Device control +# --------------------------------------------------------------------------- + + +def set_location(latitude: str, longitude: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "setLocation", "latitude": latitude, "longitude": longitude} + if label is not None: + step["label"] = label + return step + + +def set_airplane_mode(enabled: bool, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "setAirplaneMode", "enabled": enabled} + if label is not None: + step["label"] = label + return step + + +def toggle_airplane_mode(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "toggleAirplaneMode"} + if label is not None: + step["label"] = label + return step + + +def set_network_conditions( + *, + offline: bool | None = None, + latency: float | None = None, + download_speed: float | None = None, + upload_speed: float | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "setNetworkConditions"} + if offline is not None: + step["offline"] = offline + if latency is not None: + step["latency"] = latency + if download_speed is not None: + step["downloadSpeed"] = download_speed + if upload_speed is not None: + step["uploadSpeed"] = upload_speed + if label is not None: + step["label"] = label + return step + + +def open_notifications(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "openNotifications"} + if label is not None: + step["label"] = label + return step + + +def set_dark_mode(enabled: bool, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "setDarkMode", "enabled": enabled} + if label is not None: + step["label"] = label + return step + + +def set_orientation(orientation: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "setOrientation", "orientation": orientation} + if label is not None: + step["label"] = label + return step + + +# --------------------------------------------------------------------------- +# Browser (web platform) +# --------------------------------------------------------------------------- + + +def open_browser(url: str | None = None, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "openBrowser"} + if url is not None: + step["url"] = url + if label is not None: + step["label"] = label + return step + + +def switch_tab( + *, + tab_label: str | None = None, + index: int | None = None, + url: str | None = None, + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "switchTab"} + if tab_label is not None: + step["tabLabel"] = tab_label + if index is not None: + step["index"] = index + if url is not None: + step["url"] = url + if label is not None: + step["label"] = label + return step + + +def close_tab(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "closeTab"} + if label is not None: + step["label"] = label + return step + + +def get_console_logs(output: str, *, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "getConsoleLogs", "output": output} + if label is not None: + step["label"] = label + return step + + +def clear_console_logs(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "clearConsoleLogs"} + if label is not None: + step["label"] = label + return step + + +def assert_no_js_errors(*, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "assertNoJSErrors"} + if label is not None: + step["label"] = label + return step + + +def mock_network( + *, + url: str, + method: str | None = None, + response: dict[str, Any], + label: str | None = None, +) -> dict[str, Any]: + step: dict[str, Any] = {"type": "mockNetwork", "url": url} + if method is not None: + step["method"] = method + resp: dict[str, Any] = {} + if response.get("status") is not None: + resp["status"] = response["status"] + if response.get("headers") is not None: + resp["headers"] = response["headers"] + if response.get("body") is not None: + resp["body"] = response["body"] + step["response"] = resp + if label is not None: + step["label"] = label + return step + + +def hide_keyboard(*, strategy: str | None = None, label: str | None = None) -> dict[str, Any]: + step: dict[str, Any] = {"type": "hideKeyboard"} + if strategy is not None: + step["strategy"] = strategy + if label is not None: + step["label"] = label + return step + + +def wait_for_animation_to_end( + *, + sleep_ms: int | None = None, + threshold: float | None = None, + label: str | None = None, +) -> dict[str, Any]: + """Build a waitForAnimationToEnd step. + + Args: + sleep_ms: Milliseconds to pause between the two comparison screenshots. + Longer values catch slow-moving animations. Defaults to 200 ms + on the server side. + threshold: Maximum pixel-diff percentage (0.0-1.0) still considered static. + Lower is stricter. Defaults to 0.005 (0.5 %) on the server side. + label: Optional step label shown in reports. + """ + step: dict[str, Any] = {"type": "waitForAnimationToEnd"} + if sleep_ms is not None: + step["sleepMs"] = sleep_ms + if threshold is not None: + step["threshold"] = threshold + if label is not None: + step["label"] = label + return step diff --git a/client/python/maestro_runner/exceptions.py b/client/python/maestro_runner/exceptions.py new file mode 100644 index 00000000..de106cd0 --- /dev/null +++ b/client/python/maestro_runner/exceptions.py @@ -0,0 +1,19 @@ +"""Custom exceptions for maestro_runner.""" + +from __future__ import annotations + + +class MaestroError(Exception): + """Base exception for maestro-runner client errors.""" + + def __init__(self, message: str, status_code: int | None = None): + super().__init__(message) + self.status_code = status_code + + +class SessionError(MaestroError): + """Raised when session creation or management fails.""" + + +class StepError(MaestroError): + """Raised when a step execution fails and optional=False.""" diff --git a/client/python/maestro_runner/models.py b/client/python/maestro_runner/models.py new file mode 100644 index 00000000..0dfb98df --- /dev/null +++ b/client/python/maestro_runner/models.py @@ -0,0 +1,142 @@ +"""Data models mapping Go server JSON responses to Python dataclasses.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ElementSelector: + """Element selection criteria — maps to Go flow.Selector.""" + + text: str | None = None + id: str | None = None + index: int | None = None + enabled: bool | None = None + checked: bool | None = None + focused: bool | None = None + selected: bool | None = None + css: str | None = None + traits: str | None = None + child_of: ElementSelector | None = None + below: ElementSelector | None = None + above: ElementSelector | None = None + left_of: ElementSelector | None = None + right_of: ElementSelector | None = None + contains_child: ElementSelector | None = None + inside_of: ElementSelector | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to JSON-compatible dict for the Go server.""" + d: dict[str, Any] = {} + if self.text is not None: + d["text"] = self.text + if self.id is not None: + d["id"] = self.id + if self.index is not None: + d["index"] = str(self.index) + if self.enabled is not None: + d["enabled"] = self.enabled + if self.checked is not None: + d["checked"] = self.checked + if self.focused is not None: + d["focused"] = self.focused + if self.selected is not None: + d["selected"] = self.selected + if self.css is not None: + d["css"] = self.css + if self.traits is not None: + d["traits"] = self.traits + if self.child_of is not None: + d["childOf"] = self.child_of.to_dict() + if self.below is not None: + d["below"] = self.below.to_dict() + if self.above is not None: + d["above"] = self.above.to_dict() + if self.left_of is not None: + d["leftOf"] = self.left_of.to_dict() + if self.right_of is not None: + d["rightOf"] = self.right_of.to_dict() + if self.contains_child is not None: + d["containsChild"] = self.contains_child.to_dict() + if self.inside_of is not None: + d["insideOf"] = self.inside_of.to_dict() + return d + + +@dataclass +class ElementInfo: + """UI element information — maps to Go core.ElementInfo.""" + + id: str = "" + text: str = "" + bounds: dict[str, int] = field(default_factory=dict) + visible: bool = False + enabled: bool = False + focused: bool = False + checked: bool = False + selected: bool = False + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> ElementInfo | None: + if data is None: + return None + return cls( + id=data.get("id", ""), + text=data.get("text", ""), + bounds=data.get("bounds", {}), + visible=data.get("visible", False), + enabled=data.get("enabled", False), + focused=data.get("focused", False), + checked=data.get("checked", False), + selected=data.get("selected", False), + ) + + +@dataclass +class ExecutionResult: + """Step execution result — maps to Go core.CommandResult.""" + + success: bool + message: str | None = None + duration_ns: int = 0 + element: ElementInfo | None = None + data: Any = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> ExecutionResult: + return cls( + success=data.get("success", False), + message=data.get("message"), + duration_ns=data.get("duration", 0), + element=ElementInfo.from_dict(data.get("element")), + data=data.get("data"), + ) + + +@dataclass +class DeviceInfo: + """Device/platform information — maps to Go core.PlatformInfo.""" + + platform: str = "" + os_version: str = "" + device_name: str = "" + device_id: str = "" + is_simulator: bool = False + screen_width: int = 0 + screen_height: int = 0 + app_id: str = "" + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> DeviceInfo: + return cls( + platform=data.get("platform", ""), + os_version=data.get("osVersion", ""), + device_name=data.get("deviceName", ""), + device_id=data.get("deviceId", ""), + is_simulator=data.get("isSimulator", False), + screen_width=data.get("screenWidth", 0), + screen_height=data.get("screenHeight", 0), + app_id=data.get("appId", ""), + ) diff --git a/client/python/pyproject.toml b/client/python/pyproject.toml new file mode 100644 index 00000000..4c3d0ece --- /dev/null +++ b/client/python/pyproject.toml @@ -0,0 +1,74 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["maestro_runner*"] + +[project] +name = "maestro-runner" +version = "0.1.0" +description = "Python client for maestro-runner REST API" +readme = "README.md" +requires-python = ">=3.9" +dependencies = [ + "requests>=2.28", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-html>=4.0", + "pytest-xdist>=3.0", + "requests-mock>=1.11", + "ruff>=0.4.0", + "mypy>=1.10", + "types-requests>=2.31", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--html=reports/report.html --self-contained-html --junitxml=reports/junit-report.xml" +log_cli = true +log_cli_level = "DEBUG" +log_level = "DEBUG" +log_date_format = "%Y-%m-%d %H:%M:%S" + +[tool.ruff] +target-version = "py39" +line-length = 100 +src = ["maestro_runner", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "UP", # pyupgrade + "N", # pep8-naming + "S", # flake8-bandit (security) + "RUF", # ruff-specific rules +] +ignore = [ + "S101", # allow assert in tests +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "S105", "S106", "S113"] +"tests/conftest.py" = ["S101", "S105", "S106", "S113", "S603", "S607", "E501"] +"tests/test_wait_for_animation_to_end.py" = ["E501"] + +[tool.mypy] +python_version = "3.9" +strict = true +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +show_error_codes = true diff --git a/client/python/tests/__init__.py b/client/python/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/client/python/tests/conftest.py b/client/python/tests/conftest.py new file mode 100644 index 00000000..5c36151e --- /dev/null +++ b/client/python/tests/conftest.py @@ -0,0 +1,702 @@ +"""Shared pytest fixtures — auto-start maestro-runner server when needed. + +Supports pytest-xdist parallel execution: each worker gets its own server +instance on a unique port, targeting a specific device (via ANDROID_SERIAL). +""" + +from __future__ import annotations + +import base64 +import fcntl +import html +import json +import logging +import os +import re +import shutil +import subprocess +import time +from collections.abc import Generator +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pytest +import requests +from maestro_runner import MaestroClient + +SERVER_URL = os.environ.get("MAESTRO_SERVER_URL", "http://localhost:9999") +PLATFORM = os.environ.get("MAESTRO_PLATFORM", "android") +EXPLICIT_DEVICE_ID = os.environ.get("MAESTRO_DEVICE_ID") +SERVER_PORT = SERVER_URL.rsplit(":", 1)[-1].rstrip("/") + +# Where to find the binary — override with MAESTRO_RUNNER_BIN env var +_DEFAULT_BIN = os.path.join( + os.path.dirname(__file__), "..", "..", "..", "maestro-runner", +) +MAESTRO_RUNNER_BIN = os.environ.get("MAESTRO_RUNNER_BIN", _DEFAULT_BIN) +REPORTS_DIR = (Path(__file__).resolve().parent.parent / "reports") +HTML_OVERRIDE_CSS = Path(__file__).resolve().parent / "report-overrides.css" +_CURRENT_NODE_ID = "-" +_SESSION_RUN_ID = "" +_SESSION_WORKER_ID = "master" + + +def _utc_timestamp() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + + +def _active_worker_id(explicit_worker_id: str | None = None) -> str: + if explicit_worker_id: + return explicit_worker_id + return os.environ.get("PYTEST_XDIST_WORKER", "master") + + +def _active_node_id() -> str: + if _CURRENT_NODE_ID and _CURRENT_NODE_ID != "-": + return _CURRENT_NODE_ID + current_test = os.environ.get("PYTEST_CURRENT_TEST", "") + if "::" in current_test: + return current_test.split(" ", 1)[0] + return "-" + + +def _make_run_id(worker_id: str) -> str: + return f"{_utc_timestamp()}-{worker_id}-{os.getpid()}" + + +def _ensure_session_context(explicit_worker_id: str | None = None) -> tuple[str, str]: + global _SESSION_RUN_ID, _SESSION_WORKER_ID + + worker_id = _active_worker_id(explicit_worker_id) + if not _SESSION_RUN_ID: + _SESSION_RUN_ID = _make_run_id(worker_id) + _SESSION_WORKER_ID = worker_id + elif not _SESSION_WORKER_ID: + _SESSION_WORKER_ID = worker_id + + return _SESSION_RUN_ID, _SESSION_WORKER_ID + + +def _session_run_dir(run_id: str | None = None) -> Path: + active_run_id = run_id or _SESSION_RUN_ID + return REPORTS_DIR / active_run_id if active_run_id else REPORTS_DIR + + +def _move_shared_pytest_reports(run_dir: Path) -> None: + for artifact_name in ("report.html", "junit-report.xml"): + shared_path = REPORTS_DIR / artifact_name + run_path = run_dir / artifact_name + if shared_path.exists() and shared_path != run_path: + run_path.parent.mkdir(parents=True, exist_ok=True) + os.replace(shared_path, run_path) + + +def _relative_run_artifact_path(path: Path) -> str: + return str(path.relative_to(_session_run_dir())) + + +_ORIGINAL_RECORD_FACTORY = logging.getLogRecordFactory() + + +def _record_factory(*args: object, **kwargs: object) -> logging.LogRecord: + record = _ORIGINAL_RECORD_FACTORY(*args, **kwargs) + if not hasattr(record, "worker_id"): + record.worker_id = _active_worker_id() + if not hasattr(record, "node_id"): + record.node_id = _active_node_id() + return record + + +logging.setLogRecordFactory(_record_factory) + + +def _tail_file(path: Path, max_lines: int = 120) -> str: + if not path.exists(): + return "" + lines = path.read_text(encoding="utf-8", errors="replace").splitlines() + return "\n".join(lines[-max_lines:]) + + +def _persist_latest_server_metadata(entry: dict[str, str]) -> None: + REPORTS_DIR.mkdir(parents=True, exist_ok=True) + latest_path = REPORTS_DIR / "server-latest.json" + lock_path = REPORTS_DIR / "server-latest.lock" + + with lock_path.open("w", encoding="utf-8") as lock_file: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + payload: dict[str, object] = { + "updatedAt": datetime.now(timezone.utc).isoformat(), + "workers": {}, + } + + if latest_path.exists(): + try: + payload = json.loads(latest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + payload = { + "updatedAt": datetime.now(timezone.utc).isoformat(), + "workers": {}, + } + + workers = payload.get("workers", {}) + if not isinstance(workers, dict): + workers = {} + workers[entry["workerId"]] = entry + payload["workers"] = workers + payload["updatedAt"] = datetime.now(timezone.utc).isoformat() + + tmp_path = REPORTS_DIR / "server-latest.json.tmp" + tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(tmp_path, latest_path) + + +def _setup_persisted_python_logs(worker_id: str, run_id: str) -> None: + run_dir = _session_run_dir(run_id) + run_dir.mkdir(parents=True, exist_ok=True) + root_logger = logging.getLogger() + handler_name = f"pytest-run-{run_id}" + + for handler in root_logger.handlers: + if getattr(handler, "name", "") == handler_name: + return + + log_path = run_dir / "pytest-run.log" + file_handler = logging.FileHandler(log_path, encoding="utf-8") + file_handler.name = handler_name + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter( + logging.Formatter( + "%(asctime)s [%(levelname)s] [%(name)s] " + "[worker=%(worker_id)s] [node=%(node_id)s] %(message)s" + ) + ) + root_logger.setLevel(logging.DEBUG) + root_logger.addHandler(file_handler) + + +def _resolve_worker_metadata(worker_id: str) -> dict[str, Any]: + latest_path = REPORTS_DIR / "server-latest.json" + if not latest_path.exists(): + return {} + try: + data = json.loads(latest_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return {} + + workers = data.get("workers", {}) + if not isinstance(workers, dict): + return {} + + metadata = workers.get(worker_id) + if not isinstance(metadata, dict): + return {} + return metadata + + +def _write_artifact_summary(exit_status: int) -> None: + if not _SESSION_RUN_ID: + return + + worker_id = _SESSION_WORKER_ID + run_id = _SESSION_RUN_ID + run_dir = _session_run_dir(run_id) + run_dir.mkdir(parents=True, exist_ok=True) + _move_shared_pytest_reports(run_dir) + metadata = _resolve_worker_metadata(worker_id) + + artifacts: list[dict[str, Any]] = [] + for path in [ + run_dir / "report.html", + run_dir / "junit-report.xml", + ]: + if path.exists(): + artifacts.append( + { + "name": path.name, + "path": str(path), + "sizeBytes": path.stat().st_size, + } + ) + + pytest_log_path = run_dir / "pytest-run.log" + if pytest_log_path.exists(): + artifacts.append( + { + "name": pytest_log_path.name, + "path": str(pytest_log_path), + "sizeBytes": pytest_log_path.stat().st_size, + } + ) + + server_log_path = Path(str(metadata.get("serverLogPath", ""))) + if server_log_path.exists(): + artifacts.append( + { + "name": server_log_path.name, + "path": str(server_log_path), + "sizeBytes": server_log_path.stat().st_size, + } + ) + + summary: dict[str, Any] = { + "runId": run_id, + "workerId": worker_id, + "platform": PLATFORM, + "serverUrl": str(metadata.get("serverUrl", SERVER_URL)), + "serverPort": str(metadata.get("serverPort", SERVER_PORT)), + "sessionStatus": "failed" if exit_status != 0 else "passed", + "generatedAt": datetime.now(timezone.utc).isoformat(), + "artifacts": artifacts, + } + + if exit_status != 0: + summary["failureTails"] = { + "server": _tail_file(server_log_path) if server_log_path.exists() else "", + "pytest": _tail_file(pytest_log_path) if pytest_log_path.exists() else "", + } + + summary_path = run_dir / "artifact-summary.json" + summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _server_is_ready(url: str, timeout: float = 2.0) -> bool: + """Return True if the server responds to /status.""" + try: + resp = requests.get(f"{url}/status", timeout=timeout) + return resp.status_code == 200 + except requests.ConnectionError: + return False + + +def _discover_devices() -> list[str]: + """Return a list of connected Android device serials via adb.""" + try: + out = subprocess.check_output(["adb", "devices"], text=True) + except (FileNotFoundError, subprocess.CalledProcessError): + return [] + devices = [] + for line in out.strip().splitlines()[1:]: + m = re.match(r"^(\S+)\s+device$", line) + if m: + devices.append(m.group(1)) + return devices + + +def _worker_index(worker_id: str) -> int: + """Extract 0-based index from xdist worker id like 'gw0', 'gw1'.""" + m = re.search(r"(\d+)$", worker_id) + return int(m.group(1)) if m else 0 + + +def _server_command(port: str, *, device_id: str | None = None) -> list[str]: + command = ["--platform", PLATFORM] + if device_id: + command.extend(["--device", device_id]) + command.extend(["server", "--port", port]) + return command + + +@pytest.fixture(scope="session") +def maestro_server(worker_id: str) -> Generator[tuple[str, str | None], None, None]: + """Ensure a maestro-runner server is available. + + In xdist parallel mode, each worker starts its own server on a unique port + targeting a specific device. In single-worker mode, reuses any running + server or starts one. + + Yields (server_url, device_serial_or_None). + """ + run_id, worker_id = _ensure_session_context(worker_id) + _setup_persisted_python_logs(worker_id, run_id) + run_dir = _session_run_dir(run_id) + run_dir.mkdir(parents=True, exist_ok=True) + + # Single-worker mode (no xdist or xdist with -n0) + if worker_id == "master": + if _server_is_ready(SERVER_URL): + server_log_path = run_dir / "server-run.log" + server_log_path.write_text( + "Reused existing maestro-runner server; process stdout/stderr " + "owned by external process.\n", + encoding="utf-8", + ) + _persist_latest_server_metadata( + { + "workerId": worker_id, + "runId": run_id, + "serverUrl": SERVER_URL, + "serverPort": SERVER_PORT, + "serverLogPath": str(server_log_path), + "mode": "reused-existing-server", + **({"deviceId": EXPLICIT_DEVICE_ID} if EXPLICIT_DEVICE_ID else {}), + "startedAt": datetime.now(timezone.utc).isoformat(), + } + ) + yield SERVER_URL, EXPLICIT_DEVICE_ID + return + + binary = shutil.which("maestro-runner") or MAESTRO_RUNNER_BIN + if not os.path.isfile(binary): + pytest.fail( + f"maestro-runner binary not found at {binary}. " + "Set MAESTRO_RUNNER_BIN or add it to PATH." + ) + + server_log_path = run_dir / "server-run.log" + server_log = server_log_path.open("a", encoding="utf-8", buffering=1) + server_log.write(f"runId={run_id} workerId={worker_id} platform={PLATFORM}\n") + + proc = subprocess.Popen( + [binary, *_server_command(SERVER_PORT, device_id=EXPLICIT_DEVICE_ID)], + stdout=server_log, + stderr=subprocess.STDOUT, + ) + _persist_latest_server_metadata( + { + "workerId": worker_id, + "runId": run_id, + "serverUrl": SERVER_URL, + "serverPort": SERVER_PORT, + "serverLogPath": str(server_log_path), + "mode": "spawned", + **({"deviceId": EXPLICIT_DEVICE_ID} if EXPLICIT_DEVICE_ID else {}), + "startedAt": datetime.now(timezone.utc).isoformat(), + } + ) + + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if proc.poll() is not None: + out = _tail_file(server_log_path) + server_log.close() + pytest.fail(f"maestro-runner exited early (code {proc.returncode}):\n{out}") + if _server_is_ready(SERVER_URL): + break + time.sleep(0.5) + else: + proc.terminate() + server_log.close() + pytest.fail("maestro-runner server did not become ready within 30 s") + + yield SERVER_URL, EXPLICIT_DEVICE_ID + proc.terminate() + proc.wait(timeout=10) + server_log.write(f"terminated runId={run_id} workerId={worker_id}\n") + server_log.close() + return + + # Parallel mode — each worker gets its own port and device + idx = _worker_index(worker_id) + port = int(SERVER_PORT) + idx + url = f"http://localhost:{port}" + + devices = _discover_devices() + if idx >= len(devices): + pytest.fail( + f"Worker {worker_id} needs device index {idx} but only " + f"{len(devices)} device(s) found: {devices}" + ) + device_serial = devices[idx] + + binary = shutil.which("maestro-runner") or MAESTRO_RUNNER_BIN + if not os.path.isfile(binary): + pytest.fail( + f"maestro-runner binary not found at {binary}. " + "Set MAESTRO_RUNNER_BIN or add it to PATH." + ) + + server_log_path = run_dir / "server-run.log" + server_log = server_log_path.open("a", encoding="utf-8", buffering=1) + server_log.write( + f"runId={run_id} workerId={worker_id} platform={PLATFORM} " + f"deviceId={device_serial}\n" + ) + + proc = subprocess.Popen( + [binary, "--platform", PLATFORM, "server", "--port", str(port)], + stdout=server_log, + stderr=subprocess.STDOUT, + env={**os.environ, "ANDROID_SERIAL": device_serial}, + ) + _persist_latest_server_metadata( + { + "workerId": worker_id, + "runId": run_id, + "serverUrl": url, + "serverPort": str(port), + "deviceId": device_serial, + "serverLogPath": str(server_log_path), + "mode": "spawned", + "startedAt": datetime.now(timezone.utc).isoformat(), + } + ) + + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + if proc.poll() is not None: + out = _tail_file(server_log_path) + server_log.close() + pytest.fail(f"maestro-runner exited early (code {proc.returncode}):\n{out}") + if _server_is_ready(url): + break + time.sleep(0.5) + else: + proc.terminate() + server_log.close() + pytest.fail(f"maestro-runner server on port {port} did not become ready within 30 s") + + yield url, device_serial + + proc.terminate() + proc.wait(timeout=10) + server_log.write(f"terminated runId={run_id} workerId={worker_id}\n") + server_log.close() + + +@pytest.fixture(scope="session") +def client(maestro_server: tuple[str, str | None]) -> Generator[MaestroClient, None, None]: + """Create a MaestroClient session for the entire test session.""" + url, device_serial = maestro_server + caps: dict[str, str] = {"platformName": PLATFORM} + if device_serial: + caps["deviceId"] = device_serial + with MaestroClient(url, capabilities=caps) as c: + yield c + + +def _capture_failure_diagnostics(test_name: str, client: MaestroClient | None) -> dict[str, str]: + """Capture server logs, screenshot, and UI dump after a test failure. + + Stores diagnostics in a run-specific directory: reports/{run_id}/diagnostics/ + """ + if not _SESSION_RUN_ID: + return {} + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + test_safe = re.sub(r"[^a-zA-Z0-9_-]", "_", test_name) + artifacts: dict[str, str] = {} + + # Create run-specific diagnostics directory + run_dir = _session_run_dir() / "diagnostics" + run_dir.mkdir(parents=True, exist_ok=True) + + # Get server log tail + worker_metadata = _resolve_worker_metadata(_SESSION_WORKER_ID) + server_log_path = Path(str(worker_metadata.get("serverLogPath", ""))) + if server_log_path.exists(): + log_content = _tail_file(server_log_path, max_lines=200) + log_file = run_dir / f"{test_safe}-{timestamp}-server.log" + log_file.write_text(log_content, encoding="utf-8") + artifacts["server_log"] = str(log_file) + artifacts["server_log_text"] = log_content + logger = logging.getLogger(__name__) + logger.info(f"✓ Server log captured: {log_file.relative_to(REPORTS_DIR)}") + + # Capture screenshot and UI dump if client is available + if client: + try: + screenshot_file = run_dir / f"{test_safe}-{timestamp}-screenshot.png" + screenshot_data = client.screenshot() + screenshot_file.write_bytes(screenshot_data) + artifacts["screenshot_file"] = str(screenshot_file) + artifacts["screenshot_base64"] = base64.b64encode(screenshot_data).decode("utf-8") + logging.getLogger(__name__).info( + f"✓ Screenshot captured: {screenshot_file.relative_to(REPORTS_DIR)}" + ) + except Exception as e: + logging.getLogger(__name__).warning(f"Failed to capture screenshot: {e}") + + try: + ui_dump_file = run_dir / f"{test_safe}-{timestamp}-ui-dump.xml" + ui_dump = client.view_hierarchy() + ui_dump_file.write_text(ui_dump, encoding="utf-8") + artifacts["ui_dump"] = str(ui_dump_file) + logging.getLogger(__name__).info( + f"✓ UI dump captured: {ui_dump_file.relative_to(REPORTS_DIR)}" + ) + except Exception as e: + logging.getLogger(__name__).warning(f"Failed to capture UI dump: {e}") + + return artifacts + + + +_CURRENT_CLIENT: MaestroClient | None = None + + +@pytest.fixture(autouse=True) +def _track_client(client: MaestroClient) -> Generator[None, None, None]: + """Track the current client for diagnostic capture.""" + global _CURRENT_CLIENT + _CURRENT_CLIENT = client + yield + _CURRENT_CLIENT = None + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_setup(item: pytest.Item) -> None: + global _CURRENT_NODE_ID + _CURRENT_NODE_ID = item.nodeid + + +@pytest.hookimpl(tryfirst=True) +def pytest_configure(config: pytest.Config) -> None: + run_id, _ = _ensure_session_context() + run_dir = _session_run_dir(run_id) + run_dir.mkdir(parents=True, exist_ok=True) + + existing_css = list(getattr(config.option, "css", []) or []) + override_css = str(HTML_OVERRIDE_CSS) + if override_css not in existing_css: + config.option.css = [*existing_css, override_css] + + if hasattr(config.option, "htmlpath"): + config.option.htmlpath = str(run_dir / "report.html") + if hasattr(config.option, "xmlpath"): + config.option.xmlpath = str(run_dir / "junit-report.xml") + + +@pytest.hookimpl(tryfirst=True) +def pytest_runtest_teardown(item: pytest.Item, nextitem: pytest.Item | None) -> None: + del item, nextitem + global _CURRENT_NODE_ID + _CURRENT_NODE_ID = "-" + + +@pytest.hookimpl(tryfirst=True, hookwrapper=True) +def pytest_runtest_makereport( + item: pytest.Item, call: pytest.CallInfo[Any] +) -> Generator[None, None, None]: + """Capture diagnostics after test call fails and attach key artifacts to pytest-html.""" + outcome: Any = yield + report = outcome.get_result() + + if report.when != "call" or call.excinfo is None: + return + + test_name = item.name + logging.getLogger(__name__).error(f"Test {test_name} failed, capturing diagnostics...") + captured_artifacts = _capture_failure_diagnostics(test_name, _CURRENT_CLIENT) + + html_plugin = item.config.pluginmanager.getplugin("html") + if html_plugin is None: + return + + from pytest_html import extras as html_extras + + report_extras = list(getattr(report, "extras", [])) + + screenshot_base64 = captured_artifacts.get("screenshot_base64") + server_log_text = captured_artifacts.get("server_log_text") + + if screenshot_base64 or server_log_text or getattr(report, "longreprtext", ""): + control_buttons: list[str] = [] + + if screenshot_base64: + control_buttons.append( + """ + + """.strip() + ) + + if server_log_text: + control_buttons.append( + """ + + """.strip() + ) + + if getattr(report, "longreprtext", ""): + control_buttons.append( + """ + + """.strip() + ) + + report_extras.append( + html_extras.html( + + '
' + + "".join(control_buttons) + + "
" + + ) + ) + + if screenshot_base64: + report_extras.append( + html_extras.html( + + '
' + '
Screenshot
' + '
' + 'Failure Screenshot' + '
' + '
' + + ) + ) + + if server_log_text: + report_extras.append( + html_extras.html( + + '
' + '
Server Log
' + '
'
+                    + html.escape(server_log_text)
+                    + '
' + '
' + + ) + ) + + screenshot_file = captured_artifacts.get("screenshot_file") + if screenshot_file: + report_extras.append( + html_extras.url( + _relative_run_artifact_path(Path(screenshot_file)), + name="Screenshot File", + ) + ) + + ui_dump = captured_artifacts.get("ui_dump") + if ui_dump: + report_extras.append( + html_extras.url( + _relative_run_artifact_path(Path(ui_dump)), + name="UI Dump", + ) + ) + + server_log = captured_artifacts.get("server_log") + if server_log: + report_extras.append( + html_extras.url( + _relative_run_artifact_path(Path(server_log)), + name="Server Log", + ) + ) + + report.extras = report_extras + + +@pytest.hookimpl(trylast=True) +def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: + del session + _write_artifact_summary(exitstatus) diff --git a/client/python/tests/pages/__init__.py b/client/python/tests/pages/__init__.py new file mode 100644 index 00000000..bc555a0f --- /dev/null +++ b/client/python/tests/pages/__init__.py @@ -0,0 +1,19 @@ +"""Page Object Model — base page with common helpers.""" + +from __future__ import annotations + +from maestro_runner.client import MaestroClient +from maestro_runner.models import ExecutionResult + + +class BasePage: + """Shared helpers available to every page object.""" + + def __init__(self, client: MaestroClient) -> None: + self.client = client + + def wait_for_animation(self) -> ExecutionResult: + return self.client.wait_for_animation_to_end() + + def hide_keyboard(self, strategy: str | None = None) -> ExecutionResult: + return self.client.hide_keyboard(strategy=strategy) diff --git a/client/python/tests/pages/contact_list_page.py b/client/python/tests/pages/contact_list_page.py new file mode 100644 index 00000000..50619356 --- /dev/null +++ b/client/python/tests/pages/contact_list_page.py @@ -0,0 +1,28 @@ +"""Page Object — Contacts app: contact list screen.""" + +from __future__ import annotations + +from maestro_runner.models import ExecutionResult + +from tests.pages import BasePage +from tests.pages.edit_contact_page import EditContactPage + + +class ContactListPage(BasePage): + """The main Contacts list screen.""" + + APP_ID = "com.google.android.contacts" + + def launch(self, clear_state: bool = True) -> ExecutionResult: + result = self.client.launch_app(self.APP_ID, clear_state=clear_state) + self.wait_for_animation() + return result + + def open_create_contact(self) -> EditContactPage: + + self.client.tap(text="Create contact|Add contact|New contact") + self.wait_for_animation() + return EditContactPage(self.client) + + def assert_contact_visible(self, name: str) -> ExecutionResult: + return self.client.assert_visible(text=name) diff --git a/client/python/tests/pages/edit_contact_page.py b/client/python/tests/pages/edit_contact_page.py new file mode 100644 index 00000000..3d98b935 --- /dev/null +++ b/client/python/tests/pages/edit_contact_page.py @@ -0,0 +1,32 @@ +"""Page Object — Contacts app: create / edit contact form.""" + +from __future__ import annotations + +from maestro_runner.models import ExecutionResult + +from tests.pages import BasePage + + +class EditContactPage(BasePage): + """The new-contact / edit-contact form.""" + + def set_first_name(self, name: str) -> None: + self.client.tap(text="First name") + self.client.input_text(name) + self.hide_keyboard() + + def set_last_name(self, name: str) -> None: + self.client.tap(text="Last name") + self.client.input_text(name) + self.hide_keyboard(strategy="escape") + + def set_phone(self, number: str) -> None: + self.wait_for_animation() + self.client.tap(text="Phone (Mobile)|Add phone") + self.client.input_text(number) + self.hide_keyboard(strategy="back") + + def save(self) -> ExecutionResult: + result = self.client.tap(text="Save") + self.wait_for_animation() + return result diff --git a/client/python/tests/pages/ios_contact_list_page.py b/client/python/tests/pages/ios_contact_list_page.py new file mode 100644 index 00000000..908915df --- /dev/null +++ b/client/python/tests/pages/ios_contact_list_page.py @@ -0,0 +1,27 @@ +"""Page Object — iOS Contacts app: contact list screen.""" + +from __future__ import annotations + +from maestro_runner.models import ExecutionResult + +from tests.pages import BasePage +from tests.pages.ios_edit_contact_page import IOSContactEditPage + + +class IOSContactListPage(BasePage): + """The iOS Contacts list screen.""" + + APP_ID = "com.apple.MobileAddressBook" + + def launch(self) -> ExecutionResult: + result = self.client.launch_app(self.APP_ID, stop_app=True) + self.wait_for_animation() + return result + + def open_create_contact(self) -> IOSContactEditPage: + self.client.tap(text="Add") + self.wait_for_animation() + return IOSContactEditPage(self.client) + + def assert_contact_visible(self, name: str) -> ExecutionResult: + return self.client.assert_visible(text=name) diff --git a/client/python/tests/pages/ios_edit_contact_page.py b/client/python/tests/pages/ios_edit_contact_page.py new file mode 100644 index 00000000..fd2895ac --- /dev/null +++ b/client/python/tests/pages/ios_edit_contact_page.py @@ -0,0 +1,33 @@ +"""Page Object — iOS Contacts app: create / edit contact form.""" + +from __future__ import annotations + +from maestro_runner.models import ExecutionResult + +from tests.pages import BasePage + + +class IOSContactEditPage(BasePage): + """The iOS new-contact form.""" + + def set_first_name(self, name: str) -> None: + self.client.tap(text="First name") + self.client.input_text(name) + + def set_last_name(self, name: str) -> None: + self.client.tap(text="Last name") + self.client.input_text(name) + + def set_phone(self, number: str) -> None: + self.wait_for_animation() + self.client.execute_step( + {"type": "swipe", "start": "50%, 42%", "end": "50%, 12%", "duration": 400} + ) + self.client.tap(text="add phone") + self.client.tap(text="phone") + self.client.input_text(number) + + def save(self) -> ExecutionResult: + result = self.client.tap(text="Done") + self.wait_for_animation() + return result diff --git a/client/python/tests/report-overrides.css b/client/python/tests/report-overrides.css new file mode 100644 index 00000000..adcb3438 --- /dev/null +++ b/client/python/tests/report-overrides.css @@ -0,0 +1,110 @@ +td.extra { + width: 100%; + vertical-align: top; +} + +.extraHTML { + display: block; + width: 100%; +} + +.report-section-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0 12px; +} + +.report-section-toggle { + appearance: none; + border: 1px solid #bfc7d1; + border-radius: 999px; + background: #f6f8fa; + color: #2f3b4a; + cursor: pointer; + font: inherit; + font-size: 12px; + line-height: 1.2; + padding: 6px 12px; +} + +.report-section-toggle:hover { + background: #eef2f6; +} + +.report-screenshot-panel { + width: min(100%, 3.35in); + margin: 0 0 12px; +} + +.report-screenshot-panel__title { + padding: 10px 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + color: #e5edf5; + background: #111827; + border: 1px solid #d0d7de; + border-bottom: 0; + border-radius: 14px 14px 0 0; +} + +.report-screenshot-frame { + width: 100%; + height: min(6.3in, 80vh); + min-height: 420px; + overflow: hidden; + border: 1px solid #d0d7de; + border-top: 0; + border-radius: 0 0 14px 14px; + background: #111827; + display: flex; + align-items: center; + justify-content: center; +} + +.report-screenshot-image { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.report-server-log { + border: 1px solid #d0d7de; + border-radius: 14px; + background: #0f172a; + color: #e5edf5; + overflow: hidden; + width: 100%; + margin: 0 0 12px; +} + +.report-server-log__title { + padding: 10px 12px; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + background: #111827; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.report-server-log__content { + margin: 0; + padding: 12px; + max-height: 280px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font: 12px/1.45 "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; +} + +.logwrapper { + width: 100%; +} + +.report-section-hidden { + display: none; +} \ No newline at end of file diff --git a/client/python/tests/test_add_contact.py b/client/python/tests/test_add_contact.py new file mode 100644 index 00000000..dc2d8ce5 --- /dev/null +++ b/client/python/tests/test_add_contact.py @@ -0,0 +1,57 @@ +"""POM-based test — Add a new contact. + +Equivalent of: e2e/workspaces/contacts/add_contact_android.yaml + +Prerequisites: + 1. Android emulator running (adb devices shows device) + 2. Python deps installed (from client/python): + python3 -m venv .venv && source .venv/bin/activate + pip install -e ".[dev]" + 3. (Optional) Start maestro-runner server manually: + ./maestro-runner --platform android server --port 9999 + If not running, the server is auto-started by the test fixture. + +Override with env vars: + MAESTRO_SERVER_URL (default: http://localhost:9999) + MAESTRO_PLATFORM (default: android) + MAESTRO_RUNNER_BIN (path to binary, auto-detected by default) + +Run: + pytest tests/test_add_contact.py -v +""" + +from __future__ import annotations + +import pytest +from maestro_runner import MaestroClient + +from tests.pages.contact_list_page import ContactListPage + + +@pytest.fixture() +def contact_list(client: MaestroClient) -> ContactListPage: + return ContactListPage(client) + + +class TestAddContact: + """Mirrors add_contact_android.yaml: launch → create → fill → save → verify.""" + + def test_add_and_verify_contact(self, contact_list: ContactListPage): + # Launch with a clean slate + contact_list.launch(clear_state=True) + + # Open the create-contact form + edit_page = contact_list.open_create_contact() + + # Fill in name fields + edit_page.set_first_name("Alice") + edit_page.set_last_name("Tester") + + # Fill in phone number + edit_page.set_phone("5550100") + + # Save + edit_page.save() + + # Verify the contact now appears in the list + contact_list.assert_contact_visible("Alice Tester") diff --git a/client/python/tests/test_add_contact_ios.py b/client/python/tests/test_add_contact_ios.py new file mode 100644 index 00000000..d4e1d0fe --- /dev/null +++ b/client/python/tests/test_add_contact_ios.py @@ -0,0 +1,49 @@ +"""POM-based test — Add a new contact on iOS. + +Equivalent of: e2e/workspaces/contacts/add_contact_ios.yaml + +Prerequisites: + 1. iOS simulator running + 2. Python deps installed (from client/python): + python3 -m venv .venv && source .venv/bin/activate + pip install -e ".[dev]" + 3. (Optional) Start maestro-runner server manually: + ./maestro-runner --platform ios --device server --port 9999 + If not running, the server is auto-started by the test fixture. + +Override with env vars: + MAESTRO_SERVER_URL (default: http://localhost:9999) + MAESTRO_PLATFORM (set to: ios) + MAESTRO_DEVICE_ID (recommended for explicit simulator targeting) + MAESTRO_RUNNER_BIN (path to binary, auto-detected by default) + +Run: + MAESTRO_PLATFORM=ios pytest tests/test_add_contact_ios.py -v +""" + +from __future__ import annotations + +import pytest +from maestro_runner import MaestroClient + +from tests.pages.ios_contact_list_page import IOSContactListPage + + +@pytest.fixture() +def contact_list(client: MaestroClient) -> IOSContactListPage: + return IOSContactListPage(client) + + +class TestAddContactIOS: + """Mirrors add_contact_ios.yaml: launch → create → fill → save → verify.""" + + def test_add_and_verify_contact(self, contact_list: IOSContactListPage): + contact_list.launch() + + edit_page = contact_list.open_create_contact() + edit_page.set_first_name("Alice") + edit_page.set_last_name("Tester") + edit_page.set_phone("5550100") + edit_page.save() + + contact_list.assert_contact_visible("Alice Tester") diff --git a/client/python/tests/test_client.py b/client/python/tests/test_client.py new file mode 100644 index 00000000..ba55109b --- /dev/null +++ b/client/python/tests/test_client.py @@ -0,0 +1,509 @@ +"""Unit tests for maestro_runner.client using requests-mock.""" + +import pytest +import requests_mock as rm +from maestro_runner.client import MaestroClient +from maestro_runner.exceptions import MaestroError, SessionError, StepError +from maestro_runner.models import ElementSelector + +BASE = "http://localhost:9999" +SID = "test-session-123" + + +@pytest.fixture +def mock(): + """Provide a requests_mock adapter for the test.""" + with rm.Mocker() as m: + yield m + + +@pytest.fixture +def mock_with_session(mock): + """Register session create/delete and return the mock adapter.""" + mock.post(f"{BASE}/session", json={"sessionId": SID}) + mock.delete(f"{BASE}/session/{SID}", status_code=200) + return mock + + +def _make_client(mock_adapter) -> MaestroClient: + """Create a MaestroClient with a mocked session.""" + mock_adapter.post(f"{BASE}/session", json={"sessionId": SID}) + mock_adapter.delete(f"{BASE}/session/{SID}", status_code=200) + return MaestroClient(BASE, capabilities={"platformName": "android"}) + + +# ── Session management ─────────────────────────────────────────────────── + + +class TestSessionManagement: + def test_create_session(self, mock): + mock.post(f"{BASE}/session", json={"sessionId": SID}) + client = MaestroClient(BASE, capabilities={"platformName": "android"}) + assert client.session_id == SID + + def test_create_session_failure(self, mock): + mock.post(f"{BASE}/session", status_code=500, text="Internal error") + with pytest.raises(SessionError, match="Failed to create session"): + MaestroClient(BASE, capabilities={"platformName": "android"}) + + def test_no_capabilities_no_session(self): + client = MaestroClient(BASE) + assert client.session_id is None + + def test_close_deletes_session(self, mock): + mock.post(f"{BASE}/session", json={"sessionId": SID}) + mock.delete(f"{BASE}/session/{SID}", status_code=200) + client = MaestroClient(BASE, capabilities={"platformName": "android"}) + client.close() + assert client.session_id is None + assert mock.last_request.method == "DELETE" + + def test_close_without_session_is_noop(self): + client = MaestroClient(BASE) + client.close() # should not raise + + def test_context_manager(self, mock): + mock.post(f"{BASE}/session", json={"sessionId": SID}) + mock.delete(f"{BASE}/session/{SID}", status_code=200) + with MaestroClient(BASE, capabilities={"platformName": "android"}) as c: + assert c.session_id == SID + # after exiting, session should be cleaned up + assert c.session_id is None + + def test_require_session_raises_without_session(self): + client = MaestroClient(BASE) + with pytest.raises(SessionError, match="No active session"): + client.execute_step({"type": "back"}) + + +# ── execute_step / _exec ───────────────────────────────────────────────── + + +class TestExecuteStep: + def test_success(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + json={"success": True, "message": "ok"}, + ) + result = client.execute_step({"type": "back"}) + assert result.success is True + assert result.message == "ok" + + def test_http_error(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + status_code=500, + text="server error", + ) + with pytest.raises(MaestroError, match="Execute failed"): + client.execute_step({"type": "back"}) + + def test_step_failure_raises_step_error(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + json={"success": False, "message": "element not found"}, + ) + with pytest.raises(StepError, match="element not found"): + client.tap(text="Missing") + + def test_step_failure_no_raise_if_optional(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + json={"success": False, "message": "not found"}, + ) + result = client.tap(text="Maybe", optional=True) + assert result.success is False + + +# ── App lifecycle commands ─────────────────────────────────────────────── + + +class TestAppLifecycle: + def test_launch_app(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + json={"success": True}, + ) + result = client.launch_app("com.example.app", clear_state=True) + assert result.success is True + body = mock.last_request.json() + assert body["type"] == "launchApp" + assert body["appId"] == "com.example.app" + assert body["clearState"] is True + + def test_stop_app(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.stop_app("com.example.app") + body = mock.last_request.json() + assert body["type"] == "stopApp" + assert body["appId"] == "com.example.app" + + def test_clear_state(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.clear_state("com.example.app") + body = mock.last_request.json() + assert body["type"] == "clearState" + + def test_open_link(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.open_link("https://example.com") + body = mock.last_request.json() + assert body["type"] == "openLink" + assert body["link"] == "https://example.com" + + +# ── Tap commands ───────────────────────────────────────────────────────── + + +class TestTap: + def test_tap_by_text(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.tap(text="Login") + body = mock.last_request.json() + assert body["type"] == "tapOn" + assert body["selector"] == "Login" + + def test_tap_by_id(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.tap(id="btn_login") + body = mock.last_request.json() + assert body["selector"] == {"id": "btn_login"} + + def test_long_press(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.long_press(text="Hold") + body = mock.last_request.json() + assert body["longPress"] is True + + def test_tap_on_point(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.tap_on_point("50%,50%") + body = mock.last_request.json() + assert body["type"] == "tapOnPoint" + assert body["point"] == "50%,50%" + + def test_tap_on_point_long_press(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.tap_on_point("100,200", long_press=True) + body = mock.last_request.json() + assert body["longPress"] is True + + def test_tap_with_selector_object(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + sel = ElementSelector(text="Item", child_of=ElementSelector(id="list")) + client.tap(selector=sel) + body = mock.last_request.json() + assert body["selector"]["childOf"] == {"id": "list"} + + def test_tap_with_index_coerced_to_string(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.tap(text="Item", index=2) + body = mock.last_request.json() + assert body["selector"] == {"text": "Item", "index": "2"} + + def test_tap_with_boolean_selector_flags(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.tap(text="CB", enabled=True, checked=False) + sel = mock.last_request.json()["selector"] + assert sel["enabled"] is True + assert sel["checked"] is False + + def test_tap_wait_until_visible(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.tap(text="Load", wait_until_visible=True) + assert mock.last_request.json()["waitUntilVisible"] is True + + def test_tap_retry_if_no_change(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.tap(text="Retry", retry_if_no_change=False) + assert mock.last_request.json()["retryTapIfNoChange"] is False + + +# ── Input commands ─────────────────────────────────────────────────────── + + +class TestInput: + def test_input_text(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.input_text("hello@example.com") + body = mock.last_request.json() + assert body["type"] == "inputText" + assert body["text"] == "hello@example.com" + + def test_erase_text(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.erase_text(10) + body = mock.last_request.json() + assert body["type"] == "eraseText" + assert body["charactersToErase"] == 10 + + def test_erase_text_no_count(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.erase_text() + body = mock.last_request.json() + assert body == {"type": "eraseText"} + + + def test_press_key(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.press_key("ENTER") + body = mock.last_request.json() + assert body["type"] == "pressKey" + assert body["key"] == "ENTER" + + def test_back(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.back() + body = mock.last_request.json() + assert body["type"] == "back" + + +# ── Scroll / Swipe ─────────────────────────────────────────────────────── + + +class TestScrollSwipe: + def test_scroll(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.scroll() + body = mock.last_request.json() + assert body["type"] == "scroll" + + def test_swipe(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.swipe("left", duration_ms=600) + body = mock.last_request.json() + assert body["type"] == "swipe" + assert body["direction"] == "LEFT" # lowercase input uppercased + assert body["duration"] == 600 + + def test_swipe_default_duration(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.swipe("up") + assert mock.last_request.json()["duration"] == 400 + + def test_swipe_on_text(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.swipe_on(text="List", direction="DOWN") + body = mock.last_request.json() + assert body["type"] == "swipe" + assert body["selector"] == {"text": "List"} + assert body["direction"] == "DOWN" + + def test_swipe_on_id(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.swipe_on(id="scroll_view", direction="UP") + body = mock.last_request.json() + assert body["selector"] == {"id": "scroll_view"} + + +# ── Assertions ─────────────────────────────────────────────────────────── + + +class TestAssertions: + def test_assert_visible(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.assert_visible(text="Welcome") + body = mock.last_request.json() + assert body["type"] == "assertVisible" + assert body["selector"] == "Welcome" + + def test_assert_visible_with_timeout(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.assert_visible(text="Loading", timeout_ms=5000) + assert mock.last_request.json()["timeout"] == 5000 + + def test_assert_not_visible(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.assert_not_visible(text="Error") + body = mock.last_request.json() + assert body["type"] == "assertNotVisible" + assert body["selector"] == "Error" + + def test_element_exists_true(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + json={"success": True}, + ) + assert client.element_exists(text="Title") is True + + def test_element_exists_false(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + json={"success": False, "message": "not found"}, + ) + assert client.element_exists(text="Ghost") is False + + +# ── tap_first_match ────────────────────────────────────────────────────── + + +class TestTapFirstMatch: + def test_first_selector_matches(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + json={"success": True}, + ) + result = client.tap_first_match( + [{"selector": "Create contact"}, {"selector": "Add contact"}], + step="create", + ) + assert result.success is True + + def test_second_selector_matches(self, mock): + client = _make_client(mock) + responses = [ + {"json": {"success": False, "message": "miss"}}, + {"json": {"success": True, "message": "hit"}}, + ] + mock.post(f"{BASE}/session/{SID}/execute", responses) + result = client.tap_first_match( + [{"selector": "Nope"}, {"selector": "Found"}], + ) + assert result.success is True + assert result.message == "hit" + + def test_no_match_raises(self, mock): + client = _make_client(mock) + mock.post( + f"{BASE}/session/{SID}/execute", + json={"success": False, "message": "miss"}, + ) + with pytest.raises(StepError, match="none of 2 selectors matched"): + client.tap_first_match( + [{"selector": "A"}, {"selector": "B"}], + step="test", + ) + + def test_empty_selectors_raises(self, mock): + client = _make_client(mock) + with pytest.raises(StepError, match="no selectors provided"): + client.tap_first_match([]) + + +# ── Device queries ─────────────────────────────────────────────────────── + + +class TestDeviceQueries: + def test_device_info(self, mock): + client = _make_client(mock) + mock.get( + f"{BASE}/session/{SID}/device-info", + json={ + "platform": "android", + "osVersion": "14", + "deviceName": "Pixel 6", + "deviceId": "emulator-5554", + "isSimulator": True, + "screenWidth": 1080, + "screenHeight": 2400, + "appId": "", + }, + ) + info = client.device_info() + assert info.platform == "android" + assert info.screen_width == 1080 + + def test_device_info_error(self, mock): + client = _make_client(mock) + mock.get( + f"{BASE}/session/{SID}/device-info", + status_code=500, + text="fail", + ) + with pytest.raises(MaestroError, match="device-info failed"): + client.device_info() + + def test_screenshot(self, mock): + client = _make_client(mock) + mock.get( + f"{BASE}/session/{SID}/screenshot", + content=b"\x89PNG fake image data", + ) + data = client.screenshot() + assert data == b"\x89PNG fake image data" + + def test_screenshot_error(self, mock): + client = _make_client(mock) + mock.get( + f"{BASE}/session/{SID}/screenshot", + status_code=500, + text="fail", + ) + with pytest.raises(MaestroError, match="screenshot failed"): + client.screenshot() + + def test_view_hierarchy(self, mock): + client = _make_client(mock) + xml = "" + mock.get( + f"{BASE}/session/{SID}/source", + text=xml, + ) + assert client.view_hierarchy() == xml + + def test_view_hierarchy_error(self, mock): + client = _make_client(mock) + mock.get( + f"{BASE}/session/{SID}/source", + status_code=500, + text="fail", + ) + with pytest.raises(MaestroError, match="source failed"): + client.view_hierarchy() + + +# ── waitForAnimationToEnd ──────────────────────────────────────────────── + + +class TestWaitForAnimationToEnd: + def test_default_params(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.wait_for_animation_to_end() + body = mock.last_request.json() + assert body["type"] == "waitForAnimationToEnd" + assert "sleepMs" not in body + assert "threshold" not in body + + def test_with_sleep_ms_and_threshold(self, mock): + client = _make_client(mock) + mock.post(f"{BASE}/session/{SID}/execute", json={"success": True}) + client.wait_for_animation_to_end(sleep_ms=500, threshold=0.001) + body = mock.last_request.json() + assert body["type"] == "waitForAnimationToEnd" + assert body["sleepMs"] == 500 + assert body["threshold"] == 0.001 diff --git a/client/python/tests/test_contact_persists.py b/client/python/tests/test_contact_persists.py new file mode 100644 index 00000000..7ce60ee2 --- /dev/null +++ b/client/python/tests/test_contact_persists.py @@ -0,0 +1,57 @@ +"""POM-based test — Contact persists after relaunch. + +Equivalent of: e2e/workspaces/contacts/contact_persists.yaml + +This test creates a contact using the add_contact flow as setup, +then cold-relaunches the app and verifies the contact is still visible. + +Prerequisites: + 1. Android emulator running (adb devices shows device) + 2. Python deps installed (from client/python): + python3 -m venv .venv && source .venv/bin/activate + pip install -e ".[dev]" + 3. (Optional) Start maestro-runner server manually: + ./maestro-runner --platform android server --port 9999 + If not running, the server is auto-started by the test fixture. + +Override with env vars: + MAESTRO_SERVER_URL (default: http://localhost:9999) + MAESTRO_PLATFORM (default: android) + MAESTRO_RUNNER_BIN (path to binary, auto-detected by default) + +Run: + pytest tests/test_contact_persists.py -v +""" + +from __future__ import annotations + +import pytest +from maestro_runner import MaestroClient + +from tests.pages.contact_list_page import ContactListPage + + +@pytest.fixture() +def contact_list(client: MaestroClient) -> ContactListPage: + return ContactListPage(client) + + +class TestContactPersists: + """Mirrors contact_persists.yaml: add contact → relaunch → verify.""" + + def test_contact_persists_after_relaunch(self, contact_list: ContactListPage): + # First create the contact (reuses the add_contact flow as setup) + contact_list.launch(clear_state=True) + edit_page = contact_list.open_create_contact() + edit_page.set_first_name("Alice") + edit_page.set_last_name("Tester") + edit_page.set_phone("5550100") + edit_page.save() + contact_list.assert_contact_visible("Alice Tester") + + # Cold-relaunch the app + contact_list.client.stop_app(ContactListPage.APP_ID) + contact_list.launch(clear_state=False) + + # The contact must still be visible + contact_list.assert_contact_visible("Alice Tester") diff --git a/client/python/tests/test_e2e_android.py b/client/python/tests/test_e2e_android.py new file mode 100644 index 00000000..7b9fa203 --- /dev/null +++ b/client/python/tests/test_e2e_android.py @@ -0,0 +1,156 @@ +"""End-to-end test against a running Android emulator. + +Prerequisites: + 1. Android emulator running (adb devices shows device) + 2. Python deps installed: + pip install requests pytest + 3. A maestro-runner server is started automatically by the shared conftest + (maestro_server fixture) — do NOT start a second one or open a second + session by hand. The uiautomator2 driver locks the device per process, so + only one session may be active at a time; this file reuses the conftest + ``client`` session via the ``session_id`` fixture below. + +Run: + pytest tests/test_e2e_android.py -v +""" + +import os + +import pytest +import requests + +SERVER_URL = os.environ.get("MAESTRO_SERVER_URL", "http://localhost:9999") + + +@pytest.fixture(scope="module") +def session_id(client): + """Reuse the conftest-managed session instead of opening a second one. + + The shared conftest already opens a session per test process (via the + autouse ``_track_client`` fixture -> ``client`` fixture). Opening another + session here would hit the uiautomator2 device lock + (``/tmp/uia2-.sock`` → "device already in use"). Reusing the + existing session keeps the raw-HTTP assertions below intact while staying + within the single-session limit. Teardown is handled by the ``client`` + fixture, so we don't delete the session ourselves. + """ + yield client.session_id + + +def _execute(session_id: str, step: dict) -> dict: + """Helper: execute a step and return the JSON result.""" + resp = requests.post( + f"{SERVER_URL}/session/{session_id}/execute", + json=step, + ) + assert resp.status_code == 200, f"Execute failed ({resp.status_code}): {resp.text}" + return resp.json() + + +# ---- Tests (run in order via pytest-ordering or alphabetical naming) ---- + + +def test_01_server_status(): + """Server is reachable and healthy.""" + resp = requests.get(f"{SERVER_URL}/status") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ok" + + +def test_02_device_info(session_id): + """Device info returns Android platform details.""" + resp = requests.get(f"{SERVER_URL}/session/{session_id}/device-info") + assert resp.status_code == 200 + info = resp.json() + assert info["platform"] == "android" + assert info["isSimulator"] is True + assert info["screenWidth"] > 0 + assert info["screenHeight"] > 0 + print(f" Device: {info['deviceName']} (Android {info['osVersion']})") + + +def test_03_launch_settings(session_id): + """Launch the Android Settings app.""" + result = _execute(session_id, { + "type": "launchApp", + "appId": "com.android.settings", + "clearState": False, + }) + assert result["success"] is True, f"Launch failed: {result.get('message')}" + + +def test_04_assert_settings_visible(session_id): + """After launching Settings, the title should be visible.""" + result = _execute(session_id, { + "type": "assertVisible", + "selector": "Settings", + "timeout": 10000, + }) + assert result["success"] is True, f"Assert failed: {result.get('message')}" + + +def test_05_tap_search(session_id): + """Tap on the search icon/bar in Settings.""" + result = _execute(session_id, { + "type": "tapOn", + "selector": {"id": "com.android.settings:id/search_action_bar_title"}, + "timeout": 5000, + }) + # Allow failure if the ID changed on this Android version + if not result["success"]: + # Try text-based search as fallback + result = _execute(session_id, { + "type": "tapOn", + "selector": "Search settings", + "timeout": 5000, + }) + assert result["success"] is True, f"Tap search failed: {result.get('message')}" + + +def test_06_input_text(session_id): + """Type text into the search field.""" + # Tap the search edit text to ensure it's focused + _execute(session_id, { + "type": "tapOn", + "selector": {"id": "com.android.settings:id/search_src_text"}, + "timeout": 5000, + }) + result = _execute(session_id, { + "type": "inputText", + "text": "Display", + }) + assert result["success"] is True, f"Input failed: {result.get('message')}" + + +def test_07_assert_search_result_visible(session_id): + """After searching, a Display result should appear.""" + result = _execute(session_id, { + "type": "assertVisible", + "selector": "Display", + "timeout": 10000, + }) + assert result["success"] is True, f"Assert failed: {result.get('message')}" + + +def test_08_screenshot(session_id): + """Take a screenshot and verify it returns PNG data.""" + resp = requests.get(f"{SERVER_URL}/session/{session_id}/screenshot") + assert resp.status_code == 200 + assert resp.headers.get("Content-Type") == "image/png" + # PNG magic bytes + assert resp.content[:4] == b"\x89PNG", "Not valid PNG data" + print(f" Screenshot size: {len(resp.content)} bytes") + + +def test_09_view_hierarchy(session_id): + """Fetch the view hierarchy.""" + resp = requests.get(f"{SERVER_URL}/session/{session_id}/source") + assert resp.status_code == 200 + assert len(resp.content) > 100, "Hierarchy seems too small" + + +def test_10_press_back(session_id): + """Press back to leave search.""" + result = _execute(session_id, {"type": "back"}) + assert result["success"] is True diff --git a/client/python/tests/test_models.py b/client/python/tests/test_models.py new file mode 100644 index 00000000..d1bd8ba7 --- /dev/null +++ b/client/python/tests/test_models.py @@ -0,0 +1,184 @@ +"""Unit tests for maestro_runner.models.""" + +from maestro_runner.models import DeviceInfo, ElementInfo, ElementSelector, ExecutionResult + +# ── ElementSelector ────────────────────────────────────────────────────── + + +class TestElementSelector: + def test_to_dict_text_only(self): + sel = ElementSelector(text="Hello") + assert sel.to_dict() == {"text": "Hello"} + + def test_to_dict_id_only(self): + sel = ElementSelector(id="btn_login") + assert sel.to_dict() == {"id": "btn_login"} + + def test_to_dict_index_as_string(self): + sel = ElementSelector(index=3) + assert sel.to_dict() == {"index": "3"} + + def test_to_dict_boolean_flags(self): + sel = ElementSelector(enabled=True, checked=False, focused=True, selected=False) + d = sel.to_dict() + assert d == {"enabled": True, "checked": False, "focused": True, "selected": False} + + def test_to_dict_css_and_traits(self): + sel = ElementSelector(css=".btn", traits="button") + assert sel.to_dict() == {"css": ".btn", "traits": "button"} + + def test_to_dict_nested_child_of(self): + parent = ElementSelector(id="list") + child = ElementSelector(text="Item 1", child_of=parent) + d = child.to_dict() + assert d == {"text": "Item 1", "childOf": {"id": "list"}} + + def test_to_dict_all_relative_selectors(self): + ref = ElementSelector(text="Ref") + sel = ElementSelector( + text="Target", + below=ref, + above=ref, + left_of=ref, + right_of=ref, + contains_child=ref, + inside_of=ref, + ) + d = sel.to_dict() + assert d["text"] == "Target" + for key in ("below", "above", "leftOf", "rightOf", "containsChild", "insideOf"): + assert d[key] == {"text": "Ref"} + + def test_to_dict_empty(self): + sel = ElementSelector() + assert sel.to_dict() == {} + + def test_to_dict_combined_text_and_id(self): + sel = ElementSelector(text="Login", id="btn_login") + d = sel.to_dict() + assert d == {"text": "Login", "id": "btn_login"} + + +# ── ElementInfo ────────────────────────────────────────────────────────── + + +class TestElementInfo: + def test_from_dict_full(self): + data = { + "id": "btn1", + "text": "OK", + "bounds": {"x": 10, "y": 20, "width": 100, "height": 50}, + "visible": True, + "enabled": True, + "focused": False, + "checked": True, + "selected": False, + } + elem = ElementInfo.from_dict(data) + assert elem is not None + assert elem.id == "btn1" + assert elem.text == "OK" + assert elem.bounds == {"x": 10, "y": 20, "width": 100, "height": 50} + assert elem.visible is True + assert elem.enabled is True + assert elem.focused is False + assert elem.checked is True + assert elem.selected is False + + def test_from_dict_none_returns_none(self): + assert ElementInfo.from_dict(None) is None + + def test_from_dict_empty(self): + elem = ElementInfo.from_dict({}) + assert elem is not None + assert elem.id == "" + assert elem.text == "" + assert elem.bounds == {} + assert elem.visible is False + + def test_from_dict_partial(self): + elem = ElementInfo.from_dict({"text": "hello", "visible": True}) + assert elem is not None + assert elem.text == "hello" + assert elem.visible is True + assert elem.id == "" + + +# ── ExecutionResult ────────────────────────────────────────────────────── + + +class TestExecutionResult: + def test_from_dict_success(self): + data = {"success": True, "message": "done", "duration": 1234} + r = ExecutionResult.from_dict(data) + assert r.success is True + assert r.message == "done" + assert r.duration_ns == 1234 + + def test_from_dict_failure(self): + data = {"success": False, "message": "not found"} + r = ExecutionResult.from_dict(data) + assert r.success is False + assert r.message == "not found" + assert r.duration_ns == 0 + + def test_from_dict_with_element(self): + data = { + "success": True, + "element": {"id": "e1", "text": "Hello", "visible": True}, + } + r = ExecutionResult.from_dict(data) + assert r.element is not None + assert r.element.id == "e1" + assert r.element.text == "Hello" + + def test_from_dict_with_data(self): + data = {"success": True, "data": {"key": "value"}} + r = ExecutionResult.from_dict(data) + assert r.data == {"key": "value"} + + def test_from_dict_minimal(self): + r = ExecutionResult.from_dict({}) + assert r.success is False + assert r.message is None + assert r.element is None + assert r.data is None + + +# ── DeviceInfo ─────────────────────────────────────────────────────────── + + +class TestDeviceInfo: + def test_from_dict_full(self): + data = { + "platform": "android", + "osVersion": "14", + "deviceName": "Pixel 6", + "deviceId": "emulator-5554", + "isSimulator": True, + "screenWidth": 1080, + "screenHeight": 2400, + "appId": "com.example.app", + } + info = DeviceInfo.from_dict(data) + assert info.platform == "android" + assert info.os_version == "14" + assert info.device_name == "Pixel 6" + assert info.device_id == "emulator-5554" + assert info.is_simulator is True + assert info.screen_width == 1080 + assert info.screen_height == 2400 + assert info.app_id == "com.example.app" + + def test_from_dict_empty(self): + info = DeviceInfo.from_dict({}) + assert info.platform == "" + assert info.os_version == "" + assert info.screen_width == 0 + assert info.is_simulator is False + + def test_from_dict_partial(self): + info = DeviceInfo.from_dict({"platform": "ios", "screenWidth": 390}) + assert info.platform == "ios" + assert info.screen_width == 390 + assert info.device_name == "" diff --git a/client/python/tests/test_wait_for_animation_never_ends.py b/client/python/tests/test_wait_for_animation_never_ends.py new file mode 100644 index 00000000..dfcd0045 --- /dev/null +++ b/client/python/tests/test_wait_for_animation_never_ends.py @@ -0,0 +1,79 @@ +"""Test that waitForAnimationToEnd times out when animation never stops. + +The emulator's camera preview is a live (continuously changing) feed, so the +driver can never reach a "two consecutive identical screenshots" steady state +and waitForAnimationToEnd must time out and return success=False. + +Using the camera avoids any dependency on external network access or a browser, +both of which are unreliable inside CI sandboxes: the emulator often cannot +reach the runner's loopback (so a locally served spinner page never loads), and +Chrome will not render a local file:// from an intent. The camera preview is +always available on the emulator and never settles. + +Prerequisites: + 1. Android emulator running (``adb devices`` shows a device) + 2. maestro-runner binary built and on PATH (or conftest will auto-start) + 3. Python deps installed: pip install requests pytest + +Run: + pytest tests/test_wait_for_animation_never_ends.py -v +""" + +import subprocess +import time + +from maestro_runner import MaestroClient, commands + +# Pause between the two consecutive screenshots (ms) — must be long enough for +# the preview to advance at least one visible frame +_SLEEP_MS = 500 +# Maximum pixel-diff fraction still considered "static". Far below what a live +# camera preview produces, so any change is detected as animated. +_THRESHOLD = 0.0003 + + +def _launch_camera() -> None: + # Open the camera capture intent. The emulated camera shows a live preview + # that never settles, which is exactly what we need. + subprocess.run( + ["adb", "shell", "am", "start", "-a", "android.media.action.IMAGE_CAPTURE"], + capture_output=True, text=True, check=True, + ) + + +def test_wait_for_animation_times_out_on_infinite_spinner( + client: MaestroClient, +) -> None: + """ + Open the camera (live preview never settles), then call + waitForAnimationToEnd. Because the preview keeps changing the driver must + time out and return success=False. + """ + _launch_camera() + + # Give the camera time to start rendering the live preview + time.sleep(5) + + # Swipe up a bit; assert it worked + swipe_result = client.swipe("up", duration_ms=400) + assert swipe_result.success is True, ( + f"Swipe failed: {swipe_result.message}" + ) + time.sleep(1) + + # Use execute_step directly so that success=False is returned instead of + # raising StepError — this is what we want to assert on. + result = client.execute_step(commands.wait_for_animation_to_end( + sleep_ms=_SLEEP_MS, + threshold=_THRESHOLD, + label="wait_for_animation_on_camera_preview", + )) + + assert result.success is False, ( + "Expected waitForAnimationToEnd to fail (timeout) because the camera " + f"preview never settles, but got success=True. Message: {result.message}" + ) + assert "Timed out" in (result.message or ""), ( + f"Expected a timeout message, got: {result.message}" + ) + print(f" waitForAnimationToEnd timed out as expected: {result.message}") diff --git a/client/python/tests/test_wait_for_animation_to_end.py b/client/python/tests/test_wait_for_animation_to_end.py new file mode 100644 index 00000000..8f9653a2 --- /dev/null +++ b/client/python/tests/test_wait_for_animation_to_end.py @@ -0,0 +1,63 @@ +"""Tests for waitForAnimationToEnd on Android. + +The test launches Android Settings, navigates into a sub-screen (which +produces a visible transition animation) and then calls waitForAnimationToEnd +to confirm the screen has settled. + +Prerequisites: + 1. Android emulator running (``adb devices`` shows a device) + 2. maestro-runner binary built and on PATH (or conftest will auto-start) + 3. Python deps installed: pip install requests pytest + +Run: + pytest tests/test_wait_for_animation_to_end.py -v +""" + +from maestro_runner import MaestroClient + +# --------------------------------------------------------------------------- +# Tests (all use the session-scoped `client` fixture from conftest.py) +# --------------------------------------------------------------------------- + + +def test_wait_for_animation_settles_after_app_launch(client: MaestroClient) -> None: + """ + Launch Settings (which itself triggers an entry animation) then immediately + call waitForAnimationToEnd — the driver must detect that the screen has + become static and return success. + """ + client.launch_app("com.android.settings", clear_state=False) + + # Should not raise; always returns success=True (timeout is non-fatal) + result = client.wait_for_animation_to_end() + assert result.success is True, f"waitForAnimationToEnd failed: {result.message}" + assert "WARNING" not in (result.message or ""), ( + f"Got placeholder warning instead of real implementation: {result.message}" + ) + print(f" waitForAnimationToEnd message: {result.message}") + + +def test_wait_for_animation_settles_after_navigation(client: MaestroClient) -> None: + """ + Tap into a sub-screen to trigger a slide-in animation, then call + waitForAnimationToEnd and confirm it settles without error. + """ + client.tap(text="Display") + + result = client.wait_for_animation_to_end() + assert result.success is True, f"waitForAnimationToEnd failed: {result.message}" + assert "WARNING" not in (result.message or ""), ( + f"Got placeholder warning: {result.message}" + ) + print(f" waitForAnimationToEnd message: {result.message}") + + +def test_wait_for_animation_on_already_static_screen(client: MaestroClient) -> None: + """ + Call waitForAnimationToEnd when the screen is already fully static. + The driver should take two consecutive identical screenshots and return + almost immediately with a 'screen is static' message. + """ + result = client.wait_for_animation_to_end() + assert result.success is True, f"waitForAnimationToEnd on static screen failed: {result.message}" + print(f" waitForAnimationToEnd (static) message: {result.message}") diff --git a/client/typescript/.gitignore b/client/typescript/.gitignore new file mode 100644 index 00000000..c9304195 --- /dev/null +++ b/client/typescript/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +*.js.map +*.d.ts +!jest.config.js +.tsbuildinfo +coverage/ +maestro-server.log +reports/ + +# Local copied iOS driver sources +drivers/ diff --git a/client/typescript/DEVELOPER.md b/client/typescript/DEVELOPER.md new file mode 100644 index 00000000..ff794ea8 --- /dev/null +++ b/client/typescript/DEVELOPER.md @@ -0,0 +1,168 @@ +# TypeScript Client — Developer Guide + +Development reference for the `client/typescript` package. + +## Prerequisites + +- **Node.js** ≥ 18 +- **npm** (ships with Node.js) + +## Setup + +```bash +cd client/typescript +npm install +``` + +## Project Structure + +``` +client/typescript/ +├── src/ +│ ├── index.ts # Public API exports +│ ├── client.ts # MaestroClient — main HTTP client class +│ ├── commands.ts # Step builders (tapOn, inputText, swipe, …) +│ ├── models.ts # Data models (ElementSelector, ExecutionResult, DeviceInfo) +│ └── exceptions.ts # Error classes (MaestroError, SessionError, StepError) +├── tests/ +│ ├── setup.ts # Shared test harness — auto-starts maestro-runner server +│ ├── pages/ # Page Object Model base + page classes +│ │ ├── BasePage.ts +│ │ ├── ContactListPage.ts +│ │ └── EditContactPage.ts +│ └── *.test.ts # Test files +├── eslint.config.mjs # ESLint v9 flat config +├── tsconfig.json # TypeScript compiler options +├── jest.config.js # Jest config (ts-jest preset) +└── package.json +``` + +## Build + +```bash +npm run build # Compiles src/ → dist/ via tsc +``` + +Output goes to `dist/` with declarations (`.d.ts`), declaration maps, and source maps. + +## Lint + +ESLint is configured with `typescript-eslint` in flat-config format (`eslint.config.mjs`). + +```bash +npm run lint # Check for issues +npm run lint:fix # Auto-fix what's possible +``` + +### Key Rules + +| Rule | Scope | Behavior | +|------|-------|----------| +| `consistent-type-imports` | `src/` | Enforces `import type` for type-only imports | +| `no-explicit-any` | `src/` warn, `tests/` off | Discourages `any` in production code | +| `no-unused-vars` | all | Errors; `_`-prefixed names are ignored | +| `eqeqeq` | all | Strict equality required (`!= null` exempted) | +| `no-console` | `src/` warn, `tests/` off | Prevents accidental console logs in library code | +| `curly` | all | Braces required for multi-line blocks | + +## Test + +Tests use **Jest** with **ts-jest**. Unit tests use the `*.unit.test.ts` suffix, while live device/feature tests use the `*.device.test.ts` suffix and run against a live maestro-runner server. + +```bash +# Run unit tests only +npm run test:unit + +# Run E2E/device tests only +npm run test:e2e + +# Run a specific test file +npx jest tests/test_add_contact.device.test.ts +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Server URL | +| `MAESTRO_PLATFORM` | `android` | Target platform (`android` / `ios`) | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to maestro-runner binary | + +The test setup (`tests/setup.ts`) auto-starts the maestro-runner server if it isn't already running. + +### Test Reports And Server Traces + +Jest writes test reports under `client/typescript/reports/`: + +``` +reports/report.html +reports/junit-report.xml +``` + +The setup harness also persists worker-aware server logs for analysis: + +``` +reports/server-run--.log +reports/server-latest.json +reports/jest-run-.log +reports/artifact-summary-.json +``` + +- `server-run-...log` is the canonical server stdout/stderr log for that worker run. +- `server-latest.json` maps each worker id to its latest run metadata and log path. +- `jest-run-...log` records worker and node-aware test harness events for correlation. +- `artifact-summary-...json` captures artifact paths/sizes and includes tail snippets for quick triage. +- Appium-style trace lines are emitted in server logs as `[TRACE]` entries, including request/response step, status, and duration. + +## Code Conventions + +### Architecture + +The client follows a thin layered design: + +1. **`commands.ts`** — Pure functions that build step JSON payloads (`Record`) +2. **`client.ts`** — `MaestroClient` wraps HTTP calls to the REST API; each high-level method delegates to a command builder then calls `executeStep()` +3. **`models.ts`** — Typed classes (`ElementSelector`, `ExecutionResult`, `DeviceInfo`) with `fromDict()` / `toDict()` for JSON serialization +4. **`exceptions.ts`** — Error hierarchy (`MaestroError` → `SessionError` / `StepError`) + +### Adding a New Command + +1. Add a builder function in `src/commands.ts`: + +```ts +export function myCommand(arg: string, label?: string): Step { + const step: Step = { type: "myCommand", arg }; + if (label != null) step.label = label; + return step; +} +``` + +2. Add a convenience method in `src/client.ts`: + +```ts +async myCommand(arg: string, label?: string): Promise { + return this.exec(commands.myCommand(arg, label)); +} +``` + +3. Export any new public types from `src/index.ts`. + +### Page Object Model (Tests) + +Tests use the Page Object pattern to keep test logic decoupled from selectors: + +- **`BasePage`** — common helpers (`waitForAnimation`, `hideKeyboard`) +- Concrete pages extend `BasePage` and expose domain actions (e.g., `contactList.openCreateContact()`) +- Tests compose page methods; they never call `client.tap()` directly + +### Type-Only Imports + +ESLint enforces `import type` for imports used only in type positions: + +```ts +// ✓ correct +import type { ElementSelector } from "./models"; + +// ✗ will fail lint +import { ElementSelector } from "./models"; // if only used as a type +``` diff --git a/client/typescript/README.md b/client/typescript/README.md new file mode 100644 index 00000000..2d8b775b --- /dev/null +++ b/client/typescript/README.md @@ -0,0 +1,137 @@ +# maestro-runner — TypeScript Client + +TypeScript/JavaScript client for the [maestro-runner](../../README.md) REST API. + +## Installation + +```bash +cd client/typescript +npm install +``` + +## Quick Start + +```ts +import { MaestroClient } from "maestro-runner"; + +const client = new MaestroClient("http://localhost:9999"); +await client.createSession({ platformName: "android" }); + +try { + await client.tap({ text: "Login" }); + await client.inputText("user@example.com"); + await client.assertVisible({ text: "Welcome" }); +} finally { + await client.close(); +} +``` + +## Page Object Model + +Tests use the Page Object Model pattern for maintainable E2E tests: + +```ts +import { getClient, teardown } from "./setup"; +import { ContactListPage } from "./pages/ContactListPage"; + +afterAll(() => teardown()); + +it("adds a contact", async () => { + const client = await getClient(); + const contactList = new ContactListPage(client); + + await contactList.launch(true); + const editPage = await contactList.openCreateContact(); + await editPage.setFirstName("Alice"); + await editPage.setLastName("Tester"); + await editPage.setPhone("5550100"); + await editPage.save(); + await contactList.assertContactVisible("Alice Tester"); +}); +``` + +## Running Tests + +```bash +# Prerequisites for device tests: emulator/simulator running, maestro-runner server started +./maestro-runner --platform android server --port 9999 + +# Run unit tests only +npm run test:unit + +# Run device tests +npm run test:device:android +npm run test:device:ios +``` + +## Environment Variables + +| Variable | Default | Description | +| --------------------- | -------------------------- | ------------------------------- | +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Server URL | +| `MAESTRO_PLATFORM` | `android` | Target platform | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to maestro-runner binary | + +## API Reference + +### MaestroClient + +| Method | Description | +| --------------------- | -------------------------------------- | +| `createSession()` | Initialize a session | +| `close()` | Delete the session | +| `launchApp()` | Launch an app | +| `stopApp()` | Stop an app | +| `clearState()` | Clear app state | +| `tap()` | Tap on an element | +| `longPress()` | Long-press on an element | +| `tapOnPoint()` | Tap on a coordinate | +| `inputText()` | Type text | +| `eraseText()` | Erase text | +| `pressKey()` | Press a key | +| `back()` | Press back button | +| `hideKeyboard()` | Hide the keyboard | +| `scroll()` | Scroll | +| `swipe()` | Swipe in a direction | +| `assertVisible()` | Assert element is visible | +| `assertNotVisible()` | Assert element is not visible | +| `elementExists()` | Check if element exists (no throw) | +| `tapFirstMatch()` | Tap first matching selector | +| `deviceInfo()` | Get device information | +| `screenshot()` | Get screenshot as ArrayBuffer | +| `viewHierarchy()` | Get view hierarchy XML | +| `setPermissions()` | Grant/deny app permissions (`setPermissions`) | +| `resetPermissions()` | Reset browser permissions (`resetPermissions`) | +| `evalWebViewScript()` | Run JS in a mobile WebView via CDP (`evalWebViewScript`) | +| `runWebViewScript()` | Load & run a JS file in a mobile WebView via CDP (`runWebViewScript`) | +| `doubleTapOn()` | Double-tap on an element (`doubleTapOn`) | +| `longPressOn()` | Long-press on an element (`longPressOn`) | +| `dragAndDrop()` | Drag one element onto another (`dragAndDrop`) | +| `scrollUntilVisible()` | Scroll until an element appears (`scrollUntilVisible`) | +| `assertScreenshot()` | Visual regression assert (`assertScreenshot`) | +| `takeScreenshot()` | Save a screenshot to disk (`takeScreenshot`) | +| `copyTextFrom()` | Copy text from an element to clipboard (`copyTextFrom`) | +| `pasteText()` | Paste clipboard text (`pasteText`) | +| `setClipboard()` | Set clipboard contents (`setClipboard`) | +| `assertWithAI()` | Natural-language assertion via AI (`assertWithAI`) | +| `evalScript()` | Run an inline JS snippet (`evalScript`) | +| `runScript()` | Run a JS file with env vars (`runScript`) | +| `evalBrowserScript()` | Run JS in a desktop browser (`evalBrowserScript`) | +| `setLocation()` | Set device GPS location (`setLocation`) | +| `setAirplaneMode()` | Enable/disable airplane mode (`setAirplaneMode`) | +| `toggleAirplaneMode()` | Toggle airplane mode (`toggleAirplaneMode`) | +| `setNetworkConditions()` | Throttle/simulate network (`setNetworkConditions`) | +| `openNotifications()` | Open the notification shade (`openNotifications`) | +| `setDarkMode()` | Enable/disable dark mode (`setDarkMode`) | +| `setOrientation()` | Set screen orientation (`setOrientation`) | +| `openBrowser()` | Open a URL in the desktop browser (`openBrowser`) | +| `switchTab()` | Switch browser tab (`switchTab`) | +| `closeTab()` | Close current browser tab (`closeTab`) | +| `getConsoleLogs()` | Read browser console logs (`getConsoleLogs`) | +| `clearConsoleLogs()` | Clear browser console logs (`clearConsoleLogs`) | +| `assertNoJSErrors()` | Assert no console JS errors (`assertNoJSErrors`) | +| `mockNetwork()` | Mock a network request (`mockNetwork`) | + +Any step type not listed here can still be sent via `executeStep({ type: "...", ... })` — +the client forwards the raw step dict straight to the server, which supports ~90 step +types in total. diff --git a/client/typescript/eslint.config.mjs b/client/typescript/eslint.config.mjs new file mode 100644 index 00000000..025e1f74 --- /dev/null +++ b/client/typescript/eslint.config.mjs @@ -0,0 +1,47 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + files: ["src/**/*.ts"], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // TypeScript-specific + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/no-empty-function": "off", + + // General + "no-console": "warn", + eqeqeq: ["error", "always", { null: "ignore" }], + curly: ["error", "multi-line"], + "prefer-const": "error", + "no-throw-literal": "error", + }, + }, + { + files: ["tests/**/*.ts"], + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "off", + "no-console": "off", + }, + }, + { + ignores: ["dist/", "node_modules/", "coverage/", "reports/", "drivers/", "cache/", "jest.config.js"], + }, +); diff --git a/client/typescript/jest.config.js b/client/typescript/jest.config.js new file mode 100644 index 00000000..bd84719e --- /dev/null +++ b/client/typescript/jest.config.js @@ -0,0 +1,26 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/tests"], + testTimeout: 120_000, + reporters: [ + "default", + [ + "jest-html-reporters", + { + publicPath: "./reports", + filename: "report.html", + pageTitle: "maestro-runner TypeScript Test Report", + expand: true, + }, + ], + [ + "jest-junit", + { + outputDirectory: "./reports", + outputName: "junit-report.xml", + }, + ], + ], +}; diff --git a/client/typescript/package-lock.json b/client/typescript/package-lock.json new file mode 100644 index 00000000..f7aa41a2 --- /dev/null +++ b/client/typescript/package-lock.json @@ -0,0 +1,5150 @@ +{ + "name": "maestro-runner", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maestro-runner", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@eslint/js": "^9.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "eslint": "^9.0.0", + "jest": "^29.7.0", + "jest-html-reporters": "^3.1.7", + "jest-junit": "^16.0.0", + "ts-jest": "^29.1.0", + "typescript": "^5.3.0", + "typescript-eslint": "^8.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001777", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz", + "integrity": "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-html-reporters": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/jest-html-reporters/-/jest-html-reporters-3.1.7.tgz", + "integrity": "sha512-GTmjqK6muQ0S0Mnksf9QkL9X9z2FGIpNSxC52E0PHDzjPQ1XDu2+XTI3B3FS43ZiUzD1f354/5FfwbNIBzT7ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "fs-extra": "^10.0.0", + "open": "^8.0.3" + } + }, + "node_modules/jest-junit": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-16.0.0.tgz", + "integrity": "sha512-A94mmw6NfJab4Fg/BlvVOUXzXgF0XIH6EmTgJ5NDPp4xoKq0Kr7sErb+4Xs9nZvu58pJojz5RFGpqnZYJTrRfQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mkdirp": "^1.0.4", + "strip-ansi": "^6.0.1", + "uuid": "^8.3.2", + "xml": "^1.0.1" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.6", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz", + "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.8", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.3", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/client/typescript/package.json b/client/typescript/package.json new file mode 100644 index 00000000..3c47a63e --- /dev/null +++ b/client/typescript/package.json @@ -0,0 +1,42 @@ +{ + "name": "maestro-runner", + "version": "0.1.0", + "description": "TypeScript/JavaScript client for maestro-runner REST API", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "test": "echo 'Use npm run test:unit (parallel) or npm run test:device (serial real-device)' && exit 1", + "test:unit": "jest --testMatch '**/*.unit.test.ts' --passWithNoTests", + "test:device": "echo 'Use npm run test:device:android or npm run test:device:ios' && exit 1", + "test:device:android": "jest tests/test_add_contact.device.test.ts tests/test_contact_persists.device.test.ts --runInBand", + "test:device:ios": "jest tests/test_add_contact_ios.device.test.ts --runInBand", + "test:animation:android": "jest tests/test_wait_for_animation_never_ends.device.test.ts --runInBand", + "test:animation:ios": "jest tests/test_wait_for_animation_never_ends.device.test.ts --runInBand", + "test:e2e": "npm run test:device", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "keywords": [ + "maestro", + "mobile", + "testing", + "automation" + ], + "license": "Apache-2.0", + "devDependencies": { + "@eslint/js": "^9.0.0", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "eslint": "^9.0.0", + "jest": "^29.7.0", + "jest-html-reporters": "^3.1.7", + "jest-junit": "^16.0.0", + "ts-jest": "^29.1.0", + "typescript": "^5.3.0", + "typescript-eslint": "^8.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/client/typescript/src/client.ts b/client/typescript/src/client.ts new file mode 100644 index 00000000..e50a99fa --- /dev/null +++ b/client/typescript/src/client.ts @@ -0,0 +1,349 @@ +/** + * MaestroClient — main client class for maestro-runner REST API. + * + * Usage: + * + * ```ts + * const client = new MaestroClient("http://localhost:9999"); + * await client.createSession({ platformName: "android" }); + * try { + * await client.tap({ text: "Login" }); + * await client.inputText("user@example.com"); + * } finally { + * await client.close(); + * } + * ``` + */ + +import * as commands from "./commands"; +import { MaestroError, SessionError, StepError } from "./exceptions"; +import type { ElementSelector} from "./models"; +import { DeviceInfo, ExecutionResult } from "./models"; + +type Step = Record; + +export interface MaestroClientOptions { + baseUrl?: string; + capabilities?: Record; + timeout?: number; +} + +export class MaestroClient { + readonly baseUrl: string; + readonly timeout: number; + private sessionId: string | undefined; + + constructor( + baseUrl: string = "http://localhost:9999", + options?: { capabilities?: Record; timeout?: number }, + ) { + this.baseUrl = baseUrl.replace(/\/+$/, ""); + this.timeout = options?.timeout ?? 60_000; + } + + /** Initialize a session. Must be called before executing steps. */ + async createSession(capabilities: Record): Promise { + const resp = await this.fetch("/session", { + method: "POST", + body: JSON.stringify(capabilities), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new SessionError(`Failed to create session: ${text}`, resp.status); + } + const data = (await resp.json()) as { sessionId: string }; + this.sessionId = data.sessionId; + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + /** Delete the server session. */ + async close(): Promise { + if (!this.sessionId) return; + try { + await this.fetch(`/session/${this.sessionId}`, { method: "DELETE" }); + } catch { + // swallow + } + this.sessionId = undefined; + } + + // --- Low-level --- + + async executeStep(step: Step): Promise { + const sid = this.requireSession(); + const resp = await this.fetch(`/session/${sid}/execute`, { + method: "POST", + body: JSON.stringify(step), + }); + if (!resp.ok) { + const text = await resp.text(); + throw new MaestroError(`Execute failed: ${text}`, resp.status); + } + return ExecutionResult.fromDict((await resp.json()) as Record); + } + + private async exec(step: Step): Promise { + const result = await this.executeStep(step); + if (!result.success && !step.optional) { + throw new StepError(result.message ?? "step failed"); + } + return result; + } + + // --- App lifecycle --- + + async launchApp( + appId: string, + opts?: { clearState?: boolean; stopApp?: boolean; label?: string }, + ): Promise { + return this.exec(commands.launchApp(appId, opts)); + } + + async stopApp(appId: string, label?: string): Promise { + return this.exec(commands.stopApp(appId, label)); + } + + async clearState(appId: string, label?: string): Promise { + return this.exec(commands.clearState(appId, label)); + } + + async openLink(link: string, label?: string): Promise { + return this.exec(commands.openLink(link, label)); + } + + async setPermissions( + appId: string, + permissions: Record, + label?: string, + ): Promise { + return this.exec(commands.setPermissions(appId, permissions, label)); + } + + async resetPermissions(label?: string): Promise { + return this.exec(commands.resetPermissions(label)); + } + + // --- Tap --- + + async tap(opts: { + text?: string; + id?: string; + index?: number; + selector?: ElementSelector; + longPress?: boolean; + waitUntilVisible?: boolean; + retryIfNoChange?: boolean; + enabled?: boolean; + checked?: boolean; + focused?: boolean; + selected?: boolean; + optional?: boolean; + label?: string; + }): Promise { + return this.exec(commands.tapOn(opts)); + } + + async longPress(opts: { + text?: string; + id?: string; + selector?: ElementSelector; + label?: string; + }): Promise { + return this.exec(commands.tapOn({ ...opts, longPress: true })); + } + + async tapOnPoint( + point: string, + opts?: { longPress?: boolean; label?: string }, + ): Promise { + const step: Step = { type: "tapOnPoint", point }; + if (opts?.longPress) step.longPress = true; + if (opts?.label) step.label = opts.label; + return this.exec(step); + } + + // --- Input --- + + async inputText(text: string, label?: string): Promise { + return this.exec(commands.inputText(text, label)); + } + + async eraseText(characters?: number, label?: string): Promise { + return this.exec(commands.eraseText(characters, label)); + } + + async pressKey(code: string, label?: string): Promise { + return this.exec(commands.pressKey(code, label)); + } + + async back(label?: string): Promise { + return this.exec(commands.back(label)); + } + + async hideKeyboard(strategy?: string, label?: string): Promise { + return this.exec(commands.hideKeyboard(strategy, label)); + } + + async waitForAnimationToEnd( + sleepMs?: number, + threshold?: number, + label?: string, + ): Promise { + return this.exec(commands.waitForAnimationToEnd(sleepMs, threshold, label)); + } + + // --- Scroll / Swipe --- + + async scroll(label?: string): Promise { + return this.exec(commands.scroll(label)); + } + + async swipe( + direction: string, + durationMs: number = 400, + label?: string, + ): Promise { + return this.exec(commands.swipe(direction, durationMs, label)); + } + + async swipeOn(opts: { + text?: string; + id?: string; + direction?: string; + durationMs?: number; + label?: string; + }): Promise { + const step: Step = { + type: "swipe", + direction: (opts.direction ?? "UP").toUpperCase(), + duration: opts.durationMs ?? 400, + }; + if (opts.text != null) step.selector = { text: opts.text }; + else if (opts.id != null) step.selector = { id: opts.id }; + if (opts.label) step.label = opts.label; + return this.exec(step); + } + + // --- Assertions --- + + async assertVisible(opts: { + text?: string; + id?: string; + selector?: ElementSelector; + timeoutMs?: number; + label?: string; + }): Promise { + return this.exec(commands.assertVisible(opts)); + } + + async assertNotVisible(opts: { + text?: string; + id?: string; + selector?: ElementSelector; + timeoutMs?: number; + label?: string; + }): Promise { + return this.exec(commands.assertNotVisible(opts)); + } + + async elementExists(opts: { text?: string; id?: string }): Promise { + const step = commands.assertVisible({ ...opts, optional: true }); + const result = await this.executeStep(step); + return result.success; + } + + // --- Self-healing multi-selector tap --- + + async tapFirstMatch( + selectors: Record[], + step: string = "", + ): Promise { + let lastResult: ExecutionResult | undefined; + for (const sel of selectors) { + const tapStep: Step = { type: "tapOn", optional: true, ...sel }; + const result = await this.executeStep(tapStep); + if (result.success) return result; + lastResult = result; + } + if (!lastResult) { + throw new StepError("tapFirstMatch: no selectors provided"); + } + throw new StepError( + `tapFirstMatch: none of ${selectors.length} selectors matched (step=${step})`, + ); + } + + // --- Device queries --- + + async deviceInfo(): Promise { + const sid = this.requireSession(); + const resp = await this.fetch(`/session/${sid}/device-info`); + if (!resp.ok) { + const text = await resp.text(); + throw new MaestroError(`device-info failed: ${text}`, resp.status); + } + return DeviceInfo.fromDict((await resp.json()) as Record); + } + + async screenshot(): Promise { + const sid = this.requireSession(); + const resp = await this.fetch(`/session/${sid}/screenshot`); + if (!resp.ok) { + const text = await resp.text(); + throw new MaestroError(`screenshot failed: ${text}`, resp.status); + } + return resp.arrayBuffer(); + } + + async viewHierarchy(): Promise { + const sid = this.requireSession(); + const resp = await this.fetch(`/session/${sid}/source`); + if (!resp.ok) { + const text = await resp.text(); + throw new MaestroError(`source failed: ${text}`, resp.status); + } + return resp.text(); + } + + // --- WebView (mobile WebView via CDP) --- + + async evalWebViewScript( + script: string, + opts?: { output?: string; label?: string }, + ): Promise { + return this.exec(commands.evalWebViewScript(script, opts)); + } + + async runWebViewScript( + file: string, + opts?: { env?: Record; output?: string; label?: string }, + ): Promise { + return this.exec(commands.runWebViewScript(file, opts)); + } + + // --- Internals --- + + private requireSession(): string { + if (!this.sessionId) { + throw new SessionError( + "No active session. Call createSession() first.", + ); + } + return this.sessionId; + } + + private fetch(path: string, init?: RequestInit): Promise { + const url = `${this.baseUrl}${path}`; + const headers: Record = { + "Content-Type": "application/json", + }; + return fetch(url, { + ...init, + headers: { ...headers, ...(init?.headers as Record) }, + signal: AbortSignal.timeout(this.timeout), + }); + } +} diff --git a/client/typescript/src/commands.ts b/client/typescript/src/commands.ts new file mode 100644 index 00000000..87b3abbd --- /dev/null +++ b/client/typescript/src/commands.ts @@ -0,0 +1,526 @@ +/** Command builders — produce step JSON for the REST API. */ + +import type { ElementSelector } from "./models"; + +type Step = Record; +type SelectorValue = string | Record; + +function selectorValue(opts: { + text?: string; + id?: string; + index?: number; + selector?: ElementSelector; + enabled?: boolean; + checked?: boolean; + focused?: boolean; + selected?: boolean; +}): SelectorValue { + const d: Record = {}; + if (opts.selector) Object.assign(d, opts.selector.toDict()); + if (opts.text != null) d.text = opts.text; + if (opts.id != null) d.id = opts.id; + if (opts.index != null) d.index = String(opts.index); + if (opts.enabled != null) d.enabled = opts.enabled; + if (opts.checked != null) d.checked = opts.checked; + if (opts.focused != null) d.focused = opts.focused; + if (opts.selected != null) d.selected = opts.selected; + // Compact form: text-only selector → plain string + const keys = Object.keys(d); + if (keys.length === 1 && keys[0] === "text") return d.text as string; + return d; +} + +export function tapOn(opts: { + text?: string; + id?: string; + index?: number; + selector?: ElementSelector; + longPress?: boolean; + waitUntilVisible?: boolean; + retryIfNoChange?: boolean; + enabled?: boolean; + checked?: boolean; + focused?: boolean; + selected?: boolean; + optional?: boolean; + timeout?: number; + label?: string; +}): Step { + const step: Step = { type: "tapOn" }; + step.selector = selectorValue(opts); + if (opts.longPress) step.longPress = true; + if (opts.waitUntilVisible != null) step.waitUntilVisible = opts.waitUntilVisible; + if (opts.retryIfNoChange != null) step.retryTapIfNoChange = opts.retryIfNoChange; + if (opts.optional) step.optional = true; + if (opts.timeout != null) step.timeout = opts.timeout; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function inputText(text: string, label?: string): Step { + const step: Step = { type: "inputText", text }; + if (label != null) step.label = label; + return step; +} + +export function eraseText(characters?: number, label?: string): Step { + const step: Step = { type: "eraseText" }; + if (characters != null) step.charactersToErase = characters; + if (label != null) step.label = label; + return step; +} + +export function pressKey(code: string, label?: string): Step { + const step: Step = { type: "pressKey", key: code }; + if (label != null) step.label = label; + return step; +} + +export function back(label?: string): Step { + const step: Step = { type: "back" }; + if (label != null) step.label = label; + return step; +} + +export function scroll(label?: string): Step { + const step: Step = { type: "scroll" }; + if (label != null) step.label = label; + return step; +} + +export function swipe(direction: string, durationMs: number = 400, label?: string): Step { + const step: Step = { type: "swipe", direction: direction.toUpperCase(), duration: durationMs }; + if (label != null) step.label = label; + return step; +} + +export function hideKeyboard(strategy?: string, label?: string): Step { + const step: Step = { type: "hideKeyboard" }; + if (strategy != null) step.strategy = strategy; + if (label != null) step.label = label; + return step; +} + +export function waitForAnimationToEnd( + sleepMs?: number, + threshold?: number, + label?: string, +): Step { + const step: Step = { type: "waitForAnimationToEnd" }; + if (sleepMs != null) step.sleepMs = sleepMs; + if (threshold != null) step.threshold = threshold; + if (label != null) step.label = label; + return step; +} + +export function assertVisible(opts: { + text?: string; + id?: string; + selector?: ElementSelector; + timeoutMs?: number; + optional?: boolean; + label?: string; +}): Step { + const step: Step = { type: "assertVisible" }; + step.selector = selectorValue({ text: opts.text, id: opts.id, selector: opts.selector }); + if (opts.timeoutMs != null) step.timeout = opts.timeoutMs; + if (opts.optional) step.optional = true; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function assertNotVisible(opts: { + text?: string; + id?: string; + selector?: ElementSelector; + timeoutMs?: number; + label?: string; +}): Step { + const step: Step = { type: "assertNotVisible" }; + step.selector = selectorValue({ text: opts.text, id: opts.id, selector: opts.selector }); + if (opts.timeoutMs != null) step.timeout = opts.timeoutMs; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function launchApp( + appId: string, + opts?: { clearState?: boolean; stopApp?: boolean; label?: string }, +): Step { + const step: Step = { type: "launchApp", appId }; + if (opts?.clearState != null) step.clearState = opts.clearState; + if (opts?.stopApp != null) step.stopApp = opts.stopApp; + if (opts?.label != null) step.label = opts.label; + return step; +} + +export function stopApp(appId: string, label?: string): Step { + const step: Step = { type: "stopApp", appId }; + if (label != null) step.label = label; + return step; +} + +export function clearState(appId: string, label?: string): Step { + const step: Step = { type: "clearState", appId }; + if (label != null) step.label = label; + return step; +} + +export function openLink(link: string, label?: string): Step { + const step: Step = { type: "openLink", link }; + if (label != null) step.label = label; + return step; +} + +export function setPermissions( + appId: string, + permissions: Record, + label?: string, +): Step { + const step: Step = { type: "setPermissions", appId, permissions }; + if (label != null) step.label = label; + return step; +} + +export function resetPermissions(label?: string): Step { + const step: Step = { type: "resetPermissions" }; + if (label != null) step.label = label; + return step; +} + +export function evalWebViewScript( + script: string, + opts?: { output?: string; label?: string }, +): Step { + const step: Step = { type: "evalWebViewScript", script }; + if (opts?.output != null) step.output = opts.output; + if (opts?.label != null) step.label = opts.label; + return step; +} + +export function runWebViewScript( + file: string, + opts?: { env?: Record; output?: string; label?: string }, +): Step { + const step: Step = { type: "runWebViewScript", file }; + if (opts?.env != null) step.env = opts.env; + if (opts?.output != null) step.output = opts.output; + if (opts?.label != null) step.label = opts.label; + return step; +} + +// --------------------------------------------------------------------------- +// Gestures +// --------------------------------------------------------------------------- + +function coordOrSelector( + v: string | { text?: string; id?: string; selector?: ElementSelector }, +): SelectorValue { + return typeof v === "string" ? v : selectorValue(v); +} + +export function doubleTapOn(opts: { + text?: string; + id?: string; + selector?: ElementSelector; + optional?: boolean; + retryTapIfNoChange?: boolean; + waitUntilVisible?: boolean; + waitToSettleTimeoutMs?: number; + label?: string; +}): Step { + const step: Step = { type: "doubleTapOn" }; + step.selector = selectorValue(opts); + if (opts.optional) step.optional = true; + if (opts.retryTapIfNoChange != null) step.retryTapIfNoChange = opts.retryTapIfNoChange; + if (opts.waitUntilVisible != null) step.waitUntilVisible = opts.waitUntilVisible; + if (opts.waitToSettleTimeoutMs != null) step.waitToSettleTimeoutMs = opts.waitToSettleTimeoutMs; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function longPressOn(opts: { + text?: string; + id?: string; + selector?: ElementSelector; + durationMs?: number; + optional?: boolean; + retryTapIfNoChange?: boolean; + waitUntilVisible?: boolean; + waitToSettleTimeoutMs?: number; + label?: string; +}): Step { + const step: Step = { type: "longPressOn" }; + step.selector = selectorValue(opts); + if (opts.durationMs != null) step.duration = opts.durationMs; + if (opts.optional) step.optional = true; + if (opts.retryTapIfNoChange != null) step.retryTapIfNoChange = opts.retryTapIfNoChange; + if (opts.waitUntilVisible != null) step.waitUntilVisible = opts.waitUntilVisible; + if (opts.waitToSettleTimeoutMs != null) step.waitToSettleTimeoutMs = opts.waitToSettleTimeoutMs; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function dragAndDrop(opts: { + from: string | { text?: string; id?: string; selector?: ElementSelector }; + to: string | { text?: string; id?: string; selector?: ElementSelector }; + holdDuration?: number; + duration?: number; + label?: string; +}): Step { + const step: Step = { type: "dragAndDrop" }; + step.from = coordOrSelector(opts.from); + step.to = coordOrSelector(opts.to); + if (opts.holdDuration != null) step.holdDuration = opts.holdDuration; + if (opts.duration != null) step.duration = opts.duration; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function scrollUntilVisible(opts: { + element: string | { text?: string; id?: string; selector?: ElementSelector }; + from?: string | { text?: string; id?: string; selector?: ElementSelector }; + direction?: string; + maxScrolls?: number; + speed?: number; + visibilityPercentage?: number; + centerElement?: boolean; + waitToSettleTimeoutMs?: number; + optional?: boolean; + label?: string; +}): Step { + const step: Step = { type: "scrollUntilVisible" }; + step.element = coordOrSelector(opts.element); + if (opts.from != null) step.from = coordOrSelector(opts.from); + if (opts.direction != null) step.direction = opts.direction; + if (opts.maxScrolls != null) step.maxScrolls = opts.maxScrolls; + if (opts.speed != null) step.speed = opts.speed; + if (opts.visibilityPercentage != null) step.visibilityPercentage = opts.visibilityPercentage; + if (opts.centerElement != null) step.centerElement = opts.centerElement; + if (opts.waitToSettleTimeoutMs != null) step.waitToSettleTimeoutMs = opts.waitToSettleTimeoutMs; + if (opts.optional) step.optional = true; + if (opts.label != null) step.label = opts.label; + return step; +} + +// --------------------------------------------------------------------------- +// Assertions & media +// --------------------------------------------------------------------------- + +export function assertScreenshot(opts: { + path?: string; + cropOn?: string | { text?: string; id?: string; selector?: ElementSelector }; + thresholdPercentage?: number; + optional?: boolean; + label?: string; +}): Step { + const step: Step = { type: "assertScreenshot" }; + if (opts.path != null) step.path = opts.path; + if (opts.cropOn != null) { + step.cropOn = typeof opts.cropOn === "string" ? opts.cropOn : selectorValue(opts.cropOn); + } + if (opts.thresholdPercentage != null) step.thresholdPercentage = opts.thresholdPercentage; + if (opts.optional) step.optional = true; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function takeScreenshot(opts: { + path?: string; + cropOn?: string | { text?: string; id?: string; selector?: ElementSelector }; + label?: string; +}): Step { + const step: Step = { type: "takeScreenshot" }; + if (opts.path != null) step.path = opts.path; + if (opts.cropOn != null) { + step.cropOn = typeof opts.cropOn === "string" ? opts.cropOn : selectorValue(opts.cropOn); + } + if (opts.label != null) step.label = opts.label; + return step; +} + +export function copyTextFrom(opts: { + text?: string; + id?: string; + selector?: ElementSelector; + label?: string; +}): Step { + const step: Step = { type: "copyTextFrom" }; + step.selector = selectorValue(opts); + if (opts.label != null) step.label = opts.label; + return step; +} + +export function pasteText(label?: string): Step { + const step: Step = { type: "pasteText" }; + if (label != null) step.label = label; + return step; +} + +export function setClipboard(text: string, label?: string): Step { + const step: Step = { type: "setClipboard", text }; + if (label != null) step.label = label; + return step; +} + +// --------------------------------------------------------------------------- +// AI & scripting +// --------------------------------------------------------------------------- + +export function assertWithAI(assertion: string, label?: string): Step { + const step: Step = { type: "assertWithAI", assertion }; + if (label != null) step.label = label; + return step; +} + +export function evalScript(script: string, label?: string): Step { + const step: Step = { type: "evalScript", script }; + if (label != null) step.label = label; + return step; +} + +export function runScript(opts: { + script?: string; + file?: string; + env?: Record; + label?: string; +}): Step { + const step: Step = { type: "runScript" }; + if (opts.script != null) step.script = opts.script; + if (opts.file != null) step.file = opts.file; + if (opts.env != null) step.env = opts.env; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function evalBrowserScript( + script: string, + opts?: { output?: string; label?: string }, +): Step { + const step: Step = { type: "evalBrowserScript", script }; + if (opts?.output != null) step.output = opts.output; + if (opts?.label != null) step.label = opts.label; + return step; +} + +// --------------------------------------------------------------------------- +// Device control +// --------------------------------------------------------------------------- + +export function setLocation(latitude: string, longitude: string, label?: string): Step { + const step: Step = { type: "setLocation", latitude, longitude }; + if (label != null) step.label = label; + return step; +} + +export function setAirplaneMode(enabled: boolean, label?: string): Step { + const step: Step = { type: "setAirplaneMode", enabled }; + if (label != null) step.label = label; + return step; +} + +export function toggleAirplaneMode(label?: string): Step { + const step: Step = { type: "toggleAirplaneMode" }; + if (label != null) step.label = label; + return step; +} + +export function setNetworkConditions(opts: { + offline?: boolean; + latency?: number; + downloadSpeed?: number; + uploadSpeed?: number; + label?: string; +}): Step { + const step: Step = { type: "setNetworkConditions" }; + if (opts.offline != null) step.offline = opts.offline; + if (opts.latency != null) step.latency = opts.latency; + if (opts.downloadSpeed != null) step.downloadSpeed = opts.downloadSpeed; + if (opts.uploadSpeed != null) step.uploadSpeed = opts.uploadSpeed; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function openNotifications(label?: string): Step { + const step: Step = { type: "openNotifications" }; + if (label != null) step.label = label; + return step; +} + +export function setDarkMode(enabled: boolean, label?: string): Step { + const step: Step = { type: "setDarkMode", enabled }; + if (label != null) step.label = label; + return step; +} + +export function setOrientation(orientation: string, label?: string): Step { + const step: Step = { type: "setOrientation", orientation }; + if (label != null) step.label = label; + return step; +} + +// --------------------------------------------------------------------------- +// Browser (web platform) +// --------------------------------------------------------------------------- + +export function openBrowser(url?: string, label?: string): Step { + const step: Step = { type: "openBrowser" }; + if (url != null) step.url = url; + if (label != null) step.label = label; + return step; +} + +export function switchTab(opts: { + tabLabel?: string; + index?: number; + url?: string; + label?: string; +}): Step { + const step: Step = { type: "switchTab" }; + if (opts.tabLabel != null) step.tabLabel = opts.tabLabel; + if (opts.index != null) step.index = opts.index; + if (opts.url != null) step.url = opts.url; + if (opts.label != null) step.label = opts.label; + return step; +} + +export function closeTab(label?: string): Step { + const step: Step = { type: "closeTab" }; + if (label != null) step.label = label; + return step; +} + +export function getConsoleLogs(output: string, label?: string): Step { + const step: Step = { type: "getConsoleLogs", output }; + if (label != null) step.label = label; + return step; +} + +export function clearConsoleLogs(label?: string): Step { + const step: Step = { type: "clearConsoleLogs" }; + if (label != null) step.label = label; + return step; +} + +export function assertNoJSErrors(label?: string): Step { + const step: Step = { type: "assertNoJSErrors" }; + if (label != null) step.label = label; + return step; +} + +export function mockNetwork(opts: { + url: string; + method?: string; + response: { status?: number; headers?: Record; body?: string }; + label?: string; +}): Step { + const step: Step = { type: "mockNetwork", url: opts.url }; + if (opts.method != null) step.method = opts.method; + const response: Record = {}; + if (opts.response.status != null) response.status = opts.response.status; + if (opts.response.headers != null) response.headers = opts.response.headers; + if (opts.response.body != null) response.body = opts.response.body; + step.response = response; + if (opts.label != null) step.label = opts.label; + return step; +} diff --git a/client/typescript/src/exceptions.ts b/client/typescript/src/exceptions.ts new file mode 100644 index 00000000..c40eae26 --- /dev/null +++ b/client/typescript/src/exceptions.ts @@ -0,0 +1,25 @@ +/** Custom exceptions for maestro-runner client. */ + +export class MaestroError extends Error { + public readonly statusCode?: number; + + constructor(message: string, statusCode?: number) { + super(message); + this.name = "MaestroError"; + this.statusCode = statusCode; + } +} + +export class SessionError extends MaestroError { + constructor(message: string, statusCode?: number) { + super(message, statusCode); + this.name = "SessionError"; + } +} + +export class StepError extends MaestroError { + constructor(message: string, statusCode?: number) { + super(message, statusCode); + this.name = "StepError"; + } +} diff --git a/client/typescript/src/index.ts b/client/typescript/src/index.ts new file mode 100644 index 00000000..a214b8b8 --- /dev/null +++ b/client/typescript/src/index.ts @@ -0,0 +1,12 @@ +/** maestro-runner — TypeScript client for maestro-runner REST API. */ + +export { MaestroClient } from "./client"; +export type { MaestroClientOptions } from "./client"; +export { MaestroError, SessionError, StepError } from "./exceptions"; +export { + DeviceInfo, + ElementInfo, + ElementSelector, + ExecutionResult, +} from "./models"; +export type { ElementSelectorInit } from "./models"; diff --git a/client/typescript/src/models.ts b/client/typescript/src/models.ts new file mode 100644 index 00000000..110f4566 --- /dev/null +++ b/client/typescript/src/models.ts @@ -0,0 +1,176 @@ +/** Data models mapping Go server JSON responses to TypeScript types. */ + +// ---------- Element Selector ---------- + +export interface ElementSelectorInit { + text?: string; + id?: string; + index?: number; + enabled?: boolean; + checked?: boolean; + focused?: boolean; + selected?: boolean; + css?: string; + traits?: string; + childOf?: ElementSelectorInit; + below?: ElementSelectorInit; + above?: ElementSelectorInit; + leftOf?: ElementSelectorInit; + rightOf?: ElementSelectorInit; + containsChild?: ElementSelectorInit; + insideOf?: ElementSelectorInit; +} + +export class ElementSelector { + text?: string; + id?: string; + index?: number; + enabled?: boolean; + checked?: boolean; + focused?: boolean; + selected?: boolean; + css?: string; + traits?: string; + childOf?: ElementSelector; + below?: ElementSelector; + above?: ElementSelector; + leftOf?: ElementSelector; + rightOf?: ElementSelector; + containsChild?: ElementSelector; + insideOf?: ElementSelector; + + constructor(init: ElementSelectorInit = {}) { + Object.assign(this, init); + if (init.childOf) this.childOf = new ElementSelector(init.childOf); + if (init.below) this.below = new ElementSelector(init.below); + if (init.above) this.above = new ElementSelector(init.above); + if (init.leftOf) this.leftOf = new ElementSelector(init.leftOf); + if (init.rightOf) this.rightOf = new ElementSelector(init.rightOf); + if (init.containsChild) + {this.containsChild = new ElementSelector(init.containsChild);} + if (init.insideOf) this.insideOf = new ElementSelector(init.insideOf); + } + + toDict(): Record { + const d: Record = {}; + if (this.text != null) d.text = this.text; + if (this.id != null) d.id = this.id; + if (this.index != null) d.index = String(this.index); + if (this.enabled != null) d.enabled = this.enabled; + if (this.checked != null) d.checked = this.checked; + if (this.focused != null) d.focused = this.focused; + if (this.selected != null) d.selected = this.selected; + if (this.css != null) d.css = this.css; + if (this.traits != null) d.traits = this.traits; + if (this.childOf) d.childOf = this.childOf.toDict(); + if (this.below) d.below = this.below.toDict(); + if (this.above) d.above = this.above.toDict(); + if (this.leftOf) d.leftOf = this.leftOf.toDict(); + if (this.rightOf) d.rightOf = this.rightOf.toDict(); + if (this.containsChild) d.containsChild = this.containsChild.toDict(); + if (this.insideOf) d.insideOf = this.insideOf.toDict(); + return d; + } +} + +// ---------- Element Info ---------- + +export interface ElementInfoData { + id?: string; + text?: string; + bounds?: Record; + visible?: boolean; + enabled?: boolean; + focused?: boolean; + checked?: boolean; + selected?: boolean; +} + +export class ElementInfo { + id: string; + text: string; + bounds: Record; + visible: boolean; + enabled: boolean; + focused: boolean; + checked: boolean; + selected: boolean; + + constructor(data: ElementInfoData = {}) { + this.id = data.id ?? ""; + this.text = data.text ?? ""; + this.bounds = data.bounds ?? {}; + this.visible = data.visible ?? false; + this.enabled = data.enabled ?? false; + this.focused = data.focused ?? false; + this.checked = data.checked ?? false; + this.selected = data.selected ?? false; + } + + static fromDict(data?: Record | null): ElementInfo | undefined { + if (!data) return undefined; + return new ElementInfo(data as ElementInfoData); + } +} + +// ---------- Execution Result ---------- + +export class ExecutionResult { + success: boolean; + message?: string; + durationNs: number; + element?: ElementInfo; + data?: unknown; + + constructor( + success: boolean, + message?: string, + durationNs: number = 0, + element?: ElementInfo, + data?: unknown, + ) { + this.success = success; + this.message = message; + this.durationNs = durationNs; + this.element = element; + this.data = data; + } + + static fromDict(data: Record): ExecutionResult { + return new ExecutionResult( + (data.success as boolean) ?? false, + data.message as string | undefined, + (data.duration as number) ?? 0, + ElementInfo.fromDict(data.element as Record | undefined), + data.data, + ); + } +} + +// ---------- Device Info ---------- + +export class DeviceInfo { + platform: string; + osVersion: string; + deviceName: string; + deviceId: string; + isSimulator: boolean; + screenWidth: number; + screenHeight: number; + appId: string; + + constructor(data: Record = {}) { + this.platform = (data.platform as string) ?? ""; + this.osVersion = (data.osVersion as string) ?? ""; + this.deviceName = (data.deviceName as string) ?? ""; + this.deviceId = (data.deviceId as string) ?? ""; + this.isSimulator = (data.isSimulator as boolean) ?? false; + this.screenWidth = (data.screenWidth as number) ?? 0; + this.screenHeight = (data.screenHeight as number) ?? 0; + this.appId = (data.appId as string) ?? ""; + } + + static fromDict(data: Record): DeviceInfo { + return new DeviceInfo(data); + } +} diff --git a/client/typescript/tests/pages/BasePage.ts b/client/typescript/tests/pages/BasePage.ts new file mode 100644 index 00000000..75cc2983 --- /dev/null +++ b/client/typescript/tests/pages/BasePage.ts @@ -0,0 +1,15 @@ +/** Page Object Model — base page with common helpers. */ + +import { MaestroClient, ExecutionResult } from "../../src"; + +export abstract class BasePage { + constructor(protected readonly client: MaestroClient) {} + + async waitForAnimation(sleepMs?: number, threshold?: number): Promise { + return this.client.waitForAnimationToEnd(sleepMs, threshold); + } + + async hideKeyboard(strategy?: string): Promise { + return this.client.hideKeyboard(strategy); + } +} diff --git a/client/typescript/tests/pages/ContactListPage.ts b/client/typescript/tests/pages/ContactListPage.ts new file mode 100644 index 00000000..fa078e1d --- /dev/null +++ b/client/typescript/tests/pages/ContactListPage.ts @@ -0,0 +1,29 @@ +/** Page Object — Contacts app: contact list screen. */ + +import { MaestroClient, ExecutionResult } from "../../src"; +import { BasePage } from "./BasePage"; +import { EditContactPage } from "./EditContactPage"; + +export class ContactListPage extends BasePage { + static readonly APP_ID = "com.google.android.contacts"; + + constructor(client: MaestroClient) { + super(client); + } + + async launch(clearState: boolean = true): Promise { + const result = await this.client.launchApp(ContactListPage.APP_ID, { clearState }); + await this.waitForAnimation(); + return result; + } + + async openCreateContact(): Promise { + await this.client.tap({ text: "Create contact|Add contact|New contact" }); + await this.waitForAnimation(); + return new EditContactPage(this.client); + } + + async assertContactVisible(name: string): Promise { + return this.client.assertVisible({ text: name }); + } +} diff --git a/client/typescript/tests/pages/EditContactPage.ts b/client/typescript/tests/pages/EditContactPage.ts new file mode 100644 index 00000000..9e53d526 --- /dev/null +++ b/client/typescript/tests/pages/EditContactPage.ts @@ -0,0 +1,31 @@ +/** Page Object — Contacts app: create / edit contact form. */ + +import { ExecutionResult } from "../../src"; +import { BasePage } from "./BasePage"; + +export class EditContactPage extends BasePage { + async setFirstName(name: string): Promise { + await this.client.tap({ text: "First name" }); + await this.client.inputText(name); + await this.hideKeyboard(); + } + + async setLastName(name: string): Promise { + await this.client.tap({ text: "Last name" }); + await this.client.inputText(name); + await this.hideKeyboard("escape"); + } + + async setPhone(number: string): Promise { + await this.waitForAnimation(); + await this.client.tap({ text: "Phone (Mobile)|Add phone" }); + await this.client.inputText(number); + await this.hideKeyboard("back"); + } + + async save(): Promise { + const result = await this.client.tap({ text: "Save" }); + await this.waitForAnimation(); + return result; + } +} diff --git a/client/typescript/tests/pages/IOSContactListPage.ts b/client/typescript/tests/pages/IOSContactListPage.ts new file mode 100644 index 00000000..ee4ccef3 --- /dev/null +++ b/client/typescript/tests/pages/IOSContactListPage.ts @@ -0,0 +1,29 @@ +/** Page Object — iOS Contacts app: contact list screen. */ + +import { MaestroClient, ExecutionResult } from "../../src"; +import { BasePage } from "./BasePage"; +import { IOSEditContactPage } from "./IOSEditContactPage"; + +export class IOSContactListPage extends BasePage { + static readonly APP_ID = "com.apple.MobileAddressBook"; + + constructor(client: MaestroClient) { + super(client); + } + + async launch(): Promise { + const result = await this.client.launchApp(IOSContactListPage.APP_ID, { stopApp: true }); + await this.waitForAnimation(); + return result; + } + + async openCreateContact(): Promise { + await this.client.tap({ text: "Add" }); + await this.waitForAnimation(); + return new IOSEditContactPage(this.client); + } + + async assertContactVisible(name: string): Promise { + return this.client.assertVisible({ text: name }); + } +} \ No newline at end of file diff --git a/client/typescript/tests/pages/IOSEditContactPage.ts b/client/typescript/tests/pages/IOSEditContactPage.ts new file mode 100644 index 00000000..60315002 --- /dev/null +++ b/client/typescript/tests/pages/IOSEditContactPage.ts @@ -0,0 +1,39 @@ +/** Page Object — iOS Contacts app: create / edit contact form. */ + +import { MaestroClient, ExecutionResult } from "../../src"; +import { BasePage } from "./BasePage"; + +export class IOSEditContactPage extends BasePage { + constructor(client: MaestroClient) { + super(client); + } + + async setFirstName(name: string): Promise { + await this.client.tap({ text: "First name" }); + await this.client.inputText(name); + } + + async setLastName(name: string): Promise { + await this.client.tap({ text: "Last name" }); + await this.client.inputText(name); + } + + async setPhone(number: string): Promise { + await this.waitForAnimation(); + await this.client.executeStep({ + type: "swipe", + start: "50%, 42%", + end: "50%, 12%", + duration: 700, + }); + await this.client.tap({ text: "add phone" }); + await this.client.tap({ text: "phone" }); + await this.client.inputText(number); + } + + async save(): Promise { + const result = await this.client.tap({ text: "Done" }); + await this.waitForAnimation(); + return result; + } +} \ No newline at end of file diff --git a/client/typescript/tests/setup.ts b/client/typescript/tests/setup.ts new file mode 100644 index 00000000..eda65f83 --- /dev/null +++ b/client/typescript/tests/setup.ts @@ -0,0 +1,327 @@ +/** + * Shared test setup — auto-start maestro-runner server when needed. + * + * Equivalent of Python conftest.py. + * + * Supports Jest parallel execution: each worker gets its own server + * on a unique port, targeting a specific device via JEST_WORKER_ID. + * + * Env vars: + * MAESTRO_SERVER_URL (default: http://localhost:9999) + * MAESTRO_PLATFORM (default: android) + * MAESTRO_RUNNER_BIN (path to binary, auto-detected by default) + */ + +import { ChildProcess, execSync, spawn } from "child_process"; +import * as path from "path"; +import * as fs from "fs"; +import { MaestroClient } from "../src"; + +const BASE_SERVER_URL = process.env.MAESTRO_SERVER_URL ?? "http://localhost:9999"; +const PLATFORM = process.env.MAESTRO_PLATFORM ?? "android"; +const EXPLICIT_DEVICE_ID = process.env.MAESTRO_DEVICE_ID; +const BASE_PORT = parseInt(new URL(BASE_SERVER_URL).port || "9999", 10); + +// Jest assigns JEST_WORKER_ID starting at 1 for each parallel worker +const WORKER_ID = parseInt(process.env.JEST_WORKER_ID ?? "1", 10); +const WORKER_NAME = `jw${Math.max(WORKER_ID - 1, 0)}`; +const SERVER_PORT = BASE_PORT + WORKER_ID - 1; +const SERVER_URL = `http://localhost:${SERVER_PORT}`; + +const DEFAULT_BIN = path.resolve(__dirname, "..", "..", "..", "maestro-runner"); +const MAESTRO_RUNNER_BIN = process.env.MAESTRO_RUNNER_BIN ?? DEFAULT_BIN; +const REPORTS_DIR = path.resolve(__dirname, "..", "reports"); + +let runId = ""; +let serverLogPath = ""; +let serverLogStream: fs.WriteStream | undefined; +let runLogPath = ""; +let runLogStream: fs.WriteStream | undefined; + +function utcTimestamp(): string { + const date = new Date(); + const pad = (n: number): string => String(n).padStart(2, "0"); + return [ + date.getUTCFullYear(), + pad(date.getUTCMonth() + 1), + pad(date.getUTCDate()), + ].join("") + + "-" + + [pad(date.getUTCHours()), pad(date.getUTCMinutes()), pad(date.getUTCSeconds())].join(""); +} + +function persistLatestServerMetadata(mode: "spawned" | "reused-existing-server"): void { + fs.mkdirSync(REPORTS_DIR, { recursive: true }); + const latestPath = path.join(REPORTS_DIR, "server-latest.json"); + let payload: { updatedAt: string; workers: Record> } = { + updatedAt: new Date().toISOString(), + workers: {}, + }; + + if (fs.existsSync(latestPath)) { + try { + payload = JSON.parse(fs.readFileSync(latestPath, "utf-8")); + } catch { + payload = { + updatedAt: new Date().toISOString(), + workers: {}, + }; + } + } + + payload.workers[WORKER_NAME] = { + workerId: WORKER_NAME, + runId, + mode, + serverUrl: SERVER_URL, + serverPort: String(SERVER_PORT), + serverLogPath, + ...(assignedDevice ? { deviceId: assignedDevice } : {}), + startedAt: new Date().toISOString(), + }; + payload.updatedAt = new Date().toISOString(); + fs.writeFileSync(latestPath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8"); +} + +function currentNodeId(): string { + const maybeExpect = (globalThis as { expect?: { getState?: () => { currentTestName?: string } } }).expect; + const name = maybeExpect?.getState?.().currentTestName; + return name && name.trim().length > 0 ? name : "-"; +} + +function appendRunLog(level: "INFO" | "DEBUG" | "WARN", message: string): void { + if (!runLogStream) { + return; + } + const ts = new Date().toISOString(); + runLogStream.write( + `${ts} [${level}] [worker=${WORKER_NAME}] [node=${currentNodeId()}] ${message}\n`, + ); +} + +function tailFile(filePath: string, maxLines = 120): string { + if (!fs.existsSync(filePath)) { + return ""; + } + const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/); + return lines.slice(Math.max(0, lines.length - maxLines)).join("\n"); +} + +function writeArtifactSummary(status: "passed" | "failed"): void { + if (!runId) { + return; + } + + const artifacts: Array<{ name: string; path: string; sizeBytes: number }> = []; + const known = [ + path.join(REPORTS_DIR, "report.html"), + path.join(REPORTS_DIR, "junit-report.xml"), + serverLogPath, + runLogPath, + ]; + + for (const artifactPath of known) { + if (!artifactPath || !fs.existsSync(artifactPath)) { + continue; + } + const stats = fs.statSync(artifactPath); + artifacts.push({ + name: path.basename(artifactPath), + path: artifactPath, + sizeBytes: stats.size, + }); + } + + const summary: Record = { + runId, + workerId: WORKER_NAME, + platform: PLATFORM, + serverUrl: SERVER_URL, + serverPort: String(SERVER_PORT), + sessionStatus: status, + generatedAt: new Date().toISOString(), + artifacts, + tails: { + server: tailFile(serverLogPath), + jest: tailFile(runLogPath), + }, + }; + + const summaryPath = path.join(REPORTS_DIR, `artifact-summary-${runId}.json`); + fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf-8"); +} + +async function serverIsReady(url: string): Promise { + try { + const resp = await fetch(`${url}/status`, { + signal: AbortSignal.timeout(2000), + }); + return resp.ok; + } catch { + return false; + } +} + +/** Sleeps for `ms` milliseconds. */ +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Discover connected Android device serials via adb. */ +function discoverDevices(): string[] { + try { + const out = execSync("adb devices", { encoding: "utf-8" }); + return out + .split("\n") + .slice(1) + .filter((line) => line.match(/^\S+\s+device$/)) + .map((line) => line.split("\t")[0]); + } catch { + return []; + } +} + +let serverProcess: ChildProcess | undefined; +let sharedClient: MaestroClient | undefined; +let assignedDevice: string | undefined; + +function serverCommand(port: number, deviceId?: string): string[] { + const command = ["--platform", PLATFORM]; + if (deviceId) { + command.push("--device", deviceId); + } + command.push("server", "--port", String(port)); + return command; +} + +/** + * Ensure a maestro-runner server is available. Starts one if needed. + * Returns the server URL. + */ +export async function ensureServer(): Promise { + runId = `${utcTimestamp()}-${WORKER_NAME}-${process.pid}`; + fs.mkdirSync(REPORTS_DIR, { recursive: true }); + serverLogPath = path.join(REPORTS_DIR, `server-run-${utcTimestamp()}-${WORKER_NAME}.log`); + runLogPath = path.join(REPORTS_DIR, `jest-run-${runId}.log`); + if (!runLogStream) { + runLogStream = fs.createWriteStream(runLogPath, { flags: "a", encoding: "utf-8" }); + } + appendRunLog("INFO", `run initialized runId=${runId} platform=${PLATFORM}`); + + if (await serverIsReady(SERVER_URL)) { + fs.writeFileSync( + serverLogPath, + `runId=${runId} workerId=${WORKER_NAME} mode=reused-existing-server\n`, + "utf-8", + ); + persistLatestServerMetadata("reused-existing-server"); + appendRunLog("INFO", "reusing existing maestro-runner server"); + return SERVER_URL; + } + + const binary = MAESTRO_RUNNER_BIN; + if (!fs.existsSync(binary)) { + throw new Error( + `maestro-runner binary not found at ${binary}. ` + + "Set MAESTRO_RUNNER_BIN or add it to PATH.", + ); + } + + // Discover devices and assign one to this worker + if (EXPLICIT_DEVICE_ID) { + assignedDevice = EXPLICIT_DEVICE_ID; + } else if (PLATFORM === "android") { + const devices = discoverDevices(); + const idx = WORKER_ID - 1; + if (idx < devices.length) { + assignedDevice = devices[idx]; + } + } + + serverProcess = spawn( + binary, + serverCommand(SERVER_PORT, assignedDevice), + { + stdio: "pipe", + env: { + ...process.env, + MAESTRO_WORKER_ID: WORKER_NAME, + ...(assignedDevice && PLATFORM === "android" ? { ANDROID_SERIAL: assignedDevice } : {}), + }, + }, + ); + + serverLogStream = fs.createWriteStream(serverLogPath, { flags: "a", encoding: "utf-8" }); + serverLogStream.write( + `runId=${runId} workerId=${WORKER_NAME} platform=${PLATFORM}` + + `${assignedDevice ? ` deviceId=${assignedDevice}` : ""}\n`, + ); + serverProcess.stdout?.pipe(serverLogStream); + serverProcess.stderr?.pipe(serverLogStream); + persistLatestServerMetadata("spawned"); + appendRunLog("INFO", `spawned maestro-runner server on ${SERVER_URL}`); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + if (serverProcess.exitCode != null) { + throw new Error( + `maestro-runner exited early (code ${serverProcess.exitCode})`, + ); + } + if (await serverIsReady(SERVER_URL)) return SERVER_URL; + await sleep(500); + } + + serverProcess.kill(); + throw new Error("maestro-runner server did not become ready within 30 s"); +} + +/** Get a shared MaestroClient, creating session on first call. */ +export async function getClient(): Promise { + if (sharedClient) return sharedClient; + + const url = await ensureServer(); + const client = new MaestroClient(url); + const caps: Record = { platformName: PLATFORM }; + if (assignedDevice) { + caps.deviceId = assignedDevice; + } + await client.createSession(caps); + appendRunLog("INFO", `client session created for ${SERVER_URL}`); + sharedClient = client; + return client; +} + +/** Tear down the shared client and server process. */ +export async function teardown(): Promise { + let failed = false; + if (sharedClient) { + try { + await sharedClient.close(); + } catch { + failed = true; + appendRunLog("WARN", "client close failed during teardown"); + } + sharedClient = undefined; + } + if (serverProcess) { + // Detach the child's output pipes *before* closing the log stream so a + // final flush as the process exits can't write to an already-ended stream + // (which throws "write after end" and crashes the test runner). + serverProcess.stdout?.unpipe(serverLogStream); + serverProcess.stderr?.unpipe(serverLogStream); + serverProcess.kill(); + serverProcess = undefined; + } + if (serverLogStream) { + serverLogStream.write(`terminated runId=${runId} workerId=${WORKER_NAME}\n`); + serverLogStream.end(); + serverLogStream = undefined; + } + appendRunLog("INFO", "teardown completed"); + writeArtifactSummary(failed ? "failed" : "passed"); + if (runLogStream) { + runLogStream.end(); + runLogStream = undefined; + } +} diff --git a/client/typescript/tests/test_add_contact.device.test.ts b/client/typescript/tests/test_add_contact.device.test.ts new file mode 100644 index 00000000..a83816dd --- /dev/null +++ b/client/typescript/tests/test_add_contact.device.test.ts @@ -0,0 +1,58 @@ +/** + * POM-based test — Add a new contact. + * + * Equivalent of: client/python/tests/test_add_contact.py + * Also equivalent of: e2e/workspaces/contacts/add_contact_android.yaml + * + * Prerequisites: + * 1. Android emulator running (adb devices shows device) + * 2. Node deps installed (from client/typescript): + * npm install + * 3. (Optional) Start maestro-runner server manually: + * ./maestro-runner --platform android server --port 9999 + * If not running, the server is auto-started by the test setup. + * + * Override with env vars: + * MAESTRO_SERVER_URL (default: http://localhost:9999) + * MAESTRO_PLATFORM (default: android) + * MAESTRO_RUNNER_BIN (path to binary, auto-detected by default) + * + * Run: + * npx jest tests/test_add_contact.device.test.ts + */ + +import { afterAll, describe, it } from "@jest/globals"; + +import { getClient, teardown } from "./setup"; +import { ContactListPage } from "./pages/ContactListPage"; + +afterAll(async () => { + await teardown(); +}); + +describe("AddContact", () => { + /** Mirrors add_contact_android.yaml: launch → create → fill → save → verify. */ + it("should add and verify a contact", async () => { + const client = await getClient(); + const contactList = new ContactListPage(client); + + // Launch with a clean slate + await contactList.launch(true); + + // Open the create-contact form + const editPage = await contactList.openCreateContact(); + + // Fill in name fields + await editPage.setFirstName("Alice"); + await editPage.setLastName("Tester"); + + // Fill in phone number + await editPage.setPhone("5550100"); + + // Save + await editPage.save(); + + // Verify the contact now appears in the list + await contactList.assertContactVisible("Alice Tester"); + }); +}); diff --git a/client/typescript/tests/test_add_contact_ios.device.test.ts b/client/typescript/tests/test_add_contact_ios.device.test.ts new file mode 100644 index 00000000..b35c3742 --- /dev/null +++ b/client/typescript/tests/test_add_contact_ios.device.test.ts @@ -0,0 +1,48 @@ +/** + * POM-based test — Add a new contact on iOS. + * + * Equivalent of: e2e/workspaces/contacts/add_contact_ios.yaml + * + * Prerequisites: + * 1. iOS simulator running + * 2. Node deps installed (from client/typescript): + * npm install + * 3. (Optional) Start maestro-runner server manually: + * ./maestro-runner --platform ios --device server --port 9999 + * If not running, the server is auto-started by the test setup. + * + * Override with env vars: + * MAESTRO_SERVER_URL (default: http://localhost:9999) + * MAESTRO_PLATFORM (set to: ios) + * MAESTRO_DEVICE_ID (recommended for explicit simulator targeting) + * MAESTRO_RUNNER_BIN (path to binary, auto-detected by default) + * + * Run: + * MAESTRO_PLATFORM=ios npx jest tests/test_add_contact_ios.device.test.ts --runInBand + */ + +import { afterAll, describe, it } from "@jest/globals"; + +import { getClient, teardown } from "./setup"; +import { IOSContactListPage } from "./pages/IOSContactListPage"; + +afterAll(async () => { + await teardown(); +}); + +describe("AddContactIOS", () => { + it("should add and verify a contact on iOS", async () => { + const client = await getClient(); + const contactList = new IOSContactListPage(client); + + await contactList.launch(); + + const editPage = await contactList.openCreateContact(); + await editPage.setFirstName("Alice"); + await editPage.setLastName("Tester"); + await editPage.setPhone("5550100"); + await editPage.save(); + + await contactList.assertContactVisible("Alice Tester"); + }); +}); \ No newline at end of file diff --git a/client/typescript/tests/test_client.unit.test.ts b/client/typescript/tests/test_client.unit.test.ts new file mode 100644 index 00000000..f576eeba --- /dev/null +++ b/client/typescript/tests/test_client.unit.test.ts @@ -0,0 +1,255 @@ +import { afterAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; + +import { MaestroClient } from "../src/client"; +import { MaestroError, SessionError, StepError } from "../src/exceptions"; +import { ElementSelector } from "../src/models"; + +const BASE = "http://localhost:9999"; +const SID = "test-session-123"; + +function jsonResponse(status: number, payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function textResponse(status: number, text: string): Response { + return new Response(text, { status }); +} + +describe("MaestroClient (unit)", () => { + const originalFetch = global.fetch; + let fetchMock: jest.MockedFunction; + + beforeEach(() => { + fetchMock = jest.fn() as jest.MockedFunction; + global.fetch = fetchMock; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it("creates a session", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + + expect(client.getSessionId()).toBe(SID); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toBe(`${BASE}/session`); + expect(init?.method).toBe("POST"); + expect(JSON.parse(init?.body as string)).toEqual({ platformName: "android" }); + }); + + it("raises SessionError when createSession fails", async () => { + fetchMock.mockResolvedValueOnce(textResponse(500, "Internal error")); + + const client = new MaestroClient(BASE); + await expect(client.createSession({ platformName: "android" })).rejects.toBeInstanceOf( + SessionError, + ); + }); + + it("close is no-op without session", async () => { + const client = new MaestroClient(BASE); + await client.close(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("close deletes active session", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(textResponse(200, "")); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + await client.close(); + + expect(client.getSessionId()).toBeUndefined(); + expect(String(fetchMock.mock.calls[1][0])).toBe(`${BASE}/session/${SID}`); + expect(fetchMock.mock.calls[1][1]?.method).toBe("DELETE"); + }); + + it("requires an active session for executeStep", async () => { + const client = new MaestroClient(BASE); + await expect(client.executeStep({ type: "back" })).rejects.toBeInstanceOf(SessionError); + }); + + it("raises MaestroError on executeStep http error", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(textResponse(500, "server error")); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + + await expect(client.executeStep({ type: "back" })).rejects.toBeInstanceOf(MaestroError); + }); + + it("tap by text uses compact selector", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(jsonResponse(200, { success: true })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + await client.tap({ text: "Login" }); + + const body = JSON.parse(fetchMock.mock.calls[1][1]?.body as string); + expect(body.type).toBe("tapOn"); + expect(body.selector).toBe("Login"); + }); + + it("tap with selector object serializes selector fields", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(jsonResponse(200, { success: true })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + await client.tap({ selector: new ElementSelector({ text: "Item", childOf: { id: "list" } }) }); + + const body = JSON.parse(fetchMock.mock.calls[1][1]?.body as string); + expect(body.selector).toEqual({ text: "Item", childOf: { id: "list" } }); + }); + + it("raises StepError on non-optional step failure", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(jsonResponse(200, { success: false, message: "not found" })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + + await expect(client.tap({ text: "Missing" })).rejects.toBeInstanceOf(StepError); + }); + + it("does not raise StepError for optional failure", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(jsonResponse(200, { success: false, message: "not found" })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + const result = await client.tap({ text: "Maybe", optional: true }); + + expect(result.success).toBe(false); + }); + + it("elementExists returns true or false based on optional assert", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(jsonResponse(200, { success: true })) + .mockResolvedValueOnce(jsonResponse(200, { success: false })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + + await expect(client.elementExists({ text: "Visible" })).resolves.toBe(true); + await expect(client.elementExists({ text: "Missing" })).resolves.toBe(false); + }); + + it("tapFirstMatch returns on first successful selector", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(jsonResponse(200, { success: false })) + .mockResolvedValueOnce(jsonResponse(200, { success: true, message: "ok" })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + + const result = await client.tapFirstMatch([ + { selector: { text: "missing" } }, + { selector: { text: "present" } }, + ]); + + expect(result.success).toBe(true); + }); + + it("tapFirstMatch fails when selectors are empty", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + + await expect(client.tapFirstMatch([])).rejects.toBeInstanceOf(StepError); + }); + + it("fetches device info", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce( + jsonResponse(200, { + platform: "android", + osVersion: "14", + deviceName: "Pixel 6", + }), + ); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + const info = await client.deviceInfo(); + + expect(info.platform).toBe("android"); + expect(info.osVersion).toBe("14"); + expect(info.deviceName).toBe("Pixel 6"); + }); + + it("fetches screenshot bytes", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(new Response(new Uint8Array([1, 2, 3]), { status: 200 })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + const bytes = await client.screenshot(); + + expect(bytes.byteLength).toBe(3); + }); + + it("fetches view hierarchy", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(textResponse(200, "")); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + const source = await client.viewHierarchy(); + + expect(source).toContain("hierarchy"); + }); + + it("waitForAnimationToEnd sends correct step type", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(jsonResponse(200, { success: true })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + await client.waitForAnimationToEnd(); + + const body = JSON.parse(fetchMock.mock.calls[1][1]?.body as string); + expect(body.type).toBe("waitForAnimationToEnd"); + expect(body.sleepMs).toBeUndefined(); + expect(body.threshold).toBeUndefined(); + }); + + it("waitForAnimationToEnd forwards sleepMs and threshold", async () => { + fetchMock + .mockResolvedValueOnce(jsonResponse(200, { sessionId: SID })) + .mockResolvedValueOnce(jsonResponse(200, { success: true })); + + const client = new MaestroClient(BASE); + await client.createSession({ platformName: "android" }); + await client.waitForAnimationToEnd(500, 0.001); + + const body = JSON.parse(fetchMock.mock.calls[1][1]?.body as string); + expect(body.type).toBe("waitForAnimationToEnd"); + expect(body.sleepMs).toBe(500); + expect(body.threshold).toBe(0.001); + }); +}); diff --git a/client/typescript/tests/test_contact_persists.device.test.ts b/client/typescript/tests/test_contact_persists.device.test.ts new file mode 100644 index 00000000..0e46b1ee --- /dev/null +++ b/client/typescript/tests/test_contact_persists.device.test.ts @@ -0,0 +1,58 @@ +/** + * POM-based test — Contact persists after relaunch. + * + * Equivalent of: e2e/workspaces/contacts/contact_persists.yaml + * Also equivalent of: client/python/tests/test_contact_persists.py + * + * This test creates a contact using the add_contact flow as setup, + * then cold-relaunches the app and verifies the contact is still visible. + * + * Prerequisites: + * 1. Android emulator running (adb devices shows device) + * 2. Node deps installed (from client/typescript): + * npm install + * 3. (Optional) Start maestro-runner server manually: + * ./maestro-runner --platform android server --port 9999 + * If not running, the server is auto-started by the test setup. + * + * Override with env vars: + * MAESTRO_SERVER_URL (default: http://localhost:9999) + * MAESTRO_PLATFORM (default: android) + * MAESTRO_RUNNER_BIN (path to binary, auto-detected by default) + * + * Run: + * npx jest tests/test_contact_persists.device.test.ts + */ + +import { afterAll, describe, it } from "@jest/globals"; + +import { getClient, teardown } from "./setup"; +import { ContactListPage } from "./pages/ContactListPage"; + +afterAll(async () => { + await teardown(); +}); + +describe("ContactPersists", () => { + /** Mirrors contact_persists.yaml: add contact → relaunch → verify. */ + it("should persist contact after relaunch", async () => { + const client = await getClient(); + const contactList = new ContactListPage(client); + + // First create the contact (reuses the add_contact flow as setup) + await contactList.launch(true); + const editPage = await contactList.openCreateContact(); + await editPage.setFirstName("Alice"); + await editPage.setLastName("Tester"); + await editPage.setPhone("5550100"); + await editPage.save(); + await contactList.assertContactVisible("Alice Tester"); + + // Cold-relaunch the app + await client.stopApp(ContactListPage.APP_ID); + await contactList.launch(false); + + // The contact must still be visible + await contactList.assertContactVisible("Alice Tester"); + }); +}); diff --git a/client/typescript/tests/test_models.unit.test.ts b/client/typescript/tests/test_models.unit.test.ts new file mode 100644 index 00000000..5c1a8615 --- /dev/null +++ b/client/typescript/tests/test_models.unit.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "@jest/globals"; + +import { DeviceInfo, ElementInfo, ElementSelector, ExecutionResult } from "../src/models"; + +describe("ElementSelector.toDict", () => { + it("serializes text only", () => { + const sel = new ElementSelector({ text: "Hello" }); + expect(sel.toDict()).toEqual({ text: "Hello" }); + }); + + it("serializes id only", () => { + const sel = new ElementSelector({ id: "btn_login" }); + expect(sel.toDict()).toEqual({ id: "btn_login" }); + }); + + it("serializes index as string", () => { + const sel = new ElementSelector({ index: 3 }); + expect(sel.toDict()).toEqual({ index: "3" }); + }); + + it("serializes boolean flags", () => { + const sel = new ElementSelector({ + enabled: true, + checked: false, + focused: true, + selected: false, + }); + expect(sel.toDict()).toEqual({ + enabled: true, + checked: false, + focused: true, + selected: false, + }); + }); + + it("serializes nested relative selectors", () => { + const ref = new ElementSelector({ text: "Ref" }); + const sel = new ElementSelector({ + text: "Target", + childOf: { id: "parent" }, + below: ref, + above: ref, + leftOf: ref, + rightOf: ref, + containsChild: ref, + insideOf: ref, + }); + + expect(sel.toDict()).toEqual({ + text: "Target", + childOf: { id: "parent" }, + below: { text: "Ref" }, + above: { text: "Ref" }, + leftOf: { text: "Ref" }, + rightOf: { text: "Ref" }, + containsChild: { text: "Ref" }, + insideOf: { text: "Ref" }, + }); + }); + + it("returns empty object for empty selector", () => { + expect(new ElementSelector().toDict()).toEqual({}); + }); +}); + +describe("ElementInfo.fromDict", () => { + it("returns undefined for null/undefined", () => { + expect(ElementInfo.fromDict(undefined)).toBeUndefined(); + expect(ElementInfo.fromDict(null)).toBeUndefined(); + }); + + it("parses full payload", () => { + const info = ElementInfo.fromDict({ + id: "btn1", + text: "OK", + bounds: { x: 10, y: 20, width: 100, height: 50 }, + visible: true, + enabled: true, + focused: false, + checked: true, + selected: false, + }); + + expect(info).toEqual( + expect.objectContaining({ + id: "btn1", + text: "OK", + visible: true, + enabled: true, + focused: false, + checked: true, + selected: false, + }), + ); + }); + + it("applies defaults for partial payload", () => { + const info = ElementInfo.fromDict({ text: "hello", visible: true }); + expect(info).toEqual( + expect.objectContaining({ + id: "", + text: "hello", + visible: true, + }), + ); + }); +}); + +describe("ExecutionResult.fromDict", () => { + it("parses success payload", () => { + const result = ExecutionResult.fromDict({ success: true, message: "ok", duration: 1234 }); + expect(result.success).toBe(true); + expect(result.message).toBe("ok"); + expect(result.durationNs).toBe(1234); + }); + + it("parses element and data fields", () => { + const result = ExecutionResult.fromDict({ + success: true, + element: { id: "e1", text: "Hello", visible: true }, + data: { key: "value" }, + }); + + expect(result.element).toEqual(expect.objectContaining({ id: "e1", text: "Hello" })); + expect(result.data).toEqual({ key: "value" }); + }); + + it("uses defaults for empty payload", () => { + const result = ExecutionResult.fromDict({}); + expect(result.success).toBe(false); + expect(result.message).toBeUndefined(); + expect(result.element).toBeUndefined(); + expect(result.data).toBeUndefined(); + }); +}); + +describe("DeviceInfo.fromDict", () => { + it("parses full payload", () => { + const info = DeviceInfo.fromDict({ + platform: "android", + osVersion: "14", + deviceName: "Pixel 6", + deviceId: "emulator-5554", + isSimulator: true, + screenWidth: 1080, + screenHeight: 2400, + appId: "com.example.app", + }); + + expect(info).toEqual( + expect.objectContaining({ + platform: "android", + osVersion: "14", + deviceName: "Pixel 6", + deviceId: "emulator-5554", + isSimulator: true, + screenWidth: 1080, + screenHeight: 2400, + appId: "com.example.app", + }), + ); + }); + + it("applies defaults for empty payload", () => { + const info = DeviceInfo.fromDict({}); + expect(info.platform).toBe(""); + expect(info.osVersion).toBe(""); + expect(info.screenWidth).toBe(0); + expect(info.isSimulator).toBe(false); + }); +}); diff --git a/client/typescript/tests/test_wait_for_animation_never_ends.device.test.ts b/client/typescript/tests/test_wait_for_animation_never_ends.device.test.ts new file mode 100644 index 00000000..a5748da8 --- /dev/null +++ b/client/typescript/tests/test_wait_for_animation_never_ends.device.test.ts @@ -0,0 +1,84 @@ +/** + * Test that waitForAnimationToEnd times out when the animation never stops. + * + * The emulator's camera preview is a live (continuously changing) feed, so the + * driver can never reach a "two consecutive identical screenshots" steady + * state and waitForAnimationToEnd must time out and return success=false. + * + * Using the camera avoids any dependency on external network access or a + * browser, both of which are unreliable inside CI sandboxes: the emulator often + * cannot reach the runner's loopback (so a locally served spinner page never + * loads), and Chrome will not render a local file:// from an intent. The + * camera preview is always available on the emulator and never settles. + * + * Prerequisites: + * 1. Android emulator OR iOS simulator running + * 2. Node deps installed (from client/typescript): npm install + * 3. (Optional) Start maestro-runner server manually: + * ./maestro-runner --platform android server --port 9999 + * ./maestro-runner --platform ios --device server --port 9999 + * If not running, the server is auto-started by the test setup. + * + * Override via env vars: + * MAESTRO_SERVER_URL (default: http://localhost:9999) + * MAESTRO_PLATFORM (default: android) + * MAESTRO_DEVICE_ID (recommended for explicit iOS simulator targeting) + * + * Run (Android): + * cd client/typescript && npx jest tests/test_wait_for_animation_never_ends.device.test.ts --runInBand + * + * Run (iOS): + * cd client/typescript && MAESTRO_PLATFORM=ios MAESTRO_DEVICE_ID= \ + * npx jest tests/test_wait_for_animation_never_ends.device.test.ts --runInBand + */ + +import { execSync } from "child_process"; +import { afterAll, describe, expect, it } from "@jest/globals"; + +import { getClient, teardown } from "./setup"; + +// Pause between the two consecutive screenshots (ms) — must be long enough for +// the preview to advance at least one visible frame +const SLEEP_MS = 500; + +// Maximum pixel-diff fraction still considered "static". Far below what a live +// camera preview produces, so any change is detected as animated. +const THRESHOLD = 0.0003; + +function launchCamera(): void { + // Open the camera capture intent. The emulated camera shows a live preview + // that never settles, which is exactly what we need. + execSync("adb shell am start -a android.media.action.IMAGE_CAPTURE", { + stdio: "ignore", + }); +} + +afterAll(async () => { + await teardown(); +}); + +describe("WaitForAnimationToEnd", () => { + it( + "should time out on an infinite (camera preview) animation", + async () => { + const client = await getClient(); + + launchCamera(); + + // Give the camera time to start rendering the live preview + await new Promise((resolve) => setTimeout(resolve, 5_000)); + + // Swipe up a bit; assert it worked + const swipeResult = await client.swipe("up", 400); + expect(swipeResult.success).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 1_000)); + + await expect( + client.waitForAnimationToEnd(SLEEP_MS, THRESHOLD, "wait_for_animation_on_camera_preview"), + ).rejects.toThrow("Timed out"); + }, + // The server default timeout is 15 s; allow 60 s for camera start + swipe + animation check + 60_000, + ); +}); diff --git a/client/typescript/tests/test_wait_for_animation_to_end.device.test.ts b/client/typescript/tests/test_wait_for_animation_to_end.device.test.ts new file mode 100644 index 00000000..dc0ed644 --- /dev/null +++ b/client/typescript/tests/test_wait_for_animation_to_end.device.test.ts @@ -0,0 +1,60 @@ +/** + * Tests for waitForAnimationToEnd on Android. + * + * The test launches Android Settings, navigates into a sub-screen (which + * produces a visible transition animation) and then calls waitForAnimationToEnd + * to confirm the screen has settled. + * + * Equivalent of: client/python/tests/test_wait_for_animation_to_end.py + * + * Prerequisites: + * 1. Android emulator running (adb devices shows a device) + * 2. Node deps installed (from client/typescript): npm install + * 3. (Optional) Start maestro-runner server manually: + * ./maestro-runner --platform android server --port 9999 + * If not running, the server is auto-started by the test setup. + * + * Run: + * cd client/typescript && npx jest tests/test_wait_for_animation_to_end.device.test.ts --runInBand + */ + +import { afterAll, describe, expect, it } from "@jest/globals"; + +import { getClient, teardown } from "./setup"; + +afterAll(async () => { + await teardown(); +}); + +describe("WaitForAnimationToEnd (settles)", () => { + it("should settle after app launch", async () => { + const client = await getClient(); + + await client.launchApp("com.android.settings", { clearState: false }); + + // Should not throw; returns success=true once screen becomes static + const result = await client.waitForAnimationToEnd(); + expect(result.success).toBe(true); + expect(result.message).not.toContain("WARNING"); + console.log(` waitForAnimationToEnd message: ${result.message}`); + }); + + it("should settle after navigation", async () => { + const client = await getClient(); + + await client.tap({ text: "Display" }); + + const result = await client.waitForAnimationToEnd(); + expect(result.success).toBe(true); + expect(result.message).not.toContain("WARNING"); + console.log(` waitForAnimationToEnd message: ${result.message}`); + }); + + it("should settle immediately on already-static screen", async () => { + const client = await getClient(); + + const result = await client.waitForAnimationToEnd(); + expect(result.success).toBe(true); + console.log(` waitForAnimationToEnd (static) message: ${result.message}`); + }); +}); diff --git a/client/typescript/tests/tsconfig.json b/client/typescript/tests/tsconfig.json new file mode 100644 index 00000000..79d6386b --- /dev/null +++ b/client/typescript/tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["./**/*.ts", "../src/**/*.ts"] +} \ No newline at end of file diff --git a/client/typescript/tsconfig.json b/client/typescript/tsconfig.json new file mode 100644 index 00000000..7c029834 --- /dev/null +++ b/client/typescript/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": ".", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/copilot-instructions.md b/copilot-instructions.md new file mode 100644 index 00000000..968a678a --- /dev/null +++ b/copilot-instructions.md @@ -0,0 +1,13 @@ +# GitHub Copilot Instructions + +## Shell Commands + +Always prefix terminal commands with `rtk` instead of calling them directly. + +Examples: +- `rtk git status` instead of `git status` +- `rtk git log --oneline -10` instead of `git log --oneline -10` +- `rtk go test ./...` instead of `go test ./...` +- `rtk make test` instead of `make test` + +This applies to all shell commands run via the terminal tool. diff --git a/docs/clients/README.md b/docs/clients/README.md new file mode 100644 index 00000000..32e14a13 --- /dev/null +++ b/docs/clients/README.md @@ -0,0 +1,37 @@ +# Language Clients + +maestro-runner exposes two official clients that wrap the [REST API Server](../README.md#rest-api-server). +Both let you write Maestro tests in code instead of YAML — with IDE autocomplete, +type checking, and the Page Object Model pattern — while reusing the exact same +drivers, selectors, and assertions as the CLI. + +## Clients + +- [TypeScript](typescript.md) — `MaestroClient` for Node.js test runners (Jest, Vitest, Playwright). +- [Python](python.md) — `MaestroClient` for pytest-based E2E suites, with built-in `pytest-xdist` parallel support. + +## How they fit together + +``` +┌──────────────┐ JSON over HTTP ┌────────────────────────┐ Maestro steps ┌──────────────┐ +│ TS / Python │ ─────────────────▶ │ maestro-runner server │ ────────────────▶ │ Android/iOS/ │ +│ client │ │ (maestro-runner server)│ │ Web device │ +└──────────────┘ └────────────────────────┘ └──────────────┘ +``` + +Every client call maps to a session endpoint (`POST /session`, `POST /session/{id}/execute`, +`GET /session/{id}/screenshot`, …). Start the server once, then drive it from either client: + +```bash +maestro-runner server --port 9999 +``` + +## Common setup + +Both clients share the same environment variables: + +| Variable | Default | Description | +|----------------------|--------------------------|-----------------------------------| +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Base URL of the running server | +| `MAESTRO_PLATFORM` | `android` | Target platform (`android`/`ios`/`web`) | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to the `maestro-runner` binary (used to auto-start a server in tests) | diff --git a/docs/clients/python.md b/docs/clients/python.md new file mode 100644 index 00000000..1cbc0f2e --- /dev/null +++ b/docs/clients/python.md @@ -0,0 +1,312 @@ +# Python Client Tutorial + +The Python client wraps the maestro-runner [REST API Server](../README.md#rest-api-server) +so you can drive Android, iOS, and Web devices from pytest — with the same selectors and +assertions as YAML flows. It adds first-class parallel execution via `pytest-xdist`, +where each worker spins up its own server and targets its own device automatically. + +Source: [`client/python`](../../client/python). + +## 1. Prerequisites + +- A built `maestro-runner` binary on your `PATH` (or set `MAESTRO_RUNNER_BIN` to its path). +- Python 3.9+. +- An emulator/simulator/device available, **or** let the tests auto-start a server. + +## 2. Install + +```bash +cd client/python +python3 -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +``` + +The package name is `maestro-runner`; import the client with: + +```python +from maestro_runner import MaestroClient +``` + +## 3. Start the server (or let the tests do it) + +Start the REST server once, in a separate terminal: + +```bash +maestro-runner server --port 9999 +``` + +> The pytest device suites can auto-start a server per worker (see Parallel mode below), +> so you only need to start it manually for your own scripts. + +## 4. Your first script + +The client is a context manager: pass `capabilities` to create the session immediately, +and it closes on exit. + +```python +from maestro_runner import MaestroClient + +with MaestroClient( + "http://localhost:9999", + capabilities={"platformName": "android", "appId": "com.example.app"}, +) as c: + c.launch_app("com.example.app", clear_state=True) + c.tap(text="Login") + c.input_text("user@example.com") + c.input_text("secret", id="password") + c.tap(text="Sign in") + c.assert_visible(text="Welcome", timeout_ms=10000) + + info = c.device_info() + print(f"Device: {info.device_name} ({info.platform} {info.os_version})") +``` + +If you don't pass `capabilities`, call `create_session` yourself and `close()` when done: + +```python +c = MaestroClient("http://localhost:9999") +c.create_session({"platformName": "android"}) +try: + ... +finally: + c.close() +``` + +## 5. Selectors + +`tap`, `long_press`, `assert_visible`, `assert_not_visible`, and `element_exists` take +keyword arguments for the selector: + +| Argument | Type | Meaning | +|-----------------------|-----------|-------------------------------------------------| +| `text` | `str` | Match by visible text | +| `id` | `str` | Match by resource id / accessibility id | +| `index` | `int` | Disambiguate multiple matches | +| `selector` | `object` | A raw `ElementSelector` (`{"text": ..., ...}`) | +| `long_press` | `bool` | Long-press instead of tap | +| `optional` | `bool` | Don't raise if the step fails | +| `timeout_ms` | `int` | Per-call timeout for assertions | +| `wait_until_visible` | `bool` | Wait for the element before acting | +| `enabled`/`checked`/… | `bool` | State filters for the matched element | + +Example: + +```python +c.tap(id="com.example.app:id/submit", enabled=True) +c.assert_visible(text="Saved", timeout_ms=10000) +``` + +## 6. Page Object Model + +Encapsulate screens behind page objects for maintainable suites: + +```python +# pages.py +class ContactListPage: + def __init__(self, client): + self.client = client + + def launch(self, clear=False): + self.client.launch_app("com.example.app", clear_state=clear) + + def open_create_contact(self): + self.client.tap(text="Add contact") + return ContactEditPage(self.client) + + def assert_contact_visible(self, name): + self.client.assert_visible(text=name) + + +class ContactEditPage: + def __init__(self, client): + self.client = client + + def set_first_name(self, v): + self.client.input_text(v, id="first_name") + + def set_last_name(self, v): + self.client.input_text(v, id="last_name") + + def set_phone(self, v): + self.client.input_text(v, id="phone") + + def save(self): + self.client.tap(text="Save") +``` + +```python +# test_add_contact.py +from maestro_runner import MaestroClient +from pages import ContactListPage, ContactEditPage + +def test_add_contact(): + with MaestroClient("http://localhost:9999", + capabilities={"platformName": "android"}) as c: + lst = ContactListPage(c) + lst.launch(clear=True) + edit = lst.open_create_contact() + edit.set_first_name("Alice") + edit.set_last_name("Tester") + edit.set_phone("5550100") + edit.save() + lst.assert_contact_visible("Alice Tester") +``` + +## 7. Advanced calls + +```python +# Self-healing: try each selector in order, stop at the first match. +c.tap_first_match( + [{"text": "Continue"}, {"id": "continue_button"}, {"text": "Next"}], + step="dismiss onboarding", +) + +# Tap a coordinate. +c.tap_on_point("50%,50%", long_press=True) + +# Swipe on an element or direction. +c.swipe_on(text="Feed", direction="UP", duration_ms=500) +c.swipe("DOWN", duration_ms=400) + +# Screenshot / view hierarchy as raw data. +png = c.screenshot() # bytes +xml = c.view_hierarchy() # str + +# Device metadata. +info = c.device_info() +``` + +## 8. Running the suite + +### Sequential (single device) + +```bash +pytest tests/test_add_contact.py tests/test_contact_persists.py -v +``` + +### Parallel (multiple devices) + +Run across several Android emulators with [pytest-xdist](https://pypi.org/project/pytest-xdist/). +Each worker auto-starts its own `maestro-runner` server on a unique port and targets a +specific device. + +**Prerequisites:** + +1. Two or more emulators running (`adb devices` lists them). +2. `pytest-xdist` installed (included in the `[dev]` extra). + +**Run:** + +```bash +# 2 emulators in parallel +pytest tests/test_add_contact.py tests/test_contact_persists.py -n 2 -v +``` + +Worker `gw0` gets the first device (e.g. `emulator-5554`) on port `9999`, `gw1` gets the +next device (e.g. `emulator-5556`) on port `10000`, and so on. + +## 9. Environment variables + +| Variable | Default | Description | +|----------------------|--------------------------|------------------------------------| +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Base URL (port used as starting port in parallel mode) | +| `MAESTRO_PLATFORM` | `android` | Target platform | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to the maestro-runner binary | + +## 10. Permissions & WebView + +### set_permissions + +Grant or deny app permissions mid-flow. Values are `"allow"`, `"deny"`, or `"unset"`; +shortcuts include `all`, `camera`, `contacts`, `phone`, `microphone`, `location`, +`storage`, `notifications`, `calendar`, `sms`, and more: + +```python +c.set_permissions("com.example.app", { + "camera": "allow", + "microphone": "deny", + # "all": "allow" grants every permission the app declares +}) +``` + +An undeclared permission (one the app never requested) no longer fails the step — the +server skips it and logs it by name. + +### reset_permissions + +Resets all browser permissions (web platform only): + +```python +c.reset_permissions() +``` + +### eval_webview_script / run_webview_script + +Run JavaScript inside a mobile WebView (Android/iOS) via CDP — distinct from desktop +`evalBrowserScript`. `output` stores the return value in a flow variable: + +```python +res = c.eval_webview_script( + "document.querySelector('h1')?.innerText", + output="title", +) + +c.run_webview_script("scripts/login.js", env={"USERNAME": "alice"}, output="result") +``` + +## 11. More step types + +The client also wraps the most common gesture, media, device-control, and browser +step types. Every method below maps 1:1 to a server step type: + +```python +# Gestures +c.double_tap_on(text="Star") +c.long_press_on(text="Item", duration_ms=800) +c.drag_and_drop(from_="Source", to="Target", hold_duration=1000) +c.scroll_until_visible(element={"text": "Footer"}, direction="DOWN", max_scrolls=5) + +# Assertions & media +c.assert_screenshot(path="baseline.png", threshold_percentage=95) +c.take_screenshot(path="evidence.png") +c.copy_text_from(id="title") +c.paste_text() +c.set_clipboard("hello") + +# AI & scripting +c.assert_with_ai("the cart total is under $50") +c.eval_script("console.log('hi')") +c.run_script(file="setup.js", env={"MODE": "test"}) +c.eval_browser_script("window.scrollTo(0, 0)", output="ok") + +# Device control +c.set_location("37.4219", "-122.0840") +c.set_airplane_mode(True) +c.toggle_airplane_mode() +c.set_network_conditions(offline=False, latency=200, download_speed=5000) +c.open_notifications() +c.set_dark_mode(True) +c.set_orientation("LANDSCAPE") + +# Browser (web platform) +c.open_browser("https://example.com") +c.switch_tab(index=1) +c.close_tab() +c.get_console_logs("logs") +c.clear_console_logs() +c.assert_no_js_errors() +c.mock_network( + url="https://api.example.com/user", + method="GET", + response={"status": 200, "body": '{"id":1}'}, +) +``` + +Not every server step type has a typed method — but `c.execute_step({"type": "...", ...})` +forwards any raw step dict to the server, which supports ~90 step types in total. + +## Full API reference + +See [`client/python/maestro_runner/client.py`](../../client/python/maestro_runner/client.py) +for the complete `MaestroClient` API, and the client +[`README.md`](../../client/python/README.md) for the install and run summary. diff --git a/docs/clients/typescript.md b/docs/clients/typescript.md new file mode 100644 index 00000000..85b1bd5a --- /dev/null +++ b/docs/clients/typescript.md @@ -0,0 +1,326 @@ +# TypeScript Client Tutorial + +The TypeScript client wraps the maestro-runner [REST API Server](../README.md#rest-api-server) +so you can drive Android, iOS, and Web devices from Node.js test runners (Jest, Vitest, +Playwright) using the same selectors and assertions as YAML flows — with full type safety. + +Source: [`client/typescript`](../../client/typescript). + +## 1. Prerequisites + +- A built `maestro-runner` binary on your `PATH` (or set `MAESTRO_RUNNER_BIN` to its path). +- An emulator/simulator/device available, **or** let the tests auto-start a server. +- Node.js 18+ (uses the built-in `fetch`). + +## 2. Install + +```bash +cd client/typescript +npm install +``` + +This installs the client and its dev dependencies (Jest, etc.). The package name is +`maestro-runner`, so you import it as: + +```ts +import { MaestroClient } from "maestro-runner"; +``` + +## 3. Start the server (or let the client tests do it) + +Start the REST server once, in a separate terminal: + +```bash +maestro-runner server --port 9999 +# or pre-select a platform: +maestro-runner --platform android server --port 9999 +``` + +> The device test suites (`npm run test:device:*`) can also auto-start the server +> using `MAESTRO_RUNNER_BIN`, so you only need to start it manually for unit tests or +> your own scripts. + +## 4. Your first script + +```ts +import { MaestroClient } from "maestro-runner"; + +const client = new MaestroClient("http://localhost:9999"); + +// Create a session before executing any steps. +await client.createSession({ platformName: "android" }); + +try { + await client.launchApp("com.example.app", { clearState: true }); + await client.tap({ text: "Login" }); + await client.inputText("user@example.com"); + await client.inputText("secret", { id: "password" }); + await client.tap({ text: "Sign in" }); + await client.assertVisible({ text: "Welcome" }); +} finally { + // Always close the session to free the device. + await client.close(); +} +``` + +`createSession()` is required before any step. `close()` sends `DELETE /session/{id}`. +Wrap work in `try/finally` (or a context helper) so the session is always released. + +## 5. Selectors + +`tap`, `longPress`, `assertVisible`, `assertNotVisible`, and `elementExists` accept an +options object with a selector: + +| Field | Type | Meaning | +|----------------------|-----------|-------------------------------------------------| +| `text` | `string` | Match by visible text | +| `id` | `string` | Match by resource id / accessibility id | +| `index` | `number` | Disambiguate multiple matches | +| `selector` | `object` | A raw `ElementSelector` (`{ text, id, ... }`) | +| `longPress` | `boolean` | Long-press instead of tap | +| `optional` | `boolean` | Don't throw if the step fails | +| `timeoutMs` | `number` | Per-call timeout for assertions | +| `waitUntilVisible` | `boolean` | Wait for the element before acting | +| `enabled`/`checked`/… | `boolean` | State filters for the matched element | + +Example: + +```ts +await client.tap({ id: "com.example.app:id/submit", enabled: true }); +await client.assertVisible({ text: "Saved", timeoutMs: 10000 }); +``` + +## 6. Page Object Model + +For maintainable suites, encapsulate screens behind page objects and reuse a shared +client. The repository's device tests follow this pattern: + +```ts +// setup.ts +import { MaestroClient } from "maestro-runner"; + +let client: MaestroClient | undefined; + +export async function getClient(): Promise { + if (!client) { + client = new MaestroClient(process.env.MAESTRO_SERVER_URL ?? "http://localhost:9999"); + await client.createSession({ platformName: process.env.MAESTRO_PLATFORM ?? "android" }); + } + return client; +} + +export async function teardown(): Promise { + if (client) { + await client.close(); + client = undefined; + } +} +``` + +```ts +// pages/ContactListPage.ts +import type { MaestroClient } from "maestro-runner"; + +export class ContactListPage { + constructor(private readonly client: MaestroClient) {} + + async launch(clear = false): Promise { + await this.client.launchApp("com.example.app", { clearState: clear }); + } + + async openCreateContact() { + await this.client.tap({ text: "Add contact" }); + return new ContactEditPage(this.client); + } + + async assertContactVisible(name: string): Promise { + await this.client.assertVisible({ text: name }); + } +} + +class ContactEditPage { + constructor(private readonly client: MaestroClient) {} + async setFirstName(v: string) { await this.client.inputText(v, { id: "first_name" }); } + async setLastName(v: string) { await this.client.inputText(v, { id: "last_name" }); } + async setPhone(v: string) { await this.client.inputText(v, { id: "phone" }); } + async save() { await this.client.tap({ text: "Save" }); } +} +``` + +```ts +// tests/add-contact.test.ts +import { getClient, teardown } from "./setup"; +import { ContactListPage } from "./pages/ContactListPage"; + +afterAll(() => teardown()); + +it("adds a contact", async () => { + const client = await getClient(); + const list = new ContactListPage(client); + + await list.launch(true); + const edit = await list.openCreateContact(); + await edit.setFirstName("Alice"); + await edit.setLastName("Tester"); + await edit.setPhone("5550100"); + await edit.save(); + await list.assertContactVisible("Alice Tester"); +}); +``` + +## 7. Advanced calls + +```ts +// Self-healing: try each selector in order, stop at the first match. +await client.tapFirstMatch([ + { text: "Continue" }, + { id: "continue_button" }, + { text: "Next" }, +], "dismiss onboarding"); + +// Tap a coordinate. +await client.tapOnPoint("50%,50%", { longPress: true }); + +// Swipe on an element or direction. +await client.swipeOn({ text: "Feed", direction: "UP", durationMs: 500 }); +await client.swipe("DOWN", 400); + +// Screenshot / view hierarchy as raw data. +const png = await client.screenshot(); // ArrayBuffer +const xml = await client.viewHierarchy(); // string + +// Device metadata. +const info = await client.deviceInfo(); +console.log(info.deviceName, info.platform, info.osVersion); +``` + +## 8. Running the suite + +```bash +# Unit tests (no device needed) — exercises the client against a mocked server. +npm run test:unit + +# Device tests — require an emulator/simulator and a running server. +npm run test:device:android +npm run test:device:ios + +# Animation-regression device tests. +npm run test:animation:android +npm run test:animation:ios +``` + +Lint and type-check: + +```bash +npm run lint +npm run build # tsc — emits dist/ with type declarations +``` + +## 9. Environment variables + +| Variable | Default | Description | +| --------------------- | -------------------------- | ------------------------------- | +| `MAESTRO_SERVER_URL` | `http://localhost:9999` | Server URL | +| `MAESTRO_PLATFORM` | `android` | Target platform | +| `MAESTRO_RUNNER_BIN` | `../../maestro-runner` | Path to maestro-runner binary | + +## 10. Permissions & WebView + +### setPermissions + +Grant or deny app permissions mid-flow. Values are `"allow"`, `"deny"`, or `"unset"`; +shortcuts include `all`, `camera`, `contacts`, `phone`, `microphone`, `location`, +`storage`, `notifications`, `calendar`, `sms`, and more. The flow's `appId` is used when +omitted, but the typed client requires it explicitly: + +```ts +await client.setPermissions("com.example.app", { + camera: "allow", + microphone: "deny", + // "all": "allow" grants every permission the app declares +}); +``` + +An undeclared permission (one the app never requested) no longer fails the step — the +server skips it and logs it by name. + +### resetPermissions + +Resets all browser permissions (web platform only): + +```ts +await client.resetPermissions(); +``` + +### evalWebViewScript / runWebViewScript + +Run JavaScript inside a mobile WebView (Android/iOS) via CDP — distinct from desktop +`evalBrowserScript`. `output` stores the return value in a flow variable: + +```ts +const res = await client.evalWebViewScript( + "document.querySelector('h1')?.innerText", + { output: "title" }, +); + +await client.runWebViewScript("scripts/login.js", { + env: { USERNAME: "alice" }, // injected as window.__env + output: "result", +}); +``` + +## 11. More step types + +The client also wraps the most common gesture, media, device-control, and browser +step types. Every method below maps 1:1 to a server step type: + +```ts +// Gestures +await client.doubleTapOn({ text: "Star" }); +await client.longPressOn({ text: "Item", durationMs: 800 }); +await client.dragAndDrop({ from: "Source", to: "Target", holdDuration: 1000 }); +await client.scrollUntilVisible({ element: { text: "Footer" }, direction: "DOWN", maxScrolls: 5 }); + +// Assertions & media +await client.assertScreenshot({ path: "baseline.png", thresholdPercentage: 95 }); +await client.takeScreenshot({ path: "evidence.png" }); +await client.copyTextFrom({ id: "title" }); +await client.pasteText(); +await client.setClipboard("hello"); + +// AI & scripting +await client.assertWithAI("the cart total is under $50"); +await client.evalScript("console.log('hi')"); +await client.runScript({ file: "setup.js", env: { MODE: "test" } }); +await client.evalBrowserScript("window.scrollTo(0, 0)", { output: "ok" }); + +// Device control +await client.setLocation("37.4219", "-122.0840"); +await client.setAirplaneMode(true); +await client.toggleAirplaneMode(); +await client.setNetworkConditions({ offline: false, latency: 200, downloadSpeed: 5000 }); +await client.openNotifications(); +await client.setDarkMode(true); +await client.setOrientation("LANDSCAPE"); + +// Browser (web platform) +await client.openBrowser("https://example.com"); +await client.switchTab({ index: 1 }); +await client.closeTab(); +await client.getConsoleLogs("logs"); +await client.clearConsoleLogs(); +await client.assertNoJSErrors(); +await client.mockNetwork({ + url: "https://api.example.com/user", + method: "GET", + response: { status: 200, body: '{"id":1}' }, +}); +``` + +Not every server step type has a typed method — but `executeStep({ type: "...", ... })` +forwards any raw step dict to the server, which supports ~90 step types in total. + +## Full API reference + +See the client [`README.md`](../../client/typescript/README.md) for the complete +`MaestroClient` method table. diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/checksums/checksums.lock b/drivers/android/devicelab-android-driver/.gradle/8.5/checksums/checksums.lock new file mode 100644 index 00000000..5957f96d Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/8.5/checksums/checksums.lock differ diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock b/drivers/android/devicelab-android-driver/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock new file mode 100644 index 00000000..e797cff3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/8.5/dependencies-accessors/dependencies-accessors.lock differ diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/dependencies-accessors/gc.properties b/drivers/android/devicelab-android-driver/.gradle/8.5/dependencies-accessors/gc.properties new file mode 100644 index 00000000..e69de29b diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/executionHistory/executionHistory.bin b/drivers/android/devicelab-android-driver/.gradle/8.5/executionHistory/executionHistory.bin new file mode 100644 index 00000000..a13dc616 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/executionHistory/executionHistory.lock b/drivers/android/devicelab-android-driver/.gradle/8.5/executionHistory/executionHistory.lock new file mode 100644 index 00000000..5aa74305 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/fileChanges/last-build.bin b/drivers/android/devicelab-android-driver/.gradle/8.5/fileChanges/last-build.bin new file mode 100644 index 00000000..f76dd238 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/8.5/fileChanges/last-build.bin differ diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/fileHashes.bin b/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/fileHashes.bin new file mode 100644 index 00000000..a289ddbd Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/fileHashes.lock b/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/fileHashes.lock new file mode 100644 index 00000000..f2db7a33 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/resourceHashesCache.bin b/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/resourceHashesCache.bin new file mode 100644 index 00000000..02ca1ef5 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/drivers/android/devicelab-android-driver/.gradle/8.5/gc.properties b/drivers/android/devicelab-android-driver/.gradle/8.5/gc.properties new file mode 100644 index 00000000..e69de29b diff --git a/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 00000000..3db474d5 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/cache.properties b/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 00000000..0a777ed6 --- /dev/null +++ b/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Sat Feb 21 17:57:05 IST 2026 +gradle.version=8.5 diff --git a/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/outputFiles.bin b/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/outputFiles.bin new file mode 100644 index 00000000..ef0f37a6 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/drivers/android/devicelab-android-driver/.gradle/file-system.probe b/drivers/android/devicelab-android-driver/.gradle/file-system.probe new file mode 100644 index 00000000..4d3850d9 Binary files /dev/null and b/drivers/android/devicelab-android-driver/.gradle/file-system.probe differ diff --git a/drivers/android/devicelab-android-driver/.gradle/vcs-1/gc.properties b/drivers/android/devicelab-android-driver/.gradle/vcs-1/gc.properties new file mode 100644 index 00000000..e69de29b diff --git a/drivers/android/devicelab-android-driver/OPTIMIZATION_PLAN.md b/drivers/android/devicelab-android-driver/OPTIMIZATION_PLAN.md new file mode 100644 index 00000000..917fe9b0 --- /dev/null +++ b/drivers/android/devicelab-android-driver/OPTIMIZATION_PLAN.md @@ -0,0 +1,88 @@ +# DeviceLab Android Driver: Optimization Plan + +Current benchmark: **27.8s avg** (6 flows, 51 steps) — **4.1x faster** than Maestro CLI (1m55s), **26% faster** than native UIAutomator2 (37.8s). + +Device: Pixel 4a (API 33), serial: 11171JEC200939 + +--- + +## P0 — High Impact, Easy to Implement + +| # | Optimization | Expected Impact | Status | +|---|-------------|----------------|--------| +| 1 | **Disable system animations** via `adb shell settings put global` (window_animation_scale, transition_animation_scale, animator_duration_scale = 0) | Eliminates 200-300ms animation waits per screen transition | Done | +| 2 | **Screenshot downscaling** — capture at 50% resolution (half width/height) | 4x fewer pixels = ~4x faster compress + transfer | Done | +| 3 | **Event-driven window waits** — use `setOnAccessibilityEventListener` to detect `TYPE_WINDOW_STATE_CHANGED` instead of polling | Caused regression (race condition: events fire before listener setup). Reverted to fast polling (10ms). | Reverted | +| 4 | **`performAction(ACTION_CLICK)`** on elements instead of coordinate injection | Single IPC vs 3 IPCs (getbounds + DOWN + UP events) | Done | +| 5 | **TCP_NODELAY on WebSocket** — disable Nagle's algorithm | Reduces ~40ms latency per small RPC message | Done | + +## P1 — High Impact, Moderate Effort + +| # | Optimization | Expected Impact | Status | +|---|-------------|----------------|--------| +| 6 | **WebP compression** for screenshots (API 30+, lossy quality 80) | ~30% smaller than JPEG at same quality | Pending | +| 7 | **Binary screenshot transfer** — send raw bytes via WebSocket binary frame instead of base64 | Eliminates 33% base64 overhead | Done | +| 8 | **Combined RPCs** — FindAndClick, SendKeysToActive in single WebSocket round-trip | Eliminates per-step RPC latency | Done | +| 9 | **Parallel screenshot** — take screenshot in background thread while processing next command | Overlaps I/O with computation | Pending | +| 10 | **Element store cleanup** — expire cached elements after TTL to prevent memory leaks | Reliability improvement | Pending | + +## P2 — Medium Impact + +| # | Optimization | Expected Impact | Status | +|---|-------------|----------------|--------| +| 11 | **Page source XML: use `XmlSerializer`** instead of StringBuilder concatenation | Faster, correct XML escaping, -2% | Done | +| 12 | **Skip invisible nodes** in tree traversal | Risks: breaks assertNotVisible, breaks relative selectors, isVisibleToUser() is itself an IPC call | Skip | +| 13 | **Pre-compiled regex patterns** for element matching | Already compiled once per search call, not per-element. Compile cost (~1-2μs) is 1000x less than tree IPC (~1ms) | Skip | +| 14 | **Accessibility node recycling** (`recycle()` calls) | Reduces GC pressure | Pending | +| 15 | **Connection keepalive/heartbeat tuning** | Prevents reconnection overhead | Pending | + +## P3 — Low-Medium Impact + +| # | Optimization | Expected Impact | Status | +|---|-------------|----------------|--------| +| 16 | **CPU governor pinning** — set performance governor during test runs | Prevents CPU throttling | Pending | +| 17 | **Process priority boosting** — set agent process to high priority | More CPU time for agent | Pending | +| 18 | **Custom IME for fast text input** — install lightweight keyboard | Faster sendKeys without character-by-character injection | Pending | +| 19 | **Partial tree queries** — limit tree depth for element finding | Less traversal for shallow elements | Pending | +| 20 | **Parallel element finding** — search multiple strategies concurrently | Reduces worst-case find time | Pending | + +## P4 — Low Impact / Experimental + +| # | Optimization | Expected Impact | Status | +|---|-------------|----------------|--------| +| 21 | **Display buffer direct access** — use SurfaceControl/PixelCopy APIs | Faster screenshots bypassing UiAutomation | Pending | +| 22 | **Warm element cache** — pre-fetch likely elements after navigation | Reduces find latency for next step | Pending | +| 23 | **Compression on WebSocket** — enable permessage-deflate | Smaller payloads for XML source | Pending | +| 24 | **Instrumentation thread pool** — handle multiple requests concurrently | Single client sequential RPC, UiAutomation not thread-safe, bottleneck is IPC not CPU | Skip | +| 25 | **Lazy XML attributes** — only include requested attributes in source | Smaller XML, faster parse | Pending | + +## P5 — Future / Research + +| # | Optimization | Expected Impact | Status | +|---|-------------|----------------|--------| +| 26 | **gRPC instead of WebSocket JSON-RPC** — binary protocol, code-gen | Lower serialization overhead | Pending | +| 27 | **Shared memory screenshot** — mmap between agent and host | Zero-copy screenshot transfer | Pending | +| 28 | **ADB forward instead of reverse** — reduce port forwarding overhead | Already using adb forward. Nothing to change. | N/A | +| 29 | **Custom accessibility service** — bypass UiAutomation limitations | Same underlying API, worse setup (requires manual enable), loses instrumentation powers | Skip | +| 30 | **Native code (JNI) for hot paths** — C/C++ for tree traversal | Bypass JVM overhead | Pending | +| 31 | **Multi-device test sharding** — split flows across devices | Linear speedup with device count | Pending | +| 32 | **Predictive pre-execution** — analyze flow ahead and pre-warm | Overlap wait time with next step prep | Pending | +| 33 | **App-side hooks** — inject test helper code into target app | Direct access to app state | Pending | +| 34 | **Snapshot/restore** — use emulator snapshots for instant app state reset | Eliminates clearState + relaunch overhead | Pending | + +--- + +## Already Implemented (All Rounds) + +- Prefetch flags for tree traversal (API 33+) — `FLAG_PREFETCH_DESCENDANTS_HYBRID | FLAG_PREFETCH_SIBLINGS` +- `syncInputTransactions` for gestures (API 31+) — via reflection +- JPEG 60 compression + 50% downscale for screenshots +- Binary WebSocket frame for screenshot transfer (no base64) +- Combined RPCs: `findAndClick`, `sendKeysToActive` +- XmlSerializer for page source (replaces StringBuilder + manual escaping) +- `performAction(ACTION_CLICK)` instead of coordinate injection +- TCP_NODELAY on WebSocket +- `waitForWindowReady` after app launch +- Reduced findElement retry sleep (200ms → 50ms) +- Reduced DOWN→UP sleep (50ms → 20ms on API <31) +- Disable system animations during test run diff --git a/drivers/android/devicelab-android-driver/agent-test/src/androidTest/java/dev/devicelab/maestro/agent/test/MaestroAgentTest.java b/drivers/android/devicelab-android-driver/agent-test/src/androidTest/java/dev/devicelab/maestro/agent/test/MaestroAgentTest.java new file mode 100644 index 00000000..d4becac8 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent-test/src/androidTest/java/dev/devicelab/maestro/agent/test/MaestroAgentTest.java @@ -0,0 +1,19 @@ +package dev.devicelab.maestro.agent.test; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Entry point for 'am instrument' to start the Maestro Agent. + * The actual agent runs via MaestroAgentRunner (Instrumentation subclass). + * This test class exists as a placeholder required by the test APK. + */ +@RunWith(AndroidJUnit4.class) +public class MaestroAgentTest { + @Test + public void agentPlaceholder() { + // Agent is started by MaestroAgentRunner.onStart() + // This test is just a placeholder + } +} diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/annotation_processor_list/debug/annotationProcessors.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/annotation_processor_list/debug/annotationProcessors.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/annotation_processor_list/debug/annotationProcessors.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/annotation_processor_list/debugAndroidTest/annotationProcessors.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/annotation_processor_list/debugAndroidTest/annotationProcessors.json new file mode 100644 index 00000000..9e26dfee --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/annotation_processor_list/debugAndroidTest/annotationProcessors.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/apk_ide_redirect_file/debug/redirect.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/apk_ide_redirect_file/debug/redirect.txt new file mode 100644 index 00000000..66652a54 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/apk_ide_redirect_file/debug/redirect.txt @@ -0,0 +1,2 @@ +#- File Locator - +listingFile=../../../outputs/apk/debug/output-metadata.json diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/apk_ide_redirect_file/debugAndroidTest/redirect.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/apk_ide_redirect_file/debugAndroidTest/redirect.txt new file mode 100644 index 00000000..b7d58687 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/apk_ide_redirect_file/debugAndroidTest/redirect.txt @@ -0,0 +1,2 @@ +#- File Locator - +listingFile=../../../outputs/apk/androidTest/debug/output-metadata.json diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/app_metadata/debug/app-metadata.properties b/drivers/android/devicelab-android-driver/agent/build/intermediates/app_metadata/debug/app-metadata.properties new file mode 100644 index 00000000..d8c27a4c --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/app_metadata/debug/app-metadata.properties @@ -0,0 +1,2 @@ +appMetadataVersion=1.1 +androidGradlePluginVersion=8.2.0 diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/compatible_screen_manifest/debug/output-metadata.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/compatible_screen_manifest/debug/output-metadata.json new file mode 100644 index 00000000..b9701378 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/compatible_screen_manifest/debug/output-metadata.json @@ -0,0 +1,10 @@ +{ + "version": 3, + "artifactType": { + "type": "COMPATIBLE_SCREEN_MANIFEST", + "kind": "Directory" + }, + "applicationId": "dev.devicelab.driver.android", + "variantName": "debug", + "elements": [] +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar new file mode 100644 index 00000000..74b73743 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/R.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debugAndroidTest/R.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debugAndroidTest/R.jar new file mode 100644 index 00000000..c2891e17 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debugAndroidTest/R.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_app_classes_jar/debug/classes.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_app_classes_jar/debug/classes.jar new file mode 100644 index 00000000..b69c54d2 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/compile_app_classes_jar/debug/classes.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_0/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_0/graph.bin new file mode 100644 index 00000000..1d8f1e6f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_0/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_1/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_1/graph.bin new file mode 100644 index 00000000..0dfd8e95 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_1/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_2/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_2/graph.bin new file mode 100644 index 00000000..17e3d9c4 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_2/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_3/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_3/graph.bin new file mode 100644 index 00000000..f5ec3171 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_3/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_4/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_4/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_4/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_5/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_5/graph.bin new file mode 100644 index 00000000..c800ae19 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/dirs_bucket_5/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_0/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_0/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_0/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_1/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_1/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_1/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_2/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_2/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_2/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_3/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_3/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_3/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_4/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_4/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_4/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_5/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_5/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debug/out/currentProject/jar_f1d05a792a94ea98a032ddc86b438fe9b08a5f82e524ad89418c80ec3de2172e_bucket_5/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_0/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_0/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_0/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_1/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_1/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_1/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_2/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_2/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_2/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_3/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_3/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_3/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_4/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_4/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_4/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_5/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_5/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/dirs_bucket_5/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_0/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_0/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_0/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_1/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_1/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_1/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_2/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_2/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_2/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_3/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_3/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_3/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_4/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_4/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_4/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_5/graph.bin b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_5/graph.bin new file mode 100644 index 00000000..601f245f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/desugar_graph/debugAndroidTest/out/currentProject/jar_5abdbf92e400e811f371f1d312caef7ebf70550d6a41a1aec773f82b98d822d4_bucket_5/graph.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeExtDexDebug/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeExtDexDebug/classes.dex new file mode 100644 index 00000000..a73500df Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeExtDexDebug/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/0/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/0/classes.dex new file mode 100644 index 00000000..60016b34 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/0/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/10/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/10/classes.dex new file mode 100644 index 00000000..7e7881f5 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/10/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/13/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/13/classes.dex new file mode 100644 index 00000000..a21a7b79 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/13/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/2/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/2/classes.dex new file mode 100644 index 00000000..184b6552 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/2/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/9/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/9/classes.dex new file mode 100644 index 00000000..b46dba42 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/9/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeExtDexDebugAndroidTest/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeExtDexDebugAndroidTest/classes.dex new file mode 100644 index 00000000..2278a6ba Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeExtDexDebugAndroidTest/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeProjectDexDebugAndroidTest/0/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeProjectDexDebugAndroidTest/0/classes.dex new file mode 100644 index 00000000..731b31f3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeProjectDexDebugAndroidTest/0/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeProjectDexDebugAndroidTest/8/classes.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeProjectDexDebugAndroidTest/8/classes.dex new file mode 100644 index 00000000..d8344918 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeProjectDexDebugAndroidTest/8/classes.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_archive_input_jar_hashes/debug/out b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_archive_input_jar_hashes/debug/out new file mode 100644 index 00000000..691532ca Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_archive_input_jar_hashes/debug/out differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_archive_input_jar_hashes/debugAndroidTest/out b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_archive_input_jar_hashes/debugAndroidTest/out new file mode 100644 index 00000000..caa0a27b Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_archive_input_jar_hashes/debugAndroidTest/out differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_number_of_buckets_file/debug/out b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_number_of_buckets_file/debug/out new file mode 100644 index 00000000..62f94575 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_number_of_buckets_file/debug/out @@ -0,0 +1 @@ +6 \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_number_of_buckets_file/debugAndroidTest/out b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_number_of_buckets_file/debugAndroidTest/out new file mode 100644 index 00000000..62f94575 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/dex_number_of_buckets_file/debugAndroidTest/out @@ -0,0 +1 @@ +6 \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/merge-state b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/merge-state new file mode 100644 index 00000000..c9df3284 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/merge-state differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/0PhXM050IUU3RY_ziyNoWYdy+SM= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/0PhXM050IUU3RY_ziyNoWYdy+SM= new file mode 100644 index 00000000..43807b02 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/0PhXM050IUU3RY_ziyNoWYdy+SM= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/1n7HyViJ5MOgsVxcMZa9_8tSok4= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/1n7HyViJ5MOgsVxcMZa9_8tSok4= new file mode 100644 index 00000000..a8a69066 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/1n7HyViJ5MOgsVxcMZa9_8tSok4= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/2gQSFWck5F3JZ7_OzILFexcIeJM= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/2gQSFWck5F3JZ7_OzILFexcIeJM= new file mode 100644 index 00000000..6888d08f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/2gQSFWck5F3JZ7_OzILFexcIeJM= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/GjneVKAYg6FZWZlzlhochPLGcTY= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/GjneVKAYg6FZWZlzlhochPLGcTY= new file mode 100644 index 00000000..a88c5bd9 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/GjneVKAYg6FZWZlzlhochPLGcTY= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/OZ81VclKQX7lmGGIzyDrzoSN5KM= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/OZ81VclKQX7lmGGIzyDrzoSN5KM= new file mode 100644 index 00000000..fb55ca2e Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/OZ81VclKQX7lmGGIzyDrzoSN5KM= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/QSidy9+r+KEI0QuJZC5CC9GXRNU= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/QSidy9+r+KEI0QuJZC5CC9GXRNU= new file mode 100644 index 00000000..9d5fe16e Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/QSidy9+r+KEI0QuJZC5CC9GXRNU= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/WcdlUZyKF5vwFMjxVF0plDfndpQ= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/WcdlUZyKF5vwFMjxVF0plDfndpQ= new file mode 100644 index 00000000..a2cb8020 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/WcdlUZyKF5vwFMjxVF0plDfndpQ= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/ccjIC9CgkO8zq8wY5uuUmwx3QhM= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/ccjIC9CgkO8zq8wY5uuUmwx3QhM= new file mode 100644 index 00000000..cc08d4e9 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/ccjIC9CgkO8zq8wY5uuUmwx3QhM= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/dApbucwKg_t9g7NDrsBus7FvP+0= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/dApbucwKg_t9g7NDrsBus7FvP+0= new file mode 100644 index 00000000..8f9e0469 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/dApbucwKg_t9g7NDrsBus7FvP+0= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/gQhfjwg83VWn+WBqJe6hFeL8NSY= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/gQhfjwg83VWn+WBqJe6hFeL8NSY= new file mode 100644 index 00000000..783f6a71 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/gQhfjwg83VWn+WBqJe6hFeL8NSY= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/hdMR6G6CCFJc1Rbu3BkI3GM2CMI= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/hdMR6G6CCFJc1Rbu3BkI3GM2CMI= new file mode 100644 index 00000000..fb794be9 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/hdMR6G6CCFJc1Rbu3BkI3GM2CMI= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/ig85ksgVeoXshNbsOH_knoQzUzY= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/ig85ksgVeoXshNbsOH_knoQzUzY= new file mode 100644 index 00000000..6da55d8b Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/ig85ksgVeoXshNbsOH_knoQzUzY= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/mN3hJRpILEQsoG8wm1EzxWOO4lk= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/mN3hJRpILEQsoG8wm1EzxWOO4lk= new file mode 100644 index 00000000..f390c5a4 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/mN3hJRpILEQsoG8wm1EzxWOO4lk= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/olBIKhU3qT5dZ6g7ncvxhW627kc= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/olBIKhU3qT5dZ6g7ncvxhW627kc= new file mode 100644 index 00000000..2ea05b6e Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/olBIKhU3qT5dZ6g7ncvxhW627kc= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/tlsGlPGbPPX9kZKzFikKSKTK5rs= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/tlsGlPGbPPX9kZKzFikKSKTK5rs= new file mode 100644 index 00000000..905eb2bc Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/tlsGlPGbPPX9kZKzFikKSKTK5rs= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/uA8UIKFvRG+Bew9IACfUVyutSt8= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/uA8UIKFvRG+Bew9IACfUVyutSt8= new file mode 100644 index 00000000..e36377d2 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/uA8UIKFvRG+Bew9IACfUVyutSt8= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/yvohphSuxS9d8d+Wne_IC5l8RMo= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/yvohphSuxS9d8d+Wne_IC5l8RMo= new file mode 100644 index 00000000..7223f7ed Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug-mergeJavaRes/zip-cache/yvohphSuxS9d8d+Wne_IC5l8RMo= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/mergeDebugResources/compile-file-map.properties b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/mergeDebugResources/compile-file-map.properties new file mode 100644 index 00000000..46383e71 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/mergeDebugResources/compile-file-map.properties @@ -0,0 +1 @@ +#Tue Feb 24 08:47:22 IST 2026 diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/mergeDebugResources/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/mergeDebugResources/merger.xml new file mode 100644 index 00000000..c1b9682d --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/mergeDebugResources/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties new file mode 100644 index 00000000..46383e71 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/packageDebugResources/compile-file-map.properties @@ -0,0 +1 @@ +#Tue Feb 24 08:47:22 IST 2026 diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/packageDebugResources/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/packageDebugResources/merger.xml new file mode 100644 index 00000000..36c1c77b --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/packageDebugResources/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/merge-state b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/merge-state new file mode 100644 index 00000000..27a9b092 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/merge-state differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/+xTvfwSx0b84apxhEtdLfD5a5v4= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/+xTvfwSx0b84apxhEtdLfD5a5v4= new file mode 100644 index 00000000..5e676925 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/+xTvfwSx0b84apxhEtdLfD5a5v4= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/Lt4RBB0foqDMzQKu8buxnLtmqZs= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/Lt4RBB0foqDMzQKu8buxnLtmqZs= new file mode 100644 index 00000000..833b0958 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/Lt4RBB0foqDMzQKu8buxnLtmqZs= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/OltB0ku1xXC0Rbbe+VEBlwwwXOA= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/OltB0ku1xXC0Rbbe+VEBlwwwXOA= new file mode 100644 index 00000000..f57b1bbc Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/OltB0ku1xXC0Rbbe+VEBlwwwXOA= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/oleBdTAKIt+QXRPR6mxsq1jc0sU= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/oleBdTAKIt+QXRPR6mxsq1jc0sU= new file mode 100644 index 00000000..2ff5659a Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/oleBdTAKIt+QXRPR6mxsq1jc0sU= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/txIUWUSyIM9u4QmCrxZxzt+TgLQ= b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/txIUWUSyIM9u4QmCrxZxzt+TgLQ= new file mode 100644 index 00000000..db85be68 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest-mergeJavaRes/zip-cache/txIUWUSyIM9u4QmCrxZxzt+TgLQ= differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/compile-file-map.properties b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/compile-file-map.properties new file mode 100644 index 00000000..46383e71 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/compile-file-map.properties @@ -0,0 +1 @@ +#Tue Feb 24 08:47:22 IST 2026 diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v18/values-v18.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v18/values-v18.xml new file mode 100644 index 00000000..ddd5fee5 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v18/values-v18.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v21/values-v21.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v21/values-v21.xml new file mode 100644 index 00000000..e41e1d84 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v21/values-v21.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v28/values-v28.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v28/values-v28.xml new file mode 100644 index 00000000..9e081e92 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values-v28/values-v28.xml @@ -0,0 +1,21 @@ + + + + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values/values.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values/values.xml new file mode 100644 index 00000000..2164d493 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir/values/values.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merger.xml new file mode 100644 index 00000000..061922ad --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merger.xml @@ -0,0 +1,60 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestAssets/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestAssets/merger.xml new file mode 100644 index 00000000..d4508f90 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestAssets/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestJniLibFolders/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestJniLibFolders/merger.xml new file mode 100644 index 00000000..5625536f --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestJniLibFolders/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestShaders/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestShaders/merger.xml new file mode 100644 index 00000000..d00f2e9c --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAndroidTestShaders/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAssets/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAssets/merger.xml new file mode 100644 index 00000000..ab96c846 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugAssets/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml new file mode 100644 index 00000000..d0a0c12b --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugJniLibFolders/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugShaders/merger.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugShaders/merger.xml new file mode 100644 index 00000000..74993506 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/mergeDebugShaders/merger.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/dex-renamer-state.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/dex-renamer-state.txt new file mode 100644 index 00000000..4c1fe8bb --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/dex-renamer-state.txt @@ -0,0 +1,19 @@ +#Tue Feb 24 18:52:33 IST 2026 +base.0=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeExtDexDebug/classes.dex +base.1=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/0/classes.dex +base.2=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/10/classes.dex +base.3=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/13/classes.dex +base.4=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/2/classes.dex +base.5=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debug/mergeProjectDexDebug/9/classes.dex +path.0=classes.dex +path.1=0/classes.dex +path.2=10/classes.dex +path.3=13/classes.dex +path.4=2/classes.dex +path.5=9/classes.dex +renamed.0=classes.dex +renamed.1=classes2.dex +renamed.2=classes3.dex +renamed.3=classes4.dex +renamed.4=classes5.dex +renamed.5=classes6.dex diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/zip-cache/androidResources b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/zip-cache/androidResources new file mode 100644 index 00000000..fa4a43a3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/zip-cache/androidResources differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/zip-cache/javaResources0 b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/zip-cache/javaResources0 new file mode 100644 index 00000000..243cfde1 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebug/tmp/debug/zip-cache/javaResources0 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/dex-renamer-state.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/dex-renamer-state.txt new file mode 100644 index 00000000..9dcfd0f5 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/dex-renamer-state.txt @@ -0,0 +1,10 @@ +#Tue Feb 24 08:47:23 IST 2026 +base.0=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeExtDexDebugAndroidTest/classes.dex +base.1=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeProjectDexDebugAndroidTest/0/classes.dex +base.2=/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/dex/debugAndroidTest/mergeProjectDexDebugAndroidTest/8/classes.dex +path.0=classes.dex +path.1=0/classes.dex +path.2=8/classes.dex +renamed.0=classes.dex +renamed.1=classes2.dex +renamed.2=classes3.dex diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/zip-cache/androidResources b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/zip-cache/androidResources new file mode 100644 index 00000000..eef5cf98 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/zip-cache/androidResources differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/zip-cache/javaResources0 b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/zip-cache/javaResources0 new file mode 100644 index 00000000..945a7c54 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/packageDebugAndroidTest/tmp/debugAndroidTest/zip-cache/javaResources0 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/DeviceLabDriverRunner.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/DeviceLabDriverRunner.class new file mode 100644 index 00000000..0edec688 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/DeviceLabDriverRunner.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/automation/UiAutomationBridge$NodeMatcher.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/automation/UiAutomationBridge$NodeMatcher.class new file mode 100644 index 00000000..70afea1a Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/automation/UiAutomationBridge$NodeMatcher.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/automation/UiAutomationBridge.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/automation/UiAutomationBridge.class new file mode 100644 index 00000000..125cc823 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/automation/UiAutomationBridge.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/DeviceHandler.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/DeviceHandler.class new file mode 100644 index 00000000..84d86491 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/DeviceHandler.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/GestureHandler.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/GestureHandler.class new file mode 100644 index 00000000..7283dc35 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/GestureHandler.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/InputHandler.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/InputHandler.class new file mode 100644 index 00000000..41497bd3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/InputHandler.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/SessionHandler.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/SessionHandler.class new file mode 100644 index 00000000..4eb99830 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/SessionHandler.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/SettingsHandler.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/SettingsHandler.class new file mode 100644 index 00000000..b7ff7cc5 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/SettingsHandler.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/UIHandler.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/UIHandler.class new file mode 100644 index 00000000..794c0449 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/handlers/UIHandler.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/MessageRouter$Handler.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/MessageRouter$Handler.class new file mode 100644 index 00000000..78935038 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/MessageRouter$Handler.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/MessageRouter.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/MessageRouter.class new file mode 100644 index 00000000..b2f7d320 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/MessageRouter.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/Protocol$Request.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/Protocol$Request.class new file mode 100644 index 00000000..cd2d051c Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/Protocol$Request.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/Protocol.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/Protocol.class new file mode 100644 index 00000000..474a88c3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/Protocol.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/WebSocketServer.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/WebSocketServer.class new file mode 100644 index 00000000..b666b8e9 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debug/classes/dev/devicelab/driver/android/server/WebSocketServer.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debugAndroidTest/classes/dev/devicelab/driver/android/test/DeviceLabDriverTest.class b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debugAndroidTest/classes/dev/devicelab/driver/android/test/DeviceLabDriverTest.class new file mode 100644 index 00000000..7215afc7 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/javac/debugAndroidTest/classes/dev/devicelab/driver/android/test/DeviceLabDriverTest.class differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/local_only_symbol_list/debug/R-def.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/local_only_symbol_list/debug/R-def.txt new file mode 100644 index 00000000..78ac5b8b --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/local_only_symbol_list/debug/R-def.txt @@ -0,0 +1,2 @@ +R_DEF: Internal format may change without notice +local diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/manifest_merge_blame_file/debug/manifest-merger-blame-debug-report.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/manifest_merge_blame_file/debug/manifest-merger-blame-debug-report.txt new file mode 100644 index 00000000..50c6c9bf --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/manifest_merge_blame_file/debug/manifest-merger-blame-debug-report.txt @@ -0,0 +1,51 @@ +1 +2 +6 +7 +10 +11 +11-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:4:5-67 +11-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:4:22-64 +12 +12-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:5:5-79 +12-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:5:22-76 +13 +14 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:14:5-16:64 +15 android:name="dev.devicelab.driver.android.DeviceLabDriverRunner" +15-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:15:9-74 +16 android:targetPackage="dev.devicelab.driver.android" /> +16-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:16:9-61 +17 +18 +18-->[androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:24:5-28:15 +19 +19-->[androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:25:9-62 +19-->[androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:25:18-59 +20 +20-->[androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:26:9-58 +20-->[androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:26:18-55 +21 +21-->[androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:27:9-83 +21-->[androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:27:18-80 +22 +23 +24 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:7:5-12:19 +25 android:allowBackup="false" +25-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:8:9-36 +26 android:debuggable="true" +27 android:extractNativeLibs="true" +28 android:label="DeviceLab Driver" > +28-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:9:9-41 +29 +29-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:11:9-60 +29-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:11:23-57 +30 +31 +32 diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/manifest_merge_blame_file/debugAndroidTest/manifest-merger-blame-debug-androidTest-report.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/manifest_merge_blame_file/debugAndroidTest/manifest-merger-blame-debug-androidTest-report.txt new file mode 100644 index 00000000..e8d9f836 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/manifest_merge_blame_file/debugAndroidTest/manifest-merger-blame-debug-androidTest-report.txt @@ -0,0 +1,87 @@ +1 +2 +4 +5 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:5:5-74 +6 android:minSdkVersion="21" +6-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:5:15-41 +7 android:targetSdkVersion="34" /> +7-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:5:42-71 +8 +9 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:11:5-15:78 +10 android:name="dev.devicelab.driver.android.DeviceLabDriverRunner" +10-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:11:22-87 +11 android:functionalTest="false" +11-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:14:22-52 +12 android:handleProfiling="false" +12-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:13:22-53 +13 android:label="Tests for dev.devicelab.driver.android" +13-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:15:22-76 +14 android:targetPackage="dev.devicelab.driver.android" /> +14-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:12:22-74 +15 +16 +16-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:24:5-72 +16-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:24:22-69 +17 +18 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:7:5-9:19 +19 android:debuggable="true" +20 android:extractNativeLibs="true" > +21 +21-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:8:9-60 +21-->/Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/tmp/manifest/androidTest/debug/tempFile1ProcessTestManifest7816489427958627858.xml:8:23-57 +22 +23 [androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:27:9-34:20 +24 android:name="androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity" +24-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:28:13-99 +25 android:exported="true" +25-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:29:13-36 +26 android:theme="@style/WhiteBackgroundTheme" > +26-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:30:13-56 +27 +27-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:31:13-33:29 +27-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:31:28-51 +28 +28-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:32:17-77 +28-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:32:27-74 +29 +30 +31 [androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:35:9-42:20 +32 android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity" +32-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:36:13-95 +33 android:exported="true" +33-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:37:13-36 +34 android:theme="@style/WhiteBackgroundTheme" > +34-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:38:13-56 +35 +35-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:31:13-33:29 +35-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:31:28-51 +36 +36-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:32:17-77 +36-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:32:27-74 +37 +38 +39 [androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:43:9-50:20 +40 android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyFloatingActivity" +40-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:44:13-103 +41 android:exported="true" +41-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:45:13-36 +42 android:theme="@style/WhiteBackgroundDialogTheme" > +42-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:46:13-62 +43 +43-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:31:13-33:29 +43-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:31:28-51 +44 +44-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:32:17-77 +44-->[androidx.test:core:1.5.0] /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/AndroidManifest.xml:32:27-74 +45 +46 +47 +48 +49 diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_java_res/debug/base.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_java_res/debug/base.jar new file mode 100644 index 00000000..39c9e91e Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_java_res/debug/base.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_java_res/debugAndroidTest/feature-agent.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_java_res/debugAndroidTest/feature-agent.jar new file mode 100644 index 00000000..15cb0ecb Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_java_res/debugAndroidTest/feature-agent.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifest/debug/AndroidManifest.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifest/debug/AndroidManifest.xml new file mode 100644 index 00000000..f1ce05b7 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifest/debug/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifests/debug/AndroidManifest.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifests/debug/AndroidManifest.xml new file mode 100644 index 00000000..f1ce05b7 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifests/debug/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifests/debug/output-metadata.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifests/debug/output-metadata.json new file mode 100644 index 00000000..3b3630f6 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_manifests/debug/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "MERGED_MANIFESTS", + "kind": "Directory" + }, + "applicationId": "dev.devicelab.driver.android", + "variantName": "debug", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0.0", + "outputFile": "AndroidManifest.xml" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v18_values-v18.arsc.flat b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v18_values-v18.arsc.flat new file mode 100644 index 00000000..ea0ecf54 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v18_values-v18.arsc.flat differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v21_values-v21.arsc.flat b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v21_values-v21.arsc.flat new file mode 100644 index 00000000..7a7a615a Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v21_values-v21.arsc.flat differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v28_values-v28.arsc.flat b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v28_values-v28.arsc.flat new file mode 100644 index 00000000..b08958f9 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values-v28_values-v28.arsc.flat differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values_values.arsc.flat b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values_values.arsc.flat new file mode 100644 index 00000000..8add2dd9 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest/values_values.arsc.flat differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/debugAndroidTest.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/debugAndroidTest.json new file mode 100644 index 00000000..bc992a45 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/debugAndroidTest.json @@ -0,0 +1,100 @@ +{ + "logs": [ + { + "outputFile": "dev.devicelab.driver.android.test.agent-merged_res-6:/values-v28_values-v28.arsc.flat", + "map": [ + { + "source": "/Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res/values-v28/values.xml", + "from": { + "startLines": "4,13", + "startColumns": "0,0", + "startOffsets": "180,659", + "endLines": "12,21", + "endColumns": "8,8", + "endOffsets": "658,1135" + }, + "to": { + "startLines": "2,11", + "startColumns": "4,4", + "startOffsets": "55,538", + "endLines": "10,19", + "endColumns": "8,8", + "endOffsets": "533,1014" + } + } + ] + }, + { + "outputFile": "dev.devicelab.driver.android.test.agent-merged_res-6:/values_values.arsc.flat", + "map": [ + { + "source": "/Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res/values/values.xml", + "from": { + "startLines": "4,11", + "startColumns": "0,0", + "startOffsets": "176,544", + "endLines": "10,17", + "endColumns": "8,8", + "endOffsets": "543,909" + }, + "to": { + "startLines": "2,9", + "startColumns": "4,4", + "startOffsets": "55,427", + "endLines": "8,15", + "endColumns": "8,8", + "endOffsets": "422,792" + } + } + ] + }, + { + "outputFile": "dev.devicelab.driver.android.test.agent-merged_res-6:/values-v21_values-v21.arsc.flat", + "map": [ + { + "source": "/Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res/values-v21/values.xml", + "from": { + "startLines": "4,13", + "startColumns": "0,0", + "startOffsets": "180,655", + "endLines": "12,21", + "endColumns": "8,8", + "endOffsets": "654,1127" + }, + "to": { + "startLines": "2,11", + "startColumns": "4,4", + "startOffsets": "55,534", + "endLines": "10,19", + "endColumns": "8,8", + "endOffsets": "529,1006" + } + } + ] + }, + { + "outputFile": "dev.devicelab.driver.android.test.agent-merged_res-6:/values-v18_values-v18.arsc.flat", + "map": [ + { + "source": "/Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res/values-v18/values.xml", + "from": { + "startLines": "4,12", + "startColumns": "0,0", + "startOffsets": "180,596", + "endLines": "11,19", + "endColumns": "8,8", + "endOffsets": "595,1009" + }, + "to": { + "startLines": "2,10", + "startColumns": "4,4", + "startOffsets": "55,475", + "endLines": "9,17", + "endColumns": "8,8", + "endOffsets": "470,888" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v18.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v18.json new file mode 100644 index 00000000..e254d11d --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v18.json @@ -0,0 +1,28 @@ +{ + "logs": [ + { + "outputFile": "dev.devicelab.driver.android.test.agent-mergeDebugAndroidTestResources-4:/values-v18/values-v18.xml", + "map": [ + { + "source": "/Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res/values-v18/values.xml", + "from": { + "startLines": "4,12", + "startColumns": "0,0", + "startOffsets": "180,596", + "endLines": "11,19", + "endColumns": "8,8", + "endOffsets": "595,1009" + }, + "to": { + "startLines": "2,10", + "startColumns": "4,4", + "startOffsets": "55,475", + "endLines": "9,17", + "endColumns": "8,8", + "endOffsets": "470,888" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v21.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v21.json new file mode 100644 index 00000000..de7c640a --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v21.json @@ -0,0 +1,28 @@ +{ + "logs": [ + { + "outputFile": "dev.devicelab.driver.android.test.agent-mergeDebugAndroidTestResources-4:/values-v21/values-v21.xml", + "map": [ + { + "source": "/Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res/values-v21/values.xml", + "from": { + "startLines": "4,13", + "startColumns": "0,0", + "startOffsets": "180,655", + "endLines": "12,21", + "endColumns": "8,8", + "endOffsets": "654,1127" + }, + "to": { + "startLines": "2,11", + "startColumns": "4,4", + "startOffsets": "55,534", + "endLines": "10,19", + "endColumns": "8,8", + "endOffsets": "529,1006" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v28.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v28.json new file mode 100644 index 00000000..752a22ac --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values-v28.json @@ -0,0 +1,28 @@ +{ + "logs": [ + { + "outputFile": "dev.devicelab.driver.android.test.agent-mergeDebugAndroidTestResources-4:/values-v28/values-v28.xml", + "map": [ + { + "source": "/Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res/values-v28/values.xml", + "from": { + "startLines": "4,13", + "startColumns": "0,0", + "startOffsets": "180,659", + "endLines": "12,21", + "endColumns": "8,8", + "endOffsets": "658,1135" + }, + "to": { + "startLines": "2,11", + "startColumns": "4,4", + "startOffsets": "55,538", + "endLines": "10,19", + "endColumns": "8,8", + "endOffsets": "533,1014" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values.json new file mode 100644 index 00000000..928ede56 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res_blame_folder/debugAndroidTest/out/multi-v2/values.json @@ -0,0 +1,28 @@ +{ + "logs": [ + { + "outputFile": "dev.devicelab.driver.android.test.agent-mergeDebugAndroidTestResources-4:/values/values.xml", + "map": [ + { + "source": "/Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res/values/values.xml", + "from": { + "startLines": "4,11", + "startColumns": "0,0", + "startOffsets": "176,544", + "endLines": "10,17", + "endColumns": "8,8", + "endOffsets": "543,909" + }, + "to": { + "startLines": "2,9", + "startColumns": "4,4", + "startOffsets": "55,427", + "endLines": "8,15", + "endColumns": "8,8", + "endOffsets": "422,792" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/navigation_json/debug/navigation.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/navigation_json/debug/navigation.json new file mode 100644 index 00000000..0637a088 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/navigation_json/debug/navigation.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debug/AndroidManifest.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debug/AndroidManifest.xml new file mode 100644 index 00000000..f1ce05b7 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debug/AndroidManifest.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debug/output-metadata.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debug/output-metadata.json new file mode 100644 index 00000000..69a31984 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debug/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "PACKAGED_MANIFESTS", + "kind": "Directory" + }, + "applicationId": "dev.devicelab.driver.android", + "variantName": "debug", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0.0", + "outputFile": "AndroidManifest.xml" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debugAndroidTest/AndroidManifest.xml b/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debugAndroidTest/AndroidManifest.xml new file mode 100644 index 00000000..2dbf1882 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debugAndroidTest/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debugAndroidTest/output-metadata.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debugAndroidTest/output-metadata.json new file mode 100644 index 00000000..31c148a0 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/packaged_manifests/debugAndroidTest/output-metadata.json @@ -0,0 +1,18 @@ +{ + "version": 3, + "artifactType": { + "type": "PACKAGED_MANIFESTS", + "kind": "Directory" + }, + "applicationId": "dev.devicelab.driver.android.test", + "variantName": "debugAndroidTest", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "outputFile": "AndroidManifest.xml" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debug/out/output-metadata.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debug/out/output-metadata.json new file mode 100644 index 00000000..fa5bd21e --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debug/out/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "PROCESSED_RES", + "kind": "Directory" + }, + "applicationId": "dev.devicelab.driver.android", + "variantName": "debug", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0.0", + "outputFile": "resources-debug.ap_" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debug/out/resources-debug.ap_ b/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debug/out/resources-debug.ap_ new file mode 100644 index 00000000..8d410c14 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debug/out/resources-debug.ap_ differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debugAndroidTest/out/output-metadata.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debugAndroidTest/out/output-metadata.json new file mode 100644 index 00000000..cf592a1e --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debugAndroidTest/out/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "PROCESSED_RES", + "kind": "Directory" + }, + "applicationId": "dev.devicelab.driver.android.test", + "variantName": "debugAndroidTest", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 0, + "versionName": "", + "outputFile": "resources.ap_" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debugAndroidTest/out/resources.ap_ b/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debugAndroidTest/out/resources.ap_ new file mode 100644 index 00000000..9a6d8e52 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/processed_res/debugAndroidTest/out/resources.ap_ differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_0.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_0.jar new file mode 100644 index 00000000..e13d087d Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_0.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_1.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_1.jar new file mode 100644 index 00000000..7c9ef57f Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_1.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_2.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_2.jar new file mode 100644 index 00000000..ce0a5204 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_2.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_3.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_3.jar new file mode 100644 index 00000000..dc24fb1b Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_3.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_4.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_4.jar new file mode 100644 index 00000000..cd7e2636 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_4.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_5.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_5.jar new file mode 100644 index 00000000..f3b34ceb Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/9040ac348bd38617164eb013a2804580d563d286e9d84b38afe34bca353e2d3a_5.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/DeviceLabDriverRunner.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/DeviceLabDriverRunner.dex new file mode 100644 index 00000000..b46dba42 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/DeviceLabDriverRunner.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/automation/UiAutomationBridge$NodeMatcher.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/automation/UiAutomationBridge$NodeMatcher.dex new file mode 100644 index 00000000..366280fd Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/automation/UiAutomationBridge$NodeMatcher.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/automation/UiAutomationBridge.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/automation/UiAutomationBridge.dex new file mode 100644 index 00000000..52820e75 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/automation/UiAutomationBridge.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/DeviceHandler.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/DeviceHandler.dex new file mode 100644 index 00000000..76dee9f4 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/DeviceHandler.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/GestureHandler.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/GestureHandler.dex new file mode 100644 index 00000000..b13ad3f8 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/GestureHandler.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/InputHandler.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/InputHandler.dex new file mode 100644 index 00000000..6bf9d288 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/InputHandler.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/SessionHandler.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/SessionHandler.dex new file mode 100644 index 00000000..5b4c6577 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/SessionHandler.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/SettingsHandler.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/SettingsHandler.dex new file mode 100644 index 00000000..5a6f72f8 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/SettingsHandler.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/UIHandler.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/UIHandler.dex new file mode 100644 index 00000000..69ae714c Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/handlers/UIHandler.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/MessageRouter$Handler.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/MessageRouter$Handler.dex new file mode 100644 index 00000000..cecb40a3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/MessageRouter$Handler.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/MessageRouter.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/MessageRouter.dex new file mode 100644 index 00000000..ab7bfaaa Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/MessageRouter.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/Protocol$Request.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/Protocol$Request.dex new file mode 100644 index 00000000..f3218c03 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/Protocol$Request.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/Protocol.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/Protocol.dex new file mode 100644 index 00000000..ec3cf1c3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/Protocol.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/WebSocketServer.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/WebSocketServer.dex new file mode 100644 index 00000000..c321e4ce Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debug/out/dev/devicelab/driver/android/server/WebSocketServer.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_1.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_1.jar new file mode 100644 index 00000000..e66a3ff2 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_1.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_2.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_2.jar new file mode 100644 index 00000000..7c3ebfaa Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_2.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_5.jar b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_5.jar new file mode 100644 index 00000000..b79830e8 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/b17db910a3821220c65d068dc0f775d9a65e6e5032d870a891c6ee7e731127e1_5.jar differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/dev/devicelab/driver/android/test/DeviceLabDriverTest.dex b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/dev/devicelab/driver/android/test/DeviceLabDriverTest.dex new file mode 100644 index 00000000..d8344918 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/intermediates/project_dex_archive/debugAndroidTest/out/dev/devicelab/driver/android/test/DeviceLabDriverTest.dex differ diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/runtime_symbol_list/debug/R.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/runtime_symbol_list/debug/R.txt new file mode 100644 index 00000000..e69de29b diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/runtime_symbol_list/debugAndroidTest/R.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/runtime_symbol_list/debugAndroidTest/R.txt new file mode 100644 index 00000000..77b1dfd0 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/runtime_symbol_list/debugAndroidTest/R.txt @@ -0,0 +1,2 @@ +int style WhiteBackgroundDialogTheme 0x7f010000 +int style WhiteBackgroundTheme 0x7f010001 diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/signing_config_versions/debug/signing-config-versions.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/signing_config_versions/debug/signing-config-versions.json new file mode 100644 index 00000000..bb4deaa8 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/signing_config_versions/debug/signing-config-versions.json @@ -0,0 +1 @@ +{"enableV1Signing":true,"enableV2Signing":true,"enableV3Signing":false,"enableV4Signing":false} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/signing_config_versions/debugAndroidTest/signing-config-versions.json b/drivers/android/devicelab-android-driver/agent/build/intermediates/signing_config_versions/debugAndroidTest/signing-config-versions.json new file mode 100644 index 00000000..bb4deaa8 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/signing_config_versions/debugAndroidTest/signing-config-versions.json @@ -0,0 +1 @@ +{"enableV1Signing":true,"enableV2Signing":true,"enableV3Signing":false,"enableV4Signing":false} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/source_set_path_map/debug/file-map.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/source_set_path_map/debug/file-map.txt new file mode 100644 index 00000000..c66dfec7 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/source_set_path_map/debug/file-map.txt @@ -0,0 +1,10 @@ +dev.devicelab.driver.android.agent-tracing-1.1.0-0 /Users/omnarayan/.gradle/caches/transforms-3/3ef51527abdafe69403c52018cb6743f/transformed/tracing-1.1.0/res +dev.devicelab.driver.android.agent-annotation-experimental-1.1.0-1 /Users/omnarayan/.gradle/caches/transforms-3/7665749b5972d47a97a64d1a1c2bf5d5/transformed/annotation-experimental-1.1.0/res +dev.devicelab.driver.android.agent-uiautomator-2.3.0-2 /Users/omnarayan/.gradle/caches/transforms-3/ea71cbba1e14911000e15f95ef259f67/transformed/uiautomator-2.3.0/res +dev.devicelab.driver.android.agent-pngs-3 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/generated/res/pngs/debug +dev.devicelab.driver.android.agent-resValues-4 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/generated/res/resValues/debug +dev.devicelab.driver.android.agent-packageDebugResources-5 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/packageDebugResources/merged.dir +dev.devicelab.driver.android.agent-packageDebugResources-6 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debug/packageDebugResources/stripped.dir +dev.devicelab.driver.android.agent-merged_res-7 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debug +dev.devicelab.driver.android.agent-debug-8 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/debug/res +dev.devicelab.driver.android.agent-main-9 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/res diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/source_set_path_map/debugAndroidTest/file-map.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/source_set_path_map/debugAndroidTest/file-map.txt new file mode 100644 index 00000000..31805db0 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/source_set_path_map/debugAndroidTest/file-map.txt @@ -0,0 +1,7 @@ +dev.devicelab.driver.android.test.agent-tracing-1.1.0-0 /Users/omnarayan/.gradle/caches/transforms-3/3ef51527abdafe69403c52018cb6743f/transformed/tracing-1.1.0/res +dev.devicelab.driver.android.test.agent-annotation-experimental-1.1.0-1 /Users/omnarayan/.gradle/caches/transforms-3/7665749b5972d47a97a64d1a1c2bf5d5/transformed/annotation-experimental-1.1.0/res +dev.devicelab.driver.android.test.agent-core-1.5.0-2 /Users/omnarayan/.gradle/caches/transforms-3/e67ad194fe294fda38ca2cda940df44c/transformed/core-1.5.0/res +dev.devicelab.driver.android.test.agent-androidTest-3 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/generated/res/resValues/androidTest/debug +dev.devicelab.driver.android.test.agent-mergeDebugAndroidTestResources-4 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/merged.dir +dev.devicelab.driver.android.test.agent-mergeDebugAndroidTestResources-5 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/incremental/debugAndroidTest/mergeDebugAndroidTestResources/stripped.dir +dev.devicelab.driver.android.test.agent-merged_res-6 /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/build/intermediates/merged_res/debugAndroidTest diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/stable_resource_ids_file/debug/stableIds.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/stable_resource_ids_file/debug/stableIds.txt new file mode 100644 index 00000000..e69de29b diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/stable_resource_ids_file/debugAndroidTest/stableIds.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/stable_resource_ids_file/debugAndroidTest/stableIds.txt new file mode 100644 index 00000000..41dcdc64 --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/stable_resource_ids_file/debugAndroidTest/stableIds.txt @@ -0,0 +1,2 @@ +dev.devicelab.driver.android.test:style/WhiteBackgroundTheme = 0x7f010001 +dev.devicelab.driver.android.test:style/WhiteBackgroundDialogTheme = 0x7f010000 diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/symbol_list_with_package_name/debug/package-aware-r.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/symbol_list_with_package_name/debug/package-aware-r.txt new file mode 100644 index 00000000..07d0a52e --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/symbol_list_with_package_name/debug/package-aware-r.txt @@ -0,0 +1 @@ +dev.devicelab.driver.android diff --git a/drivers/android/devicelab-android-driver/agent/build/intermediates/symbol_list_with_package_name/debugAndroidTest/package-aware-r.txt b/drivers/android/devicelab-android-driver/agent/build/intermediates/symbol_list_with_package_name/debugAndroidTest/package-aware-r.txt new file mode 100644 index 00000000..560a003b --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/intermediates/symbol_list_with_package_name/debugAndroidTest/package-aware-r.txt @@ -0,0 +1,3 @@ +dev.devicelab.driver.android.test +style WhiteBackgroundDialogTheme +style WhiteBackgroundTheme diff --git a/drivers/android/devicelab-android-driver/agent/build/outputs/apk/androidTest/debug/agent-debug-androidTest.apk b/drivers/android/devicelab-android-driver/agent/build/outputs/apk/androidTest/debug/agent-debug-androidTest.apk new file mode 100644 index 00000000..1ac6b4ad Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/outputs/apk/androidTest/debug/agent-debug-androidTest.apk differ diff --git a/drivers/android/devicelab-android-driver/agent/build/outputs/apk/androidTest/debug/output-metadata.json b/drivers/android/devicelab-android-driver/agent/build/outputs/apk/androidTest/debug/output-metadata.json new file mode 100644 index 00000000..597d263e --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/outputs/apk/androidTest/debug/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "dev.devicelab.driver.android.test", + "variantName": "debugAndroidTest", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 0, + "versionName": "", + "outputFile": "agent-debug-androidTest.apk" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/outputs/apk/debug/agent-debug.apk b/drivers/android/devicelab-android-driver/agent/build/outputs/apk/debug/agent-debug.apk new file mode 100644 index 00000000..e8065020 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/outputs/apk/debug/agent-debug.apk differ diff --git a/drivers/android/devicelab-android-driver/agent/build/outputs/apk/debug/output-metadata.json b/drivers/android/devicelab-android-driver/agent/build/outputs/apk/debug/output-metadata.json new file mode 100644 index 00000000..2e27b71f --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/outputs/apk/debug/output-metadata.json @@ -0,0 +1,20 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "dev.devicelab.driver.android", + "variantName": "debug", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 1, + "versionName": "1.0.0", + "outputFile": "agent-debug.apk" + } + ], + "elementType": "File" +} \ No newline at end of file diff --git a/drivers/android/devicelab-android-driver/agent/build/outputs/logs/manifest-merger-debug-report.txt b/drivers/android/devicelab-android-driver/agent/build/outputs/logs/manifest-merger-debug-report.txt new file mode 100644 index 00000000..68ee4fbf --- /dev/null +++ b/drivers/android/devicelab-android-driver/agent/build/outputs/logs/manifest-merger-debug-report.txt @@ -0,0 +1,86 @@ +-- Merging decision tree log --- +manifest +ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:2:1-17:12 +INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:2:1-17:12 +INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:2:1-17:12 +INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:2:1-17:12 +MERGED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:17:1-32:12 +MERGED from [androidx.test.uiautomator:uiautomator:2.3.0] /Users/omnarayan/.gradle/caches/transforms-3/ea71cbba1e14911000e15f95ef259f67/transformed/uiautomator-2.3.0/AndroidManifest.xml:2:1-7:12 +MERGED from [androidx.test.services:storage:1.4.2] /Users/omnarayan/.gradle/caches/transforms-3/84ba689f8588b0c9124eb01ac084d903/transformed/storage-1.4.2/AndroidManifest.xml:17:1-24:12 +MERGED from [androidx.test:monitor:1.6.1] /Users/omnarayan/.gradle/caches/transforms-3/f0c4ffb1d0001c571c08de318540ef27/transformed/monitor-1.6.1/AndroidManifest.xml:17:1-24:12 +MERGED from [androidx.test:annotation:1.0.1] /Users/omnarayan/.gradle/caches/transforms-3/0206d03c91d9bbe95cd210bce62c1ce8/transformed/annotation-1.0.1/AndroidManifest.xml:17:1-24:12 +MERGED from [androidx.tracing:tracing:1.1.0] /Users/omnarayan/.gradle/caches/transforms-3/3ef51527abdafe69403c52018cb6743f/transformed/tracing-1.1.0/AndroidManifest.xml:17:1-24:12 +MERGED from [androidx.annotation:annotation-experimental:1.1.0] /Users/omnarayan/.gradle/caches/transforms-3/7665749b5972d47a97a64d1a1c2bf5d5/transformed/annotation-experimental-1.1.0/AndroidManifest.xml:17:1-24:12 + package + INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml + android:versionName + INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml + android:versionCode + INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml + xmlns:android + ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:2:11-69 +uses-permission#android.permission.INTERNET +ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:4:5-67 + android:name + ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:4:22-64 +uses-permission#android.permission.ACCESS_NETWORK_STATE +ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:5:5-79 + android:name + ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:5:22-76 +application +ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:7:5-12:19 +INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:7:5-12:19 +MERGED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:30:5-20 +MERGED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:30:5-20 + android:extractNativeLibs + INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml + android:label + ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:9:9-41 + android:allowBackup + ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:8:9-36 +uses-library#android.test.runner +ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:11:9-60 + android:name + ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:11:23-57 +instrumentation +ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:14:5-16:64 + android:targetPackage + ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:16:9-61 + android:name + ADDED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml:15:9-74 +uses-sdk +INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml reason: use-sdk injection requested +INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml +INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml +MERGED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.test.uiautomator:uiautomator:2.3.0] /Users/omnarayan/.gradle/caches/transforms-3/ea71cbba1e14911000e15f95ef259f67/transformed/uiautomator-2.3.0/AndroidManifest.xml:5:5-44 +MERGED from [androidx.test.uiautomator:uiautomator:2.3.0] /Users/omnarayan/.gradle/caches/transforms-3/ea71cbba1e14911000e15f95ef259f67/transformed/uiautomator-2.3.0/AndroidManifest.xml:5:5-44 +MERGED from [androidx.test.services:storage:1.4.2] /Users/omnarayan/.gradle/caches/transforms-3/84ba689f8588b0c9124eb01ac084d903/transformed/storage-1.4.2/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.test.services:storage:1.4.2] /Users/omnarayan/.gradle/caches/transforms-3/84ba689f8588b0c9124eb01ac084d903/transformed/storage-1.4.2/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.test:monitor:1.6.1] /Users/omnarayan/.gradle/caches/transforms-3/f0c4ffb1d0001c571c08de318540ef27/transformed/monitor-1.6.1/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.test:monitor:1.6.1] /Users/omnarayan/.gradle/caches/transforms-3/f0c4ffb1d0001c571c08de318540ef27/transformed/monitor-1.6.1/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.test:annotation:1.0.1] /Users/omnarayan/.gradle/caches/transforms-3/0206d03c91d9bbe95cd210bce62c1ce8/transformed/annotation-1.0.1/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.test:annotation:1.0.1] /Users/omnarayan/.gradle/caches/transforms-3/0206d03c91d9bbe95cd210bce62c1ce8/transformed/annotation-1.0.1/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.tracing:tracing:1.1.0] /Users/omnarayan/.gradle/caches/transforms-3/3ef51527abdafe69403c52018cb6743f/transformed/tracing-1.1.0/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.tracing:tracing:1.1.0] /Users/omnarayan/.gradle/caches/transforms-3/3ef51527abdafe69403c52018cb6743f/transformed/tracing-1.1.0/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.annotation:annotation-experimental:1.1.0] /Users/omnarayan/.gradle/caches/transforms-3/7665749b5972d47a97a64d1a1c2bf5d5/transformed/annotation-experimental-1.1.0/AndroidManifest.xml:20:5-22:41 +MERGED from [androidx.annotation:annotation-experimental:1.1.0] /Users/omnarayan/.gradle/caches/transforms-3/7665749b5972d47a97a64d1a1c2bf5d5/transformed/annotation-experimental-1.1.0/AndroidManifest.xml:20:5-22:41 + android:targetSdkVersion + INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml + android:minSdkVersion + INJECTED from /Users/omnarayan/work/go/src/maestro-runner/drivers/android/devicelab-android-driver/agent/src/main/AndroidManifest.xml +queries +ADDED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:24:5-28:15 +package#androidx.test.orchestrator +ADDED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:25:9-62 + android:name + ADDED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:25:18-59 +package#androidx.test.services +ADDED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:26:9-58 + android:name + ADDED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:26:18-55 +package#com.google.android.apps.common.testing.services +ADDED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:27:9-83 + android:name + ADDED from [androidx.test:runner:1.5.2] /Users/omnarayan/.gradle/caches/transforms-3/e3aadfa7130535ea5a1befba6f24cd0d/transformed/runner-1.5.2/AndroidManifest.xml:27:18-80 diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugAndroidTestJavaWithJavac/previous-compilation-data.bin b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugAndroidTestJavaWithJavac/previous-compilation-data.bin new file mode 100644 index 00000000..8b713325 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugAndroidTestJavaWithJavac/previous-compilation-data.bin differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/DeviceHandler.class.uniqueId2 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/DeviceHandler.class.uniqueId2 new file mode 100644 index 00000000..84d86491 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/DeviceHandler.class.uniqueId2 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/DeviceLabDriverRunner.class.uniqueId0 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/DeviceLabDriverRunner.class.uniqueId0 new file mode 100644 index 00000000..0edec688 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/DeviceLabDriverRunner.class.uniqueId0 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/GestureHandler.class.uniqueId4 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/GestureHandler.class.uniqueId4 new file mode 100644 index 00000000..7283dc35 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/GestureHandler.class.uniqueId4 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/InputHandler.class.uniqueId1 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/InputHandler.class.uniqueId1 new file mode 100644 index 00000000..41497bd3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/InputHandler.class.uniqueId1 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SessionHandler.class.uniqueId6 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SessionHandler.class.uniqueId6 new file mode 100644 index 00000000..4eb99830 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SessionHandler.class.uniqueId6 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SettingsHandler.class.uniqueId5 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SettingsHandler.class.uniqueId5 new file mode 100644 index 00000000..b7ff7cc5 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/SettingsHandler.class.uniqueId5 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UIHandler.class.uniqueId3 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UIHandler.class.uniqueId3 new file mode 100644 index 00000000..794c0449 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UIHandler.class.uniqueId3 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UiAutomationBridge$NodeMatcher.class.uniqueId7 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UiAutomationBridge$NodeMatcher.class.uniqueId7 new file mode 100644 index 00000000..70afea1a Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UiAutomationBridge$NodeMatcher.class.uniqueId7 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UiAutomationBridge.class.uniqueId8 b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UiAutomationBridge.class.uniqueId8 new file mode 100644 index 00000000..efa6bcd3 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/compileTransaction/stash-dir/UiAutomationBridge.class.uniqueId8 differ diff --git a/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin new file mode 100644 index 00000000..7657fe23 Binary files /dev/null and b/drivers/android/devicelab-android-driver/agent/build/tmp/compileDebugJavaWithJavac/previous-compilation-data.bin differ diff --git a/drivers/android/devicelab-android-driver/gradle/wrapper/gradle-wrapper.jar b/drivers/android/devicelab-android-driver/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000..d64cd491 Binary files /dev/null and b/drivers/android/devicelab-android-driver/gradle/wrapper/gradle-wrapper.jar differ diff --git a/drivers/android/devicelab-android-driver/gradle/wrapper/gradle-wrapper.properties b/drivers/android/devicelab-android-driver/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..1af9e093 --- /dev/null +++ b/drivers/android/devicelab-android-driver/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/drivers/android/devicelab-android-driver/gradlew b/drivers/android/devicelab-android-driver/gradlew new file mode 100755 index 00000000..97de990b --- /dev/null +++ b/drivers/android/devicelab-android-driver/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/drivers/android/devicelab-android-driver/gradlew.bat b/drivers/android/devicelab-android-driver/gradlew.bat new file mode 100644 index 00000000..ea603b41 --- /dev/null +++ b/drivers/android/devicelab-android-driver/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/drivers/android/devicelab-android-driver/local.properties b/drivers/android/devicelab-android-driver/local.properties new file mode 100644 index 00000000..67dadfd0 --- /dev/null +++ b/drivers/android/devicelab-android-driver/local.properties @@ -0,0 +1 @@ +sdk.dir=/Users/omnarayan/work/tool/android diff --git a/drivers/ios/DeviceLabDriver/DeviceLabDriver.xcodeproj/project.pbxproj b/drivers/ios/DeviceLabDriver/DeviceLabDriver.xcodeproj/project.pbxproj new file mode 100644 index 00000000..b603caae --- /dev/null +++ b/drivers/ios/DeviceLabDriver/DeviceLabDriver.xcodeproj/project.pbxproj @@ -0,0 +1,319 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A1000001 /* DeviceLabDriverRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000001 /* DeviceLabDriverRunner.swift */; }; + A1000002 /* WebSocketServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000002 /* WebSocketServer.swift */; }; + A1000003 /* RequestRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000003 /* RequestRouter.swift */; }; + A1000004 /* Protocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000004 /* Protocol.swift */; }; + A1000005 /* SessionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000005 /* SessionHandler.swift */; }; + A1000006 /* ElementHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000006 /* ElementHandler.swift */; }; + A1000007 /* GestureHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000007 /* GestureHandler.swift */; }; + A1000008 /* InputHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000008 /* InputHandler.swift */; }; + A1000009 /* AppHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1000009 /* AppHandler.swift */; }; + A100000A /* SettingsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000A /* SettingsHandler.swift */; }; + A100000B /* SnapshotManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000B /* SnapshotManager.swift */; }; + A100000C /* ElementTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000C /* ElementTree.swift */; }; + A100000D /* ElementModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B100000D /* ElementModel.swift */; }; + A100000E /* ObjCExceptionCatcher.m in Sources */ = {isa = PBXBuildFile; fileRef = B100000F /* ObjCExceptionCatcher.m */; }; + A100000F /* EventSynthesizer.m in Sources */ = {isa = PBXBuildFile; fileRef = B1000012 /* EventSynthesizer.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + B1000001 /* DeviceLabDriverRunner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLabDriverRunner.swift; sourceTree = ""; }; + B1000002 /* WebSocketServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSocketServer.swift; sourceTree = ""; }; + B1000003 /* RequestRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestRouter.swift; sourceTree = ""; }; + B1000004 /* Protocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Protocol.swift; sourceTree = ""; }; + B1000005 /* SessionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionHandler.swift; sourceTree = ""; }; + B1000006 /* ElementHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementHandler.swift; sourceTree = ""; }; + B1000007 /* GestureHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GestureHandler.swift; sourceTree = ""; }; + B1000008 /* InputHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputHandler.swift; sourceTree = ""; }; + B1000009 /* AppHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppHandler.swift; sourceTree = ""; }; + B100000A /* SettingsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsHandler.swift; sourceTree = ""; }; + B100000B /* SnapshotManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnapshotManager.swift; sourceTree = ""; }; + B100000C /* ElementTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementTree.swift; sourceTree = ""; }; + B100000D /* ElementModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ElementModel.swift; sourceTree = ""; }; + B100000E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + B100000F /* ObjCExceptionCatcher.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ObjCExceptionCatcher.m; sourceTree = ""; }; + B1000010 /* ObjCExceptionCatcher.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ObjCExceptionCatcher.h; sourceTree = ""; }; + B1000011 /* DeviceLabDriver-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "DeviceLabDriver-Bridging-Header.h"; sourceTree = ""; }; + B1000012 /* EventSynthesizer.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventSynthesizer.m; sourceTree = ""; }; + B1000013 /* EventSynthesizer.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventSynthesizer.h; sourceTree = ""; }; + C1000001 /* DeviceLabDriverRunner.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DeviceLabDriverRunner.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + D1000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + E1000001 /* Root */ = { + isa = PBXGroup; + children = ( + E1000002 /* DeviceLabDriverRunner */, + E1000003 /* Sources */, + E1000009 /* Products */, + ); + sourceTree = ""; + }; + E1000002 /* DeviceLabDriverRunner */ = { + isa = PBXGroup; + children = ( + B1000001 /* DeviceLabDriverRunner.swift */, + B100000E /* Info.plist */, + ); + path = DeviceLabDriverRunner; + sourceTree = ""; + }; + E1000003 /* Sources */ = { + isa = PBXGroup; + children = ( + E1000004 /* Server */, + E1000005 /* Handlers */, + E1000006 /* Core */, + ); + path = Sources; + sourceTree = ""; + }; + E1000004 /* Server */ = { + isa = PBXGroup; + children = ( + B1000002 /* WebSocketServer.swift */, + B1000003 /* RequestRouter.swift */, + B1000004 /* Protocol.swift */, + ); + path = Server; + sourceTree = ""; + }; + E1000005 /* Handlers */ = { + isa = PBXGroup; + children = ( + B1000005 /* SessionHandler.swift */, + B1000006 /* ElementHandler.swift */, + B1000007 /* GestureHandler.swift */, + B1000008 /* InputHandler.swift */, + B1000009 /* AppHandler.swift */, + B100000A /* SettingsHandler.swift */, + ); + path = Handlers; + sourceTree = ""; + }; + E1000006 /* Core */ = { + isa = PBXGroup; + children = ( + B100000B /* SnapshotManager.swift */, + B100000C /* ElementTree.swift */, + B100000D /* ElementModel.swift */, + B1000010 /* ObjCExceptionCatcher.h */, + B100000F /* ObjCExceptionCatcher.m */, + B1000011 /* DeviceLabDriver-Bridging-Header.h */, + B1000013 /* EventSynthesizer.h */, + B1000012 /* EventSynthesizer.m */, + ); + path = Core; + sourceTree = ""; + }; + E1000009 /* Products */ = { + isa = PBXGroup; + children = ( + C1000001 /* DeviceLabDriverRunner.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + F1000001 /* DeviceLabDriverRunner */ = { + isa = PBXNativeTarget; + buildConfigurationList = G1000003 /* Build configuration list for PBXNativeTarget "DeviceLabDriverRunner" */; + buildPhases = ( + F1000002 /* Sources */, + D1000001 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = DeviceLabDriverRunner; + productName = DeviceLabDriverRunner; + productReference = C1000001 /* DeviceLabDriverRunner.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + F1000010 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + }; + buildConfigurationList = G1000001 /* Build configuration list for PBXProject "DeviceLabDriver" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = E1000001 /* Root */; + productRefGroup = E1000009 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F1000001 /* DeviceLabDriverRunner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + F1000002 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A1000001 /* DeviceLabDriverRunner.swift in Sources */, + A1000002 /* WebSocketServer.swift in Sources */, + A1000003 /* RequestRouter.swift in Sources */, + A1000004 /* Protocol.swift in Sources */, + A1000005 /* SessionHandler.swift in Sources */, + A1000006 /* ElementHandler.swift in Sources */, + A1000007 /* GestureHandler.swift in Sources */, + A1000008 /* InputHandler.swift in Sources */, + A1000009 /* AppHandler.swift in Sources */, + A100000A /* SettingsHandler.swift in Sources */, + A100000B /* SnapshotManager.swift in Sources */, + A100000C /* ElementTree.swift in Sources */, + A100000D /* ElementModel.swift in Sources */, + A100000E /* ObjCExceptionCatcher.m in Sources */, + A100000F /* EventSynthesizer.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + H1000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_DYNAMIC_NO_PIC = NO; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + H1000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + ONLY_ACTIVE_ARCH = NO; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + H1000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DeviceLabDriverRunner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + PRODUCT_BUNDLE_IDENTIFIER = com.devicelab.DeviceLabDriverRunner; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/Core/DeviceLabDriver-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ""; + }; + name = Debug; + }; + H1000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DeviceLabDriverRunner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + PRODUCT_BUNDLE_IDENTIFIER = com.devicelab.DeviceLabDriverRunner; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Sources/Core/DeviceLabDriver-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ""; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + G1000001 /* Build configuration list for PBXProject "DeviceLabDriver" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + H1000001 /* Debug */, + H1000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + G1000003 /* Build configuration list for PBXNativeTarget "DeviceLabDriverRunner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + H1000003 /* Debug */, + H1000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + + }; + rootObject = F1000010 /* Project object */; +} diff --git a/drivers/ios/DeviceLabDriver/DeviceLabDriver.xcodeproj/xcshareddata/xcschemes/DeviceLabDriverRunner.xcscheme b/drivers/ios/DeviceLabDriver/DeviceLabDriver.xcodeproj/xcshareddata/xcschemes/DeviceLabDriverRunner.xcscheme new file mode 100644 index 00000000..3b66d3a3 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/DeviceLabDriver.xcodeproj/xcshareddata/xcschemes/DeviceLabDriverRunner.xcscheme @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/drivers/ios/DeviceLabDriver/DeviceLabDriverRunner/DeviceLabDriverRunner.swift b/drivers/ios/DeviceLabDriver/DeviceLabDriverRunner/DeviceLabDriverRunner.swift new file mode 100644 index 00000000..a6bfe68d --- /dev/null +++ b/drivers/ios/DeviceLabDriver/DeviceLabDriverRunner/DeviceLabDriverRunner.swift @@ -0,0 +1,98 @@ +import XCTest + +/// XCTest UI test runner that starts a WebSocket server and blocks forever. +/// The Go host communicates with this process via WebSocket RPCs. +final class DeviceLabDriverRunner: XCTestCase { + + /// Main test method — starts the WS server and blocks until terminated. + func testRunner() throws { + // Critical: allow the test to continue after XCTest assertion failures. + // Without this, errors like "Neither element nor any descendant has keyboard focus" + // from typeText() would kill the entire test runner process. + continueAfterFailure = true + + let port = serverPort() + NSLog("[DeviceLabDriver] Starting on port \(port)") + + // Use Settings app as a dummy — it will be replaced on Session.create with the actual app. + // We must use an explicit bundleIdentifier: the no-arg XCUIApplication() + // requires UITargetAppBundleIdentifier in the xctestrun plist. + let dummyApp = XCUIApplication(bundleIdentifier: "com.apple.Preferences") + let snapshotManager = SnapshotManager(app: dummyApp) + + // Create handlers + let sessionHandler = SessionHandler(snapshotManager: snapshotManager) + let elementHandler = ElementHandler(snapshotManager: snapshotManager, sessionHandler: sessionHandler) + let gestureHandler = GestureHandler(snapshotManager: snapshotManager, sessionHandler: sessionHandler) + let inputHandler = InputHandler(snapshotManager: snapshotManager, sessionHandler: sessionHandler) + let appHandler = AppHandler(snapshotManager: snapshotManager, sessionHandler: sessionHandler) + let settingsHandler = SettingsHandler(sessionHandler: sessionHandler) + + // Create router + let router = RequestRouter( + session: sessionHandler, + element: elementHandler, + gesture: gestureHandler, + input: inputHandler, + app: appHandler, + settings: settingsHandler + ) + + // Create and start WebSocket server + let server = WebSocketServer(port: port, router: router) + server.onReady = { + NSLog("[DeviceLabDriver] WebSocket client connected — ready for commands") + } + + do { + try server.start() + } catch { + XCTFail("Failed to start WebSocket server: \(error)") + return + } + + NSLog("[DeviceLabDriver] ServerURLHere->ws://127.0.0.1:\(port)/ws") + + // Setup UI interruption monitor for permission alerts + addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in + let action = sessionHandler.alertAction + if action == "accept" { + let allowWhile = alert.buttons["Allow While Using App"] + let allow = alert.buttons["Allow"] + let ok = alert.buttons["OK"] + if allowWhile.exists { allowWhile.tap(); return true } + if allow.exists { allow.tap(); return true } + if ok.exists { ok.tap(); return true } + let buttons = alert.buttons + if buttons.count > 0 { + buttons.element(boundBy: buttons.count - 1).tap() + return true + } + } else if action == "dismiss" { + let dontAllow = alert.buttons["Don\u{2019}t Allow"] + let cancel = alert.buttons["Cancel"] + if dontAllow.exists { dontAllow.tap(); return true } + if cancel.exists { cancel.tap(); return true } + alert.buttons.element(boundBy: 0).tap() + return true + } + return false + } + + // Keep the main thread alive using the run loop. This allows + // DispatchQueue.main.async blocks (from the request router) to + // execute — XCTest APIs must be called on the main thread. + // A semaphore.wait() would block main and starve those dispatches. + RunLoop.main.run() + } + + /// Read the server port from the USE_PORT environment variable. + /// Injected into the xctestrun plist by the Go runner. + private func serverPort() -> UInt16 { + if let portStr = ProcessInfo.processInfo.environment["USE_PORT"], + let port = UInt16(portStr) { + return port + } + return 9100 // default + } +} diff --git a/drivers/ios/DeviceLabDriver/DeviceLabDriverRunner/Info.plist b/drivers/ios/DeviceLabDriver/DeviceLabDriverRunner/Info.plist new file mode 100644 index 00000000..6c6c23c4 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/DeviceLabDriverRunner/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/drivers/ios/DeviceLabDriver/Sources/Core/DeviceLabDriver-Bridging-Header.h b/drivers/ios/DeviceLabDriver/Sources/Core/DeviceLabDriver-Bridging-Header.h new file mode 100644 index 00000000..f9572df3 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Core/DeviceLabDriver-Bridging-Header.h @@ -0,0 +1,2 @@ +#import "ObjCExceptionCatcher.h" +#import "EventSynthesizer.h" diff --git a/drivers/ios/DeviceLabDriver/Sources/Core/ElementModel.swift b/drivers/ios/DeviceLabDriver/Sources/Core/ElementModel.swift new file mode 100644 index 00000000..723a065a --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Core/ElementModel.swift @@ -0,0 +1,66 @@ +import Foundation +import XCTest + +/// Flattened representation of a UI element from the accessibility snapshot. +struct ElementModel { + let elementType: XCUIElement.ElementType + let className: String // e.g. "XCUIElementTypeButton" + let label: String + let value: String + let identifier: String // accessibilityIdentifier + let placeholderValue: String + let isEnabled: Bool + let isSelected: Bool + let hasFocus: Bool + let frame: CGRect // screen-relative bounds + let children: [ElementModel] + + /// Whether the element is likely visible (non-zero frame, on screen). + var isDisplayed: Bool { + return frame.width > 0 && frame.height > 0 + } + + /// Primary text: label, value, or identifier. + var text: String { + if !label.isEmpty { return label } + if !value.isEmpty { return value } + return identifier + } + + /// Whether this element type is typically interactive/clickable. + var isClickable: Bool { + switch elementType { + case .button, .link, .cell, .switch, .toggle, + .textField, .secureTextField, .searchField, + .slider, .stepper, .segmentedControl, + .tab, .tabBar, .picker, .datePicker, + .menuItem, .menu, .popUpButton: + return true + default: + return false + } + } + + /// Convert to JSON-serializable dictionary for the protocol. + func toDict() -> [String: Any] { + return [ + "className": className, + "text": text, + "label": label, + "value": value, + "identifier": identifier, + "placeholderValue": placeholderValue, + "enabled": isEnabled, + "selected": isSelected, + "focused": hasFocus, + "displayed": isDisplayed, + "clickable": isClickable, + "bounds": [ + "x": Int(frame.origin.x), + "y": Int(frame.origin.y), + "width": Int(frame.size.width), + "height": Int(frame.size.height), + ], + ] + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Core/ElementTree.swift b/drivers/ios/DeviceLabDriver/Sources/Core/ElementTree.swift new file mode 100644 index 00000000..4207abc4 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Core/ElementTree.swift @@ -0,0 +1,270 @@ +import Foundation +import XCTest + +/// Converts XCUIElementSnapshot tree into flat [ElementModel] and provides search. +enum ElementTree { + + // MARK: - Build from snapshot + + /// Flatten an XCUIElementSnapshot into an array of ElementModel. + static func flatten(_ snapshot: XCUIElementSnapshot) -> [ElementModel] { + var result: [ElementModel] = [] + buildElement(from: snapshot, into: &result) + return result + } + + private static func buildElement(from snap: XCUIElementSnapshot, into result: inout [ElementModel]) { + let children = (snap.children as? [XCUIElementSnapshot]) ?? [] + var childModels: [ElementModel] = [] + for child in children { + buildElement(from: child, into: &result) + // We only store direct children for XML serialization + childModels.append(makeElement(from: child, children: [])) + } + let elem = makeElement(from: snap, children: childModels) + result.append(elem) + } + + private static func makeElement(from snap: XCUIElementSnapshot, children: [ElementModel]) -> ElementModel { + return ElementModel( + elementType: snap.elementType, + className: elementTypeName(snap.elementType), + label: snap.label ?? "", + value: (snap.value as? String) ?? "", + identifier: snap.identifier, + placeholderValue: snap.placeholderValue ?? "", + isEnabled: snap.isEnabled, + isSelected: snap.isSelected, + hasFocus: snap.hasFocus, + frame: snap.frame, + children: children + ) + } + + // MARK: - Search + + /// Find elements matching text (case-insensitive contains). + static func findByText(_ elements: [ElementModel], text: String) -> [ElementModel] { + let lower = text.lowercased() + return elements.filter { elem in + elem.label.lowercased().contains(lower) || + elem.value.lowercased().contains(lower) || + elem.identifier.lowercased().contains(lower) || + elem.placeholderValue.lowercased().contains(lower) + } + } + + /// Find elements matching accessibility identifier. + static func findByID(_ elements: [ElementModel], id: String) -> [ElementModel] { + let lower = id.lowercased() + return elements.filter { $0.identifier.lowercased().contains(lower) } + } + + /// Find elements matching element type name. + static func findByType(_ elements: [ElementModel], type: String) -> [ElementModel] { + let lower = type.lowercased() + return elements.filter { $0.className.lowercased().contains(lower) } + } + + /// Filter to only visible elements (non-zero frame). + static func filterVisible(_ elements: [ElementModel]) -> [ElementModel] { + return elements.filter { $0.isDisplayed } + } + + /// Sort clickable/interactive elements first. + static func sortClickableFirst(_ elements: [ElementModel]) -> [ElementModel] { + return elements.sorted { a, b in + if a.isClickable && !b.isClickable { return true } + if !a.isClickable && b.isClickable { return false } + return false // preserve order + } + } + + /// Find the best element for tap: visible, prefer clickable, prefer exact text match. + static func findForTap(_ elements: [ElementModel], text: String) -> ElementModel? { + let lower = text.lowercased() + let matches = elements.filter { elem in + guard elem.isDisplayed else { return false } + return elem.label.lowercased().contains(lower) || + elem.value.lowercased().contains(lower) || + elem.identifier.lowercased().contains(lower) || + elem.placeholderValue.lowercased().contains(lower) + } + + if matches.isEmpty { return nil } + + // Prefer exact match + let exactMatches = matches.filter { elem in + elem.label.lowercased() == lower || + elem.value.lowercased() == lower || + elem.identifier.lowercased() == lower + } + + let pool = exactMatches.isEmpty ? matches : exactMatches + + // Prefer clickable + if let clickable = pool.first(where: { $0.isClickable }) { + return clickable + } + + return pool.first + } + + // MARK: - XML Serialization (WDA-compatible format) + + /// Serialize snapshot to XML matching WDA's page source format. + static func snapshotToXML(_ snapshot: XCUIElementSnapshot, screenSize: CGSize) -> String { + var xml = "\n" + appendElementXML(snapshot, to: &xml, indent: 0, screenSize: screenSize) + return xml + } + + private static func appendElementXML(_ snap: XCUIElementSnapshot, to xml: inout String, indent: Int, screenSize: CGSize) { + let typeName = elementTypeName(snap.elementType) + let pad = String(repeating: " ", count: indent) + + let label = escapeXML(snap.label ?? "") + let value = escapeXML((snap.value as? String) ?? "") + let name = escapeXML(snap.identifier) + let placeholder = escapeXML(snap.placeholderValue ?? "") + + let frame = snap.frame + let visible = frame.width > 0 && frame.height > 0 && + frame.origin.x < screenSize.width && frame.origin.y < screenSize.height + + let children = (snap.children as? [XCUIElementSnapshot]) ?? [] + + if children.isEmpty { + xml += "\(pad)<\(typeName)" + xml += " type=\"\(typeName)\"" + xml += " enabled=\"\(snap.isEnabled)\"" + xml += " visible=\"\(visible)\"" + if !name.isEmpty { xml += " name=\"\(name)\"" } + if !label.isEmpty { xml += " label=\"\(label)\"" } + if !value.isEmpty { xml += " value=\"\(value)\"" } + if !placeholder.isEmpty { xml += " placeholderValue=\"\(placeholder)\"" } + xml += " x=\"\(Int(frame.origin.x))\"" + xml += " y=\"\(Int(frame.origin.y))\"" + xml += " width=\"\(Int(frame.size.width))\"" + xml += " height=\"\(Int(frame.size.height))\"" + xml += " />\n" + } else { + xml += "\(pad)<\(typeName)" + xml += " type=\"\(typeName)\"" + xml += " enabled=\"\(snap.isEnabled)\"" + xml += " visible=\"\(visible)\"" + if !name.isEmpty { xml += " name=\"\(name)\"" } + if !label.isEmpty { xml += " label=\"\(label)\"" } + if !value.isEmpty { xml += " value=\"\(value)\"" } + if !placeholder.isEmpty { xml += " placeholderValue=\"\(placeholder)\"" } + xml += " x=\"\(Int(frame.origin.x))\"" + xml += " y=\"\(Int(frame.origin.y))\"" + xml += " width=\"\(Int(frame.size.width))\"" + xml += " height=\"\(Int(frame.size.height))\"" + xml += ">\n" + for child in children { + appendElementXML(child, to: &xml, indent: indent + 1, screenSize: screenSize) + } + xml += "\(pad)\n" + } + } + + private static func escapeXML(_ s: String) -> String { + return s.replacingOccurrences(of: "&", with: "&") + .replacingOccurrences(of: "<", with: "<") + .replacingOccurrences(of: ">", with: ">") + .replacingOccurrences(of: "\"", with: """) + .replacingOccurrences(of: "'", with: "'") + } + + // MARK: - Element type names + + static func elementTypeName(_ type: XCUIElement.ElementType) -> String { + switch type { + case .any: return "XCUIElementTypeAny" + case .other: return "XCUIElementTypeOther" + case .application: return "XCUIElementTypeApplication" + case .group: return "XCUIElementTypeGroup" + case .window: return "XCUIElementTypeWindow" + case .sheet: return "XCUIElementTypeSheet" + case .drawer: return "XCUIElementTypeDrawer" + case .alert: return "XCUIElementTypeAlert" + case .dialog: return "XCUIElementTypeDialog" + case .button: return "XCUIElementTypeButton" + case .radioButton: return "XCUIElementTypeRadioButton" + case .radioGroup: return "XCUIElementTypeRadioGroup" + case .checkBox: return "XCUIElementTypeCheckBox" + case .disclosureTriangle: return "XCUIElementTypeDisclosureTriangle" + case .popUpButton: return "XCUIElementTypePopUpButton" + case .comboBox: return "XCUIElementTypeComboBox" + case .menuButton: return "XCUIElementTypeMenuButton" + case .toolbarButton: return "XCUIElementTypeToolbarButton" + case .popover: return "XCUIElementTypePopover" + case .keyboard: return "XCUIElementTypeKeyboard" + case .key: return "XCUIElementTypeKey" + case .navigationBar: return "XCUIElementTypeNavigationBar" + case .tabBar: return "XCUIElementTypeTabBar" + case .tabGroup: return "XCUIElementTypeTabGroup" + case .toolbar: return "XCUIElementTypeToolbar" + case .statusBar: return "XCUIElementTypeStatusBar" + case .table: return "XCUIElementTypeTable" + case .tableRow: return "XCUIElementTypeTableRow" + case .tableColumn: return "XCUIElementTypeTableColumn" + case .outline: return "XCUIElementTypeOutline" + case .outlineRow: return "XCUIElementTypeOutlineRow" + case .browser: return "XCUIElementTypeBrowser" + case .collectionView: return "XCUIElementTypeCollectionView" + case .slider: return "XCUIElementTypeSlider" + case .pageIndicator: return "XCUIElementTypePageIndicator" + case .progressIndicator: return "XCUIElementTypeProgressIndicator" + case .activityIndicator: return "XCUIElementTypeActivityIndicator" + case .segmentedControl: return "XCUIElementTypeSegmentedControl" + case .picker: return "XCUIElementTypePicker" + case .pickerWheel: return "XCUIElementTypePickerWheel" + case .switch: return "XCUIElementTypeSwitch" + case .toggle: return "XCUIElementTypeToggle" + case .link: return "XCUIElementTypeLink" + case .image: return "XCUIElementTypeImage" + case .icon: return "XCUIElementTypeIcon" + case .searchField: return "XCUIElementTypeSearchField" + case .scrollView: return "XCUIElementTypeScrollView" + case .scrollBar: return "XCUIElementTypeScrollBar" + case .staticText: return "XCUIElementTypeStaticText" + case .textField: return "XCUIElementTypeTextField" + case .secureTextField: return "XCUIElementTypeSecureTextField" + case .datePicker: return "XCUIElementTypeDatePicker" + case .textView: return "XCUIElementTypeTextView" + case .menu: return "XCUIElementTypeMenu" + case .menuItem: return "XCUIElementTypeMenuItem" + case .menuBar: return "XCUIElementTypeMenuBar" + case .menuBarItem: return "XCUIElementTypeMenuBarItem" + case .map: return "XCUIElementTypeMap" + case .webView: return "XCUIElementTypeWebView" + case .incrementArrow: return "XCUIElementTypeIncrementArrow" + case .decrementArrow: return "XCUIElementTypeDecrementArrow" + case .timeline: return "XCUIElementTypeTimeline" + case .ratingIndicator: return "XCUIElementTypeRatingIndicator" + case .valueIndicator: return "XCUIElementTypeValueIndicator" + case .splitGroup: return "XCUIElementTypeSplitGroup" + case .splitter: return "XCUIElementTypeSplitter" + case .relevanceIndicator: return "XCUIElementTypeRelevanceIndicator" + case .colorWell: return "XCUIElementTypeColorWell" + case .helpTag: return "XCUIElementTypeHelpTag" + case .matte: return "XCUIElementTypeMatte" + case .dockItem: return "XCUIElementTypeDockItem" + case .ruler: return "XCUIElementTypeRuler" + case .rulerMarker: return "XCUIElementTypeRulerMarker" + case .grid: return "XCUIElementTypeGrid" + case .levelIndicator: return "XCUIElementTypeLevelIndicator" + case .cell: return "XCUIElementTypeCell" + case .layoutArea: return "XCUIElementTypeLayoutArea" + case .layoutItem: return "XCUIElementTypeLayoutItem" + case .handle: return "XCUIElementTypeHandle" + case .stepper: return "XCUIElementTypeStepper" + case .tab: return "XCUIElementTypeTab" + case .touchBar: return "XCUIElementTypeTouchBar" + case .statusItem: return "XCUIElementTypeStatusItem" + @unknown default: return "XCUIElementTypeOther" + } + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Core/EventSynthesizer.h b/drivers/ios/DeviceLabDriver/Sources/Core/EventSynthesizer.h new file mode 100644 index 00000000..a86f0285 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Core/EventSynthesizer.h @@ -0,0 +1,20 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// Low-level event synthesis using private XCTest APIs (same approach as WDA). +/// Uses XCPointerEventPath + XCSynthesizedEventRecord to type text directly +/// through the XCTest daemon — no keyboard focus required, returns NSError +/// instead of throwing NSException. +@interface EventSynthesizer : NSObject + +/// Types text via the XCTest event synthesizer. Does not require keyboard focus. +/// Returns nil on success, or an NSError on failure. ++ (nullable NSError *)typeText:(NSString *)text typingSpeed:(NSUInteger)speed; + +/// Types text with default typing speed (60 chars/sec). ++ (nullable NSError *)typeText:(NSString *)text; + +@end + +NS_ASSUME_NONNULL_END diff --git a/drivers/ios/DeviceLabDriver/Sources/Core/EventSynthesizer.m b/drivers/ios/DeviceLabDriver/Sources/Core/EventSynthesizer.m new file mode 100644 index 00000000..5c59f7a7 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Core/EventSynthesizer.m @@ -0,0 +1,69 @@ +#import "EventSynthesizer.h" +#import +#import + +// Private XCTest headers — same APIs used by WebDriverAgent +@interface XCPointerEventPath : NSObject +- (instancetype)initForTextInput; +- (void)typeText:(NSString *)text atOffset:(double)offset typingSpeed:(NSUInteger)speed shouldRedact:(BOOL)redact; +@end + +@interface XCSynthesizedEventRecord : NSObject +- (instancetype)initWithName:(NSString *)name; +- (void)addPointerEventPath:(XCPointerEventPath *)path; +@end + +// XCUIDevice private property for event synthesis +@interface XCUIDevice (EventSynthesis) +@property (readonly) id eventSynthesizer; +@end + +// The eventSynthesizer responds to synthesizeEvent:completion: +@protocol XCUIEventSynthesizing +- (void)synthesizeEvent:(XCSynthesizedEventRecord *)event completion:(void (^)(BOOL, NSError *))completion; +@end + +@implementation EventSynthesizer + ++ (nullable NSError *)typeText:(NSString *)text typingSpeed:(NSUInteger)speed { + XCSynthesizedEventRecord *record = [[XCSynthesizedEventRecord alloc] initWithName: + [NSString stringWithFormat:@"Type '%@'", text.length <= 12 ? text : [text substringToIndex:12]]]; + XCPointerEventPath *path = [[XCPointerEventPath alloc] initForTextInput]; + [path typeText:text atOffset:0.0 typingSpeed:speed shouldRedact:NO]; + [record addPointerEventPath:path]; + + // Send through XCUIDevice.sharedDevice.eventSynthesizer (same path as WDA). + // CRITICAL: Must spin the NSRunLoop while waiting — XCTest delivers events + // through the run loop. Using dispatch_semaphore_wait blocks the run loop + // and prevents event delivery (events queue but never reach the app). + __block NSError *synthesizeError = nil; + __block volatile atomic_bool didFinish = false; + + id synthesizer = [XCUIDevice.sharedDevice eventSynthesizer]; + if (!synthesizer) { + return [NSError errorWithDomain:@"EventSynthesizer" code:2 + userInfo:@{NSLocalizedDescriptionKey: @"XCUIDevice.eventSynthesizer is nil"}]; + } + + [(id)synthesizer synthesizeEvent:record completion:^(BOOL result, NSError *error) { + if (!result || error != nil) { + synthesizeError = error ?: [NSError errorWithDomain:@"EventSynthesizer" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"Event synthesis failed"}]; + } + atomic_fetch_or(&didFinish, true); + }]; + + // Spin the run loop until completion (same pattern as WDA's FBRunLoopSpinner) + while (!atomic_fetch_and(&didFinish, false)) { + [[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; + } + + return synthesizeError; +} + ++ (nullable NSError *)typeText:(NSString *)text { + return [self typeText:text typingSpeed:60]; +} + +@end diff --git a/drivers/ios/DeviceLabDriver/Sources/Core/ObjCExceptionCatcher.h b/drivers/ios/DeviceLabDriver/Sources/Core/ObjCExceptionCatcher.h new file mode 100644 index 00000000..fa3f7a39 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Core/ObjCExceptionCatcher.h @@ -0,0 +1,14 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Catches Objective-C NSExceptions that Swift cannot handle with do/catch. +@interface ObjCExceptionCatcher : NSObject + +/// Executes a block and catches any NSException thrown. +/// Returns nil on success, or the NSException on failure. ++ (nullable NSException *)tryBlock:(void (NS_NOESCAPE ^)(void))block; + +@end + +NS_ASSUME_NONNULL_END diff --git a/drivers/ios/DeviceLabDriver/Sources/Core/ObjCExceptionCatcher.m b/drivers/ios/DeviceLabDriver/Sources/Core/ObjCExceptionCatcher.m new file mode 100644 index 00000000..5b37c82b --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Core/ObjCExceptionCatcher.m @@ -0,0 +1,14 @@ +#import "ObjCExceptionCatcher.h" + +@implementation ObjCExceptionCatcher + ++ (nullable NSException *)tryBlock:(void (NS_NOESCAPE ^)(void))block { + @try { + block(); + return nil; + } @catch (NSException *exception) { + return exception; + } +} + +@end diff --git a/drivers/ios/DeviceLabDriver/Sources/Core/SnapshotManager.swift b/drivers/ios/DeviceLabDriver/Sources/Core/SnapshotManager.swift new file mode 100644 index 00000000..e415c920 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Core/SnapshotManager.swift @@ -0,0 +1,135 @@ +import Foundation +import XCTest + +/// Manages XCUIApplication snapshots with structural hash caching. +/// One `app.snapshot()` call captures the entire accessibility tree. +/// The hash is invalidated after any mutation (tap, type, swipe). +final class SnapshotManager { + private var app: XCUIApplication + private var cachedSnapshot: XCUIElementSnapshot? + private var cachedElements: [ElementModel]? + private var cachedHash: UInt64 = 0 + private let lock = NSLock() + + /// Screen size for visibility checks. + var screenSize: CGSize = .zero + + init(app: XCUIApplication) { + self.app = app + } + + /// Update the target application (called when Session.create launches the real app). + func setApp(_ newApp: XCUIApplication) { + lock.lock() + defer { lock.unlock() } + app = newApp + cachedSnapshot = nil + cachedElements = nil + cachedHash = 0 + } + + /// Get the current snapshot, using cache if the UI hasn't changed. + func snapshot() throws -> XCUIElementSnapshot { + lock.lock() + defer { lock.unlock() } + + let snap = try app.snapshot() + let hash = structuralHash(snap) + + if hash == cachedHash, let cached = cachedSnapshot { + return cached + } + + cachedSnapshot = snap + cachedElements = nil // Invalidate flattened cache + cachedHash = hash + return snap + } + + /// Get flattened elements from the current snapshot. + func elements() throws -> [ElementModel] { + lock.lock() + defer { lock.unlock() } + + let snap = try app.snapshot() + let hash = structuralHash(snap) + + if hash == cachedHash, let cached = cachedElements { + return cached + } + + cachedSnapshot = snap + cachedHash = hash + let elems = ElementTree.flatten(snap) + cachedElements = elems + return elems + } + + /// Get the raw snapshot for XML serialization. + func rawSnapshot() throws -> XCUIElementSnapshot { + return try app.snapshot() + } + + /// Invalidate the cache after a mutation (tap, type, swipe, etc.). + func invalidate() { + lock.lock() + defer { lock.unlock() } + cachedSnapshot = nil + cachedElements = nil + cachedHash = 0 + } + + // MARK: - Structural hash + + /// Compute a structural hash of text + class + state, ignoring bounds. + /// This means we can skip re-snapshot if only bounds changed (e.g., scroll offset). + private func structuralHash(_ snap: XCUIElementSnapshot) -> UInt64 { + var hasher = FNV1aHasher() + hashElement(snap, into: &hasher) + return hasher.value + } + + private func hashElement(_ snap: XCUIElementSnapshot, into hasher: inout FNV1aHasher) { + hasher.combine(snap.elementType.rawValue) + hasher.combine(snap.label ?? "") + hasher.combine((snap.value as? String) ?? "") + hasher.combine(snap.identifier) + hasher.combine(snap.isEnabled) + hasher.combine(snap.isSelected) + + if let children = snap.children as? [XCUIElementSnapshot] { + hasher.combine(children.count) + for child in children { + hashElement(child, into: &hasher) + } + } + } +} + +// MARK: - FNV-1a hash (fast, no allocations) + +private struct FNV1aHasher { + private(set) var value: UInt64 = 14695981039346656037 + + mutating func combine(_ int: Int) { + value ^= UInt64(bitPattern: Int64(int)) + value &*= 1099511628211 + } + + mutating func combine(_ uint: UInt) { + value ^= UInt64(uint) + value &*= 1099511628211 + } + + mutating func combine(_ bool: Bool) { + value ^= bool ? 1 : 0 + value &*= 1099511628211 + } + + mutating func combine(_ string: String) { + for byte in string.utf8 { + value ^= UInt64(byte) + value &*= 1099511628211 + } + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Handlers/AppHandler.swift b/drivers/ios/DeviceLabDriver/Sources/Handlers/AppHandler.swift new file mode 100644 index 00000000..38799647 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Handlers/AppHandler.swift @@ -0,0 +1,177 @@ +import Foundation +import XCTest + +/// Handles Device.* and App.* RPCs: launchApp, terminateApp, openURL, clipboard, orientation. +final class AppHandler { + private let snapshotManager: SnapshotManager + private weak var sessionHandler: SessionHandler? + + init(snapshotManager: SnapshotManager, sessionHandler: SessionHandler) { + self.snapshotManager = snapshotManager + self.sessionHandler = sessionHandler + } + + func handle(method: String, request: Request) throws -> AnyCodable? { + switch method { + case "Device.launchApp", "App.launch": + return try launchApp(request) + case "Device.terminateApp", "App.terminate": + return try terminateApp(request) + case "Device.openURL": + return try openURL(request) + case "Device.getOrientation": + return try getOrientation() + case "Device.setOrientation": + return try setOrientation(request) + case "Device.getClipboard": + return try getClipboard() + case "Device.setClipboard": + return try setClipboard(request) + case "Device.pressHome": + XCUIDevice.shared.press(.home) + snapshotManager.invalidate() + return AnyCodable(["success": true]) + case "Device.pressButton": + return try pressButton(request) + default: + throw HandlerError.unknownMethod(method) + } + } + + // MARK: - Launch App + + private func launchApp(_ request: Request) throws -> AnyCodable { + guard let bundleID = request.string("bundleId") ?? request.string("bundleID") else { + throw HandlerError.missingParam("bundleId") + } + + let app = XCUIApplication(bundleIdentifier: bundleID) + + if let args = request.stringArray("arguments") { + app.launchArguments = args + } + if let env = request.dict("environment") { + var envDict: [String: String] = [:] + for (k, v) in env { envDict[k] = "\(v)" } + app.launchEnvironment = envDict + } + + app.launch() + snapshotManager.setApp(app) + + return AnyCodable(["success": true, "bundleId": bundleID]) + } + + // MARK: - Terminate App + + private func terminateApp(_ request: Request) throws -> AnyCodable { + guard let bundleID = request.string("bundleId") ?? request.string("bundleID") else { + throw HandlerError.missingParam("bundleId") + } + + let app = XCUIApplication(bundleIdentifier: bundleID) + app.terminate() + snapshotManager.invalidate() + + return AnyCodable(["success": true, "bundleId": bundleID]) + } + + // MARK: - Open URL + + private func openURL(_ request: Request) throws -> AnyCodable { + guard let urlString = request.string("url") else { + throw HandlerError.missingParam("url") + } + + // Launch Safari with the URL + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + safari.launch() + + // Wait a moment for Safari to be ready + Thread.sleep(forTimeInterval: 0.5) + + // Type the URL into the address bar + let addressBar = safari.textFields.firstMatch + if addressBar.waitForExistence(timeout: 3) { + addressBar.tap() + addressBar.typeText(urlString + "\n") + } + + snapshotManager.invalidate() + return AnyCodable(["success": true, "url": urlString]) + } + + // MARK: - Orientation + + private func getOrientation() throws -> AnyCodable { + let orientation: String + switch XCUIDevice.shared.orientation { + case .portrait, .portraitUpsideDown: + orientation = "portrait" + case .landscapeLeft, .landscapeRight: + orientation = "landscape" + default: + orientation = "portrait" + } + return AnyCodable(["orientation": orientation]) + } + + private func setOrientation(_ request: Request) throws -> AnyCodable { + guard let orientation = request.string("orientation") else { + throw HandlerError.missingParam("orientation") + } + + switch orientation.lowercased() { + case "portrait": + XCUIDevice.shared.orientation = .portrait + case "landscape", "landscapeleft": + XCUIDevice.shared.orientation = .landscapeLeft + case "landscaperight": + XCUIDevice.shared.orientation = .landscapeRight + default: + throw HandlerError.invalidParam("Unknown orientation: \(orientation)") + } + + snapshotManager.invalidate() + return AnyCodable(["success": true, "orientation": orientation]) + } + + // MARK: - Clipboard + + private func getClipboard() throws -> AnyCodable { + let text = UIPasteboard.general.string ?? "" + return AnyCodable(["text": text]) + } + + private func setClipboard(_ request: Request) throws -> AnyCodable { + guard let text = request.string("text") else { + throw HandlerError.missingParam("text") + } + UIPasteboard.general.string = text + return AnyCodable(["success": true]) + } + + // MARK: - Hardware Buttons + + private func pressButton(_ request: Request) throws -> AnyCodable { + guard let button = request.string("button") else { + throw HandlerError.missingParam("button") + } + + switch button.lowercased() { + case "home": + XCUIDevice.shared.press(.home) + #if !targetEnvironment(simulator) + case "volumeup": + XCUIDevice.shared.press(.volumeUp) + case "volumedown": + XCUIDevice.shared.press(.volumeDown) + #endif + default: + throw HandlerError.invalidParam("Unknown button: \(button)") + } + + snapshotManager.invalidate() + return AnyCodable(["success": true, "button": button]) + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Handlers/ElementHandler.swift b/drivers/ios/DeviceLabDriver/Sources/Handlers/ElementHandler.swift new file mode 100644 index 00000000..e87169a7 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Handlers/ElementHandler.swift @@ -0,0 +1,129 @@ +import Foundation +import XCTest + +/// Handles UI.* RPCs: findElement, getSource, screenshot. +final class ElementHandler { + private let snapshotManager: SnapshotManager + private weak var sessionHandler: SessionHandler? + + init(snapshotManager: SnapshotManager, sessionHandler: SessionHandler) { + self.snapshotManager = snapshotManager + self.sessionHandler = sessionHandler + } + + func handle(method: String, request: Request) throws -> AnyCodable? { + switch method { + case "UI.findElement": + return try findElement(request) + case "UI.getSource": + return try getSource(request) + case "UI.getKeyboardInfo": + return try getKeyboardInfo() + case "UI.hasFocusedElement": + return try hasFocusedElement() + default: + throw HandlerError.unknownMethod(method) + } + } + + // MARK: - Screenshot (binary) + + func screenshot() throws -> Data { + let screenshot = XCUIScreen.main.screenshot() + guard let jpegData = screenshot.image.jpegData(compressionQuality: 0.8) else { + throw HandlerError.invalidParam("Failed to encode screenshot as JPEG") + } + return jpegData + } + + // MARK: - Find Element + + private func findElement(_ request: Request) throws -> AnyCodable { + let elements = try snapshotManager.elements() + + // Search by text + if let text = request.string("text") { + let visible = ElementTree.filterVisible(elements) + let matches = ElementTree.findByText(visible, text: text) + + if matches.isEmpty { + throw HandlerError.elementNotFound + } + + // Apply index + let index = request.int("index") ?? 0 + let sorted = ElementTree.sortClickableFirst(matches) + let selected: ElementModel + if index > 0 && index < sorted.count { + selected = sorted[index] + } else { + selected = sorted[0] + } + + return AnyCodable(selected.toDict()) + } + + // Search by accessibility ID + if let id = request.string("id") { + let visible = ElementTree.filterVisible(elements) + let matches = ElementTree.findByID(visible, id: id) + + if matches.isEmpty { + throw HandlerError.elementNotFound + } + + let index = request.int("index") ?? 0 + let sorted = ElementTree.sortClickableFirst(matches) + let selected: ElementModel + if index > 0 && index < sorted.count { + selected = sorted[index] + } else { + selected = sorted[0] + } + + return AnyCodable(selected.toDict()) + } + + // Search by type + if let type = request.string("type") { + let visible = ElementTree.filterVisible(elements) + let matches = ElementTree.findByType(visible, type: type) + + if matches.isEmpty { + throw HandlerError.elementNotFound + } + + return AnyCodable(matches[0].toDict()) + } + + throw HandlerError.missingParam("text, id, or type") + } + + // MARK: - Get Source (XML) + + private func getSource(_ request: Request) throws -> AnyCodable { + let snap = try snapshotManager.rawSnapshot() + let screenSize = sessionHandler?.screenSize ?? CGSize(width: 390, height: 844) + let xml = ElementTree.snapshotToXML(snap, screenSize: screenSize) + return AnyCodable(["xml": xml]) + } + + // MARK: - Keyboard Info + + private func getKeyboardInfo() throws -> AnyCodable { + guard let app = sessionHandler?.getApp() else { + return AnyCodable(["visible": false]) + } + let keyboards = app.keyboards + let visible = keyboards.count > 0 && keyboards.firstMatch.exists + return AnyCodable(["visible": visible]) + } + + // MARK: - Focus Check + + private func hasFocusedElement() throws -> AnyCodable { + let elements = try snapshotManager.elements() + let focused = elements.contains { $0.hasFocus } + return AnyCodable(["focused": focused]) + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Handlers/GestureHandler.swift b/drivers/ios/DeviceLabDriver/Sources/Handlers/GestureHandler.swift new file mode 100644 index 00000000..849737c1 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Handlers/GestureHandler.swift @@ -0,0 +1,222 @@ +import Foundation +import XCTest + +/// Handles Gesture.* RPCs: tap, doubleTap, longPress, swipe, scroll. +final class GestureHandler { + private let snapshotManager: SnapshotManager + private weak var sessionHandler: SessionHandler? + + init(snapshotManager: SnapshotManager, sessionHandler: SessionHandler) { + self.snapshotManager = snapshotManager + self.sessionHandler = sessionHandler + } + + func handle(method: String, request: Request) throws -> AnyCodable? { + switch method { + case "Gesture.tap": + return try tap(request) + case "Gesture.doubleTap": + return try doubleTap(request) + case "Gesture.longPress": + return try longPress(request) + case "Gesture.swipe": + return try swipe(request) + case "Gesture.scroll": + return try scroll(request) + case "Gesture.findAndTap": + return try findAndTap(request) + default: + throw HandlerError.unknownMethod(method) + } + } + + // MARK: - Tap at coordinates + + private func tap(_ request: Request) throws -> AnyCodable { + guard let x = request.double("x"), let y = request.double("y") else { + throw HandlerError.missingParam("x, y") + } + + guard let app = sessionHandler?.getApp() else { + throw HandlerError.invalidParam("No active session") + } + + let normalized = app.coordinate(withNormalizedOffset: .zero) + let point = normalized.withOffset(CGVector(dx: x, dy: y)) + point.tap() + + snapshotManager.invalidate() + return AnyCodable(["success": true]) + } + + // MARK: - Double tap + + private func doubleTap(_ request: Request) throws -> AnyCodable { + guard let x = request.double("x"), let y = request.double("y") else { + throw HandlerError.missingParam("x, y") + } + + guard let app = sessionHandler?.getApp() else { + throw HandlerError.invalidParam("No active session") + } + + let normalized = app.coordinate(withNormalizedOffset: .zero) + let point = normalized.withOffset(CGVector(dx: x, dy: y)) + point.doubleTap() + + snapshotManager.invalidate() + return AnyCodable(["success": true]) + } + + // MARK: - Long press + + private func longPress(_ request: Request) throws -> AnyCodable { + guard let x = request.double("x"), let y = request.double("y") else { + throw HandlerError.missingParam("x, y") + } + + let duration = request.double("duration") ?? 1.0 + + guard let app = sessionHandler?.getApp() else { + throw HandlerError.invalidParam("No active session") + } + + let normalized = app.coordinate(withNormalizedOffset: .zero) + let point = normalized.withOffset(CGVector(dx: x, dy: y)) + point.press(forDuration: duration) + + snapshotManager.invalidate() + return AnyCodable(["success": true]) + } + + // MARK: - Swipe + + private func swipe(_ request: Request) throws -> AnyCodable { + guard let fromX = request.double("fromX"), + let fromY = request.double("fromY"), + let toX = request.double("toX"), + let toY = request.double("toY") else { + throw HandlerError.missingParam("fromX, fromY, toX, toY") + } + + let duration = request.double("duration") ?? 0.3 + + guard let app = sessionHandler?.getApp() else { + throw HandlerError.invalidParam("No active session") + } + + let normalized = app.coordinate(withNormalizedOffset: .zero) + let from = normalized.withOffset(CGVector(dx: fromX, dy: fromY)) + let to = normalized.withOffset(CGVector(dx: toX, dy: toY)) + from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .default, thenHoldForDuration: duration) + + snapshotManager.invalidate() + return AnyCodable(["success": true]) + } + + // MARK: - Scroll + + private func scroll(_ request: Request) throws -> AnyCodable { + guard let direction = request.string("direction") else { + throw HandlerError.missingParam("direction") + } + + let screenSize = sessionHandler?.screenSize ?? CGSize(width: 390, height: 844) + let centerX = screenSize.width / 2 + let centerY = screenSize.height / 2 + let scrollDist = screenSize.height / 3 + + guard let app = sessionHandler?.getApp() else { + throw HandlerError.invalidParam("No active session") + } + + let normalized = app.coordinate(withNormalizedOffset: .zero) + let fromPt: CGVector + let toPt: CGVector + + switch direction.lowercased() { + case "down": + // Scroll down = reveal content below = swipe UP + fromPt = CGVector(dx: centerX, dy: centerY + scrollDist / 2) + toPt = CGVector(dx: centerX, dy: centerY - scrollDist / 2) + case "up": + // Scroll up = reveal content above = swipe DOWN + fromPt = CGVector(dx: centerX, dy: centerY - scrollDist / 2) + toPt = CGVector(dx: centerX, dy: centerY + scrollDist / 2) + case "left": + fromPt = CGVector(dx: centerX + scrollDist / 2, dy: centerY) + toPt = CGVector(dx: centerX - scrollDist / 2, dy: centerY) + case "right": + fromPt = CGVector(dx: centerX - scrollDist / 2, dy: centerY) + toPt = CGVector(dx: centerX + scrollDist / 2, dy: centerY) + default: + throw HandlerError.invalidParam("direction must be up, down, left, or right") + } + + let from = normalized.withOffset(fromPt) + let to = normalized.withOffset(toPt) + from.press(forDuration: 0.05, thenDragTo: to) + + snapshotManager.invalidate() + return AnyCodable(["success": true]) + } + + // MARK: - Combined find + tap (single RPC) + + private func findAndTap(_ request: Request) throws -> AnyCodable { + let elements = try snapshotManager.elements() + + var target: ElementModel? + + if let text = request.string("text") { + target = ElementTree.findForTap(elements, text: text) + } else if let id = request.string("id") { + let visible = ElementTree.filterVisible(elements) + let matches = ElementTree.findByID(visible, id: id) + target = ElementTree.sortClickableFirst(matches).first + } + + guard let elem = target else { + throw HandlerError.elementNotFound + } + + guard let app = sessionHandler?.getApp() else { + throw HandlerError.invalidParam("No active session") + } + + // For text fields, use XCTest's native element tap to ensure proper focus. + // Coordinate-based taps don't always trigger keyboard focus. + if elem.elementType == .textField || elem.elementType == .secureTextField || + elem.elementType == .searchField || elem.elementType == .textView { + let query: XCUIElementQuery + switch elem.elementType { + case .secureTextField: query = app.secureTextFields + case .searchField: query = app.searchFields + case .textView: query = app.textViews + default: query = app.textFields + } + + // Try to find and tap using XCTest native element query + // This ensures proper keyboard focus activation + let candidates = query.allElementsBoundByIndex + for i in 0.. AnyCodable? { + switch method { + case "Input.typeText": + return try typeText(request) + case "Input.eraseText": + return try eraseText(request) + case "Input.clearText": + return try clearText(request) + case "Input.pressKey": + return try pressKey(request) + case "Input.hideKeyboard": + return try hideKeyboard(request) + default: + throw HandlerError.unknownMethod(method) + } + } + + // MARK: - Text input via event synthesis + + /// Types text using XCPointerEventPath (same low-level API as WDA). + /// Does NOT require keyboard focus. Returns NSError on failure. + /// No keyboard wait needed — EventSynthesizer sends events through the + /// XCTest daemon event pipeline, not through the software keyboard. + private func synthesizeText(_ text: String) throws { + if let error = EventSynthesizer.typeText(text) { + throw HandlerError.invalidParam("typeText failed: \(error.localizedDescription)") + } + } + + // MARK: - Type Text + + private func typeText(_ request: Request) throws -> AnyCodable { + guard let text = request.string("text") else { + throw HandlerError.missingParam("text") + } + + guard sessionHandler?.getApp() != nil else { + throw HandlerError.invalidParam("No active session") + } + + try synthesizeText(text) + + snapshotManager.invalidate() + return AnyCodable(["success": true, "text": text]) + } + + // MARK: - Erase Text + + private func eraseText(_ request: Request) throws -> AnyCodable { + let count = request.int("count") ?? 50 + + guard sessionHandler?.getApp() != nil else { + throw HandlerError.invalidParam("No active session") + } + + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: count) + try synthesizeText(deleteString) + + snapshotManager.invalidate() + return AnyCodable(["success": true, "count": count]) + } + + // MARK: - Clear Text + + private func clearText(_ request: Request) throws -> AnyCodable { + guard sessionHandler?.getApp() != nil else { + throw HandlerError.invalidParam("No active session") + } + + let deleteString = String(repeating: XCUIKeyboardKey.delete.rawValue, count: 100) + try synthesizeText(deleteString) + + snapshotManager.invalidate() + return AnyCodable(["success": true]) + } + + // MARK: - Press Key + + private func pressKey(_ request: Request) throws -> AnyCodable { + guard let key = request.string("key") else { + throw HandlerError.missingParam("key") + } + + guard sessionHandler?.getApp() != nil else { + throw HandlerError.invalidParam("No active session") + } + + switch key.lowercased() { + case "return", "enter": + try synthesizeText("\n") + case "tab": + try synthesizeText("\t") + case "delete", "backspace": + try synthesizeText(XCUIKeyboardKey.delete.rawValue) + case "space": + try synthesizeText(" ") + case "home": + XCUIDevice.shared.press(.home) + default: + if key.count == 1 { + try synthesizeText(key) + } else { + throw HandlerError.invalidParam("Unknown key: \(key)") + } + } + + snapshotManager.invalidate() + return AnyCodable(["success": true, "key": key]) + } + + // MARK: - Hide Keyboard + + private func hideKeyboard(_ request: Request) throws -> AnyCodable { + guard sessionHandler?.getApp() != nil else { + throw HandlerError.invalidParam("No active session") + } + + try synthesizeText("\n") + + snapshotManager.invalidate() + return AnyCodable(["success": true]) + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Handlers/SessionHandler.swift b/drivers/ios/DeviceLabDriver/Sources/Handlers/SessionHandler.swift new file mode 100644 index 00000000..70a3f86e --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Handlers/SessionHandler.swift @@ -0,0 +1,178 @@ +import Foundation +import XCTest + +/// Handles Session.* and Alert.* RPCs. +final class SessionHandler { + private let snapshotManager: SnapshotManager + private var app: XCUIApplication? + private var sessionActive = false + + /// Alert auto-handling action: "accept", "dismiss", or "" (none). + var alertAction: String = "" + + /// Cached screen size. + var screenSize: CGSize = .zero + + init(snapshotManager: SnapshotManager) { + self.snapshotManager = snapshotManager + } + + func handle(method: String, request: Request) throws -> AnyCodable? { + switch method { + case "Session.create": + return try createSession(request) + case "Session.status": + return AnyCodable(["active": sessionActive]) + case "Session.delete": + sessionActive = false + return AnyCodable(["deleted": true]) + default: + throw HandlerError.unknownMethod(method) + } + } + + func handleAlert(method: String, request: Request) throws -> AnyCodable? { + switch method { + case "Alert.accept": + return try handleAlertAction(accept: true) + case "Alert.dismiss": + return try handleAlertAction(accept: false) + default: + throw HandlerError.unknownMethod(method) + } + } + + // MARK: - Session + + private func createSession(_ request: Request) throws -> AnyCodable { + guard let bundleID = request.string("bundleId") ?? request.string("bundleID") else { + throw HandlerError.missingParam("bundleId") + } + + let newApp = XCUIApplication(bundleIdentifier: bundleID) + + // Set alert action + if let action = request.string("alertAction") { + alertAction = action + } + + // Setup alert auto-handling + if alertAction == "accept" || alertAction == "dismiss" { + setupAlertMonitor(accept: alertAction == "accept") + } + + // Launch arguments + if let args = request.stringArray("launchArguments") { + newApp.launchArguments = args + } + + // Launch environment + if let env = request.dict("launchEnvironment") { + var envDict: [String: String] = [:] + for (k, v) in env { + envDict[k] = "\(v)" + } + newApp.launchEnvironment = envDict + } + + newApp.launch() + app = newApp + snapshotManager.setApp(newApp) + sessionActive = true + + // Get screen size + let mainScreen = XCUIScreen.main + screenSize = mainScreen.screenshot().image.size + snapshotManager.screenSize = screenSize + + return AnyCodable([ + "sessionId": bundleID, + "deviceInfo": [ + "platformVersion": UIDevice.current.systemVersion, + "model": UIDevice.current.model, + "displaySize": "\(Int(screenSize.width))x\(Int(screenSize.height))", + ] as [String: Any], + ]) + } + + // MARK: - Alert handling + + private var alertMonitorToken: NSObjectProtocol? + + private func setupAlertMonitor(accept: Bool) { + // XCTest UI interruption monitor for permission dialogs + alertMonitorToken = addUIInterruptionMonitor(withDescription: "Permission Alert") { alert in + if accept { + let allowButton = alert.buttons["Allow"] + let allowWhileUsing = alert.buttons["Allow While Using App"] + if allowWhileUsing.exists { + allowWhileUsing.tap() + } else if allowButton.exists { + allowButton.tap() + } else { + // Try the first button as fallback + let buttons = alert.buttons + if buttons.count > 0 { + buttons.element(boundBy: buttons.count - 1).tap() + } + } + } else { + let dontAllowButton = alert.buttons["Don\u{2019}t Allow"] + let dontAllow = alert.buttons["Don't Allow"] + if dontAllowButton.exists { + dontAllowButton.tap() + } else if dontAllow.exists { + dontAllow.tap() + } else { + alert.buttons.element(boundBy: 0).tap() + } + } + return true + } + } + + private func handleAlertAction(accept: Bool) throws -> AnyCodable { + let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + let alerts = springboard.alerts + guard alerts.count > 0 else { + throw HandlerError.elementNotFound + } + let alert = alerts.firstMatch + if accept { + let allow = alert.buttons["Allow"] + let allowWhile = alert.buttons["Allow While Using App"] + let ok = alert.buttons["OK"] + if allowWhile.exists { allowWhile.tap() } + else if allow.exists { allow.tap() } + else if ok.exists { ok.tap() } + else { + let buttons = alert.buttons + if buttons.count > 0 { + buttons.element(boundBy: buttons.count - 1).tap() + } + } + } else { + let dontAllow = alert.buttons["Don\u{2019}t Allow"] + let cancel = alert.buttons["Cancel"] + if dontAllow.exists { dontAllow.tap() } + else if cancel.exists { cancel.tap() } + else { + alert.buttons.element(boundBy: 0).tap() + } + } + snapshotManager.invalidate() + return AnyCodable(["success": true]) + } + + /// Get the current app. + func getApp() -> XCUIApplication? { return app } +} + +// UIInterruptionMonitor is a class method on XCTestCase, but we need it in our runner. +// The DeviceLabDriverRunner (XCTestCase subclass) will call setupAlertMonitor. +// For now, the addUIInterruptionMonitor call will be available in the test context. +private func addUIInterruptionMonitor(withDescription description: String, handler: @escaping (XCUIElement) -> Bool) -> NSObjectProtocol? { + // This function is available in XCTest UI test context + // It's a method on XCTestCase, so we'll wire it through the runner + return nil +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Handlers/SettingsHandler.swift b/drivers/ios/DeviceLabDriver/Sources/Handlers/SettingsHandler.swift new file mode 100644 index 00000000..3b26c705 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Handlers/SettingsHandler.swift @@ -0,0 +1,79 @@ +import Foundation +import XCTest + +/// Handles Settings.* RPCs: timeouts, quiescence, alert action. +final class SettingsHandler { + private weak var sessionHandler: SessionHandler? + + /// Find timeout in milliseconds. + var findTimeout: Int = 17000 + + /// Wait for idle timeout in milliseconds (0 = disabled). + var waitForIdleTimeout: Int = 0 + + init(sessionHandler: SessionHandler) { + self.sessionHandler = sessionHandler + } + + func handle(method: String, request: Request) throws -> AnyCodable? { + switch method { + case "Settings.update": + return try update(request) + case "Settings.get": + return try get(request) + case "Settings.setAlertAction": + return try setAlertAction(request) + case "Settings.setFindTimeout": + return try setFindTimeout(request) + case "Settings.setWaitForIdleTimeout": + return try setIdleTimeout(request) + default: + throw HandlerError.unknownMethod(method) + } + } + + private func update(_ request: Request) throws -> AnyCodable { + if let timeout = request.int("findTimeout") { + findTimeout = timeout + } + if let timeout = request.int("waitForIdleTimeout") { + waitForIdleTimeout = timeout + } + if let action = request.string("alertAction") { + sessionHandler?.alertAction = action + } + return AnyCodable(["success": true]) + } + + private func get(_ request: Request) throws -> AnyCodable { + return AnyCodable([ + "findTimeout": findTimeout, + "waitForIdleTimeout": waitForIdleTimeout, + "alertAction": sessionHandler?.alertAction ?? "", + ] as [String: Any]) + } + + private func setAlertAction(_ request: Request) throws -> AnyCodable { + guard let action = request.string("action") else { + throw HandlerError.missingParam("action") + } + sessionHandler?.alertAction = action + return AnyCodable(["success": true, "alertAction": action]) + } + + private func setFindTimeout(_ request: Request) throws -> AnyCodable { + guard let timeout = request.int("timeout") else { + throw HandlerError.missingParam("timeout") + } + findTimeout = timeout + return AnyCodable(["success": true, "findTimeout": timeout]) + } + + private func setIdleTimeout(_ request: Request) throws -> AnyCodable { + guard let timeout = request.int("timeout") else { + throw HandlerError.missingParam("timeout") + } + waitForIdleTimeout = timeout + return AnyCodable(["success": true, "waitForIdleTimeout": timeout]) + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Server/Protocol.swift b/drivers/ios/DeviceLabDriver/Sources/Server/Protocol.swift new file mode 100644 index 00000000..59c03a45 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Server/Protocol.swift @@ -0,0 +1,105 @@ +import Foundation + +// MARK: - Request / Response / Event + +/// Incoming request from the Go host. +struct Request: Decodable { + let id: Int64 + let method: String + let params: [String: AnyCodable]? +} + +/// Outgoing response matched by request ID. +struct Response: Encodable { + let id: Int64 + var result: AnyCodable? + var error: ErrorPayload? +} + +/// Outgoing push event (unsolicited). +struct Event: Encodable { + let event: String + var params: AnyCodable? +} + +/// Error detail inside a Response. +struct ErrorPayload: Codable { + let code: String + let message: String +} + +// MARK: - Type-erased Codable wrapper + +/// Minimal type-erased Codable for heterogeneous JSON values. +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { self.value = value } + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { value = NSNull(); return } + if let v = try? c.decode(Bool.self) { value = v; return } + if let v = try? c.decode(Int64.self) { value = v; return } + if let v = try? c.decode(Double.self) { value = v; return } + if let v = try? c.decode(String.self) { value = v; return } + if let v = try? c.decode([AnyCodable].self) { value = v.map(\.value); return } + if let v = try? c.decode([String: AnyCodable].self) { + value = v.mapValues(\.value); return + } + throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unsupported JSON type") + } + + func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch value { + case is NSNull: try c.encodeNil() + case let v as Bool: try c.encode(v) + case let v as Int: try c.encode(v) + case let v as Int64: try c.encode(v) + case let v as Double: try c.encode(v) + case let v as String: try c.encode(v) + case let v as [Any]: try c.encode(v.map { AnyCodable($0) }) + case let v as [String: Any]: try c.encode(v.mapValues { AnyCodable($0) }) + default: try c.encode(String(describing: value)) + } + } +} + +// MARK: - Param helpers + +extension Request { + func string(_ key: String) -> String? { + guard let p = params, let v = p[key]?.value as? String else { return nil } + return v + } + + func int(_ key: String) -> Int? { + if let p = params, let v = p[key]?.value as? Int64 { return Int(v) } + if let p = params, let v = p[key]?.value as? Int { return v } + if let p = params, let v = p[key]?.value as? Double { return Int(v) } + return nil + } + + func double(_ key: String) -> Double? { + if let p = params, let v = p[key]?.value as? Double { return v } + if let p = params, let v = p[key]?.value as? Int64 { return Double(v) } + if let p = params, let v = p[key]?.value as? Int { return Double(v) } + return nil + } + + func bool(_ key: String) -> Bool? { + guard let p = params, let v = p[key]?.value as? Bool else { return nil } + return v + } + + func dict(_ key: String) -> [String: Any]? { + guard let p = params, let v = p[key]?.value as? [String: Any] else { return nil } + return v + } + + func stringArray(_ key: String) -> [String]? { + guard let p = params, let arr = p[key]?.value as? [Any] else { return nil } + return arr.compactMap { $0 as? String } + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Server/RequestRouter.swift b/drivers/ios/DeviceLabDriver/Sources/Server/RequestRouter.swift new file mode 100644 index 00000000..ffd89421 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Server/RequestRouter.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Dispatches incoming requests to the appropriate handler by method prefix. +final class RequestRouter { + let session: SessionHandler + let element: ElementHandler + let gesture: GestureHandler + let input: InputHandler + let app: AppHandler + let settings: SettingsHandler + + init(session: SessionHandler, element: ElementHandler, gesture: GestureHandler, + input: InputHandler, app: AppHandler, settings: SettingsHandler) { + self.session = session + self.element = element + self.gesture = gesture + self.input = input + self.app = app + self.settings = settings + } + + typealias JSONCallback = (Response) -> Void + typealias BinaryCallback = (Int64, Data) -> Void + + /// Handle a request: dispatch to the correct handler and call back with the response. + func handle(_ request: Request, sendJSON: @escaping JSONCallback, sendBinary: @escaping BinaryCallback) { + // XCTest APIs (XCUIApplication, tap, typeText, snapshot, etc.) must run on + // the main thread. Dispatch all handler work there. The WebSocket server + // runs its receive loop on a Network.framework queue, so this won't deadlock. + DispatchQueue.main.async { [self] in + let method = request.method + var response = Response(id: request.id) + + do { + if method.hasPrefix("Session.") { + response.result = try session.handle(method: method, request: request) + } else if method.hasPrefix("UI.") { + // UI.screenshot returns binary data + if method == "UI.screenshot" { + let jpegData = try element.screenshot() + sendBinary(request.id, jpegData) + return + } + response.result = try element.handle(method: method, request: request) + } else if method.hasPrefix("Gesture.") { + response.result = try gesture.handle(method: method, request: request) + } else if method.hasPrefix("Input.") { + response.result = try input.handle(method: method, request: request) + } else if method.hasPrefix("Device.") || method.hasPrefix("App.") { + response.result = try app.handle(method: method, request: request) + } else if method.hasPrefix("Settings.") { + response.result = try settings.handle(method: method, request: request) + } else if method.hasPrefix("Alert.") { + response.result = try session.handleAlert(method: method, request: request) + } else { + throw HandlerError.unknownMethod(method) + } + } catch { + response.error = ErrorPayload( + code: "handler_error", + message: error.localizedDescription + ) + } + + sendJSON(response) + } + } +} + +enum HandlerError: LocalizedError { + case unknownMethod(String) + case missingParam(String) + case invalidParam(String) + case elementNotFound + case notImplemented(String) + + var errorDescription: String? { + switch self { + case .unknownMethod(let m): return "Unknown method: \(m)" + case .missingParam(let p): return "Missing parameter: \(p)" + case .invalidParam(let p): return "Invalid parameter: \(p)" + case .elementNotFound: return "Element not found" + case .notImplemented(let m): return "Not implemented: \(m)" + } + } +} diff --git a/drivers/ios/DeviceLabDriver/Sources/Server/WebSocketServer.swift b/drivers/ios/DeviceLabDriver/Sources/Server/WebSocketServer.swift new file mode 100644 index 00000000..d11bc676 --- /dev/null +++ b/drivers/ios/DeviceLabDriver/Sources/Server/WebSocketServer.swift @@ -0,0 +1,178 @@ +import Foundation +import Network + +/// Single-client WebSocket server using NWListener (Network.framework). +/// Zero external dependencies — ships with iOS 13+. +final class WebSocketServer { + private var listener: NWListener? + private var connection: NWConnection? + private let port: UInt16 + private let queue = DispatchQueue(label: "com.devicelab.ws", qos: .userInteractive) + private let router: RequestRouter + + /// Called when a client connects and the server is ready. + var onReady: (() -> Void)? + + init(port: UInt16, router: RequestRouter) { + self.port = port + self.router = router + } + + /// Start listening. Blocks until the listener is cancelled. + func start() throws { + let params = NWParameters.tcp + let wsOptions = NWProtocolWebSocket.Options() + wsOptions.autoReplyPing = true + params.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0) + + guard let nwPort = NWEndpoint.Port(rawValue: port) else { + throw ServerError.invalidPort + } + + let listener = try NWListener(using: params, on: nwPort) + self.listener = listener + + listener.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + NSLog("[DeviceLabDriver] WebSocket server listening on port \(self?.port ?? 0)") + case .failed(let error): + NSLog("[DeviceLabDriver] Listener failed: \(error)") + default: + break + } + } + + listener.newConnectionHandler = { [weak self] conn in + self?.handleConnection(conn) + } + + listener.start(queue: queue) + } + + func stop() { + connection?.cancel() + listener?.cancel() + } + + // MARK: - Connection handling + + private func handleConnection(_ conn: NWConnection) { + // Single client — replace existing + connection?.cancel() + connection = conn + + conn.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + NSLog("[DeviceLabDriver] Client connected") + self?.onReady?() + self?.receiveMessages(conn) + case .failed(let error): + NSLog("[DeviceLabDriver] Connection failed: \(error)") + case .cancelled: + NSLog("[DeviceLabDriver] Connection cancelled") + default: + break + } + } + + conn.start(queue: queue) + } + + private func receiveMessages(_ conn: NWConnection) { + conn.receiveMessage { [weak self] data, context, _, error in + guard let self = self else { return } + + if let error = error { + NSLog("[DeviceLabDriver] Receive error: \(error)") + return + } + + if let data = data, !data.isEmpty { + self.handleMessage(data, context: context, on: conn) + } + + // Continue receiving + self.receiveMessages(conn) + } + } + + private func handleMessage(_ data: Data, context: NWConnection.ContentContext?, on conn: NWConnection) { + // Check if it's a WebSocket text message + let isText = context?.protocolMetadata(definition: NWProtocolWebSocket.definition) + .flatMap { $0 as? NWProtocolWebSocket.Metadata } + .map { $0.opcode == .text } ?? true + + guard isText else { + NSLog("[DeviceLabDriver] Ignoring non-text frame") + return + } + + // Parse request + let decoder = JSONDecoder() + guard let request = try? decoder.decode(Request.self, from: data) else { + NSLog("[DeviceLabDriver] Failed to decode request: \(String(data: data, encoding: .utf8) ?? "?")") + return + } + + // Route and handle + router.handle(request) { [weak self] response in + self?.sendResponse(response, on: conn) + } sendBinary: { [weak self] id, binaryData in + self?.sendBinaryResponse(id: id, data: binaryData, on: conn) + } + } + + // MARK: - Send + + private func sendResponse(_ response: Response, on conn: NWConnection) { + let encoder = JSONEncoder() + guard let data = try? encoder.encode(response) else { + NSLog("[DeviceLabDriver] Failed to encode response") + return + } + + let metadata = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "text", metadata: [metadata]) + + conn.send(content: data, contentContext: context, isComplete: true, completion: .contentProcessed { error in + if let error = error { + NSLog("[DeviceLabDriver] Send error: \(error)") + } + }) + } + + private func sendBinaryResponse(id: Int64, data: Data, on conn: NWConnection) { + // Binary frame format: [8-byte big-endian request ID][raw payload] + var frame = Data(count: 8) + var bigEndianID = id.bigEndian + withUnsafeBytes(of: &bigEndianID) { frame.replaceSubrange(0..<8, with: $0) } + frame.append(data) + + let metadata = NWProtocolWebSocket.Metadata(opcode: .binary) + let context = NWConnection.ContentContext(identifier: "binary", metadata: [metadata]) + + conn.send(content: frame, contentContext: context, isComplete: true, completion: .contentProcessed { error in + if let error = error { + NSLog("[DeviceLabDriver] Binary send error: \(error)") + } + }) + } + + /// Send an event (unsolicited push message). + func sendEvent(_ event: Event) { + guard let conn = connection else { return } + let encoder = JSONEncoder() + guard let data = try? encoder.encode(event) else { return } + + let metadata = NWProtocolWebSocket.Metadata(opcode: .text) + let context = NWConnection.ContentContext(identifier: "event", metadata: [metadata]) + + conn.send(content: data, contentContext: context, isComplete: true, completion: .contentProcessed { _ in }) + } +} + +enum ServerError: Error { + case invalidPort +} diff --git a/e2e/workspaces/contacts/add_contact_android.yaml b/e2e/workspaces/contacts/add_contact_android.yaml new file mode 100644 index 00000000..82d939cb --- /dev/null +++ b/e2e/workspaces/contacts/add_contact_android.yaml @@ -0,0 +1,45 @@ +appId: com.google.android.contacts +name: Add a new contact (Android) +tags: + - android + - contacts +--- +# Launch with a clean slate +- launchApp: + clearState: true +- waitForAnimationToEnd + +# Open the create-contact form +- tapOn: + id: "com.google.android.contacts:id/floating_action_button" +- waitForAnimationToEnd + +# Fill in the name +- tapOn: + text: "First name" +- inputText: Alice + +- hideKeyboard +- tapOn: + text: "Last name" +- inputText: Tester + +# Fill in the phone number +- hideKeyboard: + strategy: "escape" +- waitForAnimationToEnd +- tapOn: + text: "+1" +- inputText: + text: "5550100" + keyPress: true + +# Save the contact +- hideKeyboard: + strategy: "back" +- tapOn: + text: "Save" +- waitForAnimationToEnd + +# Verify the contact now appears in the list +- assertVisible: "Alice Tester" diff --git a/e2e/workspaces/contacts/add_contact_ios.yaml b/e2e/workspaces/contacts/add_contact_ios.yaml new file mode 100644 index 00000000..f8831089 --- /dev/null +++ b/e2e/workspaces/contacts/add_contact_ios.yaml @@ -0,0 +1,43 @@ +appId: com.apple.MobileAddressBook +name: Add a new contact (iOS) +tags: + - ios + - contacts +--- +# Launch the Contacts system app on iOS +- launchApp: + stopApp: true +- waitForAnimationToEnd + +# Open the create-contact form +- tapOn: Add +- waitForAnimationToEnd + +# Fill in the name +- tapOn: + text: "First name" +- inputText: Alice + +- tapOn: + text: "Last name" +- inputText: Tester + +# Fill in the phone number +- waitForAnimationToEnd +- swipe: + start: 50%, 42% + end: 50%, 12% + duration: 700 +- tapOn: + text: "add phone" +- tapOn: + text: "phone" +- inputText: "5550100" + +# Save the contact +- tapOn: + text: "Done" +- waitForAnimationToEnd + +# Verify the contact now appears in the list +- assertVisible: "Alice Tester" diff --git a/e2e/workspaces/contacts/contact_persists.yaml b/e2e/workspaces/contacts/contact_persists.yaml new file mode 100644 index 00000000..1e297bb5 --- /dev/null +++ b/e2e/workspaces/contacts/contact_persists.yaml @@ -0,0 +1,16 @@ +appId: com.google.android.contacts +name: Contact persists after relaunch +tags: + - android + - contacts +--- +# First create the contact (reuses the add_contact_android flow as setup) +- runFlow: add_contact_android.yaml + +# Cold-relaunch the app +- stopApp +- launchApp +- waitForAnimationToEnd + +# The contact must still be visible +- assertVisible: "Alice Tester" diff --git a/e2e/workspaces/demo_app/ai_complex.yaml b/e2e/workspaces/demo_app/ai_complex.yaml new file mode 100644 index 00000000..e8e78c76 --- /dev/null +++ b/e2e/workspaces/demo_app/ai_complex.yaml @@ -0,0 +1,11 @@ +appId: com.example.example +tags: + - failing + - ai +--- +- launchApp: + clearState: true +- tapOn: Defects Test +- assertNoDefectsWithAI: + optional: true +- assertWithAI: A picture of a cute bunny is visible diff --git a/e2e/workspaces/demo_app/ai_simple.yaml b/e2e/workspaces/demo_app/ai_simple.yaml new file mode 100644 index 00000000..ca1336de --- /dev/null +++ b/e2e/workspaces/demo_app/ai_simple.yaml @@ -0,0 +1,10 @@ +appId: com.example.example +tags: + - failing + - ai +--- +- launchApp: + clearState: true +- assertWithAI: + optional: true + assertion: A login screen is visible diff --git a/e2e/workspaces/demo_app/commands/assertNotVisible.yaml b/e2e/workspaces/demo_app/commands/assertNotVisible.yaml new file mode 100644 index 00000000..c55b0fe6 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/assertNotVisible.yaml @@ -0,0 +1,13 @@ +appId: com.example.example +--- + +- launchApp # For idempotence of sections + +- assertNotVisible: 'kwyjibo' + +- assertNotVisible: + text: 'kwyjibo' + +- assertNotVisible: + text: 'Form Test' + enabled: false \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/assertScreenshotCropped.yaml b/e2e/workspaces/demo_app/commands/assertScreenshotCropped.yaml new file mode 100644 index 00000000..fb558549 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/assertScreenshotCropped.yaml @@ -0,0 +1,13 @@ +appId: com.example.example +tags: + - passing +--- +- launchApp +- tapOn: "Cropped Screenshot Test" +- waitForAnimationToEnd + +- assertScreenshot: + path: workspaces/demo_app/screenshots/assertScreenshotCropped.png + thresholdPercentage: 99.9 + cropOn: + id: "testContainer" diff --git a/e2e/workspaces/demo_app/commands/assertScreenshotCroppedHEIC.yaml b/e2e/workspaces/demo_app/commands/assertScreenshotCroppedHEIC.yaml new file mode 100644 index 00000000..b89f8011 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/assertScreenshotCroppedHEIC.yaml @@ -0,0 +1,12 @@ +appId: com.example.example +tags: + - failing +--- +- launchApp +- tapOn: "Cropped Screenshot Test" +- waitForAnimationToEnd + +- assertScreenshot: + path: workspaces/demo_app/screenshots/assertScreenshotCroppedHEIC.heic + cropOn: + id: "testContainer" diff --git a/e2e/workspaces/demo_app/commands/assertScreenshotCroppedJPG.yaml b/e2e/workspaces/demo_app/commands/assertScreenshotCroppedJPG.yaml new file mode 100644 index 00000000..cee8ca27 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/assertScreenshotCroppedJPG.yaml @@ -0,0 +1,13 @@ +appId: com.example.example +tags: + - passing +--- +- launchApp +- tapOn: "Cropped Screenshot Test" +- waitForAnimationToEnd + +- assertScreenshot: + path: workspaces/demo_app/screenshots/assertScreenshotCroppedJPG.jpg + thresholdPercentage: 99.9 + cropOn: + id: "testContainer" diff --git a/e2e/workspaces/demo_app/commands/assertTrue.yaml b/e2e/workspaces/demo_app/commands/assertTrue.yaml new file mode 100644 index 00000000..8b4b364f --- /dev/null +++ b/e2e/workspaces/demo_app/commands/assertTrue.yaml @@ -0,0 +1,12 @@ +appId: com.example.example +--- + +- launchApp # For idempotence of sections + +- assertTrue: ${"test" == "test"} + +- assertTrue: + condition: ${12 < 20} + +- assertTrue: + condition: ${THING == "five"} # Using the env at the top of the file \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/assertVisible.yaml b/e2e/workspaces/demo_app/commands/assertVisible.yaml new file mode 100644 index 00000000..4a4a91cf --- /dev/null +++ b/e2e/workspaces/demo_app/commands/assertVisible.yaml @@ -0,0 +1,12 @@ +appId: com.example.example +--- + +- launchApp # For idempotence of sections + +- assertVisible: 'Form Test' + +- assertVisible: + text: 'Form Test' + +- assertVisible: + id: 'fabAddIcon' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/back.yaml b/e2e/workspaces/demo_app/commands/back.yaml new file mode 100644 index 00000000..c937911a --- /dev/null +++ b/e2e/workspaces/demo_app/commands/back.yaml @@ -0,0 +1,8 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Form Test' +- assertVisible: 'Login' +- back +- assertVisible: 'Form Test' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/copyTextFrom.yaml b/e2e/workspaces/demo_app/commands/copyTextFrom.yaml new file mode 100644 index 00000000..19c8f3c5 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/copyTextFrom.yaml @@ -0,0 +1,10 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: + id: 'fabAddIcon' + retryTapIfNoChange: false +- copyTextFrom: + text: '\d+' +- assertTrue: ${maestro.copiedText == '1'} \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/eraseText.yaml b/e2e/workspaces/demo_app/commands/eraseText.yaml new file mode 100644 index 00000000..690d2a1d --- /dev/null +++ b/e2e/workspaces/demo_app/commands/eraseText.yaml @@ -0,0 +1,25 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Form Test' +- tapOn: 'Email' +- inputText: 'foo' +- assertVisible: 'foo' +- eraseText +# Fix me this part is flaky on CI only not local, needs to be addressed why +- assertNotVisible: + text: 'foo' + optional: true + +- inputText: 'testing' +- assertVisible: + text: 'testing' + above: 'Login' +- eraseText: 3 +- assertNotVisible: + text: 'testing' + above: 'Login' # In case there's a keyboard suggestion +- assertVisible: + text: 'test' + above: 'Login' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/evalScript.yaml b/e2e/workspaces/demo_app/commands/evalScript.yaml new file mode 100644 index 00000000..06d99f70 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/evalScript.yaml @@ -0,0 +1,7 @@ +appId: com.example.example +--- + +- launchApp # For idempotence of sections + +- evalScript: ${output.test = 'foo'} +- assertTrue: ${output.test == 'foo'} \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/extendedWaitUntil.yaml b/e2e/workspaces/demo_app/commands/extendedWaitUntil.yaml new file mode 100644 index 00000000..69c3081b --- /dev/null +++ b/e2e/workspaces/demo_app/commands/extendedWaitUntil.yaml @@ -0,0 +1,13 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- extendedWaitUntil: + timeout: 10000 + visible: + text: 'Swipe Test' + +- extendedWaitUntil: + timeout: 100 + notVisible: + text: 'Non Existent Text' diff --git a/e2e/workspaces/demo_app/commands/hideKeyboard.yaml b/e2e/workspaces/demo_app/commands/hideKeyboard.yaml new file mode 100644 index 00000000..e226e5d9 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/hideKeyboard.yaml @@ -0,0 +1,26 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Form Test' +- tapOn: 'Email' + +- runFlow: + when: + platform: android + commands: + - assertVisible: + id: com.google.android.inputmethod.latin:id/key_pos_shift # The shift key on the Android keyboard + - hideKeyboard + - assertNotVisible: + id: com.google.android.inputmethod.latin:id/key_pos_shift + +- runFlow: + when: + platform: ios + commands: + - assertVisible: + id: shift # The shift key on the iOS keyboard + - hideKeyboard + - assertNotVisible: + id: shift \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/inputRandomEmail.yaml b/e2e/workspaces/demo_app/commands/inputRandomEmail.yaml new file mode 100644 index 00000000..6b4d2596 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/inputRandomEmail.yaml @@ -0,0 +1,9 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Input/Keyboard' +- tapOn: + id: 'textInput' +- inputRandomEmail +- assertVisible: '.+@.+' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/inputRandomNumber.yaml b/e2e/workspaces/demo_app/commands/inputRandomNumber.yaml new file mode 100644 index 00000000..01df2007 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/inputRandomNumber.yaml @@ -0,0 +1,18 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Input/Keyboard' +- tapOn: + id: 'textInput' +- inputRandomNumber +- assertVisible: + text: '\d{8}' # The default length is 8 + id: 'textInput' + +- eraseText +- inputRandomNumber: + length: 4 +- assertVisible: + text: '\d{4}' + id: 'textInput' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/inputRandomPersonName.yaml b/e2e/workspaces/demo_app/commands/inputRandomPersonName.yaml new file mode 100644 index 00000000..c2921c73 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/inputRandomPersonName.yaml @@ -0,0 +1,11 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Input/Keyboard' +- tapOn: + id: 'textInput' +- inputRandomPersonName +- assertVisible: + text: '[A-Z][a-z]+ [A-Z][a-z]+' + id: 'textInput' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/inputRandomText.yaml b/e2e/workspaces/demo_app/commands/inputRandomText.yaml new file mode 100644 index 00000000..0540bb1c --- /dev/null +++ b/e2e/workspaces/demo_app/commands/inputRandomText.yaml @@ -0,0 +1,18 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Input/Keyboard' +- tapOn: + id: 'textInput' +- inputRandomText +- assertVisible: + text: '[a-z0-9]{8}' # The default length is 8 + id: 'textInput' + +- eraseText +- inputRandomText: + length: 4 +- assertVisible: + text: '[a-z0-9]{4}' + id: 'textInput' diff --git a/e2e/workspaces/demo_app/commands/inputText.yaml b/e2e/workspaces/demo_app/commands/inputText.yaml new file mode 100644 index 00000000..566fa21f --- /dev/null +++ b/e2e/workspaces/demo_app/commands/inputText.yaml @@ -0,0 +1,9 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Input/Keyboard' +- tapOn: + id: 'textInput' +- inputText: 'foo' +- assertVisible: 'foo' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/killApp.yaml b/e2e/workspaces/demo_app/commands/killApp.yaml new file mode 100644 index 00000000..a66be89f --- /dev/null +++ b/e2e/workspaces/demo_app/commands/killApp.yaml @@ -0,0 +1,7 @@ +appId: com.example.example +--- + +- launchApp +- assertVisible: 'Form Test' +- killApp +- assertNotVisible: 'Form Test' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/launchApp.yaml b/e2e/workspaces/demo_app/commands/launchApp.yaml new file mode 100644 index 00000000..26ae4406 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/launchApp.yaml @@ -0,0 +1,14 @@ +appId: com.example.example +--- +- launchApp: + appId: com.example.example + clearState: true + clearKeychain: true + stopApp: true + permissions: + all: allow +- assertVisible: 'Form Test' + +- stopApp +- launchApp +- assertVisible: 'Form Test' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/pasteText.yaml b/e2e/workspaces/demo_app/commands/pasteText.yaml new file mode 100644 index 00000000..6b5fbf9c --- /dev/null +++ b/e2e/workspaces/demo_app/commands/pasteText.yaml @@ -0,0 +1,33 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- tapOn: 'Input/Keyboard' + +# Can paste +- tapOn: + id: 'textInput' +- inputText: 'Foo' +- copyTextFrom: + id: 'textInput' +- eraseText +- assertNotVisible: 'Foo' +- pasteText +- assertVisible: 'Foo' + +# Can paste repeatedly +- eraseText +- inputText: 'bar' +- copyTextFrom: + id: 'textInput' +- eraseText +- pasteText +- pasteText +- assertVisible: 'barbar' + +# Copied Text cannot be overridden +- eraseText +- evalScript: ${maestro.copiedText = 'foo'} +- pasteText +- assertNotVisible: 'foo' +- assertVisible: 'bar' \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/pressKey.yaml b/e2e/workspaces/demo_app/commands/pressKey.yaml new file mode 100644 index 00000000..a6ac2bba --- /dev/null +++ b/e2e/workspaces/demo_app/commands/pressKey.yaml @@ -0,0 +1,16 @@ +appId: com.example.example +--- +- launchApp + +- assertVisible: 'Form Test' +- pressKey: 'Home' +- assertNotVisible: 'Form Test' + +- launchApp + +- assertVisible: 'Form Test' +- tapOn: 'Form Test' +- tapOn: 'Email' +- inputText: 'types' +- pressKey: backspace +- assertVisible: 'type' diff --git a/e2e/workspaces/demo_app/commands/repeat.yaml b/e2e/workspaces/demo_app/commands/repeat.yaml new file mode 100644 index 00000000..49047447 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/repeat.yaml @@ -0,0 +1,19 @@ +appId: com.example.example +--- +- launchApp # For idempotence of sections + +- assertVisible: '0' +- repeat: + times: 3 + commands: + - tapOn: + id: 'fabAddIcon' +- assertVisible: '3' +- assertNotVisible: '0' + +- evalScript: ${output.counter = 0} +- repeat: + while: + true: ${output.counter < 3} + commands: + - evalScript: ${output.counter = output.counter + 1} \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/retry.yaml b/e2e/workspaces/demo_app/commands/retry.yaml new file mode 100644 index 00000000..04c91920 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/retry.yaml @@ -0,0 +1,13 @@ +appId: com.example.example +--- +- launchApp + +- retry: + maxRetries: 3 + commands: + - tapOn: + id: 'fabAddIcon' + retryTapIfNoChange: false + - waitForAnimationToEnd + - assertVisible: '2' +- assertVisible: 'Flutter Demo Home Page' diff --git a/e2e/workspaces/demo_app/commands/runFlow.yaml b/e2e/workspaces/demo_app/commands/runFlow.yaml new file mode 100644 index 00000000..0e1c2b5a --- /dev/null +++ b/e2e/workspaces/demo_app/commands/runFlow.yaml @@ -0,0 +1,20 @@ +appId: com.example.example +--- + +# runFlow with file: isn't included since it's in the root flow + +- launchApp # For idempotence of sections + +- runFlow: + commands: + - evalScript: ${output.test = 'bar'} + - assertTrue: ${output.test == 'bar'} + - tapOn: + id: 'fabAddIcon' +- assertVisible: '1' + +- runFlow: + env: + THIS_THING: "six" + commands: + - assertTrue: ${THIS_THING == "six"} \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/runScript.js b/e2e/workspaces/demo_app/commands/runScript.js new file mode 100644 index 00000000..a3c2c1ff --- /dev/null +++ b/e2e/workspaces/demo_app/commands/runScript.js @@ -0,0 +1,5 @@ +if (THIS_THING == "six"){ + output.something = "foo" +} else { + output.something = "bar" +} \ No newline at end of file diff --git a/e2e/workspaces/demo_app/commands/runScript.yaml b/e2e/workspaces/demo_app/commands/runScript.yaml new file mode 100644 index 00000000..32fdb650 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/runScript.yaml @@ -0,0 +1,23 @@ +appId: com.example.example +--- + +- launchApp # For idempotence of sections + +- evalScript: ${output.something = 'baz'} + +- runScript: runScript.js +- assertTrue: ${output.something == 'bar'} + +- evalScript: ${output.something = 'baz'} + +- runScript: + file: runScript.js +- assertTrue: ${output.something == 'bar'} + +- evalScript: ${output.something = 'baz'} + +- runScript: + env: + THIS_THING: "six" + file: runScript.js +- assertTrue: ${output.something == 'foo'} diff --git a/e2e/workspaces/demo_app/commands/takeScreenshot.yaml b/e2e/workspaces/demo_app/commands/takeScreenshot.yaml new file mode 100644 index 00000000..cb5495f9 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/takeScreenshot.yaml @@ -0,0 +1,16 @@ +appId: com.example.example +--- +- launchApp +- tapOn: "Nesting Test" +- waitForAnimationToEnd + +- takeScreenshot: + path: workspaces/demo_app/screenshots/e2e_take_then_assert + cropOn: + id: "level-1" + +- assertScreenshot: + path: workspaces/demo_app/screenshots/e2e_take_then_assert.png + thresholdPercentage: 95 + cropOn: + id: "level-1" diff --git a/e2e/workspaces/demo_app/commands/takeScreenshotFullScreen.yaml b/e2e/workspaces/demo_app/commands/takeScreenshotFullScreen.yaml new file mode 100644 index 00000000..e0fd3cf9 --- /dev/null +++ b/e2e/workspaces/demo_app/commands/takeScreenshotFullScreen.yaml @@ -0,0 +1,14 @@ +appId: com.example.example +tags: + - passing +--- +- launchApp +- tapOn: "Nesting Test" +- waitForAnimationToEnd + +- takeScreenshot: + path: workspaces/demo_app/screenshots/e2e_take_then_assert_full_screen + +- assertScreenshot: + path: workspaces/demo_app/screenshots/e2e_take_then_assert_full_screen.png + thresholdPercentage: 95 diff --git a/e2e/workspaces/demo_app/commands_optional_tournee.yaml b/e2e/workspaces/demo_app/commands_optional_tournee.yaml new file mode 100644 index 00000000..a50c3444 --- /dev/null +++ b/e2e/workspaces/demo_app/commands_optional_tournee.yaml @@ -0,0 +1,44 @@ +# This flow is to ensure that commands with optional flag are not failing the flow. +appId: com.example.example +tags: + - passing +--- +- launchApp: + clearState: true +- assertVisible: + id: non-existent-id + optional: true +- assertNotVisible: + text: Flutter Demo Home Page + optional: true +- assertTrue: + condition: ${ false } + label: Warn + optional: true +- tapOn: + id: non-existent-id + optional: true +- doubleTapOn: + id: non-existent-id + optional: true +- longPressOn: + id: non-existent-id + optional: true +- copyTextFrom: + id: non-existent-id + optional: true +- launchApp: + appId: non.existent.app.id + optional: true +- tapOn: + id: non-existent-id + repeat: 3 + delay: 500 + optional: true +# - swipe: +# optional: true +- scrollUntilVisible: + element: + id: non-existent-id + optional: true + optional: true diff --git a/e2e/workspaces/demo_app/commands_tour.yaml b/e2e/workspaces/demo_app/commands_tour.yaml new file mode 100644 index 00000000..68cb396b --- /dev/null +++ b/e2e/workspaces/demo_app/commands_tour.yaml @@ -0,0 +1,197 @@ +# This flow exercises as many commands as possible, using as many configurations as possible. +appId: com.example.example +tags: + - passing +env: + THING: "five" + RUN_ONLY: "" # Set to a command name to run only that command +--- + +# TODO: addMedia + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "assertNotVisible"} + file: commands/assertNotVisible.yaml + label: assertNotVisible + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "assertTrue"} + file: commands/assertTrue.yaml + label: assertTrue + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "assertVisible"} + file: commands/assertVisible.yaml + label: assertVisible + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "assertScreenshot"} + file: commands/assertScreenshotCropped.yaml + label: assertScreenshot + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "back"} + platform: android + file: commands/back.yaml + label: back + +# TODO: clearKeychain + +# TODO: clearState + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "copyTextFrom"} + file: commands/copyTextFrom.yaml + label: copyTextFrom + +# TODO: doubleTapOn + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "eraseText"} + file: commands/eraseText.yaml + label: eraseText + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "evalScript"} + file: commands/evalScript.yaml + label: evalScript + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "extendedWaitUntil"} + file: commands/extendedWaitUntil.yaml + label: extendedWaitUntil + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "hideKeyboard"} + platform: android + file: commands/hideKeyboard.yaml + label: hideKeyboard + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "inputText"} + file: commands/inputText.yaml + label: inputText + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "inputRandomEmail"} + file: commands/inputRandomEmail.yaml + label: inputRandomEmail + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "inputRandomPersonName"} + file: commands/inputRandomPersonName.yaml + label: inputRandomPersonName + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "inputRandomPhoneNumber"} + file: commands/inputRandomNumber.yaml + label: inputRandomNumber + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "inputRandomText"} + file: commands/inputRandomText.yaml + label: inputRandomText + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "killApp"} + file: commands/killApp.yaml + label: killApp + optional: true # FIXME: Why is this failing? + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "launchApp"} + file: commands/launchApp.yaml + label: launchApp + +# TODO: longPressOn + +# TODO: openLink (probably after #2058) + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "pasteText"} + file: commands/pasteText.yaml + label: pasteText + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "pressKey"} + file: commands/pressKey.yaml + label: pressKey + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "repeat"} + file: commands/repeat.yaml + label: repeat + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "retry"} + file: commands/retry.yaml + label: retry + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "runFlow"} + file: commands/runFlow.yaml + label: runFlow + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "runScript"} + file: commands/runScript.yaml + label: runScript + +# TODO: scroll + +# TODO: scrollUntilVisible + +# TODO: setAirplaneMode + +# TODO: setLocation + +# TODO: startRecording + +# TODO: stopApp + +# TODO: stopRecording + +# TODO: swipe + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "takeScreenshot"} + file: commands/takeScreenshot.yaml + label: takeScreenshot + +- runFlow: + when: + true: ${RUN_ONLY == "" || RUN_ONLY == "takeScreenshotFullScreen"} + file: commands/takeScreenshotFullScreen.yaml + label: takeScreenshotFullScreen + +# TODO: tapOn + +# TODO: toggleAirplaneMode + +# TODO: travel + +# TODO: waitForAnimationToEnd diff --git a/e2e/workspaces/demo_app/fail_assertScreenshot.yaml b/e2e/workspaces/demo_app/fail_assertScreenshot.yaml new file mode 100644 index 00000000..279ac2d7 --- /dev/null +++ b/e2e/workspaces/demo_app/fail_assertScreenshot.yaml @@ -0,0 +1,11 @@ +appId: com.example.example +tags: + - failing +--- +- launchApp +- tapOn: "Cropped Screenshot Test" + +- assertScreenshot: + path: workspaces/demo_app/screenshots/fail_assertScreenshot.png + cropOn: + id: "testContainer" diff --git a/e2e/workspaces/demo_app/fail_fast.yaml b/e2e/workspaces/demo_app/fail_fast.yaml new file mode 100644 index 00000000..ee601ab9 --- /dev/null +++ b/e2e/workspaces/demo_app/fail_fast.yaml @@ -0,0 +1,9 @@ +appId: com.example.example +tags: + - failing +--- +- launchApp: + clearState: true +- assertTrue: + condition: ${ false } + label: Fail the flow diff --git a/e2e/workspaces/demo_app/fail_launchApp.yaml b/e2e/workspaces/demo_app/fail_launchApp.yaml new file mode 100644 index 00000000..64125f66 --- /dev/null +++ b/e2e/workspaces/demo_app/fail_launchApp.yaml @@ -0,0 +1,5 @@ +appId: com.nonexistent +tags: + - failing +--- +- launchApp diff --git a/e2e/workspaces/demo_app/fail_launchApp_nonDefault.yaml b/e2e/workspaces/demo_app/fail_launchApp_nonDefault.yaml new file mode 100644 index 00000000..6cc9b055 --- /dev/null +++ b/e2e/workspaces/demo_app/fail_launchApp_nonDefault.yaml @@ -0,0 +1,6 @@ +appId: com.example.example +tags: + - failing +--- +- launchApp: + appId: com.nonexistent diff --git a/e2e/workspaces/demo_app/fail_not_found.yaml b/e2e/workspaces/demo_app/fail_not_found.yaml new file mode 100644 index 00000000..4fd5a7dd --- /dev/null +++ b/e2e/workspaces/demo_app/fail_not_found.yaml @@ -0,0 +1,8 @@ +appId: com.example.example +tags: + - failing +--- +- launchApp: + clearState: true +- tapOn: + id: non-existent-id diff --git a/e2e/workspaces/demo_app/fail_visible.yaml b/e2e/workspaces/demo_app/fail_visible.yaml new file mode 100644 index 00000000..9b20f0d2 --- /dev/null +++ b/e2e/workspaces/demo_app/fail_visible.yaml @@ -0,0 +1,8 @@ +appId: com.example.example +tags: + - failing +--- +- launchApp: + clearState: true +- assertVisible: + id: non-existent-id diff --git a/e2e/workspaces/demo_app/fail_visible_extended.yaml b/e2e/workspaces/demo_app/fail_visible_extended.yaml new file mode 100644 index 00000000..85dc35aa --- /dev/null +++ b/e2e/workspaces/demo_app/fail_visible_extended.yaml @@ -0,0 +1,10 @@ +appId: com.example.example +tags: + - failing +--- +- launchApp: + clearState: true +- extendedWaitUntil: + visible: + id: non-existent-id + timeout: 100 diff --git a/e2e/workspaces/demo_app/fill_form.yaml b/e2e/workspaces/demo_app/fill_form.yaml new file mode 100644 index 00000000..55d7d328 --- /dev/null +++ b/e2e/workspaces/demo_app/fill_form.yaml @@ -0,0 +1,17 @@ +appId: com.example.example +tags: + - passing +--- +- launchApp: + clearState: true +- tapOn: Form Test +- tapOn: Email +- inputText: correct@mobile.dev +- tapOn: Password +- inputText: maestro +- tapOn: + text: Login + index: 1 +- assertVisible: + text: Credentials are correct + optional: true # Fix me this part is flaky on CI only not local, needs to be addressed why diff --git a/e2e/workspaces/demo_app/long_input_text.yaml b/e2e/workspaces/demo_app/long_input_text.yaml new file mode 100644 index 00000000..9c2b51e1 --- /dev/null +++ b/e2e/workspaces/demo_app/long_input_text.yaml @@ -0,0 +1,9 @@ +appId: com.example.example +tags: + - passing +--- +- launchApp: + clearState: true +- tapOn: Form Test +- tapOn: Email +- inputText: veryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryverylongemail@mobile.dev diff --git a/e2e/workspaces/demo_app/relatives.yaml b/e2e/workspaces/demo_app/relatives.yaml new file mode 100644 index 00000000..582295df --- /dev/null +++ b/e2e/workspaces/demo_app/relatives.yaml @@ -0,0 +1,27 @@ +appId: com.example.example +tags: + - passing + - android # iOS uses a different tree stucture +--- +- launchApp: + clearState: true +- tapOn: Nesting Test +- assertVisible: + id: level-0 +- assertVisible: + id: level-0 + containsChild: + id: level-1 + containsChild: + id: level-2 + rightOf: + text: left side + leftOf: + text: right side + below: top side + above: bottom side +- assertNotVisible: + id: level-0 + containsChild: + id: level-1 + below: bottom side diff --git a/e2e/workspaces/demo_app/screenshots/assertScreenshotCropped.png b/e2e/workspaces/demo_app/screenshots/assertScreenshotCropped.png new file mode 100644 index 00000000..7f0f24c9 Binary files /dev/null and b/e2e/workspaces/demo_app/screenshots/assertScreenshotCropped.png differ diff --git a/e2e/workspaces/demo_app/screenshots/assertScreenshotCroppedHEIC.heic b/e2e/workspaces/demo_app/screenshots/assertScreenshotCroppedHEIC.heic new file mode 100644 index 00000000..83946837 Binary files /dev/null and b/e2e/workspaces/demo_app/screenshots/assertScreenshotCroppedHEIC.heic differ diff --git a/e2e/workspaces/demo_app/screenshots/assertScreenshotCroppedJPG.jpg b/e2e/workspaces/demo_app/screenshots/assertScreenshotCroppedJPG.jpg new file mode 100644 index 00000000..11d55a9a Binary files /dev/null and b/e2e/workspaces/demo_app/screenshots/assertScreenshotCroppedJPG.jpg differ diff --git a/e2e/workspaces/demo_app/screenshots/fail_assertScreenshot.png b/e2e/workspaces/demo_app/screenshots/fail_assertScreenshot.png new file mode 100644 index 00000000..81c3e581 Binary files /dev/null and b/e2e/workspaces/demo_app/screenshots/fail_assertScreenshot.png differ diff --git a/e2e/workspaces/demo_app/scrollUntilVisible_timeout.yaml b/e2e/workspaces/demo_app/scrollUntilVisible_timeout.yaml new file mode 100644 index 00000000..df587d5b --- /dev/null +++ b/e2e/workspaces/demo_app/scrollUntilVisible_timeout.yaml @@ -0,0 +1,13 @@ +appId: com.example.example +tags: + - passing +--- +- launchApp: + clearState: true +- evalScript: ${maestro.startTime = new Date()} +- scrollUntilVisible: + element: non-existent + timeout: 1000 + optional: true +- evalScript: ${maestro.endTime = new Date()} +- assertTrue: ${maestro.endTime - maestro.startTime < 12000} # Far less than the 20000 default, but enough to allow for processing time diff --git a/e2e/workspaces/demo_app/swipe.yaml b/e2e/workspaces/demo_app/swipe.yaml new file mode 100644 index 00000000..97a2966f --- /dev/null +++ b/e2e/workspaces/demo_app/swipe.yaml @@ -0,0 +1,22 @@ +appId: com.example.example +tags: + - passing +--- +- launchApp: + clearState: true +- tapOn: Swipe Test +- swipe: + start: 50%, 15% + end: 15%, 50% + duration: 1000 +- swipe: + start: 15%, 50% + end: 85%, 85% + duration: 1000 +- swipe: + start: 85%, 85% + end: 85%, 50% + duration: 1000 +- tapOn: + point: 85%, 50% +- assertVisible: All green diff --git a/e2e/workspaces/no-app/README.md b/e2e/workspaces/no-app/README.md new file mode 100644 index 00000000..11acb762 --- /dev/null +++ b/e2e/workspaces/no-app/README.md @@ -0,0 +1,5 @@ +# No App + +For tests that don't require an app to be launched. + +Tests of JavaScript or environment variables can be run here. \ No newline at end of file diff --git a/e2e/workspaces/no-app/environment-variables.yaml b/e2e/workspaces/no-app/environment-variables.yaml new file mode 100644 index 00000000..1dc9821c --- /dev/null +++ b/e2e/workspaces/no-app/environment-variables.yaml @@ -0,0 +1,6 @@ +appId: com.example.notused +tags: + - passing +--- +# Relies on MAESTRO_EXAMPLE being set in the environment +- assertTrue: ${MAESTRO_EXAMPLE == 'test-value'} \ No newline at end of file diff --git a/e2e/workspaces/nowinandroid/bookmarks.yaml b/e2e/workspaces/nowinandroid/bookmarks.yaml new file mode 100644 index 00000000..758cfe8f --- /dev/null +++ b/e2e/workspaces/nowinandroid/bookmarks.yaml @@ -0,0 +1,14 @@ +appId: com.google.samples.apps.nowinandroid.demo.debug +name: Bookmarks +tags: + - android + - passing +--- +- launchApp: + clearState: true +- tapOn: Headlines +- tapOn: Done +- tapOn: Bookmark +- tapOn: Saved +- tapOn: Unbookmark +- assertVisible: No saved updates diff --git a/e2e/workspaces/nowinandroid/fail.yaml b/e2e/workspaces/nowinandroid/fail.yaml new file mode 100644 index 00000000..c6528c5c --- /dev/null +++ b/e2e/workspaces/nowinandroid/fail.yaml @@ -0,0 +1,10 @@ +appId: com.google.samples.apps.nowinandroid.demo.debug +name: Fail +tags: + - android + - failing +--- +- launchApp: + clearState: true +- tapOn: + id: non-existent-id-to-fail-this-test diff --git a/e2e/workspaces/sauce_demo/clear_state.yaml b/e2e/workspaces/sauce_demo/clear_state.yaml new file mode 100644 index 00000000..3c0fad92 --- /dev/null +++ b/e2e/workspaces/sauce_demo/clear_state.yaml @@ -0,0 +1,24 @@ +url: https://www.saucedemo.com/inventory.html +name: ClearState command should clear state +tags: + - passing + - web +--- +- launchApp: + clearState: true + +- tapOn: Username +- inputText: standard_user +- tapOn: Password +- inputText: secret_sauce +- tapOn: Login + +- assertVisible: Products + +- clearState +- launchApp + +- assertVisible: Username +- assertVisible: Password +- assertVisible: Login +- assertNotVisible: Products diff --git a/e2e/workspaces/sauce_demo/clear_state_on_launch.yaml b/e2e/workspaces/sauce_demo/clear_state_on_launch.yaml new file mode 100644 index 00000000..30b69ead --- /dev/null +++ b/e2e/workspaces/sauce_demo/clear_state_on_launch.yaml @@ -0,0 +1,25 @@ +url: https://www.saucedemo.com/inventory.html +name: LaunchApp should clear state when clearState is true +tags: + - passing + - web +--- +- launchApp: + clearState: true + +- tapOn: Username +- inputText: standard_user +- tapOn: Password +- inputText: secret_sauce +- tapOn: Login + +- assertVisible: Products + +- launchApp: + url: https://www.saucedemo.com/inventory.html + clearState: true + +- assertVisible: Username +- assertVisible: Password +- assertVisible: Login +- assertNotVisible: Products diff --git a/e2e/workspaces/sauce_demo/retain_state_default.yaml b/e2e/workspaces/sauce_demo/retain_state_default.yaml new file mode 100644 index 00000000..cb5d2d09 --- /dev/null +++ b/e2e/workspaces/sauce_demo/retain_state_default.yaml @@ -0,0 +1,23 @@ +url: https://www.saucedemo.com/inventory.html +name: LaunchApp should not clear state +tags: + - passing + - web +--- +- launchApp: + clearState: true + +- tapOn: Username +- inputText: standard_user +- tapOn: Password +- inputText: secret_sauce +- tapOn: Login + +- assertVisible: Products + +- launchApp # Should not clear state + +- assertVisible: Products +- assertVisible: Sauce Labs Backpack +- assertVisible: Sauce Labs Bike Light +- assertNotVisible: Login diff --git a/e2e/workspaces/sauce_demo/simple.yaml b/e2e/workspaces/sauce_demo/simple.yaml new file mode 100644 index 00000000..8bc7157a --- /dev/null +++ b/e2e/workspaces/sauce_demo/simple.yaml @@ -0,0 +1,19 @@ +url: https://www.saucedemo.com/ +tags: + - passing + - web +--- +- launchApp +- tapOn: Username +- inputText: standard_user +- tapOn: Password +- inputText: secret_sauce +- tapOn: Login + +- assertVisible: Products +- assertVisible: Sauce Labs Backpack +- assertVisible: Sauce Labs Bike Light + +- tapOn: Sauce Labs Backpack +- assertNotVisible: Sauce Labs Bike Light +- assertVisible: '.*sleek.*' diff --git a/e2e/workspaces/setOrientation/test-set-orientation-flow.yaml b/e2e/workspaces/setOrientation/test-set-orientation-flow.yaml new file mode 100644 index 00000000..0fbaf7d1 --- /dev/null +++ b/e2e/workspaces/setOrientation/test-set-orientation-flow.yaml @@ -0,0 +1,14 @@ +appId: com.example.maestro.orientation +tags: + - android + - passing +--- +- launchApp +- setOrientation: LANDSCAPE_LEFT +- assertVisible: "LANDSCAPE_LEFT" +- setOrientation: LANDSCAPE_RIGHT +- assertVisible: "LANDSCAPE_RIGHT" +- setOrientation: UPSIDE_DOWN +- assertVisible: "UPSIDE_DOWN" +- setOrientation: PORTRAIT +- assertVisible: "PORTRAIT" \ No newline at end of file diff --git a/e2e/workspaces/simple_web_view/webview.yaml b/e2e/workspaces/simple_web_view/webview.yaml new file mode 100644 index 00000000..007f5a36 --- /dev/null +++ b/e2e/workspaces/simple_web_view/webview.yaml @@ -0,0 +1,16 @@ +appId: com.example.SimpleWebViewApp +tags: + - passing + - ios +--- +- launchApp: + clearState: true + +- tapOn: Open Login Page + +- extendedWaitUntil: + visible: Login + timeout: 30000 + label: Wait for Login page to load +- assertVisible: Sign In +- assertVisible: Forgot your password? diff --git a/e2e/workspaces/wikipedia/android-advanced-flow.yaml b/e2e/workspaces/wikipedia/android-advanced-flow.yaml new file mode 100644 index 00000000..ce7c3350 --- /dev/null +++ b/e2e/workspaces/wikipedia/android-advanced-flow.yaml @@ -0,0 +1,16 @@ +appId: org.wikipedia +tags: + - android + - passing + - advanced +--- +- runFlow: subflows/onboarding-android.yaml +- tapOn: + id: "org.wikipedia:id/search_container" +- tapOn: + text: "Non existent view" + optional: true +- runScript: scripts/getSearchQuery.js +- inputText: ${output.result} +- assertVisible: ${output.result} +- runFlow: subflows/launch-clearstate-android.yaml diff --git a/e2e/workspaces/wikipedia/android-flow.yaml b/e2e/workspaces/wikipedia/android-flow.yaml new file mode 100644 index 00000000..7d0ced35 --- /dev/null +++ b/e2e/workspaces/wikipedia/android-flow.yaml @@ -0,0 +1,6 @@ +appId: org.wikipedia +tags: + - android + - passing +--- +- launchApp diff --git a/e2e/workspaces/wikipedia/ios-advanced-flow.yaml b/e2e/workspaces/wikipedia/ios-advanced-flow.yaml new file mode 100644 index 00000000..b1c13f32 --- /dev/null +++ b/e2e/workspaces/wikipedia/ios-advanced-flow.yaml @@ -0,0 +1,34 @@ +appId: org.wikimedia.wikipedia +tags: + - ios + - passing + - advanced +--- +- runFlow: subflows/onboarding-ios.yaml + +- runFlow: + when: + visible: + text: Explore your Wikipedia Year in Review + commands: + - tapOn: Done + label: Dismiss Year In Review popup, if visible + +- runFlow: + when: + visible: "You have been logged out" + commands: + - tapOn: + text: "Continue without logging in" + label: Dismiss the auth modal if visible + +- tapOn: + text: "Non existent view" + optional: true +- tapOn: Search Wikipedia +- runScript: scripts/getSearchQuery.js +- inputText: ${output.result} +- eraseText +- inputText: qwerty +- assertVisible: ${output.result} +- runFlow: subflows/launch-clearstate-ios.yaml diff --git a/e2e/workspaces/wikipedia/ios-flow.yaml b/e2e/workspaces/wikipedia/ios-flow.yaml new file mode 100644 index 00000000..217066d6 --- /dev/null +++ b/e2e/workspaces/wikipedia/ios-flow.yaml @@ -0,0 +1,6 @@ +appId: org.wikimedia.wikipedia +tags: + - ios + - passing +--- +- launchApp diff --git a/e2e/workspaces/wikipedia/subflows/launch-clearstate-android.yaml b/e2e/workspaces/wikipedia/subflows/launch-clearstate-android.yaml new file mode 100644 index 00000000..b9461b62 --- /dev/null +++ b/e2e/workspaces/wikipedia/subflows/launch-clearstate-android.yaml @@ -0,0 +1,6 @@ +appId: org.wikipedia +--- +- launchApp: + clearState: true +- assertVisible: "Continue" +- assertVisible: "Skip" \ No newline at end of file diff --git a/e2e/workspaces/wikipedia/subflows/launch-clearstate-ios.yaml b/e2e/workspaces/wikipedia/subflows/launch-clearstate-ios.yaml new file mode 100644 index 00000000..68c8f0ff --- /dev/null +++ b/e2e/workspaces/wikipedia/subflows/launch-clearstate-ios.yaml @@ -0,0 +1,6 @@ +appId: org.wikimedia.wikipedia +--- +- launchApp: + clearState: true +- assertVisible: "Next" +- assertVisible: "Skip" \ No newline at end of file diff --git a/e2e/workspaces/wikipedia/subflows/onboarding-android.yaml b/e2e/workspaces/wikipedia/subflows/onboarding-android.yaml new file mode 100644 index 00000000..e9ee46fc --- /dev/null +++ b/e2e/workspaces/wikipedia/subflows/onboarding-android.yaml @@ -0,0 +1,15 @@ +appId: org.wikipedia +--- +- launchApp: + clearState: true +- tapOn: + text: "Non existent view" + optional: true +- tapOn: + id: "org.wikipedia:id/fragment_onboarding_forward_button" +- tapOn: + id: "org.wikipedia:id/fragment_onboarding_forward_button" +- tapOn: + id: "org.wikipedia:id/fragment_onboarding_forward_button" +- tapOn: + id: "org.wikipedia:id/fragment_onboarding_done_button" diff --git a/e2e/workspaces/wikipedia/subflows/onboarding-ios.yaml b/e2e/workspaces/wikipedia/subflows/onboarding-ios.yaml new file mode 100644 index 00000000..7f74121d --- /dev/null +++ b/e2e/workspaces/wikipedia/subflows/onboarding-ios.yaml @@ -0,0 +1,15 @@ +appId: org.wikimedia.wikipedia +--- +- launchApp: + clearState: true +- repeat: + times: 3 + commands: + - swipe: + direction: LEFT + duration: 400 + - waitForAnimationToEnd +- tapOn: Get started +- tapOn: + text: "Non existent view" + optional: true diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/login.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/login.yml new file mode 100644 index 00000000..52685459 --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/login.yml @@ -0,0 +1,14 @@ +appId: org.wikipedia +--- +- tapOn: "More" +- tapOn: "LOG IN.*" +- tapOn: + id: ".*create_account_login_button" +- runScript: "../scripts/fetchTestUser.js" +- tapOn: "Username" +- inputText: "${output.test_user.username}" +- tapOn: "Password" +- inputText: "No provided" +- tapOn: "LOG IN" +- back +- back diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/signup.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/signup.yml new file mode 100644 index 00000000..dc1e0218 --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/auth/signup.yml @@ -0,0 +1,17 @@ +appId: org.wikipedia +--- +- tapOn: "More" +- tapOn: "LOG IN.*" +- runScript: "../scripts/generateCredentials.js" +- tapOn: "Username" +- inputText: "${output.credentials.username}" +- tapOn: "Password" +- inputText: "${output.credentials.password}" +- tapOn: "Repeat password" +- inputText: "${output.credentials.password}" +- tapOn: "Email.*" +- inputText: "${output.credentials.email}" + +# We won't actually create the account +- back +- back diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/copy-paste.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/copy-paste.yml new file mode 100644 index 00000000..cf54bde7 --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/copy-paste.yml @@ -0,0 +1,13 @@ +appId: org.wikipedia +--- +- tapOn: "Explore" +- scrollUntilVisible: + element: "Top read" +- copyTextFrom: + id: ".*view_list_card_item_title" + index: 0 +- tapOn: "Explore" +- tapOn: "Search Wikipedia" +- inputText: "${maestro.copiedText}" +- back +- back diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/feed.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/feed.yml new file mode 100644 index 00000000..7794f8a0 --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/feed.yml @@ -0,0 +1,7 @@ +appId: org.wikipedia +--- +- tapOn: "Explore" +- scrollUntilVisible: + element: "Today on Wikipedia.*" +- tapOn: "Today on Wikipedia.*" +- back diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/main.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/main.yml new file mode 100644 index 00000000..a571b86a --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/main.yml @@ -0,0 +1,6 @@ +appId: org.wikipedia +--- +- runFlow: "search.yml" +- runFlow: "saved.yml" +- runFlow: "feed.yml" +- runFlow: "copy-paste.yml" diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/saved.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/saved.yml new file mode 100644 index 00000000..be21915c --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/saved.yml @@ -0,0 +1,7 @@ +appId: org.wikipedia +--- +- tapOn: "Saved" +- tapOn: "Default list for your saved articles" +- assertVisible: "Sun" +- assertVisible: "Star at the center of the Solar System" +- back diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/search.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/search.yml new file mode 100644 index 00000000..5a79e8a6 --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/dashboard/search.yml @@ -0,0 +1,12 @@ +appId: org.wikipedia +--- +- tapOn: "Search Wikipedia" +- inputText: "Sun" +- assertVisible: "Star at the center of the Solar System" +- tapOn: + id: ".*page_list_item_title" +- tapOn: + id: ".*page_save" +- back +- back +- back diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/add-language.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/add-language.yml new file mode 100644 index 00000000..bd498d60 --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/add-language.yml @@ -0,0 +1,10 @@ +appId: org.wikipedia +--- +- tapOn: "ADD OR EDIT.*" +- tapOn: "ADD LANGUAGE" +- tapOn: + id: ".*menu_search_language" +- inputText: "Greek" +- assertVisible: "Ελληνικά" +- tapOn: "Ελληνικά" +- tapOn: "Navigate up" diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/main.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/main.yml new file mode 100644 index 00000000..855810f7 --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/main.yml @@ -0,0 +1,11 @@ +appId: org.wikipedia +--- +- runFlow: "add-language.yml" +- runFlow: "remove-language.yml" +- tapOn: "Continue" +- assertVisible: "New ways to explore" +- tapOn: "Continue" +- assertVisible: "Reading lists with sync" +- tapOn: "Continue" +- assertVisible: "Send anonymous data" +- tapOn: "Get started" diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/remove-language.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/remove-language.yml new file mode 100644 index 00000000..9dfe1c10 --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/onboarding/remove-language.yml @@ -0,0 +1,13 @@ +appId: org.wikipedia +--- +- tapOn: "ADD OR EDIT.*" +- tapOn: "More options" +- tapOn: "Remove language" +- tapOn: + id: ".*wiki_language_checkbox" + index: 1 +- tapOn: + id: ".*menu_delete_selected" +- tapOn: "OK" +- assertNotVisible: "Ελληνικά" +- tapOn: "Navigate up" diff --git a/e2e/workspaces/wikipedia/wikipedia-android-advanced/run-test.yml b/e2e/workspaces/wikipedia/wikipedia-android-advanced/run-test.yml new file mode 100644 index 00000000..0eaad46e --- /dev/null +++ b/e2e/workspaces/wikipedia/wikipedia-android-advanced/run-test.yml @@ -0,0 +1,11 @@ +appId: org.wikipedia +tags: + - android + - passing +--- +- launchApp: + clearState: true +- runFlow: "onboarding/main.yml" +- runFlow: "dashboard/main.yml" +- runFlow: "auth/signup.yml" +- runFlow: "auth/login.yml" diff --git a/pkg/cli/android.go b/pkg/cli/android.go index 5e6811f7..1691a103 100644 --- a/pkg/cli/android.go +++ b/pkg/cli/android.go @@ -451,7 +451,9 @@ func createDeviceLabDriver(cfg *RunConfig, dev *device.AndroidDevice, info devic session, err := adapter.CreateSession() if err != nil { logger.Error("Failed to create session: %v", err) - wsClient.Close() + if closeErr := wsClient.Close(); closeErr != nil { + logger.Warn("failed to close WebSocket client after session failure: %v", closeErr) + } if stopErr := dev.StopDeviceLabDriver(); stopErr != nil { logger.Warn("failed to stop DeviceLab driver after session failure: %v", stopErr) } @@ -536,6 +538,7 @@ func createDeviceLabDriver(cfg *RunConfig, dev *device.AndroidDevice, info devic driver.SetWebViewForwarder(dev) cleanup := func() { + driver.Close() if err := adapter.DeleteSession(); err != nil { logger.Debug("failed to delete session during cleanup: %v", err) } diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go index ea80944a..0f2820b9 100644 --- a/pkg/cli/cli.go +++ b/pkg/cli/cli.go @@ -168,6 +168,7 @@ Examples: Commands: []*cli.Command{ testCommand, wdaCommand, + serverCommand, hierarchyCommand, lintCommand, devicesCommand, diff --git a/pkg/cli/cli_test.go b/pkg/cli/cli_test.go index 89e486c7..2a9b6f19 100644 --- a/pkg/cli/cli_test.go +++ b/pkg/cli/cli_test.go @@ -35,6 +35,15 @@ func (m *mockDriver) SetFindTimeout(int) {} func (m *mockDriver) SetWaitForIdleTimeout(int) error { return nil } func (m *mockDriver) SetContext(context.Context) {} +type mockEmulatorStarter struct { + startSerial string + startErr error +} + +func (m *mockEmulatorStarter) Start(avdName string, timeout time.Duration) (string, error) { + return m.startSerial, m.startErr +} + func TestResolveOutputDir_Default(t *testing.T) { dir, err := resolveOutputDir("", false) if err != nil { @@ -1634,20 +1643,23 @@ func TestExecuteFlowsWithMode_AppiumParallel(t *testing.T) { // ============================================================ func TestHandleEmulatorStartup_StartEmulatorError(t *testing.T) { - // Suppress stdout - oldStdout := os.Stdout - os.Stdout, _ = os.Open(os.DevNull) - defer func() { os.Stdout = oldStdout }() + mock := &mockEmulatorStarter{ + startErr: fmt.Errorf("emulator not found"), + } cfg := &RunConfig{ Platform: "android", StartEmulator: "NonExistent_AVD_12345", BootTimeout: 5, } - mgr := emulator.NewManager() - err := handleEmulatorStartup(cfg, mgr) - // This will fail because the AVD does not exist (emulator binary may not be found) + // Suppress stdout + oldStdout := os.Stdout + os.Stdout, _ = os.Open(os.DevNull) + defer func() { os.Stdout = oldStdout }() + + err := handleEmulatorStartup(cfg, mock) + // This will fail because the mock returns an error if err == nil { t.Error("expected error when starting nonexistent emulator") } diff --git a/pkg/cli/server.go b/pkg/cli/server.go new file mode 100644 index 00000000..b8456d7d --- /dev/null +++ b/pkg/cli/server.go @@ -0,0 +1,134 @@ +package cli + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/devicelab-dev/maestro-runner/pkg/core" + "github.com/devicelab-dev/maestro-runner/pkg/logger" + "github.com/devicelab-dev/maestro-runner/pkg/server" + "github.com/urfave/cli/v2" +) + +var serverCommand = &cli.Command{ + Name: "server", + Usage: "Start the REST API server for remote test execution", + Description: `Start an HTTP server that exposes session-based endpoints for +executing Maestro steps via JSON instead of YAML flow files. + +Examples: + maestro-runner server + maestro-runner server --port 9999 + maestro-runner --platform android server`, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "port", + Usage: "Port to listen on", + Value: 9999, + EnvVars: []string{"MAESTRO_SERVER_PORT"}, + }, + }, + Action: runServer, +} + +func runServer(c *cli.Context) error { + // Helper to get flag value from current or parent context + getString := func(name string) string { + if c.IsSet(name) { + return c.String(name) + } + if c.Lineage()[1] != nil { + return c.Lineage()[1].String(name) + } + return c.String(name) + } + getBool := func(name string) bool { + if c.IsSet(name) { + return c.Bool(name) + } + if c.Lineage()[1] != nil { + return c.Lineage()[1].Bool(name) + } + return c.Bool(name) + } + + port := c.Int("port") + verbose := getBool("verbose") + _ = verbose + + // Initialize logging + if err := logger.Init("maestro-server.log"); err != nil { + fmt.Printf("Warning: Failed to initialize logger: %v\n", err) + } + defer logger.Close() + + // Create server with driver factory + srv := server.New(func(req server.SessionRequest) (core.Driver, func(), error) { + platform := strings.ToLower(req.PlatformName) + + cfg := &RunConfig{ + Platform: platform, + Driver: req.Driver, + AppID: req.AppID, + } + if req.DeviceID != "" { + cfg.Devices = []string{req.DeviceID} + } + + // Inherit global flags + cfg.AppFile = getString("app-file") + cfg.AppiumURL = getString("appium-url") + cfg.CapsFile = getString("caps") + cfg.TeamID = getString("team-id") + + switch platform { + case "android": + return CreateAndroidDriver(cfg) + case "ios": + return CreateIOSDriver(cfg) + default: + return nil, nil, fmt.Errorf("unsupported platform: %s", req.PlatformName) + } + }) + + // Create HTTP server + addr := fmt.Sprintf(":%d", port) + httpServer := &http.Server{ + Addr: addr, + Handler: srv.Handler(), + } + + // Graceful shutdown on SIGINT/SIGTERM + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + <-ctx.Done() + fmt.Println("\nShutting down server...") + srv.ShutdownAll() + if err := httpServer.Shutdown(context.Background()); err != nil { + logger.Error("Server shutdown error: %v", err) + } + }() + + fmt.Printf("maestro-runner server listening on %s\n", addr) + fmt.Printf(" POST /session - Create a new session\n") + fmt.Printf(" POST /session/{id}/execute - Execute a step\n") + fmt.Printf(" GET /session/{id}/screenshot - Take screenshot\n") + fmt.Printf(" GET /session/{id}/source - Get view hierarchy\n") + fmt.Printf(" GET /session/{id}/device-info - Get device info\n") + fmt.Printf(" DELETE /session/{id} - Delete session\n") + fmt.Printf(" GET /status - Server status\n") + fmt.Println() + + if err := httpServer.ListenAndServe(); err != http.ErrServerClosed { + return fmt.Errorf("server error: %w", err) + } + + return nil +} diff --git a/pkg/cli/test.go b/pkg/cli/test.go index 9ff1f474..5e7b5b72 100644 --- a/pkg/cli/test.go +++ b/pkg/cli/test.go @@ -285,11 +285,16 @@ func bootTimeout(cfg *RunConfig) time.Duration { return timeout } +// EmulatorStarter abstracts emulator start operations for testability. +type EmulatorStarter interface { + Start(avdName string, timeout time.Duration) (string, error) +} + // handleEmulatorStartup starts Android emulators if requested via CLI flags. // Handles two cases: // 1. --start-emulator: Explicitly start a specific AVD // 2. --auto-start-emulator: Start an emulator if no devices are found -func handleEmulatorStartup(cfg *RunConfig, mgr *emulator.Manager) error { +func handleEmulatorStartup(cfg *RunConfig, mgr EmulatorStarter) error { // Only handle Android emulators if cfg.Platform != "" && cfg.Platform != "android" { return nil diff --git a/pkg/core/imagediff.go b/pkg/core/imagediff.go index 3c1a85de..11512d3a 100644 --- a/pkg/core/imagediff.go +++ b/pkg/core/imagediff.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strconv" "strings" + "time" ) const maestroPixelTolerance = 0.1 @@ -29,6 +30,14 @@ const maestroPixelTolerance = 0.1 // pixels per comparison; in practice screenshots arrive every ~200ms so the // polling rate is bounded by ADB round-trip, not pixel work. func ImageDifference(a, b []byte) float64 { + // Fast path: byte-identical screenshots are trivially static. This also + // covers drivers whose mock/clients return opaque-but-equal payloads that + // don't decode as real images — matching the fork's consecutiveScreenshot + // diff, which short-circuited on bytes.Equal to 0. + if bytes.Equal(a, b) { + return 0 + } + imgA, _, err := image.Decode(bytes.NewReader(a)) if err != nil { return 1.0 @@ -62,6 +71,72 @@ func ImageDifference(a, b []byte) float64 { return float64(differing) / float64(total) } +// AnimationSettleResult is the outcome of WaitForScreenStatic. +type AnimationSettleResult struct { + // Settled is true when two consecutive screenshots (sleep apart) were + // pixel-similar within Threshold before the timeout elapsed. + Settled bool + // Iterations is the number of comparison iterations performed. + Iterations int + // Elapsed is how long the wait ran (≈ timeout when it never settled). + Elapsed time.Duration + // Diffs holds the per-iteration differing-pixel fractions (diagnostics). + Diffs []float64 +} + +// WaitForScreenStatic polls two consecutive screenshots (separated by sleep) +// until their differing-pixel fraction (ImageDifference) is at or below +// threshold, or until timeout elapses. Screenshot capture failures are treated +// as transient: the iteration is retried after retryInterval rather than +// aborting the wait, so a flaky capture doesn't fail a still-animating screen. +// +// It is the single implementation behind every screenshot-based driver's +// waitForAnimationToEnd step (uiautomator2, wda, devicelab, appium), so the +// four drivers behave identically: all honor sleepMs/threshold config and all +// fail (Settled=false) when the screen never stabilizes. Callers map the +// result to a CommandResult, failing the step when Settled is false. +func WaitForScreenStatic( + screenshot func() ([]byte, error), + timeout, sleep, retryInterval time.Duration, + threshold float64, +) AnimationSettleResult { + start := time.Now() + deadline := start.Add(timeout) + var diffs []float64 + i := 0 + for time.Now().Before(deadline) { + i++ + prev, err := screenshot() + if err != nil { + time.Sleep(retryInterval) + continue + } + time.Sleep(sleep) + curr, err := screenshot() + if err != nil { + time.Sleep(retryInterval) + continue + } + diff := ImageDifference(prev, curr) + diffs = append(diffs, diff) + if diff <= threshold { + return AnimationSettleResult{ + Settled: true, + Iterations: i, + Elapsed: time.Since(start), + Diffs: diffs, + } + } + time.Sleep(retryInterval) + } + return AnimationSettleResult{ + Settled: false, + Iterations: i, + Elapsed: time.Since(start), + Diffs: diffs, + } +} + // CheckImageDifference is a convenience wrapper that returns an explicit error // for the same input as ImageDifference. Useful for callers that want to // distinguish decoding failures from genuine pixel differences. diff --git a/pkg/device/android_test.go b/pkg/device/android_test.go index b01f345d..b9d27c01 100644 --- a/pkg/device/android_test.go +++ b/pkg/device/android_test.go @@ -402,6 +402,7 @@ func TestAndroidDevice_InstallUIAutomator2(t *testing.T) { } func TestAndroidDevice_New_InvalidSerial(t *testing.T) { + t.Parallel() // Test with invalid serial - should timeout _, err := New("invalid-device-serial-xyz") if err == nil { diff --git a/pkg/device/devicelab_driver.go b/pkg/device/devicelab_driver.go index a0b450a2..eb55a7fa 100644 --- a/pkg/device/devicelab_driver.go +++ b/pkg/device/devicelab_driver.go @@ -138,7 +138,9 @@ func (d *AndroidDevice) checkUiAutomationConflict() error { for _, pkg := range knownConflicts { if strings.Contains(output, pkg) { logger.Info("Stopping %s to avoid UiAutomation conflict", pkg) - _, _ = d.Shell("am force-stop " + pkg) + if _, err := d.Shell("am force-stop " + pkg); err != nil { + logger.Debug("failed to force-stop conflicting package %s: %v", pkg, err) + } } } @@ -155,7 +157,9 @@ func (d *AndroidDevice) checkUiAutomationConflict() error { if idx := strings.Index(line, "/"); idx > 0 { pkg := line[:idx] logger.Info("Stopping active instrumentation: %s", pkg) - _, _ = d.Shell("am force-stop " + pkg) + if _, err := d.Shell("am force-stop " + pkg); err != nil { + logger.Debug("failed to force-stop instrumentation package %s: %v", pkg, err) + } } } } @@ -241,7 +245,9 @@ func (d *AndroidDevice) setupDeviceLabSocketForward(cfg DeviceLabDriverConfig) e if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { logger.Debug("failed to remove stale socket file %s: %v", socketPath, err) } - os.Remove(pidPathFor(socketPath)) + if err := os.Remove(pidPathFor(socketPath)); err != nil && !os.IsNotExist(err) { + logger.Debug("failed to remove stale socket PID file for %s: %v", socketPath, err) + } } if err := d.ForwardSocket(socketPath, cfg.DevicePort); err != nil { @@ -295,7 +301,9 @@ func (d *AndroidDevice) StopDeviceLabDriver() error { if err := os.Remove(d.driverSocketPath); err != nil && !os.IsNotExist(err) { logger.Warn("failed to remove DeviceLab Android Driver socket file %s: %v", d.driverSocketPath, err) } - os.Remove(pidPathFor(d.driverSocketPath)) + if err := os.Remove(pidPathFor(d.driverSocketPath)); err != nil && !os.IsNotExist(err) { + logger.Warn("failed to remove DeviceLab Android Driver socket PID file for %s: %v", d.driverSocketPath, err) + } d.driverSocketPath = "" } // Clean up default socket path @@ -306,7 +314,9 @@ func (d *AndroidDevice) StopDeviceLabDriver() error { if err := os.Remove(defaultSocket); err != nil && !os.IsNotExist(err) { logger.Warn("failed to remove default DeviceLab Android Driver socket file %s: %v", defaultSocket, err) } - os.Remove(pidPathFor(defaultSocket)) + if err := os.Remove(pidPathFor(defaultSocket)); err != nil && !os.IsNotExist(err) { + logger.Warn("failed to remove default DeviceLab Android Driver socket PID file for %s: %v", defaultSocket, err) + } // Clean up port forward (Windows) if d.driverLocalPort != 0 { @@ -389,9 +399,11 @@ func checkDeviceLabHandshake(network, address string) bool { if err != nil { return false } - defer conn.Close() + defer func() { _ = conn.Close() }() - _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + if err := conn.SetDeadline(time.Now().Add(2 * time.Second)); err != nil { + return false + } // Send a minimal WebSocket upgrade request handshake := "GET / HTTP/1.1\r\n" + diff --git a/pkg/device/uiautomator.go b/pkg/device/uiautomator.go index 64522d52..f5fec030 100644 --- a/pkg/device/uiautomator.go +++ b/pkg/device/uiautomator.go @@ -126,7 +126,9 @@ func (d *AndroidDevice) setupSocketForward(cfg UIAutomator2Config) error { if err := os.Remove(socketPath); err != nil && !os.IsNotExist(err) { logger.Debug("failed to remove stale socket file %s: %v", socketPath, err) } - os.Remove(pidPathFor(socketPath)) + if err := os.Remove(pidPathFor(socketPath)); err != nil && !os.IsNotExist(err) { + logger.Debug("failed to remove stale socket PID file for %s: %v", socketPath, err) + } } if err := d.ForwardSocket(socketPath, cfg.DevicePort); err != nil { @@ -196,7 +198,9 @@ func (d *AndroidDevice) StopUIAutomator2() error { if err := os.Remove(d.socketPath); err != nil && !os.IsNotExist(err) { logger.Warn("failed to remove socket file %s: %v", d.socketPath, err) } - os.Remove(pidPathFor(d.socketPath)) + if err := os.Remove(pidPathFor(d.socketPath)); err != nil && !os.IsNotExist(err) { + logger.Warn("failed to remove socket PID file for %s: %v", d.socketPath, err) + } d.socketPath = "" } // Also clean up default socket path (in case of stale from previous run) @@ -207,7 +211,9 @@ func (d *AndroidDevice) StopUIAutomator2() error { if err := os.Remove(defaultSocket); err != nil && !os.IsNotExist(err) { logger.Warn("failed to remove default socket file %s: %v", defaultSocket, err) } - os.Remove(pidPathFor(defaultSocket)) + if err := os.Remove(pidPathFor(defaultSocket)); err != nil && !os.IsNotExist(err) { + logger.Warn("failed to remove default socket PID file for %s: %v", defaultSocket, err) + } // Clean up port forward (Windows) if d.localPort != 0 { diff --git a/pkg/driver/appium/commands.go b/pkg/driver/appium/commands.go index 7791c363..29bb3d5b 100644 --- a/pkg/driver/appium/commands.go +++ b/pkg/driver/appium/commands.go @@ -1005,14 +1005,71 @@ func iosKeyChar(name string) string { // Wait commands -func (d *Driver) waitForAnimationToEnd(_ *flow.WaitForAnimationToEndStep) *core.CommandResult { - // NOTE: waitForAnimationToEnd is not fully implemented. - // Maestro uses screenshot comparison which is complex to implement correctly. - // For now, we pass this step with a warning. +const ( + defaultAnimationTimeoutMs = 15000 + defaultAnimationSleepMs = 200 // pause between the two comparison screenshots + screenshotDiffThreshold = 0.005 // 0.5 % — default pixel-diff threshold + screenshotRetryIntervalMs = 100 // outer loop retry interval +) + +func (d *Driver) waitForAnimationToEnd(step *flow.WaitForAnimationToEndStep) *core.CommandResult { + timeoutMs := step.TimeoutMs + if timeoutMs <= 0 { + timeoutMs = defaultAnimationTimeoutMs + } + + sleepMs := step.SleepMs + if sleepMs <= 0 { + sleepMs = defaultAnimationSleepMs + } + + threshold := step.Threshold + if threshold <= 0 { + threshold = screenshotDiffThreshold + } + + logger.Info("waitForAnimationToEnd starting: timeoutMs=%d sleepMs=%d threshold=%.4f", + timeoutMs, sleepMs, threshold) + + res := core.WaitForScreenStatic( + func() ([]byte, error) { return d.client.Screenshot() }, + time.Duration(timeoutMs)*time.Millisecond, + time.Duration(sleepMs)*time.Millisecond, + time.Duration(screenshotRetryIntervalMs)*time.Millisecond, + threshold, + ) + + if res.Settled { + logger.Info("waitForAnimationToEnd: screen became static after %d iteration(s) (%.0fms elapsed), diffs=%s", + res.Iterations, res.Elapsed.Seconds()*1000, formatAnimationDiffs(res.Diffs)) + return successResult( + fmt.Sprintf("Animation ended (screen became static) after %d iteration(s) in %.0fms, diffs=%s", + res.Iterations, res.Elapsed.Seconds()*1000, formatAnimationDiffs(res.Diffs)), + nil, + ) + } + + logger.Info("waitForAnimationToEnd: timed out after %d iteration(s) (%.0fms), diffs=%s threshold=%.4f", + res.Iterations, res.Elapsed.Seconds()*1000, formatAnimationDiffs(res.Diffs), threshold) return &core.CommandResult{ - Success: true, - Message: "WARNING: waitForAnimationToEnd is not fully implemented - step passed without animation check", + Success: false, + Message: fmt.Sprintf( + "Timed out after %dms (%d iteration(s)) waiting for screen to become static; diffs=%s threshold=%.4f", + timeoutMs, res.Iterations, formatAnimationDiffs(res.Diffs), threshold, + ), + } +} + +// formatAnimationDiffs formats a slice of diff values as "[0.000764 0.000821 ...]" +func formatAnimationDiffs(diffs []float64) string { + if len(diffs) == 0 { + return "[]" + } + parts := make([]string, len(diffs)) + for i, d := range diffs { + parts[i] = fmt.Sprintf("%.6f", d) } + return "[" + strings.Join(parts, " ") + "]" } func (d *Driver) waitUntil(step *flow.WaitUntilStep) *core.CommandResult { diff --git a/pkg/driver/appium/commands_test.go b/pkg/driver/appium/commands_test.go index 4fc87f24..7d3e8886 100644 --- a/pkg/driver/appium/commands_test.go +++ b/pkg/driver/appium/commands_test.go @@ -681,15 +681,14 @@ func TestWaitForAnimationToEnd(t *testing.T) { defer server.Close() driver := createTestAppiumDriver(server) - step := &flow.WaitForAnimationToEndStep{} + // Mock server returns identical "fake-png-data" on every /screenshot call, + // so bytes.Equal fast-path fires and the screen is immediately "static". + step := &flow.WaitForAnimationToEndStep{BaseStep: flow.BaseStep{TimeoutMs: 1000}} result := driver.waitForAnimationToEnd(step) if !result.Success { t.Fatalf("expected success, got error: %v", result.Error) } - if !strings.Contains(result.Message, "WARNING") { - t.Fatalf("expected warning message, got %q", result.Message) - } } func TestWaitUntilVisible(t *testing.T) { diff --git a/pkg/driver/appium/driver_test.go b/pkg/driver/appium/driver_test.go index 42b6178b..5fc76cfd 100644 --- a/pkg/driver/appium/driver_test.go +++ b/pkg/driver/appium/driver_test.go @@ -1140,6 +1140,7 @@ func TestAppiumFindElementRelativeLeftOf(t *testing.T) { // TestFindElementRelativeAnchorNotFound tests when anchor element not found func TestAppiumFindElementRelativeAnchorNotFound(t *testing.T) { + t.Parallel() server := mockAppiumServerForRelativeElements() defer server.Close() driver := createTestAppiumDriver(server) @@ -1157,6 +1158,7 @@ func TestAppiumFindElementRelativeAnchorNotFound(t *testing.T) { // TestFindElementRelativeNoMatch tests when no element matches func TestAppiumFindElementRelativeNoMatch(t *testing.T) { + t.Parallel() server := mockAppiumServerForRelativeElements() defer server.Close() driver := createTestAppiumDriver(server) @@ -1630,6 +1632,7 @@ func TestAppiumScrollUntilVisibleSuccess(t *testing.T) { // TestScrollUntilVisibleNotFound tests scrollUntilVisible when element not found func TestAppiumScrollUntilVisibleNotFound(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") path := r.URL.Path @@ -2035,6 +2038,7 @@ func TestPasteTextError(t *testing.T) { // TestCopyTextFromError tests copyTextFrom when element not found func TestCopyTextFromError(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.HasSuffix(r.URL.Path, "/source") { @@ -2697,6 +2701,7 @@ func TestInputTextError(t *testing.T) { // TestInputTextIOSUsesMobileKeys verifies iOS uses "mobile: keys" instead of W3C key actions func TestInputTextIOSUsesMobileKeys(t *testing.T) { + t.Parallel() var lastPath string var lastBody map[string]interface{} server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -2756,6 +2761,7 @@ func TestInputTextAndroidStillUsesActions(t *testing.T) { // ============================================================================= func TestAppiumScrollUntilVisibleRespectsMaxScrolls(t *testing.T) { + t.Parallel() scrollCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/pkg/driver/browser/cdp/commands.go b/pkg/driver/browser/cdp/commands.go index 35dcb8a0..d5a47c86 100644 --- a/pkg/driver/browser/cdp/commands.go +++ b/pkg/driver/browser/cdp/commands.go @@ -1878,7 +1878,7 @@ func (d *Driver) initPage(page *rod.Page) error { }); err != nil { return err } - _, err := page.EvalOnNewDocument(jsHelperCode) + _, err := page.EvalOnNewDocument(JSHelperCode) return err } diff --git a/pkg/driver/browser/cdp/driver.go b/pkg/driver/browser/cdp/driver.go index 6bf7af44..eb20d731 100644 --- a/pkg/driver/browser/cdp/driver.go +++ b/pkg/driver/browser/cdp/driver.go @@ -158,7 +158,7 @@ func New(cfg Config) (*Driver, error) { } // Inject JS helper (persists across navigations) - _, err = page.EvalOnNewDocument(jsHelperCode) + _, err = page.EvalOnNewDocument(JSHelperCode) if err != nil { return nil, fmt.Errorf("failed to inject JS helper: %w", err) } diff --git a/pkg/driver/browser/cdp/jshelper.go b/pkg/driver/browser/cdp/jshelper.go index 78cf3839..b69a6eb8 100644 --- a/pkg/driver/browser/cdp/jshelper.go +++ b/pkg/driver/browser/cdp/jshelper.go @@ -9,3 +9,6 @@ import _ "embed" // //go:embed jshelper.js var jsHelperCode string + +// JSHelperCode remains exported for packages that inject the same helper code. +var JSHelperCode = jsHelperCode diff --git a/pkg/driver/devicelab/commands.go b/pkg/driver/devicelab/commands.go index dca87e23..d045ff9a 100644 --- a/pkg/driver/devicelab/commands.go +++ b/pkg/driver/devicelab/commands.go @@ -1033,6 +1033,13 @@ func (d *Driver) scrollByAdb(direction string, screenWidth, screenHeight int, pe // Android's input pipeline to register as a scroll. const scrollDurationMs = 300 +const ( + defaultAnimationTimeoutMs = 15000 + defaultAnimationSleepMs = 200 // pause between the two comparison screenshots + screenshotDiffThreshold = 0.005 // 0.5 % — default pixel-diff threshold + screenshotRetryIntervalMs = 100 // outer loop retry interval +) + // isElementNotFoundError distinguishes expected "not on screen yet" errors // (which scrollUntilVisible should swallow and keep scrolling) from real // infrastructure failures that should propagate immediately. @@ -2080,29 +2087,50 @@ func (d *Driver) waitUntil(step *flow.WaitUntilStep) *core.CommandResult { func (d *Driver) waitForAnimationToEnd(step *flow.WaitForAnimationToEndStep) *core.CommandResult { timeoutMs := step.TimeoutMs if timeoutMs <= 0 { - timeoutMs = 15000 + timeoutMs = defaultAnimationTimeoutMs + } + sleepMs := step.SleepMs + if sleepMs <= 0 { + sleepMs = defaultAnimationSleepMs + } + threshold := step.Threshold + if threshold <= 0 { + threshold = screenshotDiffThreshold } - const threshold = 0.005 - deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) - start := time.Now() - for time.Now().Before(deadline) { - prev, err := d.client.Screenshot() - if err != nil { - return errorResult(err, fmt.Sprintf("Failed to take screenshot: %v", err)) - } - curr, err := d.client.Screenshot() - if err != nil { - return errorResult(err, fmt.Sprintf("Failed to take screenshot: %v", err)) - } - diff := core.ImageDifference(prev, curr) - if diff <= threshold { - elapsed := time.Since(start) - return successResult(fmt.Sprintf("Animation ended (%.1f%% diff, %dms)", diff*100, elapsed.Milliseconds()), nil) - } + res := core.WaitForScreenStatic( + func() ([]byte, error) { return d.client.Screenshot() }, + time.Duration(timeoutMs)*time.Millisecond, + time.Duration(sleepMs)*time.Millisecond, + time.Duration(screenshotRetryIntervalMs)*time.Millisecond, + threshold, + ) + + if res.Settled { + return successResult( + fmt.Sprintf("Animation ended (%.1f%% diff, %dms)", res.Diffs[len(res.Diffs)-1]*100, res.Elapsed.Milliseconds()), + nil, + ) + } + return &core.CommandResult{ + Success: false, + Message: fmt.Sprintf( + "Timed out after %dms (%d iteration(s)) waiting for screen to become static; diffs=%s threshold=%.4f", + timeoutMs, res.Iterations, formatAnimationDiffs(res.Diffs), threshold, + ), } +} - return successResult(fmt.Sprintf("Animation did not settle within %dms — continuing", timeoutMs), nil) +// formatAnimationDiffs formats a slice of diff values as "[0.000764 0.000821 ...]" +func formatAnimationDiffs(diffs []float64) string { + if len(diffs) == 0 { + return "[]" + } + parts := make([]string, len(diffs)) + for i, d := range diffs { + parts[i] = fmt.Sprintf("%.6f", d) + } + return "[" + strings.Join(parts, " ") + "]" } // ============================================================================ diff --git a/pkg/driver/devicelab/commands_helpers_test.go b/pkg/driver/devicelab/commands_helpers_test.go index 3b5949a0..b3839324 100644 --- a/pkg/driver/devicelab/commands_helpers_test.go +++ b/pkg/driver/devicelab/commands_helpers_test.go @@ -1308,7 +1308,12 @@ func TestWaitForAnimationToEnd_ScreenshotError(t *testing.T) { err: errors.New("screenshot blocked"), } driver := New(client, &core.PlatformInfo{}, &mockShell{}) - res := driver.waitForAnimationToEnd(&flow.WaitForAnimationToEndStep{}) + // Screenshot always fails → the wait retries until the (short) timeout and + // then reports failure, matching the fail-on-timeout behaviour for a screen + // that can never be captured. + res := driver.waitForAnimationToEnd(&flow.WaitForAnimationToEndStep{ + BaseStep: flow.BaseStep{TimeoutMs: 200}, + }) if res.Success { t.Error("waitForAnimationToEnd should fail when screenshot errors") } diff --git a/pkg/driver/devicelab/commands_test.go b/pkg/driver/devicelab/commands_test.go index be5b3fef..9eb3426c 100644 --- a/pkg/driver/devicelab/commands_test.go +++ b/pkg/driver/devicelab/commands_test.go @@ -79,6 +79,7 @@ func (m *mockDeviceLabClient) WaitForWindowUpdate(string, int) (bool, error) { r var _ DeviceLabClient = (*mockDeviceLabClient)(nil) func TestScrollUntilVisibleRespectsMaxScrolls(t *testing.T) { + t.Parallel() client := &mockDeviceLabClient{ sourceFunc: func() (string, error) { return ` @@ -110,6 +111,7 @@ func TestScrollUntilVisibleRespectsMaxScrolls(t *testing.T) { } func TestScrollUntilVisibleRespectsTimeout(t *testing.T) { + t.Parallel() client := &mockDeviceLabClient{ sourceFunc: func() (string, error) { return ` @@ -141,6 +143,7 @@ func TestScrollUntilVisibleRespectsTimeout(t *testing.T) { } func TestScrollUntilVisibleDefaultMaxScrolls(t *testing.T) { + t.Parallel() client := &mockDeviceLabClient{ sourceFunc: func() (string, error) { return ` diff --git a/pkg/driver/devicelab/driver.go b/pkg/driver/devicelab/driver.go index 75acffcd..c9aa0e5a 100644 --- a/pkg/driver/devicelab/driver.go +++ b/pkg/driver/devicelab/driver.go @@ -737,8 +737,7 @@ func (d *Driver) getCDPInfo() *core.CDPInfo { if d.cdpStateFunc != nil { if info := d.cdpStateFunc(); info != nil { logger.Info("[cdp:2-source] detected via push event: socket=%s", info.Socket) - return info - } + return info } } // Fallback: scan /proc/net/unix via ADB shell @@ -843,7 +842,6 @@ func (d *Driver) isBrowserForeground() bool { } return false } - // findFocused returns the currently focused element as a core.Element. // Tries Rod first (`:focus` selector), then native ActiveElement(). func (d *Driver) findFocused() (core.Element, error) { @@ -880,7 +878,6 @@ func (d *Driver) findFocused() (core.Element, error) { func (d *Driver) isBrowserMode() bool { return d.knownCDPType == "browser" } - // ============================================================================ // Element Finding // ============================================================================ @@ -1029,11 +1026,14 @@ func (d *Driver) findElementDirectWithContext(ctx context.Context, sel flow.Sele time.Sleep(100 * time.Millisecond) continue } - + nativeStart := time.Now() elem, info, err := d.tryFindElement(combined) + nativeDur := time.Since(nativeStart) if err == nil { + logger.Debug("[native] found element: %s (%v)", sel.Describe(), nativeDur) return elem, info, nil } + logger.Debug("[native] miss: %s (%v)", sel.Describe(), nativeDur) lastErr = err } } @@ -1186,7 +1186,6 @@ func (d *Driver) findElementWithContext(ctx context.Context, sel flow.Selector, time.Sleep(100 * time.Millisecond) continue } - // Try native UiAutomator strategies var elem *uiautomator2.Element var info *core.ElementInfo @@ -1240,8 +1239,7 @@ func (d *Driver) findElementOnce(sel flow.Selector) (*uiautomator2.Element, *cor // Browser mode: skip all native strategies — all content is web if d.isBrowserMode() { - return nil, nil, fmt.Errorf("element '%s' not found via CDP", sel.Describe()) - } + return nil, nil, fmt.Errorf("element '%s' not found via CDP", sel.Describe()) } // Handle relative selectors with single page source fetch if sel.HasRelativeSelector() { diff --git a/pkg/driver/devicelab/keyboard.go b/pkg/driver/devicelab/keyboard.go index afb92125..8fa948b3 100644 --- a/pkg/driver/devicelab/keyboard.go +++ b/pkg/driver/devicelab/keyboard.go @@ -125,6 +125,7 @@ func (d *Driver) getKeyboardBounds() *core.Bounds { } // isKeyboardVisible checks if the soft keyboard is currently shown using dumpsys. +//nolint:unused func (d *Driver) isKeyboardVisible() bool { return d.getKeyboardBounds() != nil } diff --git a/pkg/driver/uiautomator2/commands.go b/pkg/driver/uiautomator2/commands.go index cf9d5f73..a8e2c4a8 100644 --- a/pkg/driver/uiautomator2/commands.go +++ b/pkg/driver/uiautomator2/commands.go @@ -526,39 +526,69 @@ func (d *Driver) eraseText(step *flow.EraseTextStep) *core.CommandResult { return successResult(fmt.Sprintf("Erased %d characters", chars), nil) } -func (d *Driver) hideKeyboard(_ *flow.HideKeyboardStep) *core.CommandResult { - // Appium's /appium/device/hide_keyboard is a no-op on some devices (notably - // several Samsung models): it returns success without closing the keyboard, - // so the next coordinate tap lands on the keyboard overlay (#42). We verify - // with dumpsys and, while the keyboard is still shown, fall back to a key - // event. - // - // KEYCODE_BACK dismisses the IME when it is open and only triggers back- - // navigation when the keyboard is NOT shown — so we send it ONLY after - // confirming the keyboard is still up, which is what keeps it from navigating - // away (the side effect reported on the devicelab driver). - - // If we can confirm the keyboard isn't shown, there's nothing to do. - if d.device != nil && !d.isKeyboardVisible() { - return successResult("Keyboard not visible", nil) +func (d *Driver) hideKeyboard(step *flow.HideKeyboardStep) *core.CommandResult { + if !d.isInputShown() { + return successResult("Keyboard not visible, skipped", nil) } + strategy := strings.ToLower(strings.TrimSpace(step.Strategy)) + + // If a specific strategy is requested, use only that one. + switch strategy { + case "appium": + return d.hideKeyboardAppium() + case "escape", "esc": + return d.hideKeyboardEscape() + case "back": + return d.hideKeyboardBack() + case "": + // Try all strategies in order. + default: + return errorResult(nil, fmt.Sprintf("Unknown hideKeyboard strategy: %q (valid: appium, escape/esc, back)", step.Strategy)) + } + + // Try all strategies: Appium → ESCAPE → BACK + if r := d.hideKeyboardAppium(); r.Success { + return r + } + if r := d.hideKeyboardEscape(); r.Success { + return r + } + return d.hideKeyboardBack() +} + +func (d *Driver) hideKeyboardAppium() *core.CommandResult { _ = d.client.HideKeyboard() - if d.waitKeyboardHidden() { - return successResult("Keyboard hidden", nil) + time.Sleep(500 * time.Millisecond) + if !d.isInputShown() { + return successResult("Keyboard hidden via Appium endpoint", nil) } + return errorResult(nil, "Appium endpoint failed toa hide keyboard") +} - // Appium's call didn't take. Fall back to BACK, but only while the keyboard - // is still shown so we can't trigger a stray back-navigation. - if d.isKeyboardVisible() { - if err := d.client.PressKeyCode(uiautomator2.KeyCodeBack); err == nil && d.waitKeyboardHidden() { - return successResult("Keyboard hidden (via back key)", nil) - } +func (d *Driver) hideKeyboardEscape() *core.CommandResult { + if d.device == nil { + return errorResult(nil, "No device available for KEYCODE_ESCAPE") } + _, _ = d.device.Shell("input keyevent 111") + time.Sleep(500 * time.Millisecond) + if !d.isInputShown() { + return successResult("Keyboard hidden via KEYCODE_ESCAPE", nil) + } + return errorResult(nil, "KEYCODE_ESCAPE failed to hide keyboard") +} - // Couldn't confirm dismissal — don't fail the step (the keyboard may already - // be gone on a device we can't inspect). - return successResult("Hide keyboard (dismissal not confirmed)", nil) +func (d *Driver) hideKeyboardBack() *core.CommandResult { + if d.device == nil { + return errorResult(nil, "No device available for BACK key") + } + // Safe: when keyboard IS visible, BACK dismisses it without navigating + _, _ = d.device.Shell("input keyevent 4") + time.Sleep(500 * time.Millisecond) + if !d.isInputShown() { + return successResult("Keyboard hidden via BACK key", nil) + } + return errorResult(nil, "BACK key failed to hide keyboard") } func (d *Driver) inputRandom(step *flow.InputRandomStep) *core.CommandResult { @@ -689,6 +719,13 @@ func (d *Driver) scrollUntilVisible(step *flow.ScrollUntilVisibleStep) *core.Com // scrollDurationMs is the swipe duration (in ms) used for adb input swipe. const scrollDurationMs = 300 +const ( + defaultAnimationTimeoutMs = 15000 + defaultAnimationSleepMs = 200 // pause between the two comparison screenshots + screenshotDiffThreshold = 0.005 // 0.5 % — default pixel-diff threshold + screenshotRetryIntervalMs = 100 // outer loop retry interval +) + // performScroll dispatches a scroll gesture. Default ("" or "adb") uses adb // input swipe (matches upstream Maestro and is the most reliable path across // Android skins, including OneUI where /appium/gestures/scroll often no-ops). @@ -885,6 +922,11 @@ func (d *Driver) findScrollableElement(timeoutMs int) (*core.ElementInfo, int) { } } + // Valid page source with elements but no scrollables — no point waiting + if len(elements) > 0 { + return nil, 0 + } + time.Sleep(pollInterval) } @@ -1849,41 +1891,52 @@ func (d *Driver) waitUntil(step *flow.WaitUntilStep) *core.CommandResult { } func (d *Driver) waitForAnimationToEnd(step *flow.WaitForAnimationToEndStep) *core.CommandResult { - return waitForScreenStatic(d, step.TimeoutMs) -} - -// waitForScreenStatic polls two consecutive screenshots and returns when the -// pixel-difference falls below the threshold, or after the timeout. -// -// Matches upstream Maestro: default 15s timeout, 0.5% threshold. The step is -// "soft" — it never fails, even when the screen never stabilizes, since the -// surrounding flow may genuinely involve an indefinite animation and we don't -// want to block test progress. -func waitForScreenStatic(d *Driver, timeoutMs int) *core.CommandResult { + timeoutMs := step.TimeoutMs if timeoutMs <= 0 { - timeoutMs = 15000 + timeoutMs = defaultAnimationTimeoutMs + } + sleepMs := step.SleepMs + if sleepMs <= 0 { + sleepMs = defaultAnimationSleepMs + } + threshold := step.Threshold + if threshold <= 0 { + threshold = screenshotDiffThreshold } - const threshold = 0.005 // 0.5%, matches upstream Maestro - deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) - start := time.Now() - for time.Now().Before(deadline) { - prev, err := d.client.Screenshot() - if err != nil { - return errorResult(err, fmt.Sprintf("Failed to take screenshot: %v", err)) - } - curr, err := d.client.Screenshot() - if err != nil { - return errorResult(err, fmt.Sprintf("Failed to take screenshot: %v", err)) - } - diff := core.ImageDifference(prev, curr) - if diff <= threshold { - elapsed := time.Since(start) - return successResult(fmt.Sprintf("Animation ended (%.1f%% diff, %dms)", diff*100, elapsed.Milliseconds()), nil) - } + res := core.WaitForScreenStatic( + func() ([]byte, error) { return d.client.Screenshot() }, + time.Duration(timeoutMs)*time.Millisecond, + time.Duration(sleepMs)*time.Millisecond, + time.Duration(screenshotRetryIntervalMs)*time.Millisecond, + threshold, + ) + + if res.Settled { + return successResult( + fmt.Sprintf("Animation ended (%.1f%% diff, %dms)", res.Diffs[len(res.Diffs)-1]*100, res.Elapsed.Milliseconds()), + nil, + ) } + return &core.CommandResult{ + Success: false, + Message: fmt.Sprintf( + "Timed out after %dms (%d iteration(s)) waiting for screen to become static; diffs=%s threshold=%.4f", + timeoutMs, res.Iterations, formatAnimationDiffs(res.Diffs), threshold, + ), + } +} - return successResult(fmt.Sprintf("Animation did not settle within %dms — continuing", timeoutMs), nil) +// formatAnimationDiffs formats a slice of diff values as "[0.000764 0.000821 ...]" +func formatAnimationDiffs(diffs []float64) string { + if len(diffs) == 0 { + return "[]" + } + parts := make([]string, len(diffs)) + for i, d := range diffs { + parts[i] = fmt.Sprintf("%.6f", d) + } + return "[" + strings.Join(parts, " ") + "]" } // ============================================================================ diff --git a/pkg/driver/uiautomator2/commands_test.go b/pkg/driver/uiautomator2/commands_test.go index 516605e8..94fbb336 100644 --- a/pkg/driver/uiautomator2/commands_test.go +++ b/pkg/driver/uiautomator2/commands_test.go @@ -1096,7 +1096,7 @@ func TestSetAirplaneModeEnabled(t *testing.T) { t.Errorf("expected success, got error: %v", result.Error) } - // Should try "cmd connectivity airplane-mode enable" first (Android 11+) + // New implementation tries "cmd connectivity airplane-mode" first (Android 11+) if len(mock.commands) < 1 || mock.commands[0] != "cmd connectivity airplane-mode enable" { t.Errorf("expected cmd connectivity command, got %v", mock.commands) } @@ -1113,7 +1113,7 @@ func TestSetAirplaneModeDisabled(t *testing.T) { t.Errorf("expected success, got error: %v", result.Error) } - // Should try "cmd connectivity airplane-mode disable" first (Android 11+) + // New implementation tries "cmd connectivity airplane-mode" first (Android 11+) if len(mock.commands) < 1 || mock.commands[0] != "cmd connectivity airplane-mode disable" { t.Errorf("expected cmd connectivity command, got %v", mock.commands) } @@ -1145,7 +1145,7 @@ func TestToggleAirplaneModeFromOff(t *testing.T) { t.Errorf("expected success, got error: %v", result.Error) } - // First command reads current state, second enables via cmd connectivity + // Should toggle from disable → enable via "cmd connectivity airplane-mode enable" found := false for _, cmd := range mock.commands { if cmd == "cmd connectivity airplane-mode enable" { @@ -1154,7 +1154,7 @@ func TestToggleAirplaneModeFromOff(t *testing.T) { } } if !found { - t.Errorf("expected cmd connectivity enable, got commands: %v", mock.commands) + t.Errorf("expected toggle to enable, got commands: %v", mock.commands) } } @@ -1169,7 +1169,7 @@ func TestToggleAirplaneModeFromOn(t *testing.T) { t.Errorf("expected success, got error: %v", result.Error) } - // First command reads current state, second disables via cmd connectivity + // Should toggle from enable → disable via "cmd connectivity airplane-mode disable" found := false for _, cmd := range mock.commands { if cmd == "cmd connectivity airplane-mode disable" { @@ -1178,7 +1178,7 @@ func TestToggleAirplaneModeFromOn(t *testing.T) { } } if !found { - t.Errorf("expected cmd connectivity disable, got commands: %v", mock.commands) + t.Errorf("expected toggle to disable, got commands: %v", mock.commands) } } @@ -2141,7 +2141,8 @@ func TestStartRecordingError(t *testing.T) { func TestHideKeyboardSuccess(t *testing.T) { client := &MockUIA2Client{} - driver := New(client, nil, nil) + shell := &MockShellExecutor{responses: []string{"mInputShown=true", "mInputShown=false"}} + driver := New(client, nil, shell) step := &flow.HideKeyboardStep{} result := driver.hideKeyboard(step) @@ -2487,7 +2488,9 @@ func TestSwipeWithAbsoluteCoords(t *testing.T) { func TestSwipeEmptyDirectionDefaultsToUp(t *testing.T) { shell := &MockShellExecutor{} - client := &MockUIA2Client{} + client := &MockUIA2Client{ + sourceData: ``, + } driver := New(client, &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 1920}, shell) step := &flow.SwipeStep{Direction: ""} @@ -3242,6 +3245,7 @@ func TestSetWaitForIdleTimeoutServerError(t *testing.T) { // ============================================================================ func TestTravelSuccess(t *testing.T) { + t.Parallel() shell := &MockShellExecutor{} driver := &Driver{device: shell} @@ -4093,6 +4097,7 @@ func TestLaunchAppViaShellAmStartErrorWithArgs(t *testing.T) { // ============================================================================ func TestScrollUntilVisibleRespectsMaxScrolls(t *testing.T) { + t.Parallel() scrollCount := 0 client := &MockUIA2Client{ sourceFunc: func() (string, error) { @@ -4131,6 +4136,7 @@ func TestScrollUntilVisibleRespectsMaxScrolls(t *testing.T) { } func TestScrollUntilVisibleRespectsTimeout(t *testing.T) { + t.Parallel() client := &MockUIA2Client{ sourceFunc: func() (string, error) { // Element never found @@ -4165,6 +4171,7 @@ func TestScrollUntilVisibleRespectsTimeout(t *testing.T) { } func TestScrollUntilVisibleDefaultMaxScrolls(t *testing.T) { + t.Parallel() client := &MockUIA2Client{ sourceFunc: func() (string, error) { return ` diff --git a/pkg/driver/uiautomator2/driver.go b/pkg/driver/uiautomator2/driver.go index 69cbedd8..ff3e5335 100644 --- a/pkg/driver/uiautomator2/driver.go +++ b/pkg/driver/uiautomator2/driver.go @@ -175,6 +175,13 @@ func (d *Driver) Execute(step flow.Step) *core.CommandResult { result = d.eraseText(s) case *flow.HideKeyboardStep: result = d.hideKeyboard(s) + case *flow.IsKeyboardVisibleStep: + visible := d.IsKeyboardVisible() + result = &core.CommandResult{ + Success: true, + Message: fmt.Sprintf("%t", visible), + Data: visible, + } case *flow.InputRandomStep: result = d.inputRandom(s) @@ -312,9 +319,16 @@ func (d *Driver) GetState() *core.StateSnapshot { state.ClipboardText = clipboard } + state.KeyboardVisible = d.IsKeyboardVisible() + return state } +// IsKeyboardVisible returns whether the soft keyboard is currently shown. +func (d *Driver) IsKeyboardVisible() bool { + return d.isInputShown() +} + // GetPlatformInfo returns device/platform information. func (d *Driver) GetPlatformInfo() *core.PlatformInfo { return d.info diff --git a/pkg/driver/uiautomator2/driver_test.go b/pkg/driver/uiautomator2/driver_test.go index 5bcb1de8..b030555e 100644 --- a/pkg/driver/uiautomator2/driver_test.go +++ b/pkg/driver/uiautomator2/driver_test.go @@ -205,6 +205,7 @@ func (m *MockUIA2Client) SetAppiumSettings(settings map[string]interface{}) erro type MockShellExecutor struct { commands []string + responses []string pushes [][2]string // {local, remote} pairs recorded by Push response string err error @@ -214,6 +215,11 @@ type MockShellExecutor struct { func (m *MockShellExecutor) Shell(cmd string) (string, error) { m.commands = append(m.commands, cmd) + if len(m.responses) > 0 { + resp := m.responses[0] + m.responses = m.responses[1:] + return resp, m.err + } return m.response, m.err } @@ -772,14 +778,15 @@ func TestWaitForAnimationToEnd_HonoursTimeout(t *testing.T) { result := driver.Execute(step) elapsed := time.Since(start) - if !result.Success { - t.Fatalf("step should soft-pass even on timeout, got error %v", result.Error) + // The fork fails (Success:false) when the screen never stabilizes. + if result.Success { + t.Fatalf("step should fail when the screen never settles, got message %q", result.Message) } if elapsed < 450*time.Millisecond { t.Errorf("returned after %v — should have polled until ~500ms timeout", elapsed) } - if !strings.Contains(result.Message, "did not settle") { - t.Errorf("message = %q, want it to contain 'did not settle'", result.Message) + if !strings.Contains(result.Message, "Timed out") { + t.Errorf("message = %q, want it to contain 'Timed out'", result.Message) } } @@ -991,7 +998,9 @@ func TestExecuteScrollDefaultDirection(t *testing.T) { } func TestExecuteSwipe(t *testing.T) { - client := &MockUIA2Client{} + client := &MockUIA2Client{ + sourceData: ``, + } shell := &MockShellExecutor{} driver := New(client, &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 1920}, shell) @@ -1021,7 +1030,9 @@ func TestExecuteSwipeError(t *testing.T) { } func TestExecuteSwipeDefaultDirection(t *testing.T) { - client := &MockUIA2Client{} + client := &MockUIA2Client{ + sourceData: ``, + } shell := &MockShellExecutor{} driver := New(client, &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 1920}, shell) @@ -1035,7 +1046,8 @@ func TestExecuteSwipeDefaultDirection(t *testing.T) { func TestExecuteHideKeyboard(t *testing.T) { client := &MockUIA2Client{} - driver := New(client, nil, nil) + shell := &MockShellExecutor{responses: []string{"mInputShown=true", "mInputShown=false"}} + driver := New(client, nil, shell) step := &flow.HideKeyboardStep{} result := driver.Execute(step) @@ -1251,9 +1263,12 @@ func TestInputRandomNoActiveElement(t *testing.T) { // ============================================================================ func TestExecuteAllStepTypes(t *testing.T) { + t.Parallel() // This test covers the Execute switch statement for all step types // Most will fail because they need findElement, but this covers the switch paths - client := &MockUIA2Client{} + client := &MockUIA2Client{ + sourceData: ``, + } shell := &MockShellExecutor{} driver := New(client, &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 1920}, shell) driver.SetFindTimeout(100) // 100ms for fast test failure @@ -1746,6 +1761,7 @@ func TestAssertVisibleElementFoundIsVisible(t *testing.T) { } func TestAssertNotVisibleElementFound(t *testing.T) { + t.Parallel() server := setupMockServer(t, map[string]func(w http.ResponseWriter, r *http.Request){ "POST /element": func(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{ @@ -3085,6 +3101,7 @@ func TestRelativeSelectorWithNegativeIndex(t *testing.T) { } func TestRelativeSelectorNoMatch(t *testing.T) { + t.Parallel() pageSource := ` @@ -3118,6 +3135,7 @@ func TestRelativeSelectorNoMatch(t *testing.T) { client := newMockHTTPClient(server.URL) driver := New(client.Client, nil, nil) + driver.SetFindTimeout(100) // No element with text "Button" below Header step := &flow.TapOnStep{ @@ -3134,6 +3152,7 @@ func TestRelativeSelectorNoMatch(t *testing.T) { } func TestRelativeSelectorPageSourceError(t *testing.T) { + t.Parallel() server := setupMockServer(t, map[string]func(w http.ResponseWriter, r *http.Request){ "POST /element": func(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{ @@ -3163,6 +3182,7 @@ func TestRelativeSelectorPageSourceError(t *testing.T) { client := newMockHTTPClient(server.URL) driver := New(client.Client, nil, nil) + driver.SetFindTimeout(100) step := &flow.TapOnStep{ Selector: flow.Selector{ @@ -3178,6 +3198,7 @@ func TestRelativeSelectorPageSourceError(t *testing.T) { } func TestRelativeSelectorParseError(t *testing.T) { + t.Parallel() server := setupMockServer(t, map[string]func(w http.ResponseWriter, r *http.Request){ "POST /element": func(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{ @@ -3206,6 +3227,7 @@ func TestRelativeSelectorParseError(t *testing.T) { client := newMockHTTPClient(server.URL) driver := New(client.Client, nil, nil) + driver.SetFindTimeout(100) step := &flow.TapOnStep{ Selector: flow.Selector{ diff --git a/pkg/driver/uiautomator2/keyboard.go b/pkg/driver/uiautomator2/keyboard.go index 545de6bc..dfb52549 100644 --- a/pkg/driver/uiautomator2/keyboard.go +++ b/pkg/driver/uiautomator2/keyboard.go @@ -13,32 +13,74 @@ import ( // Patterns for extracting keyboard bounds from "dumpsys window InputMethod". var ( - // Android <=12: "mFrame=[left,top][right,bottom]" + // Android <=12: "mFrame=[left,top][right,bottom]" (not present on Android 13+) mFrameRegex = regexp.MustCompile(`mFrame=\[(\d+),(\d+)\]\[(\d+),(\d+)\]`) - // Android 13+: "touchable region=SkRegion((left,top,right,bottom))" + // "touchable region=SkRegion((left,top,right,bottom))" — present on all versions + // when the keyboard sets mTouchableInsets (stock keyboards do; some vendor keyboards don't). touchableRegionRegex = regexp.MustCompile(`touchable region=SkRegion\(\((\d+),(\d+),(\d+),(\d+)\)\)`) + + // "mGivenContentInsets=[left,top][right,bottom]" — tells us where keyboard content + // starts within the InputMethod window. The top inset is the transparent gap above + // the keyboard. Present on all versions. + contentInsetsRegex = regexp.MustCompile(`mGivenContentInsets=\[(\d+),(\d+)\]\[(\d+),(\d+)\]`) ) // parseKeyboardFrame extracts keyboard bounds from "dumpsys window InputMethod" output. -// Supports both Android <=12 (mFrame=) and Android 13+ (touchable region + isOnScreen) formats. // Returns nil if keyboard is not visible. +// +// Strategy order (verified against AOSP source for Android 10, 11, 13): +// 1. touchable region — most accurate, gives actual keyboard area. +// 2. mFrame + mGivenContentInsets — for vendor keyboards (Samsung, Xiaomi, etc.) +// that don't set touchable insets. Content insets reveal where keyboard starts. +// 3. mFrame alone — only if the frame looks like a keyboard (not a full-screen window). func parseKeyboardFrame(dumpsysOutput string) *core.Bounds { - // Strategy 1: Android <=12 — look for mFrame= - if matches := mFrameRegex.FindStringSubmatch(dumpsysOutput); matches != nil { + // isOnScreen= is present on all Android versions (10+). mViewVisibility=0x8 means GONE. + // mInputShown=false means the IME is dismissed even when a frame is present. + if strings.Contains(dumpsysOutput, "isOnScreen=false") || + strings.Contains(dumpsysOutput, "mViewVisibility=0x8") || + strings.Contains(dumpsysOutput, "mInputShown=false") { + return nil + } + + // Strategy 1: touchable region — the actual keyboard touchable area. + // Printed when mTouchableInsets != 0, which stock keyboards set but some vendor keyboards don't. + if matches := touchableRegionRegex.FindStringSubmatch(dumpsysOutput); matches != nil { return boundsFromMatches(matches) } - // Strategy 2: Android 13+ — check isOnScreen + touchable region - if !strings.Contains(dumpsysOutput, "isOnScreen=true") { + // Strategy 2+3: mFrame-based fallback (Android <=12 only; Android 13+ uses Frames: format). + frameMatches := mFrameRegex.FindStringSubmatch(dumpsysOutput) + if frameMatches == nil { + return nil + } + bounds := boundsFromMatches(frameMatches) + if bounds == nil { return nil } - if matches := touchableRegionRegex.FindStringSubmatch(dumpsysOutput); matches != nil { - return boundsFromMatches(matches) + // Strategy 2: adjust mFrame by content insets. mGivenContentInsets.top tells us how many + // pixels from the window top are transparent (not keyboard). This handles vendor keyboards + // that use a full-screen InputMethod window but report content insets correctly. + if insetsMatches := contentInsetsRegex.FindStringSubmatch(dumpsysOutput); insetsMatches != nil { + topInset, _ := strconv.Atoi(insetsMatches[2]) + if topInset > 0 { + bounds.Y += topInset + bounds.Height -= topInset + if bounds.Height <= 0 { + return nil + } + return bounds + } } - return nil + // Strategy 3: bare mFrame. Sanity check — a real keyboard is at most ~60% of screen height. + // If the frame is taller, it's the full InputMethod window, not the keyboard. + screenBottom := bounds.Y + bounds.Height + if screenBottom > 0 && bounds.Height > screenBottom*6/10 { + return nil + } + return bounds } // boundsFromMatches converts regex matches [_, left, top, right, bottom] to Bounds. @@ -72,18 +114,31 @@ func (d *Driver) getKeyboardBounds() *core.Bounds { return nil } + // Parse the keyboard frame directly from "dumpsys window InputMethod". + // parseKeyboardFrame returns nil when the keyboard isn't shown (e.g. + // isOnScreen=false or no touchable/visible frame), so we don't need a + // separate isInputShown pre-check here. output, err := d.device.Shell("dumpsys window InputMethod") if err != nil { return nil } - if strings.Contains(output, "mInputShown=false") { - return nil - } - return parseKeyboardFrame(output) } +// isInputShown checks mInputShown via "dumpsys input_method". +// This is the canonical source for whether the soft keyboard is displayed. +func (d *Driver) isInputShown() bool { + if d.device == nil { + return false + } + out, err := d.device.Shell("dumpsys input_method | grep mInputShown") + if err != nil { + return false + } + return strings.Contains(out, "mInputShown=true") +} + // isKeyboardVisible checks if the soft keyboard is currently shown using dumpsys. func (d *Driver) isKeyboardVisible() bool { return d.getKeyboardBounds() != nil diff --git a/pkg/driver/uiautomator2/keyboard_hide_test.go b/pkg/driver/uiautomator2/keyboard_hide_test.go deleted file mode 100644 index 4d7419d1..00000000 --- a/pkg/driver/uiautomator2/keyboard_hide_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package uiautomator2 - -import ( - "testing" - - "github.com/devicelab-dev/maestro-runner/pkg/core" - "github.com/devicelab-dev/maestro-runner/pkg/flow" - "github.com/devicelab-dev/maestro-runner/pkg/uiautomator2" -) - -// dumpsys output fragments for the keyboard-visibility check. -const ( - kbShownDumpsys = `mFrame=[0,1584][1080,2400]` - kbHiddenDumpsys = `mInputShown=false` -) - -// closureShell returns whatever its fn produces — lets a test make the -// keyboard-visibility dumpsys depend on what the driver has done so far. -type closureShell struct { - fn func(cmd string) (string, error) - commands []string -} - -func (s *closureShell) Shell(cmd string) (string, error) { - s.commands = append(s.commands, cmd) - return s.fn(cmd) -} - -// TestHideKeyboard_AppiumNoOp_FallsBackToBack is the #42 regression for the -// uiautomator2 driver: on devices where Appium's hide_keyboard is a no-op (e.g. -// Samsung), the keyboard stays up after HideKeyboard() returns success. The -// driver must verify via dumpsys and fall back to KEYCODE_BACK, which closes the -// IME. Here the keyboard is modeled as closing only once BACK is pressed. -func TestHideKeyboard_AppiumNoOp_FallsBackToBack(t *testing.T) { - client := &MockUIA2Client{} - shell := &closureShell{fn: func(string) (string, error) { - // Keyboard stays shown until a BACK key is pressed (Appium call is a no-op). - for _, kc := range client.pressKeyCalls { - if kc == uiautomator2.KeyCodeBack { - return kbHiddenDumpsys, nil - } - } - return kbShownDumpsys, nil - }} - d := New(client, &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 2400}, shell) - - result := d.hideKeyboard(&flow.HideKeyboardStep{}) - if !result.Success { - t.Fatalf("expected success, got %v", result.Error) - } - if client.hideKeyboardCalls != 1 { - t.Errorf("expected Appium HideKeyboard tried once, got %d", client.hideKeyboardCalls) - } - backs := 0 - for _, kc := range client.pressKeyCalls { - if kc == uiautomator2.KeyCodeBack { - backs++ - } - } - if backs != 1 { - t.Errorf("expected exactly one BACK fallback, got %d (keyCalls=%v)", backs, client.pressKeyCalls) - } -} - -// TestHideKeyboard_AppiumWorks_NoStrayBack verifies that when Appium's call -// actually closes the keyboard, the driver does NOT press BACK — so it can't -// trigger the stray back-navigation that was reported on the devicelab driver. -func TestHideKeyboard_AppiumWorks_NoStrayBack(t *testing.T) { - client := &MockUIA2Client{} - shell := &closureShell{fn: func(string) (string, error) { - // Keyboard closes as soon as Appium's hide_keyboard is called. - if client.hideKeyboardCalls > 0 { - return kbHiddenDumpsys, nil - } - return kbShownDumpsys, nil - }} - d := New(client, &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 2400}, shell) - - result := d.hideKeyboard(&flow.HideKeyboardStep{}) - if !result.Success { - t.Fatalf("expected success, got %v", result.Error) - } - if client.hideKeyboardCalls != 1 { - t.Errorf("expected Appium HideKeyboard called once, got %d", client.hideKeyboardCalls) - } - if len(client.pressKeyCalls) != 0 { - t.Errorf("expected NO key-event fallback (no stray BACK), got %v", client.pressKeyCalls) - } -} - -// TestHideKeyboard_NotVisible_NoOp verifies that when the keyboard isn't shown, -// the driver does nothing — no Appium call, no BACK — so a hideKeyboard step on -// a screen with no keyboard can never navigate back. -func TestHideKeyboard_NotVisible_NoOp(t *testing.T) { - client := &MockUIA2Client{} - shell := &closureShell{fn: func(string) (string, error) { return kbHiddenDumpsys, nil }} - d := New(client, &core.PlatformInfo{ScreenWidth: 1080, ScreenHeight: 2400}, shell) - - result := d.hideKeyboard(&flow.HideKeyboardStep{}) - if !result.Success { - t.Fatalf("expected success, got %v", result.Error) - } - if client.hideKeyboardCalls != 0 { - t.Errorf("expected no Appium call when keyboard already hidden, got %d", client.hideKeyboardCalls) - } - if len(client.pressKeyCalls) != 0 { - t.Errorf("expected no key events when keyboard already hidden, got %v", client.pressKeyCalls) - } -} diff --git a/pkg/driver/uiautomator2/keyboard_test.go b/pkg/driver/uiautomator2/keyboard_test.go index 2db4cc34..dd45bf15 100644 --- a/pkg/driver/uiautomator2/keyboard_test.go +++ b/pkg/driver/uiautomator2/keyboard_test.go @@ -59,6 +59,20 @@ func TestParseKeyboardFrame(t *testing.T) { mForceSeamlesslyRotate=false seamlesslyRotate: pending=null isOnScreen=true`, want: &core.Bounds{X: 0, Y: 1428, Width: 1080, Height: 912}, }, + { + name: "Android 13+ with both mFrame and touchable region prefers touchable region", + input: ` Window #2 Window{abcdef InputMethod}: + mDisplayId=0 stackId=0 mSession=Session{...} + mAttrs={(0,0)(fillxfill) ty=INPUT_METHOD fmt=TRANSLUCENT} + mBaseLayer=131000 mSubLayer=0 + mFrame=[0,84][1080,2400] + mViewVisibility=0x0 mHaveFrame=true mObscured=false + touchable region=SkRegion((0,1428,1080,2340)) + mHasSurface=true isReadyForDisplay()=true + Frames: parent=[0,84][1080,2400] display=[0,84][1080,2400] frame=[0,84][1080,2400] + isOnScreen=true`, + want: &core.Bounds{X: 0, Y: 1428, Width: 1080, Height: 912}, + }, { name: "Android 13+ keyboard hidden (isOnScreen=false)", input: ` mViewVisibility=0x8 mHaveFrame=true @@ -68,6 +82,43 @@ func TestParseKeyboardFrame(t *testing.T) { isOnScreen=false`, want: nil, }, + { + name: "keyboard hidden by mViewVisibility=0x8 alone (no isOnScreen field)", + input: ` mViewVisibility=0x8 mHaveFrame=true + touchable region=SkRegion((0,1538,1080,2340)) + mHasSurface=false`, + want: nil, + }, + { + name: "SDK 30 touchable region without isOnScreen field", + input: ` Window #1 Window{abcdef InputMethod}: + mFrame=[0,84][1080,2400] + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,1292][0,0] + mTouchableInsets=3 + touchable region=SkRegion((0,1428,1080,2340)) + mHasSurface=true`, + want: &core.Bounds{X: 0, Y: 1428, Width: 1080, Height: 912}, + }, + { + name: "vendor keyboard — no touchable region, uses mFrame + content insets", + input: ` Window #1 Window{abcdef InputMethod}: + mFrame=[0,84][1080,2400] + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,1292][0,0] mGivenVisibleInsets=[0,1292][0,0] + mHasSurface=true isReadyForDisplay()=true + isOnScreen=true`, + want: &core.Bounds{X: 0, Y: 1376, Width: 1080, Height: 1024}, + }, + { + name: "full-screen mFrame without insets or touchable region — rejected", + input: ` Window #1 Window{abcdef InputMethod}: + mFrame=[0,84][1080,2400] + mViewVisibility=0x0 mHaveFrame=true + mGivenContentInsets=[0,0][0,0] + isOnScreen=true`, + want: nil, + }, } for _, tt := range tests { @@ -122,7 +173,8 @@ func TestGetKeyboardBounds(t *testing.T) { t.Run("keyboard visible with mFrame", func(t *testing.T) { mock := &MockUIA2Client{} shell := &MockShellExecutor{ - response: `mFrame=[0,1584][1080,2400]`, + response: `mInputShown=true +mFrame=[0,1584][1080,2400]`, } d := New(mock, nil, shell) bounds := d.getKeyboardBounds() @@ -137,7 +189,8 @@ func TestGetKeyboardBounds(t *testing.T) { t.Run("keyboard visible Android 13+", func(t *testing.T) { mock := &MockUIA2Client{} shell := &MockShellExecutor{ - response: ` touchable region=SkRegion((0,1428,1080,2340)) + response: `mInputShown=true + touchable region=SkRegion((0,1428,1080,2340)) isOnScreen=true`, } d := New(mock, nil, shell) @@ -158,7 +211,8 @@ func TestIsKeyboardVisible(t *testing.T) { t.Error("expected false when device is nil") } - shell := &MockShellExecutor{response: `mFrame=[0,1584][1080,2400]`} + shell := &MockShellExecutor{response: `mInputShown=true +mFrame=[0,1584][1080,2400]`} d2 := New(mock, nil, shell) if !d2.isKeyboardVisible() { t.Error("expected true when keyboard frame is present") @@ -341,7 +395,8 @@ func TestTapOnKeyboardHintMessage(t *testing.T) { defer server.Close() shell := &MockShellExecutor{ - response: ` touchable region=SkRegion((0,1428,1080,2340)) + response: `mInputShown=true + touchable region=SkRegion((0,1428,1080,2340)) isOnScreen=true`, } client := newMockHTTPClient(server.URL) @@ -369,7 +424,8 @@ func TestTapOnKeyboardHintMessage(t *testing.T) { defer server.Close() shell := &MockShellExecutor{ - response: ` touchable region=SkRegion((0,1428,1080,2340)) + response: `mInputShown=true + touchable region=SkRegion((0,1428,1080,2340)) isOnScreen=true`, } client := newMockHTTPClient(server.URL) @@ -412,7 +468,8 @@ func TestTapOnKeyboardHintMessage(t *testing.T) { defer server.Close() shell := &MockShellExecutor{ - response: ` touchable region=SkRegion((0,1428,1080,2340)) + response: `mInputShown=true + touchable region=SkRegion((0,1428,1080,2340)) isOnScreen=true`, } client := newMockHTTPClient(server.URL) @@ -438,7 +495,8 @@ func TestAssertVisibleKeyboardBlocking(t *testing.T) { defer server.Close() shell := &MockShellExecutor{ - response: ` touchable region=SkRegion((0,1428,1080,2340)) + response: `mInputShown=true + touchable region=SkRegion((0,1428,1080,2340)) isOnScreen=true`, } client := newMockHTTPClient(server.URL) diff --git a/pkg/driver/wda/commands.go b/pkg/driver/wda/commands.go index 68cee144..3f2dc6f2 100644 --- a/pkg/driver/wda/commands.go +++ b/pkg/driver/wda/commands.go @@ -16,6 +16,13 @@ import ( "github.com/devicelab-dev/maestro-runner/pkg/logger" ) +const ( + defaultAnimationTimeoutMs = 15000 + defaultAnimationSleepMs = 200 // pause between the two comparison screenshots + screenshotDiffThreshold = 0.005 // 0.5 % — default pixel-diff threshold + screenshotRetryIntervalMs = 100 // outer loop retry interval +) + // Tap commands func (d *Driver) tapOn(step *flow.TapOnStep) *core.CommandResult { @@ -1551,29 +1558,50 @@ func (d *Driver) waitUntil(step *flow.WaitUntilStep) *core.CommandResult { func (d *Driver) waitForAnimationToEnd(step *flow.WaitForAnimationToEndStep) *core.CommandResult { timeoutMs := step.TimeoutMs if timeoutMs <= 0 { - timeoutMs = 15000 + timeoutMs = defaultAnimationTimeoutMs + } + sleepMs := step.SleepMs + if sleepMs <= 0 { + sleepMs = defaultAnimationSleepMs + } + threshold := step.Threshold + if threshold <= 0 { + threshold = screenshotDiffThreshold } - const threshold = 0.005 - deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) - start := time.Now() - for time.Now().Before(deadline) { - prev, err := d.client.Screenshot() - if err != nil { - return errorResult(err, fmt.Sprintf("Screenshot failed: %v", err)) - } - curr, err := d.client.Screenshot() - if err != nil { - return errorResult(err, fmt.Sprintf("Screenshot failed: %v", err)) - } - diff := core.ImageDifference(prev, curr) - if diff <= threshold { - elapsed := time.Since(start) - return successResult(fmt.Sprintf("Animation ended (%.1f%% diff, %dms)", diff*100, elapsed.Milliseconds()), nil) - } + res := core.WaitForScreenStatic( + func() ([]byte, error) { return d.client.Screenshot() }, + time.Duration(timeoutMs)*time.Millisecond, + time.Duration(sleepMs)*time.Millisecond, + time.Duration(screenshotRetryIntervalMs)*time.Millisecond, + threshold, + ) + + if res.Settled { + return successResult( + fmt.Sprintf("Animation ended (%.1f%% diff, %dms)", res.Diffs[len(res.Diffs)-1]*100, res.Elapsed.Milliseconds()), + nil, + ) + } + return &core.CommandResult{ + Success: false, + Message: fmt.Sprintf( + "Timed out after %dms (%d iteration(s)) waiting for screen to become static; diffs=%s threshold=%.4f", + timeoutMs, res.Iterations, formatAnimationDiffs(res.Diffs), threshold, + ), } +} - return successResult(fmt.Sprintf("Animation did not settle within %dms — continuing", timeoutMs), nil) +// formatAnimationDiffs formats a slice of diff values as "[0.000764 0.000821 ...]" +func formatAnimationDiffs(diffs []float64) string { + if len(diffs) == 0 { + return "[]" + } + parts := make([]string, len(diffs)) + for i, d := range diffs { + parts[i] = fmt.Sprintf("%.6f", d) + } + return "[" + strings.Join(parts, " ") + "]" } // Media diff --git a/pkg/driver/wda/commands_test.go b/pkg/driver/wda/commands_test.go index 582c68be..df2fa873 100644 --- a/pkg/driver/wda/commands_test.go +++ b/pkg/driver/wda/commands_test.go @@ -309,6 +309,7 @@ func TestEraseTextPartialEraseSendKeysFails(t *testing.T) { // TestOpenLinkWithAutoVerify tests openLink with autoVerify flag set. func TestOpenLinkWithAutoVerify(t *testing.T) { + t.Parallel() var urlRequested string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -364,6 +365,7 @@ func TestOpenLinkWithBrowserFlag(t *testing.T) { // TestOpenLinkWithBothFlags tests openLink with both autoVerify and browser flags. func TestOpenLinkWithBothFlags(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") jsonResponse(w, map[string]interface{}{"status": 0}) @@ -586,6 +588,7 @@ func TestScrollUntilVisibleImmediateFind(t *testing.T) { // TestScrollUntilVisibleUpDirection tests scrollUntilVisible with "up" direction. func TestScrollUntilVisibleUpDirection(t *testing.T) { + t.Parallel() scrollCount := 0 server := mockWDAServerWithScrollElements(1) // Found after 1 scroll // Override to count scrolls @@ -650,6 +653,7 @@ func TestScrollUntilVisibleUpDirection(t *testing.T) { // off-screen (visible="false"). This is the core iOS bug: findElement returns // off-screen elements, so we must check info.Visible before declaring success. func TestScrollUntilVisibleSkipsOffScreenElement(t *testing.T) { + t.Parallel() scrollCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -711,6 +715,7 @@ func TestScrollUntilVisibleSkipsOffScreenElement(t *testing.T) { // TestScrollUntilVisibleCaseInsensitiveDirection tests that direction is // case-insensitive (e.g., "DOWN", "Down" work the same as "down"). func TestScrollUntilVisibleCaseInsensitiveDirection(t *testing.T) { + t.Parallel() scrollCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -1533,6 +1538,7 @@ func TestSetPermissionsEmptyPermissions(t *testing.T) { // TestSetPermissionsAllAllow tests setPermissions with "all" permission and "allow" value. // Since exec.Command("xcrun",...) will fail in test, the code handles errors gracefully. func TestSetPermissionsAllAllow(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") jsonResponse(w, map[string]interface{}{"status": 0}) @@ -1563,6 +1569,7 @@ func TestSetPermissionsAllAllow(t *testing.T) { // TestSetPermissionsSpecificAllow tests setPermissions with a specific permission. func TestSetPermissionsSpecificAllow(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") jsonResponse(w, map[string]interface{}{"status": 0}) @@ -1588,6 +1595,7 @@ func TestSetPermissionsSpecificAllow(t *testing.T) { // TestSetPermissionsSpecificDeny tests setPermissions with "deny" value. func TestSetPermissionsSpecificDeny(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") jsonResponse(w, map[string]interface{}{"status": 0}) @@ -3939,6 +3947,7 @@ func TestDismissAlertSuccess(t *testing.T) { // TestAcceptAlertNoAlertTimeout tests acceptAlert when no alert appears within timeout. // Should succeed silently per the waitForAlert contract. func TestAcceptAlertNoAlertTimeout(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.Contains(r.URL.Path, "/alert/accept") && r.Method == "POST" { @@ -3970,6 +3979,7 @@ func TestAcceptAlertNoAlertTimeout(t *testing.T) { // TestDismissAlertNoAlertTimeout tests dismissAlert when no alert appears within timeout. func TestDismissAlertNoAlertTimeout(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.Contains(r.URL.Path, "/alert/dismiss") && r.Method == "POST" { @@ -4027,6 +4037,7 @@ func TestAcceptAlertDefaultTimeout(t *testing.T) { // TestWaitForAlertPollingBehavior tests that waitForAlert polls and eventually finds an alert. func TestWaitForAlertPollingBehavior(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -4333,6 +4344,7 @@ func TestSetAirplaneModeActivateSettingsFails(t *testing.T) { } func TestSetAirplaneModeElementNotFound(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/wda/apps/activate") { jsonResponse(w, map[string]interface{}{"status": 0}) @@ -4495,6 +4507,7 @@ func TestToggleAirplaneModeTapFails(t *testing.T) { // ============================================================================= func TestScrollUntilVisibleRespectsMaxScrolls(t *testing.T) { + t.Parallel() scrollCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -4543,6 +4556,7 @@ func TestScrollUntilVisibleRespectsMaxScrolls(t *testing.T) { } func TestScrollUntilVisibleRespectsTimeout(t *testing.T) { + t.Parallel() scrollCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/pkg/driver/wda/driver_test.go b/pkg/driver/wda/driver_test.go index 8c8c54b7..bc457129 100644 --- a/pkg/driver/wda/driver_test.go +++ b/pkg/driver/wda/driver_test.go @@ -1162,6 +1162,7 @@ func mockWDAServerWithScrollElements(foundAfterScrolls int) *httptest.Server { // TestScrollUntilVisibleElementFound tests scrollUntilVisible when element is found after scrolls func TestScrollUntilVisibleElementFound(t *testing.T) { + t.Parallel() server := mockWDAServerWithScrollElements(2) // Element found after 2 scrolls defer server.Close() driver := createTestDriver(server) @@ -1180,6 +1181,7 @@ func TestScrollUntilVisibleElementFound(t *testing.T) { // TestScrollUntilVisibleElementNotFound tests scrollUntilVisible when element is not found func TestScrollUntilVisibleElementNotFound(t *testing.T) { + t.Parallel() server := mockWDAServerWithScrollElements(100) // Element never found defer server.Close() driver := createTestDriver(server) @@ -2523,6 +2525,7 @@ func TestInputTextSelectorNotFound(t *testing.T) { // TestInputTextWithSelectorNoElementID tests inputText with element that has no ID (tap fallback) func TestInputTextWithSelectorNoElementID(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") path := r.URL.Path @@ -3642,6 +3645,7 @@ func TestCopyTextFromNotFound(t *testing.T) { driver := createTestDriver(server) step := &flow.CopyTextFromStep{ + BaseStep: flow.BaseStep{TimeoutMs: 100}, Selector: flow.Selector{Text: "NonExistent"}, } result := driver.copyTextFrom(step) @@ -4602,6 +4606,7 @@ func TestAssertNotVisibleOptional(t *testing.T) { // TestInputTextAppendMode tests inputText with append mode func TestInputTextAppendMode(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // A text field holds focus, which is the state inputText runs in. @@ -5106,6 +5111,7 @@ func TestScrollDownDirection(t *testing.T) { // TestInputTextSendKeysError tests inputText when sendKeys fails func TestInputTextSendKeysError(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.Contains(r.URL.Path, "/wda/keys") { @@ -5256,6 +5262,7 @@ func TestSwipeError(t *testing.T) { // TestScrollUntilVisibleScrollFails tests scrollUntilVisible when scroll fails func TestScrollUntilVisibleScrollFails(t *testing.T) { + t.Parallel() callCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -5420,6 +5427,7 @@ func TestEraseTextElementClearFallback(t *testing.T) { // TestScrollUntilVisibleMaxScrolls tests scrollUntilVisible hitting max scrolls func TestScrollUntilVisibleMaxScrolls(t *testing.T) { + t.Parallel() scrollCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -5521,6 +5529,7 @@ func TestFindElementRelativeWithNonExistentAnchor(t *testing.T) { // TestInputTextWithUnicodeChars tests inputText with non-ASCII characters func TestInputTextWithUnicodeChars(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // A text field holds focus, which is the state inputText runs in. @@ -5670,6 +5679,7 @@ func TestFindElementWithCustomOptionalFindTimeout(t *testing.T) { // TestAssertNotVisibleDefaultTimeout tests assertNotVisible with default timeout func TestAssertNotVisibleDefaultTimeout(t *testing.T) { + t.Parallel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if strings.HasSuffix(r.URL.Path, "/source") { @@ -5696,6 +5706,7 @@ func TestAssertNotVisibleDefaultTimeout(t *testing.T) { // TestScrollUntilVisibleWithTimeoutMs tests scrollUntilVisible using TimeoutMs for maxScrolls func TestScrollUntilVisibleWithTimeoutMs(t *testing.T) { + t.Parallel() scrollCount := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/pkg/emulator/android_test.go b/pkg/emulator/android_test.go index 993903a5..8c1cb0d8 100644 --- a/pkg/emulator/android_test.go +++ b/pkg/emulator/android_test.go @@ -194,6 +194,10 @@ func TestListAVDs_Integration(t *testing.T) { } func TestManager_AllocatePort(t *testing.T) { + // AllocatePort calls RunningEmulatorPorts() which runs "adb devices". + // Use a fake adb so a live emulator-5554 is not seen as occupied. + t.Setenv("PATH", fakeADB(t)+":"+os.Getenv("PATH")) + // Create a clean manager without persistent port mapping mgr := &Manager{ portMap: make(map[string]int), @@ -428,6 +432,11 @@ func TestForceKillEmulator_InvalidSerialFormats(t *testing.T) { } func TestForceKillEmulator_ValidSerialNoProcess(t *testing.T) { + // forceKillEmulator falls back to pgrep -f "qemu-system.*-avd" which would + // find and kill a real running emulator. Use a fake pgrep (via fakeADB) that + // always exits 1 so no real process is ever touched. + t.Setenv("PATH", fakeADB(t)+":"+os.Getenv("PATH")) + // Valid serial format but no matching process running. // This test exercises the code path where pgrep fails. err := forceKillEmulator("emulator-59998") @@ -443,13 +452,47 @@ func TestForceKillEmulator_ValidSerialNoProcess(t *testing.T) { // Additional tests for Manager.Shutdown // ============================================================ +// fakeADB writes fake binaries into a temp dir so tests never hit real adb or +// pgrep: +// - "adb": exits 0 for "emu kill", exits 1 for "get-state" (device gone), +// exits 0 with empty output for everything else (e.g. "devices"). +// - "pgrep": always exits 1 (no process found), preventing forceKillEmulator +// from ever locating or killing a real emulator process. +// +// Prepend the returned dir to PATH via t.Setenv so exec.Command resolves to +// these stubs instead of the real binaries. +func fakeADB(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + // fake pgrep — always reports "not found" + if err := os.WriteFile(dir+"/pgrep", []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { + t.Fatalf("fakeADB: write pgrep: %v", err) + } + + script := `#!/bin/sh +for arg in "$@"; do + case "$arg" in + kill) exit 0 ;; + get-state) exit 1 ;; + esac +done +exit 0 +` + path := dir + "/adb" + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("fakeADB: %v", err) + } + return dir +} + func TestManager_Shutdown_TrackedEmulatorNotRunning(t *testing.T) { + // Point PATH at a fake adb so no real "adb -s emulator-5554 emu kill" fires. + t.Setenv("PATH", fakeADB(t)+":"+os.Getenv("PATH")) + mgr := NewManager() // Register an emulator that is not actually running. - // Shutdown will try ShutdownEmulator which will run "adb -s emulator-5554 emu kill" - // and fail, then fall through to forceKillEmulator. - // The test verifies the error propagation path. instance := &EmulatorInstance{ AVDName: "test-avd", Serial: "emulator-5554", @@ -460,18 +503,21 @@ func TestManager_Shutdown_TrackedEmulatorNotRunning(t *testing.T) { } mgr.started.Store("emulator-5554", instance) - // Shutdown should eventually fail because there is no real emulator running - // but this exercises the Shutdown code path. - // We do not check the error value because it depends on whether adb is available. - _ = mgr.Shutdown("emulator-5554") + // Shutdown should succeed: fake adb handles emu kill + get-state. + if err := mgr.Shutdown("emulator-5554"); err != nil { + t.Errorf("Shutdown() with fake adb should not error, got: %v", err) + } - // After Shutdown attempt (whether it succeeds or fails on real machine), - // verify the emulator was removed from tracking on success, - // or still tracked on failure. - // In unit tests without adb, it will fail, so the emulator stays tracked. + // The emulator should be removed from tracking after a successful shutdown. + if mgr.IsStartedByUs("emulator-5554") { + t.Error("emulator-5554 should have been removed from tracking after Shutdown()") + } } func TestManager_ShutdownAll_MultipleTracked(t *testing.T) { + // Point PATH at a fake adb so no real emu kill commands fire. + t.Setenv("PATH", fakeADB(t)+":"+os.Getenv("PATH")) + mgr := NewManager() // Track two emulators that are not actually running @@ -485,16 +531,16 @@ func TestManager_ShutdownAll_MultipleTracked(t *testing.T) { mgr.started.Store(serial, instance) } - // This will attempt to shut down both. - // They are not actually running, so ShutdownEmulator will fail. - err := mgr.ShutdownAll() - // We expect errors because emulators are not running - if err == nil { - // This can happen if adb is not found at all (no error from ShutdownEmulator) - // or if system adb responds unexpectedly - t.Log("ShutdownAll returned nil (adb may not be available)") - } else { - t.Logf("ShutdownAll returned expected error: %v", err) + // ShutdownAll should succeed: fake adb handles all adb sub-commands. + if err := mgr.ShutdownAll(); err != nil { + t.Errorf("ShutdownAll() with fake adb should not error, got: %v", err) + } + + // Both emulators should be removed from tracking. + for _, serial := range []string{"emulator-5554", "emulator-5556"} { + if mgr.IsStartedByUs(serial) { + t.Errorf("%s should have been removed from tracking after ShutdownAll()", serial) + } } } diff --git a/pkg/executor/flow_runner.go b/pkg/executor/flow_runner.go index 7c524d36..3667973a 100644 --- a/pkg/executor/flow_runner.go +++ b/pkg/executor/flow_runner.go @@ -396,6 +396,11 @@ func (fr *FlowRunner) executeStep(idx int, step flow.Step) (report.Status, strin var result *core.CommandResult switch s := step.(type) { + // Sleep step - handled by FlowRunner directly + case *flow.SleepStep: + time.Sleep(time.Duration(s.DurationMs) * time.Millisecond) + result = &core.CommandResult{Success: true, Message: fmt.Sprintf("Slept %dms", s.DurationMs)} + // JS/Scripting steps - handled by ScriptEngine case *flow.DefineVariablesStep: result = fr.script.ExecuteDefineVariables(s) @@ -1370,6 +1375,9 @@ func (fr *FlowRunner) executeNestedStep(step flow.Step) *core.CommandResult { } switch s := step.(type) { + case *flow.SleepStep: + time.Sleep(time.Duration(s.DurationMs) * time.Millisecond) + result = &core.CommandResult{Success: true, Message: fmt.Sprintf("Slept %dms", s.DurationMs)} case *flow.DefineVariablesStep: result = fr.script.ExecuteDefineVariables(s) case *flow.RunScriptStep: diff --git a/pkg/flow/json.go b/pkg/flow/json.go new file mode 100644 index 00000000..db1336da --- /dev/null +++ b/pkg/flow/json.go @@ -0,0 +1,354 @@ +package flow + +import ( + "encoding/json" + "fmt" +) + +// UnmarshalStep deserializes a JSON step into the correct concrete Step type. +// It reads the "type" discriminator field first, then unmarshals into the +// appropriate struct — mirroring decodeStep in parser.go for YAML. +func UnmarshalStep(data []byte) (Step, error) { + var envelope struct { + Type StepType `json:"type"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, fmt.Errorf("unmarshal step type: %w", err) + } + if envelope.Type == "" { + return nil, fmt.Errorf("missing \"type\" field in step JSON") + } + + return unmarshalStepByType(envelope.Type, data) +} + +//nolint:gocyclo +func unmarshalStepByType(stepType StepType, data []byte) (Step, error) { + switch stepType { + case StepTapOn: + var s TapOnStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepDoubleTapOn: + var s DoubleTapOnStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepLongPressOn: + var s LongPressOnStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepTapOnPoint: + var s TapOnPointStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepSwipe: + var s SwipeStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepScroll: + var s ScrollStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepScrollUntilVisible: + var s ScrollUntilVisibleStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepBack: + var s BackStep + s.StepType = stepType + return &s, nil + + case StepHideKeyboard: + var s HideKeyboardStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepAcceptAlert: + var s AcceptAlertStep + s.StepType = stepType + return &s, nil + + case StepIsKeyboardVisible: + var s IsKeyboardVisibleStep + s.StepType = stepType + return &s, nil + + case StepDismissAlert: + var s DismissAlertStep + s.StepType = stepType + return &s, nil + + case StepInputText: + var s InputTextStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepInputRandom: + var s InputRandomStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepEraseText: + var s EraseTextStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepCopyTextFrom: + var s CopyTextFromStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepPasteText: + var s PasteTextStep + s.StepType = stepType + return &s, nil + + case StepSetClipboard: + var s SetClipboardStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepAssertVisible: + var s AssertVisibleStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepAssertNotVisible: + var s AssertNotVisibleStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepAssertTrue: + var s AssertTrueStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepAssertCondition: + var s AssertConditionStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepLaunchApp: + var s LaunchAppStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepStopApp: + var s StopAppStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepKillApp: + var s KillAppStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepClearState: + var s ClearStateStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepClearKeychain: + var s ClearKeychainStep + s.StepType = stepType + return &s, nil + + case StepSetPermissions: + var s SetPermissionsStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepSetLocation: + var s SetLocationStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepSetOrientation: + var s SetOrientationStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepOpenLink: + var s OpenLinkStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepOpenBrowser: + var s OpenBrowserStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepPressKey: + var s PressKeyStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepSleep: + var s SleepStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepWaitForAnimationToEnd: + var s WaitForAnimationToEndStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + case StepTakeScreenshot: + var s TakeScreenshotStep + if err := json.Unmarshal(data, &s); err != nil { + return nil, err + } + s.StepType = stepType + return &s, nil + + default: + return nil, fmt.Errorf("unsupported step type: %s", stepType) + } +} + +// MarshalJSON for Selector: if only Text is set, marshal as a plain string. +// Otherwise marshal as object. +func (s Selector) MarshalJSON() ([]byte, error) { + if s.isTextOnly() { + return json.Marshal(s.Text) + } + // Use alias to avoid infinite recursion + type selectorAlias Selector + return json.Marshal(selectorAlias(s)) +} + +// UnmarshalJSON for Selector: accept plain string or object. +func (s *Selector) UnmarshalJSON(data []byte) error { + // Try string first + var text string + if err := json.Unmarshal(data, &text); err == nil { + s.Text = text + return nil + } + // Fall back to object + type selectorAlias Selector + var alias selectorAlias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *s = Selector(alias) + return nil +} + +// isTextOnly returns true if only the Text field is set (for compact JSON serialization). +func (s *Selector) isTextOnly() bool { + return s.Text != "" && + s.ID == "" && + s.CSS == "" && + s.Width == 0 && + s.Height == 0 && + s.Tolerance == 0 && + s.Enabled == nil && + s.Selected == nil && + s.Checked == nil && + s.Focused == nil && + s.Index == "" && + s.Traits == "" && + s.ChildOf == nil && + s.Below == nil && + s.Above == nil && + s.LeftOf == nil && + s.RightOf == nil && + s.ContainsChild == nil && + len(s.ContainsDescendants) == 0 && + s.InsideOf == nil +} diff --git a/pkg/flow/json_test.go b/pkg/flow/json_test.go new file mode 100644 index 00000000..7c4a5eb3 --- /dev/null +++ b/pkg/flow/json_test.go @@ -0,0 +1,262 @@ +package flow + +import ( + "encoding/json" + "testing" +) + +func TestUnmarshalStep_TapOn(t *testing.T) { + input := `{"type":"tapOn","selector":"Login","longPress":true,"timeout":5000}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + if step.Type() != StepTapOn { + t.Fatalf("expected type %s, got %s", StepTapOn, step.Type()) + } + tap := step.(*TapOnStep) + if tap.Selector.Text != "Login" { + t.Errorf("expected text 'Login', got %q", tap.Selector.Text) + } + if !tap.LongPress { + t.Error("expected longPress=true") + } + if tap.TimeoutMs != 5000 { + t.Errorf("expected timeout 5000, got %d", tap.TimeoutMs) + } +} + +func TestUnmarshalStep_TapOnByID(t *testing.T) { + input := `{"type":"tapOn","selector":{"id":"btn_login"},"longPress":true}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + tap := step.(*TapOnStep) + if tap.Selector.ID != "btn_login" { + t.Errorf("expected id 'btn_login', got %q", tap.Selector.ID) + } + if !tap.LongPress { + t.Error("expected longPress=true") + } +} + +func TestUnmarshalStep_InputText(t *testing.T) { + input := `{"type":"inputText","text":"user@example.com"}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + if step.Type() != StepInputText { + t.Fatalf("expected type %s, got %s", StepInputText, step.Type()) + } + is := step.(*InputTextStep) + if is.Text != "user@example.com" { + t.Errorf("expected text 'user@example.com', got %q", is.Text) + } +} + +func TestUnmarshalStep_AssertVisible(t *testing.T) { + input := `{"type":"assertVisible","selector":"Dashboard","timeout":10000}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + if step.Type() != StepAssertVisible { + t.Fatalf("expected type %s, got %s", StepAssertVisible, step.Type()) + } + av := step.(*AssertVisibleStep) + if av.Selector.Text != "Dashboard" { + t.Errorf("expected text 'Dashboard', got %q", av.Selector.Text) + } + if av.TimeoutMs != 10000 { + t.Errorf("expected timeout 10000, got %d", av.TimeoutMs) + } +} + +func TestUnmarshalStep_LaunchApp(t *testing.T) { + input := `{"type":"launchApp","appId":"com.example.app","clearState":true}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + la := step.(*LaunchAppStep) + if la.AppID != "com.example.app" { + t.Errorf("expected appId 'com.example.app', got %q", la.AppID) + } + if !la.ClearState { + t.Error("expected clearState=true") + } +} + +func TestUnmarshalStep_Swipe(t *testing.T) { + input := `{"type":"swipe","direction":"UP","duration":400}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + sw := step.(*SwipeStep) + if sw.Direction != "UP" { + t.Errorf("expected direction UP, got %q", sw.Direction) + } + if sw.Duration != 400 { + t.Errorf("expected duration 400, got %d", sw.Duration) + } +} + +func TestUnmarshalStep_Back(t *testing.T) { + input := `{"type":"back"}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + if step.Type() != StepBack { + t.Fatalf("expected type %s, got %s", StepBack, step.Type()) + } +} + +func TestUnmarshalStep_PressKey(t *testing.T) { + input := `{"type":"pressKey","key":"ENTER"}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + pk := step.(*PressKeyStep) + if pk.Key != "ENTER" { + t.Errorf("expected key 'ENTER', got %q", pk.Key) + } +} + +func TestUnmarshalStep_EraseText(t *testing.T) { + input := `{"type":"eraseText","charactersToErase":5}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + et := step.(*EraseTextStep) + if et.Characters != 5 { + t.Errorf("expected characters 5, got %d", et.Characters) + } +} + +func TestUnmarshalStep_MissingType(t *testing.T) { + input := `{"text":"Login"}` + _, err := UnmarshalStep([]byte(input)) + if err == nil { + t.Fatal("expected error for missing type") + } +} + +func TestUnmarshalStep_Optional(t *testing.T) { + input := `{"type":"tapOn","selector":"OK","optional":true}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + if !step.IsOptional() { + t.Error("expected optional=true") + } +} + +func TestUnmarshalStep_StopApp(t *testing.T) { + input := `{"type":"stopApp","appId":"com.example.app"}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + sa := step.(*StopAppStep) + if sa.AppID != "com.example.app" { + t.Errorf("expected appId 'com.example.app', got %q", sa.AppID) + } +} + +func TestUnmarshalStep_Scroll(t *testing.T) { + input := `{"type":"scroll"}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("UnmarshalStep error: %v", err) + } + if step.Type() != StepScroll { + t.Fatalf("expected type %s, got %s", StepScroll, step.Type()) + } +} + +// --- Selector JSON --- + +func TestSelector_MarshalJSON_TextOnly(t *testing.T) { + s := Selector{Text: "Login"} + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + // Text-only selector should marshal as plain string + if string(data) != `"Login"` { + t.Errorf("expected \"Login\", got %s", string(data)) + } +} + +func TestSelector_MarshalJSON_WithID(t *testing.T) { + s := Selector{Text: "Login", ID: "btn"} + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + // Should be an object + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("expected JSON object, got: %s", string(data)) + } + if m["text"] != "Login" { + t.Errorf("expected text 'Login', got %v", m["text"]) + } + if m["id"] != "btn" { + t.Errorf("expected id 'btn', got %v", m["id"]) + } +} + +func TestSelector_UnmarshalJSON_String(t *testing.T) { + var s Selector + if err := json.Unmarshal([]byte(`"Login"`), &s); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if s.Text != "Login" { + t.Errorf("expected text 'Login', got %q", s.Text) + } +} + +func TestSelector_UnmarshalJSON_Object(t *testing.T) { + var s Selector + if err := json.Unmarshal([]byte(`{"text":"Login","id":"btn","enabled":true}`), &s); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if s.Text != "Login" { + t.Errorf("expected text 'Login', got %q", s.Text) + } + if s.ID != "btn" { + t.Errorf("expected id 'btn', got %q", s.ID) + } + if s.Enabled == nil || !*s.Enabled { + t.Error("expected enabled=true") + } +} + +// --- JSON round-trip: step type is preserved --- + +func TestStepType_JSONRoundTrip(t *testing.T) { + input := `{"type":"tapOn","selector":"Login"}` + step, err := UnmarshalStep([]byte(input)) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + data, err := json.Marshal(step) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("re-unmarshal: %v", err) + } + if m["type"] != "tapOn" { + t.Errorf("expected type 'tapOn' in JSON, got %v", m["type"]) + } +} diff --git a/pkg/flow/parser.go b/pkg/flow/parser.go index 8bbf859d..f0b1d084 100644 --- a/pkg/flow/parser.go +++ b/pkg/flow/parser.go @@ -249,7 +249,7 @@ func isStepType(key string) bool { switch StepType(key) { case StepTapOn, StepDoubleTapOn, StepLongPressOn, StepTapOnPoint, StepSwipe, StepScroll, StepScrollUntilVisible, StepBack, StepHideKeyboard, - StepOpenNotifications, StepAcceptAlert, StepDismissAlert, + StepOpenNotifications, StepAcceptAlert, StepDismissAlert, StepIsKeyboardVisible, StepInputText, StepInputRandom, StepInputRandomEmail, StepInputRandomNumber, StepInputRandomPersonName, StepInputRandomText, StepEraseText, StepCopyTextFrom, StepPasteText, StepSetClipboard, @@ -267,7 +267,7 @@ func isStepType(key string) bool { StepOpenTab, StepSwitchTab, StepCloseTab, StepMockNetwork, StepBlockNetwork, StepSetNetworkConditions, StepWaitForRequest, StepClearNetworkMocks, StepTakeScreenshot, StepStartRecording, - StepStopRecording, StepAddMedia, StepRemoveMedia, StepPressKey, StepWaitForAnimationToEnd, + StepStopRecording, StepAddMedia, StepRemoveMedia, StepSleep, StepPressKey, StepWaitForAnimationToEnd, StepDefineVariables, StepDragAndDrop: return true } @@ -355,7 +355,16 @@ func decodeStep(stepType StepType, valueNode *yaml.Node, sourcePath string) (Ste return &BackStep{BaseStep: BaseStep{StepType: stepType}}, nil case StepHideKeyboard: - return &HideKeyboardStep{BaseStep: BaseStep{StepType: stepType}}, nil + var s HideKeyboardStep + if valueNode.Kind == yaml.ScalarNode { + s.Strategy = valueNode.Value + } else if valueNode.Kind == yaml.MappingNode { + if err := valueNode.Decode(&s); err != nil { + return nil, wrapParseError(sourcePath, valueNode.Line, err) + } + } + s.StepType = stepType + return &s, nil case StepOpenNotifications: return &OpenNotificationsStep{BaseStep: BaseStep{StepType: stepType}}, nil @@ -363,6 +372,9 @@ func decodeStep(stepType StepType, valueNode *yaml.Node, sourcePath string) (Ste case StepAcceptAlert: return &AcceptAlertStep{BaseStep: BaseStep{StepType: stepType}}, nil + case StepIsKeyboardVisible: + return &IsKeyboardVisibleStep{BaseStep: BaseStep{StepType: stepType}}, nil + case StepDismissAlert: return &DismissAlertStep{BaseStep: BaseStep{StepType: stepType}}, nil @@ -1024,6 +1036,20 @@ func decodeStep(stepType StepType, valueNode *yaml.Node, sourcePath string) (Ste s.StepType = stepType return &s, nil + case StepSleep: + var s SleepStep + if valueNode.Kind == yaml.ScalarNode { + var ms int + if err := valueNode.Decode(&ms); err != nil { + return nil, wrapParseError(sourcePath, valueNode.Line, err) + } + s.DurationMs = ms + } else if err := valueNode.Decode(&s); err != nil { + return nil, wrapParseError(sourcePath, valueNode.Line, err) + } + s.StepType = stepType + return &s, nil + case StepWaitForAnimationToEnd: var s WaitForAnimationToEndStep if err := valueNode.Decode(&s); err != nil { diff --git a/pkg/flow/selector.go b/pkg/flow/selector.go index 0452af2f..f637dbdb 100644 --- a/pkg/flow/selector.go +++ b/pkg/flow/selector.go @@ -13,28 +13,28 @@ import ( // Pure data structure - executor decides how to use it. type Selector struct { // Primary selectors - Text string `yaml:"text"` // Text to match - ID string `yaml:"id"` // Resource ID or accessibility ID + Text string `yaml:"text" json:"text,omitempty"` // Text to match + ID string `yaml:"id" json:"id,omitempty"` // Resource ID or accessibility ID // Size matching - Width int `yaml:"width"` - Height int `yaml:"height"` - Tolerance int `yaml:"tolerance"` + Width int `yaml:"width" json:"width,omitempty"` + Height int `yaml:"height" json:"height,omitempty"` + Tolerance int `yaml:"tolerance" json:"tolerance,omitempty"` // State filters - Enabled *bool `yaml:"enabled"` - Selected *bool `yaml:"selected"` - Checked *bool `yaml:"checked"` - Focused *bool `yaml:"focused"` + Enabled *bool `yaml:"enabled" json:"enabled,omitempty"` + Selected *bool `yaml:"selected" json:"selected,omitempty"` + Checked *bool `yaml:"checked" json:"checked,omitempty"` + Focused *bool `yaml:"focused" json:"focused,omitempty"` // Index for multiple matches (string for variable support) - Index string `yaml:"index"` + Index string `yaml:"index" json:"index,omitempty"` // Traits (comma-separated string, e.g., "button,heading") - Traits string `yaml:"traits"` + Traits string `yaml:"traits" json:"traits,omitempty"` // CSS selector for web views - CSS string `yaml:"css"` + CSS string `yaml:"css" json:"css,omitempty"` // Web-specific selectors Placeholder string `yaml:"placeholder"` // Match by HTML placeholder attribute @@ -49,27 +49,27 @@ type Selector struct { Nth int `yaml:"nth"` // Pick Nth match (0-based) when multiple elements match // Relative selectors - ChildOf *Selector `yaml:"childOf"` - Below *Selector `yaml:"below"` - Above *Selector `yaml:"above"` - LeftOf *Selector `yaml:"leftOf"` - RightOf *Selector `yaml:"rightOf"` - ContainsChild *Selector `yaml:"containsChild"` - ContainsDescendants []*Selector `yaml:"containsDescendants"` - InsideOf *Selector `yaml:"insideOf"` // Visual containment (center point inside anchor bounds) + ChildOf *Selector `yaml:"childOf" json:"childOf,omitempty"` + Below *Selector `yaml:"below" json:"below,omitempty"` + Above *Selector `yaml:"above" json:"above,omitempty"` + LeftOf *Selector `yaml:"leftOf" json:"leftOf,omitempty"` + RightOf *Selector `yaml:"rightOf" json:"rightOf,omitempty"` + ContainsChild *Selector `yaml:"containsChild" json:"containsChild,omitempty"` + ContainsDescendants []*Selector `yaml:"containsDescendants" json:"containsDescendants,omitempty"` + InsideOf *Selector `yaml:"insideOf" json:"insideOf,omitempty"` // Visual containment (center point inside anchor bounds) // Inline step properties (parsed with selector for YAML convenience) - Optional *bool `yaml:"optional"` - RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange"` - WaitUntilVisible *bool `yaml:"waitUntilVisible"` - Point string `yaml:"point"` // Tap point "x%, y%" - Start string `yaml:"start"` // Swipe start "x%, y%" - End string `yaml:"end"` // Swipe end "x%, y%" - Repeat int `yaml:"repeat"` // Tap repeat count - Delay int `yaml:"delay"` // Delay between repeats (ms) - WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs"` // Wait for UI settle (ms) - Timeout int `yaml:"timeout"` // Timeout in ms for element finding - Label string `yaml:"label"` // Step label + Optional *bool `yaml:"optional" json:"-"` + RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange" json:"-"` + WaitUntilVisible *bool `yaml:"waitUntilVisible" json:"-"` + Point string `yaml:"point" json:"-"` // Tap point "x%, y%" + Start string `yaml:"start" json:"-"` // Swipe start "x%, y%" + End string `yaml:"end" json:"-"` // Swipe end "x%, y%" + Repeat int `yaml:"repeat" json:"-"` // Tap repeat count + Delay int `yaml:"delay" json:"-"` // Delay between repeats (ms) + WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs" json:"-"` // Wait for UI settle (ms) + Timeout int `yaml:"timeout" json:"-"` // Timeout in ms for element finding + Label string `yaml:"label" json:"-"` // Step label } // selectorRaw is used for YAML parsing to capture the "element" field. diff --git a/pkg/flow/step.go b/pkg/flow/step.go index d2798e80..8dcb54d2 100644 --- a/pkg/flow/step.go +++ b/pkg/flow/step.go @@ -119,7 +119,11 @@ const ( StepAddMedia StepType = "addMedia" StepRemoveMedia StepType = "removeMedia" + // Queries + StepIsKeyboardVisible StepType = "isKeyboardVisible" + // Other + StepSleep StepType = "sleep" StepPressKey StepType = "pressKey" StepWaitForAnimationToEnd StepType = "waitForAnimationToEnd" StepDefineVariables StepType = "defineVariables" @@ -138,13 +142,13 @@ type Step interface { // BaseStep contains common fields for all steps. type BaseStep struct { - StepType StepType `yaml:"-"` - Optional bool `yaml:"optional"` - StepLabel string `yaml:"label"` - TimeoutMs int `yaml:"timeout"` + StepType StepType `yaml:"-" json:"type"` + Optional bool `yaml:"optional" json:"optional,omitempty"` + StepLabel string `yaml:"label" json:"label,omitempty"` + TimeoutMs int `yaml:"timeout" json:"timeout,omitempty"` // Platform restricts this step to a single platform; when set and it // doesn't match the running driver, the step is skipped (Maestro #1353). - Platform string `yaml:"platform"` + Platform string `yaml:"platform" json:"platform,omitempty"` } // PlatformGate returns the step's platform restriction, lowercased, or "". @@ -168,48 +172,48 @@ func (b *BaseStep) Describe() string { return string(b.StepType) } // TapOnStep taps on an element. type TapOnStep struct { - BaseStep `yaml:",inline"` - Selector Selector `yaml:",inline"` - LongPress bool `yaml:"longPress"` - Repeat int `yaml:"repeat"` - DelayMs int `yaml:"delay"` - DurationMs int `yaml:"duration"` - Point string `yaml:"point"` - RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange"` - WaitUntilVisible *bool `yaml:"waitUntilVisible"` - WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs"` + BaseStep `yaml:",inline" json:",inline"` + Selector Selector `yaml:",inline" json:"selector"` + LongPress bool `yaml:"longPress" json:"longPress,omitempty"` + Repeat int `yaml:"repeat" json:"repeat,omitempty"` + DelayMs int `yaml:"delay" json:"delay,omitempty"` + DurationMs int `yaml:"duration" json:"duration,omitempty"` + Point string `yaml:"point" json:"point,omitempty"` + RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange" json:"retryTapIfNoChange,omitempty"` + WaitUntilVisible *bool `yaml:"waitUntilVisible" json:"waitUntilVisible,omitempty"` + WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs" json:"waitToSettleTimeoutMs,omitempty"` } // DoubleTapOnStep double taps on an element (alias for tapOn with repeat=2). type DoubleTapOnStep struct { - BaseStep `yaml:",inline"` - Selector Selector `yaml:",inline"` - RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange"` - WaitUntilVisible *bool `yaml:"waitUntilVisible"` - WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs"` + BaseStep `yaml:",inline" json:",inline"` + Selector Selector `yaml:",inline" json:"selector"` + RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange" json:"retryTapIfNoChange,omitempty"` + WaitUntilVisible *bool `yaml:"waitUntilVisible" json:"waitUntilVisible,omitempty"` + WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs" json:"waitToSettleTimeoutMs,omitempty"` } // LongPressOnStep long presses on an element (alias for tapOn with longPress=true). type LongPressOnStep struct { - BaseStep `yaml:",inline"` - Selector Selector `yaml:",inline"` - DurationMs int `yaml:"duration"` - RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange"` - WaitUntilVisible *bool `yaml:"waitUntilVisible"` - WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs"` + BaseStep `yaml:",inline" json:",inline"` + Selector Selector `yaml:",inline" json:"selector"` + DurationMs int `yaml:"duration" json:"duration,omitempty"` + RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange" json:"retryTapIfNoChange,omitempty"` + WaitUntilVisible *bool `yaml:"waitUntilVisible" json:"waitUntilVisible,omitempty"` + WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs" json:"waitToSettleTimeoutMs,omitempty"` } // TapOnPointStep taps on specific coordinates. type TapOnPointStep struct { - BaseStep `yaml:",inline"` - X int `yaml:"x"` - Y int `yaml:"y"` - Point string `yaml:"point"` - LongPress bool `yaml:"longPress"` - Repeat int `yaml:"repeat"` - DurationMs int `yaml:"duration"` - RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange"` - WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs"` + BaseStep `yaml:",inline" json:",inline"` + X int `yaml:"x" json:"x,omitempty"` + Y int `yaml:"y" json:"y,omitempty"` + Point string `yaml:"point" json:"point,omitempty"` + LongPress bool `yaml:"longPress" json:"longPress,omitempty"` + Repeat int `yaml:"repeat" json:"repeat,omitempty"` + DurationMs int `yaml:"duration" json:"duration,omitempty"` + RetryTapIfNoChange *bool `yaml:"retryTapIfNoChange" json:"retryTapIfNoChange,omitempty"` + WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs" json:"waitToSettleTimeoutMs,omitempty"` } // SwipeStep performs a swipe gesture. @@ -239,19 +243,19 @@ func (s *DragAndDropStep) Describe() string { } type SwipeStep struct { - BaseStep `yaml:",inline"` - Direction string `yaml:"direction"` // UP, DOWN, LEFT, RIGHT - Selector *Selector `yaml:"-"` - Start string `yaml:"start"` // "x%, y%" - End string `yaml:"end"` // "x%, y%" - StartX int `yaml:"startX"` // Absolute X start - StartY int `yaml:"startY"` // Absolute Y start - EndX int `yaml:"endX"` // Absolute X end - EndY int `yaml:"endY"` // Absolute Y end - Duration int `yaml:"duration"` // Duration in ms - Speed int `yaml:"speed"` // Speed 0-100 - Distance float64 `yaml:"distance"` // Fraction of screen (0-1) for direction swipes; 0 = default - WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs"` + BaseStep `yaml:",inline" json:",inline"` + Direction string `yaml:"direction" json:"direction,omitempty"` // UP, DOWN, LEFT, RIGHT + Selector *Selector `yaml:"-" json:"selector,omitempty"` + Start string `yaml:"start" json:"start,omitempty"` // "x%, y%" + End string `yaml:"end" json:"end,omitempty"` // "x%, y%" + StartX int `yaml:"startX" json:"startX,omitempty"` // Absolute X start + StartY int `yaml:"startY" json:"startY,omitempty"` // Absolute Y start + EndX int `yaml:"endX" json:"endX,omitempty"` // Absolute X end + EndY int `yaml:"endY" json:"endY,omitempty"` // Absolute Y end + Duration int `yaml:"duration" json:"duration,omitempty"` // Duration in ms + Speed int `yaml:"speed" json:"speed,omitempty"` // Speed 0-100 + Distance float64 `yaml:"distance" json:"distance,omitempty"` // Fraction of screen (0-1) for direction swipes; 0 = default + WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs" json:"waitToSettleTimeoutMs,omitempty"` } // UnmarshalYAML decodes SwipeStep and maps both `from:` (upstream Maestro @@ -284,42 +288,49 @@ func (s *SwipeStep) UnmarshalYAML(node *yaml.Node) error { // ScrollStep scrolls the screen. type ScrollStep struct { - BaseStep `yaml:",inline"` - Direction string `yaml:"direction"` + BaseStep `yaml:",inline" json:",inline"` + Direction string `yaml:"direction" json:"direction,omitempty"` // Engine selects the scroll backend on Android. // "" (default) and "adb" → adb input swipe (matches upstream Maestro). // "agent" → driver's existing on-device gesture path (UIA2 server // /appium/gestures/scroll for the uiautomator2 driver, RPC MotionEvent // injection for the devicelab driver). Ignored on iOS/web. - Engine string `yaml:"engine"` + Engine string `yaml:"engine" json:"engine,omitempty"` } // ScrollUntilVisibleStep scrolls until element is visible. type ScrollUntilVisibleStep struct { - BaseStep `yaml:",inline"` - Element Selector `yaml:"element"` + BaseStep `yaml:",inline" json:",inline"` + Element Selector `yaml:"element" json:"element"` // From restricts the scroll gesture to one container, for a screen with an // inner list or a horizontal carousel that a full-width swipe would miss. // Empty means scroll the screen, which is the usual case. - From Selector `yaml:"from"` - Direction string `yaml:"direction"` - MaxScrolls int `yaml:"maxScrolls"` // Legacy: max scroll attempts - Speed int `yaml:"speed"` - VisibilityPercentage int `yaml:"visibilityPercentage"` - CenterElement bool `yaml:"centerElement"` - WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs"` + From Selector `yaml:"from" json:"from,omitempty"` + Direction string `yaml:"direction" json:"direction,omitempty"` + MaxScrolls int `yaml:"maxScrolls" json:"maxScrolls,omitempty"` + Speed int `yaml:"speed" json:"speed,omitempty"` + VisibilityPercentage int `yaml:"visibilityPercentage" json:"visibilityPercentage,omitempty"` + CenterElement bool `yaml:"centerElement" json:"centerElement,omitempty"` + WaitToSettleTimeoutMs int `yaml:"waitToSettleTimeoutMs" json:"waitToSettleTimeoutMs,omitempty"` // Engine selects the scroll backend. See ScrollStep.Engine. - Engine string `yaml:"engine"` + Engine string `yaml:"engine" json:"engine,omitempty"` } // BackStep presses back. type BackStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` } // HideKeyboardStep hides the keyboard. +// Strategy can be empty (try all), "appium", "escape", or "back". type HideKeyboardStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` + Strategy string `yaml:"strategy" json:"strategy,omitempty"` +} + +// IsKeyboardVisibleStep queries whether the soft keyboard is currently shown. +type IsKeyboardVisibleStep struct { + BaseStep `yaml:",inline" json:",inline"` } // OpenNotificationsStep pulls down the Android notification shade. @@ -330,12 +341,12 @@ type OpenNotificationsStep struct { // AcceptAlertStep accepts a system alert dialog (taps Allow/OK). type AcceptAlertStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` } // DismissAlertStep dismisses a system alert dialog (taps Don't Allow/Cancel). type DismissAlertStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` } // ============================================ @@ -344,40 +355,40 @@ type DismissAlertStep struct { // InputTextStep inputs text. type InputTextStep struct { - BaseStep `yaml:",inline"` - Text string `yaml:"text"` - KeyPress bool `yaml:"keyPress"` // If true, simulate real key presses (Android native only) - Selector Selector `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` + Text string `yaml:"text" json:"text,omitempty"` + KeyPress bool `yaml:"keyPress" json:"keyPress,omitempty"` // If true, simulate real key presses (Android native only) + Selector Selector `yaml:",inline" json:"selector,omitempty"` } // InputRandomStep generates random input. type InputRandomStep struct { - BaseStep `yaml:",inline"` - DataType string `yaml:"type"` // TEXT, NUMBER, EMAIL, PERSON_NAME, etc. - Length int `yaml:"length"` + BaseStep `yaml:",inline" json:",inline"` + DataType string `yaml:"type" json:"dataType,omitempty"` // TEXT, NUMBER, EMAIL, PERSON_NAME, etc. + Length int `yaml:"length" json:"length,omitempty"` } // EraseTextStep erases text. type EraseTextStep struct { - BaseStep `yaml:",inline"` - Characters int `yaml:"characters"` + BaseStep `yaml:",inline" json:",inline"` + Characters int `yaml:"characters" json:"charactersToErase,omitempty"` } // CopyTextFromStep copies text from element. type CopyTextFromStep struct { - BaseStep `yaml:",inline"` - Selector Selector `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` + Selector Selector `yaml:",inline" json:"selector"` } // PasteTextStep pastes text. type PasteTextStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` } // SetClipboardStep sets the clipboard to a specific text value. type SetClipboardStep struct { - BaseStep `yaml:",inline"` - Text string `yaml:"text"` + BaseStep `yaml:",inline" json:",inline"` + Text string `yaml:"text" json:"text,omitempty"` } // ============================================ @@ -386,12 +397,12 @@ type SetClipboardStep struct { // AssertVisibleStep asserts element is visible. type AssertVisibleStep struct { - BaseStep `yaml:",inline"` - Selector Selector `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` + Selector Selector `yaml:",inline" json:"selector"` // Count asserts that the selector matches exactly N visible elements // (Maestro #1363). A string so flows can write `count: ${N}`; empty means // the ordinary at-least-one assertion. - Count string `yaml:"count"` + Count string `yaml:"count" json:"count,omitempty"` } // ExpectedCount resolves the step's count assertion. Returns (0, false, nil) @@ -414,23 +425,23 @@ func (s *AssertVisibleStep) ExpectedCount() (int, bool, error) { // AssertNotVisibleStep asserts element is not visible. type AssertNotVisibleStep struct { - BaseStep `yaml:",inline"` - Selector Selector `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` + Selector Selector `yaml:",inline" json:"selector"` } // AssertTrueStep asserts a script condition is true (alias for assertCondition). type AssertTrueStep struct { - BaseStep `yaml:",inline"` - Script string `yaml:"condition"` + BaseStep `yaml:",inline" json:",inline"` + Script string `yaml:"condition" json:"condition,omitempty"` } // Condition represents a test condition. type Condition struct { - Visible *Selector `yaml:"visible"` - NotVisible *Selector `yaml:"notVisible"` - Script string `yaml:"true"` - Platform string `yaml:"platform"` - Timeout int `yaml:"timeout"` // Timeout in ms for visible/notVisible checks + Visible *Selector `yaml:"visible" json:"visible,omitempty"` + NotVisible *Selector `yaml:"notVisible" json:"notVisible,omitempty"` + Script string `yaml:"true" json:"scriptCondition,omitempty"` + Platform string `yaml:"platform" json:"platform,omitempty"` + Timeout int `yaml:"timeout" json:"timeout,omitempty"` // Timeout in ms for visible/notVisible checks } // AssertConditionStep asserts a condition. @@ -463,27 +474,27 @@ func (s *AssertConditionStep) UnmarshalYAML(node *yaml.Node) error { // AssertNoDefectsWithAIStep uses AI to check for visual defects. type AssertNoDefectsWithAIStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` } // AssertWithAIStep uses AI to verify an assertion. type AssertWithAIStep struct { - BaseStep `yaml:",inline"` - Assertion string `yaml:"assertion"` + BaseStep `yaml:",inline" json:",inline"` + Assertion string `yaml:"assertion" json:"assertion,omitempty"` } // ExtractTextWithAIStep uses AI to extract text from screen. type ExtractTextWithAIStep struct { - BaseStep `yaml:",inline"` - Query string `yaml:"query"` - Variable string `yaml:"variable"` // Variable to store result + BaseStep `yaml:",inline" json:",inline"` + Query string `yaml:"query" json:"query,omitempty"` + Variable string `yaml:"variable" json:"variable,omitempty"` // Variable to store result } // WaitUntilStep waits for a condition. type WaitUntilStep struct { - BaseStep `yaml:",inline"` - Visible *Selector `yaml:"visible"` - NotVisible *Selector `yaml:"notVisible"` + BaseStep `yaml:",inline" json:",inline"` + Visible *Selector `yaml:"visible" json:"visible,omitempty"` + NotVisible *Selector `yaml:"notVisible" json:"notVisible,omitempty"` } // ============================================ @@ -492,38 +503,38 @@ type WaitUntilStep struct { // LaunchAppStep launches an app. type LaunchAppStep struct { - BaseStep `yaml:",inline"` - AppID string `yaml:"appId"` - ClearState bool `yaml:"clearState"` - ClearKeychain bool `yaml:"clearKeychain"` - StopApp *bool `yaml:"stopApp"` - NewSession bool `yaml:"newSession"` // Appium only: create fresh session - Permissions map[string]string `yaml:"permissions"` - Arguments map[string]any `yaml:"arguments"` // Launch arguments (-key value pairs) - Environment map[string]string `yaml:"environment"` // Launch environment variables + BaseStep `yaml:",inline" json:",inline"` + AppID string `yaml:"appId" json:"appId,omitempty"` + ClearState bool `yaml:"clearState" json:"clearState,omitempty"` + ClearKeychain bool `yaml:"clearKeychain" json:"clearKeychain,omitempty"` + StopApp *bool `yaml:"stopApp" json:"stopApp,omitempty"` + NewSession bool `yaml:"newSession" json:"newSession,omitempty"` // Appium only: create fresh session + Permissions map[string]string `yaml:"permissions" json:"permissions,omitempty"` + Arguments map[string]any `yaml:"arguments" json:"arguments,omitempty"` // Launch arguments (-key value pairs) + Environment map[string]string `yaml:"environment" json:"environment,omitempty"` // Launch environment variables } // StopAppStep stops an app. type StopAppStep struct { - BaseStep `yaml:",inline"` - AppID string `yaml:"appId"` + BaseStep `yaml:",inline" json:",inline"` + AppID string `yaml:"appId" json:"appId,omitempty"` } // KillAppStep kills an app. type KillAppStep struct { - BaseStep `yaml:",inline"` - AppID string `yaml:"appId"` + BaseStep `yaml:",inline" json:",inline"` + AppID string `yaml:"appId" json:"appId,omitempty"` } // ClearStateStep clears app state. type ClearStateStep struct { - BaseStep `yaml:",inline"` - AppID string `yaml:"appId"` + BaseStep `yaml:",inline" json:",inline"` + AppID string `yaml:"appId" json:"appId,omitempty"` } // ClearKeychainStep clears keychain. type ClearKeychainStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` } // SetPermissionsStep sets app permissions. @@ -531,9 +542,9 @@ type ClearKeychainStep struct { // Permission shortcuts: location, camera, contacts, phone, microphone, // bluetooth, storage, notifications, medialibrary, calendar, sms, all type SetPermissionsStep struct { - BaseStep `yaml:",inline"` - AppID string `yaml:"appId"` - Permissions map[string]string `yaml:"permissions"` + BaseStep `yaml:",inline" json:",inline"` + AppID string `yaml:"appId" json:"appId,omitempty"` + Permissions map[string]string `yaml:"permissions" json:"permissions,omitempty"` } // ============================================ @@ -542,15 +553,15 @@ type SetPermissionsStep struct { // SetLocationStep sets device location. type SetLocationStep struct { - BaseStep `yaml:",inline"` - Latitude string `yaml:"latitude"` // String for variable support - Longitude string `yaml:"longitude"` // String for variable support + BaseStep `yaml:",inline" json:",inline"` + Latitude string `yaml:"latitude" json:"latitude,omitempty"` // String for variable support + Longitude string `yaml:"longitude" json:"longitude,omitempty"` // String for variable support } // SetOrientationStep sets device orientation. type SetOrientationStep struct { - BaseStep `yaml:",inline"` - Orientation string `yaml:"orientation"` // PORTRAIT, LANDSCAPE + BaseStep `yaml:",inline" json:",inline"` + Orientation string `yaml:"orientation" json:"orientation,omitempty"` // PORTRAIT, LANDSCAPE } // SetAirplaneModeStep sets airplane mode. @@ -560,14 +571,14 @@ type SetOrientationStep struct { // YAML scalar; the executor's variable-expansion pass writes the resolved // boolean into Enabled before the driver runs the step. type SetAirplaneModeStep struct { - BaseStep `yaml:",inline"` - Enabled bool `yaml:"-"` - EnabledRaw any `yaml:"enabled"` + BaseStep `yaml:",inline" json:",inline"` + Enabled bool `yaml:"-" json:"enabled,omitempty"` + EnabledRaw any `yaml:"enabled" json:"-"` } // ToggleAirplaneModeStep toggles airplane mode. type ToggleAirplaneModeStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` } // SetDarkModeStep switches the system UI between dark and light appearance. @@ -598,23 +609,23 @@ type AssertLightModeStep struct { // TravelStep simulates travel. type TravelStep struct { - BaseStep `yaml:",inline"` - Points []string `yaml:"points"` // "lat, long" - Speed float64 `yaml:"speed"` // km/h + BaseStep `yaml:",inline" json:",inline"` + Points []string `yaml:"points" json:"points,omitempty"` // "lat, long" + Speed float64 `yaml:"speed" json:"speed,omitempty"` // km/h } // OpenLinkStep opens a URL. type OpenLinkStep struct { - BaseStep `yaml:",inline"` - Link string `yaml:"link"` - AutoVerify *bool `yaml:"autoVerify"` - Browser *bool `yaml:"browser"` + BaseStep `yaml:",inline" json:",inline"` + Link string `yaml:"link" json:"link,omitempty"` + AutoVerify *bool `yaml:"autoVerify" json:"autoVerify,omitempty"` + Browser *bool `yaml:"browser" json:"browser,omitempty"` } // OpenBrowserStep opens a URL in the browser. type OpenBrowserStep struct { - BaseStep `yaml:",inline"` - URL string `yaml:"url"` + BaseStep `yaml:",inline" json:",inline"` + URL string `yaml:"url" json:"url,omitempty"` } // ============================================ @@ -623,19 +634,19 @@ type OpenBrowserStep struct { // RepeatStep repeats steps. type RepeatStep struct { - BaseStep `yaml:",inline"` - Times string `yaml:"times"` // String for variable support - While Condition `yaml:"while"` - Steps []Step `yaml:"-"` + BaseStep `yaml:",inline" json:",inline"` + Times string `yaml:"times" json:"times,omitempty"` // String for variable support + While Condition `yaml:"while" json:"while,omitempty"` + Steps []Step `yaml:"-" json:"-"` } // RetryStep retries steps on failure. type RetryStep struct { - BaseStep `yaml:",inline"` - MaxRetries string `yaml:"maxRetries"` // String for variable support - Steps []Step `yaml:"-"` - File string `yaml:"file"` - Env map[string]string `yaml:"env"` + BaseStep `yaml:",inline" json:",inline"` + MaxRetries string `yaml:"maxRetries" json:"maxRetries,omitempty"` // String for variable support + Steps []Step `yaml:"-" json:"-"` + File string `yaml:"file" json:"file,omitempty"` + Env map[string]string `yaml:"env" json:"env,omitempty"` } // RunFlowStep runs another flow. @@ -643,21 +654,21 @@ type RetryStep struct { // When `when:` evaluates false, execution falls through to the else branch // (ElseFile / ElseSteps). If no else branch is set, the step is skipped. type RunFlowStep struct { - BaseStep `yaml:",inline"` - File string `yaml:"file"` - Steps []Step `yaml:"-"` // Inline steps (commands) - ElseFile string `yaml:"-"` // Fallback flow file when `when` is false - ElseSteps []Step `yaml:"-"` // Inline fallback steps (else / elseCommands) - When *Condition `yaml:"when"` - Env map[string]string `yaml:"env"` + BaseStep `yaml:",inline" json:",inline"` + File string `yaml:"file" json:"file,omitempty"` + Steps []Step `yaml:"-" json:"-"` // Inline steps (commands) + ElseFile string `yaml:"-" json:"elseFile,omitempty"` // Fallback flow file when `when` is false + ElseSteps []Step `yaml:"-" json:"elseSteps,omitempty"` // Inline fallback steps (else / elseCommands) + When *Condition `yaml:"when" json:"when,omitempty"` + Env map[string]string `yaml:"env" json:"env,omitempty"` } // RunScriptStep runs a script. type RunScriptStep struct { - BaseStep `yaml:",inline"` - Script string `yaml:"script"` // Script content or filename (string form) - File string `yaml:"file"` // Script filename (map form) - Env map[string]string `yaml:"env"` + BaseStep `yaml:",inline" json:",inline"` + Script string `yaml:"script" json:"script,omitempty"` // Script content or filename (string form) + File string `yaml:"file" json:"file,omitempty"` // Script filename (map form) + Env map[string]string `yaml:"env" json:"env,omitempty"` } // RunShellStep runs a command on the machine driving the test — the host, not @@ -690,8 +701,8 @@ func (s *RunScriptStep) ScriptPath() string { // EvalScriptStep evaluates JavaScript. type EvalScriptStep struct { - BaseStep `yaml:",inline"` - Script string `yaml:"script"` + BaseStep `yaml:",inline" json:",inline"` + Script string `yaml:"script" json:"script,omitempty"` } // EvalBrowserScriptStep executes JavaScript in the browser page context (web only). @@ -895,9 +906,9 @@ type ClearNetworkMocksStep struct { // whole screen. Mirrors Maestro's takeScreenshot.cropOn (see // https://docs.maestro.dev/reference/commands-available/takescreenshot). type TakeScreenshotStep struct { - BaseStep `yaml:",inline"` - Path string `yaml:"path"` - CropOn *Selector `yaml:"cropOn,omitempty"` + BaseStep `yaml:",inline" json:",inline"` + Path string `yaml:"path" json:"path,omitempty"` + CropOn *Selector `yaml:"cropOn,omitempty" json:"cropOn,omitempty"` } // AssertScreenshotStep compares a screenshot with a reference image. @@ -907,29 +918,29 @@ type TakeScreenshotStep struct { // A literal number is resolved at parse time; a string is deferred to the // expand pass so `${VAR}` interpolation works (Maestro #3444). type AssertScreenshotStep struct { - BaseStep `yaml:",inline"` - Path string `yaml:"path"` - CropOn *Selector `yaml:"cropOn,omitempty"` - ThresholdPercentage float64 `yaml:"-"` - ThresholdRaw any `yaml:"thresholdPercentage,omitempty"` + BaseStep `yaml:",inline" json:",inline"` + Path string `yaml:"path" json:"path,omitempty"` + CropOn *Selector `yaml:"cropOn,omitempty" json:"cropOn,omitempty"` + ThresholdPercentage float64 `yaml:"-" json:"thresholdPercentage,omitempty"` + ThresholdRaw any `yaml:"thresholdPercentage,omitempty" json:"-"` } // StartRecordingStep starts recording. type StartRecordingStep struct { - BaseStep `yaml:",inline"` - Path string `yaml:"path"` + BaseStep `yaml:",inline" json:",inline"` + Path string `yaml:"path" json:"path,omitempty"` } // StopRecordingStep stops recording. type StopRecordingStep struct { - BaseStep `yaml:",inline"` - Path string `yaml:"path"` + BaseStep `yaml:",inline" json:",inline"` + Path string `yaml:"path" json:"path,omitempty"` } // AddMediaStep adds media files. type AddMediaStep struct { - BaseStep `yaml:",inline"` - Files []string `yaml:"files"` + BaseStep `yaml:",inline" json:",inline"` + Files []string `yaml:"files" json:"files,omitempty"` } // RemoveMediaStep clears media added by addMedia (Android: MediaStore index). @@ -941,10 +952,21 @@ type RemoveMediaStep struct { // Other Steps // ============================================ +// SleepStep pauses execution for a given duration in milliseconds. +type SleepStep struct { + BaseStep `yaml:",inline" json:",inline"` + DurationMs int `yaml:"durationMs" json:"durationMs,omitempty"` +} + +// Describe returns a human-readable description of the sleep step. +func (s *SleepStep) Describe() string { + return fmt.Sprintf("sleep: %dms", s.DurationMs) +} + // PressKeyStep presses a key. type PressKeyStep struct { - BaseStep `yaml:",inline"` - Key string `yaml:"key"` + BaseStep `yaml:",inline" json:",inline"` + Key string `yaml:"key" json:"key,omitempty"` } // WaitForAnimationToEndStep waits for animations. @@ -954,19 +976,27 @@ type PressKeyStep struct { // poll until static or timeout. Timeout comes from the inlined BaseStep // (`timeout:` YAML key); defaults to 15s when unset. type WaitForAnimationToEndStep struct { - BaseStep `yaml:",inline"` + BaseStep `yaml:",inline" json:",inline"` + // SleepMs is the pause inserted between the two consecutive screenshots used + // to detect motion. A longer sleep catches slow-moving animations; a shorter + // sleep speeds up detection of fast-settling screens. Defaults to 200 ms. + SleepMs int `yaml:"sleepMs" json:"sleepMs,omitempty"` + // Threshold is the maximum pixel-difference percentage (0.0–1.0) that is + // still considered "static". Lower values are stricter. Defaults to 0.005 + // (0.5 %). + Threshold float64 `yaml:"threshold" json:"threshold,omitempty"` } // DefineVariablesStep defines variables. type DefineVariablesStep struct { - BaseStep `yaml:",inline"` - Env map[string]string `yaml:"env"` + BaseStep `yaml:",inline" json:",inline"` + Env map[string]string `yaml:"env" json:"env,omitempty"` } // UnsupportedStep represents an unsupported step. type UnsupportedStep struct { - BaseStep `yaml:",inline"` - Reason string + BaseStep `yaml:",inline" json:",inline"` + Reason string `json:"reason,omitempty"` } // Describe returns a description including the unsupported reason. diff --git a/pkg/flutter/vmservice.go b/pkg/flutter/vmservice.go index 66e8c8ff..fd484f4c 100644 --- a/pkg/flutter/vmservice.go +++ b/pkg/flutter/vmservice.go @@ -72,7 +72,7 @@ func Connect(wsURL string) (*VMServiceClient, error) { // Find the Flutter isolate if err := c.findFlutterIsolate(); err != nil { - conn.Close(websocket.StatusNormalClosure, "") + _ = conn.Close(websocket.StatusNormalClosure, "") cancel() return nil, fmt.Errorf("find flutter isolate: %w", err) } @@ -115,7 +115,7 @@ func ConnectUnix(socketPath, token string) (*VMServiceClient, error) { } if err := c.findFlutterIsolate(); err != nil { - conn.Close(websocket.StatusNormalClosure, "") + _ = conn.Close(websocket.StatusNormalClosure, "") cancel() return nil, fmt.Errorf("find flutter isolate: %w", err) } diff --git a/pkg/flutter/wrapper.go b/pkg/flutter/wrapper.go index 3b616f0c..185a66f1 100644 --- a/pkg/flutter/wrapper.go +++ b/pkg/flutter/wrapper.go @@ -332,7 +332,9 @@ func (d *FlutterDriver) getFlutterTrees(needWidgetTree bool) (semanticsDump, wid // tryReconnect attempts to re-establish the VM Service connection. func (d *FlutterDriver) tryReconnect() error { if d.client != nil { - d.client.Close() + if err := d.client.Close(); err != nil { + logger.Debug("Flutter VM service close failed before reconnect: %v", err) + } d.client = nil } @@ -540,7 +542,11 @@ func (d *FlutterDriver) executeWithCoordinates(step flow.Step, node *SemanticsNo // Close closes the VM Service client if connected. func (d *FlutterDriver) Close() { - d.client.Close() + if d.client != nil { + if err := d.client.Close(); err != nil { + logger.Debug("Flutter VM service close failed: %v", err) + } + } d.client = nil } diff --git a/pkg/flutter/wrapper_test.go b/pkg/flutter/wrapper_test.go index 668d420f..b77bd1a8 100644 --- a/pkg/flutter/wrapper_test.go +++ b/pkg/flutter/wrapper_test.go @@ -485,6 +485,7 @@ func TestFlutterDriver_WidgetTreeFallback_Identifier(t *testing.T) { } func TestFlutterDriver_WidgetTreeFallback_NoMatch(t *testing.T) { + t.Parallel() // Inner driver can't find element inner := &mockDriver{ executeFunc: func(step flow.Step) *core.CommandResult { diff --git a/pkg/maestro/events.go b/pkg/maestro/events.go index 436dc7eb..71ab836e 100644 --- a/pkg/maestro/events.go +++ b/pkg/maestro/events.go @@ -3,6 +3,8 @@ package maestro import ( "encoding/json" "sync" + + "github.com/devicelab-dev/maestro-runner/pkg/logger" ) // EventHandler is a callback for push events from the device driver. @@ -79,6 +81,7 @@ func NewCDPTracker(c *Client) *CDPTracker { if err := json.Unmarshal(params, &state); err != nil { return } + logger.Info("[cdp:1-detect] push event from device agent: available=%v socket=%s", state.Available, state.Socket) ct.mu.Lock() ct.state = state ct.ready = true diff --git a/pkg/server/server.go b/pkg/server/server.go new file mode 100644 index 00000000..0d4d6db4 --- /dev/null +++ b/pkg/server/server.go @@ -0,0 +1,355 @@ +// Package server provides a REST API server that bridges HTTP calls to core.Driver. +package server + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/devicelab-dev/maestro-runner/pkg/core" + "github.com/devicelab-dev/maestro-runner/pkg/flow" + "github.com/devicelab-dev/maestro-runner/pkg/logger" +) + +// SessionState holds a driver session. +type SessionState struct { + Driver core.Driver + Cleanup func() +} + +// Server is the REST API server. +type Server struct { + mu sync.RWMutex + sessions map[string]*SessionState + + // CreateDriver is called when POST /session is invoked. + // It receives the session request and must return a driver + cleanup func. + CreateDriver func(req SessionRequest) (core.Driver, func(), error) +} + +// SessionRequest is the JSON body for POST /session. +type SessionRequest struct { + PlatformName string `json:"platformName"` + DeviceID string `json:"deviceId,omitempty"` + AppID string `json:"appId,omitempty"` + Driver string `json:"driver,omitempty"` +} + +// SessionResponse is the JSON response for POST /session. +type SessionResponse struct { + SessionID string `json:"sessionId"` +} + +// ErrorResponse is a JSON error response. +type ErrorResponse struct { + Error string `json:"error"` +} + +// New creates a new Server. +func New(createDriver func(req SessionRequest) (core.Driver, func(), error)) *Server { + return &Server{ + sessions: make(map[string]*SessionState), + CreateDriver: createDriver, + } +} + +// Handler returns an http.Handler with all routes registered. +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /status", s.handleStatus) + mux.HandleFunc("POST /session", s.handleCreateSession) + mux.HandleFunc("POST /session/{id}/execute", s.handleExecute) + mux.HandleFunc("GET /session/{id}/screenshot", s.handleScreenshot) + mux.HandleFunc("GET /session/{id}/source", s.handleSource) + mux.HandleFunc("GET /session/{id}/device-info", s.handleDeviceInfo) + mux.HandleFunc("DELETE /session/{id}", s.handleDeleteSession) + + return mux +} + +// ShutdownAll cleans up all active sessions. +func (s *Server) ShutdownAll() { + s.mu.Lock() + defer s.mu.Unlock() + for id, sess := range s.sessions { + logger.Info("Cleaning up session %s", id) + if sess.Cleanup != nil { + sess.Cleanup() + } + delete(s.sessions, id) + } +} + +func (s *Server) handleStatus(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + s.mu.RLock() + count := len(s.sessions) + s.mu.RUnlock() + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "sessions": count, + }) +} + +func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { + started := time.Now() + var req SessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid request body: " + err.Error()}) + serverTracef( + "server request worker=%s method=%s path=%s status=%d duration_ms=%d error=%q", + serverWorkerID(), r.Method, r.URL.Path, http.StatusBadRequest, time.Since(started).Milliseconds(), + err.Error(), + ) + return + } + + if req.PlatformName == "" { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "platformName is required"}) + serverTracef( + "server request worker=%s method=%s path=%s status=%d duration_ms=%d error=%q", + serverWorkerID(), r.Method, r.URL.Path, http.StatusBadRequest, time.Since(started).Milliseconds(), + "platformName is required", + ) + return + } + + driver, cleanup, err := s.CreateDriver(req) + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "failed to create driver: " + err.Error()}) + serverTracef( + "server request worker=%s method=%s path=%s status=%d duration_ms=%d error=%q platform=%s deviceId=%s", + serverWorkerID(), r.Method, r.URL.Path, http.StatusInternalServerError, + time.Since(started).Milliseconds(), err.Error(), req.PlatformName, req.DeviceID, + ) + return + } + + sessionID := generateSessionID() + s.mu.Lock() + s.sessions[sessionID] = &SessionState{ + Driver: driver, + Cleanup: cleanup, + } + s.mu.Unlock() + + logger.Info("Created session %s for platform=%s", sessionID, req.PlatformName) + serverTracef( + "server request worker=%s method=%s path=%s status=%d duration_ms=%d session=%s platform=%s deviceId=%s", + serverWorkerID(), r.Method, r.URL.Path, http.StatusOK, time.Since(started).Milliseconds(), + sessionID, req.PlatformName, req.DeviceID, + ) + writeJSON(w, http.StatusOK, SessionResponse{SessionID: sessionID}) +} + +func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request) { + started := time.Now() + sessionID := r.PathValue("id") + sess, ok := s.getSession(w, r) + if !ok { + serverTracef( + "server execute worker=%s session=%s status=%d duration_ms=%d error=%q", + serverWorkerID(), sessionID, http.StatusNotFound, time.Since(started).Milliseconds(), + "session not found", + ) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "failed to read body: " + err.Error()}) + serverTracef( + "server execute worker=%s session=%s status=%d duration_ms=%d error=%q", + serverWorkerID(), sessionID, http.StatusBadRequest, time.Since(started).Milliseconds(), + err.Error(), + ) + return + } + + step, err := flow.UnmarshalStep(body) + if err != nil { + writeJSON(w, http.StatusBadRequest, ErrorResponse{Error: "invalid step: " + err.Error()}) + serverTracef( + "server execute worker=%s session=%s status=%d duration_ms=%d error=%q raw_step=%s", + serverWorkerID(), sessionID, http.StatusBadRequest, time.Since(started).Milliseconds(), + err.Error(), trimForLog(string(body), 280), + ) + return + } + + serverTracef( + "server execute request worker=%s session=%s step=%q payload=%s", + serverWorkerID(), sessionID, stepName(step), trimForLog(string(body), 320), + ) + result := sess.Driver.Execute(step) + status := "passed" + if !result.Success { + status = "failed" + } + serverTracef( + "server execute response worker=%s session=%s step=%q status=%s duration_ms=%d message=%s", + serverWorkerID(), sessionID, stepName(step), status, time.Since(started).Milliseconds(), + trimForLog(result.Message, 220), + ) + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) handleScreenshot(w http.ResponseWriter, r *http.Request) { + sess, ok := s.getSession(w, r) + if !ok { + return + } + + png, err := sess.Driver.Screenshot() + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "screenshot failed: " + err.Error()}) + return + } + + w.Header().Set("Content-Type", "image/png") + w.WriteHeader(http.StatusOK) + if _, err := w.Write(png); err != nil { + log.Printf("failed to write screenshot: %v", err) + } +} + +func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) { + sess, ok := s.getSession(w, r) + if !ok { + return + } + + hierarchy, err := sess.Driver.Hierarchy() + if err != nil { + writeJSON(w, http.StatusInternalServerError, ErrorResponse{Error: "hierarchy failed: " + err.Error()}) + return + } + + // Detect content type from the hierarchy bytes + contentType := "application/xml" + if len(hierarchy) > 0 && hierarchy[0] == '{' { + contentType = "application/json" + } + w.Header().Set("Content-Type", contentType) + w.WriteHeader(http.StatusOK) + if _, err := w.Write(hierarchy); err != nil { + log.Printf("failed to write hierarchy: %v", err) + } +} + +func (s *Server) handleDeviceInfo(w http.ResponseWriter, r *http.Request) { + sess, ok := s.getSession(w, r) + if !ok { + return + } + + info := sess.Driver.GetPlatformInfo() + writeJSON(w, http.StatusOK, info) +} + +func (s *Server) handleDeleteSession(w http.ResponseWriter, r *http.Request) { + started := time.Now() + id := r.PathValue("id") + + s.mu.Lock() + sess, exists := s.sessions[id] + if exists { + delete(s.sessions, id) + } + s.mu.Unlock() + + if !exists { + writeJSON(w, http.StatusNotFound, ErrorResponse{Error: fmt.Sprintf("session %s not found", id)}) + serverTracef( + "server request worker=%s method=%s path=%s status=%d duration_ms=%d session=%s error=%q", + serverWorkerID(), r.Method, r.URL.Path, http.StatusNotFound, time.Since(started).Milliseconds(), + id, "session not found", + ) + return + } + + if sess.Cleanup != nil { + sess.Cleanup() + } + logger.Info("Deleted session %s", id) + serverTracef( + "server request worker=%s method=%s path=%s status=%d duration_ms=%d session=%s", + serverWorkerID(), r.Method, r.URL.Path, http.StatusNoContent, time.Since(started).Milliseconds(), id, + ) + w.WriteHeader(http.StatusNoContent) +} + +// getSession retrieves a session by the {id} path parameter. Returns false if not found. +func (s *Server) getSession(w http.ResponseWriter, r *http.Request) (*SessionState, bool) { + id := r.PathValue("id") + + s.mu.RLock() + sess, exists := s.sessions[id] + s.mu.RUnlock() + + if !exists { + writeJSON(w, http.StatusNotFound, ErrorResponse{Error: fmt.Sprintf("session %s not found", id)}) + return nil, false + } + return sess, true +} + +func generateSessionID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback — should never happen + return fmt.Sprintf("session-%d", len(b)) + } + return hex.EncodeToString(b) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("failed to encode JSON response: %v", err) + } +} + +// SanitizePlatform normalizes the platform name. +func SanitizePlatform(p string) string { + return strings.ToLower(strings.TrimSpace(p)) +} + +func serverWorkerID() string { + if worker := strings.TrimSpace(os.Getenv("PYTEST_XDIST_WORKER")); worker != "" { + return worker + } + if worker := strings.TrimSpace(os.Getenv("MAESTRO_WORKER_ID")); worker != "" { + return worker + } + return "master" +} + +func trimForLog(value string, maxLen int) string { + trimmed := strings.TrimSpace(value) + if len(trimmed) <= maxLen { + return trimmed + } + return trimmed[:maxLen] + "..." +} + +func stepName(step flow.Step) string { + if step == nil { + return "unknown" + } + return string(step.Type()) +} + +func serverTracef(format string, v ...interface{}) { + fmt.Printf("%s [TRACE] %s\n", time.Now().Format("15:04:05.000000"), fmt.Sprintf(format, v...)) +} diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go new file mode 100644 index 00000000..93b271d9 --- /dev/null +++ b/pkg/server/server_test.go @@ -0,0 +1,228 @@ +package server + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/devicelab-dev/maestro-runner/pkg/core" + "github.com/devicelab-dev/maestro-runner/pkg/driver/mock" +) + +func newTestServer() (*Server, *mock.Driver) { + drv := mock.New(mock.Config{Platform: "android", DeviceID: "test-device"}) + srv := New(func(req SessionRequest) (core.Driver, func(), error) { + return drv, func() {}, nil + }) + return srv, drv +} + +func createSession(t *testing.T, handler http.Handler) string { + t.Helper() + body := `{"platformName":"android","deviceId":"test-device"}` + req := httptest.NewRequest("POST", "/session", bytes.NewBufferString(body)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("create session: expected 200, got %d: %s", w.Code, w.Body.String()) + } + var resp SessionResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode session response: %v", err) + } + if resp.SessionID == "" { + t.Fatal("session id is empty") + } + return resp.SessionID +} + +func TestStatus(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + req := httptest.NewRequest("GET", "/status", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var body map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&body); err != nil { + t.Fatalf("decode error: %v", err) + } + if body["status"] != "ok" { + t.Errorf("expected status ok, got %v", body["status"]) + } +} + +func TestCreateSession(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + sid := createSession(t, handler) + req := httptest.NewRequest("GET", "/status", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + var body map[string]interface{} + json.NewDecoder(w.Body).Decode(&body) + if body["sessions"].(float64) != 1 { + t.Errorf("expected 1 session, got %v", body["sessions"]) + } + _ = sid +} + +func TestCreateSession_MissingPlatform(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + req := httptest.NewRequest("POST", "/session", bytes.NewBufferString(`{"deviceId":"x"}`)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestExecuteStep(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + sid := createSession(t, handler) + step := `{"type":"tapOn","selector":"Login"}` + req := httptest.NewRequest("POST", "/session/"+sid+"/execute", bytes.NewBufferString(step)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + var result core.CommandResult + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("decode error: %v", err) + } + if !result.Success { + t.Errorf("expected success, got: %s", result.Message) + } +} + +func TestExecuteStep_InvalidJSON(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + sid := createSession(t, handler) + req := httptest.NewRequest("POST", "/session/"+sid+"/execute", bytes.NewBufferString(`not json`)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", w.Code) + } +} + +func TestExecuteStep_SessionNotFound(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + req := httptest.NewRequest("POST", "/session/nonexistent/execute", bytes.NewBufferString(`{"type":"back"}`)) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestScreenshot(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + sid := createSession(t, handler) + req := httptest.NewRequest("GET", "/session/"+sid+"/screenshot", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if ct := w.Header().Get("Content-Type"); ct != "image/png" { + t.Errorf("expected image/png, got %s", ct) + } + if w.Body.Len() == 0 { + t.Error("expected non-empty body") + } +} + +func TestSource(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + sid := createSession(t, handler) + req := httptest.NewRequest("GET", "/session/"+sid+"/source", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + body, _ := io.ReadAll(w.Body) + if len(body) == 0 { + t.Error("expected non-empty hierarchy") + } +} + +func TestDeviceInfo(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + sid := createSession(t, handler) + req := httptest.NewRequest("GET", "/session/"+sid+"/device-info", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + var info core.PlatformInfo + if err := json.NewDecoder(w.Body).Decode(&info); err != nil { + t.Fatalf("decode error: %v", err) + } + if info.Platform != "android" { + t.Errorf("expected platform android, got %q", info.Platform) + } + if info.DeviceID != "test-device" { + t.Errorf("expected deviceId test-device, got %q", info.DeviceID) + } +} + +func TestDeleteSession(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + sid := createSession(t, handler) + req := httptest.NewRequest("DELETE", "/session/"+sid, nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusNoContent { + t.Fatalf("expected 204, got %d", w.Code) + } + req = httptest.NewRequest("GET", "/session/"+sid+"/device-info", nil) + w = httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 after delete, got %d", w.Code) + } +} + +func TestDeleteSession_NotFound(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + req := httptest.NewRequest("DELETE", "/session/nonexistent", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestShutdownAll(t *testing.T) { + srv, _ := newTestServer() + handler := srv.Handler() + createSession(t, handler) + createSession(t, handler) + srv.ShutdownAll() + req := httptest.NewRequest("GET", "/status", nil) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + var body map[string]interface{} + json.NewDecoder(w.Body).Decode(&body) + if body["sessions"].(float64) != 0 { + t.Errorf("expected 0 sessions after shutdown, got %v", body["sessions"]) + } +} diff --git a/prompt/plan.md b/prompt/plan.md new file mode 100644 index 00000000..0fd00f30 --- /dev/null +++ b/prompt/plan.md @@ -0,0 +1,452 @@ +# Implementation Plan: Maestro-Runner Remote Bindings + +## Goal + +Decouple the maestro-runner execution engine from YAML files by implementing a **REST Server (Go)** and a **Client binding (Python)**. + +--- + +## Current Codebase Reality + +> **Read this before writing a single line** — audit these facts so nothing is reinvented. + +| Area | Fact | +|---|---| +| **CLI framework** | `github.com/urfave/cli/v2` (NOT Cobra). All commands live in `pkg/cli/`. There is no `cmd/` directory. New commands go in `pkg/cli/` following the patterns in `pkg/cli/android.go` and `pkg/cli/ios.go`. | +| **HTTP framework** | stdlib `net/http` only — no gin or gorilla/mux in `go.mod`. Do not add a new HTTP router framework unless there is a compelling reason; stdlib is sufficient. | +| **Step types** | All structs in `pkg/flow/step.go` have **only** `yaml:` struct tags. There are no `json:` tags anywhere. The `Selector` struct (`pkg/flow/selector.go`) also has only `yaml:` tags with a custom `UnmarshalYAML`. | +| **Driver interface** | `pkg/core/driver.go` defines the `core.Driver` interface and `core.CommandResult`. Every driver (`uiautomator2`, `wda`, `appium`, `devicelab`) implements this interface. The server must use it, never call driver internals directly. | +| **Driver initialization** | The full device discovery, ADB port-forwarding, emulator/simulator boot, and driver startup logic already lives in `pkg/cli/android.go` (Android) and `pkg/cli/ios.go` (iOS). The server command **MUST** reuse these helpers; do not duplicate them. | +| **Existing JSON-RPC** | `pkg/maestro/protocol.go` already defines a well-designed JSON protocol (used by the DeviceLab WebSocket driver). The pattern `Request{ID, Method, Params}` and `Response{ID, Result, Error}` is already proven. | +| **Python binding** | `client/python/` does not exist yet. Create it fresh. | +| **Server package** | `pkg/server/` does not exist yet. Create it fresh. | + +--- + +## Phase 1: JSON Support for Step Types + +**Target:** Allow step structs to be serialized/deserialized from JSON without breaking YAML parsing. + +### Task 1.1 — Add `json:` tags to Step structs + +- **Files:** `pkg/flow/step.go` and `pkg/flow/selector.go` +- **Action:** Add `json:` tags alongside the existing `yaml:` tags on every exported field. The `yaml:` tags must remain untouched — this is **purely additive**. + +> **Key rule:** The type discriminator field must be exposed. `BaseStep.StepType` is currently `yaml:"-"`. Add `json:"type"` to it so the type round-trips through JSON. + +**Before → After examples:** + +```go +// BaseStep +StepType StepType `yaml:"-"` +// becomes: +StepType StepType `yaml:"-" json:"type"` + +// TapOnStep field +LongPress bool `yaml:"longPress"` +// becomes: +LongPress bool `yaml:"longPress" json:"longPress,omitempty"` +``` + +The `Selector` struct also needs `json:` tags, plus a custom `MarshalJSON` / `UnmarshalJSON` that mirrors its existing scalar-or-mapping YAML behavior (plain string → `Text` field). + +### Task 1.2 — Implement `UnmarshalStep(data []byte) (Step, error)` + +- **File:** `pkg/flow/parser.go` or a new `pkg/flow/json.go` +- **Action:** Read the `"type"` field from the raw JSON, then switch on it — mirroring the existing `decodeStep` switch in `parser.go` — and unmarshal into the correct concrete struct (`TapOnStep`, `InputTextStep`, etc.). + +> **Do NOT rewrite `decodeStep`.** It handles YAML. The new function handles JSON. They share the same `StepType` constants. + +--- + +## Phase 2: The Server Package + +**Target:** A self-contained HTTP server that bridges REST calls to the existing `core.Driver`. + +### Task 2.1 — Create `pkg/server/server.go` + +- Use stdlib `net/http` with a `ServeMux`. No external router needed. +- The server holds a `map[string]*sessionState` (protected by `sync.RWMutex`) where `sessionState` wraps a `core.Driver` and its initialization options. +- The `sessionId` is a UUID generated with `crypto/rand` (no external package needed). + +**Session lifecycle** (mirrors how the existing CLI works): + +| Method | Endpoint | Action | +|---|---|---| +| `POST` | `/session` | Run driver-init logic from `pkg/cli/android.go` or `pkg/cli/ios.go`; return `{"sessionId": "..."}` | +| `POST` | `/session/{id}/execute` | Call `UnmarshalStep` on body, then `driver.Execute(step)`, return `CommandResult` JSON | +| `GET` | `/session/{id}/screenshot` | Call `driver.Screenshot()`, return PNG bytes (`Content-Type: image/png`) | +| `GET` | `/session/{id}/source` | Call `driver.Hierarchy()`, return XML string | +| `GET` | `/session/{id}/device-info` | Call `driver.GetPlatformInfo()`, return `PlatformInfo` JSON | +| `DELETE` | `/session/{id}` | Call driver cleanup (same `defer` pattern used in the CLI test command) | + +### Task 2.2 — Wire the server command into the CLI + +- **File:** `pkg/cli/server.go` (new file, follow the style of `pkg/cli/test.go`) +- Register the command in `pkg/cli/cli.go` under `Commands` alongside `testCommand`. +- Reuse the same global flags that `test.go` uses (`--platform`, `--device`, `--driver`, `--appium-url`, `--caps`, `--no-ansi`, `--verbose`, etc.). +- Add only one new flag: `--port` (default `4723`). +- The command starts the HTTP server and blocks until `SIGINT`/`SIGTERM`, then gracefully shuts down all active sessions. + +--- + +## Phase 3: Wire Protocol Specification + +**Target:** A stable, documented contract for the REST API. + +> **The Go server is the SINGLE SOURCE OF TRUTH for the API shape.** +> The Python client MUST adapt to match what the Go server exposes. +> Do NOT design the Python client first and then try to fit the Go server around it. + +### Task 3.1 — OpenAPI spec + +- **File:** `docs/openapi.yaml` +- All JSON shapes are derived directly from Go structs (`json:` tags defined in Phase 1, and the existing `core.CommandResult` / `core.PlatformInfo` / `core.StateSnapshot` in `pkg/core/driver.go` which already have `json:` tags). Nothing is invented. + +--- + +#### `POST /session` + +**Request body** — capabilities that mirror the existing CLI global flags: + +```json +{ + "platformName": "android | ios", + "deviceId": "", + "appId": "com.example.app", + "driver": "uiautomator2 | wda | appium | devicelab" +} +``` + +> `deviceId`, `appId`, and `driver` are optional — auto-detected if omitted. + +**Response:** + +```json +{ "sessionId": "" } +``` + +--- + +#### `POST /session/{id}/execute` + +Request body is a **single JSON step** using the type-discriminated format from Phase 1. +Selector fields are **flat** on the step object (matching `Selector`'s `json:` tags) — NOT wrapped in a `"selector"` key. + +**Request examples:** + +```jsonc +{ "type": "tapOn", "text": "Login", "timeout": 5000 } +{ "type": "tapOn", "id": "btn_login", "longPress": true } +{ "type": "tapOn", "text": "OK", "index": 1 } +{ "type": "inputText", "text": "user@example.com" } // focused element +{ "type": "assertVisible", "text": "Dashboard", "timeout": 10000 } +{ "type": "assertNotVisible","text": "Error" } +{ "type": "launchApp", "appId": "com.example.app" } +{ "type": "stopApp", "appId": "com.example.app" } +{ "type": "swipe", "direction": "UP", "duration": 400 } +{ "type": "scroll" } +{ "type": "pressKey", "key": "ENTER" } +{ "type": "eraseText", "charactersToErase": 5 } +// Add "optional": true to suppress failure on not-found +``` + +**Response** — `core.CommandResult` serialized directly (`json:` tags already present): + +```jsonc +// Success +{ + "success": true, + "message": "tapped element 'Login'", + "duration": 312000000, // nanoseconds (Go time.Duration) + "element": { // core.ElementInfo, omitempty + "id": "btn_login", "text": "Login", + "bounds": { "x": 10, "y": 20, "width": 80, "height": 40 }, + "visible": true, "enabled": true + } +} +// Failure +{ "success": false, "message": "element not found: text=Login" } +``` + +--- + +#### `GET /session/{id}/screenshot` + +- **Response:** Raw PNG bytes +- **Content-Type:** `image/png` +- Calls `driver.Screenshot()` directly — no JSON wrapper. + +--- + +#### `GET /session/{id}/source` + +- **Response:** UI hierarchy bytes +- **Content-Type:** `application/xml` or `application/json` depending on driver +- Calls `driver.Hierarchy()` directly. + +--- + +#### `GET /session/{id}/device-info` + +**Response** — `core.PlatformInfo` serialized directly (`json:` tags already present): + +```json +{ + "platform": "android", + "osVersion": "14", + "deviceName": "Pixel 7", + "deviceId": "emulator-5554", + "isSimulator": false, + "screenWidth": 1080, + "screenHeight":2400, + "appId": "com.example.app" +} +``` + +--- + +#### `DELETE /session/{id}` + +- **Response:** `204 No Content` + +--- + +## Phase 4: Python Client + +**Target:** A thin client that adapts to the Go server endpoints defined in Phase 3. +The Go server is the source of truth. + +### Task 4.1 — Create `client/python/maestro_runner/` + +The existing `maestro_client/` was built for a different JVM-based Maestro bridge server. +Its public API (`MaestroClient`, `ElementSelector`, `ExecutionResult`, `CommandResult`, `DeviceInfo`, `tap_first_match`, `locator_logger`) is **good and should be kept**. +Only the **transport layer** needs to change to talk to the Go server. + +#### Old JVM bridge vs. New Go server + +| Old (`maestro_client/`) | New (`maestro_runner/`) | +|---|---| +| `POST /v1/execute` | `POST /session/{id}/execute` | +| `{"commands": [{"tapOnElement": {...}}]}` | Single step: `{"type": "tapOn", "text": "Login"}` | +| `GET /v1/device-info` | `GET /session/{id}/device-info` | +| `GET /v1/screenshot` | `GET /session/{id}/screenshot` | +| `GET /v1/view-hierarchy` | `GET /session/{id}/source` | +| No session concept | `POST /session` first, carry `sessionId` | +| `{"widthGrid": ..., "heightGrid": ...}` | `core.PlatformInfo` JSON (see Phase 3) | +| `ExecutionResult{success, results}` | `core.CommandResult` JSON (see Phase 3) | + +#### File structure + +``` +client/python/ + maestro_runner/ + __init__.py # exports MaestroClient (mirrors maestro_client's public API) + client.py # MaestroClient class + commands.py # command builder functions — produces Go step JSON + models.py # ElementSelector, ExecutionResult, DeviceInfo + exceptions.py # MaestroError + tests/ + test_client.py + pyproject.toml + README.md +``` + +#### Command builder output shape + +Builders MUST produce the Go step JSON. **Not** the old JVM envelope. + +```python +# CORRECT — Go step JSON +tap_on_element(text="Login", long_press=True) +# → {"type": "tapOn", "text": "Login", "longPress": true, "optional": false} + +# WRONG — old JVM envelope (do not use) +# → {"tapOnElement": {"selector": {"textRegex": "Login"}, "longPress": true}} +``` + +#### `DeviceInfo` field mapping — `core.PlatformInfo` (Go) → Python dataclass + +| Go field | Python field | +|---|---| +| `platform` | `platform: str` | +| `osVersion` | `os_version: str` | +| `deviceName` | `device_name: str` | +| `screenWidth` | `screen_width: int` | +| `screenHeight` | `screen_height: int` | +| `isSimulator` | `is_simulator: bool` | +| `deviceId` | `device_id: str` | + +#### `ExecutionResult` field mapping — `core.CommandResult` (Go) → Python dataclass + +| Go field | Python field | +|---|---| +| `success` | `success: bool` | +| `message` | `message: str \| None` | +| `duration` | `duration_ns: int` (nanoseconds) | +| `element` | `element: ElementInfo \| None` | + +#### `MaestroClient` class signature + +```python +class MaestroClient: + def __init__( + self, + base_url: str = "http://localhost:9999", + capabilities: dict | None = None, + timeout: float = 60.0, + ) -> None: + ... + # capabilities passed to POST /session; session_id stored internally +``` + +#### Method signatures + +**App lifecycle** + +```python +launch_app(app_id: str, *, clear_state: bool | None = None, + stop_app: bool | None = None, label: str | None = None) -> ExecutionResult +stop_app(app_id: str, *, label: str | None = None) -> ExecutionResult +clear_state(app_id: str, *, label: str | None = None) -> ExecutionResult +open_link(link: str, *, label: str | None = None) -> ExecutionResult +``` + +**Tap** — all selector fields are keyword-only + +```python +tap(*, text: str | None = None, id: str | None = None, index: int | None = None, + selector: ElementSelector | None = None, long_press: bool = False, + wait_until_visible: bool | None = None, retry_if_no_change: bool | None = None, + enabled: bool | None = None, checked: bool | None = None, + focused: bool | None = None, selected: bool | None = None, + optional: bool = False, label: str | None = None) -> ExecutionResult + +long_press(*, text: str | None = None, id: str | None = None, + selector: ElementSelector | None = None, label: str | None = None) -> ExecutionResult + +tap_on_point(point: str, *, long_press: bool = False, label: str | None = None) -> ExecutionResult +``` + +**Input** — `input_text` with no selector targets the currently focused element + +```python +input_text(text: str, *, label: str | None = None) -> ExecutionResult +erase_text(characters: int | None = None, *, label: str | None = None) -> ExecutionResult +press_key(code: str, *, label: str | None = None) -> ExecutionResult +back(*, label: str | None = None) -> ExecutionResult +``` + +**Scroll / swipe** + +```python +scroll(*, label: str | None = None) -> ExecutionResult +swipe(direction: str, *, duration_ms: int = 400, label: str | None = None) -> ExecutionResult +swipe_on(*, text: str | None = None, id: str | None = None, direction: str = "UP", + duration_ms: int = 400, label: str | None = None) -> ExecutionResult +``` + +**Assertions** + +```python +assert_visible(*, text: str | None = None, id: str | None = None, + selector: ElementSelector | None = None, + timeout_ms: int | None = None, label: str | None = None) -> ExecutionResult + +assert_not_visible(*, text: str | None = None, id: str | None = None, + selector: ElementSelector | None = None, + timeout_ms: int | None = None, label: str | None = None) -> ExecutionResult + +element_exists(*, text: str | None = None, id: str | None = None) -> bool +# Posts {"type":"assertVisible","text":"...","optional":true} +# Returns True if success==true, False otherwise — never raises +``` + +**Self-healing multi-selector tap** (keep from `maestro_client`) + +```python +tap_first_match(selectors: list[dict], *, step: str = "") -> ExecutionResult +``` + +**Device queries** + +```python +device_info() -> DeviceInfo # GET /session/{id}/device-info +screenshot() -> bytes # GET /session/{id}/screenshot → raw PNG +view_hierarchy() -> str # GET /session/{id}/source → XML/JSON string +``` + +**Low-level escape hatch** + +```python +execute_step(step: dict) -> ExecutionResult +# POST /session/{id}/execute with a raw step dict +``` + +--- + +## Phase 5: Integration & Testing + +**Target:** Prove the end-to-end flow works without YAML. + +### Task 5.1 — End-to-end test script + +- **File:** `client/python/tests/test_e2e.py` +- Use `pytest`. Start `maestro-runner server` in a subprocess fixture (`subprocess.Popen`), wait for `/status` to be healthy, run test flow, teardown server. + +**Example test flow (login with conditional logic):** + +```python +c = MaestroClient( + "http://localhost:9999", + capabilities={"platformName": "android", "appId": "com.example.app"}, +) +c.launch_app("com.example.app") +if c.element_exists(text="Accept"): + c.tap(text="Accept") +c.tap(text="Username") +c.input_text("testuser@example.com") +c.tap(text="Password") +c.input_text("s3cret") +c.tap(text="Login") +c.assert_visible(text="Dashboard", timeout_ms=10000) +info = c.device_info() # DeviceInfo from core.PlatformInfo +shot = c.screenshot() # raw PNG bytes from GET /session/{id}/screenshot +``` + +--- + +## Summary Checklist + +### Go — source of truth, implement first + +- [ ] Add `json:` tags (additive, do **not** remove `yaml:` tags) to all Step structs and `Selector` in `pkg/flow/`. +- [ ] Add `json:"type"` to `BaseStep.StepType` so the discriminator round-trips through JSON. +- [ ] Implement `UnmarshalStep(data []byte) (Step, error)` in `pkg/flow/` mirroring the existing `decodeStep` switch. +- [ ] Implement `Selector` `MarshalJSON`/`UnmarshalJSON` to mirror the existing scalar-or-mapping YAML behavior. +- [ ] Create `pkg/server/server.go` using stdlib `net/http` with these endpoints: + - `POST /session` + - `POST /session/{id}/execute` + - `GET /session/{id}/screenshot` + - `GET /session/{id}/source` + - `GET /session/{id}/device-info` + - `DELETE /session/{id}` +- [ ] Reuse driver initialization helpers from `pkg/cli/android.go` and `pkg/cli/ios.go` in `POST /session`. +- [ ] Create `pkg/cli/server.go` using `urfave/cli/v2` (NOT Cobra) following the style of `pkg/cli/test.go`. +- [ ] Register the server command in `pkg/cli/cli.go` alongside `testCommand`. + +### Docs — derived from Go implementation + +- [ ] Create `docs/openapi.yaml` with the wire protocol spec — shapes taken from Go structs, not invented. + +### Python — adapts to Go server, implement after Go endpoints are defined + +- [ ] Create `client/python/maestro_runner/` package (new, alongside but separate from `maestro_client/`). +- [ ] Command builders in `commands.py` MUST produce Go step JSON (`{"type":"tapOn",...}`), **not** the old JVM envelope (`{"tapOnElement":{...}}`). +- [ ] `DeviceInfo` dataclass fields map to `core.PlatformInfo` JSON keys (`platform`, `osVersion`, `deviceName`, `screenWidth`, `screenHeight`, `isSimulator`, `deviceId`). +- [ ] `ExecutionResult` maps `core.CommandResult` JSON keys (`success`, `message`, `duration`, `element`). +- [ ] `MaestroClient.__init__` calls `POST /session` with capabilities dict; stores `session_id`. +- [ ] All session-scoped endpoints must include the `session_id` in the URL path. +- [ ] Implement context-manager support (`__enter__`/`__exit__`) for automatic `DELETE /session/{id}`. +- [ ] Keep `tap_first_match` + `locator_logger` from `maestro_client` (they are transport-agnostic). +- [ ] Write pytest-based tests in `client/python/tests/`. \ No newline at end of file