Files
pleno-vue/tests/e2e/superuser-cron.spec.ts
T

857 lines
27 KiB
TypeScript

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<Record<string, unknown>> = [];
const runPayloads: Array<Record<string, unknown>> = [];
const deployPayloads: Array<Record<string, unknown>> = [];
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<string, unknown>);
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<string, unknown>);
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<string, unknown>;
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<Record<string, unknown>> = [];
let deploymentTarget: Record<string, unknown> | 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<string, unknown>);
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<Record<string, unknown>> = [];
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<string, unknown>);
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");
});
});