안녕하세요 Software QA로 일하고 있는 깍돌이 입니다.

해당 포스팅 시리즈는 NCP 를 기준으로 UI Automation 을 하는 과정기를 하나씩 포스팅 하려고 합니다.

모두가 알고 있는 사이트를 기준으로 하는게 좋을거 같아서 선정하게 되었습니다.

 

기존엔 네이버로 했었는데 트래픽이 많이 몰려서 그런가 IP를 막고 그러네요 ㅜㅜ 우회 하는 방법은 찾았지만

 

포스팅으로 써서 좋을게 없을거 같아서 요즘 클라우드 시대에 많은 사람들이 사용하기 시작하는 네이버 클라우드 플랫폼을 기준으로 작성하게 되었습니다.

 

우선 UI Tool 은 구글에서 관리하고 요즘 핫한 Puppeteer로 작성하겠습니다.

기본적인 툴 세팅이나 실행등은 많은 블로그 및 오픈소스 Github에 너무 자료 설명이 잘되어있어서 너무 한땀 한땀은 스킵을 하도록 하겠습니다.~


Puppeteer - Github ( Example 및 API 리스트들 다 있어서 좋습니다. )

https://github.com/puppeteer/puppeteer

 

GitHub - puppeteer/puppeteer: Headless Chrome Node.js API

Headless Chrome Node.js API. Contribute to puppeteer/puppeteer development by creating an account on GitHub.

github.com

 

 

환경 세팅

기본적으로 Node.js  + Type Script + Puppeteer 환경을 기준으로 하기 때문에 기본적인 환경은 작성 하도록 하겠습니다.

package.json

  "dependencies": {
    "axios": "^0.21.1",
    "cloudscraper": "^4.6.0",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "folder-logger": "^1.0.9",
    "form-data": "^2.3.3",
    "moment": "^2.29.1",
    "multer": "^1.4.2",
    "puppeteer": "^5.0.0",
    "puppeteer-extra": "^3.1.12",
    "puppeteer-extra-plugin-stealth": "^2.4.12",
    "request": "^2.88.2"
  },
  "devDependencies": {
    "@types/axios": "^0.14.0",
    "@types/dotenv": "^8.2.0",
    "@types/form-data": "^2.5.0",
    "@types/jest": "^26.0.20",
    "@types/moment": "^2.13.0",
    "@types/multer": "^1.4.5",
    "@types/node": "^14.14.28",
    "@types/puppeteer": "^5.4.3",
    "pm2": "^4.5.4",
    "typescript": "^4.1.5"
  }

tsconfig.json

 

{
  "compileOnSave": false,
  "compilerOptions": {
    "outDir": "./dist",
    "baseUrl":".",
    "paths":{
      "*":[
        "types/*",
        "bin"
      ],
      "dist/*":[
        "../../dist/*"
      ]
    },
    "noEmitHelpers":false,
    "importHelpers": false, // tslib 에서 방출된 헬퍼를 import 합니다. (예. __extends, __rest, 등..)
    "sourceMap": false, //해당하는 .map 파일을 생성합니다.
    "declaration": true,  //해당하는 .d.ts 파일을 생성합니다.
    "inlineSourceMap": false, //별도의 파일 대신 소스 맵으로 단일 파일을 내보냅니다
    "noImplicitAny": false, // any 타입으로 암시한 표현식과 선언에 오류를 발생시킵니다.
    "module": "commonjs",
    "target": "es5",
    "types": [
      "node"
    ],
    "lib": [
      "ES5","ES6","DOM","DOM.Iterable"
    ],
    "moduleResolution": "node",
    "forceConsistentCasingInFileNames": true, //동일 파일 참조에 대해 일관성 없는 대소문자를 비활성화합니다.
    "noImplicitReturns": true, // 함수의 모든 코드 경로에 반환값이 없을 때 오류를 보고합니다.
    "noImplicitThis": true, //any 타입으로 암시한 this 표현식에 오류를 보고합니다.
    "strictNullChecks": true, // 엄격한 null 검사 모드에서는 null과 undefined 값이 모든 타입의 도메인에 있지 않고 그 자체와 any만 할당할 수 있습니다(한 가지 예외사항은 undefined 또한 void에 할당 가능하다는 것입니다).
    "suppressImplicitAnyIndexErrors": true, //인덱스 시그니처가 없는 객체를 인덱싱하는 경우 --noImplicitAny 억제합니다. 오류를 시그니처 자세한 내용은 #1232 이슈를 참조하세요.
    "allowSyntheticDefaultImports": true,  //default export가 없는 모듈에서 default imports를 허용합니다. 코드 방출에는 영향을 주지 않으며, 타입 검사만 수행합니다.
    "esModuleInterop": true,// 런타임 바벨 생태계 호환성을 위한 __importStar와 __importDefault 헬퍼를 내보내고 타입 시스템 호환성을 위해 --allowSyntheticDefaultImports를 활성화합니다.
    "resolveJsonModule": true, // json 확장자로 import된 모듈을 포함합니다.
    "emitDecoratorMetadata": true, // 소스에 데코레이터 선언에 대한 설계-타입 메타 데이터를 내보냅니다
    "experimentalDecorators": true, // ES 데코레이터에 대한 실험적인 지원을 사용하도록 활성화
    "skipLibCheck": true, // 모든 선언 파일 (*.d.ts)의 타입검사를 건너 뜁니다.
    // "strict": true,
    // "alwaysStrict": true,
    "listEmittedFiles": true, // 컴파일의 일부로 생성된 파일의 이름을 출력 
  },
  "include": [
    "./src/**/*",
    "./bin/**/*",
    "./test/**/*"
  ],
  "typeRoots": [
    "./node_modules/@types"
]
}

 

