ささいなことですが。

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

だいたいなんでもExcelをPDFに変換する魔法

すみません、フリーレン風に言いたかっただけです。Nugetのライブラリです。
というわけで作りました。MITライセンスでサーバーでも、なんならWebAssemblyでブラウザ上でも使えます。
github.com

変換できるのは

  1. 文字(サイズ、太さ、色)
  2. セル(種類、色、マージ)
  3. 画像

これだけなんですけど、一般的に使う分にはこれだけいけたら十分じゃない?リクエストあればサクッと作れるものなら足していこうと思います。

GitHubのReadMeにも載せてますがこんな感じの変換ができます。

おまけ機能で簡単な文字置換ができます。これを使えば元ネタをExcelで作っておいて、それをデータに合わせて書き換えてPDFに変換ってことができます。

使い方なんですけど、Fontがちょっと面倒。これは内部的に使ってるPdfSharpの仕様です。Fontって勝手に再配布できないし、そもそもサイズが大きいのでライブラリには取り込めない。なので使う分だけ用意してもらう仕様です。とはいえ業務アプリで使う場合はそんなフォントこだわりないんじゃないかな?この例ではNotoSansで統一して太字だけ対応してます。用途に合わせて調整してみてください。

public class CustomFontResolver : IFontResolver
{
    public byte[] GetFont(string faceName)
        //Implement so that you can get as many fonts as you need.
        => faceName.EndsWith("#b") ? Resources.NotoSansJP_ExtraBold : Resources.NotoSansJP_Regular;

    public FontResolverInfo ResolveTypeface(string familyName, bool isBold, bool isItalic)
    {
        var faceName = familyName; 
        if (isBold) faceName += "#b";
        return new FontResolverInfo(faceName);
    }
}
GlobalFontSettings.FontResolver = new CustomFontResolver();

それが終わると後は変換するだけです。ファイルパスを渡すのと、Webアプリで使いやすいようにStreamを渡す版も作ってます。

using var outStream = ExcelConverter.ConvertToPdf(workbookPath, 1);
File.WriteAllBytes(pdfPath, outStream.ToArray());

作り方

ClosedXMLとOpenXmlで解析してPdfSharpで書いているだけです。だから意外と小規模なコードです。幅と高さの単位を合わせるのがめっちゃ大変だった・・・。そのへん@tmytに教えてもらいました。ありがとう!

C#だけでJavaScriptを書く! Blazor.DynamicJSを作りました ③DispatchProxy

2023/03/03(今日ですね) にある meetup app osaka@7 で話すやつです。
meetupapp.connpass.com

dynamic で JavaScript を書けるのですが、それではインテリセンスも使えないし再利用するためにはラップするなりで型をつけてやった方がいいですね。それで interface を定義して定義するだけで使えるようにしました。内部的には DispatchProxy を使っています。こんな感じで書けます。

<script>
    class Rectangle {
        constructor(height, width) {
            this.height = height;
            this.width = width;
        }

        getArea() {
            return this.height * this.width;
        }
    }

    function sum(...theArgs) {
        let total = 0;
        for (const arg of theArgs) {
            total += arg;
        }
        return total;
    }
</script>
[JSCamelCase]
public interface IWindow
{
    int Sum(params int[] val);
}

[JSCamelCase]
public interface IRectangle
{
    int Height { get; set; }
    int Width { get; set; }
    int GetArea();
}

private async Task Test()
{
    using var js = await JS.CreateDymaicRuntimeAsync();

    //メソッド
    var window = js.GetWindow<IWindow>();
    var value = window.Sum(1, 2, 3, 4, 5);

    //クラス生成
    var rect = js.New<IRectangle>("Rectangle", 5, 7);
    var area = rect.GetArea();
}

ルール

ここでインターフェイスを定義するとき問題がいくつかあって

  1. C#JavaScriptでは命名規則が違う場合がある
  2. 非同期どうするか
  3. new とかそもそも interface で表せないもの

