Debugging

Diagnose browser failures with headed runs, inspector sessions, traces, screenshots, console output, request evidence, and structured logs.

Debugging a browser test is the process of turning "it timed out" into a specific cause. The cause might be the wrong URL, a hidden element, a failed request, a dialog blocking the page, stale auth state, or a product bug.

Use the smallest tool that answers the next question. Headed mode helps when you need to watch locally. Traces help when you need a replay. Screenshots show visible state. Console and network logs explain browser-side failures. Structured logs help in CI.

Start from the symptom

Classify the failure before adding tools:

Symptom First evidence to collect
locator timeout screenshot and trace
wrong URL current URL, response/redirect evidence
missing text screenshot, trace, failed requests
dialog blocks action dialog handler evidence
CI-only failure trace, environment details, filtered logs
download missing response evidence and artifact path

Do not start by adding sleeps. A sleep rarely explains what was missing.

Write the first note in product terms: "dashboard heading missing", "invoice download did not start", "confirmation dialog did not appear". That keeps the investigation focused on behavior instead of implementation details.

Watch a run locally

Use headed mode when seeing the browser will answer the question:

php
use Playwright\Playwright;

$context = Playwright::chromium([
    'headless' => false,
    'slowMo' => 250,
]);

$page = $context->newPage();
$page->goto('https://example.com');

slowMo changes timing. It belongs in a diagnostic run, not in the committed stable path. If a test passes only with slow motion, the real fix is usually a locator, assertion, event wait, or product change.

Use the inspector for interactive diagnosis

For inspector-style debugging, launch through PlaywrightFactory and enable the inspector on the browser builder:

php
use Playwright\PlaywrightFactory;

$playwright = PlaywrightFactory::create();
$browser = $playwright->chromium()
    ->withHeadless(false)
    ->withInspector()
    ->launch();

$context = $browser->newContext();
$page = $context->newPage();

$page->goto('https://example.com');
$page->pause();

Use this locally to inspect locators and page state. Remove pause(), headed mode, and slow motion from normal automated runs.

The inspector is best for authoring or understanding a failure. It is not a CI debugging tool. CI needs artifacts that survive after the process exits: traces, screenshots, logs, and file outputs.

Record a trace

When the failure is not obvious from watching, record a trace:

php
$context = Playwright::chromium();
$page = $context->newPage();

$context->startTracing($page, [
    'screenshots' => true,
    'snapshots' => true,
]);

$page->goto('https://example.com');
$page->getByRole('button', ['name' => 'Save'])->click();

$context->stopTracing($page, __DIR__.'/var/playwright-artifacts/save.trace.zip');
$context->close();

Open it with:

bash
npx playwright show-trace var/playwright-artifacts/save.trace.zip

If you use PlaywrightTestCaseTrait, PW_TRACE=1 records a trace and writes it on failure. That behavior belongs to the PHPUnit trait; standalone scripts should start and stop tracing explicitly.

When triaging a flaky failure, prefer one trace from a failed run over several passing traces. A passing trace may show the intended path, but it does not prove which condition was missing when the failure occurred.

Capture browser-side signals

Console errors and failed requests often explain a visible timeout. Register handlers before the navigation or action that may emit them:

php
use Playwright\Console\ConsoleMessage;
use Playwright\Network\RequestInterface;

$page->events()->onConsole(static function (ConsoleMessage $message): void {
    if ('error' === $message->type()) {
        fwrite(STDERR, sprintf('[console] %s'.PHP_EOL, $message->text()));
    }
});

$page->events()->onRequestFailed(static function (RequestInterface $request): void {
    $failure = $request->failure();
    fwrite(STDERR, sprintf(
        '[request failed] %s %s'.PHP_EOL,
        $request->url(),
        $failure['errorText'] ?? 'failed'
    ));
});

Keep handlers narrow. They should collect evidence, not retry actions or hide the original failure.

Console messages, dialogs, popups, and failed requests all arrive through the same event surface. Page and browser events covers when to listen, when to wait, and when to route instead.

Use structured logging for client internals

The client can write to a PSR-3 logger. Pass the logger as the second argument to PlaywrightFactory::create():

php
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Playwright\Configuration\PlaywrightConfig;
use Playwright\PlaywrightFactory;

$handler = new StreamHandler('php://stderr');
$handler->setFormatter(new JsonFormatter());

$logger = new Logger('playwright');
$logger->pushHandler($handler);

$playwright = PlaywrightFactory::create(new PlaywrightConfig(), $logger);
$browser = $playwright->chromium()->launch();

Use structured logs for targeted reruns. Logging every browser action in every CI job can bury the useful signal.

The library cannot know which selectors, headers, POST fields, or screenshots contain secrets. Redact before data reaches the logger or artifact directory.

PlaywrightConfigBuilder can also build config from environment variables or attach a logger:

php
use Playwright\Configuration\PlaywrightConfigBuilder;

$config = PlaywrightConfigBuilder::fromEnv()
    ->withLogger($logger)
    ->build();

$playwright = PlaywrightFactory::create($config, $logger);

Use this when local and CI runs should share environment-driven settings such as timeouts, trace directory, downloads directory, or videos directory.

Debug by failure class

Locator timeout: inspect whether the element exists, is visible, is enabled, and is inside a frame. Prefer getByRole() or getByLabel() before brittle CSS.

Wrong URL: print $page->url(), assert the expected redirect, and inspect network evidence in the trace.

Network mock not used: register route() before navigation and log matching requests.

CI-only failure: compare browser engine, headless mode, viewport, timezone, locale, PHP version, Node version, and environment variables.

Download missing: verify the response status, destination path, and whether your runner exposes a DownloadInterface.

Dialog blocks the page: register an onDialog() handler before the action. If the handler is after the click, the page may already be blocked.

Frame content missing: verify the iframe exists first, then use frameLocator() for actions inside it. A main-page locator does not cross frame boundaries.

Keep debug code temporary

Debug settings change the run:

  • headed mode changes environment;
  • slow motion changes timing;
  • broad logs change output and risk leaking secrets;
  • unconditional artifacts slow the suite;
  • pauses block automation.

Make debug changes easy to remove. If the evidence should remain permanent, turn it into a narrow failure artifact or a documented CI upload step.

Permanent debug support should be boring: one artifact directory, one trace toggle, one logger path, one CI upload rule. Ad hoc debug code inside tests should disappear after the issue is understood.

Failure report checklist

A useful browser failure report should include:

  • the failing assertion and selector or URL;
  • current URL at failure time;
  • screenshot or trace path;
  • browser engine and headless/headed mode;
  • relevant console errors;
  • relevant failed requests;
  • artifact upload link in CI.

If the report lacks those basics, improve evidence collection before changing test logic. Otherwise you risk "fixing" the wrong condition.

Escalate from evidence

Once you know the failure class, choose the fix:

  • wrong locator: improve locator or product accessible name;
  • missing data: fix fixture setup or API setup;
  • failed request: fix route, backend, proxy, or auth state;
  • overlay or disabled state: assert the state that should clear it;
  • real product bug: keep the failing assertion and fix product code.

The debugging page should lead to a smaller change, not a bigger timeout.

Common mistakes

Fixing every timeout with a larger timeout. First identify the missing state.

Leaving pause() in committed tests. It blocks automation.

Logging full headers or bodies. They can contain secrets.

Using video when a trace is enough. Video shows display; trace shows actions, DOM, console, and network.

Debugging CI by watching locally only. CI failures need CI artifacts.

Go next