Set up end-to-end browser testing using Pest Browser Plugin + Playwright. New v4 test suite uses SQLite :memory: database with pre-generated schema dump (database/schema/testing-schema.sql) instead of running migrations, enabling faster test startup. - Add pestphp/pest-plugin-browser dependency - Create GenerateTestingSchema command to export PostgreSQL schema to SQLite - Add .env.testing configuration for isolated test environment - Implement v4 test directory structure (Feature, Browser, Unit tests) - Update Pest skill documentation with browser testing patterns, API reference, debugging techniques, and common pitfalls - Configure phpunit.xml and tests/Pest.php for v4 suite - Update package.json and docker-compose.dev.yml for testing dependencies
222 lines
7.2 KiB
PHP
222 lines
7.2 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class GenerateTestingSchema extends Command
|
|
{
|
|
protected $signature = 'schema:generate-testing {--connection=pgsql : The database connection to read from}';
|
|
|
|
protected $description = 'Generate SQLite testing schema from the PostgreSQL database';
|
|
|
|
private array $typeMap = [
|
|
'/\bbigint\b/' => 'INTEGER',
|
|
'/\binteger\b/' => 'INTEGER',
|
|
'/\bsmallint\b/' => 'INTEGER',
|
|
'/\bboolean\b/' => 'INTEGER',
|
|
'/character varying\(\d+\)/' => 'TEXT',
|
|
'/timestamp\(\d+\) without time zone/' => 'TEXT',
|
|
'/timestamp\(\d+\) with time zone/' => 'TEXT',
|
|
'/\bjsonb\b/' => 'TEXT',
|
|
'/\bjson\b/' => 'TEXT',
|
|
'/\buuid\b/' => 'TEXT',
|
|
'/double precision/' => 'REAL',
|
|
'/numeric\(\d+,\d+\)/' => 'REAL',
|
|
'/\bdate\b/' => 'TEXT',
|
|
];
|
|
|
|
private array $castRemovals = [
|
|
'::character varying',
|
|
'::text',
|
|
'::integer',
|
|
'::boolean',
|
|
'::timestamp without time zone',
|
|
'::timestamp with time zone',
|
|
'::numeric',
|
|
];
|
|
|
|
public function handle(): int
|
|
{
|
|
$connection = $this->option('connection');
|
|
|
|
if (DB::connection($connection)->getDriverName() !== 'pgsql') {
|
|
$this->error("Connection '{$connection}' is not PostgreSQL.");
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->info('Reading schema from PostgreSQL...');
|
|
|
|
$tables = $this->getTables($connection);
|
|
$lastMigration = DB::connection($connection)
|
|
->table('migrations')
|
|
->orderByDesc('id')
|
|
->value('migration');
|
|
|
|
$output = [];
|
|
$output[] = '-- Generated by: php artisan schema:generate-testing';
|
|
$output[] = '-- Date: '.now()->format('Y-m-d H:i:s');
|
|
$output[] = '-- Last migration: '.($lastMigration ?? 'none');
|
|
$output[] = '';
|
|
|
|
foreach ($tables as $table) {
|
|
$columns = $this->getColumns($connection, $table);
|
|
$output[] = $this->generateCreateTable($table, $columns);
|
|
}
|
|
|
|
$indexes = $this->getIndexes($connection, $tables);
|
|
foreach ($indexes as $index) {
|
|
$output[] = $index;
|
|
}
|
|
|
|
$output[] = '';
|
|
$output[] = '-- Migration records';
|
|
|
|
$migrations = DB::connection($connection)->table('migrations')->orderBy('id')->get();
|
|
foreach ($migrations as $m) {
|
|
$migration = str_replace("'", "''", $m->migration);
|
|
$output[] = "INSERT INTO \"migrations\" (\"id\", \"migration\", \"batch\") VALUES ({$m->id}, '{$migration}', {$m->batch});";
|
|
}
|
|
|
|
$path = database_path('schema/testing-schema.sql');
|
|
|
|
if (! is_dir(dirname($path))) {
|
|
mkdir(dirname($path), 0755, true);
|
|
}
|
|
|
|
file_put_contents($path, implode("\n", $output)."\n");
|
|
|
|
$this->info("Schema written to {$path}");
|
|
$this->info(count($tables).' tables, '.count($migrations).' migration records.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function getTables(string $connection): array
|
|
{
|
|
return collect(DB::connection($connection)->select(
|
|
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename"
|
|
))->pluck('tablename')->toArray();
|
|
}
|
|
|
|
private function getColumns(string $connection, string $table): array
|
|
{
|
|
return DB::connection($connection)->select(
|
|
"SELECT column_name, data_type, character_maximum_length, column_default,
|
|
is_nullable, udt_name, numeric_precision, numeric_scale, datetime_precision
|
|
FROM information_schema.columns
|
|
WHERE table_schema = 'public' AND table_name = ?
|
|
ORDER BY ordinal_position",
|
|
[$table]
|
|
);
|
|
}
|
|
|
|
private function generateCreateTable(string $table, array $columns): string
|
|
{
|
|
$lines = [];
|
|
|
|
foreach ($columns as $col) {
|
|
$lines[] = ' '.$this->generateColumnDef($table, $col);
|
|
}
|
|
|
|
return "CREATE TABLE IF NOT EXISTS \"{$table}\" (\n".implode(",\n", $lines)."\n);\n";
|
|
}
|
|
|
|
private function generateColumnDef(string $table, object $col): string
|
|
{
|
|
$name = $col->column_name;
|
|
$sqliteType = $this->convertType($col);
|
|
|
|
// Auto-increment primary key for id columns
|
|
if ($name === 'id' && $sqliteType === 'INTEGER' && $col->is_nullable === 'NO' && str_contains((string) $col->column_default, 'nextval')) {
|
|
return "\"{$name}\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL";
|
|
}
|
|
|
|
$parts = ["\"{$name}\"", $sqliteType];
|
|
|
|
// Default value
|
|
$default = $col->column_default;
|
|
if ($default !== null && ! str_contains($default, 'nextval')) {
|
|
$default = $this->cleanDefault($default);
|
|
$parts[] = "DEFAULT {$default}";
|
|
}
|
|
|
|
// NOT NULL
|
|
if ($col->is_nullable === 'NO') {
|
|
$parts[] = 'NOT NULL';
|
|
}
|
|
|
|
return implode(' ', $parts);
|
|
}
|
|
|
|
private function convertType(object $col): string
|
|
{
|
|
$pgType = $col->data_type;
|
|
|
|
return match (true) {
|
|
in_array($pgType, ['bigint', 'integer', 'smallint']) => 'INTEGER',
|
|
$pgType === 'boolean' => 'INTEGER',
|
|
in_array($pgType, ['character varying', 'text', 'USER-DEFINED']) => 'TEXT',
|
|
str_contains($pgType, 'timestamp') => 'TEXT',
|
|
in_array($pgType, ['json', 'jsonb']) => 'TEXT',
|
|
$pgType === 'uuid' => 'TEXT',
|
|
$pgType === 'double precision' => 'REAL',
|
|
$pgType === 'numeric' => 'REAL',
|
|
$pgType === 'date' => 'TEXT',
|
|
default => 'TEXT',
|
|
};
|
|
}
|
|
|
|
private function cleanDefault(string $default): string
|
|
{
|
|
foreach ($this->castRemovals as $cast) {
|
|
$default = str_replace($cast, '', $default);
|
|
}
|
|
|
|
// Remove array type casts like ::text[]
|
|
$default = preg_replace('/::[\w\s]+(\[\])?/', '', $default);
|
|
|
|
return $default;
|
|
}
|
|
|
|
private function getIndexes(string $connection, array $tables): array
|
|
{
|
|
$results = [];
|
|
|
|
$indexes = DB::connection($connection)->select(
|
|
"SELECT indexname, tablename, indexdef FROM pg_indexes
|
|
WHERE schemaname = 'public'
|
|
ORDER BY tablename, indexname"
|
|
);
|
|
|
|
foreach ($indexes as $idx) {
|
|
$def = $idx->indexdef;
|
|
|
|
// Skip primary key indexes
|
|
if (str_contains($def, '_pkey')) {
|
|
continue;
|
|
}
|
|
|
|
// Skip PG-specific indexes (GIN, GIST, expression indexes)
|
|
if (preg_match('/USING (gin|gist)/i', $def)) {
|
|
continue;
|
|
}
|
|
if (str_contains($def, '->>') || str_contains($def, '::')) {
|
|
continue;
|
|
}
|
|
|
|
// Convert to SQLite-compatible CREATE INDEX
|
|
$unique = str_contains($def, 'UNIQUE') ? 'UNIQUE ' : '';
|
|
|
|
// Extract columns from the index definition
|
|
if (preg_match('/\((.+)\)$/', $def, $m)) {
|
|
$cols = $m[1];
|
|
$results[] = "CREATE {$unique}INDEX IF NOT EXISTS \"{$idx->indexname}\" ON \"{$idx->tablename}\" ({$cols});";
|
|
}
|
|
}
|
|
|
|
return $results;
|
|
}
|
|
}
|