いくつか対応するためのルールを追加しています。

名前に対するルールです。

//camel case になる、メソッド、プロパティ単位の指定も可能
[JSCamelCase]
public interface ITest
{
    //インデックスアクセスできる
    int this[int index];
    
    // valueになる
    int Value { get; set; }

    // sum(,,,args) になる 
    int Sum(params int[] values);

    // 文字列指定 JSNameで指定した名前が使われます
    [JSName("getData2")]
    int GetData(int x);
}

プロパティ、new をメソッドに変換できます。

public interface ITest
{
    //new Rectangle になります
    [JSConstructor("Rectangle")]
    IRectangle CreateRectangle();
    
    //Valueになります
    [JSProperty("Value")]
    int GetValue();
    [JSProperty("Value")]
    void SetValue(int value);
    
    //this[index]
    [JSIndexProperty]
    int GetAt(int index);
    [JSIndexProperty]
    void SetAt(int index, int value);
}

非同期は Task を返すように定義することで非同期になります。名前の末尾にAsyncをつけると自動でそれは削除されます。つけたい場合は JSName を併用するとそっちが優先されます。プロパティも↑の属性でメソッドに置き換えることにより非同期で使えるようになります。

public interface ITest
{
    //GetValueを非同期で呼び出します
    Task<int> GetValueAsync();
}

Handsontable

それで前回のHandsontableも class と interface で書いてみました。

public class Col
{
    public object? Data { get; set; }
    public object? ReadOnly { get; set; }
    public object? Width { get; set; }
    public object? ClassName { get; set; }
    public object? Type { get; set; }
    public object? NumericFormat { get; set; }
}

public class ProductMaster
{
    public string? Edit { get; set; }
    public bool Select { get; set; }
    public string? ProductCode { get; set; }
    public string? ProductName { get; set; }
    public int UnitPrice { get; set; }
    public string? Comment { get; set; }
}

public class EnterMoves
{
    public int Row { get; set; }
    public int Col { get; set; }
}

public class InitialData
{
    public object[]? Data { get; set; }
    public string[]? ColHeaders { get; set; }
    public Col[]? Columns { get; set; }
    public EnterMoves? EnterMoves { get; set; }
    public bool OutsideClickDeselects { get; set; }
    public bool ManualColumnResize { get; set; }
    public bool FillHandle { get; set; }
}

[JSCamelCase]
public interface IHandsontable
{
    void LoadData(List<ProductMaster> data);
    void SetDataAtCell(int row, int col, string data);
}

public interface IAfterChangesInfo
{
    dynamic this[int index] { get;set; }
}

[JSCamelCase]
public interface IAfterChangesInfoArray
{
    IAfterChangesInfo this[int index] { get; set; }
    int Length { get; set; }
}

public static class HandsontableExtentions
{
    public static IHandsontable CreateHandsontable(this DynamicJSRuntime js, ElementReference grid, InitialData data, Action<IAfterChangesInfoArray, dynamic> afterChange)
    {
        var arg = js.ToJS(data);
        arg.afterChange = afterChange;
        return js.New<IHandsontable>("Handsontable", grid, arg);
    }
}
@page "/"
@using Blazor.DynamicJS
@inject IJSRuntime JS
<div @ref="_grid"></div>

