Madogiwa Blog

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

Ruby: 個人サービスをRuby v3.3.0にアップデートした💎✨

以下の問題があったのでRuby v3.3.0へのアップデートを見送っていたのですが、

madogiwa0124.hatenablog.com

Docker Hubの公式イメージに修正が入り正常に動作するようになったようなのでRuby on Rails製の個人サービスをRuby v3.3.0にアップデートしました💎✨

その後Docker Hubのdocker-library/rubyで本記事の問題が修正されました↓。これにより、Docker HubのRuby 3.3.0イメージは正常に動くようになりました。 PR: Workaround 3.3.0 crash on aarch64 by osyoyu · Pull Request #439 · docker-library/ruby Ruby 3.3.0: aarch64-linux環境でFiber.new{ }.resumeを呼ぶと落ちる問題|TechRacho by BPS株式会社

いつも通りアップデート後にRspecを実行してテストが通ることを確認できました🍏

個人サービスレベルの規模ではありますが、 特別やったことと言えばzeitwerkの警告が出ていたのでcsvを明示的にinstallするようにしたぐらいで、 互換性が担保されているのはありがたいですね🙏

ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/kernel.rb:34: warning: csv was loaded from the standard library, but will no longer be part of the default gems since Ruby 3.4.0. Add csv to your Gemfile or gemspec. Also contact author of zeitwerk-2.6.13 to add csv into its gemspec.

Ruby v3.4からcsvがdefault gemでは無くなるようですね 👀

gihyo.jp

Ruby on Rails: Capybaraで特定の要素が無くなるのを待つ方法MEMO

Capybaraで、読み込み中を表すコンポーネントが消えるのを待ってからスクショを撮りたいみたいな時に特定の要素が消えるのを待つ方法をMEMO

結論だけ言うと以下のようにスクリーンショットを習得する前にローダーが無くなることを判定すれば良い。

page.has_no_css?(".page-loader")
page.save_screenshot("tmp/foo/bar.png")

www.rubydoc.info

RSpecのmatcherとして提供されているものはhas_link?とかhas_content?とかで大体pageから呼び出せる。

便利!

決まりきったローダーがあるならmoduleに切り出してhelperにしておくと便利そう。

module WaitLoadingComponentHelper
  def wait_loading_component(page)
    page.has_no_css?(".page-loader")
  end
end

参考

blog.willnet.in

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 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便利!

Ruby on Rails: capybara-playwright-driverでsystem specを実行するMEMO📝

system specをseleniumではなくcapybara-playwright-driverを使ってplaywrightで動かしてみたのでメモ📝

github.com

playwrightに関しては以下に以前まとめていたので貼り付けておく。

madogiwa0124.hatenablog.com

準備

必要なライブラリをinstallします。

Gemfile

  gem "capybara-playwright-driver"
  gem "playwright-ruby-client"

package.json

    "@playwright/test": "1.41.1",

以下を実行してplaywrightで利用するbrowsersをinstallします。

$ npx playwright install

playwright driverを利用する

RSpec.configuredriven_by:playwrightを指定するだけでplaywrightでsystem specが実行されます🤖

# NOTE: 推奨値に変更
# > It is recommended to set the timeout to 15-30 seconds for Playwright driver.
# > https://playwright-ruby-client.vercel.app/docs/article/guides/rails_integration#update-timeout
Capybara.default_max_wait_time = 15

RSpec.configure do |config|
  config.before(:each, type: :system) do |example|
    example.metadata[:js] ? driven_by(:headless_chrome) : driven_by(:rack_test)
    if example.metadata[:js]
      headless = ENV.fetch("PLAYWIGHT_HEADLESS", "true") == "true"
      browser = ENV.fetch("PLAYWIGHT_BROWSER_TYPE", :chromium).to_sym
      driven_by(:playwright, options: {browser_type: browser, headless: headless})
    else
      driven_by(:rack_test)
    end
  end

スクリーン録画やトレースを利用する

またplaywrightのtraceや録画をcallbackを活用することで取得できるようだったので、

以下のような感じでtmp配下に配置するようにしてみました。

def save_playwright_screen_record(
  example,
  save_dir: "tmp/capybara/screen_records",
  file_name_proc: ->(example) { example.full_description.split(" ").join("_") }
)
  Capybara.current_session.driver.on_save_screenrecord do |video_path|
    file_name = "#{file_name_proc.call(example)}#{Pathname.new(video_path).extname}"
    FileUtils.mkdir_p Rails.root.join(save_dir)
    FileUtils.cp video_path, Rails.root.join(save_dir, file_name)
  end
end

def save_playwright_trace(
  example,
  save_dir: "tmp/capybara/traces",
  file_name_proc: ->(example) { example.full_description.split(" ").join("_") }
)
  Capybara.current_session.driver.on_save_trace do |trace_zip_path|
    file_name = "#{file_name_proc.call(example)}#{Pathname.new(trace_zip_path).extname}"
    FileUtils.mkdir_p Rails.root.join(save_dir)
    FileUtils.cp trace_zip_path, Rails.root.join(save_dir, file_name)
  end
end
RSpec.configure do |config|
  config.before(:each, type: :system) do |example|
    example.metadata[:js] ? driven_by(:headless_chrome) : driven_by(:rack_test)
    if example.metadata[:js]
      headless = ENV.fetch("PLAYWIGHT_HEADLESS", "true") == "true"
      browser = ENV.fetch("PLAYWIGHT_BROWSER_TYPE", :chromium).to_sym
      driven_by(:playwright, options: {browser_type: browser, headless: headless})
