Zenn
🤖

Cursor で PR の作成から Golden Test まで実装する

2025/02/14に公開
16

初めに

今回は Cursor を用いて、Pull Request の作成や Flutter の Golden Test を実装する方法をまとめていきたいと思います。なお、筆者は GitHub Actions などの扱いに慣れているわけではないため、間違っている部分等あれば指摘していただければ幸いです。

記事の対象者

  • Flutter 学習者
  • Cursor で PR の作成を行いたい方
  • Cursor で Golden Test の実装を行いたい方

目的

今回の目的は、 Cursor でPRを作成する方法をまとめ、さらに同時に Golden Test を実行できるようにすることです。最終的には、以下の動画のように Cursor に変更内容のプッシュを依頼することで Golden Test が実施され、UIの差分がPR上で確認できるようになるまで実装してみたいと思います。

https://youtu.be/5hiKwU4TSQE

作成したいPRのイメージ

今回実装した内容は以下にあるので、よろしければご覧ください。

https://github.com/Koichi5/cursor_agent_sample

環境

  • Cursor Version: 0.45.11
  • VSCode Version: 1.96.2
  • Cursor Pro プラン
    今回は Cursor の Composer Agent を使用するため、Pro プランに加入しています。

PR の作成

まずは Cursor で PR の作成ができるように設定していきます。
PR の作成は Composer の Agent モードを使用して行います。
Agent モードに関して詳しくはこちらをご覧ください。

Cursor で PR の作成を行うために以下の手順で設定を進めていきます。

  1. Yolo モードの有効化
  2. モードの切り替え
  3. ドキュメント作成
  4. 実行

1. Yolo モードの有効化

まずは Cursor Composer の Yolo モードを有効化します。
Yolo モードを有効化すると、 Agent が開発者の確認なしでコマンドを実行したりファイルを書き換えたりすることができるようになります。

Changelogや設定画面で詳しく内容を確認できるかと思います。

Yolo モードの切り替えは Cursor > Preferences > Cursor Settings に含まれています。
Cursor SettingsFeatures タブの Chat & Composer の項目に「Enable yolo mode」という設定があります。この項目にチェックを入れることで Yolo モードを有効化できます。

Yolo モードを有効化すると以下のような警告のダイアログが表示されます。

警告の内容をまとめると以下のようなことが書かれています。

  • Yolo モードを有効化すると、開発者が設定した allowlistdenylist に基づいてさまざまなコマンドが実行される
  • 外部に影響を与えそうなコマンドはなるべく含まないこと
  • 自身の責任で使用すること

上記のダイアログにある通り、Yolo モードでは開発者の許可を取らずに様々なコマンドを実行することができるため、予期しない操作が行われる可能性があります。
これを防ぐために allowlistdenylist の設定が必要になります。

Enable Yolo Mode の項目のすぐ下に以下の四つの項目が設定できる部分があります。

  • Yolo prompt
  • Command allowlist
  • Command denylist
  • Delete file protection

それぞれ詳しくみていきます。

Yolo prompt

Yolo prompt ではどのコマンドであれば自動的に実行しても良いかをテキストの形式で設定することができます。「gitの操作に関するコマンドやその他の安全なコマンド」というプロンプトが例として挙げられています。
この例の場合、どのコマンドがgitの操作に関するものか、どのコマンドが安全かなどをモデルがそれぞれテキストを読んで判断することで意図しない操作を制限することができます。

Command allowlist

Command allowlist では、名前の通り Yolo モードで開発者の確認なしで実行しても良いコマンドを指定できます。 Agent が作業を進める上でここに含まれていないコマンドを実行する必要があると判断した場合は開発者に確認するようになります。
実際に使用してみると、このリストに含まれないコマンドを実行しようとする場合は一度処理が止まり、開発者が「Run Command」を押さないと実行されません。

今回は GitHub を扱うため、git, gh コマンドを Command allowlist に追加しています。
その他のコマンドに関しては各自で扱う言語、フレームワークによって異なるため個別に設定が必要です。

Command denylist

Command denylistCommand allowlist の逆で、 Yolo モードで開発者の確認なしで実行してはいけないコマンドを指定できます。
プロジェクトの内外問わず破壊的な変更を行う可能性のあるコマンドはここに追加して、絶対に実行されないように指定しておく必要があります。