기본적으로  .env 파일을 사용합니다.  ( 필요한 부분만 작성하였습니다. ) 

PUB_REAL=https://console.ncloud.com
DB_MANAGER_SERVER=http://localhost
DB_CONNECT=false

 

main 함수

const main = async ()=>{
  try{
    /** NSTP Test Runner Param Validation */
    const TestConsole:string = paramValidator();
    /*
     Safety Navigate Console
    */
    await LoginConsole();
    switch(TestConsole.toUpperCase()){
      case 'PUB_BETA':
        await PUBLIC('beta');
        break;
      case 'PUB_REAL':
        await PUBLIC('real');
        break;
      case 'FIN_REAL':
        break;
      case 'FIN_BETA':
        break;
      default:
          Logger.info('Non Selected TestConsole');
        return;
    }
    await Puppeteer.close();
  }
  catch(err){
    Logger.error(err);
    process.exit(1);
  }
}

환경별 실행할수 있는 분기 처리문이 포함된 main 함수입니다.  오늘 포스팅에서는 paramValidator LoginConsole을 알아 보도록 하겠습니다.

 

paramValidator

우선 해당 Test-Runner를 실행하기 위해서는 로컬에서 실행하는 목적이 아닌 Trigger서버를 통해서 실행 될 예정입니다.

Express 서버를 통해 실행 될 예정이고 실행은 아래와 같이 진행됩니다.

app.get('/uuid',async(req,res)=>{
  try {
    const uuid = createUUID();

    res.send(uuid);
  } catch (error) {
    console.error(error);
    res.send(error);
  }
});

app.post('/run',async (req,res)=>{
  const {id,pw,env,platform,uuid}  = req.body;
  console.log(`Test Call ${req.body}`);

  const TaskObject = {
    "account":id,
    "password":pw,
    "env":env,
    "platform":platform,
    "uuid":uuid
  }

  console.log(TaskObject);
  runTestRunnerExec(TaskObject);
  res.send(uuid);
})
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

 // npm start account passwd pub_beta classic uuid

function runTestRunnerExec(TaskObject){
  let command =`npm start ${TaskObject.account} ${TaskObject.password} ${TaskObject.env} ${TaskObject.platform} ${TaskObject.uuid}`;
  exec(command,(err,out,stderr)=>{
    console.log(out);
  });
}
function createUUID() {
  let d = new Date().getTime();
  if (
    typeof performance !== 'undefined' &&
    typeof performance.now === 'function'
  ) {
    d += performance.now(); // use high -precision timer if available
  }

  return 'task-xxxx-xxxx-4xxx-yxxx'.replace(/[xy]/g, function(c) {
    var r = (d + Math.random() * 16) % 16 | 0;
    d = Math.floor(d / 16);
    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
  })
 }

