Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion bin/lib/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ const evaluateHandleOnTarget = async (target, { expression, arg }) => {
return typeof value === 'function' ? await value(target, arg) : await value;
};

// A HAR url filter accepts a glob or a regex. A glob may legitimately start with
// a slash, so PHP sends the regex form under its own key as a "/source/flags"
// literal rather than leaving the two shapes to be told apart here.
function harOptions(options) {
const { urlFilterRegex, ...rest } = options || {};
if (typeof urlFilterRegex !== 'string') return rest;
const lastSlash = urlFilterRegex.lastIndexOf('/');
return { ...rest, urlFilter: new RegExp(urlFilterRegex.slice(1, lastSlash), urlFilterRegex.slice(lastSlash + 1)) };
}

class ContextHandler extends BaseHandler {
async handle(command, method) {
// Closing a context drops it from the registry, and closing its browser drops it
Expand Down Expand Up @@ -86,6 +96,9 @@ class ContextHandler extends BaseHandler {
startChunk: () => context.tracing.startChunk(command.options || {}),
stop: async () => { context.__phpTracingActive = false; await context.tracing.stop(command.options || {}); },
stopChunk: () => context.tracing.stopChunk(command.options || {}),
// startHar resolves with a Disposable, which cannot cross the JSON bridge
startHar: async () => { await context.tracing.startHar(command.path, harOptions(command.options)); return {}; },
stopHar: async () => { await context.tracing.stopHar(); return {}; },
// Groups are silently skipped when tracing is off so callers (e.g. expect
// assertions) can emit them unconditionally
group: () => context.__phpTracingActive
Expand Down Expand Up @@ -983,7 +996,7 @@ class JSHandleHandler extends BaseHandler {

class SelectorsHandler extends BaseHandler {
async handle(command, method) {
const { playwright } = require('playwright');
const playwright = require('playwright');

const registry = CommandRegistry.create({
register: async () => {
Expand Down
2 changes: 2 additions & 0 deletions bin/playwright-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ class PlaywrightServer extends BaseHandler {
tracingStartChunk: () => this.contextHandler.handleTracing(command, 'startChunk'),
tracingStop: () => this.contextHandler.handleTracing(command, 'stop'),
tracingStopChunk: () => this.contextHandler.handleTracing(command, 'stopChunk'),
tracingStartHar: () => this.contextHandler.handleTracing(command, 'startHar'),
tracingStopHar: () => this.contextHandler.handleTracing(command, 'stopHar'),
tracingGroup: () => this.contextHandler.handleTracing(command, 'group'),
tracingGroupEnd: () => this.contextHandler.handleTracing(command, 'groupEnd')
});
Expand Down
7 changes: 7 additions & 0 deletions src/API/APIRequestContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
namespace Playwright\API;

use Playwright\Exception\ProtocolErrorException;
use Playwright\Tracing\Tracing;
use Playwright\Tracing\TracingInterface;
use Playwright\Transport\TransportInterface;

final class APIRequestContext implements APIRequestContextInterface
Expand Down Expand Up @@ -154,6 +156,11 @@ public function storageState(?string $path = null): array
return $result;
}

public function tracing(): TracingInterface
{
return new Tracing($this->transport, $this->contextId);
}

public function dispose(): void
{
$this->transport->send([
Expand Down
8 changes: 8 additions & 0 deletions src/API/APIRequestContextInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

namespace Playwright\API;

use Playwright\Tracing\TracingInterface;

/**
* This context can be used to trigger API endpoints, configure micro-services,
* prepare environment or the service to your e2e test.
Expand Down Expand Up @@ -62,5 +64,11 @@ public function fetch(string $urlOrRequest, array $options = []): APIResponseInt
*/
public function storageState(?string $path = null): array;

/**
* Tracing controls for this context. A context obtained from a browser context traces into that
* same browser context.
*/
public function tracing(): TracingInterface;

public function dispose(): void;
}
43 changes: 23 additions & 20 deletions src/Playwright.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,11 @@
namespace Playwright;

use Playwright\Browser\BrowserContextInterface;
use Playwright\Selector\SelectorsInterface;

final class Playwright
{
/** @var PlaywrightClient[] */
private static array $clients = [];
private static bool $shutdownRegistered = false;
private static ?PlaywrightClient $client = null;

/**
* @param array<string, mixed> $options
Expand Down Expand Up @@ -56,12 +55,22 @@ public static function safari(array $options = []): BrowserContextInterface
return self::webkit($options);
}

/**
* Selector engine registry shared by every browser this class launches.
*
* Engines must be registered before the pages that use them are created.
*/
public static function selectors(): SelectorsInterface
{
return self::client()->selectors();
}

/**
* @param array<string, mixed> $options
*/
private static function launch(string $browserType, array $options): BrowserContextInterface
{
$client = PlaywrightFactory::create();
$client = self::client();

$builder = match ($browserType) {
'chromium' => $client->chromium(),
Expand Down Expand Up @@ -101,28 +110,22 @@ private static function launch(string $browserType, array $options): BrowserCont
}
}

$context = empty($typedOptions) ? $browser->context() : $browser->newContext($typedOptions);

self::$clients[] = $client;
self::registerShutdown();

return $context;
return empty($typedOptions) ? $browser->context() : $browser->newContext($typedOptions);
}

private static function registerShutdown(): void
private static function client(): PlaywrightClient
{
if (self::$shutdownRegistered) {
return;
}
self::$shutdownRegistered = true;
register_shutdown_function(static function (): void {
foreach (self::$clients as $i => $client) {
$client = self::$client;
if (null === $client) {
$client = self::$client = PlaywrightFactory::create();
register_shutdown_function(static function () use ($client): void {
try {
$client->close();
} catch (\Throwable) {
}
unset(self::$clients[$i]);
}
});
});
}

return $client;
}
}
77 changes: 77 additions & 0 deletions src/Tracing/Options/StartHarOptions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<?php

declare(strict_types=1);

/*
* This file is part of the community-maintained Playwright PHP project.
* It is not affiliated with or endorsed by Microsoft.
*
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Playwright\Tracing\Options;

use Playwright\Regex;

final readonly class StartHarOptions
{
/**
* @param 'attach'|'embed'|'omit'|null $content
* @param 'full'|'minimal'|null $mode
*/
public function __construct(
public ?string $content = null,
public ?string $mode = null,
public ?string $resourcesDir = null,
public string|Regex|null $urlFilter = null,
) {
}

/**
* @return array<string, mixed>
*/
public function toArray(): array
{
$options = [];
if (null !== $this->content) {
$options['content'] = $this->content;
}
if (null !== $this->mode) {
$options['mode'] = $this->mode;
}
if (null !== $this->resourcesDir) {
$options['resourcesDir'] = $this->resourcesDir;
}
if ($this->urlFilter instanceof Regex) {
$options['urlFilterRegex'] = $this->urlFilter->pattern;
} elseif (null !== $this->urlFilter) {
$options['urlFilter'] = $this->urlFilter;
}

return $options;
}

/**
* @param array<string, mixed>|self $options
*/
public static function from(array|self $options = []): self
{
if ($options instanceof self) {
return $options;
}

/** @var 'attach'|'embed'|'omit'|null $content */
$content = $options['content'] ?? null;
/** @var 'full'|'minimal'|null $mode */
$mode = $options['mode'] ?? null;
/** @var string|null $resourcesDir */
$resourcesDir = $options['resourcesDir'] ?? null;
/** @var string|Regex|null $urlFilter */
$urlFilter = $options['urlFilter'] ?? null;

return new self($content, $mode, $resourcesDir, $urlFilter);
}
}
20 changes: 20 additions & 0 deletions src/Tracing/Tracing.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
namespace Playwright\Tracing;

use Playwright\Tracing\Options\StartChunkOptions;
use Playwright\Tracing\Options\StartHarOptions;
use Playwright\Tracing\Options\StartOptions;
use Playwright\Tracing\Options\StopChunkOptions;
use Playwright\Tracing\Options\StopOptions;
Expand Down Expand Up @@ -68,6 +69,25 @@ public function stopChunk(array|StopChunkOptions $options = []): void
]);
}

public function startHar(string $path, array|StartHarOptions $options = []): void
{
$options = StartHarOptions::from($options);
$this->transport->send([
'action' => 'tracingStartHar',
'contextId' => $this->contextId,
'path' => $path,
'options' => $options->toArray(),
]);
}

public function stopHar(): void
{
$this->transport->send([
'action' => 'tracingStopHar',
'contextId' => $this->contextId,
]);
}

public function group(string $name, ?string $location = null): void
{
$payload = [
Expand Down
15 changes: 15 additions & 0 deletions src/Tracing/TracingInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
namespace Playwright\Tracing;

use Playwright\Tracing\Options\StartChunkOptions;
use Playwright\Tracing\Options\StartHarOptions;
use Playwright\Tracing\Options\StartOptions;
use Playwright\Tracing\Options\StopChunkOptions;
use Playwright\Tracing\Options\StopOptions;
Expand Down Expand Up @@ -49,6 +50,20 @@ public function stop(array|StopOptions $options = []): void;
*/
public function stopChunk(array|StopChunkOptions $options = []): void;

/**
* Record network activity of this context to a HAR file, written only once stopHar() is called.
*
* A path ending in `.zip` stores response bodies as separate archive entries instead of inline.
*
* @param array<string, mixed>|StartHarOptions $options
*/
public function startHar(string $path, array|StartHarOptions $options = []): void;

/**
* Stop HAR recording and write the file to the path given to startHar().
*/
public function stopHar(): void;

public function group(string $name, ?string $location = null): void;

public function groupEnd(): void;
Expand Down
70 changes: 70 additions & 0 deletions tests/Functional/Tracing/TracingApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,16 @@
namespace Playwright\Tests\Functional\Tracing;

use PHPUnit\Framework\Attributes\CoversClass;
use Playwright\API\APIRequestContext;
use Playwright\Browser\BrowserContext;
use Playwright\Regex;
use Playwright\Tests\Functional\FunctionalTestCase;
use Playwright\Tracing\Options\StartHarOptions;
use Playwright\Tracing\Tracing;

#[CoversClass(Tracing::class)]
#[CoversClass(StartHarOptions::class)]
#[CoversClass(APIRequestContext::class)]
#[CoversClass(BrowserContext::class)]
final class TracingApiTest extends FunctionalTestCase
{
Expand Down Expand Up @@ -100,6 +105,71 @@ public function testChunksProduceSeparateArchives(): void
$this->assertGreaterThan(0, (int) filesize($chunkPath));
}

public function testHarRecordingCapturesNetworkActivity(): void
{
$harPath = $this->tempDir.'/network.har';

$tracing = $this->context->tracing();
$tracing->startHar($harPath, ['content' => 'omit', 'mode' => 'full', 'urlFilter' => '**/*']);

$this->goto('/index.html');

$tracing->stopHar();

$this->assertFileExists($harPath);
$entries = $this->readHarEntries($harPath);
$this->assertNotSame([], $entries);
$this->assertStringContainsString('/index.html', json_encode($entries, \JSON_THROW_ON_ERROR));
}

public function testHarRecordingHonoursARegexUrlFilter(): void
{
$harPath = $this->tempDir.'/filtered.har';

$tracing = $this->context->tracing();
$tracing->startHar($harPath, ['urlFilter' => new Regex('/nothing-matches-this/')]);

$this->goto('/index.html');

$tracing->stopHar();

$this->assertFileExists($harPath);
$this->assertSame([], $this->readHarEntries($harPath));
}

public function testHarRecordingIsAvailableOnTheApiRequestContext(): void
{
$harPath = $this->tempDir.'/api.har';

$tracing = $this->context->request()->tracing();
$tracing->startHar($harPath);

$this->goto('/index.html');

$tracing->stopHar();

$this->assertFileExists($harPath);
$this->assertNotSame([], $this->readHarEntries($harPath));
}

/**
* @return array<int, mixed>
*/
private function readHarEntries(string $harPath): array
{
$raw = file_get_contents($harPath);
$this->assertIsString($raw);

$har = json_decode($raw, true, 512, \JSON_THROW_ON_ERROR);
$this->assertIsArray($har);
$this->assertArrayHasKey('log', $har);
$this->assertIsArray($har['log']);
$this->assertArrayHasKey('entries', $har['log']);
$this->assertIsArray($har['log']['entries']);

return array_values($har['log']['entries']);
}

private function readTraceEvents(string $zipPath): string
{
$zip = new \ZipArchive();
Expand Down
Loading
Loading