ささいなことですが。

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

Friendly.Windows.NativeStandardControls2.5.0をリリースしました

Win32(MFCも含む)用のNativeStandardControlsに3年ぶりくらいに機能追加です。
メニューのユーティリティを追加しました。

NativeMenuItem

こんな感じで使います。
f:id:ishikawa-tatsuya:20180503154410p:plain

[TestMethod]
public void SampleWindowMenu()
{
    var app = new WindowsAppFriend(Process.Start(TargetPath.NativeControls));
    var main = WindowControl.FromZTop(app);

    //ウィンドウの持っているメニューから検索
    var b0 = NativeMenuItem.GetMenuItem(main, "B0");
    //クリックエミュレート
    //これは Friendly.Windows.KeyMouse の拡張メソッド
    b0.Click();

    //押すとポップアップが表示されるのでそこから取得
    var b01 = NativeMenuItem.GetPopupMenuItem(app, "B0-1");
    b01.Click();

    //さらにその先
    var b011 = NativeMenuItem.GetPopupMenuItem(app, "B0-1-2");
    b011.Click();
}

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

[TestMethod]
public void SampleContextMenu()
{
    var app = new WindowsAppFriend(Process.Start(TargetPath.NativeControls));
    var main = WindowControl.FromZTop(app);

    //右クリック
    main.Click(MouseButtonType.Right, 100, 100);

    //コンテキストメニューから
    var a00 = NativeMenuItem.GetPopupMenuItem(app, "A0-0");
    a00.Click();
}

有効無効とIdも取得できます。WM_COMMANDを使ってメッセージを送ることも可能です。

[TestMethod]
public void SampleSendMessage()
{
    var app = new WindowsAppFriend(Process.Start(TargetPath.NativeControls));
    var main = WindowControl.FromZTop(app);
    main.Click(MouseButtonType.Right, 5, 5);
    var p0 = NativeMenuItem.GetPopupMenuItem(app, "A0-0");

    //p0.Click();
    //ほとんどの場合はClickで問題ない
    //内部的に対象プロセスがタイマメッセージを拾えるようになるまで待つので
    //発生するイベントの完了を待ち合わすことができる

    //そのイベント内部で自分でメッセージループ回したりするような場合は、イベントの終了を待てない
    //そのような場合はこちらが同期をとりやすい
    if (p0.Enabled)
    {
        const int WM_COMMAND = 0x111;
        main.SendMessage(WM_COMMAND, new IntPtr(p0.Id), IntPtr.Zero);
    }

    //それからモーダルダイアログが表示される場合は抜けてくるが
    //その場合は表示されるダイアログの完了をまてば問題なく同期がとれる場合が多いが
    //変わった処理がされている場合は上記をasyncを渡して対応するとよい
}

ちなみにFriendlyの提供するSendMessageは特殊で、対象のスレッドでSendMessageを実行させるという方式になっています。別スレッド(プロセス)からSendMessageすると想定外のタイミング(対象スレッドが特定のAPI使っている最中に割り込むとか)で割り込んでトラブルが発生する場合があるからです。ダイアログが出てくる場合などはSendMessageなのに非同期で実行できます。これは対象のプロセスにSendMessageを実行させる箇所を非同期にしています。その処理が完了することはasyncオブジェクトで監視することができます。

var async = new Async();
main.SendMessage(WM_COMMAND, new IntPtr(p0.Id), IntPtr.Zero, async);

var dlg = main.WaitForNextModal();
dlg.Close();

//発生するイベントの完了を確実に待ち合わすことができる
async.WaitForCompletion();

必要?

そうなんですよ。テスト自動化用なんでIDも送信先のウィンドウもわかってるんです。実行するにはなくても良いのです。

なんで作った?

