How to Automate Video Calls Testing (Step-by-Step)
How to Automate Video Calls Testing (Step-by-Step)
How to Automate Video Calls Testing (Step-by-Step)
Video conferencing has become a core feature in many products, and manual verification of every call flow is slow, error‑prone, and impossible to scale across browsers, devices, and network conditions. Automating video call testing gives you repeatable checks for connection setup, media exchange, UI correctness, and post‑call analytics while freeing QA to focus on exploratory work. This guide walks you through a complete, production‑ready approach: deciding when automation pays off, picking a framework, building stable locators, handling synchronization, managing test data, integrating with CI, reporting results, and using autonomous exploration to bootstrap the first tests without writing a single line of script. Each section includes concrete examples, code snippets, and tables you can copy into your own repository.
When Automation Pays Off for Video Calls
Cost of Manual Testing Versus Automated Runs
Manual testing of a video call typically requires a tester to launch two clients, sign in, negotiate a room, verify that local and remote video appear, check audio levels, hang up, and repeat for each browser/device combination. A single end‑to‑end scenario can take 5‑10 minutes of focused effort. If you need to run the same flow on Chrome, Firefox, Safari, and two mobile browsers across three network profiles (Wi‑Fi, 4G, throttled 3G), you already face 24 × 7‑10 min = 168‑280 minutes of tester time per release. Automating the same matrix reduces the execution time to a few minutes per agent because the script drives the browsers in parallel and eliminates repetitive UI navigation.
Sources of Flakiness Unique to Real‑Time Media
Video calls introduce non‑deterministic factors that rarely appear in traditional web UI tests: ICE connection state fluctuations, dynamic bitrate adaptation, temporary packet loss, and rendering delays caused by GPU scheduling. A test that merely asserts the presence of a tag can pass while the actual media stream is frozen. Recognizing these sources early helps you design waits and assertions that target media‑level state rather than superficial DOM changes.
Quick ROI Calculation
Estimate the hourly cost of a QA engineer (including overhead) at $50. If manual execution of your video call matrix consumes 4 hours per release, that is $200. An automated suite that runs in 15 minutes on a CI agent costs roughly $12.50 in compute (assuming a modest $50/hour VM rate). Even after adding script maintenance, the break‑even point is reached after fewer than three releases, making automation a clear win for any team that ships video functionality more than quarterly.
Choosing a Testing Framework for Real‑Time Media
WebDriver‑Based Options: Selenium, Appium, Playwright
Selenium/WebDriver remains the most portable choice for cross‑browser desktop testing. Appium extends Selenium to native mobile apps, allowing you to test Android/iOS video clients with the same language bindings. Playwright, while newer, offers built‑in support for multiple browser contexts, automatic waiting, and the ability to intercept WebSocket traffic—useful for signaling inspection. All three can drive the browser’s getUserMedia API and inspect and elements.
Specialized Media Testing Tools
Tools such as Janus‑Gateway test clients, mediasoup‑demo, and OpenVidu Call provide programmable endpoints that simulate participants and expose media statistics via REST or WebSocket. Pairing these with a UI driver lets you verify both the client UI and the server‑side media pipeline. Commercial offerings like TestRTC or Cypress‑real‑world‑app add pre‑built helpers for measuring MOS, jitter, and packet loss directly from the captured streams.
Selection Criteria
| Criterion | Selenium/WebDriver | Appium | Playwright | Media‑Specific (Janus/OpenVidu) |
|---|---|---|---|---|
| Browser coverage | Chrome, Firefox, Safari, Edge | Same + mobile emulation | Chrome, Firefox, Safari, Edge | Any (via embedded player) |
| Mobile native support | No | Yes (Android/iOS) | Limited (via web view) | No (requires wrapper) |
| Built‑in waiting | Basic explicit waits | Basic explicit waits | Auto‑wait + network idle | Depends on wrapper |
| ICE / signaling inspection | Requires custom code | Same | Can intercept WebSockets | Native stats API |
| Setup complexity | Low | Medium (device farms) | Low‑Medium | Medium (media server) |
| License | Apache 2.0 | Apache 2.0 | Apache 2.0 | Varied (MIT/AGPL) |
If your primary goal is to validate UI interactions across desktop browsers, start with Playwright for its concise syntax and auto‑waiting. If you need to test native mobile apps or want a single language stack for web and mobile, choose Appium. When you also need to assert media quality metrics, layer a media‑gateway client on top of whichever UI driver you pick.
Setting Up the Test Environment
Mock Media Servers and SFUs
A reliable test suite needs a controllable media path. Instead of relying on a public SaaS (which introduces external variability), deploy a lightweight Selective Forwarding Unit (SFU) such as mediasoup, Janus Gateway, or OpenVidu behind Docker. These SFUs let you:
- Create rooms via a simple HTTP API.
- Force specific codecs (VP8, H.264, AV1) via SDP manipulation.
- Introduce artificial packet loss, latency, or bandwidth caps using
tcornetemon the container host. - Retrieve per‑peer statistics (bytes sent/received, jitter, frame rate) through a REST endpoint.
Example docker-compose.yml snippet for mediasoup:
version: "3.8"
services:
mediasoup:
image: mediasoup/media-soup-demo:latest
ports:
- "4443:4443"
- "3000:3000"
environment:
- MEDIASOUP_LISTEN_IP=0.0.0.0
- MEDIASOUP_MIN_PORT=40000
- MEDIASOUP_MAX_PORT=40100
command: ["node", "demo/server"]
Generating Fake Video and Audio Streams
Real cameras and microphones are unnecessary for automated tests. Use the browser’s getUserMedia with mediaSource set to a MediaStream generated from a canvas or an audio oscillator. The following helper creates a static color video track and a sine‑wave audio track:
function getFakeStream() {
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 480;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'rgb(0,120,215)';
ctx.fillRect(0,0,canvas.width,canvas.height);
const videoTrack = canvas.captureStream(30).getVideoTracks()[0];
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
oscillator.type = 'sine';
oscillator.frequency.value = 440; // A4
const destination = audioCtx.createMediaStreamDestination();
oscillator.connect(destination);
oscillator.start();
const audioTrack = destination.stream.getAudioTracks()[0];
return new MediaStream([videoTrack, audioTrack]);
}
In your test, replace the real getUserMedia call with a stub that returns getFakeStream(). Playwright makes this easy via page.route:
await page.route('**/getUserMedia', route => {
const fakeStream = page.evaluate(() => getFakeStream());
route.fulfill({ body: JSON.stringify({ stream: fakeStream }) });
});
Containerizing Test Agents
Package your test runner (Node.js, Python, or Java) inside a Docker image that includes the browsers you need. Use the official playwright image for Chrome/Firefox or the selenium/standalone-chrome image for Selenium. Mount a shared volume for artifacts (video recordings, logs, traces). A minimal Dockerfile for a Playwright‑based suite looks like:
FROM mcr.microsoft.com/playwright:v1.42.0-focal
WORKDIR /tests
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "test"]
Push the image to your registry and let your CI pull it for each job. This guarantees identical browser versions and OS libraries across local and remote runs.
Designing Stable Locators for Video UI Elements
Avoiding Dynamic IDs and Class Names
Video call UIs often generate random IDs for elements like participant panels, mute buttons, or video containers. Relying on these leads to brittle tests. Instead, request that developers add stable data-testid attributes (or aria-labels) to every interactive component. Example HTML:
<div data-testid="local-video" class="video-container">
<video autoplay muted playsinline></video>
</div>
<button data-testid="mute-button" aria-label="Toggle mute">
<span>Mute</span>
</button>
Your locator then becomes a simple CSS selector: [data-testid="local-video"] video.
Using Accessibility Attributes as Fallbacks
If modifying the source is not possible, leverage ARIA labels, role attributes, or visible text that is unlikely to change. For instance, a “Leave call” button often has aria-label="Leave call" or contains the exact text “Leave call”. Use XPath with normalize-space() to trim whitespace:
//button[normalize-space()='Leave call']
Handling Canvas/WebGL Rendered Video
Some platforms render video into a element for effects or compositing. In such cases, the tag may be hidden (display:none) and the actual frames appear in the canvas. To verify that media is flowing, check that the canvas width and height are non‑zero and that its pixel data changes over time. A helper function in JavaScript:
async function canvasHasMovingContent(page, selector, samples=5, interval=100) {
const ctx = await page.evaluateHandle(sel => {
const c = document.querySelector(sel);
return c.getContext('2d');
}, selector);
let prevData = null;
for (let i = 0; i < samples; i++) {
const data = await ctx.evaluateHandle(c => c.getImageData(0,0,c.width,c.height).data);
const arr = await data.json();
if (prevData && !arr.every((v,i)=>v===prevData[i])) return true;
prevData = arr;
await page.waitForTimeout(interval);
}
return false;
}
Call this after you expect remote video to start rendering; a true result indicates that the SFU is delivering frames.
Table: Locator Strategies and When to Use Them
| Strategy | Pros | Cons | Typical Use Case |
|---|---|---|---|
data-testid attribute | Stable, explicit, immune to redesign | Requires dev cooperation | Buttons, input fields, video containers |
aria-label / role | Works without markup changes, supports a11y | May be localized or changed for accessibility | Icons, menu items, toggle switches |
Visible text (normalize-space) | No attribute needed, easy to read | Fragile to copy changes, i18n issues | Static labels, confirmation dialogs |
| Canvas pixel change detection | Verifies actual media rendering | More complex, slightly slower | When video is drawn to canvas/WebGL |
| CSS nth‑child or class patterns | No markup change needed | Highly brittle to layout adjustments | Last‑resort fallback only |
Handling Waits, Synchronization, and Flaky Conditions
Explicit Waits for ICE Connection State
WebRTC exposes the RTCPeerConnection.iceConnectionState property, which progresses through new, checking, connected, completed, failed, disconnected, closed. A test should wait until the state reaches connected (or completed) before asserting media availability. In Playwright you can expose this via page.evaluate:
async function waitForIceConnected(page, timeout=15000) {
await page.waitForFunction(() => {
const pc = window.__lastPC__; // assume you store the peer connection globally
return pc && pc.iceConnectionState === 'connected';
}, { timeout });
}
You can store the peer connection when the app creates it (many frameworks expose it on window for debugging) or instrument the code with a wrapper that pushes the object to a known variable.
Detecting Remote Video Render
Even after ICE succeeds, the remote element may stay at zero width/height until the first frame arrives. A reliable wait checks for non‑zero video dimensions *and* a rising videoWidth property:
async function waitForRemoteVideo(page, selector, timeout=20000) {
await page.waitForFunction(sel => {
const v = document.querySelector(sel);
return v && v.videoWidth > 0 && v.videoHeight > 0;
}, { timeout }, selector);
}
If the app uses a canvas, replace the condition with the canvasHasMovingContent helper defined earlier.
Custom Expected Conditions for Media Metrics
Many SFUs publish stats via getStats(). You can poll for a minimum bitrate or frame rate to confirm that the stream is not stuck at a low fallback. Example in JavaScript:
async function waitForMinVideoBitrate(page, minKbps=100, timeout=30000) {
await page.waitForFunction(async () => {
const pc = window.__lastPC__;
if (!pc) return false;
const report = await pc.getStats();
let bits = 0;
report.forEach(value => {
if (value.type === 'outbound-rtp' && value.mediaType === 'video') {
bits += value.bytesSent * 8;
}
});
// convert cumulative bytes to average kbps over the last second (simplistic)
return bits / 1000 >= minKbps;
}, { timeout });
}
Retry Patterns for Intermittent Failures
Network jitter can cause occasional ICE failures that recover on a retry. Wrap the core call flow in a retry loop with exponential backoff, but limit attempts to avoid masking real bugs:
async function withRetry(fn, retries=3, baseDelay=500) {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (err) {
if (i === retries-1) throw err;
await new Promise(r => setTimeout(r, baseDelay * 2**i));
}
}
}
Apply withRetry to the sequence that signs in, creates a room, and joins the call.
Data Setup, User Accounts, and Teardown Strategies
Provisioning Test Users via API
Most video platforms expose a REST endpoint to create temporary users or generate JWT tokens. Use a dedicated service account with rights to create many short‑lived accounts. Store credentials in a vault (e.g., AWS Secrets Manager, HashiCorp Vault) and fetch them at test start. Example in Python using requests:
def create_test_user():
resp = requests.post(
"https://api.example.com/v1/users",
json={"displayName": f"tester-{uuid.uuid4()}"},
headers={"Authorization": f"Bearer {SERVICE_TOKEN}"}
)
resp.raise_for_status()
data = resp.json()
return data["userId"], data["token"]
Managing Room Lifecycle
Create a unique room identifier for each test run (UUID or timestamp). After the test ends, issue a DELETE request to the room endpoint to free server resources. If the platform does not support deletion, rely on a short TTL (time‑to‑live) configured on the SFU side; ensure your test waits longer than the TTL before assuming the room is gone.
Cleaning Up Media Recordings
If your system automatically records calls, delete those artifacts after verification to avoid storage buildup. Most recording services provide a recordingId in the call‑completed webhook; call the delete API with that ID. In cases where recordings are stored in an S3 bucket, issue a DELETE Object request.
Teardown Hooks in Test Frameworks
Register afterEach or afterAll hooks to guarantee cleanup even when a test fails. In Playwright (Node.js):
afterAll(async () => {
await api.deleteRoom(roomId);
await api.deleteUser(userId);
});
In pytest with fixtures:
@pytest.fixture
def test_context():
user_id, token = create_test_user()
room_id = create_room(token)
yield {"user_id": user_id, "token": token, "room_id": room_id}
delete_room(room_id, token)
delete_user(user_id, SERVICE_TOKEN)
Writing Maintainable Test Scripts
Page Object Model Adapted for Video Calls
Create a CallPage class that encapsulates all UI interactions and media checks. Keep locators private and expose high‑level methods like joinRoom(token), muteLocalAudio(), isRemoteVideoVisible(), getCallStats(). This isolates UI changes to a single file.
class CallPage {
constructor(page) {
this.page = page;
this.localVideo = '[data-testid="local-video"] video';
this.remoteVideo = '[data-testid="remote-video"] video';
this.muteBtn = '[data-testid="mute-button"]';
this.leaveBtn = '[data-testid="leave-button"]';
}
async joinRoom(token) {
await this.page.goto(`https://app.example.com/room?token=${token}`);
await this.page.waitForSelector(this.localVideo);
}
async muteLocalAudio() {
await this.page.click(this.muteBtn);
}
async isRemoteVideoVisible(timeout=5000) {
return await this.page.isVisible(this.remoteVideo, { timeout });
}
async getCallStats() {
return await this.page.evaluate(() => {
const pc = window.__lastPC__;
return pc ? pc.getStats() : null;
});
}
async leave() {
await this.page.click(this.leaveBtn);
}
}
Helper Library for Media Assertions
Extract repetitive checks into a utility module. Examples:
assertVideoPlaying(page, selector)– waits forvideoWidth > 0andvideo.paused === false.assertAudioLevel(page, selector, minRMS)– uses the Web Audio API to measure volume.assertNoErrorsInConsole(page)– checks thatpage.on('console', msg => ...)never saw an error level.
Parameterizing Scenarios with Data Files
Store variations (different codecs, network profiles, participant counts) in JSON or YAML files. Use a data‑driven test runner to iterate over each entry. In Jest:
test.each([
{ codec: 'VP8', loss: 0, latency: 0 },
{ codec: 'H264', loss: 0.02, latency: 80 },
{ codec: 'AV1', loss: 0.05, latency: 120 },
])('call succeeds with $codec under $loss loss and $latency ms latency', async ({codec, loss, latency}) => {
await setupNetwork({ loss, latency });
await callPage.joinRoom(token);
await callPage.isRemoteVideoVisible();
const stats = await callPage.getStats();
expect(stats.codec).toBe(codec);
});
Keeping Tests Independent
Avoid sharing state between tests (e.g., a logged‑in session). Each test should sign in fresh, create its own room, and clean up afterward. This enables parallel execution without race conditions.
Integrating with CI/CD Pipelines
Choosing the CI Agent
Most CI systems (GitHub Actions, GitLab CI, Jenkins, Azure Pipelines) support Docker containers. Use the same image you built locally to guarantee parity. If you need GPU acceleration for hardware‑accelerated video decoding, select a runner with GPU access or use a cloud provider that offers it (e.g., Google Cloud’s n1-standard-4 with nvidia-tesla-t4).
Parallel Execution Strategies
Split your test matrix by browser × networkProfile × participantCount. Each combination becomes a separate job. In GitHub Actions:
strategy:
matrix:
browser: [chromium, firefox, webkit]
profile: [wifi, lte3g, throttled]
users: [2, 4, 8]
Collecting Artifacts
After each run, archive:
- Video recordings of the test session (use
page.video.saveAs(path)in Playwright or Selenium’sgetCapabilities().getCapability("se:recordVideo")). - Console logs and network HAR files.
- SFU stats JSON files.
- Any failure screenshots (
page.screenshot()).
Upload these as workflow artifacts so developers can download and inspect them without rerunning the test.
Handling Flaky Test Reruns
Configure your CI to automatically retry a failed test up to two times before marking it a failure. Most CI platforms have a retry keyword. In Playwright’s test runner you can also set retries: 2 in playwright.config.ts. This reduces noise caused by transient network glitches while still surfacing genuine regressions.
Example GitHub Actions Workflow
name: Video Call CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
container:
image: ghcr.io/yourorg/video-call-tests:latest
options: --gpus all # if GPU needed
strategy:
matrix:
browser: [chromium, firefox]
profile: [wifi, lte3g]
steps:
- uses: actions/checkout@v3
- name: Install deps
run: npm ci
- name: Run tests
env:
PLAYWRIGHT_BROWSERS_PATH: 0
run: npx playwright test --project=${{ matrix.browser }} --retries=2
- name: Upload artifacts
if: failure()
uses: actions/upload-artifact@v3
with:
name: video-artifacts-${{ matrix.browser }}-${{ matrix.profile }}
path: test-results/**/*
Reporting and Metrics for Video Call Tests
JUnit XML and TestRail Integration
Most test runners generate JUnit XML by default. Feed this into your test management tool (TestRail, Zephyr, Xray) to get a historic pass/fail trend per video call scenario. Add a custom property to each test case that records the average MOS (Mean Opinion Score) computed from captured audio.
Computing MOS from Captured Streams
If you record the remote audio track (using MediaRecorder or FFmpeg), you can run an objective quality estimator like PESQ or POLQA to approximate MOS. A simple FFmpeg command to extract audio and run PESQ via the libpesq filter:
ffmpeg -i input.webm -vn -ac 1 -ar 16000 audio.pcm
pesq +16000 audio.pcm reference.pcm +mos
Store the resulting MOS value as a test annotation.
Dashboard for Media KPIs
Create a Grafana dashboard that pulls from a time‑series database (Prometheus, InfluxDB) where each test run pushes metrics:
call_setup_time_secondsice_connection_state_transitionsaverage_video_bitrate_kbpsaverage_audio_bitrate_kbpspacket_loss_percentjitter_msmos_score
Panel examples: a heatmap of packet loss across network profiles, a line chart of MOS over time, and a bar chart of setup time per browser.
Linking Logs to Test Outcomes
When a test fails, automatically include a link to the corresponding SFU logs and the recorded media files in the test report. In your CI step, after a failure, run a script that uploads the logs to an artifact store and writes a markdown comment with URLs.
if [ $? -ne 0 ]; then
curl -F "file=@sfu.log" https://artifactstore.example.com/upload
echo "SFU log: https://artifactstore.example.com/upload/sfu.log" >> $GITHUB_STEP_SUMMARY
fi
Leveraging Autonomous Exploration to Bootstrap Video Calls Testing (SUSA)
How Autonomous Exploration Works
SUSA uploads your APK (for Android) or points at a web URL, then launches a fleet of virtual users with distinct personas—curious, impatient, novice, adversarial, elderly, accessibility‑focused, power user, etc. Each persona follows its own behavior model: a curious user taps every visible element, an impatient user skips tutorials, an accessibility user relies on screen‑reader cues, and an adversarial user attempts malformed inputs. While exploring, SUSA records every interaction, network request, and UI state change, building a graph of reachable screens and detecting dead ends, crashes, ANRs, and WCAG violations.
Generating Initial Video Call Scripts Without Manual Coding
When SUSA encounters a video call screen, it automatically:
- Detects the presence of a
orelement that receives media streams. - Records the sequence of taps or clicks needed to join a room (e.g., “Enter meeting ID”, “Press Join”).
- Captures the signaling messages (WebSocket or HTTP POST) exchanged with the SFU.
- Emits a starter test script in the language of your choice (JavaScript/TypeScript for Playwright, Java for Appium, Python for Selenium) that reproduces the observed flow, inserts placeholder waits for media readiness, and adds basic assertions (local video present, remote video appears after ICE connected).
You can download this generated script from the SUSA dashboard, commit it to your repository, and immediately begin refining it—adding data‑driven parameters, custom media checks, and CI integration—without having to write the first line of UI navigation code from scratch.
Cross‑Session Learning Improves Stability Over Time
Each subsequent SUSA run remembers which locators proved stable (e.g., data-testid attributes that never changed) and which were volatile (auto‑generated IDs). It updates its internal selector heuristics, so the next generation of scripts is more resilient. Over several weeks, the proportion of flaky selectors drops dramatically, reducing the maintenance burden on your team.
When to Use SUSA Versus Hand‑Crafted Tests
- Early stage – When you have a new video feature and no existing test suite, run SUSA to obtain a baseline set of scripts in minutes.
- Regression guard – Schedule a nightly SUSA exploration to catch UI drift; compare the newly generated scripts against your committed versions to spot breaking changes.
- Complementary approach – Keep your hand‑crafted data‑driven suite for complex scenarios (multi‑party calls, recording, breakout rooms) and let SUSA handle the smoke‑test coverage of core happy‑path flows.
Checklist for Reliable Video Call Automation
| ✅ Item | Why It Matters |
|---|---|
Use stable data-testid or ARIA labels | Prevents locator breakage on UI tweaks |
Wait for ICE connected state before media checks | Guarantees that the transport layer is ready |
| Verify non‑zero video dimensions or canvas pixel change | Confirms actual frame delivery, not just DOM presence |
| Capture and assert media stats (bitrate, fps, packet loss) | Detects degradation that UI‑only checks would miss |
| Clean up rooms, users, and recordings after each test | Avoids resource leaks and cross‑test contamination |
| Run tests in parallel across browsers & network profiles | Finds environment‑specific issues early |
| Store logs, HAR files, and recordings as artifacts | Enables post‑mortem debugging without rerunning |
| Configure automatic retries (max 2) for transient flukes | Reduces noise while preserving signal of real regressions |
| Monitor MOS or objective quality metrics per run | Connects test outcome to perceived user experience |
| Review SUSA‑generated scripts monthly for selector drift | Keeps auto‑generated baseline in sync with evolving UI |
Closing Takeaways
Automating video call testing is no longer a luxury; it is a necessity for any product that relies on real‑time communication as a core feature. Begin by identifying the scenarios where manual effort outweighs the cost of script creation—typically regression suites, cross‑browser matrices, and performance‑sensitive flows. Choose a framework that gives you both UI control and access to WebRTC internals; Playwright offers a strong out‑of‑the‑box balance for web, while Appium extends the same skills to native mobile. Build your test environment around a controllable SFU and synthetic media streams so you can manipulate codecs, packet loss, and bandwidth without depending on flaky third‑party services.
Invest in stable locators (data-testid or ARIA) and explicit waits that target ICE connection state and actual media rendering. Encapsulate these actions in a Page Object Model and a small helper library for media assertions, then drive variations with data‑driven files. Integrate the suite into your CI pipeline using Dockerized agents, parallel matrices, and artifact collection to give developers immediate feedback when a call breaks. Enrich your JUnit reports with objective quality metrics such as MOS or bitrate, and visualize trends in Grafana to spot regressions before they reach users.
Finally, let autonomous exploration tools like SUSA bootstrap the first generation of tests. Their persona‑driven crawling produces realistic walks generate usable scripts, highlight volatile selectors, and evolve with each run, giving you a head start without writing UI navigation code from scratch. Treat those generated scripts as a living baseline: refine them, add your own assertions, and commit them to version control. With this combination of disciplined engineering practices and smart tooling, you will achieve fast, reliable, and repeatable verification of video calls—freeing your team to focus on the features that truly delight users.
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free