Delete file protection

Delete file protection を有効化すると Agent が勝手にファイルを削除しないように設定できます。有効化しておくとより安全かと思います。

これで Yolo モードの有効化、設定は完了です。

2. モードの切り替え

次に Agent を使用するためのモードの切り替えを行います。
command + i で Cursor の Composer を開くことができます。
command + n で新しい Composer を作成することができます。

新たに作成した Composer を見ると、テキストフィールドの右下に normal / agent という表示があります。ここで agent モードに切り替えることができます。

これでモードの切り替えは完了です。

3. ドキュメント作成

次にドキュメントの作成を行います。
なおこのステップを飛ばしても Agent モードで PR を作成することはできます。

今回作成するドキュメントは Project Rules に設定していきます。
Project Rules では、 Agent がコードや背景を理解するためのドキュメントを設定することができます。ルールは .cursor/rules/ 配下に .mdc 形式で保存されます。
定義したルールを使うかどうかは、Chat や Composer にて自動で判断されます。

Project RulesCursor > Preferences > Cursor Settings > General の中の Project Rules の項目で設定することができます。以下の画像の青いボタンの「+ Add new rule」を押すことでルールを追加できます。

今回は PR の作成をしてもらうので、以下の二つのドキュメントを作成していきます。

  • PR 作成の手順
  • PR のテンプレート

PR 作成の手順のドキュメントは こちら の記事を参考にさせていただいて作成しました。
内容は以下の通りです。

.cursor/rules/create-pullrequest.mdc
## pull request作成手順
まず、このファイルを参照したら、「Pull Request作成手順のファイルを確認しました!」と報告してください。

### 差分の確認
- {{マージ先ブランチ}}に関する指定がない場合は、どのブランチに対してPullRequestを作成するか必ず聞き返してください。
- `git diff origin/{{マージ先ブランチ}}...HEAD | cat` でマージ先ブランチとの差分を確認

### Pull Request 作成とブラウザでの表示
- 以下のコマンドで pull request を作成し、自動的にブラウザで開く
- PR タイトルおよび PR テンプレートはマージ先との差分をもとに適切な内容にする
- 指示がない限り Draft で pull request を作成
- PR の本文は一時ファイルを使用して作成することを推奨

  --- bash
  # PR本文を一時ファイルに保存
  cat > pr_body.txt << 'EOL'
  {{PRテンプレートの内容}}
  EOL

  # PRの作成
  git push origin HEAD && \
  gh pr create --draft --title "{{PRタイトル}}" --body-file pr_body.txt && \
  gh pr view --web
  ---

- 各セクションを明確に区分
- 必要な情報を漏れなく記載

#### PRテンプレート
@pull-request-template.mdc からテンプレート内容を取得すること

次に PR のテンプレートのドキュメントを作成していきます。
内容は以下の通りです。この内容はプロジェクトごとで全く異なると思います。
GitHub の Issue 等をチケットとして貼っておくなどするとさらにわかりやすくなると思いますが、今回はシンプルな内容にしておきます。

.cursor/rules/pull-request-template.mdc
## 概要
ここでは、PR の概要を記載します。

## 詳細
ここでは、PR の詳細を記載します。

## 画面キャプチャ
<!-- Golden Testで生成された画面キャプチャが自動的に追加されます -->
<!-- 画面の変更がある場合は、変更前後の画面キャプチャが表示されます -->

## 参考
ここでは、PR の参考になりそうな情報を記載します。

これでドキュメントの作成は完了です。

4. 実行

次に Composer でPRを作成してみたいと思います。
新しく以下のようなビューを作成して、その修正内容を含むPRの作成を依頼してみます。

実際に以下の動画のように Cursor に PR 作成を依頼すると、以下のようなPRが作成されました。

https://youtu.be/hu3H27J-8ys

## 概要
カウンター機能を持つ新しいページを追加しました。

## 詳細
以下の機能を実装しました:

- カウンター値を表示する専用ページの追加
- インクリメント(+)ボタンによる値の増加機能
- デクリメント(-)ボタンによる値の減少機能(0以下にはならないよう制御)
- モダンなUI設計
    - 中央揃えのレイアウト
    - 適切なスペーシング
    - 円形のボタンデザイン
    - 見やすいフォントサイズとウェイト