@code {
    private ElementReference _grid;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        var js = await JS.CreateDymaicRuntimeAsync();
        dynamic window = js!.GetWindow();

        const string COL_EDIT = "edit";
        const string COL_SELECT = "select";
        const string COL_PRODUCTCODE = "productCode";
        const string COL_PRODUCTNAME = "productName";
        const string COL_UNITPRICE = "unitPrice";
        const string COL_COMMENT = "comment";
        const string EDIT_MARK = "*";

        IHandsontable? hot = null;
        hot = js.CreateHandsontable(_grid,
            new InitialData
            {
                Data = new object[0],
                ColHeaders = new[] { "編集", "選択", "商品CD", "商品名", "単価", "備考" },
                Columns = new Col[]
                {
                new Col{ Data = COL_EDIT, ReadOnly = true, Type = "text" },
                new Col{ Data = COL_SELECT, Type = "checkbox" },
                new Col{ Data = COL_PRODUCTCODE, Type = "text", Width = 80 },
                new Col{ Data = COL_PRODUCTNAME, Type = "text", Width = 200, ClassName = "htLeft htMiddle" },
                new Col{ Data = COL_UNITPRICE, Type = "numeric", NumericFormat = new { pattern = "0,00", culture = "ja-JP" } },
                new Col{ Data = COL_COMMENT, Type = "text", Width = 300, ClassName = "htLeft htMiddle" }
                },
                EnterMoves = new EnterMoves { Row = 0, Col = 1 },
                OutsideClickDeselects = true,
                ManualColumnResize = true,
                FillHandle = false,
            },
            (changes, source) =>{
                if (source == "loadData") return;

                for (var i = 0; i < (int)changes.Length; i++)
                {
                    var change = changes[i];
                    // 編集と選択は対象外
                    if (change[1] == COL_EDIT || change[1] == COL_SELECT) continue;
                    // 変更前と変更後が同じは対象外
                    if (change[2] == change[3]) continue;
                    // 編集に"*"を付ける
                    hot?.SetDataAtCell((int)changes[0][0], 0, EDIT_MARK);
                }
            }
        );

        hot.LoadData(new List<ProductMaster>()
        {
            { new ProductMaster() { Edit = "", Select = false, ProductCode = "S0001", ProductName = "りんご", UnitPrice = 100, Comment = "青森産" } },
            { new ProductMaster() { Edit = "", Select = false, ProductCode = "S0002", ProductName = "みかん", UnitPrice = 80, Comment = "静岡産" } },
            { new ProductMaster() { Edit = "", Select = true,  ProductCode = "S0003", ProductName = "メロン", UnitPrice = 1000, Comment = "袋井クラウンメロン" } }
        });
    }
}

ちょっと残念なのは = まだはうまく表現できないので source と changesの 末端は dynamic のままにしています。

C#だけでJavaScriptを書く! Blazor.DynamicJSを作りました ②Handsontable

2023/03/03 にある meetup app osaka@7 で話すやつです。
meetupapp.connpass.com

年末のBlazorのアドベントカレンダーでHandsontableをBlazorで使う話が書かれてたのでやってみました。若干非効率ながら全部C#で書けました!コードはそれを参考にさせてもらいました。(てかそのJSをC#に書きなおさせてもらいました)
qiita.com

@page "/"
@using Blazor.DynamicJS
@inject IJSRuntime jsRuntime
<div @ref="_grid"></div>