코드의 일부를 가져왔으며 실행시 파라미터가 아래와 같습니다.

 npm start account passwd env platform uuid

UUID의 Controller로 UUID를 발급을 받고 해당 Worker Job(UUID) 로 실행 요청을 하기 때문에 해당 의 형태를 가지고 있어 Test-Runner에서도 한번 유효성 검사를 하는 구간을 두자는 의미에서 작성하였습니다.

 

function platformValidate(param:string) : void {
  const testPlatForm = {
    "FIN_REAL":process.env.FIN_REAL,
    "FIN_BETA":process.env.FIN_BETA,
    "GOV_REAL":process.env.GOV_REAL,
    "GOV_BETA":process.env.GOV_BETA,
    "PUB_REAL":process.env.PUB_REAL,
    "PUB_BETA":process.env.PUB_BETA
  }
  if(param==="undefined" || param===undefined || param===null){
    Logger.error('Please Check your Runtime Parameter ');
    Logger.error(`1. param is -> ${param}`);
    process.exit(0);
  }

  
  let _param:string = param.toUpperCase();
  const url = testPlatForm[_param];

  if(url==="undefined" || url===undefined || url===null){
    Logger.error(`undefined Get URL : ${url}`);
    Logger.error('Check List');
    Logger.error('1. Platform Param ');
    Logger.error('2. Directory Root Check your .env File ');
    process.exit(0);
  }
  process.env.url=url;
  return url;
}
export const paramValidator = ()=>{
  console.log(process.argv[6])
  if(process.env.NODE_ENV==='development'){
      Logger.info('Development Env');
  }
  else{
    if(process.argv.length <= 5){
      Logger.error('Parameter Validation Error');
      Logger.error('Please set [ID], [PassWord], [TargetPlatForm] ');
      Logger.error(`Current Param ID : ${process.argv[2]}`)    
      Logger.error(`Current Param Password : ${process.argv[3]}`)    
      Logger.error(`Current Param Test Console : ${process.argv[4]}`)  
      throw new Error('Param Validation Error');  
    }
    else if(typeof process.argv[6]==="undefined"){
      Logger.error('UUID is not Exist');
      throw new Error('Required UUID Param');
    }
    let ID = process.argv[2];
    var emailReg = /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*.[a-zA-Z]{2,3}$/i;
    if(!emailReg.test(ID)){
      throw new Error(`ID Email Error is not Email ${ID}`);
    }
    Logger.info(`Automation URL ${platformValidate(process.argv[4])}`);
    
    for(let i=2;i<process.argv.length;i++){
      Logger.info(process.argv[i]);
    }
    // global UUID variable
    process.env.nstpUUID = process.argv[6];
  }
  
  return process.argv[4];
}

간단하게 넘어가자면 5개의 파라미터를 기본으로 받아야하기 때문에 

account - 로그인 할 계정

passwd - 로그인 할 계정의 비밀번호

env  - 환경  ( 네이버 클라우드는 민간, 금융, 공공 3가지의 환경을 가지고 있습니다. ) 

platform - 플랫폼 ( Classic, VPC 의 2가지 플랫폼을 지원 합니다.)

uuid - UUID로 생성되어 발급된 UUID

(* 현재는 해외리전도 있는걸로 보아 해외리전 파라미터도 추가가 되어야겠네요 제대로 작업해서 만든다면요 )

 

1. 파라미터 개수 및 파라미터 확인 

2. account가 email인지 확인 

3. 파라미터에 따른 .env에서 URL을 정상적으로 가져왔는지 확인 

etc. 환경등을 확인하는 조건문 등이 있긴한데 샘플 파일이라고 귀엽게 넘어 가주시면 좋겠습니다. ㅜ

 

오류 화면

 

정상 실행 및 이동&amp;amp;amp;amp;nbsp;

파라미터 유효성 검사가 끝났다면 Console 로그인을 진행 하도록 하겠습니다.

 

LoginConsole

