☄️

storycap + reg-suit で VRT を実行する

2023/11/09に公開

はじめに

こんにちは、CastingONE 開発部です。
CastingONE では、Nuxt.js から Next.js へのリプレイスが進行中で、加えてテストの拡充も進められています。その一環で、Next.js のプロジェクトでは Storybook を用いた VRT を導入しました。

ちなみに移行についてはこちらの記事で詳しく書かれています。
https://zenn.dev/castingone_dev/articles/9321f0f590f0b9

今回は、導入するまでに検討したことや遭遇した問題、そしてそれらの問題をどのように解決したのかについてご紹介できればと思います。

導入まで

導入の背景としては、既にある Story 資産をそこまで利活用出来ていなかったということが大きいと思います。基本的に、コンポーネントを実装する時に並行して Story も作成するようにしているのですが、作った Story は視覚的に操作するだけのコンポーネントカタログとしての利用にとどまっており、テスト資材としてほとんど活用していなかったという状況でした。

そんな状況だった時に、Next.js 移行の話が出てきたのと相まって Story を活用した VRT を導入してみようとなりました。本来的な話で言えば、移行初期は色々なものが固まりきっていない時期故に変更が多くなりがちで、そういう時期の VRT 導入はあまり効果を発揮出来ないとされています。VRT はプロジェクトがある程度成熟してきてから導入するのが良いでしょう。

結果的に導入したのは、storycapreg-suit を用いたやり方ですが、VRT の手法は他にもいくつか存在します。どういう手法が存在するのかについては以下の記事が大変わかりやすかったです。
https://www.wantedly.com/companies/loglass/post_articles/463738

storycap + reg-suit に決定する前に、Chromatic を使う手法も検討しました。採用を見送った理由としては以下になります。

  • 有料のサービスであり、無料プランでも 1 ヶ月あたり 5,000 snapshots の枠があるが簡単に到達しそう
  • 運用次第だが、Chromatic 側でもレビューするとなると GitHub 上で完結しなくなり手間が増える
  • 何件差分があったかという情報が Chromatic 側でないとわからない

以上が主な理由で、storycap + reg-suit を採用すればこれらの問題は解消することが可能です。

VRTの全体像

導入した VRT の全体像としては以下の図のようになります。
VRTの全体像の図

基本的な流れとしては

  1. PR 作成時に VRT の CI が走り、その CI 上でヘッドレスブラウザを起動させ srotycap がそのコミットのスクリーンショットを取得
  2. 取得し終えたら、reg-suit が比較対象のコミットのスクリーンショットを保存先ストレージからダウンロード
  3. 差分比較・検知を実行
  4. 新しいコミットのスクリーンショットを新規保存
  5. 差分比較結果を該当の PR にコメントする

これだけ見ると設定が大変なように見えますが、ダウンロードから、差分比較・実行、新規保存するところまで、reg-suit は reg-suit run だけで全て実行してくれます。以下のようにそれぞれのコマンド自体は存在するので、処理を切り離してカスタマイズすることも可能です。

$ reg-suit sync-expected # 比較対象のスクリーンショットを取得する
$ reg-suit compare # 差分比較・検知
$ reg-suit publish -n # 新しいスクリーンショットを保存する

CastingONE ではインフラ基盤に Google Cloud Platform を採用しています。そのため、スクリーンショットは Google Cloud Storage に保存するようにしました。この辺りのセットアップも、reg-suit のプラグインを導入するだけで簡単に実現することができます。
https://github.com/reg-viz/reg-suit#plugins

ちなみに、CastingONE で利用しているプラグインは以下の3つです。

プラグイン 役割
reg-keygen-git-hash-plugin どのコミットハッシュと比較すべきかを特定する
reg-notify-github-plugin GitHub の PR にコメントする
reg-publish-gcs-plugin Google Cloud Storage とのやり取り

遭遇した問題とその解決

モノレポで Storybook を分けた際に reg-suit のコメントも一緒に分けたい

CastingONE のフロントエンド構成は以下のようになっています。yarn workspace を利用しており、packages 配下のリソースを apps 配下で共通で使用しています。

.
├── .storybook
├── apps
│   ├── appA
│   │   └── package.json
│   └── appB
│       └── package.json
├── packages
│   ├── packageA
│   │   └── package.json
│   ├── packageB
│   │   └── package.json
│   └── packageC
│       └── package.json
├── regconfig.json
├── package.json
└── yarn.lock

