Skip to main content

Branching

We use GitHub Flow with a naming convention:
Examples:
  • feature/VOT-33-participant-view
  • fix/VOT-501-sqlite-busy
  • chore/setup-repo
All branches are created from and merged back to main.

Commit Messages

  • Use imperative mood: “Add voting controls”, not “Added voting controls”
  • Include the Linear issue ID in the commit message: VOT-33: Add participant voting controls
  • Keep the first line under 72 characters

Pull Requests

PR titles must include the issue ID:

Code Style

The project uses rubocop-rails-omakase — the default Rails style guide. Run the linter before committing:
Auto-fix safe violations:

Testing

All tests live under the test/ directory following standard Rails conventions: Run all tests:
Run a specific test file:
Run the full CI check (tests + linting + security):

System tests

System tests live in test/system/ and drive a real headless Chrome/Chromium browser via Capybara + Selenium, rendering real pages with Turbo, Stimulus, and the enforcing nonce-based CSP. They are not run by bin/rails test; run them explicitly:
They need the Tailwind build output present (bin/rails tailwindcss:build). If the system chromedriver matches Chromium rather than google-chrome-stable, point Selenium at the matching binary:
CI runs test and test:system as separate jobs; the system-test job builds Tailwind first. Never run the unit suite and the system suite at the same time on one checkout: they share the SQLite test databases.

Workers and browsers

The system suite runs on half the logical processors. ApplicationSystemTestCase calls parallelize(workers: [ Etc.nprocessors / 2, 1 ].max), which replaces test_helper.rb’s one worker per logical processor (four workers on an eight-thread machine). Each worker runs its own Puma server and one headless Chrome for every Capybara session a test drives — the default session and each using_session(:name). With one worker per logical processor the browsers starved the machine and unrelated tests missed Capybara’s waits at random (seen once VOT-445 added a five-browser test); half the workers finish the suite sooner, and green. Set PARALLEL_WORKERS to choose the number of workers yourself; it overrides the halving. Without it, a run of 50 tests or fewer (one file, say) runs in a single process: that is Rails’ parallelization threshold. A new system test opens at most two browsers: the default session and one named session. The existing exceptions are projector_live_controls_test.rb (VOT-445: the pause-and-message test drives five browsers, the meeting-end test four) and meeting_closed_test.rb (four).

Turbo-upgrade canaries

The secretary shell leans on Turbo 8 / turbo-rails 2.0.23 behaviour that is not public API. These browser tests fail loudly when an upgrade changes it; run them first after bumping turbo-rails:
  • SH17.2 (test/system/secretary_shell_test.rb, «rail visits keep the stream source and its connection») — the permanent <turbo-cable-stream-source> keeper in application.js: Turbo moves a data-turbo-permanent element into the new body (Bardo), and the keeper parks the element’s subscription across that move.
  • PF11.2 (test/system/secretary_preferences_test.rb, «a broadcast refresh never flips the pane back») — the pane-state guard on turbo:before-morph-attribute in secretary_shell_controller.js.
  • A10 (test/system/secretary_pages_test.rb, the four «A10 …» tests) — the own-write refetch: turbo:submit-end / turbo:frame-load ordering, and Turbo reloading a refresh="morph" frame with a src during a page refresh (held off with data-turbo-permanent for one render).
  • VOT-474 — in test/system/secretary_pages_test.rb the in-page link tests (Turbo visits href="#…" itself; the shell cancels the click), the CSV sync preview kept through a refresh and the bot count kept through a refresh (turbo:before-morph-element); in test/system/secretary_question_form_refresh_test.rb «a rail preview superseded mid-body logs no console error» (the unawaited body read of a request Turbo aborted, consumed in application.js).

Screenshots

Two opt-in switches save screenshots. With neither set, the tests run exactly the same and write nothing.
  • PROJECTOR_SCREENSHOTS — development screenshots from the projector system tests (save_projector_screenshot in test/support/projector_system_helper.rb). 1 writes to the test’s own folder under Mockup/, relative to the checkout (/Mockup/ is git-ignored, so these are never committed); any other value is the target directory — from a worktree, pass the main checkout’s absolute path. Files are numbered after the highest NN- prefix already in the folder, so earlier screenshots are never overwritten.
  • DOCS_SCREENSHOTS — regenerates the projector guide’s images in docs/images/projektor/, which are committed with the guide. The test that captures them, test/system/docs_screenshots_test.rb, is skipped unless the switch is set, so every normal system run reports it as one skip:

Flakes

  • A run whose failures are all Selenium::WebDriver::Error::InvalidSessionIdError, with zero assertion failures, is a Chromium crash, not a regression: re-run it once before calling it one.
  • Assert a flash toast with assert_toast (ApplicationSystemTestCase), not assert_text. flash_controller.js fades a toast five seconds after it appears (.flash-dismissed, opacity: 0), which Selenium reports as not displayed, so an assert_text that reaches the page late in a busy run misses a notice that was there all along. assert_toast matches #flash-messages .flash-toast with visible: :all, faded or not.

Code coverage

Coverage is measured by SimpleCov and is opt-in via the COVERAGE=1 flag (kept off by default so the normal test run stays fast). Results from all parallel workers are merged automatically:
Baseline at introduction (VOT-377): ~89% line coverage, ~75% branch coverage. No minimum is enforced yet, so coverage never fails the build.

N+1 query guard

The bullet gem watches the four meeting hot paths — participant, chair, secretary, and projector. In development it is non-blocking and logs offenders to log/bullet.log. In the test suite, test/integration/n1_hot_paths_test.rb seeds a rich meeting and renders every hot path with Bullet.raise enabled (see test/support/bullet_helper.rb), so a newly introduced N+1 fails the build. Only n_plus_one_query is enforced; UnusedEagerLoading and CounterCache are advisory and stay disabled in the guard. Every request runs in its own Bullet window. BulletHelper (included by n1_hot_paths_test.rb and chair_podium_contract_test.rb) wraps the integration request methods — get, post, patch, put, head, delete and follow_redirect!: before a request the window is emptied, after it the request’s own N+1s raise Bullet::Notification::UnoptimizedQueryError, and a fresh window opens for the next request. Teardown discards whatever the test did after its last request. So seeding between requests is never reported, and a request is never excused by rows the test loaded or created before it: Bullet does not report an N+1 on rows already loaded or created in its window, which is how one window for the whole test (the guard’s first setup) hid the requests that followed the seeding. Giving each request its own window (VOT-447) found an N+1 the guard had missed: «Start møte» purging rehearsal quick polls loaded each option’s votes on its own (MeetingTestDataPurge now preloads them). Seed freely between requests; no reset call is needed. To check a block that is not a request, wrap it in bullet_request_window { ... } (see test/integration/bullet_helper_test.rb). When a view needs poll or agenda data, reuse the existing eager-loading scopes instead of adding ad-hoc includes:

Security

Run security checks before submitting:

Language

  • Code, comments, and technical docs: English
  • UI text and user guides: Norwegian (Bokmål)
  • Locale file: config/locales/nb.yml — the app ships Norwegian only (VOT-475). The locale setup stays: every UI string goes through t(), never a hardcoded text, and config.i18n.available_locales is derived from the files in config/locales, so adding config/locales/<locale>.yml makes that language available again. The profile’s language setting and the meeting’s «Språk på projektorskjermene» show themselves once more than one locale is available (LocaleHelper#locale_choice_available?), with no code change

Project Tracking

We use Linear for issue tracking. The team is Voteringsapp and issues use the VOT- prefix.
Last modified on October 21, 2026