로그인 콘솔입니다. 사실 이렇게까지 해야되나 싶기도하지만 LoginConsole을 로그인 모듈로 빼서 여러 곳에서 호출해서 쓴다고 가정을 하고 작업을 하다보니 꽤 많은 양의 작업이 되었습니다.

 

우선 파라미터로 id,pw를 넘겼기 때문에 process.argv를 통해 파라미터 값을 가져옵니다. ( id,pw의 순서는 고정 ) 

getBrowser를 통해 퍼펫티어의 기본 세팅을 한후 브라우저를 실행합니다.

getBrowser는 API 가 아닙니다.

싱글톤 객체로 사용중인 자체 함수입니다. initialize 는 스킵하도록 하겠습니다. ( 원한다는 요청이 있으면 다시 재포스팅하겠습니다.)

 

그리고 나오는 isLoginPage 일단 해당 설명을 하기전에 NaverCloudPlatform의 페이지이동에 대한 히스토리부터 생각해보겠습니다.

 

보시면 바로 console.ncloud.com 으로 가게 되는데 로그인이 되어있지 않는 상태 의 세션 브라우저를 확인시 순서는 아래와같습니다.

 

1. 로그인창

 

2. 비밀번호 변경 정책에 어긋 났을 경우 

 

 

3. 비밀번호 변경 정책에 어긋나지 않았을 경우 ( 콘솔 페이지로 이동 )

이동하였지만 Dimmed 처리에대한 다시 보지않기 처리가 PC에 세팅되어있지 않은 경우

 

4. Dimmed 처리에 다시보지않기 를 설정한 기록이있어 정상적으로 이동된 경우

 

 

 

여기까지가 로그인 을 하여 콘솔까지의 정상이동으로 볼수 있습니다. 이와중에 해결해야하는 부분은 아래와 같습니다.

 

1. 로그인 정상 처리

2. 로그인 정책 위반시 다음으로 넘길수있는 처리

3. Dimmed 처리 안되어있는 PC의 경우 Dimmed에 대한 처리 

 

로그인 정상 처리

process.argv로 ID, PW를 받아온 상태에서 이제는 입력을 해야하고 입력하기 위한 Element를 찾아야 합니다. 모든 사람들이 Element를 찾는 방법은 수십 수백개라 저는 저만의 방식을 찾은 거를 포스팅하겠습니다.

 

그전에 현재 페이지가 login 페이지가 맞는지 먼저 체크합니다.  ( explicitlyWait도 API 가 아닙니다. 자체 제작한 함수로 일단은 스킵하겠습니다.  해당 함수만 포스팅하려면 한페이지가 작성되어야해서 ;; )

핵심은 selector 부분입니다. 해당 css 셀렉터를 통해서 해당 페이지 유무를 확인합니다.

이미지시에 보이는 셀렉터를 기준으로 잡은 거였습니다.   로그인 페이지는 SPA Web FE 개발자에 잠시 빙의를 해보자면 Login Component를 따로 쓸걸로 예상되기 때문에 해당 페이지는 웬만하면 큰 변화가 없을 것을 기대합니다.

 

그렇다고 Chrome브라우저에 JS CSS 복사 기능을 쓰게 되면 아래와 같이 나타나는데 

document.querySelector("#username")

위처럼 사용해도 될수 있지만 해당 페이지에서 유일하게 placeholder에 아이디* 로 들어가게되는 경우는 input box 뿐이라고 생각되어서 위의 CSS Selector를 사용하였습니다. ( 저는 xpath 는 사용안하고 CSS Selector를 선호합니다.  포스팅하려면 할수있는게 엄청많군요 )

 

 이렇게 현재 페이지가 Login Page라는 점이 확인되면 Inputbox와 Password box를 찾아서 입력 후 로그인 버튼을 선택합니다. ( 이부분은 약식으로 하겠습니다. ) 

 

간단하게 입력 후 저는 Enter 키로 Navigate 를 진행했습니다.

page.waitForNavigation() -> 퍼펫티어의 API 인데 MPA에서는 문제가 되지 않지만 SPA ( Single Page Application ) 에서는 많은 문제가 됩니다. 현재 만들어진 페이지가 Vue, React, Angular 같은 SPA로 만들었는지 확인이 되어야합니다. 아니면 현재 페이지가 서버 라우팅인지, 클라이언트 라우팅인지 명확한 확인이 된 후에 해당 API 써야 합니다. 그렇지 않으면 Timeout이 나게 됩니다. - 이부분도 따로 포스팅을...

 

 

