Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: Capybaraでスクリーンショットを取得してreg-cliで画像比較する簡単なVRT的なのを作るMEMO

Ruby on Railsのsystem specで画面ショットを取りつつmasterとの画像比較してVRT的なことできないかなーと思っていたのですが、Capybaraのpage.save_screenshotreg-cliを使うと実現できそうだったのでメモ📝

ちなみにPlaywrightでは以下の通りtoHaveScreenshotを使うことで、今回試したような簡単にVRTを実施できます。

madogiwa0124.hatenablog.com

※playwright-ruby-clinetを直接使えば出来るかなと思ったけどtoHaveScreenshotには現時点では対応していなさそうだった📝

前提事項

  • rails (7.1.3.2)
  • capybara (3.40.0)
  • capybara-playwright-driver (0.5.1)
    • 試してないですがplaywrightじゃなくても出来ると思います

実現したいこと

Ruby on Railsのsystem specで画面ショットを取りつつmasterとの差分比較して一定の差異があったら落としたいので、以下のような感じでspec/screenshotsのmasterとcompareに、それぞれmasterのものとトピックで取得した画像ファイルを配置し同一パスのファイルを比較して一定の差異があればエラーにします。 ※masterは比較用に初回実行時だけ作成しリポジトリで管理するようにします。(トピックが正になる場合に更新してpushします。)

spec/screenshots
├── compare
│   └── foo
│       └── bar
│           └── baz.png
└── master
    └── foo
        └── bar
            └── baz.png

System Specで任意のタイミングで画面ショットを取得し差分比較用のフォルダに配置する

