import { expect, test } from "@playwright/test"; import { apiPathPattern, mockApi, primeMockSession } from "./support/network.js"; const cronTasks = [ { id: "economic.transfer_queue", name: "Process e-conomic transfer queue", description: "Processes pending e-conomic transfer queue jobs.", module: "economic", schedule: { type: "interval", seconds: 30 }, default_schedule: { type: "interval", seconds: 30 }, enabled: true, default_enabled: true, estimated_duration_ms: 2400, next_run_at: "2026-07-09 12:01:00", last_status: "succeeded", due: true, }, { id: "system.sync_logs", name: "Sync logs", description: "Flushes application logs.", module: "system", schedule: { type: "interval", seconds: 300 }, default_schedule: { type: "interval", seconds: 300 }, enabled: false, default_enabled: true, estimated_duration_ms: 900, next_run_at: "2026-07-09 12:05:00", last_status: "failed", last_error: "Redis unavailable", due: false, }, ]; function cronListPayload(tasks = cronTasks) { return { success: true, data: { tasks, summary: { total: tasks.length, enabled: tasks.filter((task) => task.enabled).length, due: tasks.filter((task) => task.enabled && task.due).length, }, }, meta: {}, includes: {}, }; } test.describe("Superuser cron operations", () => { test.beforeEach(async ({ page }) => { await mockApi(page, { authenticated: true, permissions: [ "superuser", "superuser_cron_view", "SUPERUSER_RUN_CRON", "superuser_cron_manage", "superuser_coolify_manage", ], }); await primeMockSession(page, { token: "superuser-cron-token", bootPath: null }); }); test("superusers can inspect, run, toggle, and reschedule cron tasks", async ({ page }, testInfo) => { const patchPayloads: Array> = []; const runPayloads: Array> = []; const deployPayloads: Array> = []; let currentTasks = cronTasks.map((task) => ({ ...task, schedule: { ...task.schedule } })); let runHistory = [ { id: 41, task_id: "economic.transfer_queue", source: "automatic", status: "succeeded", started_at: "2026-07-09 12:00:00", duration_ms: 2100, }, ]; await page.route(apiPathPattern("/superuser/cron"), async (route) => { if (route.request().method() !== "GET") { await route.fallback(); return; } await route.fulfill({ status: 202, contentType: "application/json", body: JSON.stringify(cronListPayload(currentTasks)), }); }); await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => { await route.fulfill({ status: 202, contentType: "application/json", body: JSON.stringify({ success: true, data: { runs: runHistory, }, meta: {}, includes: {}, }), }); }); await page.route(apiPathPattern("/superuser/cron/workers"), async (route) => { if (route.request().method() !== "GET") { await route.fallback(); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: { state: "healthy", desired_workers: 1, channel: { id: 1, slug: "stable", name: "Stable", }, channels: [ { id: 1, slug: "stable", name: "Stable", default_channel: true, }, ], api_target: { id: 17, app: "api", }, cron_target: { id: 71, app: "cron", coolify_service_uuid: "cron-worker-uuid", auto_deploy: false, }, workers: [ { worker_id: "release-stable-cron-worker", name: "release-stable-cron-worker", hostname: "coolify-cron-1", source: "coolify_worker", status: "running", stale: false, last_heartbeat_at: "2026-07-09 12:01:30", last_run_count: 1, commit_sha: "abcdef1234567890", release_channel_id: 1, release_target_id: 71, coolify_resource_uuid: "cron-worker-uuid", }, ], summary: { total: 1, running: 1, stale: 0, desired: 1, state: "healthy", }, latest_deployment: { id: 88, app: "cron", status: "deployed", provider_operation_id: "deploy-88", started_at: "2026-07-09 12:01:00", }, recent_deployments: [ { id: 88, app: "cron", status: "deployed", provider_operation_id: "deploy-88", started_at: "2026-07-09 12:01:00", }, ], issues: [], deployment: { ok: true, state: "healthy", channel: { id: 1, slug: "stable", name: "Stable", }, api_target: { id: 17, app: "api", }, target: { id: 71, app: "cron", coolify_service_uuid: "cron-worker-uuid", auto_deploy: false, }, latest_deployment: { id: 88, app: "cron", status: "deployed", provider_operation_id: "deploy-88", started_at: "2026-07-09 12:01:00", }, issues: [], }, }, meta: {}, includes: {}, }), }); }); await page.route(apiPathPattern("/superuser/cron/workers/deploy"), async (route) => { deployPayloads.push(route.request().postDataJSON() as Record); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: { ok: true, deployment: { id: 89, app: "cron", status: "deployed", provider_operation_id: "deploy-89", }, target: { id: 71, app: "cron", coolify_service_uuid: "cron-worker-uuid", auto_deploy: false, }, worker_status: { state: "waiting_for_heartbeat", channel: { id: 1, slug: "stable", name: "Stable", }, channels: [ { id: 1, slug: "stable", name: "Stable", default_channel: true, }, ], workers: [], summary: { total: 0, running: 0, stale: 0, desired: 1, state: "waiting_for_heartbeat", }, deployment: { state: "waiting_for_heartbeat", target: { id: 71, app: "cron", coolify_service_uuid: "cron-worker-uuid", auto_deploy: false, }, }, latest_deployment: { id: 89, status: "deployed", provider_operation_id: "deploy-89", }, issues: [ { code: "waiting_for_first_heartbeat", severity: "info", message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.", }, ], }, }, meta: {}, includes: {}, }), }); }); await page.route(apiPathPattern("/superuser/cron/run"), async (route) => { runPayloads.push(route.request().postDataJSON() as Record); runHistory = [ { id: 42, task_id: "economic.transfer_queue", source: "manual", status: "queued", scheduled_for: "2026-07-09 12:02:00", created_at: "2026-07-09 12:02:00", duration_ms: null, }, ...runHistory, ]; await route.fulfill({ status: 202, contentType: "application/json", body: JSON.stringify({ success: true, data: { id: 42, task_id: "economic.transfer_queue", source: "manual", status: "queued", scheduled_for: "2026-07-09 12:02:00", created_at: "2026-07-09 12:02:00", duration_ms: null, }, meta: {}, includes: {}, }), }); }); await page.route(apiPathPattern("/superuser/cron/config"), async (route) => { const payload = route.request().postDataJSON() as Record; patchPayloads.push(payload); currentTasks = currentTasks.map((task) => { if (task.id !== payload.task_id) { return task; } return { ...task, ...(typeof payload.enabled === "boolean" ? { enabled: payload.enabled } : {}), ...(payload.schedule ? { schedule: payload.schedule as { type: string; seconds: number } } : {}), }; }); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(cronListPayload(currentTasks)), }); }); await page.goto("/superuser/system/cron", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("superuser-cron-page")).toBeVisible(); await expect(page.getByTestId("cron-task-row-economic.transfer_queue")).toContainText( "Process e-conomic transfer queue" ); await expect(page.getByTestId("cron-total-tasks")).toContainText("2"); if (!/mobile/i.test(testInfo.project.name)) { await expect(page.getByTestId("cron-worker-panel")).toContainText("release-stable-cron-worker"); await expect(page.getByTestId("cron-worker-running")).toContainText("1"); await expect(page.getByTestId("cron-worker-target")).toContainText("cron-worker-uuid"); await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Update Coolify deployment"); await Promise.all([ page.waitForResponse( (response) => response.url().includes("/superuser/cron/workers/deploy") && response.request().method() === "POST" ), page.getByTestId("cron-worker-deploy").click(), ]); expect(deployPayloads).toEqual([{ channel_id: 1 }]); await expect(page.getByTestId("cron-run-queued")).toContainText("deployment update"); } await Promise.all([ page.waitForResponse( (response) => response.url().includes("/superuser/cron/run") && response.request().method() === "POST" ), page.getByTestId("cron-task-run-economic.transfer_queue").click(), ]); expect(runPayloads).toEqual([{ task_id: "economic.transfer_queue", force: false }]); await expect(page.getByTestId("cron-run-history")).toContainText("manual"); await expect(page.getByTestId("cron-run-history")).toContainText("Queued"); await expect(page.getByTestId("cron-run-queued")).toContainText("was queued"); await page.getByTestId("cron-task-toggle-system.sync_logs").click(); expect(patchPayloads.at(-1)).toMatchObject({ task_id: "system.sync_logs", enabled: true }); await page.getByTestId("cron-task-interval-economic.transfer_queue").fill("120"); await page.getByTestId("cron-task-save-economic.transfer_queue").click(); expect(patchPayloads.at(-1)).toMatchObject({ task_id: "economic.transfer_queue", schedule: { type: "interval", seconds: 120 }, }); }); test("superusers can deploy the cron worker to Coolify when no deployment target exists", async ({ page }) => { const deployPayloads: Array> = []; let deploymentTarget: Record | null = null; await page.route(apiPathPattern("/superuser/cron"), async (route) => { if (route.request().method() !== "GET") { await route.fallback(); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(cronListPayload()), }); }); await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: { runs: [] }, meta: {}, includes: {}, }), }); }); await page.route(apiPathPattern("/superuser/cron/workers"), async (route) => { if (route.request().method() !== "GET") { await route.fallback(); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: { state: deploymentTarget ? "waiting_for_heartbeat" : "needs_deploy", desired_workers: 1, channel: { id: 1, slug: "stable", name: "Stable", }, channels: [ { id: 1, slug: "stable", name: "Stable", default_channel: true, }, ], api_target: { id: 17, app: "api", }, cron_target: deploymentTarget, workers: [], summary: { total: 0, running: 0, stale: 0, desired: 1, state: deploymentTarget ? "waiting_for_heartbeat" : "needs_deploy", }, latest_deployment: deploymentTarget ? { id: 72, app: "cron", status: "deployed", provider_operation_id: "deploy-72", started_at: "2026-07-09 12:01:00", } : null, recent_deployments: deploymentTarget ? [ { id: 72, app: "cron", status: "deployed", provider_operation_id: "deploy-72", started_at: "2026-07-09 12:01:00", }, ] : [], issues: [ deploymentTarget ? { code: "waiting_for_first_heartbeat", severity: "info", message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.", } : { code: "missing_cron_target", severity: "warning", message: "No cron worker Coolify target exists for this release channel.", }, ], deployment: { ok: true, state: deploymentTarget ? "waiting_for_heartbeat" : "needs_deploy", channel: { id: 1, slug: "stable", name: "Stable", }, api_target: { id: 17, app: "api", }, target: deploymentTarget, latest_deployment: deploymentTarget ? { id: 72, app: "cron", status: "deployed", provider_operation_id: "deploy-72", started_at: "2026-07-09 12:01:00", } : null, }, }, meta: {}, includes: {}, }), }); }); await page.route(apiPathPattern("/superuser/cron/workers/deploy"), async (route) => { deployPayloads.push(route.request().postDataJSON() as Record); deploymentTarget = { id: 71, app: "cron", coolify_service_uuid: "cron-worker-uuid", auto_deploy: false, }; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: { ok: true, deployment: { id: 72, app: "cron", status: "deployed", provider_operation_id: "deploy-72", }, target: deploymentTarget, worker_status: { state: "waiting_for_heartbeat", channel: { id: 1, slug: "stable", name: "Stable", }, channels: [ { id: 1, slug: "stable", name: "Stable", default_channel: true, }, ], cron_target: deploymentTarget, workers: [], summary: { total: 0, running: 0, stale: 0, desired: 1, state: "waiting_for_heartbeat", }, deployment: { state: "waiting_for_heartbeat", target: deploymentTarget, }, latest_deployment: { id: 72, status: "deployed", provider_operation_id: "deploy-72", }, issues: [ { code: "waiting_for_first_heartbeat", severity: "info", message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.", }, ], }, }, meta: {}, includes: {}, }), }); }); await page.goto("/superuser/system/cron", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Deploy to Coolify"); await Promise.all([ page.waitForResponse( (response) => response.url().includes("/superuser/cron/workers/deploy") && response.request().method() === "POST" ), page.getByTestId("cron-worker-deploy").click(), ]); expect(deployPayloads).toEqual([{ channel_id: 1 }]); await expect(page.getByTestId("cron-run-queued")).toContainText("deployment was queued"); await expect(page.getByTestId("cron-worker-state")).toContainText("Waiting for heartbeat"); await expect(page.getByTestId("cron-worker-issues")).toContainText("waiting for the worker"); await expect(page.getByTestId("cron-worker-target")).toContainText("cron-worker-uuid"); await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Update Coolify deployment"); }); test("repairs an orphaned Coolify cron worker target", async ({ page }) => { const deployPayloads: Array> = []; let repaired = false; const oldTarget = { id: 71, app: "cron", coolify_service_uuid: "missing-cron-worker-uuid", auto_deploy: false, }; const repairedTarget = { id: 71, app: "cron", coolify_service_uuid: "cron-worker-uuid", auto_deploy: false, }; await page.route(apiPathPattern("/superuser/cron"), async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(cronListPayload()), }); }); await page.route(apiPathPattern("/superuser/cron/runs"), async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: { runs: [] }, meta: {}, includes: {}, }), }); }); await page.route(apiPathPattern("/superuser/cron/workers"), async (route) => { const target = repaired ? repairedTarget : oldTarget; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ success: true, data: { state: repaired ? "waiting_for_heartbeat" : "failed", desired_workers: 1, channel: { id: 1, slug: "stable", name: "Stable", }, channels: [ { id: 1, slug: "stable", name: "Stable", default_channel: true, }, ], api_target: null, cron_target: target, workers: [], summary: { total: 0, running: 0, stale: 0, desired: 1, state: repaired ? "waiting_for_heartbeat" : "failed", }, latest_deployment: repaired ? { id: 73, app: "cron", status: "deployed", provider_operation_id: "deploy-73", } : null, recent_deployments: [], provider: { configured: true, resource_uuid: target.coolify_service_uuid, resource_type: "application", checked: true, missing: !repaired, resource: repaired ? { ok: true, uuid: "cron-worker-uuid", name: "release-stable-cron-worker", } : { ok: false, missing: true, error: "Coolify API request failed: HTTP 404", }, }, issues: repaired ? [ { code: "waiting_for_first_heartbeat", severity: "info", message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.", }, ] : [ { code: "no_worker_heartbeat", severity: "danger", message: "Cron worker target exists, but no worker heartbeat has been recorded.", }, { code: "missing_coolify_worker_resource", severity: "danger", message: "The stored Coolify cron worker resource was not found and must be recreated.", }, { code: "repairable_cron_target", severity: "info", message: "No API target is configured, but the cron target has enough deployment context to repair itself.", }, ], deployment: { ok: true, state: repaired ? "waiting_for_heartbeat" : "failed", channel: { id: 1, slug: "stable", name: "Stable", }, api_target: null, target, latest_deployment: repaired ? { id: 73, app: "cron", status: "deployed", provider_operation_id: "deploy-73", } : null, provider: { missing: !repaired, }, action: repaired ? "update" : "repair", can_deploy: true, issues: [], }, }, meta: {}, includes: {}, }), }); }); await page.route(apiPathPattern("/superuser/cron/workers/deploy"), async (route) => { deployPayloads.push(route.request().postDataJSON() as Record); repaired = true; await route.fulfill({ status: 202, contentType: "application/json", body: JSON.stringify({ success: true, data: { ok: true, deployment: { id: 73, app: "cron", status: "deployed", provider_operation_id: "deploy-73", }, target: repairedTarget, worker_status: { state: "waiting_for_heartbeat", channel: { id: 1, slug: "stable", name: "Stable", }, channels: [ { id: 1, slug: "stable", name: "Stable", default_channel: true, }, ], api_target: null, cron_target: repairedTarget, workers: [], summary: { total: 0, running: 0, stale: 0, desired: 1, state: "waiting_for_heartbeat", }, deployment: { state: "waiting_for_heartbeat", target: repairedTarget, action: "update", can_deploy: true, }, latest_deployment: { id: 73, status: "deployed", provider_operation_id: "deploy-73", }, issues: [ { code: "waiting_for_first_heartbeat", severity: "info", message: "Coolify accepted the deployment; waiting for the worker to write its first heartbeat.", }, ], }, }, meta: {}, includes: {}, }), }); }); await page.goto("/superuser/system/cron", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("cron-worker-api-target")).toContainText("--"); await expect(page.getByTestId("cron-worker-target")).toContainText("missing-cron-worker-uuid"); await expect(page.getByTestId("cron-worker-issues")).toContainText("must be recreated"); await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Repair Coolify deployment"); await Promise.all([ page.waitForResponse( (response) => response.url().includes("/superuser/cron/workers/deploy") && response.request().method() === "POST" ), page.getByTestId("cron-worker-deploy").click(), ]); expect(deployPayloads).toEqual([{ channel_id: 1 }]); await expect(page.getByTestId("cron-worker-state")).toContainText("Waiting for heartbeat"); await expect(page.getByTestId("cron-worker-target")).toContainText("cron-worker-uuid"); await expect(page.getByTestId("cron-worker-deploy")).toHaveText("Update Coolify deployment"); }); });