Seleniumを使ったOutDoc(オープンソースのOutSystemsドキュメント出力モジュール)を操作するコードを書いてみました。
ログインから生成されたドキュメントのスクリーンショットを取るところまで。
ただし、長くなったので、1ページずつ取得する画像を1枚にまとめる手順は別途。
確認環境
Personal Environment(Version 11.8.0 (Build 12006))
Service Studio(Version 11.7.15)
Visual Studio Code(Version 1.47.2)
あと、NuGetで、以下のSeleniumライブラリを入れています。
Selenium.WebDriver(Version 3.141.0)
Selenium.WebDriver.ChromeDriver(Version 84.0.4147.3001)
Selenium.Support(Version 3.141.0)
ChromeDriverを使う時は、Chromeとバージョンを揃えないと以下のようなエラーが発生しました。
型 'System.InvalidOperationException' のハンドルされていない例外が WebDriver.dll で発生しました: 'session not created: This version of ChromeDriver only supports Chrome version 84 (SessionNotCreated)'
サンプルコード
https://github.com/jyunji-watanabe/OutSystemsUtilities/tree/master/OutDocExporter
ビルドしたら、.exeと同じ場所にappsettings.jsonをコピーして、PEのURLとログインアカウント、パスワードを設定。
実行は、以下のようにモジュール名をパラメータにする。.\OutDocExporter.exe HousesoftSampleReactive
OutDocでの画面遷移
ログインからドキュメント出力までは以下のように動きます。
なお、OutDocはTraditional Webという種類のモジュールであるため、各要素のセレクターにidを使うことができません。idが自動生成されるため。
- ログイン画面:ユーザー名とパスワードを入力してログイン
- ホーム画面:ログイン後のトップページ。トップメニューからeSpace一覧画面を開く
- eSpace一覧画面:モジュール名で検索した後、対象モジュールのリンクを開く
- ドキュメント生成画面:開くとドキュメントの準備が始まる。準備をAjax Refreshで待ち、終わると自動でドキュメントを開くボタンが表示されるのでクリックする
- ドキュメント画面:開いたら、スクリーンショットをとる
実装
ページオブジェクト
UIテストの文脈で進められているデザインパターン。
各ページ毎に操作用のクラスを分割し、ページ内の操作をクラス内に閉じ込めることで、読みやすく、変更に強くなる。
これから作るのはUIテストではないが、わかりやすさのために踏襲する。
1. ログイン画面
- 実際にはChrome操作用のChromeDriverを使いますが、別のブラウザに変えても使えるようにベースクラスのRemoteWebDriverをコンストラクタのパラメータに使っています
- 各ページオブジェクトクラスのコンストラクタでは、想定通りのページを開けるか確認し、問題があればオリジナルの例外クラスをスロー(ログイン画面ではTitleで)
- SetCredentialメソッドで、パラメータをUsernameとPasswordのテキストボックスに設定している
- driver.FindElement(By.CssSelector())の呼び出しは、CSSセレクターを使ってHTMLの特定要素を取り出す処理
- Usernameの入力部品は「table.FindElement(By.CssSelector("tr:nth-of-type(3) input"))」で取得している。これは、tableタグの、3行目にあるinputタグという指定
- inputタグを取得したら、.Clear()で入力済みの値をきれいにしてから、SendKeysでパラメータの値を設定
- Loginボタンは、ボタンがページ内に一つしかないことから、「input[type=submit]」で指定できる
- 画面遷移するメソッドは、戻り値で遷移先のページオブジェクトクラスを返す
usingOpenQA.Selenium.Remote;usingOpenQA.Selenium;publicclassLogin{privateconststringPageTitle="Login";privateRemoteWebDriverdriver;publicLogin(RemoteWebDriverdriver){this.driver=driver;if(this.driver.Title!=Login.PageTitle){thrownewIllegalPageStateException("ページタイトルが想定と異なります(想定:"+Login.PageTitle+"、実際:"+this.driver.Title+")");}}publicvoidSetCredential(stringuserName,stringpassword){// UserNameとPasswordを入力vartable=driver.FindElement(By.CssSelector(".MainContent table table"));varuserInput=table.FindElement(By.CssSelector("tr:nth-of-type(3) input"));userInput.Clear();userInput.SendKeys(userName);varpasswordInput=table.FindElement(By.CssSelector("tr:nth-of-type(5) input"));passwordInput.Clear();passwordInput.SendKeys(password);}publicHomeScreenLoginAndGoToHomeScreen(){driver.FindElement(By.CssSelector("input[type=submit]")).Click();returnnewHomeScreen(this.driver);}}
2.ホーム画面
このページは中身がないので、赤枠のタブを開くだけの処理。
- 「.Menu_TopMenus」はトップメニューのWeb Block内で、明示的に指定されているクラス名で変更の恐れが無いため使っている
- トップメニュー内の各メニューはContainer Widget(=>divタグ)内にLink Widget(=>aタグ)という構成であることから「.Menu_TopMenus>div>a」でトップメニュー内のメニューリンクを指定できる。nth-of-type(2)は同じ階層にあるdivタグの2番め、すなわち左から2番めのメニューを指している
usingOpenQA.Selenium.Remote;usingOpenQA.Selenium;publicclassHomeScreen{// (中略)publicESpaceListMoveToESpaceList(){this.driver.FindElementByCssSelector(".Menu_TopMenus>div:nth-of-type(2)>a").Click();returnnewESpaceList(this.driver);}}
3.eSpace一覧画面
トップメニューで、「eSpaces」を選択すると開く画面。
- コンストラクタで、トップメニューの正しいタブが開かれている(Active)になっていることをチェック
- ①モジュール一覧はページングされているため、目標のモジュールが最初に表示されていないことがある → 検索ボックスにモジュール名を入力してSearchボタンをクリック
- ②検索処理が遅いため、対象モジュールのリンクが表示されるまで待ち処理を入れてある
- OpenQA.Selenium.Support.UI.WebDriverWaitを使う
- コンストラクタでは、最長待ち時間に2分を設定しています。環境によって調整してください
- WebDriverWait.Untilに待ち条件を指定するのですが、色々なサンプルにのっているExpectedConditionsを使った指定はdeprecatedとマークされています。ただ、その代替として挙げられていた匿名関数を使った方法だと待ち判定が発生するたびに例外(条件が満たされていない)が投げられるので、ExpectedConditionsで指定
- ElementIsVisibleはセレクタで示した要素が表示されるまで待つという条件
- ③モジュール名と一致するリンクは一つしかないはずなので、表示されたらクリック
usingOpenQA.Selenium;usingOpenQA.Selenium.Remote;usingOpenQA.Selenium.Support.UI;publicclassESpaceList{privateconststringTabTitle="eSpaces";privateRemoteWebDriverdriver;publicESpaceList(RemoteWebDriverdriver){this.driver=driver;// トップメニューのアクティブなタブ内テキストvaractiveTabTextInTheTopMenu=driver.FindElementByCssSelector(".Menu_TopMenuActive>a").Text;if(activeTabTextInTheTopMenu!=ESpaceList.TabTitle){thrownewIllegalPageStateException("選択されているタブが想定と異なります(想定:"+ESpaceList.TabTitle+"、実際:"+activeTabTextInTheTopMenu+")");}}publicESpaceDesignFeedBackOpenESpace(stringeSpaceName){// 検索キーワードの設定varsearchInput=this.driver.FindElementByCssSelector(".Filters input[type=text]");searchInput.Clear();searchInput.SendKeys(eSpaceName);// 検索ボタンクリックthis.driver.FindElementByCssSelector(".Filters input[type=submit]").Click();// 検索結果の1ページ目にリンクテキストが、指定eSpace名であるLinkが表示される(=検索される)のを待ってクリックvarwaitForLinkWitheSpaceName=newWebDriverWait(this.driver,newSystem.TimeSpan(0,2,0));// 匿名関数で実装すると、探索のたびに例外を投げるので、deprecatedだが、いったんExpectedConditionsで実装しておくwaitForLinkWitheSpaceName.Until(ExpectedConditions.ElementIsVisible(By.LinkText(eSpaceName))).Click();// 対象モジュールのドキュメント生成ページが開くreturnnewESpaceDesignFeedBack(this.driver,eSpaceName);}}
4.ドキュメント生成画面
画面を開くと、ローディングアイコンが表示されます。しばらくしてドキュメントの準備ができると「Open Documentation」というボタンが表示される。このボタンをクリックすると、目標のドキュメントが開きます。
- ボタンの表示を待つ処理は、基本的に、上でLinkの表示を待ったときと同じ
- セレクタの「.Button[value^=Open]」はButtonクラスがあり、かつvalue属性が「Open」で始まるもの、という指定
usingOpenQA.Selenium;usingOpenQA.Selenium.Remote;usingOpenQA.Selenium.Support.UI;publicclassESpaceDesignFeedBack{// (中略)/// <summary>/// Open Documentationボタンが表示されるまで待ち、クリックする/// </summary>publicvoidWaitForButtonAndClick(){// 最長2分間待つタイマーvarwaitForOpenDocumentButton=newWebDriverWait(this.driver,newSystem.TimeSpan(0,2,0));// 「Open Documentation」というvalueを持つボタンが表示されるのを待ち、表示されたらクリックwaitForOpenDocumentButton.Until(ExpectedConditions.ElementIsVisible(By.CssSelector(".Button[value^=Open]"))).Click();}}
スクリーンショット取得
以前は、FirefoxDriverを使うと、ページ全体のスクリーンショットを取れたようですが、今は仕様変更でだめでした。
よって、スクリーンショット撮影 → 縦方向にスクロールを繰り返してページ全体のスクリーンショット群を取得する処理にしています。
処理が長くなりすぎたので、画像を1枚にまとめるのは別の機会に。
参考記事
Chromeでフルサイズのスクリーンショットを撮るためのパッチ
を参考にしました。
OutDocの場合は、横スクロールが必要ないので、縦方向のみにしています。
コード
- staticメソッドにしてあるので、呼び方は、ScreenShot.TakeWholePageAsScreenShot(driver)
- ちょっとさぼって、出力先は「C:\work\ss」に固定。適宜修正してください
- スクロールの判定には、ページ全体の高さとスクリーンショット1回分の高さをJavaScriptで取得して使っている
- JavaScriptを実行したい場合は、WebDriverをOpenQA.Selenium.IJavaScriptExecutorインターフェースにキャストして、ExecuteScriptを呼び出す
- スクロールさせるのもJavaScript
- WebDriverにスクリーンショットを取らせる場合、OpenQA.Selenium.ITakesScreenShotインターフェースにキャストして、GetScreenShotでScreenshotオブジェクトを取得、さらにそのSaveAsFileで指定パスにファイルを保存
- このロジックだと指定パスに日時_00001.png, 日時_00002.png……のようなファイルが出来上がる
usingOpenQA.Selenium.Remote;usingOpenQA.Selenium;publicclassScreenShot{publicstaticstringPath="C:\\work\\ss";publicstaticvoidTakeWholePageAsScreenShot(RemoteWebDriverdriver){// 仕様単純化のため、横スクロールは不要を前提とするvarjsExecutor=driverasIJavaScriptExecutor;// JavaScriptを使って、スクロール回数の計算に使う値を取得vartotalHeight=(long)jsExecutor.ExecuteScript("return document.body.scrollHeight;");varpageHeight=(long)jsExecutor.ExecuteScript("return window.innerHeight;");// スクロール制御用varscrolledHeight=(long)0;varcurrentImageCount=1;// ファイル名末尾に連番をつけるための変数。今何番目の画像かを示すvarfilePathPrefix=Path+"\\"+System.DateTime.Now.ToString("yyyyMMdd_HHmmss_");// 1ページ分ずつスクロールしながら、スクリーンショットを撮っていくvariTakesScreenshot=(ITakesScreenshot)driver;while(scrolledHeight<totalHeight){jsExecutor.ExecuteScript($"window.scrollTo(0, {scrolledHeight});");iTakesScreenshot.GetScreenshot().SaveAsFile(filePathPrefix+currentImageCount.ToString().PadLeft(5,'0')+".png");// ループ制御currentImageCount++;scrolledHeight+=pageHeight;}}}
メインプログラム
- 設定ファイルから、URL、ユーザー名、パスワード、パラメータからモジュール名を取得
- 処理本体では、順にページオブジェクトを使って画面遷移していき、最後でスクリーンショット
usingSystem;usingSystem.IO;usingOpenQA.Selenium;usingOpenQA.Selenium.Chrome;usingOpenQA.Selenium.Interactions;usingMicrosoft.Extensions.Configuration;classProgram{staticvoidMain(string[]args){if(args.Length==0){System.Console.WriteLine("ドキュメントを出力するモジュール名を指定してください。");return;}vareSpaceName=args[0];if(Directory.Exists(ScreenShot.Path)==false){System.Console.WriteLine($"出力先フォルダ{ScreenShot.Path}を作成するか、出力先フォルダを変更してください");return;}IConfigurationconfiguration=newConfigurationBuilder().AddJsonFile("appsettings.json",true,true).Build();varurl=configuration.GetSection("HostAddress").Value+"OutDoc";varuserName=configuration.GetSection("UserName").Value;varpassword=configuration.GetSection("Password").Value;try{DownloadDocument(url,userName,password,eSpaceName);}catch(System.Exceptionex){System.Console.WriteLine(ex.Message+Environment.NewLine+ex.StackTrace);}}privatestaticvoidDownloadDocument(stringurl,stringuserName,stringpassword,stringeSpaceName){using(vardriver=newChromeDriver()){// ログインページヘ遷移し、ログインを実行するdriver.Navigate().GoToUrl(url);varloginPage=newLogin(driver);loginPage.SetCredential(userName,password);varhomeScreen=loginPage.LoginAndGoToHomeScreen();// eSpacesページへ遷移するvareSpacesList=homeScreen.MoveToESpaceList();// eSpace名で検索して対象モジュールのみ表示した上で、クリックして開く(ドキュメントページへ)vareSpaceDesingFeedBack=eSpacesList.OpenESpace(eSpaceName);// 生成されたドキュメントを開くeSpaceDesingFeedBack.WaitForButtonAndClick();// スクリーンショットを取得ScreenShot.TakeWholePageAsScreenShot(driver);vardebugdummy="dummy";// デバッグ用(ブラウザ表示した状態でブレークするため)ダミー行}}}