+    save_playwright_trace(example) if example.metadata[:trace]
+    save_playwright_screen_record(example) if example.metadata[:screen_record]
    else
      driven_by(:rack_test)
    end
  end

ビデオの保存はdriven_by(:playwright, options: {browser_type: browser, headless: headless, record_video_dir: "tmp/capybara/screen_record"})のようにrecord_video_dirを指定するだけも任意のディレクトリに配置できるがファイル名をいい感じにしたかったのでゴリっと書いてみた🦍 (もっといいやり方ありそうだけど。。。)

we can record the videos without specifying record_video_dir explicitly or preparing a temporary directory. capybara-playwright-driver automatically prepare and set record_video_dir internally. https://playwright-ruby-client.vercel.app/docs/article/guides/recording_video#using-screen-recording-from-capybara-driver

おわりに

capybara-playwright-driver 簡単に乗り換えられてありがたい・・・!!🙏✨

参考

note.com

個人のWebサービスをVite 5.0系にアップデートしたので対応したことMEMO📝

2023/11/16 Vite v5がリリースされました⚡️

vitejs.dev

リリースから2ヶ月経ちライブラリ側のサポートも揃ってきたので、 個人のWebサービスをVite 4系からVite v5系にアップデートしたので対応したこととかをメモしておきます📝

前提事項

利用しているライブラリは以下のような感じです。(Ruby on Railsのサービスでフロントエンド関連のファイルのビルドにViteを利用しています。)

Vite v5アップデートで対応したこと

以下のドキュメントに公式のマイグレーションガイドがあるので読みつつ進めました。

以下に具体的に必要だった対応事項を記載します。

Vite v5 からCSSファイルはトップレベルに含まれなくなったのでstylesheet_pack_tagの利用をやめる

以下の通りVite v5 からCSSファイルはminifest.jsonのトップレベルに含まれなくなりました。

対応する CSS ファイルは manifest.json ファイルのトップレベル項目としてリストされない Vite 4 では、JavaScript エントリーポイントに対応する CSS ファイルもマニフェストファイル (build.manifest) のトップレベルエントリーとしてリストされていました。 これらのエントリーは意図せずに追加されたもので、単純な場合にのみ機能しました。 https://ja.vitejs.dev/guide/migration.html#%E5%AF%BE%E5%BF%9C%E3%81%99%E3%82%8B-css-%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AF-manifest-json-%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E3%83%88%E3%83%83%E3%83%95%E3%82%9A%E3%83%AC%E3%83%98%E3%82%99%E3%83%AB%E9%A0%85%E7%9B%AE%E3%81%A8%E3%81%97%E3%81%A6%E3%83%AA%E3%82%B9%E3%83%88%E3%81%95%E3%82%8C%E3%81%AA%E3%81%84

元々は以下のような感じでトップレベルのCSSを直接参照していたのですが上記の破壊的変更によりよみこめなくなりエラーとなっていました。

<%= vite_stylesheet_tag 'application' %>

読み込めなくなっていたもののvite_javascript_tagの以下の処理でentryのjs配下のcssstylesheet_link_tagで挿入されるため、

  def vite_javascript_tag(*names, # 省略
    # 省略
    tags << stylesheet_link_tag(*entries.fetch(:stylesheets), media: media, **options) unless skip_style_tags

    tags
  end

ref: https://github.com/ElMassimo/vite_ruby/blob/369facf440f41162efee825a87d9491ff83a03b8/vite_rails/lib/vite_rails/tag_helpers.rb#L52

stylesheet_pack_tagの利用自体が不要だったので削除しました。

- <%= vite_stylesheet_tag 'application' %>

Vite v5からcjsが非推奨になったのでpackge.jsonのtypeをmoduleに変更する

以下の通りVite v5から CJS Node API の非推奨化され警告が出ていたので、

CJS Node API の非推奨化 Vite の CJS Node API は非推奨になりました。 今後、require('vite') を呼ぶときは、非推奨の警告メッセージが出力されます。 ファイルやフレームワークを更新して、代わりに Vite の ESM ビルドをインポートするとよいでしょう。 https://ja.vitejs.dev/guide/migration.html#cjs-node-api-%E3%81%AE%E9%9D%9E%E6%8E%A8%E5%A5%A8%E5%8C%96

The CJS build of Vite's Node API is deprecated. See https://vitejs.dev/guide/troubleshooting.html#vite-cjs-node-api-deprecated for more details.

以下のようにcjsの記法をESM形式に修正する or ファイルの拡張子を.cjsに修正し、

- const autoprefixer = require("autoprefixer");
+ import autoprefixer from 'autoprefixer';
+ import tailwindcss from 'tailwindcss';

- module.exports = {
-   plugins: [require("tailwindcss"), autoprefixer({ grid: true })],
+ export default {
+   plugins: [tailwindcss, autoprefixer({ grid: true })],
};

package.jsontypemoduleに変更しました。

+ "type": "module",

historeによるコンポーネントカタログのbuild時に、まだ警告が出ていますがhistoire側の問題なので対応していません

github.com

おわりに

基本的にVite公式のマイグレーションガイドに従ってバージョンアップできました🙌 日本語訳もあり非常にありがたかったです・・・!