Repository navigation
Soundcheck Interactions
The interact module checks that a page's controls still work: it reads forms and buttons, clicks menus, tabs and modals. When you allow it, it also submits forms and runs scripted flows. This page covers the four levels, dead clicks, the rules that keep clicking safe and the production guard.
| Level | Name | What it does | Default |
|---|---|---|---|
| 1 | inspect |
Reads the page: forms, fields, labels, ids and click handlers. Changes nothing. | on |
| 2 | click |
Clicks controls that act on the page (menus, tabs, accordions, modals, sliders, toggles) and watches what happens. | on |
| 3 | submit |
Submits forms with test data: empty first, expecting validation, then valid, expecting success. Sends real data. | off |
| 4 | flows |
Runs your YAML flows from .soundcheck/flows/. |
off |
Choose them with --level or interactions.levels:
rhythm soundcheck snapshot staging.yoursite.com --level inspect,click,submitinteractions:
levels: [inspect, click, submit, flows]Pass the same levels to compare, or keep them in the config file; otherwise the comparison notes interaction levels differ and new checks report everything as NEW.
Levels 1 to 3 run on every page while it's open at desktop size, before the mobile reload. Level 4 runs once, after all pages. Nothing is clicked or submitted on /wp-admin or /wp-login.php pages. Submitting and flows are covered in Forms and flows.
Tip
A form can say "sent" while no email goes out: the confirmation is on the page, the mailer behind it is broken. Soundcheck alone can't see that. Add --echo with Echo running: each submit records the emails, SMS and webhooks it caused; a form that sent nothing gets Form says sent, but no message went out. A message the snapshot had is BROKE when it's gone. See With Soundcheck.
| Check | Level | Title | Finds |
|---|---|---|---|
interact/form-action |
error | Form sends to a missing page (404): /old-handler/ |
a same-site form whose action answers 404 or 410 (or 405 for a GET form); tested with HEAD, falling back to GET, never POST
|
interact/no-nonce |
warning | Form has no nonce: form #newsletter |
on WordPress, a form that posts to the site without a nonce or CSRF token (comment, search, login and known form plugin forms excepted) |
interact/unnamed-field |
warning | Form field has no name: Email |
a field without a name; the browser never sends its value |
interact/dead-click |
warning | Button does nothing: Menu |
a button or a # / javascript:void(0) link with no click listener on it, its parents, the document or the window |
interact/unlabeled |
notice | Required field has no label: Phone |
a visible required field with no <label>, aria-label or title |
interact/duplicate-id |
notice | Duplicate id: "main" |
an id used by several elements (icons inside inline SVG excepted) |
Click listeners are found through Chrome's DevTools, so handlers added with addEventListener, onclick attributes and handlers delegated to a parent or the document all count. When level 2 is on, the controls it clicks are judged by the click instead.
Soundcheck collects up to 200 controls that look interactive: buttons, role=button|tab|menuitem|switch, <summary>, elements with aria-expanded, aria-controls, aria-haspopup, Bootstrap data-toggle attributes, # and javascript: links and anything with a click listener. Controls that open menus and panels go first. It then clicks up to interactions.max_clicks of them per page (default 15), each like a visitor: scroll it into view, send hover events, wait a moment for hover effects, then press and release and click. These pointer and mouse events are dispatched inside the page, so clicking works the same in every window while several pages are checked at once. Because they're script events, CSS :hover styles don't apply and handlers that insist on event.isTrusted ignore them.
Only controls a visitor could click are candidates. A control that's hidden, has no size, sits off screen, or is clipped by a parent that scrolls or hides its overflow is left out. One that's covered by something else (an open menu, a modal, a cookie banner) gets one retry after a reload. Clicking stops after 45 seconds on a page, with a note such as interact /shop/: stopped clicking after 45s with 12 controls left (the page is slow).
After each click it watches for up to 1.5 s (longer while requests the click started are still running):
| Outcome | Reported as |
|---|---|
| the DOM changed, a request was made, the page navigated, scrolled, or opened a dialog | ok |
| nothing happened at all |
interact/dead-click, warning: Button does nothing: Menu
|
| an uncaught JavaScript error |
interact/click-error, error: Clicking Add to wishlist throws a JS error
|
| a request the click made failed |
interact/click-request, error: Clicking Load more makes a request that fails
|
[NEW] Button does nothing: Menu - /, /about/, /blog/ +31 more
Clicking it changed nothing on the page, made no request and didn't navigate. (button.menu-toggle)
fix: Wire up its click handler, or remove the button if it's no longer needed.
[BROKE] Clicking Add to wishlist throws a JS error - /shop/
Uncaught ReferenceError: WishlistStore is not defined (6 controls: li:nth-of-type(1) > button.add-to-wishlist, li:nth-of-type(2) > button.add-to-wishlist, li:nth-of-type(3) > button.add-to-wishlist, li:nth-of-type(4) > button.add-to-wishlist, li:nth-of-type(5) > button.add-to-wishlist, li:nth-of-type(6) > button.add-to-wishlist)
fix: Click it with the browser console open to see the stack trace.
Titles use the control's visible label. Identical controls on a page with the same outcome (six "Add to wishlist" buttons that all throw) are one issue, with the selectors in the detail (6 controls: …, up to ten listed). The detail of a single control ends with its selector, so you can find it with document.querySelector(). Selectors leave out the state classes form plugins add and change (Contact Form 7's init, sent, invalid and the like, plus their WPForms, Gravity Forms and Elementor equivalents), so the same control has the same selector in both runs.
Requests to analytics and ad hosts (Google Analytics and Tag Manager, Facebook, Hotjar, Clarity, Segment and others) don't count as a reaction: they fire on almost any click. A link to # that only jumps to the top of the page doesn't count either.
Between clicks, Escape closes whatever opened. If a click navigated, the page is reloaded. Every click is recorded in the run's JSON under interactions with its outcome (ok, dead, error, skipped) and, for skipped ones, why, such as navigates away or looks destructive.
Level 2 explores a page without changing anything on the server:
-
Links that leave the page aren't clicked. Navigating links are recorded as
navigates away; discovery already visits those pages. -
Destructive-looking controls aren't clicked. A control is skipped (
looks destructive) when its text,aria-label, id, class, href, title, value or name matches any of:delete,remove,unsubscribe,log out/logout/log-out/logoff,sign out/signout/sign-out,cancel,deactivate,trash,destroy,erase,purge,revoke,wipe,uninstall,disconnect,unlink,empty cart,clear cart,reset password,lost password,close account,add to cart,place order,buy now,pay now,checkout,purchase,_wpnonce,/wp-admin,?action=/&action= -
Dialogs are defused. Before clicking,
window.open,alert,confirm,promptandprintare replaced in the page: nothing opens or blocks.confirm()answers Cancel, so a "Really delete?" prompt never goes ahead. -
Form submit buttons belong to level 3. Level 2 never clicks a submit button inside a form.
-
Admin pages are off limits. Nothing under
/wp-adminor/wp-login.phpis clicked. -
Every check runs as a visitor. Pages are visited in a private browser session that's never logged in.
To click a control the blocklist skips, because you know it's harmless on your site (a "Remove filter" chip, say), allow it by CSS selector:
interactions:
allow: [".filter-chip .remove", "#cancel-search"]List your live site under production in .soundcheck/config.yaml:
production: ["yoursite.com"]Entries are sites, matched by host: yoursite.com covers http and https, www.yoursite.com and any port. *.yoursite.com covers every subdomain and the apex; without *., shop.yoursite.com isn't covered. A port in an entry (staging.test:8443) matches only that port. A path (yoursite.com/shop) matches only that part of the site. A bad entry is a usage error: production: "http://" isn't a site like example.com, *.example.com or https://example.com/shop/. The run header lists them: Production: yoursite.com: forms are not submitted there (or Production: none listed: submit and flows run on any site). On production URLs Soundcheck:
-
Doesn't submit forms. Level 3 skips every form with
production URL; allowlist the form in interactions.forms to submit it, unless the form matches a selector ininteractions.forms. -
Doesn't run the cache leak test, which would serve the test account's pages to real visitors if the cache leaks. See Cache.
-
Doesn't run
--lighthouse, since Unlighthouse crawls every page with its own settings. -
Refuses click and submit steps in flows, unless the flow file sets
production: true. Read-only steps (goto,fill,select,check,wait,expect,screenshot) still run; the flow stops at the first refused step with a warning,interact/flow-production. See Flows on production.
Inspecting and clicking pages still run. Notes say what was skipped:
note: Form submits skipped on production URLs: allowlist forms in interactions.forms to test them there
note: cache: skipped the cache leak test on a production URL: a leaking cache would show the test account to real visitors. Run it on staging
note: Flow clicks and submits skipped on production URLs: set production: true in a flow to allow them
To test one form on production anyway, for example a newsletter signup that writes to a test list, allowlist it:
interactions:
forms: ["#newsletter-form"]Clicking takes time: each click waits up to 1.5 s for a reaction. A page with many controls takes several seconds, at most 45. If a check is slow, lower interactions.max_clicks or use --level inspect for a quick pass. Controls a visitor couldn't click (hidden, off screen, clipped, still covered after the retry) are never reported, so a control that couldn't be clicked doesn't show up as a false problem.
← Visual changes · Home · Forms and flows →
rhythm · Useful ideas. Working systems. · Check only sites you own or have permission to test. Keep staging sealed (responsible use) · This wiki is published from wiki/ in the rhythm repository; suggest changes there with a pull request.
Get started
rhythm in depth
Tuner
- Overview
- Your first scan
- Reading results
- Choosing checks
- Requests and politeness
- Output and reports
- Batch scans
- Local scans
- Integrity and malware
- Changes and notifications
Tuner in depth
- Known vulnerabilities
- PHP advisories
- CI and code scanning
- GitHub Action
- Config file
- Watch and serve
- Writing checks
- Check packs
- Finding reference
- CLI reference
- Coming from Wordfence CLI or WPScan
- FAQ and troubleshooting
Soundcheck
- Overview
- Your first check
- Reading results
- Review and accept
- Discovery
- Page health
- Visual changes
- Interactions
- Forms and flows
- Cache
- Speed
- Accessibility and SEO
Soundcheck in depth
- CI
- CI baselines and PR comments
- Configuration
- Error rules and exec
- WordPress updates
- Fleet and client reports
- History
- Import from BackstopJS and Lost Pixel
- Firefox and WebKit
- CLI reference
- FAQ and troubleshooting
- Benchmark
Echo
- Overview
- Your first run
- Connectors
- Sealing staging
- Echo Inbox
- Messages and checks
- Checks and rules
- Attachments
- Actions and compare
- With Soundcheck
- Replay
- Deliverability
Echo in depth
- Docker and DDEV
- Agency hub and reports
- Schedule
- MCP server
- Conformance kit
- CI
- Configuration
- API
- CLI reference
- FAQ and troubleshooting
- Benchmark
Project