diff --git a/README.md b/README.md index 3652528a..55d99dfd 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,16 @@ 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 browser-tests by recording your interactions in the browser: + +```bash +vendor/bin/pest --record +``` + +See **[RECORDING.md](RECORDING.md)** for more information. + - 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/RECORDING.md b/RECORDING.md new file mode 100644 index 00000000..a4b6fdae --- /dev/null +++ b/RECORDING.md @@ -0,0 +1,79 @@ +# 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`) | +| `--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. + +`--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 +``` + +`--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 + +```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/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/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 new file mode 100644 index 00000000..30d90b7d --- /dev/null +++ b/src/Record.php @@ -0,0 +1,388 @@ +hasArgument(self::OPTION, $arguments)) { + return $arguments; + } + + $arguments = $this->popArgument(self::OPTION, $arguments); + + $url = $this->popArgumentValue('--url', $arguments); + $visitPath = $this->popArgumentValue('--visit', $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); + $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'; + + $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, $auth, $authScript, $viewport, $device, $browser, $channel, $lang, $timezone, $colorScheme, $testIdAttribute, $env, $migrateFresh, $seed); + + exit(0); + } + + private function record( + ?string $url, + ?string $visitPath, + bool $auth, + ?string $authScript, + ?string $viewport, + ?string $device, + ?string $browser, + ?string $channel, + ?string $lang, + ?string $timezone, + ?string $colorScheme, + string $testIdAttribute, + string $env = 'testing', + bool $migrateFresh = false, + bool $seed = false, + ): void + { + $codegen = new Codegen; + + try { + $codegen->checkDependencies(); + } catch (RuntimeException $e) { + $this->writeLine('✗ ' . $e->getMessage()); + + return; + } + + if ($seed && ! $migrateFresh) { + $this->writeLine('⚠ --seed has no effect without --migrate-fresh.'); + } + + if ($migrateFresh) { + try { + $this->migrateFresh($env, $seed); + } catch (RuntimeException $e) { + $this->writeLine('✗ ' . $e->getMessage()); + + return; + } + } + + if (is_null($viewport) && is_null($device)) { + $viewport = $this->detectScreenResolution(); + } + + $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; + $userModelClass = null; + + if ($auth) { + $auth = $this->resolveAuthState($url, $env, $authScript); + + 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'; + + try { + $this->writeLine('● Recorder running — close the browser to finish...'); + + $jsonl = $codegen->record($url, $tmpFile, $testIdAttribute, $viewport, $visitPath, $loadStorage, $device, $browser, $channel, $lang, $timezone, $colorScheme); + + 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; + } + + $writer = new TestWriter; + $title = $this->prompt('Test description'); + $outputPath = $this->resolveOutputPath($writer); + $code = (new TestGenerator($testIdAttribute))->generate($events, $title, $url, $userModelClass); + + $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); + } + } + + 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; + } + } + + $process->stop(3); + + throw new RuntimeException('Dev server failed to start within 10 seconds.'); + } + + 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+)/', 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+)/', mb_trim($output), $m) ? $m[1] . ',' . ((int) $m[2] - 80) : '1920,1000', + default => '1920,1000', + }; + } + + 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: ' . mb_trim($process->getErrorOutput())); + } + + $this->writeLine('✔ Database ready.'); + } + + /** + * @return array{string, ?string}|null + */ + private function resolveAuthState(string $url, string $env, ?string $authScript): ?array + { + $storageFile = sys_get_temp_dir() + . DIRECTORY_SEPARATOR + . 'pest-auth-' . getmypid() . '.json'; + + $this->writeLine('● Generating auth state...'); + + try { + $userModelClass = $this->generateAuthState($url, $storageFile, $env, $authScript); + } catch (RuntimeException $e) { + $this->writeLine('✗ ' . $e->getMessage()); + + return null; + } + + $this->writeLine('✔ Auth state ready.'); + + return [$storageFile, $userModelClass]; + } + + private function generateAuthState(string $url, string $storageFile, string $env, ?string $authScript): ?string + { + $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(); + + if (! $process->isSuccessful()) { + throw new RuntimeException('Auth generation failed: ' . mb_trim($process->getErrorOutput())); + } + + $userModelClass = mb_trim($process->getOutput()); + + return $userModelClass !== '' ? $userModelClass : null; + } + + 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', + ); + } + + return __DIR__ . DIRECTORY_SEPARATOR . 'Recorder' . DIRECTORY_SEPARATOR . 'Laravel' . DIRECTORY_SEPARATOR . 'auth-gen.php'; + } + + private function resolveOutputPath(TestWriter $writer): string + { + $testsDir = $this->testSuite->rootPath . DIRECTORY_SEPARATOR . $this->testSuite->testPath . DIRECTORY_SEPARATOR . 'Browser'; + $existing = $writer->findExistingTestFiles($testsDir); + + if ($existing !== []) { + $choices = array_merge( + ['New file...'], + array_map( + fn(string $path): string => mb_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 && mb_trim($answer) !== '') ? mb_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(mb_trim($input))) ? (int) mb_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..ce1d7ab2 --- /dev/null +++ b/src/Recorder/Codegen.php @@ -0,0 +1,149 @@ +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 $browser = null, + ?string $channel = null, + ?string $lang = null, + ?string $timezone = null, + ?string $colorScheme = null, + ): string + { + $command = $this->buildCommand( + url: $url, + outputFile: $outputFile, + testIdAttribute: $testIdAttribute, + viewport: $viewport, + visitPath: $visitPath, + loadStorage: $loadStorage, + device: $device, + browser: $browser, + channel: $channel, + lang: $lang, + timezone: $timezone, + colorScheme: $colorScheme, + ); + + $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 : ''; + } + + /** + * @return string[] + */ + private function buildCommand( + string $url, + string $outputFile, + string $testIdAttribute, + ?string $viewport, + ?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 = [ + 'npx', 'playwright', 'codegen', + '--target=jsonl', + '--test-id-attribute=' . $testIdAttribute, + '--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, + default => null, + }; + + if (! is_null($flag)) { + $command[] = $flag; + } + + 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 ($event->type === 'assertText') { + return $event->text !== null && $event->text !== ''; + } + + 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 + { + $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[$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[$pageIndex][$selector] ?? null) !== $index) { + continue; + } + } + + $result[] = $event; + } + + return $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/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/Locator.php b/src/Recorder/Locator.php new file mode 100644 index 00000000..4be02171 --- /dev/null +++ b/src/Recorder/Locator.php @@ -0,0 +1,66 @@ +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', 'checkbox', 'radio' => $name, + 'textbox', 'searchbox', 'combobox' => Selector::getByLabelSelector($name, true), + 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); + + if (! is_null($userModelClass)) { + $body = " \$this->actingAs(\\{$userModelClass}::factory()->create());\n\n" . $body; + } + + $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(" visit('%s')", $page['path'])]; + + foreach ($page['events'] as $event) { + $action = $this->renderAction($event); + + if (! is_null($action)) { + $lines[] = ' ->' . $action; + } + } + + $lastIndex = count($lines) - 1; + $lines[$lastIndex] .= ';'; + + $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), + + default => null, + }; + } + + private function renderAssertText(RecordedEvent $event): ?string + { + $text = $event->text ?? ''; + + if ($text === '') { + return null; + } + + 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..63210755 --- /dev/null +++ b/src/Recorder/TestWriter.php @@ -0,0 +1,62 @@ +isFile() && str_ends_with($file->getFilename(), 'Test.php')) { + $files[] = $file->getPathname(); + } + } + + sort($files); + + return $files; + } + + public function write(string $path, string $testCode, bool $actingAs = false): void + { + if (! file_exists($path)) { + $dir = dirname($path); + + if (! is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $uses = $actingAs ? "uses(Tests\\TestCase::class);\n\n" : ''; + + file_put_contents($path, " 'test-id', + 'body' => $id, + 'options' => [], + ]; +} + +function makeRoleLocator(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 = [ + 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); +}); + +it('drops unsupported event types', function (): void { + $sanitizer = new EventSanitizer('id'); + $events = [ + makeRecordedEvent('hover', makeTestIdLocator('btn')), + makeRecordedEvent('press', makeTestIdLocator('input')), + makeRecordedEvent('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 = [ + makeRecordedEvent('navigate'), + makeRecordedEvent('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 = [ + makeRecordedEvent('click'), + makeRecordedEvent('fill'), + makeRecordedEvent('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 = [ + makeRecordedEvent('click', [ + 'kind' => 'unknown', + 'body' => '', + 'options' => [], + ]), + makeRecordedEvent('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 = [ + makeRecordedEvent('click', makeTestIdLocator('email')), + makeRecordedEvent('fill', makeTestIdLocator('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 = [ + makeRecordedEvent('click', makeTestIdLocator('btn')), + makeRecordedEvent('fill', makeTestIdLocator('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 = [ + makeRecordedEvent('click', makeRoleLocator('button', 'Submit')), + makeRecordedEvent('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 = [ + 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); + + 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 = [ + 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 = [ + 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); + + 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..a519af57 --- /dev/null +++ b/tests/Unit/Recorder/TestGeneratorTest.php @@ -0,0 +1,182 @@ + 'role', + 'body' => 'button', + 'options' => ['name' => $name], + ], + ); +} + +function fillEvent(string $id, string $value): RecordedEvent +{ + return new RecordedEvent( + type: 'fill', + locator: [ + 'kind' => 'test-id', + 'body' => $id, + 'options' => [], + ], + text: $value, + ); +} + +function assertTextEvent(string $text): RecordedEvent +{ + return new RecordedEvent(type: 'assertText', text: $text); +} + +// actingAs +it('injects actingAs with resolved model class', function (): void { + $generator = new TestGenerator('id'); + $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 userModelClass is null', function (): void { + $generator = new TestGenerator('id'); + $events = [navigateEvent('http://localhost/'), fillEvent('email', 'test@example.com')]; + $code = $generator->generate($events, 'can do something', 'http://localhost'); + + expect($code)->not->toContain('actingAs'); +}); + +// test structure +it('wraps output in it() closure', function (): void { + $generator = new TestGenerator('id'); + $events = [navigateEvent('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 = [navigateEvent('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 = [ + navigateEvent('http://localhost/'), + clickEvent('Submit'), + navigateEvent('http://localhost/dashboard'), + clickEvent('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 = [navigateEvent('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 = [navigateEvent('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 = [navigateEvent('http://localhost/'), clickEvent('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 = [navigateEvent('http://localhost/'), fillEvent('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 = [navigateEvent('http://localhost/'), assertTextEvent('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 = [navigateEvent('http://localhost/'), assertTextEvent('')]; + $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 = [navigateEvent('http://localhost/'), fillEvent('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 = [navigateEvent('http://localhost/'), fillEvent('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 = [ + navigateEvent('http://localhost/'), + navigateEvent('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 userModelClass is set', function (): void { + $generator = new TestGenerator('id'); + $events = [navigateEvent('http://localhost/dashboard')]; + $code = $generator->generate($events, 'test', 'http://localhost', 'App\\Models\\User'); + $actingAsPos = strpos($code, 'actingAs'); + $visitPos = strpos($code, 'visit('); + + expect($actingAsPos)->toBeLessThan($visitPos); +});