첫 스크립트 만들기

가상 폰을 상대로 완전한 로그인 스크립트를 단계별로 만듭니다. Python, Node.js, 셸로 작성하며 모든 줄을 설명합니다.

번역된 페이지입니다. 영어 문서와 내용이 다르면 영어 문서가 기준입니다. English

이 튜토리얼에서는 앱을 열고, 광고가 떠 있으면 닫고, 로그인하고, 로그인이 됐는지 확인하고, 화면에서 값 두 개를 읽고, 스크린샷을 저장하는 스크립트를 작성합니다. 그 과정에서 모든 스크립트에 쓰게 될 개념을 만납니다. 요소 찾기, 대기, 조건 확인, 에러입니다.

폰은 필요 없습니다. Droidline에는 데모 앱이 실행 중인 폰인 척하는 작은 프로그램 droidline-fakephone이 함께 들어 있습니다. 실제 앱과 똑같은 프로토콜을 쓰므로, 여기서 작성한 스크립트는 실제 폰에서도 돌아갑니다.

시작하기 전에 설치의 1, 2, 5단계를 마치세요. droidline 프로그램들을 PATH에 넣고, Python이나 Node.js를 쓴다면 해당 언어의 라이브러리를 설치하는 단계입니다.

1. 서버와 가상 폰 시작

터미널 창이 세 개 필요합니다.

터미널 1에서는 서버를 실행합니다. 열어 둡니다.

droidline serve

터미널 2에서는 가상 폰을 실행합니다. 가상 폰은 네트워크에서 서버를 찾고 페어링 코드를 출력합니다.

droidline-fakephone
Found MY-PC at 192.168.0.12:8779

Pairing code: 482913
On the PC run: droidline pair 482913

터미널 3은 작업하는 곳입니다. 코드를 승인하고 폰에 이름을 붙입니다.

droidline pair 482913 --name fake-01
droidline devices
NAME     ID        MODEL           ANDROID  STATE   ROUTE  MISSING
fake-01  lpez3oxg  Droidline Fake  14       online  lan

2. 먼저 화면 살펴보기

어떤 앱이든 스크립트를 쓰기 전에 Droidline이 그 앱의 화면을 어떻게 보는지 살펴보세요. 데모 앱을 열고 화면을 저장합니다.

droidline launch dev.droidline.demo
droidline dump screen.json

screen.json은 화면의 모든 요소를 설명합니다. 다음은 로그인 화면에서 중요한 부분만 남기고 나머지 속성은 뺀 것입니다.

{
  "package": "dev.droidline.demo",
  "activity": ".LoginActivity",
  "tree": {
    "children": [
      { "text": "Sign in",           "id": "",                               "class": "android.widget.TextView" },
      { "text": "",                  "id": "dev.droidline.demo:id/email",    "desc": "Email", "class": "android.widget.EditText" },
      { "text": "",                  "id": "dev.droidline.demo:id/password", "desc": "Password", "class": "android.widget.EditText" },
      { "text": "Keep me signed in", "id": "dev.droidline.demo:id/auto_login", "class": "android.widget.CheckBox", "checked": false },
      { "text": "Log in",            "id": "dev.droidline.demo:id/login",    "class": "android.widget.Button", "enabled": false },
      { "text": "Close ad",          "id": "dev.droidline.demo:id/ad_close", "class": "android.widget.Button" }
    ]
  }
}

요소를 구별하는 속성은 세 가지입니다.

  • text는 요소가 보여 주는 글자입니다. 예를 들면 Log in입니다.
  • id는 앱 개발자가 요소에 붙인 이름입니다. 폰의 언어가 바뀌어도 그대로이므로, 있다면 가장 믿을 만한 선택입니다. :id/ 앞부분은 빼도 됩니다. email은 dev.droidline.demo:id/email과 일치합니다.
  • desc는 스크린 리더를 위한 설명으로, 아이콘에는 이것이 유일한 라벨인 경우가 많습니다.

