からめもぶろぐ。

俺たちは雰囲気で OAuth をやっている

SPClientCore 4.1.0 を公開しました

SPClientCore 4.1.0 を公開しました。

SPClientCore は PowerShell 7 向けの SharePoint Online 管理モジュールです。

www.powershellgallery.com

変更点は以下の通りです。

  • Get-KshTenantSettings を Get-KshTenantAppCatalog に変更しました。
  • 任意のサイト コレクションのユーザーを管理するコマンドレットを追加しました。
  • プロパティを取得するコマンドレットを追加しました。

提供されているコマンドレットの数は 287 になりました。

jest.spyOn を呼び出すと TypeError: Cannot redefine property が発生する

そもそもは Microsoft Graph JavaScript Client Library (@microsoft/microsoft-graph-client) を 3.0.0 に上げたときに BatchResponseContent をモックするテストが失敗するようになったところから始まります。モックしようとしている BatchResponseContent のコードには何も変更はありません。ちょっとよくわからなかったので issue を上げてみました。

github.com

結論をいうと「3.0.0 から TypeScript 4 でビルドするようになったのでその影響ですよ」とのこと。なるほどとは思いながらも理解のために少し現象を追ってみました。

サンプル コード

github.com

中身は同じコードで TypeScript のバージョンだけが違う 2 つのプロジェクトを用意します。

src/HelloWorld.ts

export class HelloWorld {

  output() {
    console.log('Hello World');
  }

}

src/index.ts

export * from './HelloWorld';

tsconfig.json

{
  "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "declaration": true,
    "outDir": "dist"
  },
  "include": [
    "src"
  ]
}

テスト コード

上記で作成したプロジェクトを参照して jest.spyOn でモックします。

import * as typescript2 from 'typescript2';
import * as typescript4 from 'typescript4';

it('jest.spyOn() with typescript 2', () => {
  jest.spyOn(typescript2, 'HelloWorld');
});

it('jest.spyOn() with typescript 4', () => {
  jest.spyOn(typescript4, 'HelloWorld');
});

これを実行すると以下の結果になります。事象としては再現できていますね。TypeSctipt 2 ではテストが成功していますが TypeScript 4 ではテストが失敗します。

 FAIL  src/index.test.ts
  √ jest.spyOn() with typescript 2 (1 ms)
  × jest.spyOn() with typescript 4 (1 ms)

  ● jest.spyOn() with typescript 4

    TypeError: Cannot redefine property: HelloWorld
        at Function.defineProperty (<anonymous>)

       7 |
       8 | it('jest.spyOn() with typescript 4', () => {
    >  9 |   jest.spyOn(typescript4, 'HelloWorld');
         |        ^
      10 | });
      11 |

      at ModuleMocker.spyOn (node_modules/jest-mock/build/index.js:831:16)
      at Object.<anonymous> (src/index.test.ts:9:8)

ビルドされたコード

dist/index.js

TypeScript 2.9.2 でビルドしたコードが以下の通りです。

"use strict";
function __export(m) {
    for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p];
}
Object.defineProperty(exports, "__esModule", { value: true });
__export(require("./HelloWorld"));

TypeScript 4.4.3 でビルドしたコードが以下の通りです。

"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
    for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./HelloWorld"), exports);

明らかに違うよねというのは、__exportStar からそれぞれのプロパティに対して __createBinding を呼んでいて、最終的に Object.defineProperty が呼ばれるということ。ここで既定値の configurable: false が指定されて読み取り専用としてマークされてしまうことが、jest.spyOn でエラーになる根本原因と思われます。

まとめ

じゃあどうすればいいのよって話になるのですがいくつかのオプションがあります。

インポート先のファイルを index.js から個々のファイルに変更する

たとえば先のサンプルの場合は以下のようにすることで回避が可能です。__exportStar が呼ばれないので jest.spyOn が可能です。

import * as typescript2 from 'typescript2';
- import * as typescript4 from 'typescript4';
+ import * as typescript4 from 'typescript4/dist/HelloWorld';

it('jest.spyOn() with typescript 2', () => {
  jest.spyOn(typescript2, 'HelloWorld');
});

it('jest.spyOn() with typescript 4', () => {
  jest.spyOn(typescript4, 'HelloWorld');
});

ただしライブラリが webpack してる場合はこの方法はできないです。

ライブラリ側で export * をやめる

export するプロパティを指定してあげると __exportStar が生成されないので回避できるようです。

- export * from './HelloWorld';
+ export { HelloWorld } from './HelloWorld';

ただしライブラリを変更しないといけないので難しくはあります。

jest.spyOn をあきらめる

おとなしく jest.mock を使いましょう。

Teamtile 1.6.0 を公開しました