엔터까지 입력이 된다면 이제 해당 사이트의 로그인 정책에 위반되었을 케이스도 확인 하여야 합니다.

로그인 정책 위반시 다음으로 넘길수있는 처리

위에 보시면 이동후에 isDashBoard -> 네 맞습니다. 콘솔 대쉬보드로 이동이 됐는지 체크하는겁니다.

확인내용은 다음과 같습니다. (현재 URL , 이동할 URL) 을 받아와서 비교합니다.

이동하려는 페이지(대쉬보드 URL )가 아니라면 나올수있는 페이지는 비밀번호 정책 페이지 또는 비밀번호 오류 가 있을수 있습니다.

 

1. 대쉬보드 일 경우 ( Successfully )

2. 대쉬보드가 아닐 경우

   2-1 ( passwordAFterwardsCheck ( 정책위반이 될경우 - 비밀번호 변경 ) )

   2-2 ( 그외에 오류 ) - 비밀번호 가 틀리거나 ID를 잘못입력했거나 -> Exception Error

 

 

 

passwordAfterwardsCheck ( 비밀번호 변경 후 90일이 지났을 경우 에 대한 예외처리 )

async function passwordAfterwardsCheck(page:puppeteer.Page,currentURL,navigateURL){
  const selector = '.loginSecure';
  const loginSecureEle = await Puppeteer.explicitlyWait(page,selector);

  /** Check passwordChange Request Page */
  if(loginSecureEle!==false && typeof loginSecureEle !=="boolean"){
    Logger.info(`🚧 The current page is the password change request page. `);
    Logger.info('🚧 Proceed to change the default setting afterwards.');

    let props = await Puppeteer.getProps(page,loginSecureEle,'innerText');
    let regEx = /비밀번호변경 안내|90일마다/gi;
    let changePasswordCheck = regEx.test(props);
    /** Check passwordChange Request Button */
    /** 30일 비밀번호 변경 버튼 유무 확인  */
    if(changePasswordCheck){ 
      Logger.info('[Check] nextChangeButton ');
      const selector = '.loginSecure button';
      const buttons = await Puppeteer.explicitlyWaits(page,selector);

      let nextChangeBtn;
      await Array.prototype.reduce.call(buttons,async(prev,curr)=>{
        const nextItem = await prev;
        let itemText = await Puppeteer.getProps(page,curr,'innerText');
        let diffText = itemText.replace(/\r\n|\n| |\s/gi,"");
        Logger.info(`[diffText] : ${diffText}`);

        if(/다음에변경하기/gi.test(diffText)){
          Logger.info('[Check-Success] nextChangeButton ');
          nextChangeBtn = curr;
        };

        return nextItem;
      },Promise.resolve());
      if(nextChangeBtn==="undefined" || 
      nextChangeBtn===undefined || 
      nextChangeBtn ==="null" || 
      nextChangeBtn===null){ 
        //! nextChange Button is NULL
         Logger.error('[Check-Failed] nextChange Button is not Exist'); 
         return false;
      }
      else{
        Logger.info(`[Click] passwordAfterwards Button`);
        // * Console로 변경 될 때 까지 클릭 
        await Puppeteer.forcedClick(page,nextChangeBtn,"다음에 변경하기",async ()=>{
          try {
            let regExp = /[A-Za-z.-]+/g;
            let currentURL:any = await page?.url();
            let currentProtocolUrl = currentURL.match(regExp)[1];
            let navigateProtocolUrl = navigateURL.match(regExp)[1];
            Logger.info(`[Current] ${currentProtocolUrl}  <----> [Target] ${navigateProtocolUrl}`)

            return currentProtocolUrl===navigateProtocolUrl;
          } catch (error) {
            console.error(error);
            return false;
          }
        });

        //? passwordAfterwardsCheck Success Return  
        return true;
      }
    }

    /** InValid ID or InValid Password */
    else{
      Logger.error('changePasswordCheck is not Exist');
      return false;
    }
  }
  else{
    Logger.info('not Exist loginSecureEle ');
    Logger.info('Login Actions Check');
    const loginRootElement = await Puppeteer.explicitlyWait(page,'.center-wrap.mh-20');
    let resultCheck;
    if(loginRootElement!==false && typeof loginRootElement !=="boolean"){
      resultCheck = await Puppeteer.getProps(page,loginRootElement,'innerHTML');
    }

    if(resultCheck.match(/아이디(메일)를 확인해 주세요/gi) ){
      Logger.error('[Login Actions] ID/Mail Error');
    }
    else if(resultCheck.match(/패스워드 오류/gi) &&
    resultCheck.match(/패스워드 오류 : 0회/gi)){
      Logger.error(('[Login Actions] ID Error'));
    }
    else if(resultCheck.match(/패스워드 오류/gi)){
      Logger.error(('[Login Actions] PassWord Error'));
    }
    else{
      Logger.error('[Login Actions] UnKnown Login Error');
    }
    return false;
  }

}

 

