Branching
We use GitHub Flow with a naming convention:feature/VOT-33-participant-viewfix/VOT-501-sqlite-busychore/setup-repo
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:Testing
All tests live under thetest/ directory following standard Rails conventions:
Run all tests:
System tests
System tests live intest/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:
bin/rails tailwindcss:build). If
the system chromedriver matches Chromium rather than google-chrome-stable,
point Selenium at the matching binary:
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 bumpingturbo-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 inapplication.js: Turbo moves adata-turbo-permanentelement into the new body (Bardo), and the keeper parks the element’ssubscriptionacross that move. - PF11.2 (
test/system/secretary_preferences_test.rb, «a broadcast refresh never flips the pane back») — the pane-state guard onturbo:before-morph-attributeinsecretary_shell_controller.js. - A10 (
test/system/secretary_pages_test.rb, the four «A10 …» tests) — the own-write refetch:turbo:submit-end/turbo:frame-loadordering, and Turbo reloading arefresh="morph"frame with asrcduring a page refresh (held off withdata-turbo-permanentfor one render). - VOT-474 — in
test/system/secretary_pages_test.rbthe in-page link tests (Turbo visitshref="#…"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); intest/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 inapplication.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_screenshotintest/support/projector_system_helper.rb).1writes to the test’s own folder underMockup/, 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 highestNN-prefix already in the folder, so earlier screenshots are never overwritten.DOCS_SCREENSHOTS— regenerates the projector guide’s images indocs/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), notassert_text.flash_controller.jsfades a toast five seconds after it appears (.flash-dismissed,opacity: 0), which Selenium reports as not displayed, so anassert_textthat reaches the page late in a busy run misses a notice that was there all along.assert_toastmatches#flash-messages .flash-toastwithvisible: :all, faded or not.
Code coverage
Coverage is measured by SimpleCov and is opt-in via theCOVERAGE=1 flag (kept off by default so the normal test
run stays fast). Results from all parallel workers are merged automatically:
N+1 query guard
Thebullet 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 throught(), never a hardcoded text, andconfig.i18n.available_localesis derived from the files inconfig/locales, so addingconfig/locales/<locale>.ymlmakes 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 theVOT- prefix.