From 43c10ff17f1494ab96510e90d505e744bb2f4317 Mon Sep 17 00:00:00 2001 From: Johan Montenij Date: Thu, 21 May 2026 16:11:45 +0200 Subject: [PATCH 1/6] Implement pest --record command for browser test generation --- README.md | 49 ++++++++ composer.json | 3 +- src/Record.php | 202 ++++++++++++++++++++++++++++++++ src/Recorder/Codegen.php | 146 +++++++++++++++++++++++ src/Recorder/EventParser.php | 38 ++++++ src/Recorder/EventSanitizer.php | 121 +++++++++++++++++++ src/Recorder/Locator.php | 65 ++++++++++ src/Recorder/RecordedEvent.php | 44 +++++++ src/Recorder/TestGenerator.php | 154 ++++++++++++++++++++++++ src/Recorder/TestWriter.php | 60 ++++++++++ 10 files changed, 881 insertions(+), 1 deletion(-) create mode 100644 src/Record.php create mode 100644 src/Recorder/Codegen.php create mode 100644 src/Recorder/EventParser.php create mode 100644 src/Recorder/EventSanitizer.php create mode 100644 src/Recorder/Locator.php create mode 100644 src/Recorder/RecordedEvent.php create mode 100644 src/Recorder/TestGenerator.php create mode 100644 src/Recorder/TestWriter.php diff --git a/README.md b/README.md index 3652528a..25cc6bb8 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,55 @@ This repository contains the Pest Plugin for Browser. > If you want to start testing your application with Pest, visit the main **[Pest Repository](https://github.com/pestphp/pest)**. +## Recording Tests + +Generate a browser test by recording your interactions live in the browser — no test code to write by hand. + +```bash +vendor/bin/pest --record +``` + +Playwright's codegen opens a browser window. Interact with your application, then close the browser. Pest prompts for a test description and output file, then writes the generated test to `tests/Browser/`. + +**Options:** + +| Option | Default | Description | +|---|---|---| +| `--url=` | `http://localhost:8000` | Base URL to open | +| `--visit=` | `/` | Path to open on start (e.g. `/login`) | +| `--test-id-attribute=` | `data-test` | HTML attribute used for element selectors | +| `--device=` | — | Emulate a device (e.g. `"iPhone 15"`) | +| `--viewport=` | — | Viewport size in pixels (e.g. `1280,800`) | +| `--acting-as=` | — | Name of a saved browser auth state to load | + +**Example:** + +```bash +vendor/bin/pest --record --url=https://example.com --visit=/login --test-id-attribute=id +``` + +**Example output:** + +```php +it('can login', function (): void { + $page = visit('/login'); + $page->fill('#email', 'user@example.com'); + $page->fill('#password', 'secret'); + $page->click('Sign in'); + $page->assertSee('Dashboard'); +}); +``` + +**Auth state (`--acting-as`):** + +Saves and reloads browser-level auth state (cookies, localStorage) between recordings. On first use, a login sequence is recorded and saved. Subsequent recordings load the saved state automatically. + +```bash +vendor/bin/pest --record --acting-as=admin +``` + +**Requirements:** `npm install -D @playwright/test` and `npx playwright install`. + - Explore our docs at **[pestphp.com »](https://pestphp.com)** - Follow the creator Nuno Maduro: - YouTube: **[youtube.com/@nunomaduro](https://www.youtube.com/@nunomaduro)** — Videos every weekday diff --git a/composer.json b/composer.json index aefd1dd9..125e0e01 100644 --- a/composer.json +++ b/composer.json @@ -95,7 +95,8 @@ "extra": { "pest": { "plugins": [ - "Pest\\Browser\\Plugin" + "Pest\\Browser\\Plugin", + "Pest\\Browser\\Record" ] } } diff --git a/src/Record.php b/src/Record.php new file mode 100644 index 00000000..4dd22a9a --- /dev/null +++ b/src/Record.php @@ -0,0 +1,202 @@ +hasArgument(self::OPTION, $arguments)) { + return $arguments; + } + + $arguments = $this->popArgument(self::OPTION, $arguments); + + $url = $this->popArgumentValue('--url', $arguments) ?? 'http://localhost:8000'; + $visitPath = $this->popArgumentValue('--visit', $arguments); + $authName = $this->popArgumentValue('--acting-as', $arguments); + $viewport = $this->popArgumentValue('--viewport', $arguments); + $device = $this->popArgumentValue('--device', $arguments); + $testIdAttribute = $this->popArgumentValue('--test-id-attribute', $arguments) ?? self::DEFAULT_TEST_ID_ATTRIBUTE; + + $this->record($url, $visitPath, $authName, $viewport, $device, $testIdAttribute); + + exit(0); + } + + private function record( + string $url, + ?string $visitPath, + ?string $authName, + ?string $viewport, + ?string $device, + string $testIdAttribute, + ): void + { + $codegen = new Codegen; + + try { + $codegen->checkDependencies(); + } catch (RuntimeException $e) { + $this->writeLine('✗ ' . $e->getMessage()); + + return; + } + + $loadStorage = ! is_null($authName) + ? $this->resolveAuthState($codegen, $url, $authName, $viewport, $testIdAttribute) + : null; + + $tmpFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pest-recording-' . getmypid() . '.jsonl'; + + try { + $this->writeLine('● Recorder running — close the browser to finish...'); + + $jsonl = $codegen->record($url, $tmpFile, $testIdAttribute, $viewport, $visitPath, $loadStorage, $device); + + if ($jsonl === '') { + $this->writeLine('✗ No recording captured.'); + + return; + } + + $events = (new EventParser)->parse($jsonl); + $events = (new EventSanitizer($testIdAttribute))->sanitize($events); + + if ($events === []) { + $this->writeLine('✗ No mappable actions recorded.'); + + return; + } + + $title = $this->prompt('Test description'); + $outputPath = $this->resolveOutputPath(); + $code = (new TestGenerator($testIdAttribute))->generate($events, $title, $url); + + (new TestWriter)->write($outputPath, $code); + + $this->writeLine(sprintf('✔ Test written: %s', $outputPath)); + } catch (RuntimeException $e) { + $this->writeLine('✗ ' . $e->getMessage()); + } finally { + @unlink($tmpFile); + } + } + + private function resolveAuthState( + Codegen $codegen, + string $url, + string $name, + ?string $viewport, + string $testIdAttribute, + ): ?string + { + $storageFile = sys_get_temp_dir() + . DIRECTORY_SEPARATOR + . 'pest-auth-' . preg_replace('/[^a-z0-9_-]/i', '-', $name) . '.json'; + + if (! file_exists($storageFile)) { + $this->writeLine(sprintf( + "● No auth state found for '%s'. Record a login sequence to save it.", + $name, + )); + + try { + $codegen->captureAuthState($url, $storageFile, '/login', $testIdAttribute, $viewport); + } catch (RuntimeException $e) { + $this->writeLine('✗ ' . $e->getMessage()); + + return null; + } + } + + return file_exists($storageFile) ? $storageFile : null; + } + + private function resolveOutputPath(): string + { + $testsDir = $this->testSuite->rootPath . DIRECTORY_SEPARATOR . $this->testSuite->testPath . DIRECTORY_SEPARATOR . 'Browser'; + $writer = new TestWriter; + $existing = $writer->findExistingTestFiles($testsDir); + + if ($existing !== []) { + $choices = array_merge( + ['New file...'], + array_map( + fn(string $path): string => ltrim(str_replace($testsDir, '', $path), DIRECTORY_SEPARATOR), + $existing, + ), + ); + + $choice = $this->choose('Output file', $choices); + + if ($choice !== 'New file...') { + return $testsDir . DIRECTORY_SEPARATOR . $choice; + } + } + + $name = ucfirst(str_replace(['.php', 'Test.php'], '', $this->prompt('Test file name'))); + + return $testsDir . DIRECTORY_SEPARATOR . $name . 'Test.php'; + } + + private function prompt(string $question): string + { + $this->output->write(" ? {$question}: "); + + $answer = fgets(STDIN); + + return ($answer !== false && trim($answer) !== '') ? trim($answer) : 'Untitled'; + } + + private function choose(string $question, array $choices): string + { + $this->output->writeln(" ? {$question}:"); + + foreach ($choices as $index => $choice) { + $this->output->writeln(" [{$index}] {$choice}"); + } + + $this->output->write(' Choice: '); + + $input = fgets(STDIN); + $index = ($input !== false && is_numeric(trim($input))) ? (int) trim($input) : 0; + + return $choices[$index] ?? $choices[0]; + } + + private function writeLine(string $message): void + { + $this->output->writeln(" {$message}"); + } +} diff --git a/src/Recorder/Codegen.php b/src/Recorder/Codegen.php new file mode 100644 index 00000000..118cad84 --- /dev/null +++ b/src/Recorder/Codegen.php @@ -0,0 +1,146 @@ +run(); + + if (! $process->isSuccessful()) { + $stderr = trim($process->getErrorOutput()); + $hint = $stderr !== '' ? "\n{$stderr}" : ''; + + throw new RuntimeException( + 'Playwright not found. Run: npm install -D @playwright/test' . $hint, + ); + } + } + + public function record( + string $url, + string $outputFile, + string $testIdAttribute, + ?string $viewport = null, + ?string $visitPath = null, + ?string $loadStorage = null, + ?string $device = null, + ): string + { + $command = $this->buildCommand( + url: $url, + outputFile: $outputFile, + testIdAttribute: $testIdAttribute, + viewport: $viewport, + visitPath: $visitPath, + saveStorage: null, + loadStorage: $loadStorage, + device: $device, + ); + + $process = (new Process($command))->setTimeout(null); + $process->run(); + + if (! $process->isSuccessful()) { + $this->throwFromStderr($process->getErrorOutput()); + } + + if (! file_exists($outputFile)) { + return ''; + } + + $content = file_get_contents($outputFile); + + return is_string($content) ? $content : ''; + } + + public function captureAuthState( + string $url, + string $storageFile, + string $loginPath, + string $testIdAttribute, + ?string $viewport = null, + ): void + { + $dir = dirname($storageFile); + + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $tmpOutput = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pest-auth-capture-' . getmypid() . '.jsonl'; + + $command = $this->buildCommand( + url: $url, + outputFile: $tmpOutput, + testIdAttribute: $testIdAttribute, + viewport: $viewport, + visitPath: $loginPath, + saveStorage: $storageFile, + ); + + (new Process($command))->setTimeout(null)->run(); + + @unlink($tmpOutput); + } + + /** + * @return string[] + */ + private function buildCommand( + string $url, + string $outputFile, + string $testIdAttribute, + ?string $viewport, + ?string $visitPath, + ?string $saveStorage, + ?string $loadStorage = null, + ?string $device = null, + ): array + { + $command = [ + 'npx', 'playwright', 'codegen', + '--target=jsonl', + '--test-id-attribute=' . $testIdAttribute, + '--output=' . $outputFile, + ]; + + if (! is_null($device)) { + $command[] = '--device=' . $device; + } else if (! is_null($viewport)) { + $command[] = '--viewport-size=' . $viewport; + } + + if (! is_null($saveStorage)) { + $command[] = '--save-storage=' . $saveStorage; + } + + if (! is_null($loadStorage)) { + $command[] = '--load-storage=' . $loadStorage; + } + + $command[] = ! is_null($visitPath) + ? rtrim($url, '/') . '/' . ltrim($visitPath, '/') + : $url; + + return $command; + } + + private function throwFromStderr(string $stderr): never + { + $stderr = trim($stderr); + + if (str_contains($stderr, "Executable doesn't exist")) { + throw new RuntimeException('Playwright browsers not installed. Run: npx playwright install'); + } + + throw new RuntimeException($stderr !== '' ? $stderr : 'Recording failed unexpectedly.'); + } +} diff --git a/src/Recorder/EventParser.php b/src/Recorder/EventParser.php new file mode 100644 index 00000000..a7700a21 --- /dev/null +++ b/src/Recorder/EventParser.php @@ -0,0 +1,38 @@ +dropUnsupported($events); + $events = $this->dropRedundantClicks($events); + $events = $this->deduplicateFills($events); + + return array_values($events); + } + + /** + * @param RecordedEvent[] $events + * @return RecordedEvent[] + */ + private function dropUnsupported(array $events): array + { + return array_values(array_filter($events, function (RecordedEvent $event): bool { + if (! in_array($event->type, self::SUPPORTED_TYPES, true)) { + return false; + } + + if ($event->type === 'navigate') { + return is_string($event->url); + } + + if (is_null($event->locator)) { + return false; + } + + return ! is_null(Locator::fromArray($event->locator)->toSelector($this->testIdAttribute)); + })); + } + + /** + * @param RecordedEvent[] $events + * @return RecordedEvent[] + */ + private function dropRedundantClicks(array $events): array + { + $result = []; + + foreach ($events as $index => $event) { + if ($event->type === 'click') { + $next = $events[$index + 1] ?? null; + + if ( + ! is_null($next) + && $next->type === 'fill' + && $this->resolveSelector($event) === $this->resolveSelector($next) + ) { + continue; + } + } + + $result[] = $event; + } + + return $result; + } + + /** + * @param RecordedEvent[] $events + * @return RecordedEvent[] + */ + private function deduplicateFills(array $events): array + { + $lastFillIndex = []; + $result = []; + + foreach ($events as $event) { + if ($event->type === 'fill') { + $selector = $this->resolveSelector($event); + + if (! is_null($selector) && isset($lastFillIndex[$selector])) { + unset($result[$lastFillIndex[$selector]]); + } + + $lastFillIndex[$selector ?? ''] = count($result); + } + + $result[] = $event; + } + + return array_values($result); + } + + private function resolveSelector(RecordedEvent $event): ?string + { + if (is_null($event->locator)) { + return null; + } + + return Locator::fromArray($event->locator)->toSelector($this->testIdAttribute); + } +} diff --git a/src/Recorder/Locator.php b/src/Recorder/Locator.php new file mode 100644 index 00000000..13b405e7 --- /dev/null +++ b/src/Recorder/Locator.php @@ -0,0 +1,65 @@ +kind === 'test-id' && $this->body !== '') { + return match ($testIdAttribute) { + 'data-test' => '@' . $this->body, + 'data-testid' => '@' . $this->body, + 'id' => '#' . $this->body, + default => sprintf('[%s="%s"]', $testIdAttribute, $this->body), + }; + } + + if ($this->kind === 'role') { + return $this->resolveRoleSelector(); + } + + if ($this->kind === 'text' && $this->body !== '') { + return $this->body; + } + + if (in_array($this->kind, ['css', 'default'], true) && $this->body !== '') { + return $this->body; + } + + return null; + } + + private function resolveRoleSelector(): ?string + { + $name = trim($this->options['name'] ?? ''); + + if ($name === '') { + return null; + } + + return match ($this->body) { + 'button', 'link', 'menuitem', 'tab', 'option' => $name, + 'textbox', 'searchbox', 'combobox' => sprintf('[aria-label="%s"]', $name), + 'checkbox', 'radio' => $name, + default => null, + }; + } +} diff --git a/src/Recorder/RecordedEvent.php b/src/Recorder/RecordedEvent.php new file mode 100644 index 00000000..21baacb7 --- /dev/null +++ b/src/Recorder/RecordedEvent.php @@ -0,0 +1,44 @@ +groupByNavigation($events, $baseUrl); + $body = $this->renderBody($pages); + + $escapedTitle = str_replace("'", "\\'", $title); + + return sprintf("it('%s', function (): void {\n%s\n});", $escapedTitle, $body); + } + + /** + * @param RecordedEvent[] $events + * @return array + */ + private function groupByNavigation(array $events, string $baseUrl): array + { + $pages = []; + $current = null; + + foreach ($events as $event) { + if ($event->type === 'navigate') { + if (! is_null($current)) { + $pages[] = $current; + } + + $current = [ + 'path' => $this->toPath($event->url ?? '/', $baseUrl), + 'events' => [], + ]; + + continue; + } + + if (is_null($current)) { + $current = ['path' => '/', 'events' => []]; + } + + $current['events'][] = $event; + } + + if (! is_null($current)) { + $pages[] = $current; + } + + return $pages; + } + + /** + * @param array $pages + */ + private function renderBody(array $pages): string + { + $blocks = []; + + foreach ($pages as $page) { + $lines = [sprintf(" \$page = visit('%s');", $page['path'])]; + + foreach ($page['events'] as $event) { + $line = $this->renderAction($event); + + if (! is_null($line)) { + $lines[] = ' $page->' . $line . ';'; + } + } + + $blocks[] = implode("\n", $lines); + } + + return implode("\n\n", $blocks); + } + + private function renderAction(RecordedEvent $event): ?string + { + $selector = ! is_null($event->locator) + ? Locator::fromArray($event->locator)->toSelector($this->testIdAttribute) + : null; + + return match ($event->type) { + 'click' => ! is_null($selector) + ? sprintf("click('%s')", $this->escape($selector)) + : null, + + 'fill' => ! is_null($selector) + ? sprintf("fill('%s', '%s')", $this->escape($selector), $this->escape($event->text ?? '')) + : null, + + 'selectOption' => ! is_null($selector) + ? sprintf("select('%s', '%s')", $this->escape($selector), $this->escape($event->selectValue ?? '')) + : null, + + 'check' => ! is_null($selector) + ? sprintf("check('%s')", $this->escape($selector)) + : null, + + 'uncheck' => ! is_null($selector) + ? sprintf("uncheck('%s')", $this->escape($selector)) + : null, + + 'assertVisible' => ! is_null($selector) + ? sprintf("assertVisible('%s')", $this->escape($selector)) + : null, + + 'assertText' => $this->renderAssertText($event, $selector), + + default => null, + }; + } + + private function renderAssertText(RecordedEvent $event, ?string $selector): ?string + { + $text = $event->text ?? ''; + + if ($text === '') { + return null; + } + + if (! is_null($selector)) { + return sprintf("assertSeeIn('%s', '%s')", $this->escape($selector), $this->escape($text)); + } + + return sprintf("assertSee('%s')", $this->escape($text)); + } + + private function toPath(string $url, string $baseUrl): string + { + $base = rtrim($baseUrl, '/'); + + if (str_starts_with($url, $base)) { + return substr($url, strlen($base)) ?: '/'; + } + + return $url; + } + + private function escape(string $value): string + { + return str_replace(['\\', "'"], ['\\\\', "\\'"], $value); + } +} diff --git a/src/Recorder/TestWriter.php b/src/Recorder/TestWriter.php new file mode 100644 index 00000000..22287df3 --- /dev/null +++ b/src/Recorder/TestWriter.php @@ -0,0 +1,60 @@ +isFile() && str_ends_with($file->getFilename(), 'Test.php')) { + $files[] = $file->getPathname(); + } + } + + sort($files); + + return $files; + } + + public function write(string $path, string $testCode): void + { + if (! file_exists($path)) { + $dir = dirname($path); + + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($path, " Date: Thu, 21 May 2026 21:33:59 +0200 Subject: [PATCH 2/6] Implement pest --record command for browser test generation Add --server/--migrate-fresh/--seed flags and auto-detect login sequences - Record.php: add --server (starts `php artisan serve`), --env, --migrate-fresh, --seed options - TestGenerator: strip password form fills and inject actingAs(factory()) automatically; pass $actingAs when --acting-as is set - Locator: use Selector::getByLabelSelector for textbox/searchbox/combobox roles - README: document --server (env mismatch problem), expand --acting-as section, fix example output --- README.md | 66 ++++++++++++++---- src/Record.php | 120 +++++++++++++++++++++++++++++++-- src/Recorder/Locator.php | 7 +- src/Recorder/TestGenerator.php | 88 ++++++++++++++++++++++-- 4 files changed, 255 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 25cc6bb8..549ae5c2 100644 --- a/README.md +++ b/README.md @@ -16,37 +16,75 @@ Playwright's codegen opens a browser window. Interact with your application, the | Option | Default | Description | |---|---|---| -| `--url=` | `http://localhost:8000` | Base URL to open | -| `--visit=` | `/` | Path to open on start (e.g. `/login`) | -| `--test-id-attribute=` | `data-test` | HTML attribute used for element selectors | +| `--url=` | `APP_URL` from `.env` | Base URL to open | +| `--visit=` | `/` | Path to open on start (e.g. `/dashboard`) | +| `--test-id-attribute=` | `id` | HTML attribute used for element selectors | | `--device=` | — | Emulate a device (e.g. `"iPhone 15"`) | | `--viewport=` | — | Viewport size in pixels (e.g. `1280,800`) | -| `--acting-as=` | — | Name of a saved browser auth state to load | +| `--acting-as=` | — | Name of a saved auth state (see below) | +| `--server` | — | Start `php artisan serve` before recording | +| `--env=` | `local` | Environment passed to `--server` and `--migrate-fresh` | +| `--migrate-fresh` | — | Run `migrate:fresh` before opening the browser | +| `--seed` | — | Seed the database after `--migrate-fresh` | **Example:** ```bash -vendor/bin/pest --record --url=https://example.com --visit=/login --test-id-attribute=id +vendor/bin/pest --record --visit=/dashboard --acting-as=user ``` **Example output:** ```php -it('can login', function (): void { - $page = visit('/login'); - $page->fill('#email', 'user@example.com'); - $page->fill('#password', 'secret'); - $page->click('Sign in'); - $page->assertSee('Dashboard'); +it('can switch to dark mode', function (): void { + $this->actingAs(\App\Models\User::factory()->create()); + + visit('/dashboard') + ->click('Settings') + ->click('Appearance') + ->click('Dark'); }); ``` -**Auth state (`--acting-as`):** +**Why you need `--server`:** + +`pest --record` opens a real browser (Playwright codegen) against a running HTTP server. The recorder connects to your app just like a normal user would — it does NOT use the in-process test server that runs during `vendor/bin/pest`. + +If your app is not already running, pass `--server` to start `php artisan serve` automatically: + +```bash +vendor/bin/pest --record --server --env=local --visit=/dashboard +``` + +Without `--server`, you must start the dev server yourself before recording: + +```bash +php artisan serve & +vendor/bin/pest --record +``` + +**Auth state and the environment mismatch problem (`--acting-as`):** + +Tests run with a fresh, isolated database (`APP_ENV=testing`). If you record a login sequence without `--acting-as`, your real credentials from the local environment are captured — but those credentials don't exist in the test database. + +`--acting-as` solves this in two steps: + +1. **First use:** a browser opens at `/login`. Log in with your real credentials. The browser session (cookies, localStorage) is saved to a temp file. +2. **Subsequent recordings:** the saved session is loaded automatically, so the browser starts already authenticated. No login form is filled, so no credentials are captured. + +The generated test replaces the login flow with `actingAs(User::factory()->create())`, which creates a real user in the test database and authenticates without touching the login form: + +```bash +# First run saves the auth session; subsequent runs reuse it +vendor/bin/pest --record --server --acting-as=user --visit=/dashboard +``` + +If you record without `--acting-as` and Pest detects a password field in the recording, it automatically strips the login sequence and injects `actingAs()` instead. -Saves and reloads browser-level auth state (cookies, localStorage) between recordings. On first use, a login sequence is recorded and saved. Subsequent recordings load the saved state automatically. +**Fresh database before recording:** ```bash -vendor/bin/pest --record --acting-as=admin +vendor/bin/pest --record --server --migrate-fresh --seed --env=local --visit=/dashboard ``` **Requirements:** `npm install -D @playwright/test` and `npx playwright install`. diff --git a/src/Record.php b/src/Record.php index 4dd22a9a..cdcce29c 100644 --- a/src/Record.php +++ b/src/Record.php @@ -14,6 +14,7 @@ use Pest\TestSuite; use RuntimeException; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; /** * @internal @@ -24,7 +25,7 @@ final class Record implements HandlesArguments private const string OPTION = '--record'; - private const string DEFAULT_TEST_ID_ATTRIBUTE = 'data-test'; + private const string DEFAULT_TEST_ID_ATTRIBUTE = 'id'; public function __construct( private readonly OutputInterface $output, @@ -42,14 +43,30 @@ public function handleArguments(array $arguments): array $arguments = $this->popArgument(self::OPTION, $arguments); - $url = $this->popArgumentValue('--url', $arguments) ?? 'http://localhost:8000'; + $url = $this->popArgumentValue('--url', $arguments) ?? $this->resolveAppUrl(); $visitPath = $this->popArgumentValue('--visit', $arguments); $authName = $this->popArgumentValue('--acting-as', $arguments); $viewport = $this->popArgumentValue('--viewport', $arguments); $device = $this->popArgumentValue('--device', $arguments); $testIdAttribute = $this->popArgumentValue('--test-id-attribute', $arguments) ?? self::DEFAULT_TEST_ID_ATTRIBUTE; + $env = $this->popArgumentValue('--env', $arguments) ?? 'local'; - $this->record($url, $visitPath, $authName, $viewport, $device, $testIdAttribute); + $server = $this->hasArgument('--server', $arguments); + if ($server) { + $arguments = $this->popArgument('--server', $arguments); + } + + $migrateFresh = $this->hasArgument('--migrate-fresh', $arguments); + if ($migrateFresh) { + $arguments = $this->popArgument('--migrate-fresh', $arguments); + } + + $seed = $this->hasArgument('--seed', $arguments); + if ($seed) { + $arguments = $this->popArgument('--seed', $arguments); + } + + $this->record($url, $visitPath, $authName, $viewport, $device, $testIdAttribute, $server, $env, $migrateFresh, $seed); exit(0); } @@ -61,6 +78,10 @@ private function record( ?string $viewport, ?string $device, string $testIdAttribute, + bool $server = false, + string $env = 'local', + bool $migrateFresh = false, + bool $seed = false, ): void { $codegen = new Codegen; @@ -73,6 +94,18 @@ private function record( return; } + if ($migrateFresh) { + try { + $this->migrateFresh($env, $seed); + } catch (RuntimeException $e) { + $this->writeLine('✗ ' . $e->getMessage()); + + return; + } + } + + $serverProcess = $server ? $this->startServer($url, $env) : null; + $loadStorage = ! is_null($authName) ? $this->resolveAuthState($codegen, $url, $authName, $viewport, $testIdAttribute) : null; @@ -101,7 +134,7 @@ private function record( $title = $this->prompt('Test description'); $outputPath = $this->resolveOutputPath(); - $code = (new TestGenerator($testIdAttribute))->generate($events, $title, $url); + $code = (new TestGenerator($testIdAttribute))->generate($events, $title, $url, ! is_null($authName)); (new TestWriter)->write($outputPath, $code); @@ -110,9 +143,58 @@ private function record( $this->writeLine('✗ ' . $e->getMessage()); } finally { @unlink($tmpFile); + $serverProcess?->stop(3); } } + private function startServer(string $url, string $env): Process + { + $port = (int) (parse_url($url, PHP_URL_PORT) ?? 8000); + + $process = new Process(['php', 'artisan', 'serve', '--port=' . $port, '--env=' . $env]); + $process->setTimeout(null); + $process->start(); + + $deadline = time() + 10; + + while (time() < $deadline) { + usleep(200_000); + $connection = @fsockopen('127.0.0.1', $port, $errno, $errstr, 1); + + if ($connection !== false) { + fclose($connection); + $this->writeLine('✔ Dev server started at ' . $url); + + return $process; + } + } + + $this->writeLine('● Server may not be ready yet, proceeding...'); + + return $process; + } + + private function migrateFresh(string $env, bool $seed): void + { + $this->writeLine('● Running migrate:fresh...'); + + $command = ['php', 'artisan', 'migrate:fresh', '--env=' . $env, '--force']; + + if ($seed) { + $command[] = '--seed'; + } + + $process = new Process($command); + $process->setTimeout(null); + $process->run(); + + if (! $process->isSuccessful()) { + throw new RuntimeException('migrate:fresh failed: ' . trim($process->getErrorOutput())); + } + + $this->writeLine('✔ Database ready.'); + } + private function resolveAuthState( Codegen $codegen, string $url, @@ -170,6 +252,36 @@ private function resolveOutputPath(): string return $testsDir . DIRECTORY_SEPARATOR . $name . 'Test.php'; } + private function resolveAppUrl(): string + { + return $_ENV['APP_URL'] + ?? $_SERVER['APP_URL'] + ?? (getenv('APP_URL') ?: null) + ?? $this->readDotEnvValue('APP_URL') + ?? 'http://localhost:8000'; + } + + private function readDotEnvValue(string $key): ?string + { + $envFile = $this->testSuite->rootPath.DIRECTORY_SEPARATOR.'.env'; + + if (! file_exists($envFile)) { + return null; + } + + foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { + if (str_starts_with(ltrim($line), '#')) { + continue; + } + + if (str_starts_with($line, $key.'=')) { + return trim(substr($line, strlen($key) + 1), '"\''); + } + } + + return null; + } + private function prompt(string $question): string { $this->output->write(" ? {$question}: "); diff --git a/src/Recorder/Locator.php b/src/Recorder/Locator.php index 13b405e7..4be02171 100644 --- a/src/Recorder/Locator.php +++ b/src/Recorder/Locator.php @@ -4,6 +4,8 @@ namespace Pest\Browser\Recorder; +use Pest\Browser\Support\Selector; + final readonly class Locator { private function __construct( @@ -56,9 +58,8 @@ private function resolveRoleSelector(): ?string } return match ($this->body) { - 'button', 'link', 'menuitem', 'tab', 'option' => $name, - 'textbox', 'searchbox', 'combobox' => sprintf('[aria-label="%s"]', $name), - 'checkbox', 'radio' => $name, + 'button', 'link', 'menuitem', 'tab', 'option', 'checkbox', 'radio' => $name, + 'textbox', 'searchbox', 'combobox' => Selector::getByLabelSelector($name, true), default => null, }; } diff --git a/src/Recorder/TestGenerator.php b/src/Recorder/TestGenerator.php index d1641f8a..886decf1 100644 --- a/src/Recorder/TestGenerator.php +++ b/src/Recorder/TestGenerator.php @@ -13,16 +13,91 @@ public function __construct( /** * @param RecordedEvent[] $events */ - public function generate(array $events, string $title, string $baseUrl): string + public function generate(array $events, string $title, string $baseUrl, bool $actingAs = false): string { + if (! $actingAs) { + [$events, $actingAs] = $this->stripLoginSequence($events); + } + $pages = $this->groupByNavigation($events, $baseUrl); $body = $this->renderBody($pages); + if ($actingAs) { + $body = " \$this->actingAs(\\App\\Models\\User::factory()->create());\n\n" . $body; + } + $escapedTitle = str_replace("'", "\\'", $title); return sprintf("it('%s', function (): void {\n%s\n});", $escapedTitle, $body); } + /** + * Detect a login form (email + password fills) and strip it from the recorded events. + * Returns the cleaned event list and whether a login sequence was found. + * + * @param RecordedEvent[] $events + * @return array{0: RecordedEvent[], 1: bool} + */ + private function stripLoginSequence(array $events): array + { + $passwordIdx = null; + + foreach ($events as $i => $event) { + if ($event->type !== 'fill') { + continue; + } + + $selector = $this->resolveSelector($event); + + if ($selector !== null && str_contains(strtolower($selector), 'password')) { + $passwordIdx = $i; + break; + } + } + + if ($passwordIdx === null) { + return [$events, false]; + } + + $emailIdx = null; + + for ($i = $passwordIdx - 1; $i >= 0; $i--) { + if ($events[$i]->type === 'fill') { + $emailIdx = $i; + break; + } + } + + $start = $emailIdx ?? $passwordIdx; + + // Also remove the "Log in" link click that precedes the email field + if ($emailIdx !== null && $start > 0 && $events[$start - 1]->type === 'click') { + $start--; + } + + // Remove up to 2 clicks after the password fill (remember-me checkbox + submit button) + $end = $passwordIdx; + $clickCount = 0; + + while (isset($events[$end + 1]) && $events[$end + 1]->type === 'click' && $clickCount < 2) { + $end++; + $clickCount++; + } + + array_splice($events, $start, $end - $start + 1); + + return [$events, true]; + } + + private function resolveSelector(RecordedEvent $event): ?string + { + if ($event->locator === null) { + return null; + } + + return Locator::fromArray($event->locator)->toSelector($this->testIdAttribute); + } + /** * @param RecordedEvent[] $events * @return array @@ -68,16 +143,19 @@ private function renderBody(array $pages): string $blocks = []; foreach ($pages as $page) { - $lines = [sprintf(" \$page = visit('%s');", $page['path'])]; + $lines = [sprintf(" visit('%s')", $page['path'])]; foreach ($page['events'] as $event) { - $line = $this->renderAction($event); + $action = $this->renderAction($event); - if (! is_null($line)) { - $lines[] = ' $page->' . $line . ';'; + if (! is_null($action)) { + $lines[] = ' ->' . $action; } } + $lastIndex = count($lines) - 1; + $lines[$lastIndex] .= ';'; + $blocks[] = implode("\n", $lines); } From 70ea1289d8fbe10103e72b345adf7ac3279b3e67 Mon Sep 17 00:00:00 2001 From: Johan Montenij Date: Fri, 22 May 2026 13:39:19 +0200 Subject: [PATCH 3/6] Refactor browser recorder: auto-start test server, programmatic auth using factory session, remove login sequence auto-detection, add RECORDING.md --- README.md | 83 +------------- RECORDING.md | 51 +++++++++ src/Record.php | 199 +++++++++++++++++++++------------ src/Recorder/TestGenerator.php | 79 +------------ 4 files changed, 184 insertions(+), 228 deletions(-) create mode 100644 RECORDING.md diff --git a/README.md b/README.md index 549ae5c2..55d99dfd 100644 --- a/README.md +++ b/README.md @@ -2,92 +2,15 @@ This repository contains the Pest Plugin for Browser. > If you want to start testing your application with Pest, visit the main **[Pest Repository](https://github.com/pestphp/pest)**. -## Recording Tests +### Recording Tests -Generate a browser test by recording your interactions live in the browser — no test code to write by hand. +Generate browser-tests by recording your interactions in the browser: ```bash vendor/bin/pest --record ``` -Playwright's codegen opens a browser window. Interact with your application, then close the browser. Pest prompts for a test description and output file, then writes the generated test to `tests/Browser/`. - -**Options:** - -| Option | Default | Description | -|---|---|---| -| `--url=` | `APP_URL` from `.env` | Base URL to open | -| `--visit=` | `/` | Path to open on start (e.g. `/dashboard`) | -| `--test-id-attribute=` | `id` | HTML attribute used for element selectors | -| `--device=` | — | Emulate a device (e.g. `"iPhone 15"`) | -| `--viewport=` | — | Viewport size in pixels (e.g. `1280,800`) | -| `--acting-as=` | — | Name of a saved auth state (see below) | -| `--server` | — | Start `php artisan serve` before recording | -| `--env=` | `local` | Environment passed to `--server` and `--migrate-fresh` | -| `--migrate-fresh` | — | Run `migrate:fresh` before opening the browser | -| `--seed` | — | Seed the database after `--migrate-fresh` | - -**Example:** - -```bash -vendor/bin/pest --record --visit=/dashboard --acting-as=user -``` - -**Example output:** - -```php -it('can switch to dark mode', function (): void { - $this->actingAs(\App\Models\User::factory()->create()); - - visit('/dashboard') - ->click('Settings') - ->click('Appearance') - ->click('Dark'); -}); -``` - -**Why you need `--server`:** - -`pest --record` opens a real browser (Playwright codegen) against a running HTTP server. The recorder connects to your app just like a normal user would — it does NOT use the in-process test server that runs during `vendor/bin/pest`. - -If your app is not already running, pass `--server` to start `php artisan serve` automatically: - -```bash -vendor/bin/pest --record --server --env=local --visit=/dashboard -``` - -Without `--server`, you must start the dev server yourself before recording: - -```bash -php artisan serve & -vendor/bin/pest --record -``` - -**Auth state and the environment mismatch problem (`--acting-as`):** - -Tests run with a fresh, isolated database (`APP_ENV=testing`). If you record a login sequence without `--acting-as`, your real credentials from the local environment are captured — but those credentials don't exist in the test database. - -`--acting-as` solves this in two steps: - -1. **First use:** a browser opens at `/login`. Log in with your real credentials. The browser session (cookies, localStorage) is saved to a temp file. -2. **Subsequent recordings:** the saved session is loaded automatically, so the browser starts already authenticated. No login form is filled, so no credentials are captured. - -The generated test replaces the login flow with `actingAs(User::factory()->create())`, which creates a real user in the test database and authenticates without touching the login form: - -```bash -# First run saves the auth session; subsequent runs reuse it -vendor/bin/pest --record --server --acting-as=user --visit=/dashboard -``` - -If you record without `--acting-as` and Pest detects a password field in the recording, it automatically strips the login sequence and injects `actingAs()` instead. - -**Fresh database before recording:** - -```bash -vendor/bin/pest --record --server --migrate-fresh --seed --env=local --visit=/dashboard -``` - -**Requirements:** `npm install -D @playwright/test` and `npx playwright install`. +See **[RECORDING.md](RECORDING.md)** for more information. - Explore our docs at **[pestphp.com »](https://pestphp.com)** - Follow the creator Nuno Maduro: diff --git a/RECORDING.md b/RECORDING.md new file mode 100644 index 00000000..fc4a0cfa --- /dev/null +++ b/RECORDING.md @@ -0,0 +1,51 @@ +# Recording Guide + +## How it works + +`vendor/bin/pest --record` automatically starts an HTTP server with `APP_ENV=testing` before the browser opens, then shuts it down when recording ends — matching the same behavior as running browser tests normally with `vendor/bin/pest`. + +The browser opens at full screen resolution by default (auto-detected per OS). Close the browser when done. Pest prompts for a test description and output file, then writes the generated test to `tests/Browser/`. + +## Options + +| Option | Default | Description | +|---|---|---| +| `--url=` | auto | Skip auto-server; connect to this URL instead | +| `--visit=` | `/` | Path to open on start | +| `--test-id-attribute=` | `id` | HTML attribute used for element selectors | +| `--device=` | — | Emulate a device (e.g. `"iPhone 15"`) | +| `--viewport=` | screen resolution | Viewport size in pixels (e.g. `1280,800`) | +| `--acting-as=` | — | Name for the auth state (see below) | +| `--env=` | `testing` | Environment for the auto-started server | +| `--migrate-fresh` | — | Run `migrate:fresh` before opening the browser | +| `--seed` | — | Seed the database after `--migrate-fresh` | + +## Recording authenticated flows (`--acting-as`) + +Tests run with `APP_ENV=testing` (fresh, isolated DB). Recording against credentials that only exist in your local DB means those credentials won't exist when the test runs. + +`--acting-as` solves this without manual login: before the browser opens, Pest bootstraps the Laravel app, creates a factory user, starts a session authenticated as that user, and injects a valid session cookie into the browser. The browser starts pre-authenticated. + +```bash +vendor/bin/pest --record --acting-as=user --visit=/dashboard +``` + +The generated test uses `$this->actingAs(\App\Models\User::factory()->create())` — no real credentials, works in any environment. + +## Fresh database before recording + +```bash +vendor/bin/pest --record --migrate-fresh --seed --visit=/dashboard +``` + +Useful when you want to record against a predictable dataset. + +## Recording against an existing server + +Pass `--url=` to skip the auto-server entirely: + +```bash +vendor/bin/pest --record --url=http://localhost:8000 --visit=/dashboard +``` + +Useful when you have Herd, Valet, or a running `php artisan serve` instance. diff --git a/src/Record.php b/src/Record.php index cdcce29c..54de2d74 100644 --- a/src/Record.php +++ b/src/Record.php @@ -9,6 +9,7 @@ use Pest\Browser\Recorder\EventSanitizer; use Pest\Browser\Recorder\TestGenerator; use Pest\Browser\Recorder\TestWriter; +use Pest\Browser\Support\Port; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Plugins\Concerns\HandleArguments; use Pest\TestSuite; @@ -43,43 +44,30 @@ public function handleArguments(array $arguments): array $arguments = $this->popArgument(self::OPTION, $arguments); - $url = $this->popArgumentValue('--url', $arguments) ?? $this->resolveAppUrl(); + $url = $this->popArgumentValue('--url', $arguments); $visitPath = $this->popArgumentValue('--visit', $arguments); $authName = $this->popArgumentValue('--acting-as', $arguments); $viewport = $this->popArgumentValue('--viewport', $arguments); $device = $this->popArgumentValue('--device', $arguments); $testIdAttribute = $this->popArgumentValue('--test-id-attribute', $arguments) ?? self::DEFAULT_TEST_ID_ATTRIBUTE; - $env = $this->popArgumentValue('--env', $arguments) ?? 'local'; - - $server = $this->hasArgument('--server', $arguments); - if ($server) { - $arguments = $this->popArgument('--server', $arguments); - } + $env = $this->popArgumentValue('--env', $arguments) ?? 'testing'; $migrateFresh = $this->hasArgument('--migrate-fresh', $arguments); - if ($migrateFresh) { - $arguments = $this->popArgument('--migrate-fresh', $arguments); - } - $seed = $this->hasArgument('--seed', $arguments); - if ($seed) { - $arguments = $this->popArgument('--seed', $arguments); - } - $this->record($url, $visitPath, $authName, $viewport, $device, $testIdAttribute, $server, $env, $migrateFresh, $seed); + $this->record($url, $visitPath, $authName, $viewport, $device, $testIdAttribute, $env, $migrateFresh, $seed); exit(0); } private function record( - string $url, + ?string $url, ?string $visitPath, ?string $authName, ?string $viewport, ?string $device, string $testIdAttribute, - bool $server = false, - string $env = 'local', + string $env = 'testing', bool $migrateFresh = false, bool $seed = false, ): void @@ -104,11 +92,29 @@ private function record( } } - $serverProcess = $server ? $this->startServer($url, $env) : null; + if (is_null($viewport) && is_null($device)) { + $viewport = $this->detectScreenResolution(); + } - $loadStorage = ! is_null($authName) - ? $this->resolveAuthState($codegen, $url, $authName, $viewport, $testIdAttribute) - : null; + $serverProcess = null; + + if (is_null($url)) { + $port = Port::find(); + $url = sprintf('http://127.0.0.1:%d', $port); + $serverProcess = $this->startServer($url, $env); + } + + $loadStorage = null; + + if (! is_null($authName)) { + $loadStorage = $this->resolveAuthState($url, $authName, $env); + + if (is_null($loadStorage)) { + $this->writeLine('✗ Auth state generation failed.'); + + return; + } + } $tmpFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pest-recording-' . getmypid() . '.jsonl'; @@ -174,6 +180,27 @@ private function startServer(string $url, string $env): Process return $process; } + private function detectScreenResolution(): string + { + $output = match (PHP_OS_FAMILY) { + 'Linux' => shell_exec('xrandr --current 2>/dev/null | grep -m1 " connected" | grep -oP "\d+x\d+"'), + 'Darwin' => shell_exec('system_profiler SPDisplaysDataType 2>/dev/null | grep -m1 "Resolution"'), + 'Windows' => shell_exec('wmic desktopmonitor get screenwidth,screenheight 2>nul'), + default => null, + }; + + if ($output === null || $output === '') { + return '1920,1000'; + } + + return match (PHP_OS_FAMILY) { + 'Linux' => preg_match('/(\d+)x(\d+)/', trim($output), $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', + 'Darwin' => preg_match('/(\d+) x (\d+)/', $output, $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', + 'Windows' => preg_match('/(\d+)\s+(\d+)/', trim($output), $m) ? $m[2] . ',' . ((int) $m[1] - 80) : '1920,1000', + default => '1920,1000', + }; + } + private function migrateFresh(string $env, bool $seed): void { $this->writeLine('● Running migrate:fresh...'); @@ -195,34 +222,94 @@ private function migrateFresh(string $env, bool $seed): void $this->writeLine('✔ Database ready.'); } - private function resolveAuthState( - Codegen $codegen, - string $url, - string $name, - ?string $viewport, - string $testIdAttribute, - ): ?string + private function resolveAuthState(string $url, string $name, string $env): ?string { $storageFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pest-auth-' . preg_replace('/[^a-z0-9_-]/i', '-', $name) . '.json'; - if (! file_exists($storageFile)) { - $this->writeLine(sprintf( - "● No auth state found for '%s'. Record a login sequence to save it.", - $name, - )); + $this->writeLine(sprintf("● Generating auth state for '%s'...", $name)); - try { - $codegen->captureAuthState($url, $storageFile, '/login', $testIdAttribute, $viewport); - } catch (RuntimeException $e) { - $this->writeLine('✗ ' . $e->getMessage()); + try { + $this->generateAuthState($url, $storageFile, $env); + } catch (RuntimeException $e) { + $this->writeLine('✗ ' . $e->getMessage()); - return null; - } + return null; + } + + $this->writeLine('✔ Auth state ready.'); + + return $storageFile; + } + + private function generateAuthState(string $url, string $storageFile, string $env): void + { + $rootPath = addslashes($this->testSuite->rootPath); + $host = parse_url($url, PHP_URL_HOST) ?? '127.0.0.1'; + $cookieName = addslashes(preg_replace('/[^a-z0-9_\-]/i', '_', basename($rootPath)) . '_session'); + + $script = <<make(\Illuminate\Contracts\Console\Kernel::class); + \$kernel->bootstrap(); + + \$manager = app('session'); + \$store = \$manager->driver(); + \$store->setId(\Illuminate\Support\Str::random(40)); + \$store->start(); + + \$user = \App\Models\User::factory()->create(); + app('auth')->guard()->setUser(\$user); + \$store->put(app('auth')->guard()->getName(), \$user->getAuthIdentifier()); + \$store->put('password_hash_' . app('auth')->getDefaultDriver(), \$user->getAuthPassword()); + \$store->save(); + + \$sessionId = \$store->getId(); + \$cookieName = config('session.cookie'); + \$encrypter = app('encrypter'); + \$prefix = \Illuminate\Cookie\CookieValuePrefix::create(\$cookieName, \$encrypter->getKey()); + \$encrypted = \$encrypter->encrypt(\$prefix . \$sessionId, false); + + echo json_encode([ + 'cookies' => [[ + 'name' => \$cookieName, + 'value' => \$encrypted, + 'domain' => '{$host}', + 'path' => '/', + 'expires' => -1, + 'httpOnly' => true, + 'secure' => false, + 'sameSite' => 'Lax', + ]], + 'origins' => [], + ]); + PHP; + + $tmpScript = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pest-auth-gen-' . getmypid() . '.php'; + file_put_contents($tmpScript, $script); + + $process = new Process(['php', $tmpScript]); + $process->setTimeout(30); + $process->setEnv(['APP_ENV' => $env]); + $process->run(); + + @unlink($tmpScript); + + if (! $process->isSuccessful()) { + throw new RuntimeException('Auth generation failed: ' . trim($process->getErrorOutput())); + } + + $output = trim($process->getOutput()); + + if ($output === '' || json_decode($output) === null) { + throw new RuntimeException('Auth generation returned invalid output.'); } - return file_exists($storageFile) ? $storageFile : null; + file_put_contents($storageFile, $output); } private function resolveOutputPath(): string @@ -252,36 +339,6 @@ private function resolveOutputPath(): string return $testsDir . DIRECTORY_SEPARATOR . $name . 'Test.php'; } - private function resolveAppUrl(): string - { - return $_ENV['APP_URL'] - ?? $_SERVER['APP_URL'] - ?? (getenv('APP_URL') ?: null) - ?? $this->readDotEnvValue('APP_URL') - ?? 'http://localhost:8000'; - } - - private function readDotEnvValue(string $key): ?string - { - $envFile = $this->testSuite->rootPath.DIRECTORY_SEPARATOR.'.env'; - - if (! file_exists($envFile)) { - return null; - } - - foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { - if (str_starts_with(ltrim($line), '#')) { - continue; - } - - if (str_starts_with($line, $key.'=')) { - return trim(substr($line, strlen($key) + 1), '"\''); - } - } - - return null; - } - private function prompt(string $question): string { $this->output->write(" ? {$question}: "); diff --git a/src/Recorder/TestGenerator.php b/src/Recorder/TestGenerator.php index 886decf1..7421247b 100644 --- a/src/Recorder/TestGenerator.php +++ b/src/Recorder/TestGenerator.php @@ -15,10 +15,6 @@ public function __construct( */ public function generate(array $events, string $title, string $baseUrl, bool $actingAs = false): string { - if (! $actingAs) { - [$events, $actingAs] = $this->stripLoginSequence($events); - } - $pages = $this->groupByNavigation($events, $baseUrl); $body = $this->renderBody($pages); @@ -31,73 +27,6 @@ public function generate(array $events, string $title, string $baseUrl, bool $ac return sprintf("it('%s', function (): void {\n%s\n});", $escapedTitle, $body); } - /** - * Detect a login form (email + password fills) and strip it from the recorded events. - * Returns the cleaned event list and whether a login sequence was found. - * - * @param RecordedEvent[] $events - * @return array{0: RecordedEvent[], 1: bool} - */ - private function stripLoginSequence(array $events): array - { - $passwordIdx = null; - - foreach ($events as $i => $event) { - if ($event->type !== 'fill') { - continue; - } - - $selector = $this->resolveSelector($event); - - if ($selector !== null && str_contains(strtolower($selector), 'password')) { - $passwordIdx = $i; - break; - } - } - - if ($passwordIdx === null) { - return [$events, false]; - } - - $emailIdx = null; - - for ($i = $passwordIdx - 1; $i >= 0; $i--) { - if ($events[$i]->type === 'fill') { - $emailIdx = $i; - break; - } - } - - $start = $emailIdx ?? $passwordIdx; - - // Also remove the "Log in" link click that precedes the email field - if ($emailIdx !== null && $start > 0 && $events[$start - 1]->type === 'click') { - $start--; - } - - // Remove up to 2 clicks after the password fill (remember-me checkbox + submit button) - $end = $passwordIdx; - $clickCount = 0; - - while (isset($events[$end + 1]) && $events[$end + 1]->type === 'click' && $clickCount < 2) { - $end++; - $clickCount++; - } - - array_splice($events, $start, $end - $start + 1); - - return [$events, true]; - } - - private function resolveSelector(RecordedEvent $event): ?string - { - if ($event->locator === null) { - return null; - } - - return Locator::fromArray($event->locator)->toSelector($this->testIdAttribute); - } - /** * @param RecordedEvent[] $events * @return array @@ -193,13 +122,13 @@ private function renderAction(RecordedEvent $event): ?string ? sprintf("assertVisible('%s')", $this->escape($selector)) : null, - 'assertText' => $this->renderAssertText($event, $selector), + 'assertText' => $this->renderAssertText($event), default => null, }; } - private function renderAssertText(RecordedEvent $event, ?string $selector): ?string + private function renderAssertText(RecordedEvent $event): ?string { $text = $event->text ?? ''; @@ -207,10 +136,6 @@ private function renderAssertText(RecordedEvent $event, ?string $selector): ?str return null; } - if (! is_null($selector)) { - return sprintf("assertSeeIn('%s', '%s')", $this->escape($selector), $this->escape($text)); - } - return sprintf("assertSee('%s')", $this->escape($text)); } From 0b469e7214ae3719d6b0b80cd7e25dd435fc968d Mon Sep 17 00:00:00 2001 From: Johan Montenij Date: Fri, 22 May 2026 14:32:07 +0200 Subject: [PATCH 4/6] Add unit tests for Recorder classes and fix deduplicateFills stale-index bug in EventSanitizer --- src/Recorder/EventSanitizer.php | 23 ++- tests/Unit/Recorder/EventSanitizerTest.php | 178 +++++++++++++++++++ tests/Unit/Recorder/LocatorTest.php | 195 +++++++++++++++++++++ tests/Unit/Recorder/TestGeneratorTest.php | 182 +++++++++++++++++++ 4 files changed, 570 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/Recorder/EventSanitizerTest.php create mode 100644 tests/Unit/Recorder/LocatorTest.php create mode 100644 tests/Unit/Recorder/TestGeneratorTest.php diff --git a/src/Recorder/EventSanitizer.php b/src/Recorder/EventSanitizer.php index ff1a97e5..1fc114d3 100644 --- a/src/Recorder/EventSanitizer.php +++ b/src/Recorder/EventSanitizer.php @@ -90,24 +90,31 @@ private function dropRedundantClicks(array $events): array */ private function deduplicateFills(array $events): array { - $lastFillIndex = []; - $result = []; + $lastIndex = []; - foreach ($events as $event) { + foreach ($events as $index => $event) { if ($event->type === 'fill') { $selector = $this->resolveSelector($event); - - if (! is_null($selector) && isset($lastFillIndex[$selector])) { - unset($result[$lastFillIndex[$selector]]); + if (! is_null($selector)) { + $lastIndex[$selector] = $index; } + } + } + + $result = []; - $lastFillIndex[$selector ?? ''] = count($result); + foreach ($events as $index => $event) { + if ($event->type === 'fill') { + $selector = $this->resolveSelector($event); + if (! is_null($selector) && $lastIndex[$selector] !== $index) { + continue; + } } $result[] = $event; } - return array_values($result); + return $result; } private function resolveSelector(RecordedEvent $event): ?string diff --git a/tests/Unit/Recorder/EventSanitizerTest.php b/tests/Unit/Recorder/EventSanitizerTest.php new file mode 100644 index 00000000..5acd90c8 --- /dev/null +++ b/tests/Unit/Recorder/EventSanitizerTest.php @@ -0,0 +1,178 @@ + 'test-id', + 'body' => $id, + 'options' => [], + ]; +} + +function roleLocator(string $role, string $name): array +{ + return [ + 'kind' => 'role', + 'body' => $role, + 'options' => [ + 'name' => $name, + ], + ]; +} + +// dropUnsupported +it('keeps supported event types', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('navigate', url: 'http://localhost/'), + makeEvent('click', testIdLocator('btn')), + makeEvent('fill', testIdLocator('email')), + makeEvent('check', testIdLocator('remember')), + makeEvent('uncheck', testIdLocator('remember')), + makeEvent('assertVisible', testIdLocator('heading')), + makeEvent('assertText', testIdLocator('msg'), text: 'Hello'), + ]; + + expect($sanitizer->sanitize($events))->toHaveCount(7); +}); + +it('drops unsupported event types', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('hover', testIdLocator('btn')), + makeEvent('press', testIdLocator('input')), + makeEvent('navigate', url: 'http://localhost/'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(1) + ->and($result[0]->type)->toBe('navigate'); +}); + +it('drops navigate events without url', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('navigate'), + makeEvent('navigate', url: 'http://localhost/'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(1); +}); + +it('drops events with null locator (except navigate)', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('click'), + makeEvent('fill'), + makeEvent('navigate', url: 'http://localhost/'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(1) + ->and($result[0]->type)->toBe('navigate'); +}); + +it('drops events where locator resolves to null selector', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('click', [ + 'kind' => 'unknown', + 'body' => '', + 'options' => [], + ]), + makeEvent('navigate', url: 'http://localhost/'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(1); +}); + +// dropRedundantClicks +it('drops click when immediately followed by fill on same selector', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('click', testIdLocator('email')), + makeEvent('fill', testIdLocator('email'), text: 'user@example.com'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(1) + ->and($result[0]->type)->toBe('fill'); +}); + +it('keeps click when followed by fill on different selector', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('click', testIdLocator('btn')), + makeEvent('fill', testIdLocator('email'), text: 'user@example.com'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(2); +}); + +it('keeps click not followed by fill', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('click', roleLocator('button', 'Submit')), + makeEvent('navigate', url: 'http://localhost/dashboard'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(2); +}); + +// deduplicateFills +it('keeps only the last fill for each selector', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('fill', testIdLocator('email'), text: 'first@example.com'), + makeEvent('fill', testIdLocator('email'), text: 'second@example.com'), + makeEvent('fill', testIdLocator('email'), text: 'final@example.com'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(1) + ->and($result[0]->text)->toBe('final@example.com'); +}); + +it('keeps fills for different selectors', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('fill', testIdLocator('email'), text: 'user@example.com'), + makeEvent('fill', testIdLocator('password'), text: 'secret'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(2); +}); + +it('preserves event order after deduplication', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeEvent('fill', testIdLocator('email'), text: 'first@example.com'), + makeEvent('fill', testIdLocator('password'), text: 'secret'), + makeEvent('fill', testIdLocator('email'), text: 'final@example.com'), + ]; + $result = $sanitizer->sanitize($events); + + expect($result)->toHaveCount(2) + ->and($result[0]->text)->toBe('secret') + ->and($result[1]->text)->toBe('final@example.com'); +}); diff --git a/tests/Unit/Recorder/LocatorTest.php b/tests/Unit/Recorder/LocatorTest.php new file mode 100644 index 00000000..dd9ed5dd --- /dev/null +++ b/tests/Unit/Recorder/LocatorTest.php @@ -0,0 +1,195 @@ + 'test-id', + 'body' => 'submit-btn', + 'options' => [], + ]); + + expect($locator->toSelector('id'))->toBe('#submit-btn'); +}); + +it('resolves test-id with data-test attribute to @ selector', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'test-id', + 'body' => 'submit-btn', + 'options' => [], + ]); + + expect($locator->toSelector('data-test'))->toBe('@submit-btn'); +}); + +it('resolves test-id with data-testid attribute to @ selector', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'test-id', + 'body' => 'submit-btn', + 'options' => [], + ]); + + expect($locator->toSelector('data-testid'))->toBe('@submit-btn'); +}); + +it('resolves test-id with custom attribute to attribute selector', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'test-id', + 'body' => 'submit-btn', + 'options' => [], + ]); + + expect($locator->toSelector('data-cy'))->toBe('[data-cy="submit-btn"]'); +}); + +it('returns null for test-id with empty body', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'test-id', + 'body' => '', + 'options' => [], + ]); + + expect($locator->toSelector('id'))->toBeNull(); +}); + +// role kind — clickable elements use name directly +it('resolves role=button to name', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'button', + 'options' => ['name' => 'Submit'], + ]); + + expect($locator->toSelector('id'))->toBe('Submit'); +}); + +it('resolves role=link to name', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'link', + 'options' => ['name' => 'Dashboard'], + ]); + + expect($locator->toSelector('id'))->toBe('Dashboard'); +}); + +it('resolves role=menuitem to name', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'menuitem', + 'options' => ['name' => 'Settings'], + ]); + + expect($locator->toSelector('id'))->toBe('Settings'); +}); + +it('resolves role=checkbox to name', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'checkbox', + 'options' => ['name' => 'Remember me'], + ]); + + expect($locator->toSelector('id'))->toBe('Remember me'); +}); + +// role kind — input elements use label selector +it('resolves role=textbox to label selector', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'textbox', + 'options' => ['name' => 'Email address'], + ]); + + expect($locator->toSelector('id'))->toBe('internal:label="Email address"s'); +}); + +it('resolves role=searchbox to label selector', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'searchbox', + 'options' => ['name' => 'Search'], + ]); + + expect($locator->toSelector('id'))->toBe('internal:label="Search"s'); +}); + +it('resolves role=combobox to label selector', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'combobox', + 'options' => ['name' => 'Country'], + ]); + + expect($locator->toSelector('id'))->toBe('internal:label="Country"s'); +}); + +it('returns null for role with empty name', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'button', + 'options' => ['name' => ''], + ]); + + expect($locator->toSelector('id'))->toBeNull(); +}); + +it('returns null for unhandled role body', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'role', + 'body' => 'grid', + 'options' => ['name' => 'Data'], + ]); + + expect($locator->toSelector('id'))->toBeNull(); +}); + +// text / css / default kinds +it('resolves text kind to body', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'text', + 'body' => 'Click here', + 'options' => [], + ]); + + expect($locator->toSelector('id'))->toBe('Click here'); +}); + +it('resolves css kind to body', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'css', + 'body' => '.btn-primary', + 'options' => [], + ]); + + expect($locator->toSelector('id'))->toBe('.btn-primary'); +}); + +it('resolves default kind to body', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'default', + 'body' => 'input[type=email]', + 'options' => [], + ]); + + expect($locator->toSelector('id'))->toBe('input[type=email]'); +}); + +it('returns null for unknown kind', function (): void { + $locator = Locator::fromArray([ + 'kind' => 'label', + 'body' => 'Email', + 'options' => [], + ]); + + expect($locator->toSelector('id'))->toBeNull(); +}); + +it('uses defaults when array keys are missing', function (): void { + $locator = Locator::fromArray([]); + + expect($locator->toSelector('id'))->toBeNull(); +}); diff --git a/tests/Unit/Recorder/TestGeneratorTest.php b/tests/Unit/Recorder/TestGeneratorTest.php new file mode 100644 index 00000000..a9ccf566 --- /dev/null +++ b/tests/Unit/Recorder/TestGeneratorTest.php @@ -0,0 +1,182 @@ + 'role', + 'body' => 'button', + 'options' => ['name' => $name], + ], + ); +} + +function fill(string $id, string $value): RecordedEvent +{ + return new RecordedEvent( + type: 'fill', + locator: [ + 'kind' => 'test-id', + 'body' => $id, + 'options' => [], + ], + text: $value, + ); +} + +function assertText(string $text): RecordedEvent +{ + return new RecordedEvent(type: 'assertText', text: $text); +} + +// actingAs +it('injects actingAs when actingAs=true', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/'), fill('email', 'test@example.com')]; + $code = $generator->generate($events, 'can do something', 'http://localhost', true); + + expect($code)->toContain('$this->actingAs(\App\Models\User::factory()->create())'); +}); + +it('does not inject actingAs when actingAs=false', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/'), fill('email', 'test@example.com')]; + $code = $generator->generate($events, 'can do something', 'http://localhost', false); + + expect($code)->not->toContain('actingAs'); +}); + +// test structure +it('wraps output in it() closure', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/')]; + $code = $generator->generate($events, 'can visit home', 'http://localhost'); + + expect($code)->toStartWith("it('can visit home', function (): void {"); +}); + +it('escapes single quotes in title', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/')]; + $code = $generator->generate($events, "can't login", 'http://localhost'); + + expect($code)->toContain("it('can\\'t login'"); +}); + +// navigation grouping +it('groups events into visit() blocks per page', function (): void { + $generator = new TestGenerator('id'); + $events = [ + navigate('http://localhost/'), + click('Submit'), + navigate('http://localhost/dashboard'), + click('Settings'), + ]; + $code = $generator->generate($events, 'can navigate', 'http://localhost'); + + expect($code) + ->toContain("visit('/')") + ->toContain("visit('/dashboard')"); +}); + +it('strips base url from path', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost:8000/dashboard')]; + $code = $generator->generate($events, 'test', 'http://localhost:8000'); + + expect($code)->toContain("visit('/dashboard')"); +}); + +it('uses / when url matches base exactly', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/')]; + $code = $generator->generate($events, 'test', 'http://localhost'); + + expect($code)->toContain("visit('/')"); +}); + +// action rendering +it('renders click action', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/'), click('Log in')]; + $code = $generator->generate($events, 'test', 'http://localhost'); + + expect($code)->toContain("->click('Log in')"); +}); + +it('renders fill action with id selector', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/'), fill('email', 'user@example.com')]; + $code = $generator->generate($events, 'test', 'http://localhost'); + + expect($code)->toContain("->fill('#email', 'user@example.com')"); +}); + +it('renders assertText as assertSee without selector', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/'), assertText('These credentials do not match')]; + $code = $generator->generate($events, 'test', 'http://localhost'); + + expect($code) + ->toContain("->assertSee('These credentials do not match')") + ->not->toContain('assertSeeIn'); +}); + +it('skips assertText with empty text', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/'), assertText('')]; + $code = $generator->generate($events, 'test', 'http://localhost'); + + expect($code)->not->toContain('assertSee'); +}); + +// escaping +it('escapes single quotes in fill value', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/'), fill('name', "O'Brien")]; + $code = $generator->generate($events, 'test', 'http://localhost'); + + expect($code)->toContain("->fill('#name', 'O\\'Brien')"); +}); + +it('escapes backslashes in fill value', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/'), fill('path', 'C:\\Users')]; + $code = $generator->generate($events, 'test', 'http://localhost'); + + expect($code)->toContain("->fill('#path', 'C:\\\\Users')"); +}); + +// multiple pages separated by blank line +it('separates multiple pages with blank line', function (): void { + $generator = new TestGenerator('id'); + $events = [ + navigate('http://localhost/'), + navigate('http://localhost/dashboard'), + ]; + $code = $generator->generate($events, 'test', 'http://localhost'); + + expect($code)->toContain("\n\n"); +}); + +// actingAs placed before first visit +it('places actingAs before visit when actingAs=true', function (): void { + $generator = new TestGenerator('id'); + $events = [navigate('http://localhost/dashboard')]; + $code = $generator->generate($events, 'test', 'http://localhost', true); + $actingAsPos = strpos($code, 'actingAs'); + $visitPos = strpos($code, 'visit('); + + expect($actingAsPos)->toBeLessThan($visitPos); +}); From 13512423233788e12b44cee0521be981572536a9 Mon Sep 17 00:00:00 2001 From: Johan Montenij Date: Fri, 22 May 2026 16:34:29 +0200 Subject: [PATCH 5/6] Replace --acting-as with boolean --auth/--user flags, extract auth generation into a dedicated Laravel script, and fix various recorder issues --- RECORDING.md | 11 +- src/Record.php | 139 ++++++++++----------- src/Recorder/Codegen.php | 46 ++----- src/Recorder/EventSanitizer.php | 21 +++- src/Recorder/Laravel/auth-gen.php | 53 ++++++++ src/Recorder/TestGenerator.php | 6 +- src/Recorder/TestWriter.php | 6 +- tests/Unit/Recorder/EventSanitizerTest.php | 105 +++++++++++----- tests/Unit/Recorder/TestGeneratorTest.php | 58 ++++----- 9 files changed, 258 insertions(+), 187 deletions(-) create mode 100644 src/Recorder/Laravel/auth-gen.php diff --git a/RECORDING.md b/RECORDING.md index fc4a0cfa..a6a114a7 100644 --- a/RECORDING.md +++ b/RECORDING.md @@ -15,21 +15,24 @@ The browser opens at full screen resolution by default (auto-detected per OS). C | `--test-id-attribute=` | `id` | HTML attribute used for element selectors | | `--device=` | — | Emulate a device (e.g. `"iPhone 15"`) | | `--viewport=` | screen resolution | Viewport size in pixels (e.g. `1280,800`) | -| `--acting-as=` | — | Name for the auth state (see below) | +| `--auth` / `--user` | — | Start browser pre-authenticated as a factory user (see below) | +| `--auth-script=` | — | Path to a custom auth bootstrap script (non-Laravel apps) | | `--env=` | `testing` | Environment for the auto-started server | | `--migrate-fresh` | — | Run `migrate:fresh` before opening the browser | | `--seed` | — | Seed the database after `--migrate-fresh` | -## Recording authenticated flows (`--acting-as`) +## Recording authenticated flows (`--auth`) Tests run with `APP_ENV=testing` (fresh, isolated DB). Recording against credentials that only exist in your local DB means those credentials won't exist when the test runs. -`--acting-as` solves this without manual login: before the browser opens, Pest bootstraps the Laravel app, creates a factory user, starts a session authenticated as that user, and injects a valid session cookie into the browser. The browser starts pre-authenticated. +`--auth` solves this without manual login: before the browser opens, Pest bootstraps the Laravel app, creates a factory user, starts a session authenticated as that user, and injects a valid session cookie into the browser. The browser starts pre-authenticated. ```bash -vendor/bin/pest --record --acting-as=user --visit=/dashboard +vendor/bin/pest --record --auth --visit=/dashboard ``` +`--user` is an alias for `--auth`. + The generated test uses `$this->actingAs(\App\Models\User::factory()->create())` — no real credentials, works in any environment. ## Fresh database before recording diff --git a/src/Record.php b/src/Record.php index 54de2d74..70542587 100644 --- a/src/Record.php +++ b/src/Record.php @@ -46,16 +46,27 @@ public function handleArguments(array $arguments): array $url = $this->popArgumentValue('--url', $arguments); $visitPath = $this->popArgumentValue('--visit', $arguments); - $authName = $this->popArgumentValue('--acting-as', $arguments); + $auth = $this->hasArgument('--auth', $arguments) || $this->hasArgument('--user', $arguments); + $arguments = $this->hasArgument('--auth', $arguments) ? $this->popArgument('--auth', $arguments) : $arguments; + $arguments = $this->hasArgument('--user', $arguments) ? $this->popArgument('--user', $arguments) : $arguments; $viewport = $this->popArgumentValue('--viewport', $arguments); $device = $this->popArgumentValue('--device', $arguments); $testIdAttribute = $this->popArgumentValue('--test-id-attribute', $arguments) ?? self::DEFAULT_TEST_ID_ATTRIBUTE; $env = $this->popArgumentValue('--env', $arguments) ?? 'testing'; + $authScript = $this->popArgumentValue('--auth-script', $arguments); + $migrateFresh = $this->hasArgument('--migrate-fresh', $arguments); + if ($migrateFresh) { + $arguments = $this->popArgument('--migrate-fresh', $arguments); + } + $seed = $this->hasArgument('--seed', $arguments); + if ($seed) { + $arguments = $this->popArgument('--seed', $arguments); + } - $this->record($url, $visitPath, $authName, $viewport, $device, $testIdAttribute, $env, $migrateFresh, $seed); + $this->record($url, $visitPath, $auth, $authScript, $viewport, $device, $testIdAttribute, $env, $migrateFresh, $seed); exit(0); } @@ -63,7 +74,8 @@ public function handleArguments(array $arguments): array private function record( ?string $url, ?string $visitPath, - ?string $authName, + bool $auth, + ?string $authScript, ?string $viewport, ?string $device, string $testIdAttribute, @@ -82,6 +94,10 @@ private function record( return; } + if ($seed && ! $migrateFresh) { + $this->writeLine('⚠ --seed has no effect without --migrate-fresh.'); + } + if ($migrateFresh) { try { $this->migrateFresh($env, $seed); @@ -105,15 +121,18 @@ private function record( } $loadStorage = null; + $userModelClass = null; - if (! is_null($authName)) { - $loadStorage = $this->resolveAuthState($url, $authName, $env); + if ($auth) { + $auth = $this->resolveAuthState($url, $env, $authScript); - if (is_null($loadStorage)) { + if (is_null($auth)) { $this->writeLine('✗ Auth state generation failed.'); return; } + + [$loadStorage, $userModelClass] = $auth; } $tmpFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pest-recording-' . getmypid() . '.jsonl'; @@ -138,17 +157,21 @@ private function record( return; } + $writer = new TestWriter; $title = $this->prompt('Test description'); - $outputPath = $this->resolveOutputPath(); - $code = (new TestGenerator($testIdAttribute))->generate($events, $title, $url, ! is_null($authName)); + $outputPath = $this->resolveOutputPath($writer); + $code = (new TestGenerator($testIdAttribute))->generate($events, $title, $url, $userModelClass); - (new TestWriter)->write($outputPath, $code); + $writer->write($outputPath, $code, ! is_null($userModelClass)); $this->writeLine(sprintf('✔ Test written: %s', $outputPath)); } catch (RuntimeException $e) { $this->writeLine('✗ ' . $e->getMessage()); } finally { @unlink($tmpFile); + if (! is_null($loadStorage)) { + @unlink($loadStorage); + } $serverProcess?->stop(3); } } @@ -175,9 +198,9 @@ private function startServer(string $url, string $env): Process } } - $this->writeLine('● Server may not be ready yet, proceeding...'); + $process->stop(3); - return $process; + throw new RuntimeException('Dev server failed to start within 10 seconds.'); } private function detectScreenResolution(): string @@ -196,7 +219,7 @@ private function detectScreenResolution(): string return match (PHP_OS_FAMILY) { 'Linux' => preg_match('/(\d+)x(\d+)/', trim($output), $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', 'Darwin' => preg_match('/(\d+) x (\d+)/', $output, $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', - 'Windows' => preg_match('/(\d+)\s+(\d+)/', trim($output), $m) ? $m[2] . ',' . ((int) $m[1] - 80) : '1920,1000', + 'Windows' => preg_match('/(\d+)\s+(\d+)/', trim($output), $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', default => '1920,1000', }; } @@ -222,16 +245,19 @@ private function migrateFresh(string $env, bool $seed): void $this->writeLine('✔ Database ready.'); } - private function resolveAuthState(string $url, string $name, string $env): ?string + /** + * @return array{string, ?string}|null + */ + private function resolveAuthState(string $url, string $env, ?string $authScript): ?array { $storageFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR - . 'pest-auth-' . preg_replace('/[^a-z0-9_-]/i', '-', $name) . '.json'; + . 'pest-auth-' . getmypid() . '.json'; - $this->writeLine(sprintf("● Generating auth state for '%s'...", $name)); + $this->writeLine('● Generating auth state...'); try { - $this->generateAuthState($url, $storageFile, $env); + $userModelClass = $this->generateAuthState($url, $storageFile, $env, $authScript); } catch (RuntimeException $e) { $this->writeLine('✗ ' . $e->getMessage()); @@ -240,82 +266,47 @@ private function resolveAuthState(string $url, string $name, string $env): ?stri $this->writeLine('✔ Auth state ready.'); - return $storageFile; + return [$storageFile, $userModelClass]; } - private function generateAuthState(string $url, string $storageFile, string $env): void + private function generateAuthState(string $url, string $storageFile, string $env, ?string $authScript): ?string { - $rootPath = addslashes($this->testSuite->rootPath); - $host = parse_url($url, PHP_URL_HOST) ?? '127.0.0.1'; - $cookieName = addslashes(preg_replace('/[^a-z0-9_\-]/i', '_', basename($rootPath)) . '_session'); - - $script = <<make(\Illuminate\Contracts\Console\Kernel::class); - \$kernel->bootstrap(); - - \$manager = app('session'); - \$store = \$manager->driver(); - \$store->setId(\Illuminate\Support\Str::random(40)); - \$store->start(); - - \$user = \App\Models\User::factory()->create(); - app('auth')->guard()->setUser(\$user); - \$store->put(app('auth')->guard()->getName(), \$user->getAuthIdentifier()); - \$store->put('password_hash_' . app('auth')->getDefaultDriver(), \$user->getAuthPassword()); - \$store->save(); - - \$sessionId = \$store->getId(); - \$cookieName = config('session.cookie'); - \$encrypter = app('encrypter'); - \$prefix = \Illuminate\Cookie\CookieValuePrefix::create(\$cookieName, \$encrypter->getKey()); - \$encrypted = \$encrypter->encrypt(\$prefix . \$sessionId, false); - - echo json_encode([ - 'cookies' => [[ - 'name' => \$cookieName, - 'value' => \$encrypted, - 'domain' => '{$host}', - 'path' => '/', - 'expires' => -1, - 'httpOnly' => true, - 'secure' => false, - 'sameSite' => 'Lax', - ]], - 'origins' => [], - ]); - PHP; - - $tmpScript = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pest-auth-gen-' . getmypid() . '.php'; - file_put_contents($tmpScript, $script); - - $process = new Process(['php', $tmpScript]); + $host = preg_replace('/[^a-zA-Z0-9._\-\[\]:]/', '', parse_url($url, PHP_URL_HOST) ?? '127.0.0.1'); + + $scriptPath = $this->resolveAuthScriptPath($authScript); + + $process = new Process(['php', $scriptPath, $this->testSuite->rootPath, $host, $storageFile]); $process->setTimeout(30); $process->setEnv(['APP_ENV' => $env]); $process->run(); - @unlink($tmpScript); - if (! $process->isSuccessful()) { throw new RuntimeException('Auth generation failed: ' . trim($process->getErrorOutput())); } - $output = trim($process->getOutput()); + $userModelClass = trim($process->getOutput()); + + return $userModelClass !== '' ? $userModelClass : null; + } - if ($output === '' || json_decode($output) === null) { - throw new RuntimeException('Auth generation returned invalid output.'); + private function resolveAuthScriptPath(?string $customScript): string + { + if (! is_null($customScript)) { + return $customScript; + } + + if (! file_exists($this->testSuite->rootPath . DIRECTORY_SEPARATOR . 'bootstrap' . DIRECTORY_SEPARATOR . 'app.php')) { + throw new RuntimeException( + '--auth requires a Laravel application. For other frameworks, provide a custom bootstrap script with --auth-script=path/to/auth.php', + ); } - file_put_contents($storageFile, $output); + return __DIR__ . DIRECTORY_SEPARATOR . 'Recorder' . DIRECTORY_SEPARATOR . 'Laravel' . DIRECTORY_SEPARATOR . 'auth-gen.php'; } - private function resolveOutputPath(): string + private function resolveOutputPath(TestWriter $writer): string { $testsDir = $this->testSuite->rootPath . DIRECTORY_SEPARATOR . $this->testSuite->testPath . DIRECTORY_SEPARATOR . 'Browser'; - $writer = new TestWriter; $existing = $writer->findExistingTestFiles($testsDir); if ($existing !== []) { diff --git a/src/Recorder/Codegen.php b/src/Recorder/Codegen.php index 118cad84..1370ffca 100644 --- a/src/Recorder/Codegen.php +++ b/src/Recorder/Codegen.php @@ -40,7 +40,6 @@ public function record( testIdAttribute: $testIdAttribute, viewport: $viewport, visitPath: $visitPath, - saveStorage: null, loadStorage: $loadStorage, device: $device, ); @@ -61,36 +60,6 @@ public function record( return is_string($content) ? $content : ''; } - public function captureAuthState( - string $url, - string $storageFile, - string $loginPath, - string $testIdAttribute, - ?string $viewport = null, - ): void - { - $dir = dirname($storageFile); - - if (! is_dir($dir)) { - mkdir($dir, 0755, true); - } - - $tmpOutput = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'pest-auth-capture-' . getmypid() . '.jsonl'; - - $command = $this->buildCommand( - url: $url, - outputFile: $tmpOutput, - testIdAttribute: $testIdAttribute, - viewport: $viewport, - visitPath: $loginPath, - saveStorage: $storageFile, - ); - - (new Process($command))->setTimeout(null)->run(); - - @unlink($tmpOutput); - } - /** * @return string[] */ @@ -100,7 +69,6 @@ private function buildCommand( string $testIdAttribute, ?string $viewport, ?string $visitPath, - ?string $saveStorage, ?string $loadStorage = null, ?string $device = null, ): array @@ -112,14 +80,14 @@ private function buildCommand( '--output=' . $outputFile, ]; - if (! is_null($device)) { - $command[] = '--device=' . $device; - } else if (! is_null($viewport)) { - $command[] = '--viewport-size=' . $viewport; - } + $flag = match (true) { + ! is_null($device) => '--device=' . $device, + ! is_null($viewport) => '--viewport-size=' . $viewport, + default => null, + }; - if (! is_null($saveStorage)) { - $command[] = '--save-storage=' . $saveStorage; + if (! is_null($flag)) { + $command[] = $flag; } if (! is_null($loadStorage)) { diff --git a/src/Recorder/EventSanitizer.php b/src/Recorder/EventSanitizer.php index 1fc114d3..de8ad49f 100644 --- a/src/Recorder/EventSanitizer.php +++ b/src/Recorder/EventSanitizer.php @@ -49,6 +49,10 @@ private function dropUnsupported(array $events): array return is_string($event->url); } + if ($event->type === 'assertText') { + return $event->text !== null && $event->text !== ''; + } + if (is_null($event->locator)) { return false; } @@ -90,23 +94,36 @@ private function dropRedundantClicks(array $events): array */ private function deduplicateFills(array $events): array { + $pageIndex = 0; $lastIndex = []; foreach ($events as $index => $event) { + if ($event->type === 'navigate') { + $pageIndex++; + continue; + } + if ($event->type === 'fill') { $selector = $this->resolveSelector($event); if (! is_null($selector)) { - $lastIndex[$selector] = $index; + $lastIndex[$pageIndex][$selector] = $index; } } } + $pageIndex = 0; $result = []; foreach ($events as $index => $event) { + if ($event->type === 'navigate') { + $pageIndex++; + $result[] = $event; + continue; + } + if ($event->type === 'fill') { $selector = $this->resolveSelector($event); - if (! is_null($selector) && $lastIndex[$selector] !== $index) { + if (! is_null($selector) && ($lastIndex[$pageIndex][$selector] ?? null) !== $index) { continue; } } diff --git a/src/Recorder/Laravel/auth-gen.php b/src/Recorder/Laravel/auth-gen.php new file mode 100644 index 00000000..68d389ee --- /dev/null +++ b/src/Recorder/Laravel/auth-gen.php @@ -0,0 +1,53 @@ +make(Kernel::class); +$kernel->bootstrap(); + +$userModel = config('auth.providers.users.model', User::class); + +$manager = app('session'); +$store = $manager->driver(); +$store->setId(Str::random(40)); +$store->start(); + +$user = $userModel::factory()->create(); +app('auth')->guard()->setUser($user); +$store->put(app('auth')->guard()->getName(), $user->getAuthIdentifier()); +$store->put('password_hash_' . app('auth')->getDefaultDriver(), $user->getAuthPassword()); +$store->save(); + +$sessionId = $store->getId(); +$cookieName = config('session.cookie'); +$encrypter = app('encrypter'); +$prefix = CookieValuePrefix::create($cookieName, $encrypter->getKey()); +$encrypted = $encrypter->encrypt($prefix . $sessionId, false); + +file_put_contents($storageFile, json_encode([ + 'cookies' => [[ + 'name' => $cookieName, + 'value' => $encrypted, + 'domain' => $host, + 'path' => '/', + 'expires' => -1, + 'httpOnly' => true, + 'secure' => false, + 'sameSite' => 'Lax', + ]], + 'origins' => [], +])); + +echo $userModel; diff --git a/src/Recorder/TestGenerator.php b/src/Recorder/TestGenerator.php index 7421247b..d9511665 100644 --- a/src/Recorder/TestGenerator.php +++ b/src/Recorder/TestGenerator.php @@ -13,13 +13,13 @@ public function __construct( /** * @param RecordedEvent[] $events */ - public function generate(array $events, string $title, string $baseUrl, bool $actingAs = false): string + public function generate(array $events, string $title, string $baseUrl, ?string $userModelClass = null): string { $pages = $this->groupByNavigation($events, $baseUrl); $body = $this->renderBody($pages); - if ($actingAs) { - $body = " \$this->actingAs(\\App\\Models\\User::factory()->create());\n\n" . $body; + if (! is_null($userModelClass)) { + $body = " \$this->actingAs(\\{$userModelClass}::factory()->create());\n\n" . $body; } $escapedTitle = str_replace("'", "\\'", $title); diff --git a/src/Recorder/TestWriter.php b/src/Recorder/TestWriter.php index 22287df3..63210755 100644 --- a/src/Recorder/TestWriter.php +++ b/src/Recorder/TestWriter.php @@ -38,7 +38,7 @@ public function findExistingTestFiles(string $testsDirectory): array return $files; } - public function write(string $path, string $testCode): void + public function write(string $path, string $testCode, bool $actingAs = false): void { if (! file_exists($path)) { $dir = dirname($path); @@ -47,7 +47,9 @@ public function write(string $path, string $testCode): void mkdir($dir, 0755, true); } - file_put_contents($path, " 'test-id', @@ -24,7 +24,7 @@ function testIdLocator(string $id): array ]; } -function roleLocator(string $role, string $name): array +function makeRoleLocator(string $role, string $name): array { return [ 'kind' => 'role', @@ -39,13 +39,13 @@ function roleLocator(string $role, string $name): array it('keeps supported event types', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('navigate', url: 'http://localhost/'), - makeEvent('click', testIdLocator('btn')), - makeEvent('fill', testIdLocator('email')), - makeEvent('check', testIdLocator('remember')), - makeEvent('uncheck', testIdLocator('remember')), - makeEvent('assertVisible', testIdLocator('heading')), - makeEvent('assertText', testIdLocator('msg'), text: 'Hello'), + makeRecordedEvent('navigate', url: 'http://localhost/'), + makeRecordedEvent('click', makeTestIdLocator('btn')), + makeRecordedEvent('fill', makeTestIdLocator('email')), + makeRecordedEvent('check', makeTestIdLocator('remember')), + makeRecordedEvent('uncheck', makeTestIdLocator('remember')), + makeRecordedEvent('assertVisible', makeTestIdLocator('heading')), + makeRecordedEvent('assertText', makeTestIdLocator('msg'), text: 'Hello'), ]; expect($sanitizer->sanitize($events))->toHaveCount(7); @@ -54,9 +54,9 @@ function roleLocator(string $role, string $name): array it('drops unsupported event types', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('hover', testIdLocator('btn')), - makeEvent('press', testIdLocator('input')), - makeEvent('navigate', url: 'http://localhost/'), + makeRecordedEvent('hover', makeTestIdLocator('btn')), + makeRecordedEvent('press', makeTestIdLocator('input')), + makeRecordedEvent('navigate', url: 'http://localhost/'), ]; $result = $sanitizer->sanitize($events); @@ -67,8 +67,8 @@ function roleLocator(string $role, string $name): array it('drops navigate events without url', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('navigate'), - makeEvent('navigate', url: 'http://localhost/'), + makeRecordedEvent('navigate'), + makeRecordedEvent('navigate', url: 'http://localhost/'), ]; $result = $sanitizer->sanitize($events); @@ -78,9 +78,9 @@ function roleLocator(string $role, string $name): array it('drops events with null locator (except navigate)', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('click'), - makeEvent('fill'), - makeEvent('navigate', url: 'http://localhost/'), + makeRecordedEvent('click'), + makeRecordedEvent('fill'), + makeRecordedEvent('navigate', url: 'http://localhost/'), ]; $result = $sanitizer->sanitize($events); @@ -91,12 +91,12 @@ function roleLocator(string $role, string $name): array it('drops events where locator resolves to null selector', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('click', [ + makeRecordedEvent('click', [ 'kind' => 'unknown', 'body' => '', 'options' => [], ]), - makeEvent('navigate', url: 'http://localhost/'), + makeRecordedEvent('navigate', url: 'http://localhost/'), ]; $result = $sanitizer->sanitize($events); @@ -107,8 +107,8 @@ function roleLocator(string $role, string $name): array it('drops click when immediately followed by fill on same selector', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('click', testIdLocator('email')), - makeEvent('fill', testIdLocator('email'), text: 'user@example.com'), + makeRecordedEvent('click', makeTestIdLocator('email')), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'user@example.com'), ]; $result = $sanitizer->sanitize($events); @@ -119,8 +119,8 @@ function roleLocator(string $role, string $name): array it('keeps click when followed by fill on different selector', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('click', testIdLocator('btn')), - makeEvent('fill', testIdLocator('email'), text: 'user@example.com'), + makeRecordedEvent('click', makeTestIdLocator('btn')), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'user@example.com'), ]; $result = $sanitizer->sanitize($events); @@ -130,8 +130,8 @@ function roleLocator(string $role, string $name): array it('keeps click not followed by fill', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('click', roleLocator('button', 'Submit')), - makeEvent('navigate', url: 'http://localhost/dashboard'), + makeRecordedEvent('click', makeRoleLocator('button', 'Submit')), + makeRecordedEvent('navigate', url: 'http://localhost/dashboard'), ]; $result = $sanitizer->sanitize($events); @@ -142,9 +142,9 @@ function roleLocator(string $role, string $name): array it('keeps only the last fill for each selector', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('fill', testIdLocator('email'), text: 'first@example.com'), - makeEvent('fill', testIdLocator('email'), text: 'second@example.com'), - makeEvent('fill', testIdLocator('email'), text: 'final@example.com'), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'first@example.com'), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'second@example.com'), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'final@example.com'), ]; $result = $sanitizer->sanitize($events); @@ -155,20 +155,57 @@ function roleLocator(string $role, string $name): array it('keeps fills for different selectors', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('fill', testIdLocator('email'), text: 'user@example.com'), - makeEvent('fill', testIdLocator('password'), text: 'secret'), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'user@example.com'), + makeRecordedEvent('fill', makeTestIdLocator('password'), text: 'secret'), ]; $result = $sanitizer->sanitize($events); expect($result)->toHaveCount(2); }); +it('keeps assertText with unresolvable locator when text is non-empty', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeRecordedEvent('assertText', ['kind' => 'role', 'body' => 'main', 'options' => ['attrs' => []]], text: 'Welcome'), + ]; + $result = $sanitizer->sanitize($events); + expect($result)->toHaveCount(1) + ->and($result[0]->text)->toBe('Welcome'); +}); + +it('keeps assertText with null locator when text is non-empty', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [makeRecordedEvent('assertText', text: 'Welcome')]; + $result = $sanitizer->sanitize($events); + expect($result)->toHaveCount(1); +}); + +it('drops assertText when text is empty', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [makeRecordedEvent('assertText', text: '')]; + expect($sanitizer->sanitize($events))->toHaveCount(0); +}); + +it('keeps fills for same selector on different pages', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeRecordedEvent('navigate', url: 'http://localhost/login'), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'admin@a.com'), + makeRecordedEvent('navigate', url: 'http://localhost/register'), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'user@b.com'), + ]; + $result = $sanitizer->sanitize($events); + expect($result)->toHaveCount(4) + ->and($result[1]->text)->toBe('admin@a.com') + ->and($result[3]->text)->toBe('user@b.com'); +}); + it('preserves event order after deduplication', function (): void { $sanitizer = new EventSanitizer('id'); $events = [ - makeEvent('fill', testIdLocator('email'), text: 'first@example.com'), - makeEvent('fill', testIdLocator('password'), text: 'secret'), - makeEvent('fill', testIdLocator('email'), text: 'final@example.com'), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'first@example.com'), + makeRecordedEvent('fill', makeTestIdLocator('password'), text: 'secret'), + makeRecordedEvent('fill', makeTestIdLocator('email'), text: 'final@example.com'), ]; $result = $sanitizer->sanitize($events); diff --git a/tests/Unit/Recorder/TestGeneratorTest.php b/tests/Unit/Recorder/TestGeneratorTest.php index a9ccf566..a519af57 100644 --- a/tests/Unit/Recorder/TestGeneratorTest.php +++ b/tests/Unit/Recorder/TestGeneratorTest.php @@ -5,12 +5,12 @@ use Pest\Browser\Recorder\RecordedEvent; use Pest\Browser\Recorder\TestGenerator; -function navigate(string $url): RecordedEvent +function navigateEvent(string $url): RecordedEvent { return new RecordedEvent(type: 'navigate', url: $url); } -function click(string $name): RecordedEvent +function clickEvent(string $name): RecordedEvent { return new RecordedEvent( type: 'click', @@ -22,7 +22,7 @@ function click(string $name): RecordedEvent ); } -function fill(string $id, string $value): RecordedEvent +function fillEvent(string $id, string $value): RecordedEvent { return new RecordedEvent( type: 'fill', @@ -35,24 +35,24 @@ function fill(string $id, string $value): RecordedEvent ); } -function assertText(string $text): RecordedEvent +function assertTextEvent(string $text): RecordedEvent { return new RecordedEvent(type: 'assertText', text: $text); } // actingAs -it('injects actingAs when actingAs=true', function (): void { +it('injects actingAs with resolved model class', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/'), fill('email', 'test@example.com')]; - $code = $generator->generate($events, 'can do something', 'http://localhost', true); + $events = [navigateEvent('http://localhost/'), fillEvent('email', 'test@example.com')]; + $code = $generator->generate($events, 'can do something', 'http://localhost', 'App\\Models\\User'); expect($code)->toContain('$this->actingAs(\App\Models\User::factory()->create())'); }); -it('does not inject actingAs when actingAs=false', function (): void { +it('does not inject actingAs when userModelClass is null', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/'), fill('email', 'test@example.com')]; - $code = $generator->generate($events, 'can do something', 'http://localhost', false); + $events = [navigateEvent('http://localhost/'), fillEvent('email', 'test@example.com')]; + $code = $generator->generate($events, 'can do something', 'http://localhost'); expect($code)->not->toContain('actingAs'); }); @@ -60,7 +60,7 @@ function assertText(string $text): RecordedEvent // test structure it('wraps output in it() closure', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/')]; + $events = [navigateEvent('http://localhost/')]; $code = $generator->generate($events, 'can visit home', 'http://localhost'); expect($code)->toStartWith("it('can visit home', function (): void {"); @@ -68,7 +68,7 @@ function assertText(string $text): RecordedEvent it('escapes single quotes in title', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/')]; + $events = [navigateEvent('http://localhost/')]; $code = $generator->generate($events, "can't login", 'http://localhost'); expect($code)->toContain("it('can\\'t login'"); @@ -78,10 +78,10 @@ function assertText(string $text): RecordedEvent it('groups events into visit() blocks per page', function (): void { $generator = new TestGenerator('id'); $events = [ - navigate('http://localhost/'), - click('Submit'), - navigate('http://localhost/dashboard'), - click('Settings'), + navigateEvent('http://localhost/'), + clickEvent('Submit'), + navigateEvent('http://localhost/dashboard'), + clickEvent('Settings'), ]; $code = $generator->generate($events, 'can navigate', 'http://localhost'); @@ -92,7 +92,7 @@ function assertText(string $text): RecordedEvent it('strips base url from path', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost:8000/dashboard')]; + $events = [navigateEvent('http://localhost:8000/dashboard')]; $code = $generator->generate($events, 'test', 'http://localhost:8000'); expect($code)->toContain("visit('/dashboard')"); @@ -100,7 +100,7 @@ function assertText(string $text): RecordedEvent it('uses / when url matches base exactly', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/')]; + $events = [navigateEvent('http://localhost/')]; $code = $generator->generate($events, 'test', 'http://localhost'); expect($code)->toContain("visit('/')"); @@ -109,7 +109,7 @@ function assertText(string $text): RecordedEvent // action rendering it('renders click action', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/'), click('Log in')]; + $events = [navigateEvent('http://localhost/'), clickEvent('Log in')]; $code = $generator->generate($events, 'test', 'http://localhost'); expect($code)->toContain("->click('Log in')"); @@ -117,7 +117,7 @@ function assertText(string $text): RecordedEvent it('renders fill action with id selector', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/'), fill('email', 'user@example.com')]; + $events = [navigateEvent('http://localhost/'), fillEvent('email', 'user@example.com')]; $code = $generator->generate($events, 'test', 'http://localhost'); expect($code)->toContain("->fill('#email', 'user@example.com')"); @@ -125,7 +125,7 @@ function assertText(string $text): RecordedEvent it('renders assertText as assertSee without selector', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/'), assertText('These credentials do not match')]; + $events = [navigateEvent('http://localhost/'), assertTextEvent('These credentials do not match')]; $code = $generator->generate($events, 'test', 'http://localhost'); expect($code) @@ -135,7 +135,7 @@ function assertText(string $text): RecordedEvent it('skips assertText with empty text', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/'), assertText('')]; + $events = [navigateEvent('http://localhost/'), assertTextEvent('')]; $code = $generator->generate($events, 'test', 'http://localhost'); expect($code)->not->toContain('assertSee'); @@ -144,7 +144,7 @@ function assertText(string $text): RecordedEvent // escaping it('escapes single quotes in fill value', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/'), fill('name', "O'Brien")]; + $events = [navigateEvent('http://localhost/'), fillEvent('name', "O'Brien")]; $code = $generator->generate($events, 'test', 'http://localhost'); expect($code)->toContain("->fill('#name', 'O\\'Brien')"); @@ -152,7 +152,7 @@ function assertText(string $text): RecordedEvent it('escapes backslashes in fill value', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/'), fill('path', 'C:\\Users')]; + $events = [navigateEvent('http://localhost/'), fillEvent('path', 'C:\\Users')]; $code = $generator->generate($events, 'test', 'http://localhost'); expect($code)->toContain("->fill('#path', 'C:\\\\Users')"); @@ -162,8 +162,8 @@ function assertText(string $text): RecordedEvent it('separates multiple pages with blank line', function (): void { $generator = new TestGenerator('id'); $events = [ - navigate('http://localhost/'), - navigate('http://localhost/dashboard'), + navigateEvent('http://localhost/'), + navigateEvent('http://localhost/dashboard'), ]; $code = $generator->generate($events, 'test', 'http://localhost'); @@ -171,10 +171,10 @@ function assertText(string $text): RecordedEvent }); // actingAs placed before first visit -it('places actingAs before visit when actingAs=true', function (): void { +it('places actingAs before visit when userModelClass is set', function (): void { $generator = new TestGenerator('id'); - $events = [navigate('http://localhost/dashboard')]; - $code = $generator->generate($events, 'test', 'http://localhost', true); + $events = [navigateEvent('http://localhost/dashboard')]; + $code = $generator->generate($events, 'test', 'http://localhost', 'App\\Models\\User'); $actingAsPos = strpos($code, 'actingAs'); $visitPos = strpos($code, 'visit('); From 4f8a6fd97ba01326cfbd061d50c06062ef0c7ae8 Mon Sep 17 00:00:00 2001 From: Johan Montenij Date: Fri, 22 May 2026 17:18:47 +0200 Subject: [PATCH 6/6] Add --browser, --channel, --lang, --timezone, and --color-scheme flags to the recorder --- RECORDING.md | 59 ++++++++++++++++++++++++++++------------ src/Plugin.php | 10 +++---- src/Record.php | 46 ++++++++++++++++++++++++------- src/Recorder/Codegen.php | 35 ++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 32 deletions(-) diff --git a/RECORDING.md b/RECORDING.md index a6a114a7..a4b6fdae 100644 --- a/RECORDING.md +++ b/RECORDING.md @@ -2,30 +2,54 @@ ## How it works -`vendor/bin/pest --record` automatically starts an HTTP server with `APP_ENV=testing` before the browser opens, then shuts it down when recording ends — matching the same behavior as running browser tests normally with `vendor/bin/pest`. +`vendor/bin/pest --record` automatically starts an HTTP server with `APP_ENV=testing` before the browser opens, then +shuts it down when recording ends — matching the same behavior as running browser tests normally with `vendor/bin/pest`. -The browser opens at full screen resolution by default (auto-detected per OS). Close the browser when done. Pest prompts for a test description and output file, then writes the generated test to `tests/Browser/`. +The browser opens at full screen resolution by default (auto-detected per OS). Close the browser when done. Pest prompts +for a test description and output file, then writes the generated test to `tests/Browser/`. ## Options -| Option | Default | Description | -|---|---|---| -| `--url=` | auto | Skip auto-server; connect to this URL instead | -| `--visit=` | `/` | Path to open on start | -| `--test-id-attribute=` | `id` | HTML attribute used for element selectors | -| `--device=` | — | Emulate a device (e.g. `"iPhone 15"`) | -| `--viewport=` | screen resolution | Viewport size in pixels (e.g. `1280,800`) | -| `--auth` / `--user` | — | Start browser pre-authenticated as a factory user (see below) | -| `--auth-script=` | — | Path to a custom auth bootstrap script (non-Laravel apps) | -| `--env=` | `testing` | Environment for the auto-started server | -| `--migrate-fresh` | — | Run `migrate:fresh` before opening the browser | -| `--seed` | — | Seed the database after `--migrate-fresh` | +| Option | Default | Description | +|------------------------|-------------------|---------------------------------------------------------------| +| `--url=` | auto | Skip auto-server; connect to this URL instead | +| `--visit=` | `/` | Path to open on start | +| `--test-id-attribute=` | `id` | HTML attribute used for element selectors | +| `--device=` | — | Emulate a device (e.g. `"iPhone 15"`) | +| `--viewport=` | screen resolution | Viewport size in pixels (e.g. `1280,800`) | +| `--auth` / `--user` | — | Start browser pre-authenticated as a factory user (see below) | +| `--auth-script=` | — | Path to a custom auth bootstrap script (non-Laravel apps) | +| `--env=` | `testing` | Environment for the auto-started server | +| `--migrate-fresh` | — | Run `migrate:fresh` before opening the browser | +| `--seed` | — | Seed the database after `--migrate-fresh` | +| `--browser=` | `chrome` | Browser to record with: `chrome`, `firefox`, `safari` | +| `--channel=` | — | Browser channel (e.g. `msedge`, `chrome-canary`) | +| `--lang=` | — | Override browser locale (e.g. `fr`, `nl`) | +| `--timezone=` | — | Override browser timezone (e.g. `Europe/Paris`) | +| `--color-scheme=` | — | Preferred color scheme: `dark`, `light`, `no-preference` | + +## Recording in a specific browser + +```bash +vendor/bin/pest --record --browser=firefox +vendor/bin/pest --record --browser=chrome --channel=msedge +``` + +## Recording with locale or timezone + +```bash +vendor/bin/pest --record --lang=fr --timezone=Europe/Paris +vendor/bin/pest --record --color-scheme=dark +``` ## Recording authenticated flows (`--auth`) -Tests run with `APP_ENV=testing` (fresh, isolated DB). Recording against credentials that only exist in your local DB means those credentials won't exist when the test runs. +Tests run with `APP_ENV=testing` (fresh, isolated DB). Recording against credentials that only exist in your local DB +means those credentials won't exist when the test runs. -`--auth` solves this without manual login: before the browser opens, Pest bootstraps the Laravel app, creates a factory user, starts a session authenticated as that user, and injects a valid session cookie into the browser. The browser starts pre-authenticated. +`--auth` solves this without manual login: before the browser opens, Pest bootstraps the Laravel app, creates a factory +user, starts a session authenticated as that user, and injects a valid session cookie into the browser. The browser +starts pre-authenticated. ```bash vendor/bin/pest --record --auth --visit=/dashboard @@ -33,7 +57,8 @@ vendor/bin/pest --record --auth --visit=/dashboard `--user` is an alias for `--auth`. -The generated test uses `$this->actingAs(\App\Models\User::factory()->create())` — no real credentials, works in any environment. +The generated test uses `$this->actingAs(\App\Models\User::factory()->create())` — no real credentials, works in any +environment. ## Fresh database before recording diff --git a/src/Plugin.php b/src/Plugin.php index 90ff64ca..beb2aa10 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -59,7 +59,7 @@ public function boot(): void /** * Handles the arguments passed to the plugin. * - * @param array $arguments} + * @param array $arguments } */ public function handleArguments(array $arguments): array { @@ -93,7 +93,7 @@ public function handleArguments(array $arguments): array $arguments = $this->popArgument('--light', $arguments); } - if ($this->hasArgument('--browser', $arguments)) { + if ($this->hasArgument('--browser', $arguments) && ! $this->hasArgument('--record', $arguments)) { $index = array_search('--browser', $arguments, true); if ($index === false || ! isset($arguments[$index + 1])) { @@ -106,8 +106,8 @@ public function handleArguments(array $arguments): array if (($browser = BrowserType::tryFrom($browser)) === null) { throw new BrowserNotSupportedException( - 'The specified browser type is not supported. Supported types are: '. - implode(', ', array_map(fn (BrowserType $type): string => mb_strtolower($type->name), BrowserType::cases())) + 'The specified browser type is not supported. Supported types are: ' . + implode(', ', array_map(fn(BrowserType $type): string => mb_strtolower($type->name), BrowserType::cases())) ); } @@ -152,7 +152,7 @@ public function terminate(): void */ private function in(): string { - return TestSuite::getInstance()->rootPath.DIRECTORY_SEPARATOR.TestSuite::getInstance()->testPath; + return TestSuite::getInstance()->rootPath . DIRECTORY_SEPARATOR . TestSuite::getInstance()->testPath; } /** diff --git a/src/Record.php b/src/Record.php index 70542587..30d90b7d 100644 --- a/src/Record.php +++ b/src/Record.php @@ -4,6 +4,7 @@ namespace Pest\Browser; +use Pest\Browser\Enums\BrowserType; use Pest\Browser\Recorder\Codegen; use Pest\Browser\Recorder\EventParser; use Pest\Browser\Recorder\EventSanitizer; @@ -51,6 +52,26 @@ public function handleArguments(array $arguments): array $arguments = $this->hasArgument('--user', $arguments) ? $this->popArgument('--user', $arguments) : $arguments; $viewport = $this->popArgumentValue('--viewport', $arguments); $device = $this->popArgumentValue('--device', $arguments); + $browserValue = $this->popArgumentValue('--browser', $arguments); + $browser = null; + + if (! is_null($browserValue)) { + $browserType = BrowserType::tryFrom($browserValue); + + if (is_null($browserType)) { + $this->writeLine(sprintf('✗ Unknown browser "%s". Valid values: chrome, firefox, safari.', $browserValue)); + + return $arguments; + } + + $browser = $browserType->toPlaywrightName(); + } + + $channel = $this->popArgumentValue('--channel', $arguments); + $lang = $this->popArgumentValue('--lang', $arguments); + $timezone = $this->popArgumentValue('--timezone', $arguments); + $colorScheme = $this->popArgumentValue('--color-scheme', $arguments); + $testIdAttribute = $this->popArgumentValue('--test-id-attribute', $arguments) ?? self::DEFAULT_TEST_ID_ATTRIBUTE; $env = $this->popArgumentValue('--env', $arguments) ?? 'testing'; @@ -66,7 +87,7 @@ public function handleArguments(array $arguments): array $arguments = $this->popArgument('--seed', $arguments); } - $this->record($url, $visitPath, $auth, $authScript, $viewport, $device, $testIdAttribute, $env, $migrateFresh, $seed); + $this->record($url, $visitPath, $auth, $authScript, $viewport, $device, $browser, $channel, $lang, $timezone, $colorScheme, $testIdAttribute, $env, $migrateFresh, $seed); exit(0); } @@ -78,6 +99,11 @@ private function record( ?string $authScript, ?string $viewport, ?string $device, + ?string $browser, + ?string $channel, + ?string $lang, + ?string $timezone, + ?string $colorScheme, string $testIdAttribute, string $env = 'testing', bool $migrateFresh = false, @@ -140,7 +166,7 @@ private function record( try { $this->writeLine('● Recorder running — close the browser to finish...'); - $jsonl = $codegen->record($url, $tmpFile, $testIdAttribute, $viewport, $visitPath, $loadStorage, $device); + $jsonl = $codegen->record($url, $tmpFile, $testIdAttribute, $viewport, $visitPath, $loadStorage, $device, $browser, $channel, $lang, $timezone, $colorScheme); if ($jsonl === '') { $this->writeLine('✗ No recording captured.'); @@ -217,9 +243,9 @@ private function detectScreenResolution(): string } return match (PHP_OS_FAMILY) { - 'Linux' => preg_match('/(\d+)x(\d+)/', trim($output), $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', + 'Linux' => preg_match('/(\d+)x(\d+)/', mb_trim($output), $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', 'Darwin' => preg_match('/(\d+) x (\d+)/', $output, $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', - 'Windows' => preg_match('/(\d+)\s+(\d+)/', trim($output), $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', + 'Windows' => preg_match('/(\d+)\s+(\d+)/', mb_trim($output), $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', default => '1920,1000', }; } @@ -239,7 +265,7 @@ private function migrateFresh(string $env, bool $seed): void $process->run(); if (! $process->isSuccessful()) { - throw new RuntimeException('migrate:fresh failed: ' . trim($process->getErrorOutput())); + throw new RuntimeException('migrate:fresh failed: ' . mb_trim($process->getErrorOutput())); } $this->writeLine('✔ Database ready.'); @@ -281,10 +307,10 @@ private function generateAuthState(string $url, string $storageFile, string $env $process->run(); if (! $process->isSuccessful()) { - throw new RuntimeException('Auth generation failed: ' . trim($process->getErrorOutput())); + throw new RuntimeException('Auth generation failed: ' . mb_trim($process->getErrorOutput())); } - $userModelClass = trim($process->getOutput()); + $userModelClass = mb_trim($process->getOutput()); return $userModelClass !== '' ? $userModelClass : null; } @@ -313,7 +339,7 @@ private function resolveOutputPath(TestWriter $writer): string $choices = array_merge( ['New file...'], array_map( - fn(string $path): string => ltrim(str_replace($testsDir, '', $path), DIRECTORY_SEPARATOR), + fn(string $path): string => mb_ltrim(str_replace($testsDir, '', $path), DIRECTORY_SEPARATOR), $existing, ), ); @@ -336,7 +362,7 @@ private function prompt(string $question): string $answer = fgets(STDIN); - return ($answer !== false && trim($answer) !== '') ? trim($answer) : 'Untitled'; + return ($answer !== false && mb_trim($answer) !== '') ? mb_trim($answer) : 'Untitled'; } private function choose(string $question, array $choices): string @@ -350,7 +376,7 @@ private function choose(string $question, array $choices): string $this->output->write(' Choice: '); $input = fgets(STDIN); - $index = ($input !== false && is_numeric(trim($input))) ? (int) trim($input) : 0; + $index = ($input !== false && is_numeric(mb_trim($input))) ? (int) mb_trim($input) : 0; return $choices[$index] ?? $choices[0]; } diff --git a/src/Recorder/Codegen.php b/src/Recorder/Codegen.php index 1370ffca..ce1d7ab2 100644 --- a/src/Recorder/Codegen.php +++ b/src/Recorder/Codegen.php @@ -32,6 +32,11 @@ public function record( ?string $visitPath = null, ?string $loadStorage = null, ?string $device = null, + ?string $browser = null, + ?string $channel = null, + ?string $lang = null, + ?string $timezone = null, + ?string $colorScheme = null, ): string { $command = $this->buildCommand( @@ -42,6 +47,11 @@ public function record( visitPath: $visitPath, loadStorage: $loadStorage, device: $device, + browser: $browser, + channel: $channel, + lang: $lang, + timezone: $timezone, + colorScheme: $colorScheme, ); $process = (new Process($command))->setTimeout(null); @@ -71,6 +81,11 @@ private function buildCommand( ?string $visitPath, ?string $loadStorage = null, ?string $device = null, + ?string $browser = null, + ?string $channel = null, + ?string $lang = null, + ?string $timezone = null, + ?string $colorScheme = null, ): array { $command = [ @@ -80,6 +95,26 @@ private function buildCommand( '--output=' . $outputFile, ]; + if (! is_null($browser)) { + $command[] = '--browser=' . $browser; + } + + if (! is_null($channel)) { + $command[] = '--channel=' . $channel; + } + + if (! is_null($lang)) { + $command[] = '--lang=' . $lang; + } + + if (! is_null($timezone)) { + $command[] = '--timezone=' . $timezone; + } + + if (! is_null($colorScheme)) { + $command[] = '--color-scheme=' . $colorScheme; + } + $flag = match (true) { ! is_null($device) => '--device=' . $device, ! is_null($viewport) => '--viewport-size=' . $viewport,