diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..74e6c44f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,65 @@ +## What is Cherry? + +Cherry (cherrypush.com) is a technical debt tracking tool. Users configure codebase patterns to track, and Cherry reports stats on every commit to a dashboard. The app is a Rails 7 monolith with a React SPA for authenticated pages. + +## Commands + +### Development + +```sh +bin/dev # Start dev server (Rails + Tailwind + Vite) on port 3001 +docker compose up -d # Start PostgreSQL (required) +rails db:setup # Create and seed database +``` + +### Testing + +```sh +HEADLESS=1 bin/rails test:all # Run all tests (unit + system, headless) +bin/rails test # Unit/integration tests only +HEADLESS=1 bin/rails test:system # System tests only (headless Chrome) +bin/rails test test/models/metric_test.rb # Single test file +bin/rails test test/models/metric_test.rb:42 # Single test by line number +bin/rails test:system # System tests with visible Chrome (no HEADLESS) +``` + +When making changes, always run the related tests before considering the work done. + +### Linting + +```sh +bundle exec rubocop # Ruby linter +npx eslint app/javascript/ # JS/TS linter +npx prettier --check . # Formatting check +``` + +## Architecture + +### Hybrid Rails + React SPA + +- **Static/public pages**: Server-rendered ERB templates (`app/views/pages/`), styled with Tailwind CSS +- **Authenticated app (`/user/*`)**: Single-page React app. Rails serves a shell via `User::ApplicationController#spa`, then React Router handles client-side routing. All `/user/*` JSON endpoints serve data to the React SPA via axios. +- **Frontend bundling**: Vite (via `vite_rails` gem and `vite-plugin-ruby`). React components live in `app/javascript/components/`, hooks in `app/javascript/hooks/`, helpers in `app/javascript/helpers/`. + +### API layers + +- **CLI API (`/api/*`)**: Used by the `cherrypush` npm CLI to push metrics. Authenticates via `api_key` param. Controllers in `app/controllers/api/`. +- **SPA API (`/user/*` JSON)**: Internal API for the React frontend. Authenticates via session cookie (Google OAuth via OmniAuth). Controllers in `app/controllers/user/`. + +### Key models + +The core domain: `Project` has many `Metric`s, each `Metric` has many `Occurrence`s (individual code locations) and `Contribution`s (who changed what). `Organization` groups projects; `User` belongs to organizations via `Membership`. `Dashboard` has many `Chart`s (via `ChartMetric` join). + +### Testing + +- Minitest with `minitest-spec-rails` (allows `describe`/`it` blocks) +- `FactoryBot` for test data (factories in `test/factories/`) +- System tests use Capybara + Selenium with Chrome. `HEADLESS` env var controls headless mode. +- Tests run in parallel by default (`parallelize(workers: :number_of_processors)`) + +### Key conventions + +- Ruby style: `rubocop` with compact class/module style (`class Foo::Bar` not nested) +- Memoized instance variables require leading underscore (`_` prefix) per rubocop config +- Authorization via Pundit policies +- Background jobs via DelayedJob diff --git a/Gemfile b/Gemfile index 267d2cfd..29a84373 100644 --- a/Gemfile +++ b/Gemfile @@ -61,6 +61,7 @@ group :development do end group :test do + gem 'mini_magick' gem 'minitest-spec-rails' # allows rspec-like syntax gem 'minitest-stub-const' # provides stub_const helper for tests diff --git a/Gemfile.lock b/Gemfile.lock index a575165e..855841b2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -178,6 +178,8 @@ GEM marcel (1.0.4) matrix (0.4.2) method_source (1.1.0) + mini_magick (5.3.1) + logger mini_mime (1.1.5) minitest (5.25.1) minitest-spec-rails (7.3.0) @@ -398,6 +400,7 @@ DEPENDENCIES heroicon hiredis kaminari + mini_magick minitest-spec-rails minitest-stub-const omniauth diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index e71c2853..a55f7b5e 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1,6 +1,6 @@ <% title 'Home' %> -
+

Create beautiful <%= render 'components/highlight', text: 'dashboards' %> to track your /dev/null || true +kill -9 $(lsof -ti :3036) 2>/dev/null || true + +mkdir -p tmp/pids + +foreman start -f Procfile.dev "$@" diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index f4ad356c..da83cf4c 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true require 'test_helper' +require 'support/screenshot_helpers' class ApplicationSystemTestCase < ActionDispatch::SystemTestCase include SignInHelper include NavigationHelper + include ScreenshotHelpers Capybara.server = :puma, { Silent: true } # removes noisy logs when launching tests diff --git a/test/screenshots/home.png b/test/screenshots/home.png new file mode 100644 index 00000000..d7ac60f0 Binary files /dev/null and b/test/screenshots/home.png differ diff --git a/test/support/screenshot_helpers.rb b/test/support/screenshot_helpers.rb new file mode 100644 index 00000000..81e07522 --- /dev/null +++ b/test/support/screenshot_helpers.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "mini_magick" +require "open3" + +module ScreenshotHelpers + SCREENSHOTS_DIR = Rails.root.join("test/screenshots") + SCREENSHOT_DIFF_THRESHOLD = 0.01 # 1% difference + + def capture_screenshot(name) + return if ENV["CI"] + + hide_toasts + + target_path = SCREENSHOTS_DIR.join("#{name}.png") + FileUtils.mkdir_p(target_path.dirname) + + Tempfile.create(["screenshot", ".png"]) do |temp_file| + page.save_screenshot(temp_file.path) # rubocop:disable Lint/Debugger + + should_save = !File.exist?(target_path) || images_differ?(temp_file.path, target_path) + FileUtils.cp(temp_file.path, target_path) if should_save + end + end + + private + + def hide_toasts + page.execute_script("document.querySelector('[data-rht-toaster]')?.remove()") + rescue StandardError # ignore if JS execution fails + nil + end + + def images_differ?(new_image_path, existing_image_path) + new_image = MiniMagick::Image.open(new_image_path) + existing_image = MiniMagick::Image.open(existing_image_path) + return true if new_image.dimensions != existing_image.dimensions + + _, stderr, = Open3.capture3("compare", "-metric", "AE", existing_image_path.to_s, new_image_path.to_s, "null:") + different_pixels = stderr.to_i + total_pixels = existing_image.width * existing_image.height + (different_pixels.to_f / total_pixels) > SCREENSHOT_DIFF_THRESHOLD + end +end diff --git a/test/system/static_pages_test.rb b/test/system/static_pages_test.rb index 9a4c0d23..a01a18b3 100644 --- a/test/system/static_pages_test.rb +++ b/test/system/static_pages_test.rb @@ -11,6 +11,7 @@ class StaticPagesTest < ApplicationSystemTestCase sign_in create(:user) visit root_url assert_text 'START NOW' + capture_screenshot('home') click_on 'Terms' assert_text 'Terms of Service' click_on 'Privacy'