当初は apps 配下のコンポーネントや packages 配下のコンポーネント に存在する全てのコンポーネントを一つの Storybook として管理しておりましたが、コンポーネントの数が膨大になったために Storybook を分割し、以下のような構成にすることにしました。

 .
 ├── .storybook
 ├── apps
 │   ├── appA
+│   │   ├── .storybook
+│   │   ├── regconfig.json
 │   │   └── package.json
 │   └── appB
+│       ├── .storybook
+│       ├── regconfig.json
 │       └── package.json
 ├── packages
 │   ├── packageA
 │   │   └── package.json
 │   ├── packageB
 │   │   └── package.json
 │   └── packageC
 │       └── package.json
 ├── regconfig.json
 ├── package.json
 └── yarn.lock

packages 配下のコンポーネントは ルート直下の Storybook で管理し、apps 配下のコンポーネントは各 app の Storybook で管理するようにしました。こうすることで、Story 自体は分けて確認できるようになりました。加えて、regconfig.json も各場所に設置したので reg-suit の PR に対するコメント も Storybook ごとにを分けてくれるかと思ったのですが、一個しかコメントがされませんでした。

reg-suitがPRにコメントしている画像

上述した通り、GitHub PR コメントへの通知は、reg-notify-github-plugin というプラグインを使用しています。コメントの仕方を任意で設定することができるのですが、

prCommentBehavior - Optional - How the plugin comments to your pull requests. Enabled values are the following. Default: default.

"default" : Update the PR comment if exists. Otherwise post new comment.
"new" : Delete existing old comment and post new comment.
"once" : Does nothing if the PR comment exists.

  • "default" : PRコメントがあれば更新する。そうでなければ新しいコメントを投稿する
  • "new" : 既存の古いコメントを削除し、新しいコメントを投稿する
  • "once" : PRコメントが存在する場合は何もしない

という挙動しか設定することが出来ませんでした。つまりコメントの絶対数は 1 以外にならず、確認するとしたらコメントの編集履歴を辿るほかありませんでした。

どのように解決したか

編集履歴を辿るやり方はあまりにもやりづらいので、別の方法が無いか検討したところ GitHub にコメントするところに関しては、reg-notify-github-plugin を使わずに自前で作ることにしました。それについては、こちらの記事を参考にしました。
https://developers.cyberagent.co.jp/blog/archives/29784/

具体的には、GitHub に通知するスクリプトを書いてそれを CI から実行するというだけです。実際に書いたスクリプトはこのような感じです。

/* eslint-disable no-console */
const fs = require('fs')
const path = require('path')
const fetch = require('node-fetch')

const {
  GITHUB_SHA1,
  GITHUB_PULL_REQUEST,
  GITHUB_PROJECT_USERNAME,
  GITHUB_PROJECT_REPONAME,
  GITHUB_PR_NUMBER,
  GITHUB_REPOSITORY_ACCESS_TOKEN,
} = process.env

console.log('GITHUB_SHA1', GITHUB_SHA1)
console.log('GITHUB_PULL_REQUEST', GITHUB_PULL_REQUEST)
console.log('GITHUB_PROJECT_USERNAME', GITHUB_PROJECT_USERNAME)
console.log('GITHUB_PROJECT_REPONAME', GITHUB_PROJECT_REPONAME)
console.log('GITHUB_PR_NUMBER', GITHUB_PR_NUMBER)
console.log('GITHUB_REPOSITORY_ACCESS_TOKEN', GITHUB_REPOSITORY_ACCESS_TOKEN)

if (!GITHUB_PR_NUMBER && !GITHUB_PULL_REQUEST) {
  console.log('VRT結果コメントの追加先Pull Requestがありません')
  process.exit(0)
}