@code {
    private ElementReference _grid;
    class Col
    {
        public object? data { get; set; }
        public object? readOnly { get; set; }
        public object? width { get; set; }
        public object? className { get; set; }
        public object? type { get; set; }
        public object? numericFormat { get; set; }
    }

    public class ProductMaster
    {
        public string? Edit { get; set; }
        public bool Select { get; set; }
        public string? ProductCode { get; set; }
        public string? ProductName { get; set; }
        public int UnitPrice { get; set; }
        public string? Comment { get; set; }
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;
        var js = await jsRuntime.CreateDymaicRuntimeAsync();

        const string COL_EDIT = "edit";
        const string COL_SELECT = "select";
        const string COL_PRODUCTCODE = "productCode";
        const string COL_PRODUCTNAME = "productName";
        const string COL_UNITPRICE = "unitPrice";
        const string COL_COMMENT = "comment";
        const string EDIT_MARK = "*";

        //コンストラクタ引数をJavaScriptの世界につくる
        var arg = js.ToJS(new
        {
            data = new object[0],
            colHeaders = new[] { "編集", "選択", "商品CD", "商品名", "単価", "備考" },
            columns = new Col[]
            {
                new Col{ data = COL_EDIT, readOnly = true, type = "text" },
                new Col{ data = COL_SELECT, type = "checkbox" },
                new Col{ data = COL_PRODUCTCODE, type = "text", width = 80 },
                new Col{ data = COL_PRODUCTNAME, type = "text", width = 200, className = "htLeft htMiddle" },
                new Col{ data = COL_UNITPRICE, type = "numeric", numericFormat = new { pattern = "0,00", culture = "ja-JP" } },
                new Col{ data = COL_COMMENT, type = "text", width = 300, className = "htLeft htMiddle" }
            },
            enterMoves = new { row = 0, col = 1 },
            outsideClickDeselects = true,
            manualColumnResize = true,
            fillHandle = false,
        });

        //コールバックで使うので先に宣言
        dynamic? hot = null;

        //オブジェクトの中にシリアライズできないメンバーは入れれないので別途ここで入れる
        arg.afterChange = (Action<dynamic, dynamic>)((changes, source) =>
        {
            if (source == "loadData") return;

            //これほんとは重いからやらない方がいい
            //changesの型を調べてシリアライズして持ってきた方がいい
            for (var i = 0; i < (int)changes.length; i++)
            {
                var change = changes[i];
                // 編集と選択は対象外
                if (change[1] == COL_EDIT || change[1] == COL_SELECT) continue;
                // 変更前と変更後が同じは対象外
                if (change[2] == change[3]) continue;
                // 編集に"*"を付ける
                hot?.setDataAtCell(changes[0][0], 0, EDIT_MARK);
            }
        });

        hot = js.New("Handsontable", _grid, arg);

        var products = new List<ProductMaster>()
        {
            { new ProductMaster() { Edit = "", Select = false, ProductCode = "S0001", ProductName = "りんご", UnitPrice = 100, Comment = "青森産" } },
            { new ProductMaster() { Edit = "", Select = false, ProductCode = "S0002", ProductName = "みかん", UnitPrice = 80, Comment = "静岡産" } },
            { new ProductMaster() { Edit = "", Select = true,  ProductCode = "S0003", ProductName = "メロン", UnitPrice = 1000, Comment = "袋井クラウンメロン" } }
        };

        hot.loadData(products);
    }
}

コールバックの中の処理がちょっと非効率。ループ回るたびにJSの世界とやり取りしてるんですよね・・・。これはchangesがシリアライズできたらいんですけどね、これ複数の型が入ってる配列だから今うまくシリアライズできないんですよねー。なんかいい方法考えよう。

C#だけでJavaScriptを書く! Blazor.DynamicJSを作りました ①

2023/03/03 にある meetup app osaka@7 で話すやつです。
meetupapp.connpass.com

BlazorでちょいちょいJavaScript書くときありますよね、でもちょっとしたやつやからJavaScript別に書くの面倒なんですよねー、でDynamic使ったらラップできるんじゃないかってことでやってみました。Nugetにも公開しててコードはこちら
github.com

こんな感じで書けます。

//JavaScriptを書くためのオブジェクト作成
using var js = await JS.CreateDymaicRuntimeAsync();

//ここからはdynamicでJavaScript書けます。
dynamic window = js.GetWindow();

dynamic button = window.document.createElement("button");
button.innerText = "new button";

dynamic div = window.document.getElementById("parent-div");
div.append(button);

//コールバックも書けます
button.addEventListener("click", (Action<dynamic>)(e => {
    string detail = e.detail.toString();
    window.console.log($"clicked {detail}");
}));

結構頑張っててコールバックもかけるんですよね。「遅いんじゃないの?」って言われそうですけど、まあそのとおりですねw、一行につき一回くらJavaScriptの呼び出しが入ります。とはいえそんな速くはないけどWebAssemblyだとこれくらいの行数やったら実使用上は問題ないんちゃうかな?

