ささいなことですが。

Windowsアプリテスト自動化ライブラリFriendly開発者の日記です。

Selenium.Friendly.Blazor - その2

前回の続き
ishikawa-tatsuya.hatenablog.com

Selenium.Friendly.Blazor を作ったんですが、Windowsアプリ版と違って対象アプリで

  1. アセンブリを参照しないといけない
  2. App.razorに少し書き足さないといけない

という点がありました。仕方ないかなーと思ってたんですがtwitterで記事を見てくれた @jsakamoto が2の解決策を教えてくれました!

BINDING.assembly_load

BINDING.assembly_loadっていうのでJavaScriptからアセンブリを読み込めるようです。早速やってみました。BlazorAppFriendのコンストラクタに仕込んでみます。

public BlazorAppFriend(object webDriver)
{
    // ※アセンブリをロード!
    ((dynamic)webDriver).ExecuteScript("BINDING.assembly_load('Selenium.Friendly.Blazor');");

    ResourcesLocal.Initialize();
    _connector = new FriendlyConnectorCore(webDriver);
    ResourcesLocal.Install(this);
}

コンポーネントの検索

protected override void OnInitialized()
  => Selenium.Friendly.Blazor.BlazorController.Initialize(this);

App.razorでこれを書き足していたのは二つ理由があって

  1. アセンブリのロード
  2. App.razorの登録

