問題点

ReScriptをSvelteで利用する場合、ReScript自体はES6でビルドする必要が有る。
ReScriptを"module": "es6"でビルドさえすれば、Svelte自体は問題なく動作するし、ReScript普通にSvelteから扱える。
が、ReScriptのユニットテスを実行したい場合、単純には出来ない。
これは、テストを実行するのはNodeなのでES6じゃなくてcommonjsじゃないといけないため。

前提条件

ReScriptのテストはbs-jestを使う。
ReScriptでは、bsconfig.jsbs-dev-dependenciesで指定したモジュールについてもbsb -make-worldでプロジェクトの他のコード同様に毎回トランスパイルされる。
その際、もしその外部ライブラリが"module": "commonjs"を指定していても、プロジェクトのbsconfig.json"module": "es6"が優先されて、その外部ライブラリもES6としてトランスパイルされる。

そのおかげで、ReScriptの外部ライブラリの設定を気にせずに自分の環境(commonjs or es6)に合った方法でビルド&利用できる。

必要なライブラリと設定ファイル

最終的に、うまく行く手順で必要なライブラリは以下。

npm install --save-dev @babel/core @babel/preset-env @babel/plugin-transform-modules-commonjs @glennsl/bs-jest jest babel-jest replace-in-file

Jestを実行するのに必要な情報をjest.config.jsonに記載する。

module.exports = {
  testMatch: ["**/test/rescript/**/*.?(m)js"],
  moduleFileExtensions: ["js", "jsx", "mjs"]
};

テストが実行できない

SvelteでReScriptを使うためにES6でビルドしたので、コマンドラインからテストツール(今回はjest)を実行すると、(npx jestで実行できる)

SyntaxError: Cannot use import statement outside a module

と表示されて実行できない。
コレは、jest(bs-jest)もReScript自体じゃなくてトランスパイルしたJavaScriptをテストとしてて実行するため。
当然そのReScriptで書いたテストもES6としてトランスパイルされいるので、commonjsで動作するNodeではimport文が利用できない。
そのため上記のエラーが発生する。

対策

案1. bs.jsではなく、mjsにする

bs.configを以下のように設定する。

    "package-specs": {
        "module": "es6",
        "in-source": true
    },
   "suffix": ".mjs",

そして、以下のようにテストを実行する。

NODE_OPTIONS=--experimental-vm-modules npx jest

コレでテストは実行できる。

問題点

NODE_OPTIONに指定しているオプションは正式にリリースされたものではないので、今後使えなくなる可能性がある。
そして何より、genTypeが使えなくなる。
genTypeはbs.jsというファイル名で終わっていることが前提になっている。
Sveleから簡単にReScriptを扱いたくてgenTypeを導入したいので、それが出来ないのであれば全く意味がない。
Svelteを起動しようとすると以下のようなエラーになって起動すらしない。

[!] Error: Could not resolve './HelloReScript.bs' from src/rescript/HelloReScript.gen.tsx

案2. es6じゃなくてcommonjsにする

bsconfig.jsを以下のように設定する。

    "package-specs": {
        "module": "commonjs",
        "in-source": true
    },
   "suffix": ".bs.js",

コレでテストは何も考えずに実行できるようになる。

[koji:svelte-typescript-app]$ npx jest                                       
 PASS  test/rescript/testsub/SampleTest2.bs.js
 PASS  test/rescript/SampleTest.bs.js

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.906 s, estimated 1 s
Ran all test suites.
[koji:svelte-typescript-app]$ 

問題点

Svelteは起動するけど、ブラウザで確認すると何も表示されない。
ブラウザのデバッグコンソールを見ると

Uncaught ReferenceError: require is not defined

と表示されている。
どうやらgenTypeで生成されたTypeScriptファイル内でrequireが使われている事が問題になっている。
ただ、その中でimport文も使われているので、「ESかCommonjsか分からん」という状態になってSvelteの起動段階でES6に変換しきれていないと思われる。
genTypeのバグの可能性(requireとimportが混在)もあるので、いつかこの対策でも動作するようになるかも?
https://github.com/rescript-association/genType/issues/519