## 画面キャプチャ

## 参考
Material Design ガイドライン(ボタンデザインとレイアウト)

どのような動作になっているか具体的にみていくと、以下のようになっています。
開発者は PR の作成を依頼して、マージ先のブランチを指定することで PR の作成ができるようになっています。PR のテンプレートを編集するなどしてプロジェクトにあった PR が作成できるかと思います。

  1. [開発者] PRの作成を依頼
  2. [Cursor] PR作成に関するドキュメントを確認
  3. [Cursor] git status で変更内容を確認
  4. [Cursor] PR作成に際してマージ先ブランチを確認
  5. [開発者] マージ先ブランチを指定
  6. [Cursor] 新しいブランチを作成して、コミット、プッシュ
  7. [Cursor] PRを作成して、ブラウザで表示
動作は安定するのか?

全く同じ修正内容で同じように PR の作成を依頼するとどのようになるか試してみたところ以下のような結果になりました。

https://youtu.be/r32YkVAm5og

## 概要
カウンター機能を持つ新しいページを追加しました。

## 詳細
- カウンターページの新規作成
    - インクリメント/デクリメントボタンの実装
    - カウンター値の表示
    - 最小値を0に制限

## 画面キャプチャ

## 参考

PR のテンプレートを使用しているため、記載すべき項目は満たしていますが、やはりその内容は異なります。PR に関しては記載すべき内容をドキュメントにまとめるだけでなく、変更内容のコードとそのPRの内容を例として挙げてまとめておくと内容のばらつきが減るかもしれません。

また、Agent の挙動に関してもばらつきがあります。具体的には、マージ先のブランチを聞かずに PR の作成をしたり、処理の実行に echo を使うか print を使うかなど多少異なる部分があります。

これらのばらつきは Agent に任せる限り完全に無くすことは難しいと思うので、工夫が必要になるかと思います。

Golden Test の実行

次に Golden Test の実行も行いたいと思います。
Golden Test の実行は以下の手順で行います。

  1. Golden Test の作成
  2. ドキュメントの作成
  3. GitHub Actions の設定
  4. Golden Test の実行
  5. UI に変更があった際の挙動の確認

1. Golden Test の作成

Golden Test の作成に移りますが、その前にプロジェクトのルートディレクトリに dart_test.yaml ファイルを作成して、以下のような内容にしておきます。
このようにすることで、オプションをつけて実行するテストのターゲットを絞り込むことができます。

dart_test.yaml
tags:
  golden:

次に test/support/alchemist ディレクトリを作成して、 Golden Test に必要な処理を追加していきます。
コードは以下の通りです。

以下では、 Golden Test で使用するデバイスの定義を行なっています。

test/support/alchemist/device.dart
// https://github.com/eBay/flutter_glove_box/blob/master/packages/golden_toolkit/lib/src/device.dart

import 'package:flutter/material.dart';

/// This [Device] is a configuration for golden test.
class Device {
  /// This [Device] is a configuration for golden test.
  const Device({
    required this.size,
    required this.name,
    this.devicePixelRatio = 1.0,
    this.textScaleFactor = 1.0,
    this.brightness = Brightness.light,
    this.safeArea = EdgeInsets.zero,
  });

  /// [phoneLandscape] example of phone that in landscape mode
  static const Device phoneLandscape =
      Device(name: 'phone_landscape', size: Size(667, 375));

  /// [phonePortrait] example of phone that in portrait mode
  static const Device phonePortrait =
      Device(name: 'phone_portrait', size: Size(375, 667));

  /// [tabletLandscape] example of tablet that in landscape mode
  static const Device tabletLandscape =
      Device(name: 'tablet_landscape', size: Size(1366, 1024));

  /// [tabletPortrait] example of tablet that in portrait mode
  static const Device tabletPortrait =
      Device(name: 'tablet_portrait', size: Size(1024, 1366));

  static List<Device> all = [
    phonePortrait,
    phonePortrait.dark(),
    phoneLandscape,
    phoneLandscape.dark(),
    tabletPortrait,
    tabletPortrait.dark(),
    tabletLandscape,
    tabletLandscape.dark(),
  ];

