toContain(HasRestartLimit::class);
$resource = new $modelClass;
expect($resource->getFillable())->toContain(
'restart_count',
'max_restart_count',
'restart_limit_reached',
'last_restart_at',
'last_restart_type',
)->and($resource->getCasts())->toMatchArray([
'restart_count' => 'integer',
'max_restart_count' => 'integer',
'restart_limit_reached' => 'boolean',
'last_restart_at' => 'datetime',
]);
})->with([
ApplicationPreview::class,
ServiceApplication::class,
ServiceDatabase::class,
StandaloneClickhouse::class,
StandaloneDragonfly::class,
StandaloneKeydb::class,
StandaloneMariadb::class,
StandaloneMongodb::class,
StandaloneMysql::class,
StandalonePostgresql::class,
StandaloneRedis::class,
]);
it('collects restart counts for preview and service containers from both status sources', function () {
$dockerStatus = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
$sentinelStatus = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
expect($dockerStatus)
->toContain('previewContainerRestartCounts')
->toContain('serviceContainerRestartCounts')
->and($sentinelStatus)
->toContain('previewContainerRestartCounts')
->toContain('serviceContainerRestartCounts');
});
it('ignores docker compose one-off job containers in both status sources', function () {
$dockerStatus = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
$sentinelStatus = file_get_contents(app_path('Jobs/PushServerUpdateJob.php'));
expect($dockerStatus)
->toContain("filter_var(data_get(\$labels, 'com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)")
->and($sentinelStatus)
->toContain("filter_var(\$labels->get('com.docker.compose.oneoff'), FILTER_VALIDATE_BOOLEAN)");
});
it('shows restart limit warnings for every resource family', function () {
$previews = file_get_contents(resource_path('views/livewire/project/application/previews.blade.php'));
$serviceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
$applicationStatus = file_get_contents(resource_path('views/livewire/project/application/status.blade.php'));
$databaseStatus = file_get_contents(resource_path('views/livewire/project/database/status.blade.php'));
expect($previews)->toContain('')
->and($serviceCard)->toContain('')
->and($applicationStatus)->toContain('')
->and($databaseStatus)->toContain('');
$serviceStatus = file_get_contents(resource_path('views/livewire/project/service/status.blade.php'));
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
expect($serviceStatus)->toContain('')
->and($serviceStatus)->toContain('$selectedResource?->status ?? $service->status')
->and($serviceHeading)->toContain('')
->and($serviceHeading)->toContain('$selectedResource?->status ?? $service->status');
});
it('matches application restart badge layout on mobile resource headings', function () {
$applicationHeading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$databaseHeading = file_get_contents(resource_path('views/livewire/project/database/heading.blade.php'));
$serviceHeading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
foreach ([$applicationHeading, $databaseHeading, $serviceHeading] as $heading) {
expect($heading)
->toContain('class="relative flex w-full min-w-0 items-center gap-2"')
->toContain('class="flex w-full flex-wrap gap-1"');
}
});
it('shows database restart limits in shared resource listings', function () {
$resourceIndex = file_get_contents(app_path('Livewire/Project/Resource/Index.php'));
$serverResources = file_get_contents(resource_path('views/livewire/server/resources.blade.php'));
$destination = file_get_contents(resource_path('views/livewire/project/shared/destination.blade.php'));
expect($resourceIndex)
->toContain("method_exists(\$item, 'stoppedAfterRestartLimit')")
->not->toContain("\$type === 'application' && \$item->stoppedAfterRestartLimit()")
->and($serverResources)
->toContain('')
->and($destination)
->toContain('');
});
it('keeps the service resource table readable with horizontal scrolling on mobile', function () {
$configuration = file_get_contents(resource_path('views/livewire/project/service/configuration.blade.php'));
$resourceCard = file_get_contents(resource_path('views/livewire/project/service/resource-card.blade.php'));
expect($configuration)
->toContain("'overflow-x-auto rounded-xl")
->toContain('min-w-[48rem]')
->and($resourceCard)
->toContain('min-w-[48rem]')
->not->toContain('
toContain('x-on:keydown.enter="openSettings($event)"')
->toContain("closest('a, button')")
->toContain('role="link"')
->toContain('tabindex="0"');
});
it('uses selected service resource actions instead of parent complex status actions', function () {
$heading = file_get_contents(resource_path('views/livewire/project/service/heading.blade.php'));
$headingClass = file_get_contents(app_path('Livewire/Project/Service/Heading.php'));
expect(substr_count($heading, "\$selectedResource && \$selectedResource->container_present !== false && \$selectedResourceStatus->startsWith('exited')"))->toBe(2)
->and($heading)
->toContain('Remove container')
->toContain('removeSelectedResourceContainer')
->and($headingClass)
->toContain('public function removeSelectedResourceContainer(): void');
});
it('imports the application model used when claiming a restart limit', function () {
$statusAction = file_get_contents(app_path('Actions/Docker/GetContainersStatus.php'));
expect($statusAction)
->toContain('use App\\Models\\Application;')
->toContain('Application::query()');
});
it('adds restart limit columns to previews services and standalone databases', function () {
$migrations = collect(glob(database_path('migrations/*.php')))
->map(fn (string $path): string => file_get_contents($path))
->implode("\n");
expect($migrations)
->toContain("'application_previews'")
->toContain("'service_applications'")
->toContain("'service_databases'")
->toContain("'max_restart_count'")
->toContain("'restart_limit_reached'");
$restartLimitMigrations = collect(glob(database_path('migrations/*_add_restart_limit_to_*.php')));
expect($restartLimitMigrations)->toHaveCount(11);
expect($restartLimitMigrations->map(
fn (string $path): string => substr(basename($path), 0, 17)
)->unique())->toHaveCount(11);
$restartLimitMigrations->each(function (string $path): void {
expect(file_get_contents($path))->not->toContain('foreach (');
});
});
it('atomically claims a resource restart limit once and can reset it', function () {
Schema::create('restart_limit_test_resources', function (Blueprint $table): void {
$table->id();
$table->string('status')->default('running');
$table->integer('restart_count')->default(0);
$table->integer('max_restart_count')->default(2);
$table->boolean('restart_limit_reached')->default(false);
$table->timestamp('last_restart_at')->nullable();
$table->string('last_restart_type')->nullable();
$table->timestamps();
});
$resource = new class extends Model
{
use HasRestartLimit;
protected $table = 'restart_limit_test_resources';
};
$resource->save();
$resource->refresh();
expect($resource->trackRestartCount(2))->toBeTrue()
->and($resource->fresh()->restart_limit_reached)->toBeTrue()
->and($resource->trackRestartCount(2))->toBeFalse();
$resource->resetRestartLimit();
expect($resource->fresh()->restart_count)->toBe(0)
->and($resource->restart_limit_reached)->toBeFalse();
$resourceWithExistingRestarts = $resource->newInstance();
$resourceWithExistingRestarts->max_restart_count = 0;
$resourceWithExistingRestarts->save();
expect($resourceWithExistingRestarts->trackRestartCount(17))->toBeFalse();
$resourceWithExistingRestarts->update(['max_restart_count' => 10]);
expect($resourceWithExistingRestarts->trackRestartCount(17))->toBeTrue()
->and($resourceWithExistingRestarts->fresh()->restart_limit_reached)->toBeTrue();
Schema::drop('restart_limit_test_resources');
});