案3. 自分でトランスパイルする(解決策)

今回問題になっているのは、テスト用のReScriptもES6としてトランスパイルされる、という点のみ。
(ちなみに前提条件で述べた通り、JestのReScriptバインディングのbs-jestも同様にES6になってしまっている)

なので、テスト実行時に毎回、

  • bs-jestとjest用のテストファイル群をES6からcommonjsに変換する
  • be-jest内でes6のライブラリを見ている部分が有るので、それをjs用のパスに置換する

という2つの処理を実行すれば、テストを実行できるようになる。
ということで、上記の処理をrescript_es_to_cjs_for_test.jsという名前で以下のようにする。

const replace = require('replace-in-file');
const babel = require("@babel/core");
const fs = require("fs")
const bsJest = "node_modules/@glennsl/bs-jest/src/jest.bs.js"
const testDir = "test/rescript"

function esToCjs(fileName) {
    let code = fs.readFileSync(fileName, 'utf-8');
    const babelObj = babel.transform(code, {
        plugins: ["@babel/plugin-transform-modules-commonjs"]
    });
    return babelObj.code;
}

function overwrite(fileName, code) {
    fs.writeFileSync(fileName, code);
}

function listFiles(dir) {
    let files = fs.readdirSync(dir, { withFileTypes: true }).flatMap(dirent => {
        const name = dirent.name;

        if (dirent.isFile()) {
            if (name.endsWith("bs.js")) {
                return [`${dir}/${name}`];
            } else {
                return [];
            }
        } else {
            return listFiles(`${dir}/${name}`);
        }
    });
    return files;
}

// 全てのReScriptテストファイルとbs-jestをES6からCommonJSに変換
const testFiles = [bsJest, ...listFiles(testDir)]
testFiles.forEach(fileName => {
    const code = esToCjs(fileName);
    overwrite(fileName, code);
})

// bs-jestはbs-platformのes6ライブラリを使っている。
// なので、その部分を"js"に変換する必要が有る。(/bs-platform/lib/js/がcommonjs用)
try {
    const options = {
        files: bsJest,
        from: /bs-platform\/lib\/es6*/g,
        to: 'bs-platform/lib/js',
    };
    const results = replace.sync(options);
}
catch (error) {
    console.error('Error occurred:', error);
}

これで、このスクリプトを実行してjestを実行するようにすればテストが実行できる。

[koji:svelte-typescript-app]$ node rescript_es_to_cjs_for_test.js && npx jest
 PASS  test/rescript/testsub/SampleTest2.bs.js
 PASS  test/rescript/SampleTest.bs.js

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        1.007 s
Ran all test suites.
[koji:svelte-typescript-app]$ 

まとめ

最後の解決策を取ることで、Svelte用にReScriptを利用して開発が出来てbs-jestでReScriptのテストも出来る状態になった。

通常のJavaScriptプロジェクトであれば、テスト時のみES6じゃなくてcocmmonjsでビルドする、ということが簡単に出来る。
ES6+Babel7環境でJestする方法
しかし、ReScriptの場合はbsconfig.jsonの中身でビルドする方法を指定するので、このように自動でテスト時のみcommonjsでビルドする、ということは出来ない。(手動でbsconfig.jsをテスト時のみ書き換えれば出来るけど)

もしBabelで自動でcommonjsに変換できても、be-jest内のes6jsに置換する処理は別途必要になる。
Babelのプラグインでファイル内の文字列を置換するものは有ったが、特定のファイルやディレクトリ内のみ、という指定が出来ず、プロジェクト全体が無条件で対象になるようだったので使えなかった。

なので、今回は手動で必要なファイル、ディレクトリ内のファイルのみcommonjsにトランスパイルして必要なファイルのみ文字列を置換するというスクリプトを作成した。

理想としては、上記の記事にようにテスト時のみ自動でcommonjsでビルドする、ということが出来るようになったら嬉しい。