  /// [name] specify device name. Ex: Phone, Tablet, Watch
  final String name;

  /// [size] specify device screen size. Ex: Size(1366, 1024))
  final Size size;

  /// [devicePixelRatio] specify device Pixel Ratio
  final double devicePixelRatio;

  /// [textScaleFactor] specify custom text scale factor
  final double textScaleFactor;

  /// [brightness] specify platform brightness
  final Brightness brightness;

  /// [safeArea] specify insets to define a safe area
  final EdgeInsets safeArea;

  /// [copyWith] convenience function for [Device] modification
  Device copyWith({
    Size? size,
    double? devicePixelRatio,
    String? name,
    double? textScale,
    Brightness? brightness,
    EdgeInsets? safeArea,
  }) {
    return Device(
      size: size ?? this.size,
      devicePixelRatio: devicePixelRatio ?? this.devicePixelRatio,
      name: name ?? this.name,
      textScaleFactor: textScale ?? textScaleFactor,
      brightness: brightness ?? this.brightness,
      safeArea: safeArea ?? this.safeArea,
    );
  }

  /// [dark] convenience method to copy the current device and apply dark theme
  Device dark() {
    return Device(
      size: size,
      devicePixelRatio: devicePixelRatio,
      textScaleFactor: textScaleFactor,
      brightness: Brightness.dark,
      safeArea: safeArea,
      name: '${name}_dark',
    );
  }

  
  String toString() {
    return 'Device: $name, '
        '${size.width}x${size.height} @ $devicePixelRatio, '
        'text: $textScaleFactor, $brightness, safe: $safeArea';
  }
}

デバイスに関しては以下のリンクなどがわかりやすいかと思います。

https://github.com/eBay/flutter_glove_box/blob/master/packages/golden_toolkit/lib/src/device.dart

https://github.com/Betterment/alchemist/issues/37

次に以下で Golden Test のシナリオを作成していきます。

test/support/alchemist/golden_test_device_scenario.dart
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';

import 'device.dart';
export 'device.dart';

class GoldenTestDeviceScenario extends StatelessWidget {
  const GoldenTestDeviceScenario({
    super.key,
    required this.name,
    required this.device,
    required this.builder,
  });

  final String name;
  final Device device;
  final ValueGetter<Widget> builder;

  
  Widget build(BuildContext context) {
    return GoldenTestScenario(
      name: '$name (${device.name})',
      child: ClipRect(
        child: MediaQuery(
          data: MediaQuery.of(context).copyWith(
            size: device.size,
            padding: device.safeArea,
            platformBrightness: device.brightness,
            devicePixelRatio: device.devicePixelRatio,
            textScaler: TextScaler.linear(device.textScaleFactor),
          ),
          child: SizedBox(
            height: device.size.height,
            width: device.size.width,
            child: builder(),
          ),
        ),
      ),
    );
  }
}

これでテストの準備は完了です。
上記の二つのファイルはそれぞれの画面で Golden Test を行う際に参照します。

2. ドキュメントの作成

次に、先ほど PR 作成を依頼したときと同様にドキュメントの作成を行います。
内容は以下の通りです。これも Project Rules として追加しておきます。

golden-test.mdc
## Golden Test とは
Flutter にて画面差分やデザインの崩れがないかを確認するためのテスト手法です。Widget Test の一種として分類されています。

## alchemist について
このプロジェクトでは、Flutter で Golden Test を実施するために alchemist というパッケージを導入しています。このパッケージがあることで、デザインの修正のような視覚的な修正も機械的に判定することができるようになり、考慮漏れ等が減少します。

## このプロジェクトにおける alchemist の扱い方
以下ではこのプロジェクトで alchemist をどのように扱っているかをまとめます。これらの内容は GitHub で PR を作成する際にも非常に重要になります。
このプロジェクトでは、ユーザーの目に見えるViewは`lib`ディレクトリ配下の`views`ディレクトリで管理しています。`views`ディレクトリの配下にあるすべてのファイルに対して Golden Test が作成されていると仮定して実装を進めていきます。
それぞれの`views`に含まれるファイルのテストコードは`test`ディレクトリ配下に含まれており、`lib`配下のテスト対象となるファイルと全く同じディレクトリ構造で格納されています。
@my_home_page.dartを例に取ります。このファイルは`lib/views/my_home_page/my_home_page.dart`に含まれています。そしてこのファイルのテストコードは`test/views/my_home_page_test/my_home_page_test.dart`に含まれています。テストコードはテスト対象のファイルの名前の最後に「_test」をつける命名規則に従っています。
以上が、テスト対象となるコードとそのテストコードの格納場所です。

