Skip to main content
Version: 3.0.1

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:

DecisionChoiceWhy
Browser driverdmore/chrome-mink-driver (CDP)Direct protocol — no Selenium server or chromedriver binary needed
Auth bypassscalableapp/laravel-test-loginExposes /_test/login/{email} route in testing env — session cookies persist across page visits
Suite isolationSeparate browser suite in behat.ymlExisting API tests (default suite) are unaffected
Databasepcrai_test (main)Not the pool databases (pcrai_test_0110) 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-login is sourced from the Branpolo/laravel-test-login GitHub repository via a VCS repository entry in composer.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=8 enables 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

VariableRequiredDefaultNotes
PHP_CLI_SERVER_WORKERSYes (on artisan)1Set to 8 — SPA has ~20 JS chunks that need parallel loading
APP_ENVYes (on artisan)Must be testing for /_test/login routes to register
APP_URLYes (on behat)http://127.0.0.1:8000Base URL the browser navigates to
RATE_LIMIT_MAX_ATTEMPTSYes (on artisan)Set to 9999 to disable rate limiting
DB_HOSTYes (on artisan)127.0.0.1 (not mysql — no Docker)
DB_AUDIT_HOSTYes (on artisan)127.0.0.1
DB_DATABASEYes (on artisan and behat)pcrai_test for single-agent. Must also pass to behat for PDO steps (e.g., aUserWithRoleExists)
MAIL_MAILERYes (on artisan)Set to log — prevents SendGrid TypeError on config import API (no API key in test env)
CHROME_DEBUG_PORTNo9222Override 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.

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 visit navigates to the given path (appended to APP_URL)
  • I click the sidebar item finds by data-menu attribute, sidebar button span text, or div[@title]
  • I click the element finds by CSS selector
  • I click the button finds by Mink's findButton() or XPath button text
  • I click the tab finds by button span text (report page tabs)
  • I click the first run in the list clicks the first .data-cell element

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 filter clicks the .filter-name div matching the given text
  • I select the filter option clicks a .menu-button, label, or button matching the text
  • I select filter checkbox clicks label[for="checkbox-{index}-{group}"] (0-indexed)
  • I click apply clicks 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 in uses Mink's fillField() — works for standard <input> and <textarea> elements
  • I select from uses Mink's selectFieldOption() with a fallback for Vue custom dropdowns (clicks the dropdown trigger, then selects the matching option)
  • I press key dispatches a keyboard event via evaluateScript with a keyCode map. Blurs focus first for compatibility with v-hotkey, which ignores events on INPUT/TEXTAREA/SELECT (see Gotchas)
  • I type into provides char-by-char input for <input>/<textarea> and also supports contenteditable elements (uses document.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 element polls for up to 10s until CSS selector matches
  • to appear variant uses 15s timeout
  • to contain polls until element has matching text content (15s timeout)
  • I wait N seconds is 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 see checks text appears in page HTML
  • I should see an element checks CSS selector exists
  • I should not see an element checks CSS selector does NOT exist
  • the element should contain checks element's text content
  • I should see at least N elements counts matching elements
Then the element "#dark-mode-toggle" should be checked
  • the element should be checked uses JavaScript el.checked to verify radio button or checkbox state (the DOM checked property, 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 get View [index] not found (500 error).
  • After login, the guest middleware on /login redirects to / (the SPA). If the SPA isn't built, this 500s.
  • SPA pages require I wait for the element steps 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:

  1. /_test/login/{email} sets the Laravel session cookie
  2. Subsequent visit() calls carry that cookie automatically
  3. Each @BeforeScenario starts 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:

  1. closeStaleChromeTabs() — Called in @BeforeScenario, this closes all existing Chrome tabs via GET http://127.0.0.1:9222/json followed by GET /json/close/{id} for each tab. This prevents the "connection dead" errors caused by leftover tabs from prior scenarios.

  2. ensureChromeRunning() — Also called in @BeforeScenario, this performs a health check via GET /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 /tmp is full, start Chrome with TMPDIR=/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-configuration need 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-name elements 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:

AgentArtisan PortChrome PortDatabase
180019223pcrai_test_01
280029224pcrai_test_02
380039225pcrai_test_03
480049226pcrai_test_04
580059227pcrai_test_05
680069228pcrai_test_06
780079229pcrai_test_07
880089230pcrai_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:

MetricValue
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 :selector with 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 :selector won'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_button attributes 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 elements instead of exact counts
  • Per-wave DB reset: Run migrate:fresh --seed before 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

FeatureBehat/Mink (new)Selenium IDE (legacy)
IntegrationSame test runner as API testsStandalone .side files
AuthProgrammatic (/_test/login)Manual TOTP flow via external site
BrowserHeadless Chrome via CDPSelenium IDE extension or Ghost Inspector
Speed~0.4s per scenario~10-30s per test (with MFA)
CI integration./vendor/bin/behat --suite=browserRequires Selenium server setup
Test count314 scenarios across 24 feature files12 smoke tests
MaintenancePHP step definitionsJSON 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 exists creates 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 :key must blur focus first and dispatch using keyCode for compatibility with v-hotkey. The key name is mapped to a keyCode (e.g., Escape -> 27).
  • @ mentions / contenteditable: When I type :text into :selector handles contenteditable elements (e.g., the Run Notes comment input) using document.execCommand('insertText'), since fillField() does not work on non-input elements.
  • Radio/checkbox state: Then the element :selector should be checked reads the JavaScript el.checked property, 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

FilePurposeScenarios
code/behat.ymlSuite configuration (browser suite added)
code/features/bootstrap/BrowserContext.phpMink context with CDP Chrome driver + API seeding (41 step definitions)
tests/exports/browser/smoke.featureLogin page + authenticated SPA smoke2
tests/exports/browser/navigation.featureSPA page rendering (Run Files, Users, Audit, Upload, Errors)5
tests/exports/browser/audit.featureAudit page load + Actions filter2
tests/exports/browser/reports.featureLJ Report, Outcomes Report, Trends Report, Westgard panel, filter combos9
tests/exports/browser/runfile-list.featureRun Files list, search, filters, tabs, pagination, sort, column visibility, expand21
tests/exports/browser/runfile-report.featureRun report page, well table, notifications, sort, action buttons, tabs, AMB UI14
tests/exports/browser/upload-runs.featureUpload Runs page, file input, validation, role access, progress table, drag-drop13
tests/exports/browser/globalui.featureGlobal UI elements, role indicators, infinite scroll, notification bell pane, notification load/pagination19
tests/exports/browser/globalui-idle-timeout.featureIdle timeout warning, countdown, dismissal, inactivity logout (slow: ~4 min)3
tests/exports/browser/user-management.featureUser management page load + role display2
tests/exports/browser/filters.featureCross-page filter interactions, text search, date picker, state preservation, multi-select10
tests/exports/browser/help-keyboard.featureHelp widget, search, admin management, keyboard shortcuts, help content pages14
tests/exports/browser/tables.featureTable pagination, column sort, column visibility toggles, row selection, empty state15
tests/exports/browser/user-settings.featureSettings page controls, radio buttons, save/persist, timezone selector, preferences8
tests/exports/browser/role-tests.featureSidebar permissions per role, role badge display6
tests/exports/browser/comments.featureComment panel, @ mentions, email invite, notification prefs, Run Notes, role filtering16
tests/exports/browser/notifications.featureNotification icons, counts, click-to-filter, ACE indicators, mark-read, dismiss10
tests/exports/browser/client-config.featureClient Configuration page toggles6
tests/exports/browser/stdcurve-reanalyze.featureStandard curve R2 + reanalyze archive8
tests/exports/browser/site-management.featureSite list, creation, multisite9
tests/exports/browser/progress-states.featureLoading states, skeleton, transitions11
tests/exports/browser/accession-history.featureWell table, print model, outcomes3
tests/exports/browser/kit-config.featureKit Configuration viewer + RBAC16
tests/exports/browser/kit-config-import.featureKit Configuration import/export10
tests/exports/browser/config-io.featureConfig I/O pipeline, upload flow3
code/composer.jsonPackages: behat/mink, mink-extension, chrome-mink-driver, laravel-test-login
Total25 feature files320