function createRegReport() {
  const regOutJsonStr = fs.readFileSync(
    path.join(__dirname, './.reg/out.json'),
    'utf-8'
  )
  const regOutJson = JSON.parse(regOutJsonStr)

  const newItemCount = regOutJson.newItems.length
  const diffItemCount = regOutJson.diffItems.length
  const deletedItemCount = regOutJson.deletedItems.length
  const passedItemCount = regOutJson.passedItems.length

  const regConfigJsonStr = fs.readFileSync(
    path.join(__dirname, './regconfig.json'),
    'utf-8'
  )
  const regConfigJson = JSON.parse(regConfigJsonStr)

  const regGcsBaseUrl = 'https://storage.googleapis.com'
  const regGcsBucketName =
    regConfigJson.plugins['reg-publish-gcs-plugin'].bucketName
  const regCustomPathPrefix =
    regConfigJson.plugins['reg-publish-gcs-plugin'].pathPrefix
  const wordToJudgeIsRegReport =
    regCustomPathPrefix + ' の Storybook スクリーンショットに差分'

  if (newItemCount === 0 && diffItemCount === 0 && deletedItemCount === 0) {
    return {
      regReport: `${wordToJudgeIsRegReport}はありません :sparkles:`,
      wordToJudgeIsRegReport,
    }
  }

  return {
    regReport: `${wordToJudgeIsRegReport}があります。
| :red_circle:  Changed | :white_circle:  New | :black_circle:  Deleted | :large_blue_circle:  Passing |
| --- | --- | --- | --- |
| ${diffItemCount} | ${newItemCount} | ${deletedItemCount} | ${passedItemCount} |

[レポート](${regGcsBaseUrl}/${regGcsBucketName}/${regCustomPathPrefix}/${GITHUB_SHA1}/index.html)を確認してください。
- [ ] 差分に問題ないことを確認しました`,
    wordToJudgeIsRegReport,
  }
}

async function postReportCommentToPr({ regReport, wordToJudgeIsRegReport }) {
  const requestHeaders = {
    Authorization: `token ${GITHUB_REPOSITORY_ACCESS_TOKEN}`,
  }

  const fetchCommentsResult = await fetch(
    `https://api.github.com/repos/${GITHUB_PROJECT_USERNAME}/${GITHUB_PROJECT_REPONAME}/issues/${GITHUB_PR_NUMBER}/comments`,
    { headers: requestHeaders }
  ).catch(() => ({ ok: false }))

  const comments = fetchCommentsResult.ok
    ? await fetchCommentsResult.json()
    : []
  const regReportComments = comments.filter(({ body }) =>
    new RegExp(`^${wordToJudgeIsRegReport}`).test(body)
  )
  const commentIdToUpdate = regReportComments[regReportComments.length - 1]?.id

  // 既存レポートがない場合は、新規コメントを追加
  if (commentIdToUpdate === undefined) {
    console.log(`${GITHUB_PULL_REQUEST} にVRT結果コメントを追加します`)

    try {
      const postCommentResult = await fetch(
        `https://api.github.com/repos/${GITHUB_PROJECT_USERNAME}/${GITHUB_PROJECT_REPONAME}/issues/${GITHUB_PR_NUMBER}/comments`,
        {
          method: 'post',
          body: JSON.stringify({ body: regReport }),
          headers: requestHeaders,
        }
      )

      if (!postCommentResult.ok) {
        throw new Error(postCommentResult.statusText)
      }

      console.log('コメント追加成功しました')
    } catch (error) {
      console.error('コメント追加失敗しました', error)
    }

    return
  }

  // 既存レポートがある場合、既存コメントを更新
  console.log(`${GITHUB_PULL_REQUEST} のVRT結果コメントを更新します`)

  try {
    const patchCommentResult = await fetch(
      `https://api.github.com/repos/${GITHUB_PROJECT_USERNAME}/${GITHUB_PROJECT_REPONAME}/issues/comments/${commentIdToUpdate}`,
      {
        method: 'patch',
        body: JSON.stringify({ body: regReport }),
        headers: requestHeaders,
      }
    )

    if (!patchCommentResult.ok) {
      throw new Error(patchCommentResult.statusText)
    }

    console.log('コメント更新成功しました')
  } catch (error) {
    console.error('コメント更新失敗しました', error)
  }
}

postReportCommentToPr(createRegReport()).catch((error) => {
  console.error('VRT 結果通知失敗しました', error)
})

こうすることで、コメントを Storybook ごとに分けることが出来るようになりました。以下画像の、上のコメントが apps 配下の Storybook で、下が packages の Storybook になっています。
PRへのコメントを自前で設定した時の画像

おわりに

CastingONE では、フロントエンドテストを推進してくれるメンバーを大募集しています。カジュアル面談もやってますので、お気軽にご連絡ください!

https://www.wantedly.com/projects/1130967
https://www.wantedly.com/projects/768663

Discussion