次に Golden Test の実行方法について述べます。
以下の手順で Golden Test は実行されます。
1. 開発者が新たに`lib/views`ディレクトリ配下でUIのファイルを作成する
2. Pull Request を作成する前に `flutter test --update-goldens --tags=golden` コマンドを実行することで新たに作成したファイルのUIの画像を登録しておく(このコマンドで作成されたUIの画像はGolden Test のテストコードが含まれるディレクトリと同じディレクトリの `goldens/ci/my_app_default.png` に作成されます。例えば、`my_home_page.dart` というファイルを作成し、テストコードを記述してコマンドを実行すると `test/views/my_home_page_test/goldens/ci/my_app_default.png` のディレクトリに画像が作成されます)
3. 別の実装で既にGolden Test が実装されているファイルを編集する
4. ファイルを修正後 `flutter test --tags=golden` コマンドを実行するとテストが失敗する(この時テストが失敗するのはUIが変更されていることを検知しているためです)
5. テストが失敗した場合は `test/views/{テスト対象のファイル名}_test` ディレクトリに `failures` ディレクトリが作成されます。その中の `my_app_default_masterImage.png` ファイルがマスターの画像であり、ステップ3で修正する前の実行結果のUI画像です。また、`failures`同じディレクトリの `my_app_default_testImage.png` がステップ3で修正した後の実行結果のUI画像です。
6. ステップ3で行ったUIの変更をPRを通してチームメンバーに共有し、その変更に問題がなければPRをマージし、同時に `flutter test --update-goldens --tags=golden` を実行します。これで Golden Image が更新されます。つまり、Golden Test の master 画像が変更後の画像に切り替わります。

以上が Golden Test の実行内容です。

Golden Test の実装は @my_home_page.dart, @my_home_page_test.dart の内容を参考にして実装してください。

上記の内容では、以下の三点を制限してドキュメントを書いています。

  1. UIの変更は必ず lib/views ディレクトリで行われること
  2. lib ディレクトリと同じディレクトリ構造で、 test ディレクトリ配下に Golden Test を実装すること
  3. すでに実装されている別のテストを参考にして実装すること

上記の三点はプロジェクトによって大きく異なる部分で、それぞれ編集しなければならない点かと思います。

1 に関しては、通常のプロジェクトで全てのUIに関するファイルが一つのディレクトリに収まることは考えにくいため、〇〇_screen.dart××_page.dart のようにファイルの命名から Golden Test の対象を絞り込むことができると思います。

2 に関しては、テストコードの位置を完全に決定していますが、これもプロジェクトによって変更することがあると思います。

3 に関しては、具体的なテストの実装例を挙げる以外の方法として、テンプレートを用意してテスト対象となっている Widget の名前だけ変更すれば良い形にしておくと動作が安定するかなと思います。

各画面の Golden Test のテンプレート

実際に作成するとこのようなテンプレートになるかもしれません。
ただ、Golden Test 自体を Cursor がある程度書いてくれるため、先ほどのステップで作成したデバイスとシナリオを渡してテストを書いてもらっても良いと思います。

golden_test_template.dart
import 'package:alchemist/alchemist.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
// import path_to_test_target

import '../../support/alchemist/golden_test_device_scenario.dart';

void main() {
  group('TestTarget Golden Test', () {
    Widget buildMyApp() {
      return MaterialApp(
        theme: ThemeData(
          useMaterial3: true,
          colorScheme: ColorScheme.fromSeed(
            seedColor: Colors.deepPurple,
            brightness: Brightness.light,
          ),
        ),
        home: const TestTarget(),
      );
    }

    final phonePortrait = Device.phonePortrait;

    goldenTest(
      'Default',
      fileName: 'my_app_default',
      builder: () {
        return GoldenTestGroup(
          columns: 1,
          children: [
            GoldenTestDeviceScenario(
              name: phonePortrait.name,
              device: phonePortrait,
              builder: () => buildMyApp(),
            ),
          ],
        );
      },
    );
  });
}