속성과 값을 합친 것을 선택자라고 합니다. ("text", "Log in")이나 ("id", "email")처럼 씁니다. 요소를 다루는 명령은 모두 선택자를 받습니다. Log in이 enabled: false인 점을 눈여겨보세요. 앱은 이메일이 입력된 뒤에야 이 버튼을 활성화합니다.

3. 스크립트를 단계별로 작성하기

아래 각 단계에서 몇 줄씩 추가합니다. 아무 예제에서나 언어를 고르면 페이지의 나머지 예제도 따라 바뀝니다.

연결하고 앱 열기

from droidline import connect

d = connect()
d.launch("dev.droidline.demo")
d.wait("text", "Sign in")
import { connect } from "droidline";

const d = await connect();
await d.launch("dev.droidline.demo");
await d.wait("text", "Sign in");
#!/usr/bin/env bash
set -e   # 처음 실패하는 명령에서 멈춤

droidline launch dev.droidline.demo
droidline wait text "Sign in"

connect()는 온라인인 유일한 폰을 찾아, 그 폰에 명령을 보내는 객체를 돌려줍니다. 이 객체는 관례상 d라고 부릅니다. launch는 패키지명으로 앱을 실행합니다. 패키지명은 모든 안드로이드 앱이 하나씩 가진 고유한 이름입니다. wait는 요소가 나타날 때까지 기다립니다(기본값은 최대 10초). 그래서 다음 줄이 아직 로딩 중인 화면을 상대로 실행되는 일이 없습니다.

셸에서는 연결할 것이 없습니다. 모든 droidline 명령이 서버와 폰을 알아서 찾습니다.

광고가 있을 때만 닫기

if d.exists("text", "Close ad"):
    d.touch("text", "Close ad")
if (await d.exists("text", "Close ad")) {
  await d.touch("text", "Close ad");
}
if [ "$(droidline exists text "Close ad")" = "true" ]; then
  droidline touch text "Close ad"
fi

exists는 조건 확인 명령입니다. 바로 true나 false로 답하고 절대 실패하지 않습니다. 그래서 광고나 팝업처럼 화면에 있을 수도 없을 수도 있는 것에 알맞습니다. touch는 요소를 찾고, 필요하면 기다렸다가 탭합니다.

이메일 입력

d.input("id", "email", "knife")
await d.input("id", "email", "knife");
droidline input id email knife

input은 선택자와 입력할 텍스트를 받습니다. 입력란에 있던 내용은 새 텍스트로 바뀝니다. 이메일 입력란에는 맞춰 볼 자체 텍스트가 없으므로 여기서는 선택자에 id를 씁니다.

체크되어 있지 않으면 체크박스 체크하기

if not d.checked("id", "auto_login"):
    d.touch("id", "auto_login")
if (!(await d.checked("id", "auto_login"))) {
  await d.touch("id", "auto_login");
}
if [ "$(droidline checked id auto_login)" = "false" ]; then
  droidline touch id auto_login
fi

체크박스는 탭할 때마다 상태가 바뀌므로, 무작정 탭하면 이미 체크된 체크박스의 체크가 풀립니다. checked로 현재 상태를 먼저 읽습니다.

로그인하고 성공했는지 확인하기

d.touch("text", "Log in")

screen = d.which([("text", "Log in"), ("id", "main_tab")], timeout=15)
if screen != 1:
    d.screenshot("login-failed.png")
    raise SystemExit("Did not reach the main screen")
await d.touch("text", "Log in");

const screen = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 });
if (screen !== 1) {
  await d.screenshot("login-failed.png");
  throw new Error("Did not reach the main screen");
}
droidline touch text "Log in"

screen=$(droidline which text="Log in" id=main_tab --timeout 15)
if [ "$screen" != "1" ]; then
  droidline screenshot login-failed.png
  echo "Did not reach the main screen" >&2
  exit 1
fi