해당의 페이지도 마찬가지로 공통 Component 로 보여지는 페이지 이기때문에 정책변경에 대한 .loginSecure 를 찾습니다. 지금 보니까 article semantic tags를 사용하였기 때문에 article .loginsecure 로 찾는 게 조금 더 유니크 할수 있겠네요

 

forcedClick 같은것도 자체 제작 API 입니다.  해당 코드가 정상적으로 이동되면 이제 콘솔대쉬보드로 이동되면서

Dimmed 처리가 나타나게 됩니다. 

 

Dimmed 처리의 핵심부터 말씀드리자면

 

.coach-mark 입니다.  해당 이 body 역할을 하고 있는 것으로 확인되며 해당 Dimmed 처리 역시 해당 Element Body 를 이용해서 찾은 후  변하지 않는 유니크 값을 찾습니다. 

/환영합니다.|님,|다시 보지 않기/gi;

 

 

async function dimmedCloseActions(page:puppeteer.Page):Promise<boolean>{
  const selector = '.coach-mark';
  const dimmedEle = await Puppeteer.explicitlyWait(page,selector);

  if(dimmedEle!==false && typeof dimmedEle !=="boolean"){

    let diText = await Puppeteer.getProps(page,dimmedEle,"innerText");
    let regex = /환영합니다.|님,|다시 보지 않기/gi;
    await page.waitFor(500);

    if(regex.test(diText)===true){
      Logger.info("🚧 Dimmed is output and Close Window.");
      let dimmedBtnArr = await Puppeteer.explicitlyWaits(dimmedEle,'.btn');
      let dimmedCloseBtn;

      //! dimmedBtnArr 이 undefined 거나 NULL일 경우에 대한 분기 처리 필요 

      await Array.prototype.reduce.call(dimmedBtnArr,async(prev,curr)=>{
        let nextItem = await prev;
        let btnText = await Puppeteer.getProps(dimmedEle,curr,'innerText');  
        let diffText = btnText.replace(/\r\n|\n| |\s/gi,"");
        
        if(/닫기/gi.test(diffText)){
          dimmedCloseBtn = curr;
        }
        return nextItem;
      },Promise.resolve());

        await Puppeteer.forcedClick(page,dimmedCloseBtn,"딤드 처리 제거",async ()=>{
          try{
            let afterDimmedEle = await Puppeteer.explicitlyWait(page,'.coach-mark',3,500);
            if(afterDimmedEle===false){
              return true;
            }
            else{
              return false;
            }
          }
          catch(err){
            console.error(err);
            return false;
          }
        })
      return true;
    }
    else{
      return false;
    }
  }
  else{
    Logger.info('🚧 Dimmed was not output .Process to the next Step .');
    return true;
  }

}

 

저는 여기까지 유니크한 값으로 비밀번호 정책 및 Dimmed처리를 진행하였습니다.

이후에 로그인 및 대쉬보드 이동 테스트를 500번정도 진행하였으나 현재까진 오류가 없었습니다.

 

수정사항이있다면 추가로 관리하겠습니다.

 

 

 

 

 

감사합니다.

 

*** Place Holder  값이 바뀌여서 Login 함수의 수정이 필요하게되었습니다.

 

+ Recent posts