これでドキュメントの作成は完了です。

3. GitHub Actions の設定

次に GitHub Actions の設定を行います。
.github/workflows/ ディレクトリに golden_test.yml ファイルを作成して、 Golden Test で実行する内容をまとめていきます。
コードは以下の通りです。

.github/workflows/golden_test.yml
name: Flutter Golden Tests

on:
  pull_request:
    paths:
      - "lib/views/**"
      - "test/views/**"
      - "pubspec.yaml"

permissions:
  contents: write
  pull-requests: write

jobs:
  golden_tests:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: "3.27.4"
          channel: "stable"
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v42
        with:
          files: |
            lib/views/**/*.dart

      - name: Check for new views
        if: steps.changed-files.outputs.any_changed == 'true'
        id: check-new-views
        run: |
          has_new_views=false
          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
            if [[ $file == lib/views/* ]]; then
              component_name=$(basename ${file%.*})
              test_dir="test/views/${component_name}_test"
              if [ ! -d "$test_dir/goldens/ci" ]; then
                has_new_views=true
                echo "New view detected: $component_name"
                break
              fi
            fi
          done
          echo "has_new_views=$has_new_views" >> $GITHUB_OUTPUT

      - name: Initialize Golden images for new views
        if: steps.changed-files.outputs.any_changed == 'true' && steps.check-new-views.outputs.has_new_views == 'true'
        run: |
          git checkout -f ${{ github.head_ref }}
          flutter pub get
          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
            if [[ $file == lib/views/* ]]; then
              component_name=$(basename ${file%.*})
              test_path="test/views/${component_name}_test/${component_name}_test.dart"
              test_dir="test/views/${component_name}_test"
              if [ ! -d "$test_dir/goldens/ci" ] && [ -f "$test_path" ]; then
                echo "Initializing Golden Test for $component_name"
                mkdir -p "$test_dir/goldens/ci"
                mkdir -p "$test_dir/failures"
                flutter test --update-goldens "$test_path"
                # Find the actual generated golden file
                golden_file=$(find "$test_dir/goldens/ci" -type f -name "*.png" | head -n 1)
                if [ -n "$golden_file" ]; then
                  cp "$golden_file" "$test_dir/failures/my_app_default_masterImage.png"
                  cp "$golden_file" "$test_dir/failures/my_app_default_testImage.png"
                else
                  echo "No golden file found for $component_name"
                  exit 1
                fi
              fi
            fi
          done

      - name: Create base images
        if: steps.changed-files.outputs.any_changed == 'true'
        run: |
          git checkout -f ${{ github.base_ref }}
          flutter pub get
          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
            if [[ $file == lib/views/* ]]; then
              component_name=$(basename ${file%.*})
              test_path="test/views/${component_name}_test/${component_name}_test.dart"
              test_dir="test/views/${component_name}_test"
              if [ -f "$test_path" ]; then
                echo "Running Golden Test for $component_name (base)"
                flutter test --update-goldens "$test_path"
                # Find the actual generated golden file
                golden_file=$(find "$test_dir/goldens/ci" -type f -name "*.png" | head -n 1)
                if [ -n "$golden_file" ]; then
                  mkdir -p "$test_dir/failures"
                  cp "$golden_file" "$test_dir/failures/my_app_default_masterImage.png"
                else
                  echo "No golden file found for $component_name"
                  exit 1
                fi
              fi
            fi
          done

      - name: Create test images
        if: steps.changed-files.outputs.any_changed == 'true'
        run: |
          git checkout -f ${{ github.head_ref }}
          flutter pub get
          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
            if [[ $file == lib/views/* ]]; then
              component_name=$(basename ${file%.*})
              test_path="test/views/${component_name}_test/${component_name}_test.dart"
              test_dir="test/views/${component_name}_test"
              if [ -f "$test_path" ]; then
                echo "Running Golden Test for $component_name (test)"
                flutter test --update-goldens "$test_path"
                # Find the actual generated golden file
                golden_file=$(find "$test_dir/goldens/ci" -type f -name "*.png" | head -n 1)
                if [ -n "$golden_file" ]; then
                  mkdir -p "$test_dir/failures"
                  cp "$golden_file" "$test_dir/failures/my_app_default_testImage.png"
                else
                  echo "No golden file found for $component_name"
                  exit 1
                fi
              fi
            fi
          done

      - name: Check for UI changes
        if: steps.changed-files.outputs.any_changed == 'true'
        id: check-ui-changes
        run: |
          has_changes=false
          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
            if [[ $file == lib/views/* ]]; then
              component_name=$(basename ${file%.*})
              test_dir="test/views/${component_name}_test"
              master_image="$test_dir/failures/my_app_default_masterImage.png"
              test_image="$test_dir/failures/my_app_default_testImage.png"
              echo "Checking images for $component_name:"
              echo "Master image: $master_image"
              echo "Test image: $test_image"
              if [ -f "$master_image" ] && [ -f "$test_image" ]; then
                echo "Both images exist"
                if ! cmp -s "$master_image" "$test_image"; then
                  echo "Images are different"
                  has_changes=true
                  break
                else
                  echo "Images are identical"
                fi
              else
                echo "One or both images are missing"
                if [ ! -f "$master_image" ]; then
                  echo "Master image is missing"
                fi
                if [ ! -f "$test_image" ]; then
                  echo "Test image is missing"
                fi
              fi
            fi
          done
          echo "has_ui_changes=$has_changes" >> $GITHUB_OUTPUT
          echo "UI changes detected: $has_changes"

      - name: Commit and push images
        if: steps.changed-files.outputs.any_changed == 'true' && (steps.check-ui-changes.outputs.has_ui_changes == 'true' || steps.check-new-views.outputs.has_new_views == 'true')
        run: |
          git config --local user.email "github-actions[bot]@users.noreply.github.com"
          git config --local user.name "github-actions[bot]"
          git add test/views/*/failures/*.png test/views/*/goldens/ci/*.png pubspec.lock
          git status
          if git diff --cached --quiet; then
            echo "No changes to commit"
            exit 0
          fi
          git commit -m "test: Update Golden Test images and dependencies [skip ci]"
          git fetch origin ${{ github.head_ref }}
          git rebase origin/${{ github.head_ref }}
          git push -f origin ${{ github.head_ref }}

      - name: Wait for images to be available
        if: steps.changed-files.outputs.any_changed == 'true' && (steps.check-ui-changes.outputs.has_ui_changes == 'true' || steps.check-new-views.outputs.has_new_views == 'true')
        run: sleep 10

      - name: Generate comparison markdown
        if: steps.changed-files.outputs.any_changed == 'true' && (steps.check-ui-changes.outputs.has_ui_changes == 'true' || steps.check-new-views.outputs.has_new_views == 'true')
        id: generate-markdown
        run: |
          echo "Generating comparison markdown..."
          markdown=""
          repo_url="https://raw.githubusercontent.com/${{ github.repository }}/${{ github.head_ref }}"
          commit_sha=$(git rev-parse HEAD)

          for file in ${{ steps.changed-files.outputs.all_changed_files }}; do
            if [[ $file == lib/views/* ]]; then
              component_name=$(basename ${file%.*})
              test_dir="test/views/${component_name}_test"
              if [ -d "$test_dir" ]; then
                markdown="${markdown}### ${component_name} の変更\n\n"
                markdown="${markdown}| 変更前 | 変更後 |\n"
                markdown="${markdown}|--------|--------|\n"
                markdown="${markdown}|![Before](https://github.com/${{ github.repository }}/blob/$commit_sha/$test_dir/failures/my_app_default_masterImage.png?raw=true)|![After](https://github.com/${{ github.repository }}/blob/$commit_sha/$test_dir/failures/my_app_default_testImage.png?raw=true)|\n\n"
              fi
            fi
          done

          echo "markdown<<EOF" >> $GITHUB_OUTPUT
          echo -e "$markdown" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - name: Update PR description
        if: steps.changed-files.outputs.any_changed == 'true' && (steps.check-ui-changes.outputs.has_ui_changes == 'true' || steps.check-new-views.outputs.has_new_views == 'true')
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          pr_body="# UI の変更確認
          以下のファイルのUI変更がありました:
          ${{ steps.changed-files.outputs.all_changed_files }}
          ${{ steps.generate-markdown.outputs.markdown }}

          変更内容を確認し、意図した通りの変更になっているかご確認ください。
          問題がなければ、\`flutter test --update-goldens --tags=golden\` を実行してGolden Imageを更新してください。"

          gh pr edit ${{ github.event.pull_request.number }} --body "$pr_body"

      - name: No UI changes detected
        if: steps.changed-files.outputs.any_changed == 'true' && steps.check-ui-changes.outputs.has_ui_changes != 'true' && steps.check-new-views.outputs.has_new_views != 'true'
        run: |
          echo "No UI changes were detected in the Golden Test images."

とても長くなっていますが、ステップに分けて見ると以下のようになっています。

  1. トリガー: プルリクエストが作成または更新され、対象パス内のファイルが変更される。
  2. セットアップ: リポジトリをチェックアウトし、Flutter 環境と依存関係をセットアップ。
  3. 変更ファイルの確認: 変更された Dart ファイルを特定。
  4. 新規ビューの検出と初期化: 新しく追加されたビューに対して Golden 画像を初期化。
  5. テスト画像の生成: ベースブランチとヘッドブランチでそれぞれ Golden テストを実行し、画像を生成。
  6. UI 変更の比較: 生成された画像同士を比較して UI の変更を検出。
  7. 結果の反映: 変更があった場合、画像をコミット・プッシュし、PR の説明を更新。
  8. 通知: 変更がなかった場合はその旨をログに出力。

これにより、 lib/views ディレクトリに変更がある段階で Cursor に Golden Test の実装を依頼すると、Golden Test が実行され、その結果がPRの変更として反映されるようになります。

このワークフローでは以下のような条件があるため、これらは各プロジェクトでカスタマイズする必要があるかと思います。

  • このテストでは lib/views ディレクトリ配下の修正しか検知していない
  • テストディレクトリも test/views/{component_name}_test/goldens/ci として固定している
  • 生成した画像の保存場所や名前を固定していること

4. Golden Test の実行

これで準備が整ったので、 Golden Test を実行してみます。

Cursor に対して、実装した Golden Test を実行してプッシュするように指示すると、以下の動画のように内容をプッシュしてくれます。
PRの内容には、 Golden Test を実行したビューの画像が添付されるようになります。
ただ、 Before / After で表示していますが実際にはビューの変更が行われていないため同じ画像を表示していることになります。この辺りももっと修正できる点かと思います。

https://youtu.be/UQLNl8WUam0

作成されるPRは以下のようになっていて、ビューの画像が添付されています。

5. UI に変更があった際の挙動の確認

次に lib/views ディレクトリ配下のファイルに変更があった際の挙動を見ていきます。
以下の画像のように CounterPage のカラーテーマの brightness を変更してみます。

Before After

この内容をプッシュするように Cursor に依頼すると以下の動画のような挙動になります。

https://youtu.be/5hiKwU4TSQE

GitHub Actions で Golden Test が実行され、以下の画像のように修正前後の画像がPRに表示されるようになります。

複雑な動きを確認する必要がある場合は実際の画面の動画を撮ってPRに追加する必要がありますが、簡単なUIの変更などであれば、このスクリーンショットでも変更箇所がわかるかと思います。

以上です。

まとめ

最後まで読んでいただいてありがとうございました。

今回は Cursor で PR 作成等を行う方法についてまとめました。
実装を進めていく中で、 Cursor と GitHub Actions でかなりできることが広がって面白いなと感じました。

なお、今回扱った内容は技術的な内容ではあるものの、以下の二点から Tech ではなく Idea として記載しています。何かの参考になれば幸いです。

  • プロジェクトごとで設定する項目が非常に多い
  • GitHub Actions の設定等不完全な部分がある

参考

https://zenn.dev/globis/articles/cursor-project-rules

https://zenn.dev/yorifuji/articles/3d62f6de561618

https://zenn.dev/taisei_dev/articles/f88b46aad05dba

16

Discussion

ログインするとコメントできます