2はコンポーネントを検索するためのルートのコンポーネントが必要だったので付けてました。何となくどこかにstaticであるような気はしたのですが、どうせ1が解決できてないしなーってことで調査を保留にしてたのですが解決したので探してみました。(そして見つけた。やった!
github.com
Microsoft.AspNetCore.Components.WebAssembly.Rendering.RendererRegistry

private static readonly Dictionary? _renderers
というフィールドを持っていてそこにApp以下Componentが入っていました(今は)

で、これをコンポーネント検索のところで使うとApp.razorの登録なしで検索することができました。
github.com

あとは Assembly を参照せずに読み込ますことができれば完璧!

なんだけど、そんなのできるんかな?
誰かいいアイデアあったら教えてください。

Selenium.Friendly.Blazor

meetup app osaka@6 に登壇します。
meetupapp.connpass.com

推しの技術を発表するという企画。僕の押しの技術はSeleniumとFriendlyなんですが、最近は仕事でBlazorもやってます。でこの三つなんですがじゃあ全部入りでやってみようと作ってみました。コードはここに置いてます。
github.com

使い方

結果から。使い方としてこんな感じでできるようになりました。

[Test]
public void Counter()
{
    var driver = new ChromeDriver();
    driver!.Url = "https://localhost:7128/counter";

    //ロードされるまで待つ
    while (driver.Title != "Counter") Thread.Sleep(100);

    //Blazor操作用オブジェクト作成
    var app = new BlazorAppFriend(driver);

    //コンポーネント検索
    var counter = app.FindComponentByType("BlazorApp.Pages.Counter");

    //APIを直接操作
    counter.currentCount = 1000;
    counter.StateHasChanged();
}

f:id:ishikawa-tatsuya:20220223164813p:plain

オブジェクトの生成とか配列の書き換えもOK

[Test]
public void FetchData()
{
    var driver = new ChromeDriver();
    driver!.Url = "https://localhost:7128/fetchdata";

    //ロードされるまで待つ
    while (driver.Title != "Weather forecast") Thread.Sleep(100);

    var app = new BlazorAppFriend(driver);

    //コンポーネント取得
    var fetchData = app.FindComponentByType("BlazorApp.Pages.FetchData");

    //お天気データをBlazorアプリ内に生成する
    var forecasts = app.Type("BlazorApp.Pages.FetchData+WeatherForecast[]")(1);
    forecasts[0] = app.Type("BlazorApp.Pages.FetchData+WeatherForecast")();
    forecasts[0].Date = DateTime.Now;
    forecasts[0].TemperatureC = 3;
    forecasts[0].Summary = "Friendly!";

    //セット
    fetchData.forecasts = forecasts;
    fetchData.StateHasChanged();
}

f:id:ishikawa-tatsuya:20220223164927p:plain

残念なことにWindowsアプリのように何の仕掛けもなくってわけにはいきませんでした。Blazorアプリ側でもライブラリの参照と一行だけ呼び出しが必要です。App.razorのOnInitializeでこれ呼びます。

protected override void OnInitialized()
    => Selenium.Friendly.Blazor.BlazorController.Initialize(this);

実装

Friendlyは外部から対象アプリのAPIを呼び出すものです。簡単に言うとリフレクション情報を対象アプリに渡して実行させています。Windowsアプリではdllインジェクションとか駆使しつつ、最終的にはWindowメッセージでプロセス間通信をしています。

ではSelenium→Blazorではどうすればいいのでしょうか?

調べてみるとBlazorではJavaScriptから.Netのメソッドを呼び出すことができるようです。そしてSeleniumJavaScriptを呼び出せる。この二つを組み合わせたらできそうです。

JSInvokable

docs.microsoft.com
同期、非同期の呼び出しをそれぞれサポートしています。これを使います。やりたいことはAPI実行情報を渡して結果を戻してもらいたいです。データはJsonで受け渡しするのでstring型の引数と戻り値を付けます。文字列で受け取ったものをオブジェクトにして実行して戻り値をさらにJsonのテキストにして返す感じです。DotNetFriendlyControlはWindowsアプリ用のをほとんどそのままコピってきました。

using Selenium.Friendly.Blazor.DotNetExecutor;
using Microsoft.JSInterop;

namespace Selenium.Friendly.Blazor
{
    public static class JSInterface
    {
        static DotNetFriendlyControl _ctrl = new DotNetFriendlyControl();

        internal static bool FriendlyAccessEnabled { get; set; }

        [JSInvokable]
        public static string ExecuteFriendly(string args)
        {
            if (!FriendlyAccessEnabled) throw new NotSupportedException();
            return _ctrl.Execute(args);
        }
    }
}

これを書いておくとJavaScriptからこんな感じで呼ぶことができます。

DotNet.invokeMethod("Selenium.Friendly.Blazor", "ExecuteFriendly", args);

ExecuteScript

次はテストコードでの呼び出しです。Seleniumと依存関係つけるの面倒だったので一旦object渡しでもらうようにしてます。

internal static ReturnInfo SendAndRecieve(object wedbDriver, ProtocolInfo data)
{
	var arg = JsonConvert.SerializeObject(data);

	var src = ((dynamic)wedbDriver).ExecuteScript(@"
var arg = arguments[0];
return DotNet.invokeMethod(""Selenium.Friendly.Blazor"", ""ExecuteFriendly"", arg);
", arg);
	var ret =  JsonConvert.DeserializeObject<ReturnInfo>((string)src);
	ret.SetReturnValueFromJson();
	return ret;
}

これでSeleniumからBlazorアプリまでの通信経路ができました。

残りの実装

この経路ができたら残りの実装はWindows用のFriendlyの実装をコピってWindows用のところを削除したり↑の実装に差し替えたりって感じでした。実はFriendly作るときに色々考えてインターフェイスはそのままに通信経路だけ変えて様々なものを操作できるようにって目論んでたんですよ。その結果無駄に複雑になって今にして思えばやっちまったって感じではあります。でもそのおかげでコード流用はやりやすくて割とサクッとできました。

Blazor用の機能

Componentを探す機能をつけてみました。

var fetchData = app.FindComponentByType("BlazorApp.Pages.FetchData");

どうやってるかと言うと、アウトなことやってます。(まあこれは勉強会用のネタライブラリなんで)
このコードを見てみると今は ComponetBase→RenderHandle→Renderer→ComponetのDictionary って感じで持っていたので(private)それをリフレクションで手繰りました。
github.com

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using System.Reflection;

namespace Selenium.Friendly.Blazor
{
    public class BlazorController
    {
        static ComponentBase _app;

        public static void Initialize(ComponentBase app)
        {
            _app = app;
            JSInterface.FriendlyAccessEnabled = true;
        }

        public static ComponentBase FindComponentByType(string typeFullName)
        { 
            var list = new List<ComponentBase>();
            GetDescendants(_app, list);
            return list.Where(x => x.GetType().FullName == typeFullName).FirstOrDefault();
        }

        public static List<ComponentBase> GetDescendants(ComponentBase parent, List<ComponentBase> list)
        {
            list.Add(parent);

            //今はこれで下位のコンポーネントを取ってこれるみたい
            /*
            foreach (var e in parent._renderHandle._renderer._componentStateById)
            {
                var child = e.ValueComponent;
            }
            */
            var flgs = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;
            var _renderHandleField = typeof(ComponentBase).GetField("_renderHandle", flgs);
            var _rendererFiled = typeof(RenderHandle).GetField("_renderer", flgs);
            var _componentStateByIdField = typeof(Renderer).GetField("_componentStateById", flgs);

            var _renderHandle = _renderHandleField.GetValue(parent);
            var _renderer = _rendererFiled.GetValue(_renderHandle);
            dynamic _componentStateById = _componentStateByIdField.GetValue(_renderer);
            foreach (object e in _componentStateById)
            {
                var valueField = e.GetType().GetProperty("Value", flgs);
                var obj = valueField.GetValue(e);
                if (obj == null) continue;
                var prop = obj.GetType().GetProperty("Component", flgs);
                var val = prop.GetValue(obj);
                var child = val as ComponentBase;
                if (child == null) continue;

                if (list.Contains(child)) continue;
                GetDescendants(child, list);
            }
            return list;
        }
    }
}

結論

これ使わなくてもSeleniumだけでテストしたらいいと思いましたw

リフレクションでハマったこととか

meetup app osaka@5で登壇します。そしてそれ用の資料となるブログです。(多分土曜の朝まで更新し続けると思います
meetupapp.connpass.com

TestAssistantProってツールを作っているのですが、なんだかんだでリフレクションが非常に重要になってきます。その辺りでハマったり「おっ」って思ったりしたことを書いていきます。ちなみにTestAssistantProはこれです。
youtu.be

参照しているdllがない

いきなりリフレクションと少し離れるのですが、ビルドしたときのフォルダに参照いるdllがない問題にハマりました。.NetFrameworkではこんなことはないですよね。これ何がまずいかっていうとVS拡張からdllの何かの機能を実行しようって時に関連dllが読み込めなくてエラーになる。

タイプ dllが集まる
.NetFramework(Exe)
.NetFramework(Library)
.NetStandard ×
.NetCore(Exe)
.NetCore(Library) ×
.NetCore(ユニットテスト)

どうやらライブラリの場合に集まらないみたいです。が、ライブラリでも例外的にユニットテストは集まっている。調べてみると「Microsoft.NET.Test.Sdk」が参照されている場合にはexe同様に集められているようです。なぜ「Microsoft.NET.Test.Sdk」を参照すると集まるのかはわかりませんでしたが(PropertyGroupのTestProject、IsTestProjectをtrueにしてもダメ)ないとテスト実行できないので何か特殊な仕掛けがあるのでしょう。

対象の処理を実行するには?

基本的には関連するdllを全部読み込む必要があります。exeは一か所に集まっているので簡単ですね。で、ライブラリ系の集まってないやつをどうするかというと道は二つです。

  1. deps.jsonを解析して関係するdllをPC内から探して全て読み込む
  2. 必要なdllを集めて読み込む

1はやる気が全く起きませんでした。大人しく2の方法にします。これはどうすればいいかというとpublishコマンドを使えば集まります。以下サンプルコードです。
呼び出される.NetCoreのライブラリのコード。

using Newtonsoft.Json;
using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

namespace CoreLib
{
    public class Test
    {
        public static void Execute()
        {
            //jsonのシリアライズ
            //別のNewtonsoft.Json.dllを参照する必要がある
            var text = JsonConvert.SerializeObject(new Data { Value = "aaa" });
            var obj1 = JsonConvert.DeserializeObject<Data>(text);

            //BinaryFormatterのシリアライズ
            //.NetFrameworkならこのこれの解決にはAssemblyResolveが必要
            var bin = ToBinary(new Data { Value = "aaa"});
            var obj2 = ToObject<Data>(bin);
        }

        static byte[] ToBinary<T>(T obj)
        {
            var formatter = new BinaryFormatter();
            using (var mem = new MemoryStream())
            {
                formatter.Serialize(mem, obj);
                return mem.ToArray();
            }
        }

        static T ToObject<T>(byte[] bin)
        {
            var formatter = new BinaryFormatter();
            using (var mem = new MemoryStream(bin))
            {
                return (T)formatter.Deserialize(mem);
            }
        }
    }

    [Serializable]
    public class Data
    {
        public string Value { get; set; }
    }
}

リフレクションで呼び出す側のコードです。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;

namespace CoreExe
{
    class Program
    {
        static void Main(string[] args)
        {
            var projectDir = @"C:\Reflection\CoreLib";
            var binDir = @"C:\Reflection\CoreLib\bin\Debug\netcoreapp3.1\publish";
            var dllPath = @"C:\Reflection\CoreLib\bin\Debug\netcoreapp3.1\publish\CoreLib.dll";

            //publishでビルドしてdllを集める
            var process = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo("dotnet", "publish -c Debug")
            {
                WorkingDirectory = projectDir,
                CreateNoWindow = true,
                UseShellExecute = false
            }); ;
            process.WaitForExit();

            //全部のdllを読み込み
            foreach (var e in Directory.GetFiles(binDir, "*.dll", SearchOption.AllDirectories))
            {
                Assembly.LoadFrom(e);
            }

            //※アセンブリ解決補助
            AppDomain.CurrentDomain.AssemblyResolve += CurrentDomainAssemblyResolve;

            //実行
            var type = Assembly.LoadFrom(dllPath).GetType("CoreLib.Test");
            var method = type.GetMethod("Execute");
            method.Invoke(null, new object[0]);
        }

        //※アセンブリ解決補助
        static Assembly CurrentDomainAssemblyResolve(object sender, ResolveEventArgs args)
            => AppDomain.CurrentDomain.GetAssemblies().Where(e => e.FullName == args.Name).FirstOrDefault();
    }
}

.NetFrameworkと異なる動作

いくつかあります。全部ではないですが私が気づいた範囲で書いておきます。

AssemblyResolveの動作が異なる

これは改善された点です。先に書いたコードでAssemblyResolveを使っている箇所があります。.NetFrameworkではBinaryFormatterのシリアライズの時点でAssemblyResolveの補助がなければ解決することができませんでした。しかし.NetCoreではこれがなくても解決できます。昔からこんなんなくても自力で解決できるやろって思ってたからこれは嬉しいですね。ではなぜAssemblyResolve自体残っているのかですがサテライトアセンブリの解決に必要なようです。
docs.microsoft.com

.NetCore2.0より前はAppDomainが使えなかった

昔話です。今は使えるので問題ないです。がフルに使えるわけではありません。CreateDomainなどの機能は削除されています。とは言えリフレクション系で良くお世話になる AppDomain.CurrentDomain.GetAssemblies() は使えるので問題はないですね。いやいやロードしているアセンブリの一覧とれないとかリフレクションできへんやんって感じでした。

.NetCore2.0より前はTypeからTypeInfoが抜かれていた

懐かしのストアアプリ時代に一度設計が変わりました。それがPCLや.NetCore1.1、.NetStandard1.6まではそのまま続いていました。でもよほど不評だったのか今では旧のインターフェイスも使えるようになっています。

普通は

void Test(Type type)
{
    //いやいや普通にTypeに聞けばいいし
    var isClass = type.IsClass;
}

TypeInfoが抜かれていた暗黒の時代

void Test(Type type)
{
    //TypeIsClassがなくてGetTypeInfo()でTypeInfoを取得してそれに聞かないといけない
    var isClass = type.GetTypeInfo().IsClass;
}

Xamarin/UWP

XamarinとUWPに関しては最近全然触ってなくて(昔に実験的に触っただけ)今触りましたw。一般的なリフレクションは当然使えます。ただAppDomain触ったりdll読み込んだりは制限があります。昔はAPI自体なかったんだけど最近ではAPIはあるけど使えんかったら実行時エラーって流れになってますね。
dllの読み込みに関しては以下だけ読み込めます。UWPでは同一フォルダにあるものはビルド時に参照していなくても読み込めるようです。

タイプ Assembly.LoadFrom
UWP 同一フォルダにあるもの
Xamarin ビルド時に参照しているもの

あと、リフレクションではないのですがiOSではIL Emitはできないという有名な話があるようです。

まとめ

.NetCore2.0、.NetStandard2.0以降は改善されて冒頭のdllが集まらないこと以外は.NetFrameworkと遜色なくつかえて意外とハマらなくなってますね。

おまけ

尺が余ったとき用のリフレクションのチップスを一つ。
例えばわたって来たタイプを元にジェネリック型のインスタンスを生成したい場合に作るのはこれで良いけど、それどうやって使いますかってやつ。3つくらい考えられます。

  1. 戻ってきたやつをリフレクションで実行
  2. dynamic
  3. Type Erasure

僕は2と3を使い分けることが多いですね。Type Erasure ってのはジェネリックなどで静的な型付けが必要なクラスをジェネリックのタイプを消して動的に使うためのテクニックです。もとはC++のテンプレートで発生したテクニックのようです。

public interface IExecutor
{
    void Execute();
}

public class Executor<T> : IExecutor
{
    public void Execute()
    {
        //...何かの処理
    }
}
static void UserExecutor(Type t)
{
    //生成
    var executorType = typeof(Executor<>).MakeGenericType(t);
    var executor = Activator.CreateInstance(executorType);

    //①リフレクション
    executorType.GetMethod("Execute").Invoke(executor, new object[0]);

    //②dynamic
    dynamic executorDynamic = executor;
    executorDynamic.Execute();

    //③Type Erasure
    var iexecutor = (IExecutor)executor;
    iexecutor.Execute();
}

Selenium.CefSharp.Driver_β をリリースしました。

CefSharpのブラウザを操作できるSelenium.CefSharp.Driverをリリースしました。
後は使いながら調整していく感じです。
Nugetから取得できるので是非つかってください!

github.com

そもそもCefSharpとは?

まずそこからですよねー。WpfとかWinFormsにChromiumを組み込むためのライブラリです。これ自体が超絶ニッチなライブラリです。そしてそれを操作するSeleniumとかどこまでもマニアック。

ChromeDriverでは動かせないの?

アプリ自体がブラウザみたいなやつは頑張ったら動かせるらしいんですけど、例えばアプリの途中でボタン押したらブラウザでてくるとかは無理みたいです。なんだけど、わざわざ組み込むんだからブラウザだけってなくね?ってわけで動いている奴にアタッチして操作できるものにしました。

アーキテクチャ

シンプルで以下3つの組み合わせです。

  1. Friendly
  2. JavaScript
  3. key Mouse エミュレート

まあ、僕がつくるんだからFriendlyは使いますよね・・・。ChromiumWebBrowserのAPIみて「こんだけでければ何とかなるやろ」ってので始めました。WebBrowserExtensionsのExecuteScriptAsyncってメソッドがあってJavaScriptを呼び出せるんですよね。これが決め手で大部分の処理はJavaScriptでまかなってます。僕はJavaScriptそんな詳しくないんでk-maruさんに手伝ってもらいました。Key Mouse エミュレートなんですけど、これは Friendly.Windows.KeyMouseを使っていてタイミングは調整しています。

サンプルコード

アタッチした後は普通にSeleniumインターフェイスで操作できます。

//Friendlyでアタッチ
var app = new WindowsAppFriend(process);

//普通にFriendlyの操作でウィンドウを取得
var window = app.Type<Application>().Current.MainWindow;

//そこに定義されている変数を取得、ドライバ作成
var driver = new CefSharpDriver(window._browser);

//あとは普通にSeleniumの操作
_driver.Url = "https://www.test.co.jp/";

//idから検索
var button = _driver.FindElement(By.Id("testButtonClick"));

//click.
button.Click();

//名前から検索
var textBox = _driver.FindElement(By.Name("nameInput"));

//キー操作
textBox.SendKeys("abc");

//タグから検索
var select = _driver.FindElement(By.TagName("select"));

//Selenium Supportも使える
new SelectElement(select).SelectByText("Orange");

//XPathから検索
var buttonAlt = _driver.FindElement(By.XPath("//*[@id=\"form\"]/table/tbody/tr[7]/td/input[2]"));

//Actoionsも使える
new Actions(_driver).KeyDown(Keys.Alt).Click(buttonAlt).Build().Perform();

//もちろんJavaScriptも呼び出せます。
var defaultValue = (string)_driver.ExecuteScript("return arguments[0].defaultValue;", textBox);

使えない機能

  1. IHasInputDevices
  2. IOptions
  3. IHasCapabilities

これらは使えません。IHasInputDevicesは実装しても良かったけど、なんだか非推奨になってたのでやめました。でも後は頑張って実装したのでそれなりに使えます。WindowとかFrame,Alertも無理やり対応しました。

WebDriverではない

重要なのはいわゆるWebDriverのプロトコルは使ってなくてそれに似せてるだけです。だから動きもChromeDriverと全く同じかといわれるとそうではない。でも十分実用に耐えるレベルを目指しています。実際に弊社のお客さんのとこでも使ってもらってます。なんで否が応でもそれなりの品質になります。よろしければ是非使ってみてください。

そして建設的なフィードバックをお待ちしております(時事ネタ

FriendlyでWPFアプリをテストするときのコツ

WPFのアプリをテストするときは躓きやすいのでちょっとコツを書きます。

こんな感じの構成のときどうやって操作していいのか最初はちょっと難しいですよね。
f:id:ishikawa-tatsuya:20200517124431p:plain

<NavigationWindow x:Class="WpfAppSample.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfAppSample"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
</NavigationWindow>
<Page x:Class="WpfAppSample.SamplePage"
      xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
      xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
      xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
      xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
      xmlns:local="clr-namespace:WpfAppSample"
      mc:Ignorable="d" 
      d:DesignHeight="450" d:DesignWidth="800"
      Title="SamplePage">

    <Grid>
        <Grid.ColumnDefinitions>
            <ColumnDefinition></ColumnDefinition>
            <ColumnDefinition></ColumnDefinition>
        </Grid.ColumnDefinitions>
        <local:SampleUserControl Grid.Column="0">
        </local:SampleUserControl>
        <Canvas Grid.Column="1">
            <TextBox Height="119" Canvas.Left="82" TextWrapping="Wrap" Text="{Binding Data}" Canvas.Top="182" Width="224"/>
        </Canvas>
    </Grid>
</Page>
<UserControl x:Class="WpfAppSample.SampleUserControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:WpfAppSample"
             mc:Ignorable="d" 
             d:DesignHeight="450" d:DesignWidth="800">
    <Canvas Background="#FFC9D6F6">
        <ListView Height="368" Width="340" Canvas.Left="25" Canvas.Top="56" ItemsSource="{Binding Users}">
            <ListView.ItemTemplate>
                <DataTemplate>
                    <StackPanel>
                        <TextBlock Text="{Binding Name}"/>
                        <CheckBox Content="Engineer" IsChecked="{Binding IsEngineer}"/>
                    </StackPanel>
                </DataTemplate>
            </ListView.ItemTemplate>
        </ListView>
        <Button Content="Add" Canvas.Left="227" Canvas.Top="28" Width="75" Click="Button_Click"/>
        <TextBox Height="23" Canvas.Left="25" TextWrapping="Wrap" Text="{Binding Name}" Canvas.Top="28" Width="120"/>
        <CheckBox Content="Engineer" Canvas.Left="150" Canvas.Top="30" Width="77" IsChecked="{Binding IsEngineer}"/>
    </Canvas>
</UserControl>

ドライバを作る

まずはドライバを作ります。このサンプルだと以下に作ります。一つ一つ作っていくところがポイントですね。

  • Window
  • Page
  • UserControl
  • ItemsControlのItem

要素の特定

特定方法ですが、Windowを捕まえるのはWindowControlのメソッドを使います。これはWinFormsと同じですね。以下WindowsAppFriendに対する拡張メソッドにしていますが中身はWindowControlのstaticメソッドです。

var win = app.WaitForIdentifyFromTypeFullName("WpfAppSample.MainWindow");

それで、Window以下に関しては、x:nameでアクセスできるならWinFormsの時と同じくフィールドを使ってもらえばいいのですが、ついていない場合はVisualTreeをだどって見つけます。前に書いてたのでそのリンクを貼っておきます。
ishikawa-tatsuya.hatenablog.com
ishikawa-tatsuya.hatenablog.com

ドライバのサンプル

using Codeer.Friendly.Windows;
using Codeer.Friendly.Windows.Grasp;

namespace Driver.Windows
{
    //ドライバ
    public class MainWindowDriver
    {
        public WindowControl Core { get; }

        public MainWindowDriver(WindowControl core)
        {
            Core = core;
        }
    }

    //捕まえるための拡張
    public static class MainWindowDriverExtensions
    {
        public static MainWindowDriver AttachMainWindow(this WindowsAppFriend app)
            => new MainWindowDriver(app.WaitForIdentifyFromTypeFullName("WpfAppSample.MainWindow"));
    }
}
using Codeer.Friendly;
using Codeer.Friendly.Dynamic;
using RM.Friendly.WPFStandardControls;

namespace Driver.Windows
{
    public class SamplePageDriver
    {
        public WPFUserControl Core { get; }
        public WPFTextBox TextBox => Core.LogicalTree().ByBinding("Data").Single().Dynamic();

        public SamplePageDriver(AppVar core)
        {
            Core = new WPFUserControl(core);
        }
    }

    //MainWindowからSamplePageを取得するための拡張
    public static class SamplePageDriverExtensions
    {
        public static SamplePageDriver AttachSamplePage(this MainWindowDriver window)
            => window.Core.VisualTree().ByType("WpfAppSample.SamplePage").Single().Dynamic();
    }
}
using Codeer.Friendly;
using Codeer.Friendly.Dynamic;
using RM.Friendly.WPFStandardControls;
using System.Windows.Controls;

namespace Driver.Windows
{
    public class SampleUserControl_Driver
    {
        public WPFUserControl Core { get; }
        public WPFListView ListView => Core.LogicalTree().ByBinding("Users").Single().Dynamic();
        public WPFButtonBase Add => Core.LogicalTree().ByType<Button>().ByContentText("Add").Single().Dynamic();
        public WPFTextBox TextBox => Core.LogicalTree().ByBinding("Name").Single().Dynamic();
        public WPFToggleButton Engineer => Core.LogicalTree().ByBinding("IsEngineer").Single().Dynamic();

        public SampleUserControl_Driver(AppVar core)
        {
            Core = new WPFUserControl(core);
        }
    }

    //SamplePageからSampleUserControlを取得するための拡張
    public static class SampleUserControlDriverExtensions
    {
        public static SampleUserControl_Driver Attach_SampleUserControl(this SamplePageDriver page)
            => page.Core.VisualTree().ByType("WpfAppSample.SampleUserControl").Single().Dynamic();
    }
}
using Codeer.Friendly;
using Codeer.Friendly.Dynamic;
using RM.Friendly.WPFStandardControls;

namespace Driver.Windows
{
    public class UserListViewItemDriver
    {
        public WPFUserControl Core { get; }
        public WPFTextBlock TextBlock => Core.VisualTree().ByBinding("Name").Single().Dynamic();
        public WPFToggleButton Engineer => Core.VisualTree().ByBinding("IsEngineer").Single().Dynamic();

        public UserListViewItemDriver(AppVar core)
        {
            Core = new WPFUserControl(core);
        }
    }

    //ListViewItemにUserListViewItemDriverを適用するための拡張メソッド
    public static class UserListViewItemDriverExtensions
    {
        public static UserListViewItemDriver AsUser(this WPFListViewItem item)
            => new UserListViewItemDriver(item.AppVar);
    }
}

シナリオのサンプル

using System.Diagnostics;
using Codeer.Friendly.Windows;
using Driver;
using NUnit.Framework;
using Driver.Windows;

namespace Scenario
{
    [TestFixture]
    public class Test
    {
        WindowsAppFriend _app;

        [SetUp]
        public void TestInitialize() => _app = ProcessController.Start();

        [TearDown]
        public void TestCleanup() => Process.GetProcessById(_app.ProcessId).Kill();

        [Test]
        public void TestMethod()
        {
            //Windowを見つける
            var mainWindow = _app.AttachMainWindow();

            //ページを見つける
            var samplePage = mainWindow.AttachSamplePage();

            //UserControlを見つける
            var sampleUserControl = samplePage.AttachSampleUserControl();

            //アイテムを取得
            var item = sampleUserControl.ListView.GetItem(1);
            
            //アイテム一つに対するドライバにする
            var userItem = item.AsUser();

            //操作
            userItem.Engineer.EmulateCheck(true);
        }
    }
}

もっと複雑なのは?

おそらく操作する対象を特定するのが難しいのだと思います。これ以上複雑な場合はFriendlyの基本機能、「対象プロセスのAPIをなんでも使える」というのを使って最適な特定方法を作ってみてください。その場合、必要なのはFriendlyの知識ではなくWPFの知識だったりします。頑張ってください!

BOSS WAZA-AIR 買った!

WAZA-AIR買いました!こんな製品があるって知ったのが発売日の次の日。検索かけたらどこの店でも売り切れで次回入荷は1月下旬って言われてたんですけど、突然サウンドハウスさんから発送しましたのメールが!うれしすぎるー。そんで12/20に届きました。
f:id:ishikawa-tatsuya:20191221213114j:plain:w300

それで色々触ってみたのですが、各機能に対する感想です。

機能 感想
ワイヤレス接続
エフェクター ...
アンプシミュレータ ...
キャビネットシミュレータ ...
ジャイロ
ヘッドフォン
外部音源再生

※...は好みによると思います。

以下時々、購入前に使ってたシステムとの比較がでてきますが、以前にこんなブログ書いてました。
真夜中に気持ちよくギターを弾きたい! - ささいなことですが。
IR + サラウンド が凄すぎた - ささいなことですが。

遅延なしワイヤレス接続すげー!

これと比べるとやっぱりATH-DWL550は少し遅延していますね。いや気づいてはいたんですけど、まあアンプから離れた位置で弾いたらこんなものかなーとかで、無理やり納得してましたw。WAZA-AIRはほんとに遅延ない。これはほんとにスゴイです!これだけでも買った価値あった。

ヘッドフォン

普通に良いです。広がり感がありますね。ギターの音をいい感じに聞こえるようにするためにハードウェア的にも徹底的にチューニングしてるんやろなー(知らんけど

外部音源

少しわかりづらかったですけど、WAZA-AIRはBluetothのチャンネルは2つついてますね。なんで同時にアンプの設定変えたり音源流したりできます。一緒に鳴らしてもギターの音が埋もれたりせずに聞こえるのは流石です。
f:id:ishikawa-tatsuya:20191222012419p:plain:w250

ジャイロ

他の人がYutubeでやってるのを見ると、あんまり期待しすぎない方が良いとの話のジャイロw。空間を演出してくれます。確かに僕もギターだけで演奏している時はいらんって思いました。残響音が演出過剰で好みではない。
しかし!外部音源に合わせて弾くと、ステージとか凄い気持ちよく弾けました。iRealに合わせて弾いてましたが、めっちゃ雰囲気出ました。バンドの音と合わせると残響音もこれくらいの方がちょうどいいですね。
それでサラウンドってモードあるんですけど(これがデフォになっている)ギターの音だけを楽しみたいときはちょっとキツイ。OFFを選択してしまう。サラウンドはギターだけで弾く用にもっと自然な感じのものを用意してほしいなー。アップデートで入らんかなー。
それからサラウンドあった方が確かに立体的なイメージになるのですけど、WAZA-AIRの場合はヘッドホンの作りが良くてサラウンドOFFでもいい感じの音になります。

エフェクター/アンプシミュレータ/キャビネットシミュレータ

これは好みによります。僕はイマイチ好みの音でなかった・・・。結構パラメータいじり倒してたんですけどねー。思っている音に持っていけない。
やっぱり MV-50(Boutique) + BluBox の方がアンプ/キャビネットシミュレータとしては優秀。とは言え値段と筐体サイズに差があるし当たり前と言えば当たり前の話なんですけどね。やっぱりMV-50(Boutique) + BluBox を使いたい。トランスミッターをBluBoxの後に刺せばいけるんちゃうやろか?というわけでやってみました。

外部のアンプ/キャビネットシミュレータを使う

WAZA-AIRにはFlatってのがあります。これはギターアンプシミュレータをしてないPA的な感じのアンプなんやと思います。
BluBoxはPAに送ったりするのもメインの用途なんでこのモードにしたら良い感じになるのでは?でこんな感じの最終はセッティングしました。ちなみにWAZA-AIRキャビネットシミュレータはOFFにはならないのでそのままにしています。
f:id:ishikawa-tatsuya:20191222014547p:plain:w250

機材のつなぎ方こんな感じ。こないだまではこれでミキサーのヘッドホンにATH-DWL550を刺して使ってたんですよね。微調整はしたものの、結局はATH-DWL550をWaza-Airに変えただけ。
f:id:ishikawa-tatsuya:20191221212302j:plain:w300
f:id:ishikawa-tatsuya:20191222015810p:plain

結果は・・・。

めっちゃいい!やっぱり真空管は正義!(nutubeやけど)アンプ/キャビネットシミュレータはMV-50 + BluBoxが最強かも(この価格帯では)。すでにお気に入りのアンプ/キャビネットシミュレータ持っている人はこの方法(Flatアンプ)でやるといいかも。お手軽さは減るけど、外に持っていてまでこれ使うことあんまりないと思いますw。とは言えもう少し綺麗にまとめたいな。DIYで箱つくるかな・・・。

ハマったことと対策

WAZA-AIRへの入力が大きすぎると音が切れる

まあ、本来はギターとかを入力するものですからね。なのでミキサーからの出力を少し小さめにして、WAZA-AIRのアンプの方でGainを大き目にしています。

トランスミッターがスタンバイモードになる

トランスミッターのデフォルトはモーションセンシングになっていて振動がなければスタンバイモードになります。これを変えてやります。OFFにするかサウンドセンシングにするかで対応できます。ただ、サウンドセンシングで5分にしたはずだけど、この刺し方だと逆にスタンバイになってくれなかったですけどw
f:id:ishikawa-tatsuya:20191222023418p:plain:w250

WAZA-AIRに送るときはモノラルになる

ハマったというほどでもないのですが、BlueboxからミキサーにはLRを2ch送っているのですが、LeftしかWAZA-AIRに行かなくてちょっとブルー。ミキサーでRightもLeftにして混ぜて送りました。そんなに変わらんけど気持ちの問題ですね。

MV50→Bluboxはスピーカーアウトから

これは別にWAZA-AIR関係なくて、ハマったことでもないですけど、スピーカーアウトから出してBluboxに入れた方が真空管感が増しているように感じています。

ATH-DWL550と比べるとこれだけ良くなりました!

  • 遅延なくなった
  • 音改善
  • 外部音源の接続が楽になった
  • 気分に応じてStageモードで遊べる

TestAssistantProもいつの間にか.NetCore対応してました!

TestAssitantPro っていうのはCodeerの販売しているVisualStudio拡張です。(FriendlyはOSSで無料だけどこのツールは有料です)
www.codeer.co.jp
すでに多くの企業様でご利用いただいております。ありがとうございます!

FriendlyやSeleniumを使ったコードをGUIを使って作成することができます。手書きコードとキャプチャリプレイツールのいいとこどりのツールです。それでこのツールの面白いところは、プロジェクトのコードや参照しているNugetのパッケージ、dllを使って対象プロセスを解析したりコードを生成したりできるのです。まさにコードとツールのシナジー効果

Friendlyを最新にしたら自然と.NetCore対応

そうなんです。使っているFriendly系のライブラリが最新だったら.NetCoreも操作可能になるのです!実験してみます。まずは.NetCoreのWPFアプリを作成します。一見するとどっちかわからないんでobjectの所属するアセンブリの名前を表示してみます。
f:id:ishikawa-tatsuya:20190821234400p:plain

次はTestAssistantProのプロジェクトを作成します。普通のC#のソリューションなんですけど、これで作るとDriver、DriverInTarget、Scenarioって3つプロジェクトそれぞれに最新のFriendlyをインストールした状態で作成してくれます。
f:id:ishikawa-tatsuya:20190821234650p:plain

まずは、対象の画面のドライバを作成します。ドライバ(Seleniumで言うところのPageObject)を作って、それを使ったシナリオを書く。この辺りが手書き相当の品質のコードを生成するポイント。
f:id:ishikawa-tatsuya:20190821234834p:plain
画面の解析に成功しました。
f:id:ishikawa-tatsuya:20190821235050p:plain
そして続くドライバ生成にも成功。今回はそのまま使いますが、生成したドライバはC#のコードになるのでもちろん手で名前変えたり好きなように変更できます。
f:id:ishikawa-tatsuya:20190821235144p:plain
そしたら次は操作キャプチャしてみます。普通のNUnitやMSTestのフレームワークで動くテストでコードを挿入したい箇所で右クリックからCapture。
f:id:ishikawa-tatsuya:20190821235921p:plain
f:id:ishikawa-tatsuya:20190821235327p:plain
見事に操作からコードを生成することができました!

ご興味あればトライアルライセンスを発行します

ご興味ある方は、こちらからご連絡くださいねー。
www.codeer.co.jp