Log in을 탭한 뒤에는 두 가지 중 하나가 일어납니다. 메인 화면이 열리거나, 무언가 잘못되어 로그인 화면에 머무릅니다. which는 후보 중 가장 먼저 나타나는 것을 기다리고, 그 후보의 목록 내 위치를 돌려줍니다. 첫 번째면 0, 두 번째면 1, 타임아웃 전에 아무것도 나타나지 않으면 -1입니다. 실패했을 때 스크린샷을 남겨 두면 나중에 무엇이 잘못됐는지 쉽게 볼 수 있습니다.

값 읽고 스크린샷 저장하기

print(d.get_text("id", "greeting"))
print("Balance:", d.get_text("id", "balance"))
print("Chats:", d.count("class", "android.widget.CheckBox"))
d.screenshot("after-login.png")
console.log(await d.getText("id", "greeting"));
console.log("Balance:", await d.getText("id", "balance"));
console.log("Chats:", await d.count("class", "android.widget.CheckBox"));
await d.screenshot("after-login.png");
droidline get_text id greeting
echo "Balance: $(droidline get_text id balance)"
echo "Chats: $(droidline count class android.widget.CheckBox)"
droidline screenshot after-login.png

get_text는 요소의 텍스트를 돌려주고, count는 일치하는 요소의 개수를 돌려줍니다. 스크린샷은 폰이 아니라 PC에 저장됩니다.

4. 전체 스크립트

from droidline import connect

d = connect()

# 데모 앱을 열고 로그인 화면을 기다림
d.launch("dev.droidline.demo")
d.wait("text", "Sign in")

# 광고가 떠 있을 때만 닫음
if d.exists("text", "Close ad"):
    d.touch("text", "Close ad")

# 이메일을 입력하고, "Keep me signed in"이 체크되어 있지 않으면 체크
d.input("id", "email", "knife")
if not d.checked("id", "auto_login"):
    d.touch("id", "auto_login")

# 로그인한 뒤 어떤 화면이 나타났는지 확인
d.touch("text", "Log in")
screen = d.which([("text", "Log in"), ("id", "main_tab")], timeout=15)
if screen != 1:
    d.screenshot("login-failed.png")
    raise SystemExit("Did not reach the main screen")

# 앱이 보여 주는 값을 읽고 스크린샷을 남김
print(d.get_text("id", "greeting"))
print("Balance:", d.get_text("id", "balance"))
print("Chats:", d.count("class", "android.widget.CheckBox"))
d.screenshot("after-login.png")
print("done")
import { connect } from "droidline";

const d = await connect();

// 데모 앱을 열고 로그인 화면을 기다림
await d.launch("dev.droidline.demo");
await d.wait("text", "Sign in");

// 광고가 떠 있을 때만 닫음
if (await d.exists("text", "Close ad")) {
  await d.touch("text", "Close ad");
}

// 이메일을 입력하고, "Keep me signed in"이 체크되어 있지 않으면 체크
await d.input("id", "email", "knife");
if (!(await d.checked("id", "auto_login"))) {
  await d.touch("id", "auto_login");
}

// 로그인한 뒤 어떤 화면이 나타났는지 확인
await d.touch("text", "Log in");
const screen = await d.which([["text", "Log in"], ["id", "main_tab"]], { timeout: 15 });
if (screen !== 1) {
  await d.screenshot("login-failed.png");
  throw new Error("Did not reach the main screen");
}

// 앱이 보여 주는 값을 읽고 스크린샷을 남김
console.log(await d.getText("id", "greeting"));
console.log("Balance:", await d.getText("id", "balance"));
console.log("Chats:", await d.count("class", "android.widget.CheckBox"));
await d.screenshot("after-login.png");
console.log("done");
#!/usr/bin/env bash
set -e   # 처음 실패하는 명령에서 멈춤

# 데모 앱을 열고 로그인 화면을 기다림
droidline launch dev.droidline.demo
droidline wait text "Sign in"

# 광고가 떠 있을 때만 닫음
if [ "$(droidline exists text "Close ad")" = "true" ]; then
  droidline touch text "Close ad"
fi

