diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php
index 76e0735ff4..f7e5ca1d1f 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php
@@ -6,6 +6,7 @@ use App\Models\ScheduledTask;
use App\Traits\ListensToTeamChannel;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
+use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Component;
@@ -13,9 +14,6 @@ class Executions extends Component
{
use ListensToTeamChannel;
- #[Locked]
- public ScheduledTask $task;
-
#[Locked]
public int $taskId;
@@ -48,11 +46,11 @@ class Executions extends Component
{
try {
$this->taskId = $taskId;
- $this->task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
- $this->executions = $this->task->executions()->take(20)->get();
- $this->serverTimezone = data_get($this->task, 'application.destination.server.settings.server_timezone');
+ $task = ScheduledTask::where('team_id', Auth::user()->currentTeam()->id)->findOrFail($taskId);
+ $this->executions = $task->executions()->take(20)->get();
+ $this->serverTimezone = data_get($task, 'application.destination.server.settings.server_timezone');
if (! $this->serverTimezone) {
- $this->serverTimezone = data_get($this->task, 'service.destination.server.settings.server_timezone');
+ $this->serverTimezone = data_get($task, 'service.destination.server.settings.server_timezone');
}
if (! $this->serverTimezone) {
$this->serverTimezone = 'UTC';
@@ -62,8 +60,22 @@ class Executions extends Component
}
}
+ /**
+ * Load the task on each request instead of storing it as a model property. A stored model fails
+ * with a 404 when the poll runs after the task is deleted and before the page navigates away.
+ */
+ #[Computed]
+ public function task(): ?ScheduledTask
+ {
+ return ScheduledTask::find($this->taskId);
+ }
+
public function refreshExecutions(): void
{
+ if (! $this->task) {
+ return;
+ }
+
$this->executions = $this->task->executions()->take(20)->get();
if ($this->selectedKey) {
$this->selectedExecution = $this->task->executions()->find($this->selectedKey);
@@ -84,7 +96,7 @@ class Executions extends Component
return;
}
$this->selectedKey = $key;
- $this->selectedExecution = $this->task->executions()->find($key);
+ $this->selectedExecution = $this->task?->executions()->find($key);
$this->currentPage = 1;
// Start polling if task is running
@@ -157,7 +169,9 @@ class Executions extends Component
*/
public function canReadOutput(): bool
{
- return auth()->user()?->can('update', $this->task->application ?? $this->task->service) ?? false;
+ $resource = $this->task?->application ?? $this->task?->service;
+
+ return $resource && auth()->user()?->can('update', $resource);
}
public function hasMoreLogs()
diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php
index 4419b2e701..35a0f12410 100644
--- a/app/Livewire/Project/Shared/ScheduledTask/Show.php
+++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php
@@ -179,11 +179,10 @@ class Show extends Component
$this->task->delete();
auditLog('ui.scheduled_task.deleted', $this->taskAuditContext());
- if ($this->type === 'application') {
- return redirectRoute($this, 'project.application.scheduled-tasks.show', $this->parameters);
- } else {
- return redirectRoute($this, 'project.service.scheduled-tasks.show', $this->parameters);
- }
+ redirectRoute($this, "project.{$this->type}.scheduled-tasks.show", $this->parameters);
+
+ // Without true, the confirmation modal refreshes this deleted task while wire:navigate loads the list.
+ return true;
} catch (\Exception $e) {
return handleError($e);
}
diff --git a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php
index 8283e359d1..6f978dc40c 100644
--- a/resources/views/livewire/project/shared/scheduled-task/executions.blade.php
+++ b/resources/views/livewire/project/shared/scheduled-task/executions.blade.php
@@ -25,8 +25,8 @@
'failed' => 'Failed',
default => ucfirst($executionStatus),
};
- $server = data_get($task, 'application.destination.server')
- ?? data_get($task, 'service.destination.server');
+ $server = data_get($this->task, 'application.destination.server')
+ ?? data_get($this->task, 'service.destination.server');
@endphp
diff --git a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php
index b250db119b..bb5792ca07 100644
--- a/tests/Feature/ScheduledTaskCrossTeamIdorTest.php
+++ b/tests/Feature/ScheduledTaskCrossTeamIdorTest.php
@@ -60,8 +60,8 @@ describe('ScheduledTask locked properties', function () {
expect($attributes)->not->toBeEmpty();
});
- test('Executions component task property has Locked attribute', function () {
- $property = new ReflectionProperty(Executions::class, 'task');
+ test('Executions component task id property has Locked attribute', function () {
+ $property = new ReflectionProperty(Executions::class, 'taskId');
$attributes = $property->getAttributes(Locked::class);
expect($attributes)->not->toBeEmpty();
diff --git a/tests/Feature/ScheduledTaskDeleteTest.php b/tests/Feature/ScheduledTaskDeleteTest.php
new file mode 100644
index 0000000000..b34a38428b
--- /dev/null
+++ b/tests/Feature/ScheduledTaskDeleteTest.php
@@ -0,0 +1,127 @@
+ 0]);
+
+ $this->team = Team::factory()->create();
+ $this->user = User::factory()->create();
+ $this->team->members()->attach($this->user->id, ['role' => 'owner']);
+ $this->actingAs($this->user);
+ session(['currentTeam' => $this->team]);
+
+ $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
+ $server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id]);
+ $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
+ $this->project = Project::factory()->create(['team_id' => $this->team->id]);
+ $this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
+});
+
+afterEach(function () {
+ Event::forget(RouteMatched::class);
+});
+
+/**
+ * @param array $parameters
+ */
+function bindScheduledTaskDeleteRoute(array $parameters): void
+{
+ Event::listen(RouteMatched::class, function (RouteMatched $event) use ($parameters): void {
+ foreach ($parameters as $name => $value) {
+ $event->route->setParameter($name, $value);
+ }
+ });
+}
+
+test('deleting a service scheduled task returns true so the confirmation modal does not refresh the deleted task', function () {
+ $service = Service::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+ $task = ScheduledTask::factory()->create([
+ 'team_id' => $this->team->id,
+ 'service_id' => $service->id,
+ 'name' => 'nightly',
+ 'command' => 'echo nightly',
+ 'frequency' => 'daily',
+ ]);
+ $parameters = [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'service_uuid' => $service->uuid,
+ ];
+ bindScheduledTaskDeleteRoute([...$parameters, 'task_uuid' => $task->uuid]);
+
+ Livewire::test(Show::class)
+ ->call('delete')
+ ->assertReturned(true)
+ ->assertRedirect(route('project.service.scheduled-tasks.show', $parameters));
+
+ expect($task->fresh())->toBeNull();
+});
+
+test('deleting an application scheduled task returns true and redirects to the task list', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+ $task = ScheduledTask::factory()->create([
+ 'team_id' => $this->team->id,
+ 'application_id' => $application->id,
+ 'name' => 'nightly',
+ 'command' => 'echo nightly',
+ 'frequency' => 'daily',
+ ]);
+ $parameters = [
+ 'project_uuid' => $this->project->uuid,
+ 'environment_uuid' => $this->environment->uuid,
+ 'application_uuid' => $application->uuid,
+ ];
+ bindScheduledTaskDeleteRoute([...$parameters, 'task_uuid' => $task->uuid]);
+
+ Livewire::test(Show::class)
+ ->call('delete')
+ ->assertReturned(true)
+ ->assertRedirect(route('project.application.scheduled-tasks.show', $parameters));
+
+ expect($task->fresh())->toBeNull();
+});
+
+test('the executions poll still works after its task is deleted while the page navigates away', function () {
+ $application = Application::factory()->create([
+ 'environment_id' => $this->environment->id,
+ 'destination_id' => $this->destination->id,
+ 'destination_type' => $this->destination->getMorphClass(),
+ ]);
+ $task = ScheduledTask::factory()->create([
+ 'team_id' => $this->team->id,
+ 'application_id' => $application->id,
+ ]);
+
+ $component = Livewire::test(Executions::class, ['taskId' => $task->id]);
+ $task->delete();
+
+ $component->call('refreshExecutions')->assertOk();
+});
diff --git a/tests/v4/Browser/ScheduledTaskDeleteTest.php b/tests/v4/Browser/ScheduledTaskDeleteTest.php
new file mode 100644
index 0000000000..7b180f911f
--- /dev/null
+++ b/tests/v4/Browser/ScheduledTaskDeleteTest.php
@@ -0,0 +1,77 @@
+stack = seedBrowserResourceStack();
+ $service = createBrowserService($this->stack, ['uuid' => 'svc-browser-task', 'name' => 'Task Service'])['service'];
+ $this->task = ScheduledTask::factory()->create([
+ 'team_id' => 0,
+ 'service_id' => $service->id,
+ 'name' => 'nightly-cleanup',
+ 'command' => 'echo cleanup',
+ 'frequency' => 'daily',
+ ]);
+ $this->base = serviceConfigurationUrl($this->stack['project'], $this->stack['environment'], $service);
+});
+
+/**
+ * Records the HTTP status of every Livewire request the page sends.
+ */
+function recordScheduledTaskLivewireStatuses(mixed $page): void
+{
+ $page->script(<<<'JS'
+ (() => {
+ window.livewireStatuses = [];
+ const fetch = window.fetch;
+ window.fetch = async (...args) => {
+ const response = await fetch(...args);
+ if (String(args[0]).includes('/livewire')) {
+ window.livewireStatuses.push(response.status);
+ }
+ return response;
+ };
+ })()
+ JS);
+}
+
+it('deletes a service scheduled task and returns to the task list', function () {
+ loginAndSkipBoarding();
+
+ $page = visit("{$this->base}/tasks/{$this->task->uuid}")
+ ->assertSee('Task configuration');
+ recordScheduledTaskLivewireStatuses($page);
+
+ $page->click('button:visible:has-text("Delete")')
+ ->fill('input[x-model="userConfirmationText"]', 'nightly-cleanup')
+ ->click('button:visible:has-text("Permanently delete")')
+ ->wait(3);
+
+ expect($this->task->fresh())->toBeNull()
+ ->and($page->script('window.livewireStatuses'))->not->toContain(404);
+
+ $page->assertPathIs(parse_url("{$this->base}/scheduled-tasks", PHP_URL_PATH))
+ ->screenshot(filename: 'scheduled-task-deleted');
+});
+
+it('keeps polling executions without a 404 after the task is deleted', function () {
+ loginAndSkipBoarding();
+
+ $page = visit("{$this->base}/tasks/{$this->task->uuid}")
+ ->assertSee('Recent executions');
+ recordScheduledTaskLivewireStatuses($page);
+
+ // The executions list polls every 5 seconds, so a poll can run after the delete and before the list page loads.
+ $this->task->delete();
+ $page->script(<<<'JS'
+ Livewire.all().find((component) => component.name === 'project.shared.scheduled-task.executions')
+ .$wire.refreshExecutions()
+ JS);
+ $page->wait(1);
+
+ expect($page->script('window.livewireStatuses'))->toBe([200]);
+ $page->screenshot(filename: 'scheduled-task-poll-after-delete');
+});