画面ショットを取得すること自体は、page.save_screenshot(path: Rails.root.join("spec/screenshots/compare/foo/bar/baz")を実行すればいいのですが、差分比較用のフォルダを毎回してするのは面倒なので以下のようなHeplerを用意していい感じのフォルダに配置できるようにしてみました。

module VrtScreenshotHelper
  def vrt_screenshot(page, path:)
    return if ENV["GET_SCREENSHOTS_FOR_VRT"] != "true"
    base_path = Rails.root.join("spec/screenshots/compare")
    page.save_screenshot(base_path.join(path))
  end
end

こうすると以下のように取得できます。

    it "sample example" do
      visit example_path
      expect(page).to have_content "sample title"
      expect(page).to have_content "sample description"
      vrt_screenshot(page, path: "foo/bar/baz.png")
    end

初回実行時にはmasterの画面ショットを作成する必要があるので、以下のように変更してmaster側に画面ショットを取得します。

module VrtScreenshotHelper
  def vrt_screenshot(page, path:)
    return if ENV["GET_SCREENSHOTS_FOR_VRT"] != "true"
-   base_path = Rails.root.join("spec/screenshots/compare")
+   base_path = Rails.root.join("spec/screenshots/master")
    page.save_screenshot(base_path.join(path))
  end
end

取得した画面ショットを比較して一定差分があった場合に落とす

取得した画面ショットを比較して一定差分があった場合に落とすのは以下のreg-cliを使うと簡単に実現することが出来ました。

github.com

以下のように比較するパスと差分を表す画像の出力先を指定し、レポート出力先-R、エラーにする閾値-Tを指定することで先ほど保存したスクリーンショットをmasterを比較し、一定の差分があったらレポート付きでエラーにすることが出来ます。

$ reg-cli spec/screenshots/compare spec/screenshots/master spec/screenshots/diff -R spec/screenshots/diff/report.html -T 0.01

上記はspec/screenshots/comparespec/screenshots/masterを比較し、差分を示す画像をspec/screenshots/diffに配置、1%でも差分があればエラーとし、レポートをspec/screenshots/diff/report.htmlに吐き出すような指定になります。

実際に実行して失敗すると以下のような出力が表示され差分を検知できます。

$ reg-cli spec/screenshots/compare spec/screenshots/master spec/screenshots/diff -R spec/screenshots/diff/report.html -T 0.01 -U

✘ change  spec/screenshots/compare/foo/bar/baz.png

✘ 1 file(s) changed.

差分が発生した場合にはレポートで具体的な差分をブラウザで確認できます。

https://github.com/reg-viz/reg-cli?tab=readme-ov-file#html-report

CircleCIやGitHub Actionでartifactsとして保存するようにすると差分比較がよりしやすくなって良さそうです。

おわりに

reg-cliあまり使ってことなかったのですが、お手軽に画像比較・レポート作成まで出来て非常に便利ですね ✨

Ruby on Rails: CPSを有効化後にテスト環境で`content_security_policy_nonce`で空文字が返却されエラーになる件の対応方法MEMO

個人のWebサービスconfig.content_security_policy_report_only=trueを外し、CSP違反があった場合にブラウザエラーを発生させるようにしたところ、Sytem Specが軒並み落ちるようになり対応したのでやったことをメモ📝

事象

ブラウザエラーを見たところ、以下のようなCSP違反のログが多数出ていたので、

The source list for the Content Security Policy directive 'script-src' contains an invalid source: ''nonce-''. It will be ignored.

実際に返却されるhtmlを確認してみたのですが、scriptタグに設定されるnonceが空文字になっていました。

<script nonce="">

原因

Rails側のnonceの生成処理は以下のようになっているのですが、このrequest.sesionの参照処理時にテスト環境でだけ、#<ActionDispatch::Request::Session:0xd610 not yet loaded>となっており、sessionが未ロードの状態のため空文字が返ってしまっていた模様 🤔

Rails.application.configure do
  config.content_security_policy_nonce_generator = ->(request) {
    request.session.id.to_s
  }
end

テスト環境では実際にログインせずにcurrent_userallow_any_instance_ofで返してるので、その辺の理由でcontent_security_policy_nonce_generator実行時点でsessionが未ロードの状態になってしまっているのかも(?)

解決策

以下のようにrequest.session[:init] = trueを実施し明示的にsessionを扱うことでロードさせるようにしたところ解決した。

Rails.application.configure do
  config.content_security_policy_nonce_generator = ->(request) {
    # NOTE: テスト実行時に以下となりSessionが取得できずnonceが空文字になりCSP違反が発生してしまうので強制的にロードする
    # #<ActionDispatch::Request::Session:0xd610 not yet loaded>
    # https://github.com/rails/rails/issues/10813#issuecomment-297204965
    request.session[:init] = true
    request.session.id.to_s
  }
end

参考

github.com

Ruby: `define_method`でキーワード引数を持つメソッドを定義するメモ📝

Rubydefine_methodを使うと外部からレシーバーに任意のメソッドを定義できますが、キーワード引数を持つメソッドを定義するときにやり方を迷ったのでメモ📝

docs.ruby-lang.org

define_methodは以下のようにblockを渡してメソッドを定義できますが、block引数ではキーワード引数を定義できませんが、

class Foo
  def foo; end
end

Foo.define_method(:bar) do |prefix|
  puts "#{prefix} bar!"
end

Foo.new.bar("Hello")
#=> Hello bar!

define_methodには、以下の通りProcオブジェクトを渡すことができるので、

[PARAM] method: Proc、Method あるいは UnboundMethod のいずれかのインスタンスを指定します。 Module#define_method (Ruby 3.3 リファレンスマニュアル)

以下のようにキーワード引数を持つlamdaやprocのオブジェクトを渡すことでキーワード引数を持つメソッドをdefine_methodで定義することが出来ます✨

class Foo
  def foo; end
end

Foo.define_method(:bar, ->(prefix:) { puts "#{prefix} bar!" })
Foo.new.bar(prefix: "Hello")
#=> Hello bar!

Rubyは色々method定義できたりして便利ですね!

Ruby on Rails: GoodJobをRailsアプリケーションと同一プロセスで実行するメモ📝

Heroku等で運用しているとバッチサーバーを別のプロセスで実行するとお金が掛かったりするのでGoodJobを用いている場合にRailsアプリケーションと同一プロセスで起動する方法をメモ📝

GoodJobについてはこちら

madogiwa0124.hatenablog.com

やり方としては簡単で以下のようにgood_job.execution_mode:asyncに変更するだけで大丈夫だった。

 frozen_string_literal: true

Rails.application.configure do
   config.good_job.queues = "*"
-  config.good_job.execution_mode = :external
+  config.good_job.execution_mode = :async
   config.good_job.cleanup_preserved_jobs_before_seconds_ago = 1.hour.to_i
   config.good_job.max_threads = 2
end

以下の通りexecution_mode:asyncに設定するとRailsのアプリケーションプロセス内の別スレッドとして動作させることができるようです。また、consoleやmigration等で起動された場合には動作しないようになっているのもありがたいですね🙏

  • execution_mode (symbol) specifies how and where jobs should be executed. You can also set this with the environment variable GOOD_JOB_EXECUTION_MODE. It can be any one of:
    • :async (or :async_server) executes jobs in separate threads within the Rails web server process (bundle exec rails server). It can be more economical for small workloads because you don’t need a separate machine or environment for running your jobs, but if your web server is under heavy load or your jobs require a lot of resources, you should choose :external instead. When not in the Rails web server, jobs will execute in :external mode to ensure jobs are not executed within rails console, rails db:migrate, rails assets:prepare, etc.

https://github.com/bensheldon/good_job/blob/3933005203f8781d5937e8541256488eee47380f/README.md#configuration-options

コードを軽く読んでみた限りですが、execution_mode:asyncの場合にはRailsアプリケーションが起動されたタイミング(config.after_initialize)でGoodJob._start_async_adaptersが実行され、Railsアプリケーション内にGoodJobの各種機能が有効化され実行が開始され、

    initializer "good_job.start_async" do
      config.after_initialize do
        ActiveSupport.on_load(:active_record) do
          ActiveSupport.on_load(:active_job) do
            GoodJob._framework_ready = true
            GoodJob._start_async_adapters
          end
          GoodJob._start_async_adapters
        end
        GoodJob._start_async_adapters
      end
    end
  end

https://github.com/bensheldon/good_job/blob/3933005203f8781d5937e8541256488eee47380f/lib/good_job/engine.rb#L51-L70

エンキューされたタイミングで別スレッドを作って非同期で処理するようにしているようです。

    def enqueue_at(active_job, timestamp)
      # ...
          executed_locally = execute_async? && @capsule&.create_thread(execution.job_state)
          Notifier.notify(execution.job_state) if !executed_locally && send_notify?(active_job)

https://github.com/bensheldon/good_job/blob/3933005203f8781d5937e8541256488eee47380f/lib/good_job/adapter.rb#L181-L188

※cronとかもどうなるのかなと思ったけどGoodJobの各種機能が有効化されるタイミングでCronのマネージャーも有効化されるからRailsアプリケーションの中でcronも動くようだった。

Solid Queueに移行しようかなと思っていたのですが、

github.com

Solid Queueでは現時点では以下で検討されてますが、まだ同一プロセスでの実行はできないっぽいですね👀

github.com

GoodJob便利!

CSS: `display: flex`で横並びにした要素をモバイルサイズの場合に縦並びにする方法MEMO

display: flexで横並びにした要素をモバイルサイズの場合に縦並びにするときにいい感じのやり方を探して、少しハマったのでメモ📝

結論としては以下の通りモバイルサイズの時だけflex-direction: column;を指定すれば横並びが縦並びになる🤩

.items {
  display: flex;
  justify-content: center;

  @media (max-width: 768px) {
    flex-direction: column;
  }
}

CodePen

See the Pen `display: flex`でモバイルサイズの場合に縦並びにする by madogiwa (@madogiwa0124) on CodePen.

flexbox便利ですねー✍️

developer.mozilla.org

TypeScriptでClassのconstructorの引数の型を取得する方法MEMO

TypeScriptで普通の関数であればParameter<function>で関数の引数の型を取得できますが、Classのconstructorの引数の型を取得する方法がイマイチ分からず少しハマったのでやり方をメモ📝

Parameters
Constructs a tuple type from the types used in the parameters of a function type Type.
https://www.typescriptlang.org/docs/handbook/utility-types.html#parameterstype

結論としてはConstructorParameters<typeof Class>を使えば取得できた!

ConstructorParameters
Constructs a tuple or array type from the types of a constructor function type.
https://www.typescriptlang.org/docs/handbook/utility-types.html#constructorparameterstype

例えばVue 2からVue 3への移行したいときに、Vue 3に上げる前にnew Vue相当の処理をcreateAppを使って実装できる以下のようなWrapperを用意したりするときに型チェックも行えて便利!!

Vue2Wrapper

import Vue from "vue"; // Vue 2


export const createApp = (...args: ConstructorParameters<typeof Vue>) => {
  return new Vue(...args);
};

entry.ts

import { createApp } from "Vue2Wrapper"; 

const app = createApp({ /* ... */ });

Vue.js: Vueアプリケーション内で発生したエラーをRollbarに通知するメモ📝

個人のWebサービスのエラー通知にRollbarを利用しているのですが、以下の通りデフォルトではVueアプリケーション内で発生したエラー(コンポーネント内のロジックでエラーが起きたケース等)は通知されないことに今更気づき、通知されるようにしたので対応したことをメモ📝

Add Rollbar to Vue’s global error handler. Uncaught exceptions within the Vue app are sent to this handler, and appear in the browser console, but are not sent > to the browser’s global error handler. Adding Rollbar here ensures that these errors are reported. https://docs.rollbar.com/docs/vue-js

Rollbarへ通知するためのプラグインを追加

ほぼドキュメントの通りですが、app.config.errorHandlerを設定し、Vueアプリケーションでエラーが発生した場合にRollbarへの通知ロジックが実行されるようなPluginを用意して、

// NOTE: Rollbarにブラウザ側で発生したエラー通知を送る機能を有効化
// https://docs.rollbar.com/docs/browser-js

import Rollbar from "rollbar";
import type { App } from "vue";

const token = process.env.ROLLBAR_POST_CLIENT_ITEM_ACCESS_TOKEN;
const rollbarParamsValidation = () => !!(!(NODE_ENV === "test") && token);

const rollbar = new Rollbar({
  accessToken: token,
  captureUncaught: true,
  captureUnhandledRejections: true,
  enabled: rollbarParamsValidation(),
  payload: {
    environment: NODE_ENV,
  },
});

export default rollbar;

// NOTE: Vueアプリケーション内部のエラーをRollbarに通知するプラグイン
// https://docs.rollbar.com/docs/vue-js
export const VueRollbarPlugin = {
  install(app: App) {
    app.config.errorHandler = (error, _vm, info) => {
      // NOTE: 公式docでは以下となっていたが、おそらく送信時にvueComponentをjsonに変換するのにブラウザがフリーズするため送信しないようにした。
      // rollbar.error(error, { vueComponent: vm, info });
      rollbar.error(error as Error, { info });
      // NOTE: 本来はエラーをコンソールに出力するのは避けるべきだが、
      // エラー発生時にconsole.errorに表示しておいた方が確認しやすいため残している
      // eslint-disable-next-line no-console
      console.error(error);
    };
    app.provide("rollbar", rollbar);
  },
};

Pluginの有効化

以下のようにcreateApp時にuseして有効化するようにしました。

import NavigationBar from "@js/components/organisms/NavigationBar.vue";
import { VueRollbarPlugin } from "@js/services/Rollbar";
import { createApp } from "vue";

const app = createAppWithDefault({ components: { NavigationBar } });
app.use(VueRollbarPlugin);
app.mount("#vue-header");

また、毎回これを書くのはめんどくさそうに思ったのでcreateAppWithDefaultを定義して、

import { createApp, type CreateAppFunction } from "vue";
import { VueRollbarPlugin } from "./Rollbar";
type VueCreateAppType = CreateAppFunction<Element>;

export const createAppWithDefault: VueCreateAppType = (...args: Parameters<VueCreateAppType>) => {
  const app = createApp(...args);
  app.use(VueRollbarPlugin);
  return app;
};

以下のように利用するとcreateApp後に自動的にuseするようにしました。

import NavigationBar from "@js/components/organisms/NavigationBar.vue";
import { createAppWithDefault } from "@js/services/Vue";

const app = createAppWithDefault({ components: { NavigationBar } });
app.mount("#vue-header");

おまけ:Sentryのケース

Sentryの場合には、@sentry/vueが提供されていてDocumentの通りに設定すると自動的にapp.config.errorHandlerをよしなに設定してくれるっぽかった。

https://docs.sentry.io/platforms/javascript/guides/vue/