グローバルなやつだけじゃなくてimportもこんな感じで書けます。

using var js = await JS.CreateDymaicRuntimeAsync();

//絶対パスを指定してね
dynamic mod = await _js!.ImportAsync("/module.js");

//module.js に sum ってメソッドがあるとして
int sum = mod.sum(1, 2, 3, 4);

いい感じの書き心地ですよね。ここまではよかったんですけどね・・・、new と 非同期がいまいち。まずは new

<script>
    Rectangle: class {
        constructor(height, width) {
            this.height = height;
            this.width = width;
        }
    }
 </script>
using var js = await JS.CreateDymaicRuntimeAsync();
dynamic window = js.GetWindow();

//new XXX ってやる部分を JSSynctaxで囲む
dynamic rect = new JSSyntax(window.Rectangle).New(10, 20);

//moduleの場合も同様
dynamic mod = await _js!.ImportAsync("/module.js");
//moduleにもRectangleが定義されているとして
dynamic rect2= new JSSyntax(mod.Rectangle).New(10, 20);

//あんまりなんでグローバルのクラスはこう書けるようにもしてます
var rect3 = js.New("Rectangle", 1, 2);

JSSyntaxってのが苦肉の策。ここはシンタックスなんですよー、シンタックスに対する操作なんですよーって感じ。非同期も同様に JSSyntax で囲むようにしました。

using var js = await JS.CreateDymaicRuntimeAsync();
dynamic window = js.GetWindow();

dynamic div = window.document.getElementById("parent-div");

await new JSSyntax(div.append).InvokeAsync(button);

//コールバックも非同期にできます
button.addEventListener("click", (Func<dynamic, Task>)(async e =>
{
    await Task.CompletedTask;
    string detail = e.detail.toString();
    window.console.log($"clicked {detail}");
}));

でも正直 WebAssemblyの方は別にasyncでなくてもええんちゃう?って思って僕は非同期使ってないけどどうなんやろ?あとmodule使うにせよmoduleのクラスをC#からnewすることってないから(グローバルはjs.New()でいけるようにしたし)僕が使う分には許容範囲の書き心地です。

Blazor.DynamicJS管理下のJavaScriptオブジェクト

dynamicで受けたやつは何かというと以下二つです。
①呼び出し情報
JavaScriptオブジェクト(numberとかも含む)

②はそれに対して操作することもできるし、それがJsonにできるものであればC#の世界に持ってこれます。

dynamic window = js.GetWindow();

//これはまだJavaScriptの呼び出しは発生していないくて、"window.document"っていう文字列の呼び出し情報
dynamic document = window.document;

//ここで JavaScript を呼び出して戻り値のボタンは Blazor.DynamicJSのヘルパJavaScriptで管理していてそのIDを返してます。
dynamic button = window.document.createElement("button");

//buttonを操作するときはそのIDとプロパティ名とかで呼び出す感じ
button.innerText = "new button";

//dynamic以外の型に変換するとシリアライズされて持ってこれる
string innerText = button.innerText;

引数

引数はもともとの IJSRuntime に渡せるもの(Jsonにできるものと ElementReference)と前述したJavaScriptオブジェクトを渡せます。

dynamic button = window.document.createElement("button");
dynamic div = window.document.getElementById("parent-div");
//引数にJavaScriptのオブジェクトを渡してます。
div.append(button);

実はFriendlyと同じ設計思想

Friendlyは別プロセスのメソッドをリフレクションで呼び出して、これはJavaScriptに対して同じ思想でやってるだけでした。オブジェクトの管理とかシリアライズとか引数のルールとかも実は同じです。

連載物です。

「ちょっとやってみるかなー」って軽く始めたけど結構ガッツリ実装ました。これは dynamic を使って緩く書く書き方なんですけど、DispatchProxy で型を決めて呼び出す方式も実装しちゃったんですよね。なんであと何回か書きます。内部のつくりとかも書けたら書きます。

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();
}