# 이메일을 입력하고, "Keep me signed in"이 체크되어 있지 않으면 체크
droidline input id email knife
if [ "$(droidline checked id auto_login)" = "false" ]; then
  droidline touch id auto_login
fi

# 로그인한 뒤 어떤 화면이 나타났는지 확인
droidline touch text "Log in"
screen=$(droidline which text="Log in" id=main_tab --timeout 15)
if [ "$screen" != "1" ]; then
  droidline screenshot login-failed.png
  echo "Did not reach the main screen" >&2
  exit 1
fi

# 앱이 보여 주는 값을 읽고 스크린샷을 남김
droidline get_text id greeting
echo "Balance: $(droidline get_text id balance)"
echo "Chats: $(droidline count class android.widget.CheckBox)"
droidline screenshot after-login.png
echo done

login.py, login.mjs, login.sh 중 하나로 저장하고 실행합니다.

python login.py
node login.mjs
bash login.sh
Welcome, knife
Balance: 12,500
Chats: 3
done

셸 버전은 {"ms":0,"via":"node"} 같은 각 명령의 응답도 출력합니다. Windows에서는 셸 버전을 Git Bash에서 실행하거나, 같은 스크립트의 PowerShell 버전을 CLI 가이드에서 보세요.

5. 문제가 생겼을 때

touch 단계의 "Log in"을 "Sign up"으로 바꾸고 스크립트를 다시 실행해 보세요. 다음과 같은 에러를 내며 멈춥니다(Python 버전에서는 트레이스백의 마지막 줄에 나옵니다).

droidline._generated.NotFoundError: Could not find text 'Sign up' within 10s. Current screen: dev.droidline.demo / .LoginActivity

메시지는 무엇을 얼마 동안 찾았는지, 그 대신 어떤 화면이 보였는지 알려 줍니다. 모든 에러에는 code(여기서는 NOT_FOUND)도 있고, 다시 시도하면 도움이 될지도 알려 줍니다. 멈추지 않고 에러를 처리하려면 이렇게 합니다.

from droidline import connect, NotFoundError

d = connect()
try:
    d.touch("text", "Sign up", timeout=2)
except NotFoundError as e:
    print(e.code, e.retryable, e)
import { connect, DroidlineError } from "droidline";

const d = await connect();
try {
  await d.touch("text", "Sign up", { timeout: 2 });
} catch (e) {
  if (!(e instanceof DroidlineError)) throw e;
  console.log(e.code, e.retryable, e.message);
}
if ! droidline touch text "Sign up" --timeout 2; then
  echo "not there, carrying on"
fi

timeout=2를 주면 명령이 10초가 아니라 2초 뒤에 포기합니다. 셸에서는 실패한 명령이 에러를 출력하고 상태 코드 1로 종료하며, if !가 이를 잡습니다. 모든 코드는 에러 코드에 있습니다.

6. 실제 폰에서 실행하기

같은 스크립트가 실제 폰에서도 돌아갑니다. 바뀌는 것은 앱에 관한 세부 사항뿐입니다.

  1. 실제 폰을 페어링합니다(빠른 시작). 가상 폰이 아직 온라인이라면 Ctrl+C로 멈추거나, 폰 이름을 넘기세요. SDK에서는 connect("shelf-01"), CLI에서는 --device shelf-01입니다.
  2. dev.droidline.demo를 자동화하려는 앱의 패키지명으로 바꿉니다. droidline apps는 폰에 있는 앱을 패키지명과 함께 보여 줍니다.
  3. 폰에서 화면을 하나씩 열고 droidline dump screen.json을 실행한 뒤, 찾은 내용에서 선택자를 고릅니다. 잘 고르는 방법은 요소 찾기에서 설명합니다.
  4. 텍스트는 폰의 언어로 씁니다. 한국어로 설정된 폰이라면 영어로 "Log in"인 버튼은 아마 "로그인"일 것입니다.

다음으로 개념을 읽고 각 명령 뒤에서 무슨 일이 일어나는지 이해하거나, 레시피에서 바로 쓸 수 있는 패턴을 둘러보세요.