Browser Testing Guide (Behat/Mink)
Purpose and Audience
This guide documents how to write and run browser-based end-to-end tests using Behat/Mink with headless Chrome (via Chrome DevTools Protocol). These tests validate UI behavior by driving a real browser, covering the ~290 TM-UI test cases identified in the STD domain specifications. See the V3 Testing Dashboard for current browser test status (320 scenarios across 25 files).
Audience: Developers, QA Engineers, AI agents writing TM-UI tests.
Supersedes: The Selenium IDE guide describes the legacy .side file approach (12 smoke tests). All 12 Selenium IDE tests have been converted to Behat/Mink scenarios, extended with comprehensive Tier 1, Tier 2, Wave 1, and Wave 2 coverage, for a total of 314 scenarios across 24 feature files integrated into the Behat test runner.
See the Testing Guide for API-level Behat tests (step definitions, config loading, assertions).
Architecture
┌─────────────────────────────────────────────────────────┐
│ HOST MACHINE │
│ │
│ ┌──────────────────┐ ┌────────────────────────┐ │
│ │ Chromium (CDP) │◄─────► Behat + Mink │ │
│ │ Port 9222 │ │ (BrowserContext.php) │ │
│ │ Headless, no GUI │ └───────────┬────────────┘ │
│ └──────────────────┘ │ │
│ │ │
│ ┌──────────────────┐ ┌────────────▼────────────┐ │
│ │ MySQL │◄─────► Laravel (artisan serve) │ │
│ │ pcrai_test │ │ Port 8000 │ │
│ │ 127.0.0.1:3306 │ │ APP_ENV=testing │ │
│ └──────────────────┘ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
Key design decisions:
| Decision | Choice | Why |
|---|---|---|
| Browser driver | dmore/chrome-mink-driver (CDP) | Direct protocol — no Selenium server or chromedriver binary needed |
| Auth bypass | scalableapp/laravel-test-login | Exposes /_test/login/{email} route in testing env — session cookies persist across page visits |
| Suite isolation | Separate browser suite in behat.yml | Existing API tests (default suite) are unaffected |
| Database | pcrai_test (main) | Not the pool databases (pcrai_test_01–10) which are reserved for API test agents |
Prerequisites
1. Pusher credentials in .env
The Vue SPA requires Pusher credentials to initialize (the laravel-echo plugin fails silently without them, preventing Vue from mounting). Add to code/.env:
PUSHER_APP_ID=1578682
PUSHER_APP_KEY=ffad75f7f5a1565a9ebf
PUSHER_APP_SECRET=3dcaece017c24a086121
PUSHER_APP_CLUSTER=mt1
VUE_APP_PUSHER_APP_KEY=ffad75f7f5a1565a9ebf
VUE_APP_PUSHER_APP_CLUSTER=mt1
The VUE_APP_* vars are baked into the frontend at build time. The backend PUSHER_* vars are read at runtime.
2. Frontend build (one-time)
The Vue SPA must be compiled for the index.blade.php view to exist:
cd code/resources/frontend
npm install
npm run build
This outputs compiled assets to code/public/ and generates code/resources/views/index.blade.php. Re-run after frontend code changes or after changing VUE_APP_* env vars.
3. Composer packages (already installed)
cd code
composer require --dev behat/mink \
"friends-of-behat/mink-extension:^2.7" \
"dmore/chrome-mink-driver:^2.9" \
scalableapp/laravel-test-login
Note:
scalableapp/laravel-test-loginis sourced from theBranpolo/laravel-test-loginGitHub repository via a VCS repository entry incomposer.json.
Running Browser Tests
Quick start (3 terminals)
Terminal 1 — Start headless Chrome:
chromium-browser --headless --disable-gpu --no-sandbox \
--remote-debugging-port=9222 --disable-dev-shm-usage &
# Verify:
curl -s http://127.0.0.1:9222/json/version | head -2
Terminal 2 — Start Laravel dev server:
cd code
PHP_CLI_SERVER_WORKERS=8 RATE_LIMIT_MAX_ATTEMPTS=9999 APP_ENV=testing \
MAIL_MAILER=log \
DB_HOST=127.0.0.1 DB_AUDIT_HOST=127.0.0.1 \
DB_DATABASE=pcrai_test DB_USERNAME=sail DB_PASSWORD=password \
php artisan serve --host=127.0.0.1 --port=8000
Critical:
PHP_CLI_SERVER_WORKERS=8enables parallel request handling. Without it, the built-in PHP server is single-threaded and cannot serve the ~20 JS chunks the SPA needs concurrently.
Terminal 3 — Run tests:
cd code
APP_URL=http://127.0.0.1:8000 \
DB_HOST=127.0.0.1 DB_DATABASE=pcrai_test DB_USERNAME=sail DB_PASSWORD=password \
./vendor/bin/behat --suite=browser
Environment variables
| Variable | Required | Default | Notes |
|---|---|---|---|
PHP_CLI_SERVER_WORKERS | Yes (on artisan) | 1 | Set to 8 — SPA has ~20 JS chunks that need parallel loading |
APP_ENV | Yes (on artisan) | — | Must be testing for /_test/login routes to register |
APP_URL | Yes (on behat) | http://127.0.0.1:8000 | Base URL the browser navigates to |
RATE_LIMIT_MAX_ATTEMPTS | Yes (on artisan) | — | Set to 9999 to disable rate limiting |
DB_HOST | Yes (on artisan) | — | 127.0.0.1 (not mysql — no Docker) |
DB_AUDIT_HOST | Yes (on artisan) | — | 127.0.0.1 |
DB_DATABASE | Yes (on artisan and behat) | — | pcrai_test for single-agent. Must also pass to behat for PDO steps (e.g., aUserWithRoleExists) |
MAIL_MAILER | Yes (on artisan) | — | Set to log — prevents SendGrid TypeError on config import API (no API key in test env) |
CHROME_DEBUG_PORT | No | 9222 | Override if Chrome is on a different port |
Stopping background processes
kill $(lsof -t -i:8000) 2>/dev/null # artisan serve
kill $(lsof -t -i:9222) 2>/dev/null # headless Chrome
Configuration
behat.yml
The browser suite is defined alongside the existing default (API) suite:
default:
suites:
default:
# ... existing API test config ...
browser:
paths:
- '%paths.base%/../tests/exports/browser'
contexts:
- BrowserContext
BrowserContext.php
Located at code/features/bootstrap/BrowserContext.php. This context:
- Creates a fresh Chrome CDP session per scenario (
@BeforeScenario/@AfterScenario) - Provides step definitions for browser navigation and assertions
- Does not extend
BaseFeatureContext(no database seeding, no API traits)
Writing Browser Tests
Feature file location
All browser test .feature files live in:
tests/exports/browser/
Available step definitions
Authentication
Given I am logged in as user "system@diagnostics.ai"
Visits /_test/login/{email}, which authenticates via session cookie. The user must exist in the database. Accepts email addresses or user IDs (UUIDs).
Important: User IDs in this app are UUIDs, not integers. Use email addresses for readability.
Given a user "viewer@test.com" with role "viewer" exists
Creates a user via direct MySQL insert (bypasses Cognito, which is unavailable in test environments). Supported roles: admin, editor, viewer. The user is available immediately for login via /_test/login/{email}.
API Data Seeding
Given the test configuration "BT-9510/EZ - San Juan Capistrano CA_v3_from clone.xlsx" is loaded via API
Given the test run file "BT-9510/TV_BOTH_NEG.json" is uploaded via API
Given the active site is set to "site-uuid-here" via API
Seed test data via cURL API calls (not browser). Paths are relative to tests/support_files/. The API session is established automatically via /_test/login with XSRF token handling. Config import uses multipart upload to POST api/config-data; run upload sends JSON to POST api/runs.
Navigation
When I visit "/login"
When I click the sidebar item "Audits"
When I click the element ".my-class"
When I click the button "Apply"
When I click the tab "Assay Summary"
When I click the first run in the list
I visitnavigates to the given path (appended toAPP_URL)I click the sidebar itemfinds bydata-menuattribute, sidebar button span text, ordiv[@title]I click the elementfinds by CSS selectorI click the buttonfinds by Mink'sfindButton()or XPath button textI click the tabfinds by button span text (report page tabs)I click the first run in the listclicks the first.data-cellelement
Filter Interactions
When I open the filter "Actions"
When I select the filter option "CMV"
When I select filter checkbox 0 for "actions"
When I click apply
I open the filterclicks the.filter-namediv matching the given textI select the filter optionclicks a.menu-button, label, or button matching the textI select filter checkboxclickslabel[for="checkbox-{index}-{group}"](0-indexed)I click applyclicks the Apply button
Form Interaction
When I fill in "search" with "CMV"
When I select "Administrator" from "role"
When I press key "Escape"
When I type "@john" into ".mention-input"
I fill inuses Mink'sfillField()— works for standard<input>and<textarea>elementsI select fromuses Mink'sselectFieldOption()with a fallback for Vue custom dropdowns (clicks the dropdown trigger, then selects the matching option)I press keydispatches a keyboard event viaevaluateScriptwith a keyCode map. Blurs focus first for compatibility withv-hotkey, which ignores events on INPUT/TEXTAREA/SELECT (see Gotchas)I type intoprovides char-by-char input for<input>/<textarea>and also supportscontenteditableelements (usesdocument.execCommand('insertText'))
Wait Helpers
And I wait for the element "[data-screen_title]"
And I wait for the element ".data-cell" to appear
And I wait for the element "[data-screen_title]" to contain "Run Files"
And I wait 2 seconds
I wait for the elementpolls for up to 10s until CSS selector matchesto appearvariant uses 15s timeoutto containpolls until element has matching text content (15s timeout)I wait N secondsis a fixed sleep (use sparingly, prefer element waits)
Essential for SPA pages where Vue renders asynchronously after API calls complete.
Content Assertions
Then I should see "Welcome"
Then I should see an element "input[name=username]"
Then I should not see an element ".error-banner"
Then the element "[data-screen_title]" should contain "Audit Trail Viewer"
Then I should see at least 3 elements matching ".data-cell"
I should seechecks text appears in page HTMLI should see an elementchecks CSS selector existsI should not see an elementchecks CSS selector does NOT existthe element should containchecks element's text contentI should see at least N elementscounts matching elements
Then the element "#dark-mode-toggle" should be checked
the element should be checkeduses JavaScriptel.checkedto verify radio button or checkbox state (the DOMcheckedproperty, not the HTML attribute)
File Interaction
When I attach the file "BT-9510/TV_BOTH_NEG.json" to "files[]"
Attaches a file to a form field. Path is relative to tests/support_files/.
Page Metadata
Then the page title should contain "ArcticHare"
Then the response status should be 200
Gotchas and Lessons Learned
Login form fields
The login form uses name="username" (not name="email"). Check actual field names before writing selectors.
Rate limiting
The app has request rate limiting enabled by default. Without RATE_LIMIT_MAX_ATTEMPTS=9999 on the artisan serve process, requests will be redirected to /too-many-requests after a few hits.
Pusher credentials (silent Vue failure)
Without VUE_APP_PUSHER_APP_KEY and VUE_APP_PUSHER_APP_CLUSTER baked into the build, the laravel-echo plugin throws during ES module initialization. This silently prevents Vue from mounting — #app exists but has 0 children and the page is blank. There are no console errors visible. This is the most likely cause of "page loads but nothing renders."
Multi-worker server (critical for SPA)
php artisan serve is single-threaded by default. The SPA has ~20 JS chunks loaded via <script type="module" defer>. Without PHP_CLI_SERVER_WORKERS=8, these load sequentially at ~500ms each, making the SPA take 10+ seconds to boot. With 8 workers, all chunks load in parallel (~60ms total).
SPA vs Blade pages
- Blade pages (
/login,/change-password,/maintenance,/unauthorized,/too-many-requests) render server-side and are immediately available. - SPA pages (
/and all authenticated routes) require the frontend build (npm run build). Without it, you getView [index] not found(500 error). - After login, the
guestmiddleware on/loginredirects to/(the SPA). If the SPA isn't built, this 500s. - SPA pages require
I wait for the elementsteps because Vue rendering is asynchronous.
Session cookies and Chrome
The dmore/chrome-mink-driver maintains session cookies across visit() calls within a scenario. This means:
/_test/login/{email}sets the Laravel session cookie- Subsequent
visit()calls carry that cookie automatically - Each
@BeforeScenariostarts a fresh browser session (no cookie leakage between scenarios)
No Docker
MySQL runs directly on the host. Always use DB_HOST=127.0.0.1, never mysql (the Docker service name).
Database choice
Use pcrai_test for browser tests. The pool databases (pcrai_test_01 through pcrai_test_10) are reserved for parallel API test agents.
MAIL_MAILER=log (SendGrid crash)
The config import API endpoint (POST api/config-data) triggers email notifications. Without MAIL_MAILER=log on the artisan serve process, it attempts to call SendGrid::send() which throws a TypeError because no API key is configured in the test environment. Always start artisan serve with MAIL_MAILER=log.
DB_DATABASE on behat process
The aUserWithRoleExists step definition creates users via direct PDO (bypassing Eloquent/Cognito). It reads the DB_DATABASE environment variable to connect. If DB_DATABASE is only set on the artisan serve process but not on the behat process, PDO will connect to the wrong database (or fail). Always pass DB_DATABASE to both artisan serve and the behat command.
migrate:fresh requires --path flags
The migrate:fresh command requires explicit --path flags — without them, only 2 of 276 migrations run (telescope + jobs), then the seeder crashes on the missing client_configurations table:
php artisan migrate:fresh --path=database/migrations --path=database/migrations/app --path=database/migrations/audit --seed
logged_in_site_id after seeding
The seeder does not set logged_in_site_id on user records. Without this field, site-dependent pages (Run Files, Reports, etc.) return empty data. After seeding, update the user record:
UPDATE users SET logged_in_site_id = (SELECT id FROM sites LIMIT 1) WHERE logged_in_site_id IS NULL;
help_items table not populated by seeder
The database seeder does not insert any help_items records. Tests that interact with help content (TC-HELP-003 through TC-HELP-010) need manual INSERT statements with correct data types: tags must be a JSON array (e.g., '["import"]'), and page_id must be an integer.
Feature flags disabled by default
The database seeder sets most feature flags to is_enabled = 0. Tests that depend on specific UI features will fail silently (elements simply don't render). After migrate:fresh --seed, enable required flags:
UPDATE features SET is_enabled = 1 WHERE code IN ('assay_summary', 'user_tagging_in_comments', 'activate_archive_mode', 'outcome_summary', 'westgard', 'run_plate_map');
The the feature :code is enabled step definition can also enable flags per-scenario in feature files.
Config and run data pre-loading
Browser tests that need run data (AMB scenarios, run report scenarios) must have config and run files pre-loaded before the browser suite runs. The browser suite has no database seeding capability. Use the Behat API suite steps (Given the test configuration ... is loaded via API / Given the test run file ... is uploaded via API) or pre-load via a separate behat run targeting the default suite.
Chrome SingletonLock
Headless Chrome may leave a stale SingletonLock file at /home/aron/snap/chromium/common/chromium/SingletonLock after an unclean shutdown. If this file exists, Chrome refuses to start with a Failed to create a ProcessSingleton error. Always remove it before starting Chrome:
rm -f /home/aron/snap/chromium/common/chromium/SingletonLock
iPressKey limitation with letter keys
The When I press key :key step dispatches a KeyboardEvent with the correct keyCode. However, for single letter keys (p, i, etc.), the dispatched event does not reliably trigger v-hotkey handlers. This appears to be a keyCode mapping issue with the library. Workaround: Use I click the element with a data-attribute selector targeting the button directly (e.g., [data-runfile_report_top_button='Print runfile report'] instead of pressing p).
User creation via API fails
POST api/users calls CognitoUserManager::createCognitoUser() which is unavailable in test environments. Use Given a user :email with role :role exists which creates users via direct MySQL insert, bypassing Cognito entirely.
v-hotkey FORBIDDEN_NODES
The When I press key step must blur focus before dispatching the keyboard event. The Vue v-hotkey directive ignores keyboard events that originate from INPUT, TEXTAREA, or SELECT elements (these are its FORBIDDEN_NODES). The step calls document.activeElement.blur() before dispatching to ensure v-hotkey handlers fire.
Comment input is contenteditable
The comment/notes input in the Run Report is a contenteditable <div>, not an <input> element. Access it via Assay Summary > Run Notes (collapsed by default — click the tab first). Use When I type :text into :selector which handles contenteditable via document.execCommand('insertText').
Chrome CDP session stability
The dmore/chrome-mink-driver creates a new Chrome tab per scenario (@BeforeScenario / @AfterScenario). The primary source of CDP flakiness was stale tabs accumulating after failed stopBrowser() calls. Two mechanisms now address this:
-
closeStaleChromeTabs()— Called in@BeforeScenario, this closes all existing Chrome tabs viaGET http://127.0.0.1:9222/jsonfollowed byGET /json/close/{id}for each tab. This prevents the "connection dead" errors caused by leftover tabs from prior scenarios. -
ensureChromeRunning()— Also called in@BeforeScenario, this performs a health check viaGET /json/version. If Chrome has crashed or is unresponsive, it automatically kills the stale process and restarts Chrome with the standard headless flags.
These two fixes have significantly reduced flake rate compared to the original ~3 flaky scenarios per run. Chrome can still become unstable if /tmp is low on space (it needs temp storage for tab profiles). Symptoms: Browser crashed or Timed out waiting for element on scenarios that pass individually.
Mitigations:
- The stale tab cleanup and auto-restart handle most transient CDP failures automatically
- Re-run the suite — remaining flaky failures are non-deterministic and will pass on retry
- If
/tmpis full, start Chrome withTMPDIR=/path/to/disk --user-data-dir=/path/to/disk/profile - Ensure
PHP_CLI_SERVER_WORKERS=8(not 4) — fewer workers slow SPA loading past the 10s timeout
Flaky timing — rerun before confirming failure
Some browser tests are timing-sensitive (Vue rendering, API responses, toast notifications). A single failure does NOT confirm a bug — always rerun the specific scenario or file before investigating. Common flaky patterns:
- Config categories on
/client-configurationneed 2+ seconds to render after page load - Toast notifications (
vue-notification-template) auto-dismiss after 4 seconds — slow test execution can miss them - Filter dropdowns need API data to populate before
.filter-nameelements become interactive - Chart rendering (Highcharts) may take extra time on first load
If a scenario fails once but passes on immediate rerun, it is a timing flake, not a real failure.
Parallel Chrome instances — use /shared for user-data-dir
Each headless Chrome instance needs --user-data-dir on a non-tmpfs filesystem to avoid filling /tmp. BrowserContext.php's ensureChromeRunning() already uses /shared/chrome-profiles/port-{port}.
8+ simultaneous Chrome instances are fine with sufficient RAM and --user-data-dir on /shared. Each instance uses ~100-200MB RAM.
Port allocation for parallel agents:
| Agent | Artisan Port | Chrome Port | Database |
|---|---|---|---|
| 1 | 8001 | 9223 | pcrai_test_01 |
| 2 | 8002 | 9224 | pcrai_test_02 |
| 3 | 8003 | 9225 | pcrai_test_03 |
| 4 | 8004 | 9226 | pcrai_test_04 |
| 5 | 8005 | 9227 | pcrai_test_05 |
| 6 | 8006 | 9228 | pcrai_test_06 |
| 7 | 8007 | 9229 | pcrai_test_07 |
| 8 | 8008 | 9230 | pcrai_test_08 |
Port 8000/9222 reserved for main session. Each agent needs its own artisan serve + Chrome + pool DB.
Headless-only
This is an Ubuntu server with no GUI. Chrome must run with --headless --no-sandbox. There is no way to visually watch the browser.
Performance
Browser tests are fast with this stack:
| Metric | Value |
|---|---|
| 314 scenarios (full suite) | ~5-8 min typical |
| Blade page load + form assertion | ~0.3s |
Auth + SPA page render (with wait) | ~0.4-0.6s per page |
| Vue SPA initial mount (first load) | ~0.4s (with multi-worker server) |
The stale tab cleanup (closeStaleChromeTabs) and auto-restart (ensureChromeRunning) mechanisms introduced in Tier 2 have significantly reduced CDP flakiness. See Chrome CDP session stability for details.
Cognito blocks user CRUD in test environment
The StoreUserAction, BlockUserAction, UnblockUserAction, UpdateUserTypeAction, and DeleteUser flows all call CognitoUserManager methods which throw exceptions without AWS Cognito credentials. In the test environment, user management browser tests are limited to display-state verification (column values, toggle states, button presence/disabled state). User write operations (create, edit role, disable, delete) cannot be tested end-to-end via the browser. Use Given a user :email with role :role exists for user creation (direct MySQL, bypasses Cognito).
No I should not see :text step definition
BrowserContext.php has I should not see an element :selector (CSS-based) but does NOT have a text-based negative assertion (I should not see "some text"). Workarounds:
- Use
I should not see an element :selectorwith a specific CSS selector targeting the element containing the text - Use positive assertions instead (check for what SHOULD be there rather than what shouldn't)
- Check element count:
I should see at least 0 elements matching :selectorwon't work (step requires >= 1), so use the element-absence step instead
PushIconButton SVG buttons without data attributes
Some PushIconButton components (e.g., Export Configuration Sheets, Customer-Friendly Export, Create User) render only an SVG icon with no visible text and no data-top_button attribute. These are unreachable by both Mink's findButton() (no text) and CSS selectors (SVG namespace). Workarounds:
- Use a sibling/parent CSS selector if the button has a unique position:
.flex.items-center > button:not([data-top_button]) - Use
evaluateScript()via a custom step (but remember: NO BrowserContext.php edits during waves) - Test the feature via alternative paths (e.g., verify the modal can be opened via keyboard shortcut, or test the result of the action via API)
- For Wave 7+, consider adding
data-top_buttonattributes to these buttons in the Vue source
BT-9510 config causes 500 error on import
The BT-9510/EZ - San Juan Capistrano CA_v3_from clone.xlsx config file causes a 500 error during import due to a null mix_name in RoleAliasesValidator. Use BT-9560/Merk_PP_2.19.3.xlsx (64KB, known good) for all config import tests. Large configs (>100KB) may also hit the 30-second max_execution_time on artisan serve.
Version tagging applies to browser tests
The same multi-version tagging scheme used for API tests (@V3_0_0, @V3_0_1) applies to browser tests. If a UI behavior changes between versions, create version-tagged scenario copies with the appropriate tag. Tests with no version tag are universal and run against all versions. The --target-version flag on behat-optimizer.py filters browser scenarios the same way as API scenarios. See Multi-Version Testing in the Dev Testing Guide for the full scheme.
DB state persists between browser scenarios
Unlike API tests which use RefreshDatabase, browser tests do NOT reset the database between scenarios. Data created by one scenario (uploaded configs, created users, created sites) persists into subsequent scenarios. Design strategies:
- Order-dependent scenarios: Place empty-state tests before data-creation tests in the feature file
- Explicit reset: Click "Reset" buttons or use API calls to clear data at the start of scenarios that need empty state
- Tolerant assertions: Use
I should see at least N elementsinstead of exact counts - Per-wave DB reset: Run
migrate:fresh --seedbefore each wave (already standard practice)
Chrome snap SingletonLock — sequential startup required
When running multiple headless Chrome instances with the snap version of Chromium, each instance creates a global SingletonLock at /home/aron/snap/chromium/common/chromium/SingletonLock. Starting multiple instances simultaneously fails. Must start instances sequentially with rm -f /home/aron/snap/chromium/common/chromium/SingletonLock between each startup. Allow 2-3 seconds per instance for it to initialize before starting the next. BrowserContext.php's ensureChromeRunning() handles single-instance auto-start but cannot handle the SingletonLock race when multiple agents start simultaneously.
Comparison: Behat/Mink vs Selenium IDE
| Feature | Behat/Mink (new) | Selenium IDE (legacy) |
|---|---|---|
| Integration | Same test runner as API tests | Standalone .side files |
| Auth | Programmatic (/_test/login) | Manual TOTP flow via external site |
| Browser | Headless Chrome via CDP | Selenium IDE extension or Ghost Inspector |
| Speed | ~0.4s per scenario | ~10-30s per test (with MFA) |
| CI integration | ./vendor/bin/behat --suite=browser | Requires Selenium server setup |
| Test count | 314 scenarios across 24 feature files | 12 smoke tests |
| Maintenance | PHP step definitions | JSON command arrays |
Extending the Context
All common TM-UI patterns (authentication, navigation, filter interaction, element waits, content assertions, file attachment, API seeding) are already implemented as step definitions in BrowserContext.php (see Available step definitions above).
When adding new step definitions beyond the existing 38, add them to BrowserContext.php.
Tier 2 patterns worth noting:
- Role-based testing:
Given a user :email with role :role existscreates users via direct MySQL insert (Cognito is unavailable in test environments). This enables testing sidebar visibility and feature permissions per role. - Keyboard events:
When I press key :keymust blur focus first and dispatch usingkeyCodefor compatibility withv-hotkey. The key name is mapped to a keyCode (e.g.,Escape-> 27). - @ mentions / contenteditable:
When I type :text into :selectorhandlescontenteditableelements (e.g., the Run Notes comment input) usingdocument.execCommand('insertText'), sincefillField()does not work on non-input elements. - Radio/checkbox state:
Then the element :selector should be checkedreads the JavaScriptel.checkedproperty, which reflects the live DOM state rather than the HTML attribute.
Useful Mink API patterns for custom steps:
// Fill in a form field (for forms beyond the existing step definitions)
$this->session->getPage()->fillField('field_name', 'value');
// Execute JavaScript (e.g., to read Vue component state)
$result = $this->session->evaluateScript('document.title');
// Take screenshot (for debugging flaky CDP failures)
$screenshot = $this->session->getScreenshot();
file_put_contents('/tmp/debug.png', $screenshot);
Wait for element (SPA pattern)
SPA pages render asynchronously. Always use I wait for the element before asserting on Vue-rendered content:
When I visit "/runs"
And I wait for the element "[data-screen_title='Run Files']"
Then I should see an element "[data-screen_title='Run Files']"
The wait step polls every 100ms for up to 10 seconds (configurable). Without it, assertions may fail because Vue hasn't finished rendering.
The data-screen_title attribute is a reliable indicator that a Vue page component has mounted — every SPA view has a <Title data-screen_title="..." /> component.
File Inventory
| File | Purpose | Scenarios |
|---|---|---|
code/behat.yml | Suite configuration (browser suite added) | — |
code/features/bootstrap/BrowserContext.php | Mink context with CDP Chrome driver + API seeding (41 step definitions) | — |
tests/exports/browser/smoke.feature | Login page + authenticated SPA smoke | 2 |
tests/exports/browser/navigation.feature | SPA page rendering (Run Files, Users, Audit, Upload, Errors) | 5 |
tests/exports/browser/audit.feature | Audit page load + Actions filter | 2 |
tests/exports/browser/reports.feature | LJ Report, Outcomes Report, Trends Report, Westgard panel, filter combos | 9 |
tests/exports/browser/runfile-list.feature | Run Files list, search, filters, tabs, pagination, sort, column visibility, expand | 21 |
tests/exports/browser/runfile-report.feature | Run report page, well table, notifications, sort, action buttons, tabs, AMB UI | 14 |
tests/exports/browser/upload-runs.feature | Upload Runs page, file input, validation, role access, progress table, drag-drop | 13 |
tests/exports/browser/globalui.feature | Global UI elements, role indicators, infinite scroll, notification bell pane, notification load/pagination | 19 |
tests/exports/browser/globalui-idle-timeout.feature | Idle timeout warning, countdown, dismissal, inactivity logout (slow: ~4 min) | 3 |
tests/exports/browser/user-management.feature | User management page load + role display | 2 |
tests/exports/browser/filters.feature | Cross-page filter interactions, text search, date picker, state preservation, multi-select | 10 |
tests/exports/browser/help-keyboard.feature | Help widget, search, admin management, keyboard shortcuts, help content pages | 14 |
tests/exports/browser/tables.feature | Table pagination, column sort, column visibility toggles, row selection, empty state | 15 |
tests/exports/browser/user-settings.feature | Settings page controls, radio buttons, save/persist, timezone selector, preferences | 8 |
tests/exports/browser/role-tests.feature | Sidebar permissions per role, role badge display | 6 |
tests/exports/browser/comments.feature | Comment panel, @ mentions, email invite, notification prefs, Run Notes, role filtering | 16 |
tests/exports/browser/notifications.feature | Notification icons, counts, click-to-filter, ACE indicators, mark-read, dismiss | 10 |
tests/exports/browser/client-config.feature | Client Configuration page toggles | 6 |
tests/exports/browser/stdcurve-reanalyze.feature | Standard curve R2 + reanalyze archive | 8 |
tests/exports/browser/site-management.feature | Site list, creation, multisite | 9 |
tests/exports/browser/progress-states.feature | Loading states, skeleton, transitions | 11 |
tests/exports/browser/accession-history.feature | Well table, print model, outcomes | 3 |
tests/exports/browser/kit-config.feature | Kit Configuration viewer + RBAC | 16 |
tests/exports/browser/kit-config-import.feature | Kit Configuration import/export | 10 |
tests/exports/browser/config-io.feature | Config I/O pipeline, upload flow | 3 |
code/composer.json | Packages: behat/mink, mink-extension, chrome-mink-driver, laravel-test-login | — |
| Total | 25 feature files | 320 |