SendMessageでWM_COMMANDを送るのは確実に動作して良いのですが、逆に言えばどんな時でも実行できてしまうというのもあります。メニューが存在してなかったり、無効だったりした場合でも実行できてしまうのです。実はネイティブアプリの場合は、ここは手動でやってもらったり、別口をDLL公開関数で作ってもらったりで対応してました。あと、この方針はキーマウスエミュレートと組み合わせて使うので去年まではあんまり推奨してなかったのですよね。でも、去年キーマウスエミュレートでも同期のとれる方法を思いついて、ちょくちょく使ってるのでこちらも頑張ってみるかという流れです。

おまけ

さっきのSendMessageはこんな感じで書きたいですよね。

//これ相当のことを一発でやりたい
//if (p0.Enabled)
//{
//	main.SendMessage(WM_COMMAND, new IntPtr(p0.Id), IntPtr.Zero);
//}
p0.Execute();

それでこれをやるためにはメニューハンドルから送信先のウィンドウハンドルを引っ張ってくる必要があるのですが、(もちろん p0.Execute(main) とか引数付けたらいいんですけど、それはイマイチなのでやりたくない)でもちょっと調べた感じではメニューハンドルから送信先のウィンドウハンドルを逆引きするAPIはないようです。
それでも、@さんに聞いたところフックしてWM_INITMENUPOPUPを見張ればいいんじゃない?とのご意見をいただいたのでやってみました。(その他ご意見いただいた@さん、@さん、@さんもありがとうございます!)
Friendlyを使うとフックもこんなに簡単に書けちゃうんですよー。

[TestMethod]
public void GetLastPopupOwnerSample()
{
    //dllインジェクション
    app.LoadAssembly(GetType().Assembly);
    //フックするクラスを対象プロセス内部に生成
    var hooker = app.Type<Hooker>()();

    //ポップアップ表示
    main.Click(MouseButtonType.Right, 5, 5);

    //送信先ウィンドウハンドル取得
    IntPtr sendWnd = hooker.LastPopupOwner;
}

public class Hooker
{
    const int WM_INITMENUPOPUP = 0x0117;
    const int WH_CALLWNDPROC = 4;

    delegate IntPtr HookProc(int code, IntPtr wParam, IntPtr lParam);

    [DllImport("user32.dll")]
    static extern IntPtr SetWindowsHookEx(int hookType, HookProc lpfn, IntPtr hMod, int dwThreadId);

    [DllImport("user32.dll")]
    static extern IntPtr CallNextHookEx(IntPtr hookHandle, int nCode, IntPtr wParam, IntPtr lParam);

    [DllImport("kernel32.dll")]
    static extern int GetCurrentThreadId();

    [StructLayout(LayoutKind.Sequential)]
    struct CWPSTRUCT
    {
        public IntPtr wparam;
        public IntPtr lparam;
        public int message;
        public IntPtr hwnd;
    }

    HookProc _traceProc;
    IntPtr _idHook;

    public IntPtr LastPopupOwner { get; set; }

    public Hooker()
    {
        var threadId = GetCurrentThreadId();
        _traceProc = WindowProcHook;
        _idHook = SetWindowsHookEx(WH_CALLWNDPROC, _traceProc, IntPtr.Zero, threadId);
    }

    ~Hooker() => GC.KeepAlive(_traceProc);

    IntPtr WindowProcHook(int hookCode, IntPtr wParam, IntPtr lParam)
    {
        if (hookCode < 0)
        {
            return CallNextHookEx(_idHook, hookCode, wParam, lParam);
        }

        var msg = (CWPSTRUCT)Marshal.PtrToStructure(lParam, typeof(CWPSTRUCT));
        if (msg.message == WM_INITMENUPOPUP)
        {
            LastPopupOwner = msg.hwnd;
        }
        return CallNextHookEx(_idHook, hookCode, wParam, lParam);
    }
}

で取ることには成功したのですが、フック開始をユーザーに明示的にやらせるのかとか、あとやりたいことの割に仕掛けが大げさなので一旦ライブラリにはいれないことにしました。(やりたい人はこのコードを使ってね)もっとサクッとメニューハンドルから送信先のウィンドウハンドル取れたらいいんですけどねー。内部的には知ってるはずだよなー。なんかないかなー。