ささいなことですが。

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

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