not->toContain('Force deploy without cache');
});
+it('shows stop in application action menus when the application is exited', function () {
+ $heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
+ $desktopExitedActions = str($heading)
+ ->after("@if (str(\$application->status)->startsWith('exited'))")
+ ->before('@else')
+ ->toString();
+
+ $mobileActions = str($heading)
+ ->after('id="application-mobile-actions"')
+ ->before('')
+ ->toString();
+
+ expect($mobileActions)->toContain('application-mobile-stop-trigger')
+ ->and($desktopExitedActions)->toContain('application-mobile-stop-trigger')
+ ->and($mobileActions)->toContain('Deploy (without cache)')
+ ->and(strrpos($mobileActions, 'Deploy (without cache)'))
+ ->toBeLessThan(strrpos($mobileActions, 'application-mobile-stop-trigger'));
+});
+
it('places the state-aware no-cache action immediately after deploy or redeploy', function () {
$heading = file_get_contents(resource_path('views/livewire/project/application/heading.blade.php'));
$actions = str($heading)->after('id="application-desktop-actions"')->before('@endteleport')->toString();
diff --git a/tests/Feature/SentinelPushDeduplicationTest.php b/tests/Feature/SentinelPushDeduplicationTest.php
index aef1b74849..bc6f2d260e 100644
--- a/tests/Feature/SentinelPushDeduplicationTest.php
+++ b/tests/Feature/SentinelPushDeduplicationTest.php
@@ -147,6 +147,16 @@ it('dispatches the job when container state changes', function () use ($running)
Queue::assertPushed(PushServerUpdateJob::class, 2);
});
+it('dispatches the job when only the container restart count changes', function () {
+ $beforeRestart = [['name' => 'app-1', 'state' => 'running', 'restart_count' => 0]];
+ $afterRestart = [['name' => 'app-1', 'state' => 'running', 'restart_count' => 1]];
+
+ pushSentinel($this->token, sentinelPayload($beforeRestart))->assertOk();
+ pushSentinel($this->token, sentinelPayload($afterRestart))->assertOk();
+
+ Queue::assertPushed(PushServerUpdateJob::class, 2);
+});
+
it('ignores health status changes while container lifecycle state is unchanged', function () {
$healthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'healthy']];
$unhealthy = [['name' => 'app-1', 'state' => 'running', 'health_status' => 'unhealthy']];
diff --git a/tests/Unit/ContainerStatusAggregatorTest.php b/tests/Unit/ContainerStatusAggregatorTest.php
index 71425a21cb..0a40206ac7 100644
--- a/tests/Unit/ContainerStatusAggregatorTest.php
+++ b/tests/Unit/ContainerStatusAggregatorTest.php
@@ -70,12 +70,12 @@ describe('aggregateFromStrings', function () {
expect($result)->toBe('running:unknown');
});
- test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
+ test('returns exited for an exited container with a restart count', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
- expect($result)->toBe('degraded:unhealthy');
+ expect($result)->toBe('exited');
});
test('returns exited for exited containers without restart count', function () {
@@ -214,12 +214,12 @@ describe('aggregateFromStrings', function () {
expect($result)->toBe('degraded:unhealthy');
});
- test('prioritizes crash loop over running containers', function () {
+ test('returns exited when all containers are exited with restart counts', function () {
$statuses = collect(['exited', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 3);
- expect($result)->toBe('degraded:unhealthy');
+ expect($result)->toBe('exited');
});
test('prioritizes mixed state over healthy running', function () {
@@ -238,12 +238,12 @@ describe('aggregateFromStrings', function () {
expect($result)->toBe('starting:unknown');
});
- test('prioritizes running over paused/exited when no starting', function () {
+ test('returns degraded for mixed running and exited containers', function () {
$statuses = collect(['running:healthy', 'paused', 'exited']);
$result = $this->aggregator->aggregateFromStrings($statuses);
- expect($result)->toBe('running:healthy');
+ expect($result)->toBe('degraded:unhealthy');
});
test('prioritizes dead over paused/starting/exited', function () {
@@ -357,7 +357,7 @@ describe('aggregateFromContainers', function () {
expect($result)->toBe('degraded:unhealthy');
});
- test('returns degraded:unhealthy for crash loop (exited with restart count)', function () {
+ test('returns exited for an exited container object with a restart count', function () {
$containers = collect([
(object) [
'State' => (object) [
@@ -368,7 +368,7 @@ describe('aggregateFromContainers', function () {
$result = $this->aggregator->aggregateFromContainers($containers, maxRestartCount: 5);
- expect($result)->toBe('degraded:unhealthy');
+ expect($result)->toBe('exited');
});
test('returns exited for exited containers without restart count', function () {
@@ -501,7 +501,7 @@ describe('state priority enforcement', function () {
expect($result)->toBe('degraded:unhealthy');
});
- test('crash loop has third highest priority', function () {
+ test('mixed running and exited containers are degraded before paused or starting states', function () {
$statuses = collect([
'exited',
'running:healthy',
@@ -602,31 +602,29 @@ describe('maxRestartCount validation', function () {
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 0);
- // Zero is valid default - no crash loop detection
expect($result)->toBe('exited');
});
- test('positive maxRestartCount works correctly', function () {
+ test('positive maxRestartCount does not override an exited state', function () {
$statuses = collect(['exited']);
$result = $this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 5);
- // Positive value enables crash loop detection
- expect($result)->toBe('degraded:unhealthy');
+ expect($result)->toBe('exited');
});
- test('crash loop detection still functions after validation', function () {
+ test('exited state is preserved for any positive restart count', function () {
$statuses = collect(['exited']);
// Test with various positive restart counts
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 1))
- ->toBe('degraded:unhealthy');
+ ->toBe('exited');
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 100))
- ->toBe('degraded:unhealthy');
+ ->toBe('exited');
expect($this->aggregator->aggregateFromStrings($statuses, maxRestartCount: 999))
- ->toBe('degraded:unhealthy');
+ ->toBe('exited');
});
test('default maxRestartCount parameter works', function () {
diff --git a/tests/Unit/PushServerUpdateJobReturnTypesTest.php b/tests/Unit/PushServerUpdateJobReturnTypesTest.php
new file mode 100644
index 0000000000..763d456b98
--- /dev/null
+++ b/tests/Unit/PushServerUpdateJobReturnTypesTest.php
@@ -0,0 +1,9 @@
+getReturnType()?->getName())->toBe('void');
+});
diff --git a/tests/Unit/RemovedContainerStoppedNotificationTest.php b/tests/Unit/RemovedContainerStoppedNotificationTest.php
new file mode 100644
index 0000000000..016801c69d
--- /dev/null
+++ b/tests/Unit/RemovedContainerStoppedNotificationTest.php
@@ -0,0 +1,11 @@
+not->toContain('ContainerStopped')
+ ->and($telegramChannel)->not->toContain('ContainerStopped')
+ ->and(file_exists(__DIR__.'/../../app/Notifications/Container/ContainerStopped.php'))->toBeFalse()
+ ->and(file_exists(__DIR__.'/../../resources/views/emails/container-stopped.blade.php'))->toBeFalse();
+});
diff --git a/tests/Unit/RestartCountTrackerTest.php b/tests/Unit/RestartCountTrackerTest.php
new file mode 100644
index 0000000000..3004ffa16a
--- /dev/null
+++ b/tests/Unit/RestartCountTrackerTest.php
@@ -0,0 +1,95 @@
+evaluate(
+ previousRestartCount: 11,
+ observedRestartCount: 0,
+ maxRestartCount: 2,
+ newGenerationConfirmed: true,
+ );
+
+ expect($result)->toMatchArray([
+ 'restart_count' => 0,
+ 'restart_count_changed' => true,
+ 'restart_limit_reached' => false,
+ 'new_generation' => true,
+ ]);
+});
+
+it('evaluates the restart limit immediately in a new generation', function () {
+ $result = (new RestartCountTracker)->evaluate(
+ previousRestartCount: 11,
+ observedRestartCount: 3,
+ maxRestartCount: 2,
+ newGenerationConfirmed: true,
+ );
+
+ expect($result)->toMatchArray([
+ 'restart_count' => 3,
+ 'restart_count_changed' => true,
+ 'restart_limit_reached' => true,
+ 'new_generation' => true,
+ ]);
+});
+
+it('does not reset the generation without explicit confirmation', function () {
+ $result = (new RestartCountTracker)->evaluate(
+ previousRestartCount: 11,
+ observedRestartCount: 0,
+ maxRestartCount: 2,
+ );
+
+ expect($result)->toMatchArray([
+ 'restart_count' => 11,
+ 'restart_count_changed' => false,
+ 'restart_limit_reached' => false,
+ 'new_generation' => false,
+ ]);
+});
+
+it('preserves the previous count when an active payload omits the container with the previous maximum', function () {
+ $result = (new RestartCountTracker)->evaluate(
+ previousRestartCount: 11,
+ observedRestartCount: 3,
+ maxRestartCount: 20,
+ );
+
+ expect($result)->toMatchArray([
+ 'restart_count' => 11,
+ 'restart_count_changed' => false,
+ 'restart_limit_reached' => false,
+ 'new_generation' => false,
+ ]);
+});
+
+it('detects a normal threshold crossing', function () {
+ $result = (new RestartCountTracker)->evaluate(
+ previousRestartCount: 1,
+ observedRestartCount: 2,
+ maxRestartCount: 2,
+ );
+
+ expect($result)->toMatchArray([
+ 'restart_count' => 2,
+ 'restart_count_changed' => true,
+ 'restart_limit_reached' => true,
+ 'new_generation' => false,
+ ]);
+});
+
+it('detects a limit that is enabled below the current observed restart count', function () {
+ $result = (new RestartCountTracker)->evaluate(
+ previousRestartCount: 17,
+ observedRestartCount: 17,
+ maxRestartCount: 10,
+ );
+
+ expect($result)->toMatchArray([
+ 'restart_count' => 17,
+ 'restart_count_changed' => false,
+ 'restart_limit_reached' => true,
+ 'new_generation' => false,
+ ]);
+});
diff --git a/tests/Unit/StopActionsPersistStatusTest.php b/tests/Unit/StopActionsPersistStatusTest.php
index a924381bb9..35b6052dd3 100644
--- a/tests/Unit/StopActionsPersistStatusTest.php
+++ b/tests/Unit/StopActionsPersistStatusTest.php
@@ -10,8 +10,7 @@ it('persists exited status for every full application stop path', function () {
$action = file_get_contents(__DIR__.'/../../app/Actions/Application/StopApplication.php');
expect($action)
- ->toContain("\$status = ['status' => 'exited'];")
- ->toContain('$application->update($status);')
+ ->toMatch('/\$status\s*=\s*\[\s*\'status\'\s*=>\s*\'exited\',.*?\];.*?\$application->update\(\$status\);/s')
->not->toMatch('/docker stack rm .*?return;/s');
});
@@ -19,8 +18,10 @@ it('persists exited status for all children when stopping a service', function (
$action = file_get_contents(__DIR__.'/../../app/Actions/Service/StopService.php');
expect($action)
- ->toContain("\$applications->each->update(['status' => 'exited']);")
- ->toContain("\$dbs->each->update(['status' => 'exited']);");
+ ->toContain("\$application->update(['status' => 'exited']);")
+ ->toContain('$application->resetRestartLimit();')
+ ->toContain("\$database->update(['status' => 'exited']);")
+ ->toContain('$database->resetRestartLimit();');
});
it('persists exited status when stopping an individual service resource', function () {
@@ -28,6 +29,8 @@ it('persists exited status when stopping an individual service resource', functi
expect($action)
->toContain("\$serviceApplication->update(['status' => 'exited']);")
+ ->toContain('$commands = ["docker rm -f {$containerName}"];')
+ ->toContain('throwError: ! $removeContainer')
->toContain('ServiceStatusChanged::dispatch($service->environment->project->team->id);');
});
diff --git a/tests/Unit/StopDatabaseTypesTest.php b/tests/Unit/StopDatabaseTypesTest.php
new file mode 100644
index 0000000000..0295b0c956
--- /dev/null
+++ b/tests/Unit/StopDatabaseTypesTest.php
@@ -0,0 +1,12 @@
+getReturnType()?->getName())->toBe('string')
+ ->and($stopContainer->getParameters()[0]->getType()?->getName())->toBe(BaseModel::class);
+});