diff --git a/bin/lib/handlers.js b/bin/lib/handlers.js index 4523980..9a3677d 100644 --- a/bin/lib/handlers.js +++ b/bin/lib/handlers.js @@ -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 @@ -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 @@ -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 () => { diff --git a/bin/playwright-server.js b/bin/playwright-server.js index 463e92b..f9fa1b5 100644 --- a/bin/playwright-server.js +++ b/bin/playwright-server.js @@ -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') }); diff --git a/src/API/APIRequestContext.php b/src/API/APIRequestContext.php index 8c53a92..8ed5fc9 100644 --- a/src/API/APIRequestContext.php +++ b/src/API/APIRequestContext.php @@ -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 @@ -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([ diff --git a/src/API/APIRequestContextInterface.php b/src/API/APIRequestContextInterface.php index 5a6e5cd..eb4321e 100644 --- a/src/API/APIRequestContextInterface.php +++ b/src/API/APIRequestContextInterface.php @@ -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. @@ -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; } diff --git a/src/Playwright.php b/src/Playwright.php index 96445e4..df3acdd 100644 --- a/src/Playwright.php +++ b/src/Playwright.php @@ -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 $options @@ -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 $options */ private static function launch(string $browserType, array $options): BrowserContextInterface { - $client = PlaywrightFactory::create(); + $client = self::client(); $builder = match ($browserType) { 'chromium' => $client->chromium(), @@ -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; } } diff --git a/src/Tracing/Options/StartHarOptions.php b/src/Tracing/Options/StartHarOptions.php new file mode 100644 index 0000000..e0801e2 --- /dev/null +++ b/src/Tracing/Options/StartHarOptions.php @@ -0,0 +1,77 @@ + + */ + 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|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); + } +} diff --git a/src/Tracing/Tracing.php b/src/Tracing/Tracing.php index 7ac6042..10dd07e 100644 --- a/src/Tracing/Tracing.php +++ b/src/Tracing/Tracing.php @@ -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; @@ -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 = [ diff --git a/src/Tracing/TracingInterface.php b/src/Tracing/TracingInterface.php index 77a1962..b61dd3a 100644 --- a/src/Tracing/TracingInterface.php +++ b/src/Tracing/TracingInterface.php @@ -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; @@ -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|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; diff --git a/tests/Functional/Tracing/TracingApiTest.php b/tests/Functional/Tracing/TracingApiTest.php index 5199695..502c288 100644 --- a/tests/Functional/Tracing/TracingApiTest.php +++ b/tests/Functional/Tracing/TracingApiTest.php @@ -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 { @@ -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 + */ + 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(); diff --git a/tests/Integration/Selector/PlaywrightFacadeSelectorsTest.php b/tests/Integration/Selector/PlaywrightFacadeSelectorsTest.php new file mode 100644 index 0000000..93b5363 --- /dev/null +++ b/tests/Integration/Selector/PlaywrightFacadeSelectorsTest.php @@ -0,0 +1,80 @@ +context?->close(); + $this->context = null; + } + + #[Test] + public function itRegistersAnEngineUsableByABrowserItLaunches(): void + { + $script = <<<'JS' + { + query(root, selector) { + return root.querySelector(`[data-engine="${selector}"]`); + }, + queryAll(root, selector) { + return Array.from(root.querySelectorAll(`[data-engine="${selector}"]`)); + } + } + JS; + + Playwright::selectors()->register('facade-engine', $script); + + $this->context = Playwright::chromium(['headless' => true]); + $page = $this->context->newPage(); + $page->setContent('
engine hit
'); + + $this->assertSame('engine hit', $page->locator('facade-engine=target')->textContent()); + } + + #[Test] + public function itHandsOutOneRegistryForTheWholeFacade(): void + { + $selectors = Playwright::selectors(); + + $this->assertInstanceOf(SelectorsInterface::class, $selectors); + $this->assertSame($selectors, Playwright::selectors()); + } + + #[Test] + public function itReadsBackTheTestIdAttribute(): void + { + Playwright::selectors()->setTestIdAttribute('data-facade-id'); + + $this->assertSame('data-facade-id', Playwright::selectors()->getTestIdAttribute()); + } +} diff --git a/tests/Unit/API/APIRequestContextTest.php b/tests/Unit/API/APIRequestContextTest.php new file mode 100644 index 0000000..51da474 --- /dev/null +++ b/tests/Unit/API/APIRequestContextTest.php @@ -0,0 +1,54 @@ +transport = $this->createMock(TransportInterface::class); + $this->context = new APIRequestContext($this->transport, 'context_1'); + } + + public function testTracingReturnsATracingInstance(): void + { + $this->assertInstanceOf(TracingInterface::class, $this->context->tracing()); + } + + public function testTracingTargetsTheSameContext(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'tracingStartHar', + 'contextId' => 'context_1', + 'path' => '/tmp/api.har', + 'options' => [], + ]) + ->willReturn([]); + + $this->context->tracing()->startHar('/tmp/api.har'); + } +} diff --git a/tests/Unit/Tracing/Options/StartHarOptionsTest.php b/tests/Unit/Tracing/Options/StartHarOptionsTest.php new file mode 100644 index 0000000..3ce1918 --- /dev/null +++ b/tests/Unit/Tracing/Options/StartHarOptionsTest.php @@ -0,0 +1,75 @@ +assertSame([], (new StartHarOptions())->toArray()); + } + + public function testAllScalarOptionsAreForwarded(): void + { + $options = new StartHarOptions( + content: 'attach', + mode: 'minimal', + resourcesDir: '/tmp/resources', + urlFilter: '**/api/**', + ); + + $this->assertSame([ + 'content' => 'attach', + 'mode' => 'minimal', + 'resourcesDir' => '/tmp/resources', + 'urlFilter' => '**/api/**', + ], $options->toArray()); + } + + public function testRegexUrlFilterUsesADistinctKey(): void + { + $options = new StartHarOptions(urlFilter: new Regex('/\\/api\\/.*/i')); + + $this->assertSame(['urlFilterRegex' => '/\\/api\\/.*/i'], $options->toArray()); + } + + public function testFromArrayReadsEveryOption(): void + { + $options = StartHarOptions::from([ + 'content' => 'omit', + 'mode' => 'full', + 'resourcesDir' => '/tmp/res', + 'urlFilter' => '**/*.png', + ]); + + $this->assertSame('omit', $options->content); + $this->assertSame('full', $options->mode); + $this->assertSame('/tmp/res', $options->resourcesDir); + $this->assertSame('**/*.png', $options->urlFilter); + } + + public function testFromReturnsTheSameInstance(): void + { + $options = new StartHarOptions(mode: 'minimal'); + + $this->assertSame($options, StartHarOptions::from($options)); + } +} diff --git a/tests/Unit/Tracing/TracingTest.php b/tests/Unit/Tracing/TracingTest.php index 471956b..41cb937 100644 --- a/tests/Unit/Tracing/TracingTest.php +++ b/tests/Unit/Tracing/TracingTest.php @@ -88,6 +88,49 @@ public function testGroupSendsLocationWhenProvided(): void $this->tracing->group('my step', 'tests/MyTest.php'); } + public function testStartHarSendsPathAndOptions(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'tracingStartHar', + 'contextId' => 'context_1', + 'path' => '/tmp/network.har', + 'options' => ['mode' => 'minimal', 'urlFilter' => '**/api/**'], + ]) + ->willReturn([]); + + $this->tracing->startHar('/tmp/network.har', ['mode' => 'minimal', 'urlFilter' => '**/api/**']); + } + + public function testStartHarSendsEmptyOptionsByDefault(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'tracingStartHar', + 'contextId' => 'context_1', + 'path' => '/tmp/network.har', + 'options' => [], + ]) + ->willReturn([]); + + $this->tracing->startHar('/tmp/network.har'); + } + + public function testStopHarSendsAction(): void + { + $this->transport->expects($this->once()) + ->method('send') + ->with([ + 'action' => 'tracingStopHar', + 'contextId' => 'context_1', + ]) + ->willReturn([]); + + $this->tracing->stopHar(); + } + public function testGroupEndSendsAction(): void { $this->transport->expects($this->once())