Teamtile 1.6.0 を公開しました。Teamtile は自分が参加しているチームの一覧をタイル状に表示する Microsoft Teams のアプリです。

github.com

機能変更については以下の通りです。バックエンドはほとんど書き直しました。

  • useReducer を使ってデータをグローバルに管理するようにしました。標準機能で Redux と同じことを実現しています。
  • IndexedDB のスキーマを更新しました。
  • チャネルやメンバーが 100 アイテム以上ある場合も取得できるようにしました。ページングは Microsoft Graph SDK が全部やってくれました。
  • 起動時にスケルトンを表示するようにしました。

C# で IBS-TH1 のバッテリー残量を取得する

blog.karamem0.dev

この記事の続き。ひとつ買ってなんとなくうまく稼働していたので、別の部屋のためにもうひとつ買ったりしていましたが、最初の個体が電池切れでお亡くなりになっているのに気付きました。だいたい 4 か月くらいですかね。ネットの情報では IBS-TH1 MINI のほうは 1 か月くらいしか持たないという情報もあるのでまあまあそんなものかなと。いずれにしても電池が切れるのに気付かないのもよくないのでバッテリー残量を取りたいよねという話。

スマートフォンのアプリではバッテリー残量を表示できているのでできなくはないことはわかっています。調べてみると GATT ではどうやってもバッテリー残量は取れなくて、アドバタイズで取るみたいです。あまり詳しくないので間違ってたら申し訳ないですが、アドバタイズと GATT の違いは UDP と TCP の違いみたいな感じでしょうか。アドバタイズはペリフェラル (今回でいうと IBS-TH1) が定期的にブロードキャストするパケットのことです。この Manufacture Specific というデータ領域にバッテリー残量が入っています。ただ、面倒なのは、アドバタイズにはパッシブ スキャンとアクティブ スキャンがあって、アクティブ スキャンでないと Manufacture Specific が入ってこないのと、Manufacture Specific の先頭 2 バイトの企業識別子は含まれないので、その点を考慮する必要があります。

言葉だけだとわからないのでコードを載せておきます。

public static class Program
{

    private const string MacAddress = "{{macaddress}}";

    private static async Task Main()
    {
        var watcher = new BluetoothLEAdvertisementWatcher();
        watcher.ScanningMode = BluetoothLEScanningMode.Active;
        watcher.Received += (sender, e) =>
        {
            var macAddress = string.Join(":",
                BitConverter.GetBytes(e.BluetoothAddress)
                    .Reverse()
                    .Select(x => x.ToString("X2")))
                .Substring(6);
            if (string.Equals(macAddress, MacAddress, StringComparison.OrdinalIgnoreCase) != true)
            {
                return;
            }
            var manifactureData = e.Advertisement
                .GetSectionsByType(BluetoothLEAdvertisementDataTypes.ManufacturerSpecificData)
                .Select(x => x.Data.ToArray()).FirstOrDefault();
            if (manifactureData == null)
            {
                return;
            }
            var t = (double)BitConverter.ToInt16(manifactureData, 0) / 100;
            var h = (double)BitConverter.ToInt16(manifactureData, 2) / 100;
            var b = manifactureData[7];
            Console.WriteLine("{0:s} 温度: {1}, 湿度: {2}, バッテリー: {3}", DateTime.Now, t, h, b);
        };
        watcher.Start();
        await Task.Delay(Timeout.Infinite);
    }

}

アクティブ スキャンの場合はアドバタイズ フレームの受信後にスキャン要求を送って追加の情報を取得する方法で、Manufacture Specific はこの追加の情報として入ってくるデータなので、BluetoothLEAdvertisementWatcher.Received イベントのすべてのイベント データに Manufacture Specific が入ってくるとは限りません。また、BluetoothLEAdvertisement.ManufacturerData プロパティは先頭 2 バイトを取った形でデータを返しますので、GetSectionsByType メソッドで生のデータを取ってくる必要があります。

実行するとこんな感じでデータが取れるはずです。

2021-09-05T16:17:26 温度: 27.36, 湿度: 62.99, バッテリー: 100
2021-09-05T16:21:32 温度: 27.35, 湿度: 62.99, バッテリー: 100
2021-09-05T16:21:34 温度: 27.35, 湿度: 62.99, バッテリー: 100
2021-09-05T16:21:36 温度: 27.35, 湿度: 62.99, バッテリー: 100
2021-09-05T16:21:40 温度: 27.35, 湿度: 62.99, バッテリー: 100
2021-09-05T16:22:33 温度: 27.35, 湿度: 62.99, バッテリー: 100

まあ確かに取れるには取れるのですが、取れるタイミングが非常に安定しない (上記の例だと 1 件目のあとに 3 分間空きがあってそのあと連続してデータが返ってくる) ため、扱いが非常に面倒くさい感じはありますね。