2836 lines
112 KiB
JavaScript
2836 lines
112 KiB
JavaScript
import { expect, test } from "@playwright/test";
|
|
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
|
import { isDesktopProject } from "./support/projects";
|
|
|
|
test.describe.configure({ mode: "serial", timeout: 180_000 });
|
|
|
|
const DEFAULT_RELEASE_BRANCH = "master";
|
|
|
|
const permissions = [
|
|
"superuser",
|
|
"user",
|
|
"superuser_release_manager_view",
|
|
"superuser_release_manager_manage",
|
|
"superuser_release_manager_deploy",
|
|
"superuser_release_manager_rollback",
|
|
"superuser_release_manager_replay",
|
|
];
|
|
|
|
function json(body, status = 200) {
|
|
return {
|
|
status,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(body),
|
|
};
|
|
}
|
|
|
|
function createReleaseState() {
|
|
return {
|
|
nextChannelId: 4,
|
|
nextAssignmentId: 1,
|
|
nextTargetId: 2,
|
|
nextDeploymentId: 3,
|
|
nextServiceSetId: 2,
|
|
nextBundleId: 1,
|
|
nextCoolifyTargetId: 20,
|
|
assignmentPayloads: [],
|
|
bundlePayloads: [],
|
|
targetPayloads: [],
|
|
deploymentPayloads: [],
|
|
operations: [],
|
|
channels: [
|
|
{
|
|
id: 1,
|
|
slug: "stable",
|
|
name: "Stable",
|
|
description: "Default production channel",
|
|
enabled: true,
|
|
default_channel: true,
|
|
rollout_percent: 100,
|
|
frontend_base_url: "https://app.example.test",
|
|
api_base_url: "https://api.example.test",
|
|
replay_enabled: false,
|
|
capture_level: "metadata",
|
|
retention_days: 14,
|
|
versions: {
|
|
frontend: { version_label: "frontend-2026.05.19", commit_sha: "abc123" },
|
|
api: { version_label: "api-2026.05.19", commit_sha: "def456" },
|
|
},
|
|
},
|
|
{
|
|
id: 2,
|
|
slug: "canary",
|
|
name: "Canary",
|
|
description: "Early release channel",
|
|
enabled: true,
|
|
default_channel: false,
|
|
rollout_percent: 5,
|
|
frontend_base_url: "https://canary.example.test",
|
|
api_base_url: "https://api-canary.example.test",
|
|
replay_enabled: true,
|
|
capture_level: "full_redacted",
|
|
retention_days: 7,
|
|
versions: {
|
|
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
|
|
api: null,
|
|
},
|
|
},
|
|
{
|
|
id: 3,
|
|
slug: "beta",
|
|
name: "Beta",
|
|
enabled: true,
|
|
default_channel: false,
|
|
rollout_percent: 0,
|
|
replay_enabled: false,
|
|
capture_level: "metadata",
|
|
retention_days: 14,
|
|
versions: {},
|
|
},
|
|
],
|
|
assignments: [],
|
|
targets: [
|
|
{
|
|
id: 1,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "frontend",
|
|
repository: "truckwash/front-end-vue",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
coolify_instance_id: 3,
|
|
coolify_instance_label: "Production Coolify",
|
|
coolify_service_uuid: "service-canary",
|
|
health_url: "https://canary.example.test/health",
|
|
auto_deploy: true,
|
|
deploy_context: {
|
|
coolify_public_url: "https://canary.example.test",
|
|
coolify_domain: "canary.example.test",
|
|
coolify_port: "443",
|
|
coolify_project_uuid: "project-main",
|
|
coolify_environment_name: "canary",
|
|
coolify_server_uuid: "server-main",
|
|
},
|
|
},
|
|
],
|
|
serviceSets: [
|
|
{
|
|
id: 1,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
name: "Canary shared stack",
|
|
slug: "canary-shared-stack",
|
|
mode: "attach_existing",
|
|
status: "ready",
|
|
targets: {
|
|
frontend: {
|
|
id: 1,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "frontend",
|
|
repository: "truckwash/front-end-vue",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
coolify_instance_id: 3,
|
|
coolify_instance_label: "Production Coolify",
|
|
coolify_service_uuid: "service-canary",
|
|
health_url: "https://canary.example.test/health",
|
|
deploy_context: {
|
|
coolify_public_url: "https://canary.example.test",
|
|
coolify_domain: "canary.example.test",
|
|
coolify_port: "443",
|
|
coolify_project_uuid: "project-main",
|
|
coolify_environment_name: "canary",
|
|
coolify_server_uuid: "server-main",
|
|
},
|
|
},
|
|
api: null,
|
|
},
|
|
data_services: {
|
|
database: {
|
|
id: 10,
|
|
kind: "database",
|
|
label: "Canary MariaDB",
|
|
resource_name: "mariadb-canary",
|
|
resource_uuid: "mariadb-canary-service",
|
|
instance_label: "Production Coolify",
|
|
deployment_status: "running",
|
|
replication: {
|
|
id: 100,
|
|
label: "mariadb-canary",
|
|
status: "ok",
|
|
role: "replica",
|
|
host: "db-canary.example.test",
|
|
port: 3306,
|
|
},
|
|
},
|
|
redis: {
|
|
id: 11,
|
|
kind: "redis",
|
|
label: "Canary Redis",
|
|
resource_name: "redis-canary",
|
|
resource_uuid: "redis-canary-service",
|
|
instance_label: "Production Coolify",
|
|
deployment_status: "running",
|
|
replication: {
|
|
id: 101,
|
|
label: "redis-canary",
|
|
status: "ok",
|
|
role: "replica",
|
|
host: "redis-canary.example.test",
|
|
port: 6379,
|
|
},
|
|
},
|
|
minio: {
|
|
id: 12,
|
|
kind: "minio",
|
|
label: "Canary MinIO",
|
|
resource_name: "minio-canary",
|
|
resource_uuid: "minio-canary-service",
|
|
instance_label: "Production Coolify",
|
|
deployment_status: "running",
|
|
replication: {
|
|
id: 102,
|
|
label: "minio-canary",
|
|
status: "ok",
|
|
role: "replica",
|
|
host: "minio-canary.example.test",
|
|
port: 9000,
|
|
},
|
|
},
|
|
},
|
|
attached_bundles: [],
|
|
},
|
|
],
|
|
bundles: [],
|
|
deployments: [
|
|
{
|
|
id: 2,
|
|
target_id: 1,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "api",
|
|
repository: "copenhagentruckwash/api",
|
|
branch: "master",
|
|
commit_sha: "ab31cd6dbb288606a24fcdbcb7bb7d58d843e4f3",
|
|
version_label: "internal-api-2026-05-19",
|
|
status: "failed",
|
|
error_message: "Composer install failed in the Coolify build.",
|
|
failure_summary: {
|
|
category: "configuration",
|
|
stage: "provider_configuration",
|
|
root_cause: "Composer install failed in the Coolify build.",
|
|
next_action: "Run the PHP test suite in the php1 container before retrying the deployment.",
|
|
promotion_blocked: true,
|
|
},
|
|
promotable: false,
|
|
promotion_blocked_reason:
|
|
"Deployment failed and cannot be promoted. Cause: Composer install failed in the Coolify build.",
|
|
started_at: "2026-05-19T08:05:00.000Z",
|
|
},
|
|
{
|
|
id: 1,
|
|
target_id: 1,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "frontend",
|
|
repository: "truckwash/front-end-vue",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
commit_sha: "c0ffee",
|
|
version_label: "frontend-canary",
|
|
status: "deployed",
|
|
promotable: true,
|
|
started_at: "2026-05-19T08:00:00.000Z",
|
|
},
|
|
],
|
|
replayTargets: [],
|
|
timelineSessions: [
|
|
{
|
|
id: 7,
|
|
trace_id: "trace-canary-1",
|
|
principal_type: "user",
|
|
principal_id: "42",
|
|
customer_number: 424242,
|
|
user: {
|
|
type: "user",
|
|
id: "42",
|
|
customer_number: 424242,
|
|
name: "Acme Dispatcher",
|
|
email: "dispatcher@example.test",
|
|
label: "Acme Dispatcher",
|
|
},
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
release: {
|
|
frontend: { version_label: "frontend-canary", commit_sha: "c0ffee" },
|
|
api: { version_label: "api-canary", commit_sha: "def456" },
|
|
},
|
|
device: {
|
|
type: "desktop",
|
|
browser_name: "Chrome",
|
|
browser_version: "125.0.0",
|
|
os_name: "Windows",
|
|
os_version: "11",
|
|
viewport_width: 1440,
|
|
viewport_height: 900,
|
|
device_pixel_ratio: 1,
|
|
user_agent: "Mozilla/5.0 Chrome/125.0.0",
|
|
},
|
|
last_route_path: "/superuser/configuration/releases",
|
|
event_count: 1,
|
|
error_count: 1,
|
|
error_report_count: 1,
|
|
module_keys: ["requestqueue"],
|
|
first_event_at: "2026-05-19T08:10:00.000Z",
|
|
last_event_at: "2026-05-19T08:10:00.000Z",
|
|
created_at: "2026-05-19T08:09:00.000Z",
|
|
last_seen_at: "2026-05-19T08:10:00.000Z",
|
|
},
|
|
],
|
|
timelineEvents: [
|
|
{
|
|
id: 99,
|
|
trace_id: "trace-canary-1",
|
|
event_type: "request_failed",
|
|
severity: "error",
|
|
channel_slug: "canary",
|
|
module_key: "requestqueue",
|
|
route_path: "/superuser/configuration/releases",
|
|
component: null,
|
|
occurred_at: "2026-05-19T08:10:00.000Z",
|
|
payload: {
|
|
request: { url: "/superuser/releases", headers: { Authorization: "[redacted]" } },
|
|
response: { status: 500 },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
}
|
|
|
|
function releaseStatusService(label, service_key, overrides = {}) {
|
|
return {
|
|
service_key,
|
|
label,
|
|
status: "ready",
|
|
state: "ready",
|
|
severity: "ok",
|
|
message: `${label} release service is ready.`,
|
|
next_action: "",
|
|
target_tab: "overview",
|
|
issue_type: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function releaseStatusIssue(channel, service, overrides = {}) {
|
|
const issue = {
|
|
severity: "critical",
|
|
type: "missing_value",
|
|
channel_id: channel.id,
|
|
channel_slug: channel.slug,
|
|
service_key: service,
|
|
label: service ? releaseStatusServiceLabel(service) : "Release bundle",
|
|
message: "",
|
|
next_action: "",
|
|
target_tab: "overview",
|
|
target_id: null,
|
|
deployment_id: null,
|
|
coolify_target_id: null,
|
|
missing_key: null,
|
|
...overrides,
|
|
};
|
|
issue.key = releaseStatusIssueKey(issue);
|
|
issue.impact =
|
|
issue.impact ||
|
|
(issue.type === "failed_deployment"
|
|
? "This channel cannot be promoted until the failed deployment is replaced by a successful one."
|
|
: "This channel is incomplete and cannot receive traffic safely.");
|
|
issue.resolution_state = issue.resolution_state || "blocked";
|
|
issue.actions = Array.isArray(issue.actions) ? issue.actions : releaseStatusIssueActions(issue);
|
|
return issue;
|
|
}
|
|
|
|
function releaseStatusIssueKey(issue) {
|
|
return [
|
|
issue.type || "",
|
|
issue.channel_id || issue.channel_slug || "",
|
|
issue.service_key || "",
|
|
issue.missing_key || "",
|
|
issue.deployment_id || "",
|
|
issue.coolify_target_id || "",
|
|
].join(":");
|
|
}
|
|
|
|
function releaseStatusIssueActions(issue) {
|
|
if (issue.type === "failed_deployment") {
|
|
return [
|
|
{
|
|
id: "retry_deployment",
|
|
label: "Retry deployment",
|
|
kind: "mutation",
|
|
requires_confirmation: true,
|
|
requires_input: false,
|
|
disabled_reason: "",
|
|
permission: "superuser_release_manager_deploy",
|
|
},
|
|
];
|
|
}
|
|
if (issue.type === "missing_value" && ["frontend_version", "api_version"].includes(issue.missing_key)) {
|
|
return [
|
|
{
|
|
id: "deploy_missing_version",
|
|
label: "Deploy missing version",
|
|
kind: "mutation",
|
|
requires_confirmation: true,
|
|
requires_input: false,
|
|
disabled_reason: "",
|
|
permission: "superuser_release_manager_deploy",
|
|
},
|
|
];
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function releaseStatusServiceLabel(service) {
|
|
return (
|
|
{
|
|
frontend: "Frontend",
|
|
api: "API",
|
|
database: "Database",
|
|
redis: "Redis",
|
|
minio: "MinIO",
|
|
}[service] || service
|
|
);
|
|
}
|
|
|
|
function releaseStatusOverviewFixture(state) {
|
|
const channels = state.channels.map((channel) => {
|
|
const latestDeployments = state.deployments.filter(
|
|
(deployment) => Number(deployment.channel_id) === Number(channel.id)
|
|
);
|
|
const latestApi = latestDeployments.find((deployment) => deployment.app === "api");
|
|
const failedApi = latestApi?.status === "failed" ? latestApi : null;
|
|
const missingValues =
|
|
channel.slug === "beta"
|
|
? [
|
|
{ key: "frontend_version", label: "Frontend version", service_key: "frontend", target_tab: "deployments" },
|
|
{ key: "frontend_base_url", label: "Frontend URL", service_key: "frontend", target_tab: "integrations" },
|
|
{ key: "api_version", label: "API version", service_key: "api", target_tab: "deployments" },
|
|
{ key: "api_base_url", label: "API URL", service_key: "api", target_tab: "integrations" },
|
|
]
|
|
: [];
|
|
const services = [
|
|
releaseStatusService("Frontend", "frontend", {
|
|
version_label: channel.versions?.frontend?.version_label || null,
|
|
deployment_id: latestDeployments.find((deployment) => deployment.app === "frontend")?.id || null,
|
|
}),
|
|
failedApi
|
|
? releaseStatusService("API", "api", {
|
|
status: "failed",
|
|
state: "failed",
|
|
severity: "critical",
|
|
issue_type: "failed_deployment",
|
|
message: failedApi.failure_summary.root_cause,
|
|
next_action: failedApi.failure_summary.next_action,
|
|
target_tab: "deployments",
|
|
deployment_id: failedApi.id,
|
|
})
|
|
: releaseStatusService("API", "api", {
|
|
version_label: channel.versions?.api?.version_label || null,
|
|
deployment_id: latestDeployments.find((deployment) => deployment.app === "api")?.id || null,
|
|
}),
|
|
releaseStatusService("Database", "database"),
|
|
releaseStatusService("Redis", "redis"),
|
|
releaseStatusService("MinIO", "minio"),
|
|
];
|
|
if (channel.slug === "beta") {
|
|
services[0] = releaseStatusService("Frontend", "frontend", {
|
|
status: "missing",
|
|
state: "missing_value",
|
|
severity: "critical",
|
|
issue_type: "missing_value",
|
|
message: "Frontend is missing Frontend version.",
|
|
next_action: "Deploy the missing frontend version.",
|
|
target_tab: "deployments",
|
|
missing_key: "frontend_version",
|
|
});
|
|
services[1] = releaseStatusService("API", "api", {
|
|
status: "missing",
|
|
state: "missing_value",
|
|
severity: "critical",
|
|
issue_type: "missing_value",
|
|
message: "API is missing API version.",
|
|
next_action: "Deploy the missing API version.",
|
|
target_tab: "deployments",
|
|
missing_key: "api_version",
|
|
});
|
|
}
|
|
const serviceIssues = services
|
|
.filter((service) => service.severity !== "ok" && service.issue_type)
|
|
.map((service) =>
|
|
releaseStatusIssue(channel, service.service_key, {
|
|
severity: service.severity,
|
|
type: service.issue_type,
|
|
label: service.label,
|
|
message: service.message,
|
|
next_action: service.next_action,
|
|
target_tab: service.target_tab,
|
|
deployment_id: service.deployment_id,
|
|
missing_key: service.missing_key,
|
|
})
|
|
);
|
|
const missingIssues = missingValues.map((missing) =>
|
|
releaseStatusIssue(channel, missing.service_key, {
|
|
type: "missing_value",
|
|
label: missing.label,
|
|
message: `${channel.name} is missing ${missing.label}.`,
|
|
next_action: `Complete ${missing.label}.`,
|
|
target_tab: missing.target_tab,
|
|
missing_key: missing.key,
|
|
})
|
|
);
|
|
const issues = [...missingIssues, ...serviceIssues];
|
|
const critical = issues.some((issue) => issue.severity === "critical");
|
|
|
|
return {
|
|
channel_id: channel.id,
|
|
channel_slug: channel.slug,
|
|
channel_name: channel.name,
|
|
default_channel: Boolean(channel.default_channel),
|
|
enabled: channel.enabled !== false,
|
|
severity: critical ? "critical" : "ok",
|
|
readiness: critical ? "blocked" : "ready",
|
|
message: critical
|
|
? `${issues.length} issues need attention before promotion.`
|
|
: "All release services are ready.",
|
|
availability: {
|
|
configured: missingValues.length === 0,
|
|
missing: missingValues.map((missing) => missing.key),
|
|
status: missingValues.length === 0 ? "ready" : "unconfigured",
|
|
},
|
|
missing_values: missingValues,
|
|
services,
|
|
versions: {
|
|
...channel.versions,
|
|
bundle_id: channel.slug === "stable" ? 1 : channel.versions?.bundle_id,
|
|
},
|
|
replay: {
|
|
enabled: Boolean(channel.replay_enabled),
|
|
capture_level: channel.capture_level,
|
|
},
|
|
latest_deployments: latestDeployments,
|
|
issues,
|
|
};
|
|
});
|
|
const issues = channels
|
|
.flatMap((channel) => channel.issues)
|
|
.sort((a, b) =>
|
|
a.severity === b.severity ? a.channel_slug.localeCompare(b.channel_slug) : a.severity === "critical" ? -1 : 1
|
|
);
|
|
const affectedChannels = new Set(issues.map((issue) => issue.channel_slug));
|
|
|
|
return {
|
|
generated_at: "2026-05-20T10:00:00.000Z",
|
|
state: issues.length > 0 ? "blocked" : "ready",
|
|
totals: {
|
|
channels: channels.length,
|
|
ready_channels: channels.filter((channel) => channel.readiness === "ready").length,
|
|
affected_channels: affectedChannels.size,
|
|
issues: issues.length,
|
|
critical: issues.filter((issue) => issue.severity === "critical").length,
|
|
warning: issues.filter((issue) => issue.severity === "warning").length,
|
|
services: channels.length * 5,
|
|
unhealthy_services: channels.reduce(
|
|
(count, channel) => count + channel.services.filter((service) => service.severity !== "ok").length,
|
|
0
|
|
),
|
|
missing_values: issues.filter((issue) => issue.type === "missing_value").length,
|
|
},
|
|
issues,
|
|
channels,
|
|
};
|
|
}
|
|
|
|
function summary(state) {
|
|
return {
|
|
generated_at: "2026-05-20T10:00:00.000Z",
|
|
channels: state.channels,
|
|
assignments: state.assignments,
|
|
deployment_targets: state.targets,
|
|
service_sets: state.serviceSets.map((set) => ({
|
|
...set,
|
|
active: Boolean(set.active),
|
|
stack: {
|
|
frontend: set.targets?.frontend || null,
|
|
api: set.targets?.api || null,
|
|
database: set.data_services?.database || null,
|
|
redis: set.data_services?.redis || null,
|
|
minio: set.data_services?.minio || null,
|
|
},
|
|
attached_bundle_count: state.bundles.filter((bundle) => Number(bundle.service_set_id) === Number(set.id)).length,
|
|
})),
|
|
bundles: state.bundles,
|
|
deployments: state.deployments,
|
|
operations: state.operations,
|
|
data_services: Object.fromEntries(
|
|
state.channels.map((channel) => [
|
|
channel.slug,
|
|
{
|
|
channel_id: channel.id,
|
|
channel_slug: channel.slug,
|
|
mode: "production_shared",
|
|
policy: { mode: "production_shared", normal_sync_changes_service: false },
|
|
services: {
|
|
database: {
|
|
kind: "database",
|
|
mode: "production_shared",
|
|
state: "online",
|
|
target: {
|
|
instance_label: "node-1",
|
|
deployment_status: "running",
|
|
replication: {
|
|
label: `${channel.slug}-mariadb`,
|
|
host: `${channel.slug}-db.truckwash.internal`,
|
|
port: 3306,
|
|
status: "ok",
|
|
role: "primary",
|
|
lag_seconds: 0,
|
|
last_checked_at: "2026-05-20T10:00:00.000Z",
|
|
uptime_seconds: 86400,
|
|
},
|
|
},
|
|
},
|
|
redis: {
|
|
kind: "redis",
|
|
mode: "production_shared",
|
|
state: "online",
|
|
target: {
|
|
instance_label: "node-1",
|
|
deployment_status: "running",
|
|
replication: {
|
|
label: `${channel.slug}-redis`,
|
|
host: `${channel.slug}-redis.truckwash.internal`,
|
|
port: 6379,
|
|
status: "ok",
|
|
role: "primary",
|
|
lag_seconds: 0,
|
|
last_checked_at: "2026-05-20T10:00:00.000Z",
|
|
uptime_seconds: 86400,
|
|
},
|
|
},
|
|
},
|
|
minio: {
|
|
kind: "minio",
|
|
mode: "production_shared",
|
|
state: "online",
|
|
target: {
|
|
instance_label: "node-1",
|
|
deployment_status: "running",
|
|
replication: {
|
|
label: `${channel.slug}-minio`,
|
|
host: `${channel.slug}-minio.truckwash.internal`,
|
|
port: 9000,
|
|
status: "ok",
|
|
role: "primary",
|
|
lag_seconds: 0,
|
|
last_checked_at: "2026-05-20T10:00:00.000Z",
|
|
uptime_seconds: 86400,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
])
|
|
),
|
|
replication_policy: {
|
|
default_mode: "production_shared",
|
|
normal_sync_changes_data_services: false,
|
|
service_kinds: ["database", "redis", "minio"],
|
|
},
|
|
coolify: {
|
|
integrated: true,
|
|
deployment_targets: state.targets.length,
|
|
instances: 1,
|
|
},
|
|
failover: {
|
|
integrated: true,
|
|
replication_hosts: 3,
|
|
default_data_mode: "production_shared",
|
|
},
|
|
replay_targets: state.replayTargets,
|
|
module_health: [
|
|
{
|
|
id: 1,
|
|
module_key: "requestqueue",
|
|
status: "ok",
|
|
checked_at: "2026-05-19T08:11:00.000Z",
|
|
},
|
|
],
|
|
timeline: {
|
|
sessions: state.timelineSessions.length,
|
|
events: state.timelineEvents.length,
|
|
},
|
|
module_keys: ["requestqueue", "frontend", "auth", "coolify"],
|
|
suggestions: {
|
|
repositories: ["truckwash/front-end-vue", "truckwash/backend-php"],
|
|
branches: [DEFAULT_RELEASE_BRANCH, "beta", "canary", "internal", "release/canary", "release/beta"],
|
|
frontend_base_urls: ["http://localhost:5173", "https://canary.example.test", "https://app.example.test"],
|
|
api_base_urls: ["http://api.truckwash.io:4433", "https://api-canary.example.test", "https://api.example.test"],
|
|
health_urls: [
|
|
"http://localhost:5173/health",
|
|
"http://api.truckwash.io:4433/ping",
|
|
"https://canary.example.test/health",
|
|
"https://api-canary.example.test/ping",
|
|
],
|
|
load_balancer_domains: ["api-v2.truckwash.io", "release-api.truckwash.io"],
|
|
coolify_instances: [{ id: 3, label: "Production Coolify", status: "ok" }],
|
|
coolify_projects: [
|
|
{
|
|
instance_id: 3,
|
|
instance_label: "Production Coolify",
|
|
uuid: "project-main",
|
|
name: "Production Project",
|
|
description: "Default production project",
|
|
default: true,
|
|
},
|
|
{
|
|
instance_id: 3,
|
|
instance_label: "Production Coolify",
|
|
uuid: "project-internal",
|
|
name: "Internal Project",
|
|
description: "Internal validation project",
|
|
default: false,
|
|
},
|
|
],
|
|
coolify_services: [
|
|
{
|
|
instance_id: 3,
|
|
instance_label: "Production Coolify",
|
|
uuid: "api-canary-service",
|
|
name: "API Canary",
|
|
status: "running",
|
|
urls: ["https://api-canary.example.test"],
|
|
},
|
|
],
|
|
coolify_github_apps: [
|
|
{
|
|
instance_id: 3,
|
|
instance_label: "Production Coolify",
|
|
uuid: "github-app-copenhagentruckwash-github",
|
|
name: "copenhagentruckwash-github",
|
|
organization: "truckwash",
|
|
type: "github",
|
|
},
|
|
],
|
|
coolify_service_uuids: ["service-canary", "api-canary-service"],
|
|
channel_presets: [
|
|
{ slug: "stable", name: "Stable", rollout_percent: 100, description: "Default production channel" },
|
|
{ slug: "canary", name: "Canary", rollout_percent: 5, description: "Early release channel" },
|
|
{ slug: "internal", name: "Internal", rollout_percent: 0, description: "Staff-only channel" },
|
|
],
|
|
target_presets: [
|
|
{
|
|
label: "Frontend target",
|
|
app: "frontend",
|
|
repository: "truckwash/front-end-vue",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
health_url: "https://canary.example.test/health",
|
|
auto_deploy: true,
|
|
},
|
|
{
|
|
label: "API target",
|
|
app: "api",
|
|
repository: "truckwash/backend-php",
|
|
branch: "canary",
|
|
health_url: "https://api-canary.example.test/ping",
|
|
auto_deploy: true,
|
|
},
|
|
],
|
|
},
|
|
status_overview: releaseStatusOverviewFixture(state),
|
|
};
|
|
}
|
|
|
|
function channelSlug(state, id) {
|
|
return state.channels.find((channel) => Number(channel.id) === Number(id))?.slug || "stable";
|
|
}
|
|
|
|
function endpointForTargetFixture(target, slug = target.channel_slug || "stable") {
|
|
const context = target.deploy_context || {};
|
|
if (context.endpoint_mode === "manual") {
|
|
const host = String(context.manual_endpoint_host || "").trim();
|
|
const port = Number(context.manual_endpoint_port || 0) || null;
|
|
return {
|
|
mode: "manual",
|
|
status: host ? "resolved" : "pending",
|
|
host: host || null,
|
|
port,
|
|
url: host ? `https://${host}${port && port !== 443 ? `:${port}` : ""}` : null,
|
|
source: "manual",
|
|
message: host ? "Manual endpoint override is configured." : "Manual endpoint mode needs a public host.",
|
|
};
|
|
}
|
|
const publicUrl = String(context.coolify_public_url || target.health_url || "").trim();
|
|
if (publicUrl) {
|
|
const parsed = new URL(publicUrl, "https://fallback.test");
|
|
return {
|
|
mode: "auto",
|
|
status: "resolved",
|
|
host: parsed.hostname,
|
|
port: Number(parsed.port || (parsed.protocol === "http:" ? 80 : 443)),
|
|
url: publicUrl.replace(/\/(health|ping)$/i, "").replace(/\/$/, ""),
|
|
source: context.coolify_public_url ? "coolify_public_url" : "health_url",
|
|
message: "Automatic endpoint resolved from configured URL.",
|
|
};
|
|
}
|
|
return {
|
|
mode: "auto",
|
|
status: "pending",
|
|
host: "api-v2.truckwash.io",
|
|
port: 443,
|
|
url: `https://api-v2.truckwash.io/${slug}/${target.app}`,
|
|
source: "auto_gateway",
|
|
message: "Automatic gateway endpoint will be used when Coolify routing is ready.",
|
|
};
|
|
}
|
|
|
|
function addInternalChannel(state) {
|
|
const channel = {
|
|
id: state.nextChannelId++,
|
|
slug: "internal",
|
|
name: "Internal",
|
|
description: "Staff-only channel",
|
|
enabled: true,
|
|
default_channel: false,
|
|
rollout_percent: 0,
|
|
replay_enabled: false,
|
|
capture_level: "metadata",
|
|
retention_days: 14,
|
|
versions: {},
|
|
};
|
|
state.channels.push(channel);
|
|
return channel;
|
|
}
|
|
|
|
function addDeployedBundle(state, overrides = {}) {
|
|
const serviceSet = state.serviceSets[0];
|
|
const channelId = Number(overrides.channel_id || serviceSet?.channel_id || 2);
|
|
const id = state.nextBundleId++;
|
|
const frontendCommit = {
|
|
sha: "c0ffee0000000000000000000000000000000000",
|
|
short_sha: "c0ffee000000",
|
|
message: "Frontend release build",
|
|
author_name: "Release Bot",
|
|
authored_at: "2026-05-19T08:00:00.000Z",
|
|
};
|
|
const apiCommit = {
|
|
sha: "feedface00000000000000000000000000000000",
|
|
short_sha: "feedface0000",
|
|
message: "Deploy canary build",
|
|
author_name: "Release Bot",
|
|
authored_at: "2026-05-19T08:10:00.000Z",
|
|
};
|
|
const bundle = {
|
|
id,
|
|
channel_id: channelId,
|
|
channel_slug: channelSlug(state, channelId),
|
|
service_set_id: serviceSet?.id || 1,
|
|
service_set_name: serviceSet?.name || "Service set",
|
|
version_label: overrides.version_label || "stable-bundle-2026-05-19",
|
|
status: "deployed",
|
|
apps: {
|
|
frontend: {
|
|
version_id: id * 10 + 1,
|
|
deployment_id: null,
|
|
repository: "truckwash/front-end-vue",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
commit_sha: "c0ffee",
|
|
commit: frontendCommit,
|
|
version: { metadata: { github_access: { commit: frontendCommit, latest_commit: frontendCommit } } },
|
|
},
|
|
api: {
|
|
version_id: id * 10 + 2,
|
|
deployment_id: null,
|
|
repository: "truckwash/backend-php",
|
|
branch: "canary",
|
|
commit_sha: "feedface",
|
|
commit: apiCommit,
|
|
version: { metadata: { github_access: { commit: apiCommit, latest_commit: apiCommit } } },
|
|
},
|
|
},
|
|
};
|
|
state.bundles.unshift(bundle);
|
|
return bundle;
|
|
}
|
|
|
|
function setActiveBundle(state, bundle) {
|
|
if (!bundle) return null;
|
|
for (const entry of state.bundles) {
|
|
if (
|
|
Number(entry.channel_id) === Number(bundle.channel_id) &&
|
|
Number(entry.id) !== Number(bundle.id) &&
|
|
entry.status === "promoted"
|
|
) {
|
|
entry.status = "superseded";
|
|
}
|
|
}
|
|
for (const set of state.serviceSets) {
|
|
set.active = Number(set.id) === Number(bundle.service_set_id);
|
|
}
|
|
bundle.status = "promoted";
|
|
const channel = state.channels.find((entry) => Number(entry.id) === Number(bundle.channel_id));
|
|
if (channel) {
|
|
channel.versions = {
|
|
frontend: {
|
|
version_label: bundle.version_label,
|
|
commit_sha: bundle.apps?.frontend?.commit_sha || "",
|
|
},
|
|
api: {
|
|
version_label: bundle.version_label,
|
|
commit_sha: bundle.apps?.api?.commit_sha || "",
|
|
},
|
|
bundle_id: bundle.id,
|
|
};
|
|
}
|
|
return channel;
|
|
}
|
|
|
|
function isolatedDataService(state, kind, stackName = "isolated-stack") {
|
|
const id = state.nextCoolifyTargetId++;
|
|
return {
|
|
id,
|
|
kind,
|
|
label: `release-${stackName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${kind}`,
|
|
role: "replica",
|
|
resource_name: `release-${stackName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${kind}-${id}`,
|
|
resource_uuid: `isolated-${kind}-${id}`,
|
|
deployment_status: "deploying",
|
|
availability_state: "degraded",
|
|
options: {
|
|
isolated_stack: true,
|
|
skip_replication_provisioning: true,
|
|
production_data_attached: false,
|
|
},
|
|
replication: {
|
|
id: id + 1000,
|
|
label: `release-${stackName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${kind}`,
|
|
status: "unknown",
|
|
role: "replica",
|
|
source_host_id: null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function completeIsolatedDataServices(state, serviceSet) {
|
|
serviceSet.data_services = serviceSet.data_services || {};
|
|
for (const kind of ["database", "redis", "minio"]) {
|
|
if (!serviceSet.data_services[kind]) {
|
|
serviceSet.data_services[kind] = isolatedDataService(state, kind, serviceSet.name || "isolated-stack");
|
|
}
|
|
}
|
|
serviceSet.status = "isolated_stack";
|
|
serviceSet.health = { status: "isolated_stack", stack_complete: true };
|
|
return serviceSet;
|
|
}
|
|
|
|
async function boot(page, state = createReleaseState(), options = {}) {
|
|
await page.addInitScript(() => {
|
|
window.localStorage.setItem("locale", "en");
|
|
});
|
|
await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: options.permissions || permissions,
|
|
sessionData: {
|
|
runtime_config: {
|
|
release: {
|
|
source: "local",
|
|
requested_source: "local",
|
|
trace_id: "trace-e2e",
|
|
channel: { slug: "stable", name: "Stable", default_channel: true },
|
|
api_base_url: "/api",
|
|
versions: {
|
|
frontend: { version_label: "frontend-2026.05.19", commit_sha: "abc123" },
|
|
api: { version_label: "api-2026.05.19", commit_sha: "def456" },
|
|
},
|
|
availability: { configured: true, missing: [], status: "ready" },
|
|
capture_policy: { enabled: true, capture_level: "metadata" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
await installReleaseMocks(page, state);
|
|
await seedAuthenticatedState(page, "release-manager-token");
|
|
return state;
|
|
}
|
|
|
|
async function installReleaseMocks(page, state) {
|
|
await page.route(/\/customers\?search=.*/i, async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const query = (url.searchParams.get("search") || "").toLowerCase();
|
|
const customers = [
|
|
{ id: 1, customerNumber: 424242, name: "Acme Transport" },
|
|
{ id: 2, customerNumber: 123456, name: "Nordic Logistics" },
|
|
].filter((customer) => {
|
|
return customer.name.toLowerCase().includes(query) || String(customer.customerNumber).includes(query);
|
|
});
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: customers,
|
|
meta: { pagination: { total: customers.length, count: customers.length, per_page: 10, current_page: 1 } },
|
|
})
|
|
);
|
|
});
|
|
|
|
await page.route(/\/superuser\/releases(?:\/.*)?(?:\?.*)?$/i, async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
const pathname = url.pathname;
|
|
const method = request.method();
|
|
|
|
if (pathname.endsWith("/superuser/releases") && method === "GET") {
|
|
if (typeof state.beforeSummaryResponse === "function") {
|
|
await state.beforeSummaryResponse();
|
|
}
|
|
await route.fulfill(json({ data: summary(state) }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/assignment-subjects") && method === "GET") {
|
|
const query = (url.searchParams.get("search") || "").toLowerCase();
|
|
state.assignmentSubjectRequests = state.assignmentSubjectRequests || [];
|
|
state.assignmentSubjectRequests.push(query);
|
|
const limit = Math.max(1, Math.min(10, Number(url.searchParams.get("limit") || 5)));
|
|
const subjects = [
|
|
{
|
|
subject_type: "user",
|
|
subject_id: "42",
|
|
label: "Acme Dispatcher - Customer #424242 / dispatcher@example.test",
|
|
title: "Acme Dispatcher",
|
|
description: "Customer #424242 / dispatcher@example.test",
|
|
icon: "fas fa-user",
|
|
source: "users",
|
|
},
|
|
{
|
|
subject_type: "subuser",
|
|
subject_id: "77",
|
|
label: "Driver Alex - @driveralex / driver@example.test",
|
|
title: "Driver Alex",
|
|
description: "@driveralex / driver@example.test",
|
|
icon: "fas fa-id-badge",
|
|
source: "subusers",
|
|
},
|
|
{
|
|
subject_type: "customer",
|
|
subject_id: "424242",
|
|
label: "Acme Transport - Customer #424242",
|
|
title: "Acme Transport",
|
|
description: "Customer #424242",
|
|
icon: "fas fa-building",
|
|
source: "customers",
|
|
},
|
|
].filter((subject) => {
|
|
if (!query) return true;
|
|
return [subject.subject_type, subject.subject_id, subject.label, subject.title, subject.description].some(
|
|
(value) =>
|
|
String(value || "")
|
|
.toLowerCase()
|
|
.includes(query)
|
|
);
|
|
});
|
|
|
|
await route.fulfill(json({ data: subjects.slice(0, limit) }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/config") && method === "GET") {
|
|
if (typeof state.beforeConfigResponse === "function") {
|
|
await state.beforeConfigResponse();
|
|
}
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
github_token_configured: true,
|
|
github_token_env_configured: false,
|
|
github_token_module_configured: true,
|
|
github_token_variable: "ReleaseManager.github_token",
|
|
github_token_env_variable: "RELEASE_MANAGER_GITHUB_TOKEN",
|
|
github_api_url: "https://api.github.com",
|
|
github_api_url_variable: "ReleaseManager.github_api_url",
|
|
github_webhook_secret_configured: true,
|
|
github_webhook_secret_variable: "ReleaseManager.github_webhook_secret",
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/config") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
github_token_configured: true,
|
|
github_token_env_configured: false,
|
|
github_token_module_configured: true,
|
|
github_token_variable: "ReleaseManager.github_token",
|
|
github_token_env_variable: "RELEASE_MANAGER_GITHUB_TOKEN",
|
|
github_api_url: payload.github_api_url || "https://api.github.com",
|
|
github_api_url_variable: "ReleaseManager.github_api_url",
|
|
github_webhook_secret_configured: true,
|
|
github_webhook_secret_variable: "ReleaseManager.github_webhook_secret",
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/github/repositories") && method === "GET") {
|
|
if (typeof state.beforeGithubRepositoriesResponse === "function") {
|
|
await state.beforeGithubRepositoriesResponse();
|
|
}
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
token_configured: true,
|
|
repositories: [
|
|
{
|
|
full_name: "truckwash/front-end-vue",
|
|
name: "front-end-vue",
|
|
private: true,
|
|
default_branch: DEFAULT_RELEASE_BRANCH,
|
|
description: "Truckwash dashboard frontend",
|
|
},
|
|
{
|
|
full_name: "truckwash/backend-php",
|
|
name: "backend-php",
|
|
private: true,
|
|
default_branch: "canary",
|
|
description: "Truckwash API",
|
|
},
|
|
],
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/github/branches") && method === "GET") {
|
|
const repository = url.searchParams.get("repository") || "truckwash/front-end-vue";
|
|
const branches = repository.includes("backend")
|
|
? [
|
|
{ name: "canary", commit_sha: "feedface", protected: false },
|
|
{ name: "release/canary", commit_sha: "feedface", protected: false },
|
|
{ name: "hotfix/api", commit_sha: "badc0de", protected: false },
|
|
]
|
|
: [
|
|
{ name: DEFAULT_RELEASE_BRANCH, commit_sha: "c0ffee", protected: true },
|
|
{ name: "feature/frontend-preview", commit_sha: "f00dbabe", protected: false },
|
|
];
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
token_configured: true,
|
|
repository,
|
|
branches,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/github/commits") && method === "GET") {
|
|
const repository = url.searchParams.get("repository") || "truckwash/front-end-vue";
|
|
const branch = url.searchParams.get("branch") || DEFAULT_RELEASE_BRANCH;
|
|
const commits = repository.includes("backend")
|
|
? [
|
|
{
|
|
sha: "feedface00000000000000000000000000000000",
|
|
short_sha: "feedface0000",
|
|
message: "Deploy canary build",
|
|
author_name: "Release Bot",
|
|
authored_at: "2026-05-19T08:10:00.000Z",
|
|
},
|
|
{
|
|
sha: "badc0de000000000000000000000000000000000",
|
|
short_sha: "badc0de00000",
|
|
message: "Previous API release",
|
|
author_name: "Release Bot",
|
|
authored_at: "2026-05-19T08:00:00.000Z",
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
sha: "f00dbabe00000000000000000000000000000000",
|
|
short_sha: "f00dbabe0000",
|
|
message: "Frontend preview",
|
|
author_name: "Release Bot",
|
|
authored_at: "2026-05-19T08:15:00.000Z",
|
|
},
|
|
{
|
|
sha: "c0ffee0000000000000000000000000000000000",
|
|
short_sha: "c0ffee000000",
|
|
message: "Previous frontend release",
|
|
author_name: "Release Bot",
|
|
authored_at: "2026-05-19T08:00:00.000Z",
|
|
},
|
|
];
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
token_configured: true,
|
|
repository,
|
|
branch,
|
|
commits,
|
|
latest: commits[0],
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/github/test") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
const latestSha = ["canary", "release/canary"].includes(payload.branch)
|
|
? "feedface00000000000000000000000000000000"
|
|
: "c0ffee";
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
ok: true,
|
|
status: "accessible",
|
|
token_configured: true,
|
|
message: "Repository and branch are accessible with the configured GitHub token.",
|
|
repository: payload.repository,
|
|
branch: payload.branch || DEFAULT_RELEASE_BRANCH,
|
|
commit_mode: payload.commit_mode || "latest",
|
|
commit_sha: payload.commit_mode === "specific" ? payload.commit_sha : latestSha,
|
|
latest_commit_sha: latestSha,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/channels") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
const channel = {
|
|
...payload,
|
|
id: state.nextChannelId++,
|
|
enabled: payload.enabled !== false,
|
|
default_channel: Boolean(payload.default_channel),
|
|
rollout_percent: Number(payload.rollout_percent || 0),
|
|
replay_enabled: Boolean(payload.replay_enabled),
|
|
retention_days: Number(payload.retention_days || 14),
|
|
versions: {},
|
|
};
|
|
state.channels.push(channel);
|
|
await route.fulfill(json({ data: channel }));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/channels\/\d+$/.test(pathname) && method === "PATCH") {
|
|
const id = Number(pathname.split("/").pop());
|
|
const payload = request.postDataJSON?.() || {};
|
|
const channel = state.channels.find((entry) => entry.id === id);
|
|
Object.assign(channel, payload);
|
|
await route.fulfill(json({ data: channel }));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/channels\/\d+\/rollback$/.test(pathname) && method === "POST") {
|
|
const channelId = Number(pathname.match(/channels\/(\d+)\/rollback/)?.[1] || 0);
|
|
const deployment = {
|
|
id: state.nextDeploymentId++,
|
|
channel_id: channelId,
|
|
channel_slug: channelSlug(state, channelId),
|
|
app: "api",
|
|
repository: "truckwash/backend-php",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
commit_sha: "rollback",
|
|
version_label: "rollback",
|
|
status: "rolled_back",
|
|
started_at: "2026-05-19T08:13:00.000Z",
|
|
};
|
|
state.deployments.unshift(deployment);
|
|
await route.fulfill(json({ data: deployment }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/operations") && method === "GET") {
|
|
if (typeof state.beforeOperationsResponse === "function") {
|
|
await state.beforeOperationsResponse();
|
|
}
|
|
await route.fulfill(json({ data: state.operations }));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/operations\/\d+$/.test(pathname) && method === "GET") {
|
|
const id = Number(pathname.split("/").pop());
|
|
await route.fulfill(json({ data: state.operations.find((operation) => Number(operation.id) === id) || null }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/test-runs") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
const channel =
|
|
state.channels.find((entry) => Number(entry.id) === Number(payload.channel_id)) || state.channels[0];
|
|
const operation = {
|
|
id: state.operations.length + 1,
|
|
operation_type: "release_test",
|
|
channel_id: channel?.id || null,
|
|
channel_slug: channel?.slug || null,
|
|
status: "warning",
|
|
title: `Release checks for ${channel?.name || "Release Manager"}`,
|
|
summary: "Release Manager tests completed with warnings.",
|
|
solution_hint: "Create the missing branch or target, then retry.",
|
|
step_count: 3,
|
|
passed_step_count: 1,
|
|
failed_step_count: 1,
|
|
warning_step_count: 1,
|
|
steps: [
|
|
{
|
|
id: 1,
|
|
step_key: "dashboard",
|
|
label: "Dashboard data contract",
|
|
status: "passed",
|
|
message: "Dashboard data is available.",
|
|
},
|
|
{
|
|
id: 2,
|
|
step_key: "branch",
|
|
label: "Canary API branch",
|
|
status: "warning",
|
|
message: "Branch canary could not be verified and will be skipped by sync.",
|
|
diagnostic: "GitHub branch was not found.",
|
|
solution_hint: "Create the canary branch from master, then retry.",
|
|
context: { retry_action: "retry_sync", channel_id: channel?.id || null },
|
|
},
|
|
{
|
|
id: 3,
|
|
step_key: "target",
|
|
label: "API Coolify target",
|
|
status: "failed",
|
|
message: "No Coolify target exists for this channel/app.",
|
|
diagnostic: "Missing release_deployment_targets row.",
|
|
solution_hint: "Create the target in Release Manager integrations.",
|
|
context: { retry_action: "configure_target", channel_id: channel?.id || null },
|
|
},
|
|
],
|
|
};
|
|
state.operations.unshift(operation);
|
|
await route.fulfill(json({ data: operation }, 202));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/channels\/\d+\/sync$/.test(pathname) && method === "POST") {
|
|
const channelId = Number(pathname.match(/channels\/(\d+)\/sync/)?.[1] || 0);
|
|
const channel = state.channels.find((entry) => Number(entry.id) === channelId);
|
|
const operation = {
|
|
id: state.operations.length + 1,
|
|
operation_type: "channel_sync",
|
|
channel_id: channelId,
|
|
channel_slug: channel?.slug || null,
|
|
status: "passed",
|
|
title: `Sync ${channel?.name || "channel"} release channel`,
|
|
summary: "Channel sync completed.",
|
|
context: {
|
|
source: "manual",
|
|
app: "frontend",
|
|
commit_sha: "c0ffee",
|
|
},
|
|
step_count: 2,
|
|
passed_step_count: 2,
|
|
failed_step_count: 0,
|
|
warning_step_count: 0,
|
|
steps: [
|
|
{
|
|
id: 1,
|
|
step_key: "channel_mapping",
|
|
label: "Channel route and branch mapping",
|
|
status: "passed",
|
|
message: "Route and branch mapping passed.",
|
|
},
|
|
{
|
|
id: 2,
|
|
step_key: "frontend_deploy",
|
|
label: "Frontend deploy and activate",
|
|
status: "passed",
|
|
message: "Frontend is active.",
|
|
},
|
|
],
|
|
};
|
|
state.operations.unshift(operation);
|
|
await route.fulfill(json({ data: operation }, 202));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/channels\/\d+\/bundle$/.test(pathname) && method === "POST") {
|
|
const channelId = Number(pathname.match(/channels\/(\d+)\/bundle/)?.[1] || 0);
|
|
const payload = request.postDataJSON?.() || {};
|
|
const bundle = state.bundles.find(
|
|
(entry) => Number(entry.id) === Number(payload.bundle_id) && Number(entry.channel_id) === channelId
|
|
);
|
|
if (!bundle || !["deployed", "promoted", "active"].includes(bundle.status)) {
|
|
await route.fulfill(json({ message: "Only deployed release bundles can be set on a channel." }, 409));
|
|
return;
|
|
}
|
|
const channel = setActiveBundle(state, bundle);
|
|
await route.fulfill(json({ data: { channel, versions: channel?.versions || {}, bundle } }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/assignments") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
state.assignmentPayloads.push(payload);
|
|
const assignment = {
|
|
...payload,
|
|
id: state.nextAssignmentId++,
|
|
channel_slug: channelSlug(state, payload.channel_id),
|
|
};
|
|
state.assignments.unshift(assignment);
|
|
await route.fulfill(json({ data: assignment }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/targets") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
state.targetPayloads.push(payload);
|
|
if (!["project-internal", "project-main"].includes(payload.deploy_context?.coolify_project_uuid)) {
|
|
await route.fulfill(json({ message: "Coolify project was not selected." }, 400));
|
|
return;
|
|
}
|
|
const target = {
|
|
...payload,
|
|
id: state.nextTargetId++,
|
|
channel_slug: channelSlug(state, payload.channel_id),
|
|
coolify_instance_label: payload.coolify_instance_id ? "Production Coolify" : null,
|
|
deploy_context: payload.deploy_context || {},
|
|
};
|
|
target.endpoint = endpointForTargetFixture(target, target.channel_slug);
|
|
state.targets.unshift(target);
|
|
if (payload.app === "api" && !payload.deploy_context?.isolated_stack && state.serviceSets[0]) {
|
|
state.serviceSets[0].targets.api = target;
|
|
}
|
|
await route.fulfill(json({ data: target }));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/targets\/\d+$/.test(pathname) && method === "DELETE") {
|
|
const id = Number(pathname.match(/targets\/(\d+)/)?.[1] || 0);
|
|
state.targets = state.targets.filter((entry) => Number(entry.id) !== id);
|
|
for (const serviceSet of state.serviceSets) {
|
|
for (const app of ["frontend", "api"]) {
|
|
if (Number(serviceSet.targets?.[app]?.id) === id) {
|
|
serviceSet.targets[app] = null;
|
|
}
|
|
}
|
|
}
|
|
await route.fulfill(json({ data: { id, removed: true, provider_resources_deleted: false } }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/service-sets") && method === "GET") {
|
|
await route.fulfill(json({ data: summary(state).service_sets }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/service-sets") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
const source = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.source_service_set_id));
|
|
const dataSource = state.serviceSets.find(
|
|
(entry) => Number(entry.id) === Number(payload.data_source_service_set_id)
|
|
);
|
|
const mode = payload.mode || "attach_existing";
|
|
const serviceSet = {
|
|
id: state.nextServiceSetId++,
|
|
channel_id: payload.channel_id || source?.channel_id || 2,
|
|
channel_slug: channelSlug(state, payload.channel_id || source?.channel_id || 2),
|
|
name: payload.name || "Fresh release stack",
|
|
slug: (payload.name || "fresh-release-stack").toLowerCase().replace(/[^a-z0-9]+/g, "-"),
|
|
mode,
|
|
data_policy: payload.metadata?.data_policy || (mode === "attach_existing" ? "production_shared" : mode),
|
|
data_service_mode:
|
|
payload.metadata?.data_service_mode || (mode === "attach_existing" ? "production_shared" : mode),
|
|
source_service_set_id: payload.source_service_set_id || null,
|
|
data_source_service_set_id: payload.data_source_service_set_id || null,
|
|
data_source_channel_slug: payload.metadata?.data_source_channel_slug || dataSource?.channel_slug || null,
|
|
status:
|
|
mode === "clone_existing"
|
|
? "provisioning"
|
|
: mode === "fresh_empty"
|
|
? "isolated_empty"
|
|
: mode === "isolated_stack"
|
|
? "isolated_stack"
|
|
: "ready",
|
|
targets: {
|
|
frontend:
|
|
state.targets.find((entry) => Number(entry.id) === Number(payload.frontend_target_id)) ||
|
|
source?.targets?.frontend ||
|
|
null,
|
|
api:
|
|
state.targets.find((entry) => Number(entry.id) === Number(payload.api_target_id)) ||
|
|
source?.targets?.api ||
|
|
null,
|
|
},
|
|
data_services: ["fresh_empty", "isolated_stack"].includes(mode)
|
|
? { database: null, redis: null, minio: null }
|
|
: dataSource?.data_services ||
|
|
source?.data_services || {
|
|
database: null,
|
|
redis: null,
|
|
minio: null,
|
|
},
|
|
metadata: payload.metadata || {},
|
|
attached_bundles: [],
|
|
};
|
|
if (mode === "isolated_stack" && payload.create_data_targets !== false) {
|
|
completeIsolatedDataServices(state, serviceSet);
|
|
}
|
|
state.serviceSets.unshift(serviceSet);
|
|
await route.fulfill(json({ data: summary(state).service_sets.find((entry) => entry.id === serviceSet.id) }));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/service-sets\/\d+$/.test(pathname) && method === "DELETE") {
|
|
const id = Number(pathname.match(/service-sets\/(\d+)/)?.[1] || 0);
|
|
const serviceSet = state.serviceSets.find((entry) => Number(entry.id) === id);
|
|
if (!serviceSet || serviceSet.mode !== "isolated_stack" || serviceSet.active) {
|
|
await route.fulfill(json({ message: "The active release service set cannot be removed." }, 400));
|
|
return;
|
|
}
|
|
state.serviceSets = state.serviceSets.filter((entry) => Number(entry.id) !== id);
|
|
state.bundles = state.bundles.filter((entry) => Number(entry.service_set_id) !== id);
|
|
await route.fulfill(json({ data: { id, removed: true, provider_resources_deleted: false } }));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/service-sets\/\d+\/isolated-data-services$/.test(pathname) && method === "POST") {
|
|
const id = Number(pathname.match(/service-sets\/(\d+)\/isolated-data-services/)?.[1] || 0);
|
|
const serviceSet = state.serviceSets.find((entry) => Number(entry.id) === id);
|
|
if (!serviceSet || serviceSet.mode !== "isolated_stack") {
|
|
await route.fulfill(json({ message: "Only isolated stacks can add data services." }, 400));
|
|
return;
|
|
}
|
|
completeIsolatedDataServices(state, serviceSet);
|
|
await route.fulfill(json({ data: summary(state).service_sets.find((entry) => entry.id === serviceSet.id) }, 202));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/bundles") && method === "GET") {
|
|
await route.fulfill(json({ data: state.bundles }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/bundles") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
state.bundlePayloads.push(payload);
|
|
const serviceSet = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.service_set_id));
|
|
const frontendCommit = {
|
|
sha:
|
|
payload.frontend?.commit_mode === "specific"
|
|
? payload.frontend?.commit_sha
|
|
: "c0ffee0000000000000000000000000000000000",
|
|
short_sha: String(
|
|
payload.frontend?.commit_mode === "specific" ? payload.frontend?.commit_sha : "c0ffee000000"
|
|
).slice(0, 12),
|
|
message: "Frontend release build",
|
|
author_name: "Release Bot",
|
|
authored_at: "2026-05-19T08:00:00.000Z",
|
|
};
|
|
const apiCommit = {
|
|
sha:
|
|
payload.api?.commit_mode === "specific"
|
|
? payload.api?.commit_sha
|
|
: "feedface00000000000000000000000000000000",
|
|
short_sha: String(payload.api?.commit_mode === "specific" ? payload.api?.commit_sha : "feedface0000").slice(
|
|
0,
|
|
12
|
|
),
|
|
message: "Deploy canary build",
|
|
author_name: "Release Bot",
|
|
authored_at: "2026-05-19T08:10:00.000Z",
|
|
};
|
|
const bundle = {
|
|
id: state.nextBundleId++,
|
|
channel_id: payload.channel_id || serviceSet?.channel_id || 2,
|
|
channel_slug: channelSlug(state, payload.channel_id || serviceSet?.channel_id || 2),
|
|
service_set_id: payload.service_set_id,
|
|
service_set_name: serviceSet?.name || "Service set",
|
|
version_label: payload.version_label || `bundle-${state.nextBundleId}`,
|
|
status: "draft",
|
|
apps: {
|
|
frontend: {
|
|
version_id: state.nextBundleId * 10 + 1,
|
|
deployment_id: null,
|
|
repository: payload.frontend?.repository || "truckwash/front-end-vue",
|
|
branch: payload.frontend?.branch || DEFAULT_RELEASE_BRANCH,
|
|
commit_sha: frontendCommit.sha,
|
|
commit: frontendCommit,
|
|
version: { metadata: { github_access: { commit: frontendCommit, latest_commit: frontendCommit } } },
|
|
},
|
|
api: {
|
|
version_id: state.nextBundleId * 10 + 2,
|
|
deployment_id: null,
|
|
repository: payload.api?.repository || "truckwash/backend-php",
|
|
branch: payload.api?.branch || "release/canary",
|
|
commit_sha: apiCommit.sha,
|
|
commit: apiCommit,
|
|
version: { metadata: { github_access: { commit: apiCommit, latest_commit: apiCommit } } },
|
|
},
|
|
},
|
|
};
|
|
state.bundles.unshift(bundle);
|
|
await route.fulfill(json({ data: bundle }, 201));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/bundles\/\d+\/deploy$/.test(pathname) && method === "POST") {
|
|
const id = Number(pathname.match(/bundles\/(\d+)\/deploy/)?.[1] || 0);
|
|
const bundle = state.bundles.find((entry) => entry.id === id);
|
|
if (bundle) {
|
|
bundle.status = "deployed";
|
|
for (const app of ["frontend", "api"]) {
|
|
const deployment = {
|
|
id: state.nextDeploymentId++,
|
|
target_id: null,
|
|
channel_id: bundle.channel_id,
|
|
channel_slug: bundle.channel_slug,
|
|
app,
|
|
repository: bundle.apps[app].repository,
|
|
branch: bundle.apps[app].branch,
|
|
commit_sha: bundle.apps[app].commit_sha,
|
|
version_label: bundle.version_label,
|
|
status: "deployed",
|
|
bundle_id: bundle.id,
|
|
service_set_id: bundle.service_set_id,
|
|
deployment_kind: "bundle_member",
|
|
promotable: true,
|
|
started_at: "2026-05-19T08:20:00.000Z",
|
|
};
|
|
bundle.apps[app].deployment_id = deployment.id;
|
|
state.deployments.unshift(deployment);
|
|
}
|
|
}
|
|
await route.fulfill(json({ data: bundle || null }, 202));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/bundles\/\d+\/promote$/.test(pathname) && method === "POST") {
|
|
const id = Number(pathname.match(/bundles\/(\d+)\/promote/)?.[1] || 0);
|
|
const bundle = state.bundles.find((entry) => entry.id === id);
|
|
const channel = setActiveBundle(state, bundle);
|
|
await route.fulfill(json({ data: { channel, versions: channel?.versions || {}, bundle } }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/issues/actions") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
const currentSummary = summary(state);
|
|
const issue = currentSummary.status_overview.issues.find((entry) => entry.key === payload.issue_key);
|
|
if (!issue) {
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
status: "failed",
|
|
message: "This release issue is no longer active.",
|
|
result: null,
|
|
summary: currentSummary,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (payload.action_id === "retry_deployment") {
|
|
const original = state.deployments.find((entry) => Number(entry.id) === Number(issue.deployment_id));
|
|
const deployment = {
|
|
...(original || {}),
|
|
id: state.nextDeploymentId++,
|
|
status: "deploying",
|
|
error_message: null,
|
|
failure_summary: null,
|
|
promotable: false,
|
|
started_at: "2026-05-19T08:30:00.000Z",
|
|
};
|
|
state.deployments.unshift(deployment);
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
status: "queued",
|
|
message: "Deployment retry started.",
|
|
issue,
|
|
action: issue.actions.find((action) => action.id === payload.action_id),
|
|
result: { deployment },
|
|
summary: summary(state),
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
status: "needs_input",
|
|
message: "Select the missing deployment inputs.",
|
|
issue,
|
|
action: issue.actions.find((action) => action.id === payload.action_id),
|
|
result: { required_inputs: ["target_id"] },
|
|
summary: currentSummary,
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/deployments") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
state.deploymentPayloads.push(payload);
|
|
const target = state.targets.find((entry) => Number(entry.id) === Number(payload.target_id));
|
|
const channelId = Number(payload.channel_id || target?.channel_id || 1);
|
|
const deployment = {
|
|
id: state.nextDeploymentId++,
|
|
target_id: payload.target_id || null,
|
|
channel_id: channelId,
|
|
channel_slug: channelSlug(state, channelId),
|
|
app: payload.app || target?.app || "frontend",
|
|
repository: payload.repository || target?.repository || "truckwash/front-end-vue",
|
|
branch: payload.branch || target?.branch || DEFAULT_RELEASE_BRANCH,
|
|
commit_sha: payload.commit_sha || "latestcommit",
|
|
version_label: payload.version_label || "manual-release",
|
|
status: "deploying",
|
|
started_at: "2026-05-19T08:12:00.000Z",
|
|
};
|
|
state.deployments.unshift(deployment);
|
|
await route.fulfill(json({ data: deployment }));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/deployments\/\d+\/promote$/.test(pathname) && method === "POST") {
|
|
const id = Number(pathname.match(/deployments\/(\d+)\/promote/)?.[1] || 0);
|
|
const deployment = state.deployments.find((entry) => entry.id === id);
|
|
if (deployment) {
|
|
deployment.status = "promoted";
|
|
}
|
|
await route.fulfill(json({ data: deployment || null }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/replay-targets") && method === "POST") {
|
|
const payload = request.postDataJSON?.() || {};
|
|
const replayTarget = {
|
|
...payload,
|
|
id: state.replayTargets.length + 1,
|
|
enabled: payload.enabled !== false,
|
|
};
|
|
state.replayTargets.unshift(replayTarget);
|
|
await route.fulfill(json({ data: replayTarget }));
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/timeline/sessions") && method === "GET") {
|
|
if (typeof state.beforeTimelineSessionsResponse === "function") {
|
|
await state.beforeTimelineSessionsResponse();
|
|
}
|
|
await route.fulfill(json({ data: state.timelineSessions }));
|
|
return;
|
|
}
|
|
|
|
if (/\/superuser\/releases\/timeline\/sessions\/[^/]+$/.test(pathname) && method === "GET") {
|
|
const traceId = decodeURIComponent(pathname.split("/").pop() || "");
|
|
const session = state.timelineSessions.find((entry) => entry.trace_id === traceId);
|
|
await route.fulfill(
|
|
json({
|
|
data: {
|
|
session,
|
|
events: state.timelineEvents.filter((event) => event.trace_id === traceId),
|
|
error_reports: [
|
|
{
|
|
id: 17,
|
|
status: "open",
|
|
reporter: { type: "user", user_id: 42, name: "Acme Dispatcher" },
|
|
route_path: "/superuser/configuration/releases",
|
|
release_trace_id: traceId,
|
|
frontend_version: "frontend-canary",
|
|
api_version: "api-canary",
|
|
request_error_count: 1,
|
|
vue_error_count: 0,
|
|
created_at: "2026-05-19T08:11:00.000Z",
|
|
},
|
|
],
|
|
release: {
|
|
channel: state.channels.find((channel) => channel.slug === session?.channel_slug) || null,
|
|
frontend: {
|
|
...(session?.release?.frontend || {}),
|
|
version: { id: 12, app: "frontend", status: "deployed", version_label: "frontend-canary" },
|
|
deployment: { id: 21, status: "deployed", bundle_id: 31 },
|
|
},
|
|
api: {
|
|
...(session?.release?.api || {}),
|
|
version: { id: 13, app: "api", status: "deployed", version_label: "api-canary" },
|
|
deployment: { id: 22, status: "deployed", bundle_id: 31 },
|
|
},
|
|
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
|
|
},
|
|
},
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (pathname.endsWith("/superuser/releases/timeline") && method === "GET") {
|
|
if (typeof state.beforeTimelineResponse === "function") {
|
|
await state.beforeTimelineResponse();
|
|
}
|
|
await route.fulfill(json({ data: state.timelineEvents }));
|
|
return;
|
|
}
|
|
|
|
await route.fulfill(json({ data: [] }));
|
|
});
|
|
|
|
await page.route(/\/release\/timeline\/events$/i, async (route) => {
|
|
await route.fulfill(json({ data: { accepted: 1 } }));
|
|
});
|
|
}
|
|
|
|
async function expectReleaseManagerReady(page) {
|
|
await expect(page.getByTestId("release-manager-page")).toBeVisible({ timeout: 90_000 });
|
|
await expect(page.getByTestId("release-management-interface")).toBeVisible({ timeout: 90_000 });
|
|
}
|
|
|
|
async function expandActiveReleaseCategory(page) {
|
|
await expectReleaseManagerReady(page);
|
|
}
|
|
|
|
const releaseSectionNames = {
|
|
Overview: "overview",
|
|
Channels: "channels",
|
|
Assignments: "assignments",
|
|
Deployments: "deployments",
|
|
Replay: "operations",
|
|
Operations: "operations",
|
|
Integrations: "integrations",
|
|
"Data services": "data-services",
|
|
Settings: "settings",
|
|
};
|
|
|
|
async function selectReleaseTab(page, name) {
|
|
await expectReleaseManagerReady(page);
|
|
const key = releaseSectionNames[name] || String(name || "").toLowerCase();
|
|
const link = page.getByTestId("release-management-interface").getByTestId(`release-section-link-${key}`);
|
|
if ((await link.count()) > 0) {
|
|
await link.click();
|
|
return;
|
|
}
|
|
await page.goto(`/superuser/configuration/releases/${key}${new URL(page.url()).search}`, {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await expectReleaseManagerReady(page);
|
|
}
|
|
|
|
function createResponseGate() {
|
|
const pending = [];
|
|
return {
|
|
wait: () =>
|
|
new Promise((resolve) => {
|
|
pending.push(resolve);
|
|
}),
|
|
async releaseNext() {
|
|
await expect.poll(() => pending.length).toBeGreaterThan(0);
|
|
pending.shift()?.();
|
|
},
|
|
};
|
|
}
|
|
|
|
test("loading states cover initial release data, refreshes, settings, and GitHub repositories", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
const summaryGate = createResponseGate();
|
|
const configGate = createResponseGate();
|
|
const repositoriesGate = createResponseGate();
|
|
state.beforeSummaryResponse = () => summaryGate.wait();
|
|
state.beforeConfigResponse = () => configGate.wait();
|
|
state.beforeGithubRepositoriesResponse = () => repositoriesGate.wait();
|
|
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=master", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expect(page.getByTestId("release-manager-page")).toBeVisible({ timeout: 90_000 });
|
|
await expect(page.getByTestId("release-status-dashboard-loading")).toBeVisible();
|
|
await expect(page.getByTestId("release-context-loading")).toBeVisible();
|
|
await expect(page.getByTestId("release-context-channel")).toBeDisabled();
|
|
await expect(page.getByTestId("release-status-dashboard")).toBeHidden();
|
|
|
|
await summaryGate.releaseNext();
|
|
await expect(page.getByTestId("release-status-dashboard")).toBeVisible();
|
|
await expect(page.getByTestId("release-context-channel")).toBeEnabled();
|
|
await expect(page.getByTestId("release-manager-refreshing")).toBeHidden();
|
|
|
|
state.beforeSummaryResponse = () => summaryGate.wait();
|
|
await page.getByTestId("release-context-refresh").click();
|
|
await expect(page.getByTestId("release-manager-refreshing")).toBeVisible();
|
|
await expect(page.getByTestId("release-status-dashboard")).toBeVisible();
|
|
await summaryGate.releaseNext();
|
|
await expect(page.getByTestId("release-manager-refreshing")).toBeHidden();
|
|
|
|
await selectReleaseTab(page, "Settings");
|
|
await expect(page.getByTestId("release-settings-config-loading")).toBeVisible();
|
|
await expect(page.getByTestId("release-settings-summary")).toHaveCount(0);
|
|
await configGate.releaseNext();
|
|
await expect(page.getByTestId("release-settings-summary")).toBeVisible();
|
|
|
|
await selectReleaseTab(page, "Integrations");
|
|
await expect(page.getByTestId("release-github-repositories-loading")).toBeVisible();
|
|
await repositoriesGate.releaseNext();
|
|
await expect(page.getByTestId("release-github-repositories-loading")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-target-form")).toBeVisible();
|
|
});
|
|
|
|
test("loading states cover operation refresh and replay timeline searches", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
const operationsGate = createResponseGate();
|
|
const timelineSessionsGate = createResponseGate();
|
|
const timelineEventsGate = createResponseGate();
|
|
state.beforeOperationsResponse = () => operationsGate.wait();
|
|
state.beforeTimelineSessionsResponse = () => timelineSessionsGate.wait();
|
|
state.beforeTimelineResponse = () => timelineEventsGate.wait();
|
|
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/releases/operations?channel=stable&app=api&branch=master", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page.getByTestId("release-section-operations")).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: /Refresh operations/i }).click();
|
|
await expect(page.getByTestId("release-data-grid-loading")).toBeVisible();
|
|
await operationsGate.releaseNext();
|
|
await expect(page.getByTestId("release-data-grid-loading")).toHaveCount(0);
|
|
|
|
await expect(page.getByTestId("release-timeline-sessions-loading")).toBeVisible();
|
|
await expect(page.getByTestId("release-timeline-events-loading")).toBeVisible();
|
|
await timelineSessionsGate.releaseNext();
|
|
await timelineEventsGate.releaseNext();
|
|
await expect(page.getByTestId("release-timeline-sessions-loading")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-timeline-events-loading")).toHaveCount(0);
|
|
});
|
|
|
|
test("overview status dashboard prioritizes release issues and opens channel details", async ({ page }, testInfo) => {
|
|
test.skip(!isDesktopProject(testInfo), "Release overview dashboard detail assertions are desktop-focused.");
|
|
test.setTimeout(180_000);
|
|
const state = createReleaseState();
|
|
await boot(page, state);
|
|
if (test.info().project.name.includes("mobile")) {
|
|
await page.waitForTimeout(5_000);
|
|
}
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=master", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page.getByTestId("release-section-link-integrations")).toBeVisible();
|
|
await expect(page.getByTestId("release-section-link-deployments")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-section-link-channels")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-manager-page")).not.toContainText("endpoint not configured");
|
|
await expect(page.getByTestId("release-context-source")).toContainText("Local source");
|
|
await expect(page.getByTestId("release-context-endpoints")).toContainText("/api");
|
|
await expect(page.getByTestId("release-context-endpoints")).not.toContainText("/master/api");
|
|
await expect(page.getByTestId("release-runtime-source")).toContainText("Local source");
|
|
|
|
const dashboard = page.getByTestId("release-status-dashboard");
|
|
await expect(dashboard).toBeVisible({ timeout: 90_000 });
|
|
await expect(dashboard).toContainText("Release promotion is blocked");
|
|
await expect(dashboard.getByTestId("release-status-summary")).toContainText("Critical");
|
|
await expect(page.getByTestId("release-cockpit-channel-list")).toContainText("Canary");
|
|
await expect(page.getByTestId("release-cockpit-create-release")).toBeVisible();
|
|
|
|
await expect(page.getByTestId("release-control-api")).toHaveCount(0);
|
|
await selectReleaseTab(page, "Channels");
|
|
await expect(page.getByTestId("release-unified-dashboard")).toContainText(
|
|
"Channels, deployments, data, Coolify, and failover"
|
|
);
|
|
await expect(page.getByTestId("release-channel-lane")).toHaveCount(1);
|
|
await expect(page.getByTestId("release-channel-lane").filter({ hasText: "Canary" })).toContainText(
|
|
"canary -> canary"
|
|
);
|
|
await expect(page.getByTestId("release-channel-lane")).not.toContainText("Stable");
|
|
await selectReleaseTab(page, "Overview");
|
|
|
|
const issuePanel = dashboard.getByTestId("release-status-issues");
|
|
const failedDeploymentIssue = issuePanel.getByRole("button").filter({ hasText: "Composer install failed" }).first();
|
|
await expect(failedDeploymentIssue).toBeVisible();
|
|
await failedDeploymentIssue.evaluate((element) => element.scrollIntoView({ block: "center", inline: "nearest" }));
|
|
const viewport = page.viewportSize();
|
|
const issueBox = await failedDeploymentIssue.boundingBox();
|
|
expect(issueBox).toBeTruthy();
|
|
if ((viewport?.width || 0) > 768) {
|
|
await expect(failedDeploymentIssue).toBeVisible();
|
|
} else {
|
|
await failedDeploymentIssue.scrollIntoViewIfNeeded();
|
|
await expect(failedDeploymentIssue).toBeVisible();
|
|
}
|
|
|
|
await page.getByTestId("release-cockpit-primary-action").click();
|
|
await expect(page.getByTestId("release-status-drawer")).toBeVisible();
|
|
await expect(
|
|
page.getByTestId("release-status-drawer").getByRole("button", { name: /Retry deployment/i })
|
|
).toBeVisible();
|
|
page.once("dialog", (dialog) => dialog.accept());
|
|
await page
|
|
.getByTestId("release-status-drawer")
|
|
.getByRole("button", { name: /Retry deployment/i })
|
|
.click();
|
|
await expect(page.getByTestId("release-issue-action-result")).toContainText("Deployment retry started");
|
|
await expect(issuePanel).not.toContainText("Composer install failed");
|
|
await page.locator(".release-status-drawer .delete").click();
|
|
await selectReleaseTab(page, "Overview");
|
|
|
|
const canaryCard = page
|
|
.getByTestId("release-cockpit-channel-list")
|
|
.locator(".release-cockpit-channel")
|
|
.filter({ hasText: "Canary" })
|
|
.first();
|
|
await expect(canaryCard).toBeVisible();
|
|
await canaryCard.click();
|
|
|
|
const drawer = page.getByTestId("release-status-drawer");
|
|
await expect(drawer).toBeVisible();
|
|
await expect(drawer).toContainText("Canary");
|
|
await expect(drawer).toContainText("All release services are ready");
|
|
await expect(drawer).not.toContainText("Run the PHP test suite in the php1 container");
|
|
await expect(drawer.getByTestId("release-status-service")).toHaveCount(5);
|
|
await expect(drawer).toContainText("Frontend");
|
|
await expect(drawer).toContainText("API");
|
|
await expect(drawer).toContainText("Database");
|
|
await expect(drawer).toContainText("Redis");
|
|
await expect(drawer).toContainText("MinIO");
|
|
|
|
const drawerBox = await drawer.boundingBox();
|
|
expect(drawerBox).toBeTruthy();
|
|
expect(drawerBox.x).toBeGreaterThanOrEqual(0);
|
|
expect(drawerBox.width).toBeLessThanOrEqual((viewport?.width || 1440) + 1);
|
|
|
|
await page.locator(".release-status-drawer .delete").click();
|
|
await page.getByTestId("release-context-channel").selectOption("beta");
|
|
const betaCard = page
|
|
.getByTestId("release-cockpit-channel-list")
|
|
.locator(".release-cockpit-channel")
|
|
.filter({ hasText: "Beta" })
|
|
.first();
|
|
await betaCard.click();
|
|
await expect(drawer).toBeVisible();
|
|
await expect(drawer.getByTestId("release-status-drawer-missing-values")).not.toContainText("Release bundle");
|
|
await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("Frontend version");
|
|
await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("Frontend URL");
|
|
await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("API version");
|
|
await expect(drawer.getByTestId("release-status-drawer-missing-values")).toContainText("API URL");
|
|
|
|
await drawer
|
|
.getByRole("button", { name: /Operations/ })
|
|
.first()
|
|
.click();
|
|
await expect(page).toHaveURL(/\/superuser\/configuration\/releases\/operations/);
|
|
});
|
|
|
|
test("unified dashboard exposes channel lanes, operation diagnostics, and old-route redirects", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/coolify", { waitUntil: "domcontentloaded" });
|
|
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page).toHaveURL(/\/superuser\/configuration\/releases\/integrations\?panel=coolify/);
|
|
await expect(page.getByTestId("release-section-link-deployments")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-section-link-channels")).toHaveCount(0);
|
|
await selectReleaseTab(page, "Channels");
|
|
await expect(page.getByTestId("release-coolify-panel")).toContainText("Coolify");
|
|
await expect(page.getByTestId("release-failover-panel")).toContainText("Failover");
|
|
await expect(page.getByTestId("release-channel-lane")).toHaveCount(1);
|
|
await expect(page.getByTestId("release-channel-lane").filter({ hasText: "Stable" })).toContainText(
|
|
"stable -> master"
|
|
);
|
|
await expect(page.getByTestId("release-channel-lane")).not.toContainText("Canary");
|
|
|
|
await selectReleaseTab(page, "Overview");
|
|
await expect(page.getByTestId("release-context-test")).toBeVisible();
|
|
await page.getByTestId("release-context-test").click();
|
|
const modal = page.getByTestId("release-operation-modal");
|
|
await expect(modal).toBeVisible();
|
|
await expect(modal).toContainText("Dashboard data contract");
|
|
await expect(modal).toContainText("Diagnostic");
|
|
await expect(modal).toContainText("Solution");
|
|
await modal.getByRole("button", { name: "Retry" }).first().click();
|
|
await expect(modal).toContainText("Channel sync completed");
|
|
await page.keyboard.press("Escape");
|
|
await expect(modal).toBeHidden();
|
|
|
|
await page.goto("/superuser/configuration/failover", { waitUntil: "domcontentloaded" });
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page).toHaveURL(/\/superuser\/configuration\/releases\/data-services\?panel=failover/);
|
|
await expect(page.getByTestId("release-section-data-services")).toContainText("Data services and failover");
|
|
});
|
|
|
|
test("route-backed workspace keeps context and exposes operational data-service tooltips", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
state.operations.unshift({
|
|
id: 77,
|
|
operation_type: "channel_sync",
|
|
channel_id: 1,
|
|
channel_slug: "stable",
|
|
app: "frontend",
|
|
status: "passed",
|
|
title: "Automatic Stable frontend update",
|
|
summary: "FRONTEND container was deployed and promoted at e1a8af5a7271.",
|
|
step_count: 3,
|
|
passed_step_count: 3,
|
|
failed_step_count: 0,
|
|
warning_step_count: 0,
|
|
context: {
|
|
source: "release_gate",
|
|
app: "frontend",
|
|
commit_sha: "e1a8af5a727176f0343eb5fe3620ea890e7c6135",
|
|
auto_sync_event: {
|
|
status: "promoted",
|
|
app: "frontend",
|
|
commit_sha: "e1a8af5a727176f0343eb5fe3620ea890e7c6135",
|
|
source: "release_gate",
|
|
},
|
|
},
|
|
steps: [
|
|
{
|
|
id: 1,
|
|
step_key: "frontend_readiness",
|
|
label: "FRONTEND container readiness",
|
|
status: "passed",
|
|
message: "FRONTEND container readiness matched commit e1a8af5a7271.",
|
|
},
|
|
],
|
|
});
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/releases/data-services?channel=stable&app=api&branch=master", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page.getByTestId("release-context-channel")).toHaveValue("stable");
|
|
await expect(page.getByTestId("release-context-app")).toHaveValue("api");
|
|
await expect(page.getByTestId("release-context-branch")).toHaveValue("master");
|
|
await expect(page.getByTestId("release-section-data-services")).toBeVisible();
|
|
|
|
const databaseLink = page
|
|
.getByTestId("release-data-service-row")
|
|
.filter({ hasText: "database" })
|
|
.first()
|
|
.locator(".release-entity-link")
|
|
.first();
|
|
await expect(databaseLink).toHaveAttribute("title", /Node: node-1/);
|
|
await expect(databaseLink).toHaveAttribute("title", /Hostname: stable-db\.truckwash\.internal/);
|
|
await expect(databaseLink).toHaveAttribute("title", /Port: 3306/);
|
|
await expect(databaseLink).toHaveAttribute("title", /Uptime: 1d/);
|
|
await expect(databaseLink).toHaveAttribute("title", /Last check: 2026-05-20T10:00:00\.000Z/);
|
|
|
|
await selectReleaseTab(page, "Operations");
|
|
await expect(page).toHaveURL(
|
|
/\/superuser\/configuration\/releases\/operations\?channel=stable&app=api&branch=master/
|
|
);
|
|
await expect(page.getByTestId("release-context-channel")).toHaveValue("stable");
|
|
await expect(page.getByTestId("release-context-app")).toHaveValue("api");
|
|
await expect(
|
|
page.getByTestId("release-operation-grid-row").filter({ hasText: "Automatic Stable frontend update" })
|
|
).toContainText("release_gate / frontend / e1a8af5a7271");
|
|
|
|
if ((page.viewportSize()?.width || 0) > 768) {
|
|
const documentScroll = await page.evaluate(() => ({
|
|
clientHeight: document.documentElement.clientHeight,
|
|
scrollHeight: Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
|
|
}));
|
|
expect(documentScroll.scrollHeight).toBeLessThanOrEqual(documentScroll.clientHeight + 2);
|
|
}
|
|
});
|
|
|
|
test("primary release workflow hides manual bundle controls and syncs the selected channel", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
addDeployedBundle(state, { channel_id: 2 });
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=frontend&branch=canary", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page.getByTestId("release-section-link-deployments")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-section-link-channels")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-bundle-flow")).toBeHidden();
|
|
await expect(page.getByTestId("release-channel-bundle-picker")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-channels-overview")).toHaveCount(0);
|
|
|
|
await page.getByTestId("release-context-sync").click();
|
|
const modal = page.getByTestId("release-operation-modal");
|
|
await expect(modal).toBeVisible();
|
|
await expect(modal).toContainText("Sync Canary release channel");
|
|
await expect(modal).toContainText("Channel route and branch mapping");
|
|
await expect(modal).toContainText("Frontend deploy and activate");
|
|
await expect(modal.getByTestId("release-operation-meta")).toContainText("manual");
|
|
await expect(modal.getByTestId("release-operation-meta")).toContainText("frontend");
|
|
await expect(modal.getByTestId("release-operation-meta")).toContainText("c0ffee");
|
|
expect(state.operations[0]).toMatchObject({ operation_type: "channel_sync", channel_id: 2 });
|
|
});
|
|
|
|
test("release context maps branches and endpoints when the selected channel changes", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
addInternalChannel(state);
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/releases/overview?channel=stable&app=api&branch=master", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page.getByTestId("release-context-branch")).toHaveValue("master");
|
|
await expect(page.getByTestId("release-context-endpoints")).toContainText("/api");
|
|
await expect(page.getByTestId("release-context-endpoints")).not.toContainText("/master/api");
|
|
|
|
await page.getByTestId("release-context-channel").selectOption("beta");
|
|
await expect(page.getByTestId("release-context-branch")).toHaveValue("beta");
|
|
await expect(page.getByTestId("release-context-endpoints")).toContainText("/api");
|
|
await expect(page.getByTestId("release-context-endpoints")).not.toContainText("/beta/api");
|
|
|
|
await page.getByTestId("release-context-channel").selectOption("internal");
|
|
await expect(page.getByTestId("release-context-branch")).toHaveValue("internal");
|
|
await expect(page.getByTestId("release-context-endpoints")).toContainText("/api");
|
|
await expect(page.getByTestId("release-context-endpoints")).not.toContainText("/internal/api");
|
|
});
|
|
|
|
test("overview filters channel health and issues by selected channel and app", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=canary", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expectReleaseManagerReady(page);
|
|
const cockpitChannels = page.getByTestId("release-cockpit-channel-list");
|
|
await expect(cockpitChannels).toContainText("Canary");
|
|
await expect(cockpitChannels).not.toContainText("Stable");
|
|
await expect(cockpitChannels).not.toContainText("Beta");
|
|
|
|
const issuePanel = page.getByTestId("release-status-issues");
|
|
await expect(issuePanel).toContainText("Composer install failed");
|
|
await expect(issuePanel).not.toContainText("Frontend version");
|
|
|
|
await page.getByTestId("release-context-app").selectOption("frontend");
|
|
await expect(page.getByTestId("release-context-app")).toHaveValue("frontend");
|
|
await expect(issuePanel).not.toContainText("Composer install failed");
|
|
});
|
|
|
|
test("view-only release managers cannot search assignment subjects", async ({ page }) => {
|
|
const state = await boot(page, createReleaseState(), {
|
|
permissions: ["superuser_release_manager_view"],
|
|
});
|
|
await page.goto("/superuser/configuration/releases/assignments", { waitUntil: "domcontentloaded" });
|
|
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page.getByTestId("release-section-assignments")).toBeVisible();
|
|
await expect(page.getByTestId("release-assignment-form")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-assignment-channel-suggestions")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-assignment-subject-search")).toHaveCount(0);
|
|
expect(state.assignmentSubjectRequests || []).toEqual([]);
|
|
});
|
|
|
|
test("view-only release managers see redacted deployment failure diagnostics", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
state.deployments[0].error_message =
|
|
"Clone failed for https://deploy:ghp_SECRET_TOKEN_123456789@github.internal/acme/api.git DB_PASSWORD=s3cr3t internal-host=10.0.4.12";
|
|
state.deployments[0].failure_summary.root_cause = state.deployments[0].error_message;
|
|
state.deployments[0].failure_summary.next_action =
|
|
"Rotate ghp_SECRET_TOKEN_123456789 and inspect /var/lib/coolify/apps/api/.env before retrying.";
|
|
|
|
await boot(page, state, {
|
|
permissions: ["superuser_release_manager_view"],
|
|
});
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=master", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expectReleaseManagerReady(page);
|
|
await expect(page.getByTestId("release-manager-page")).not.toContainText("ghp_SECRET_TOKEN_123456789");
|
|
await expect(page.getByTestId("release-manager-page")).not.toContainText("DB_PASSWORD=s3cr3t");
|
|
await expect(page.getByTestId("release-manager-page")).not.toContainText("10.0.4.12");
|
|
await expect(page.getByTestId("release-manager-page")).not.toContainText("github.internal");
|
|
|
|
await selectReleaseTab(page, "Deployments");
|
|
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted]");
|
|
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("DB_PASSWORD=[redacted]");
|
|
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-private-ip]");
|
|
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-internal-host]");
|
|
await expect(page.getByTestId("release-deployment-failure-2")).toContainText("[redacted-env-path]");
|
|
});
|
|
|
|
test("superusers manage release settings, assignments, integrations, and sync operations", async ({ page }) => {
|
|
const state = await boot(page);
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=api&branch=canary", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await expectReleaseManagerReady(page);
|
|
if ((page.viewportSize()?.width || 0) > 768) {
|
|
await expect(page.getByRole("link", { name: /Release Manager/i }).first()).toBeVisible();
|
|
}
|
|
await expect(page.locator(".tabs").getByText("Release Manager", { exact: true })).toBeVisible();
|
|
await expect(page.getByTestId("release-control-api")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-section-link-deployments")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-section-link-channels")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-status-dashboard")).toBeVisible();
|
|
await expect(page.getByTestId("release-channels-overview")).toHaveCount(0);
|
|
await expect(page.getByTestId("release-guided-setup")).toHaveCount(0);
|
|
|
|
await selectReleaseTab(page, "Settings");
|
|
await expect(page.getByTestId("release-settings-summary")).toContainText("ReleaseManager.github_token");
|
|
await expect(page.getByTestId("release-settings-guide")).toContainText("GitHub token");
|
|
const settingsForm = page.getByTestId("release-settings-form");
|
|
await settingsForm.getByPlaceholder("Leave blank to keep the existing token").fill("github_pat_release_manager_test");
|
|
await page.getByTestId("release-settings-next").click();
|
|
await expect(page.getByTestId("release-settings-step-api-url")).toContainText("ReleaseManager.github_api_url");
|
|
await settingsForm.getByPlaceholder("https://api.github.com").fill("https://api.github.com");
|
|
await page.getByTestId("release-settings-next").click();
|
|
await expect(page.getByTestId("release-settings-step-webhook")).toContainText("/release/github/webhook");
|
|
await settingsForm.getByPlaceholder("Webhook HMAC secret").fill("release-manager-webhook-secret");
|
|
await page.getByTestId("release-settings-next").click();
|
|
await expect(page.getByTestId("release-settings-step-review")).toContainText("GitHub webhook secret");
|
|
await page.getByTestId("release-settings-save").click();
|
|
await expect(page.getByTestId("release-settings-summary")).toContainText("https://api.github.com");
|
|
|
|
await selectReleaseTab(page, "Assignments");
|
|
await expandActiveReleaseCategory(page);
|
|
await page.getByTestId("release-assignment-channel-suggestions").getByText("canary").click();
|
|
const assignmentSubjectInput = page.getByTestId("release-assignment-subject-search");
|
|
await assignmentSubjectInput.fill("Acme");
|
|
await expect(page.getByTestId("release-assignment-subject-group-user")).toContainText("Users");
|
|
await expect(page.getByTestId("release-assignment-subject-option-user-42")).toContainText("Acme Dispatcher");
|
|
await expect(page.getByTestId("release-assignment-subject-group-customer")).toContainText("Customers");
|
|
await expect(page.getByTestId("release-assignment-subject-option-customer-424242")).toContainText("Acme Transport");
|
|
await assignmentSubjectInput.fill("Driver");
|
|
await expect(page.getByTestId("release-assignment-subject-group-subuser")).toContainText("Subusers");
|
|
await expect(page.getByTestId("release-assignment-subject-option-subuser-77")).toContainText("Driver Alex");
|
|
await assignmentSubjectInput.fill("Acme");
|
|
await page.getByTestId("release-assignment-subject-option-customer-424242").click();
|
|
await expect(assignmentSubjectInput).toHaveValue("Acme Transport - Customer #424242");
|
|
await page.getByTestId("release-assignment-form").getByRole("button", { name: "Assign" }).click();
|
|
expect(state.assignmentPayloads[state.assignmentPayloads.length - 1]).toMatchObject({
|
|
subject_type: "customer",
|
|
subject_id: "424242",
|
|
});
|
|
await expect(page.getByTestId("release-assignments-table")).toContainText("customer:424242");
|
|
await expect(
|
|
page.getByTestId("release-assignments-table").locator('[data-testid^="release-assignment-actions-"]').first()
|
|
).toBeVisible();
|
|
await assignmentSubjectInput.fill("909");
|
|
await expect(page.getByTestId("release-assignment-subject-group-manual")).toContainText("Manual");
|
|
await expect(page.getByTestId("release-assignment-subject-manual-user-909")).toContainText("User #909");
|
|
await expect(page.getByTestId("release-assignment-subject-manual-subuser-909")).toContainText("Subuser #909");
|
|
await expect(page.getByTestId("release-assignment-subject-manual-customer-909")).toContainText("Customer #909");
|
|
await page.getByTestId("release-assignment-subject-manual-subuser-909").click({ force: true });
|
|
await page.getByTestId("release-assignment-form").getByRole("button", { name: "Assign" }).click();
|
|
expect(state.assignmentPayloads[state.assignmentPayloads.length - 1]).toMatchObject({
|
|
subject_type: "subuser",
|
|
subject_id: "909",
|
|
});
|
|
await expect(page.getByTestId("release-assignments-table")).toContainText("subuser:909");
|
|
|
|
await selectReleaseTab(page, "Integrations");
|
|
await expandActiveReleaseCategory(page);
|
|
await page.getByTestId("release-target-presets").getByText("API target").click();
|
|
await page.getByTestId("release-target-form").locator("select").first().selectOption("2");
|
|
await page.getByTestId("release-target-form").getByPlaceholder("branch").fill("canary");
|
|
await expect(page.getByTestId("release-target-form").getByPlaceholder("owner/repo")).toHaveValue(
|
|
"truckwash/backend-php"
|
|
);
|
|
await page.getByTestId("release-target-form").getByPlaceholder("Coolify service UUID").fill("API");
|
|
await page.getByText("API Canary").click();
|
|
await expect(page.getByTestId("release-target-form").getByPlaceholder("Coolify service UUID")).toHaveValue(
|
|
"api-canary-service"
|
|
);
|
|
const loadBalancerDomainInput = page.getByTestId("release-target-form").getByPlaceholder("Load balancer domain");
|
|
await loadBalancerDomainInput.click();
|
|
const domainOptions = page
|
|
.locator(".autocomplete .dropdown-content")
|
|
.filter({ hasText: "api-v2.truckwash.io" })
|
|
.last();
|
|
await expect(domainOptions.getByRole("button", { name: /api-v2\.truckwash\.io/ })).toBeVisible();
|
|
await expect(domainOptions.getByText("http://localhost:5173")).toHaveCount(0);
|
|
await expect(domainOptions.getByText("http://api.truckwash.io:4433")).toHaveCount(0);
|
|
await loadBalancerDomainInput.fill("api-v2.truckwash.io");
|
|
await page.getByTestId("release-target-public-url").fill("https://api-v2.truckwash.io/canary/api");
|
|
await page
|
|
.locator('[data-testid="release-target-endpoint-mode"] select, select[data-testid="release-target-endpoint-mode"]')
|
|
.selectOption("manual");
|
|
await page
|
|
.locator(
|
|
'[data-testid="release-target-manual-endpoint-host"] input, input[data-testid="release-target-manual-endpoint-host"]'
|
|
)
|
|
.fill("manual-api.truckwash.io");
|
|
await page
|
|
.locator(
|
|
'[data-testid="release-target-manual-endpoint-port"] input, input[data-testid="release-target-manual-endpoint-port"]'
|
|
)
|
|
.fill("8443");
|
|
await page
|
|
.locator('[data-testid="release-target-app-port"] input, input[data-testid="release-target-app-port"]')
|
|
.fill("8080");
|
|
await expect(page.getByTestId("release-target-form").getByLabel("Coolify SSL")).toBeChecked();
|
|
await page.getByTestId("release-target-form").locator("select").nth(3).selectOption("project-internal");
|
|
await page.getByTestId("release-target-form").getByRole("button", { name: "Test access" }).click();
|
|
await expect(page.getByTestId("release-target-form")).toContainText("accessible");
|
|
await page.getByTestId("release-target-form").getByRole("button", { name: "Save target" }).click();
|
|
await expect
|
|
.poll(() => state.targetPayloads.at(-1)?.deploy_context, { timeout: 10_000 })
|
|
.toMatchObject({
|
|
endpoint_mode: "manual",
|
|
manual_endpoint_host: "manual-api.truckwash.io",
|
|
manual_endpoint_port: "8443",
|
|
coolify_ports_exposes: "8080",
|
|
});
|
|
await expect(page.getByTestId("release-targets-table")).toContainText("truckwash/backend-php#canary");
|
|
await expect(
|
|
page.getByTestId("release-targets-table").locator('[data-testid^="release-target-actions-"]').first()
|
|
).toBeVisible();
|
|
|
|
await page.getByTestId("release-context-sync").click();
|
|
await expect(page.getByTestId("release-operation-modal")).toContainText("Channel sync completed");
|
|
await page.keyboard.press("Escape");
|
|
await selectReleaseTab(page, "Operations");
|
|
await expect(page.getByTestId("release-operation-grid-row").first()).toContainText("Sync Canary release channel");
|
|
await expect(page.getByTestId("release-operation-grid-row").first()).toContainText("canary");
|
|
});
|
|
|
|
test("beta bundle flow locks to production data sharing @beta-production-data", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
state.nextTargetId = 40;
|
|
state.nextServiceSetId = 60;
|
|
const stableFrontend = {
|
|
id: 20,
|
|
channel_id: 1,
|
|
channel_slug: "stable",
|
|
app: "frontend",
|
|
repository: "truckwash/front-end-vue",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
coolify_instance_id: 3,
|
|
coolify_instance_label: "Production Coolify",
|
|
coolify_service_uuid: "frontend-stable-service",
|
|
health_url: "https://app.example.test/health",
|
|
auto_deploy: true,
|
|
deploy_context: {
|
|
coolify_public_url: "https://app.example.test",
|
|
coolify_project_uuid: "project-main",
|
|
coolify_environment_name: "production",
|
|
},
|
|
};
|
|
const stableApi = {
|
|
id: 21,
|
|
channel_id: 1,
|
|
channel_slug: "stable",
|
|
app: "api",
|
|
repository: "truckwash/backend-php",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
coolify_instance_id: 3,
|
|
coolify_instance_label: "Production Coolify",
|
|
coolify_service_uuid: "api-stable-service",
|
|
health_url: "https://api.example.test/ping",
|
|
auto_deploy: true,
|
|
deploy_context: {
|
|
coolify_public_url: "https://api.example.test",
|
|
coolify_project_uuid: "project-main",
|
|
coolify_environment_name: "production",
|
|
},
|
|
};
|
|
const betaFrontend = {
|
|
id: 30,
|
|
channel_id: 3,
|
|
channel_slug: "beta",
|
|
app: "frontend",
|
|
repository: "truckwash/front-end-vue",
|
|
branch: "release/beta",
|
|
coolify_instance_id: 3,
|
|
coolify_instance_label: "Production Coolify",
|
|
coolify_service_uuid: "frontend-beta-service",
|
|
health_url: "https://beta.example.test/health",
|
|
auto_deploy: true,
|
|
deploy_context: {
|
|
coolify_public_url: "https://beta.example.test",
|
|
coolify_project_uuid: "project-main",
|
|
coolify_environment_name: "production",
|
|
},
|
|
};
|
|
const betaApi = {
|
|
id: 31,
|
|
channel_id: 3,
|
|
channel_slug: "beta",
|
|
app: "api",
|
|
repository: "truckwash/backend-php",
|
|
branch: "release/beta",
|
|
coolify_instance_id: 3,
|
|
coolify_instance_label: "Production Coolify",
|
|
coolify_service_uuid: "api-beta-service",
|
|
health_url: "https://api-beta.example.test/ping",
|
|
auto_deploy: true,
|
|
deploy_context: {
|
|
coolify_public_url: "https://api-beta.example.test",
|
|
coolify_project_uuid: "project-main",
|
|
coolify_environment_name: "production",
|
|
},
|
|
};
|
|
const stableSet = {
|
|
id: 50,
|
|
channel_id: 1,
|
|
channel_slug: "stable",
|
|
name: "Stable production stack",
|
|
slug: "stable-production-stack",
|
|
mode: "attach_existing",
|
|
data_policy: "production_shared",
|
|
data_service_mode: "production_shared",
|
|
status: "ready",
|
|
active: true,
|
|
targets: {
|
|
frontend: stableFrontend,
|
|
api: stableApi,
|
|
},
|
|
data_services: {
|
|
database: {
|
|
id: 70,
|
|
kind: "database",
|
|
label: "Production MariaDB",
|
|
resource_name: "mariadb-production",
|
|
deployment_status: "running",
|
|
replication: { label: "prod-mariadb", host: "db.prod.internal", port: 3306, status: "ok" },
|
|
},
|
|
redis: {
|
|
id: 71,
|
|
kind: "redis",
|
|
label: "Production Redis",
|
|
resource_name: "redis-production",
|
|
deployment_status: "running",
|
|
replication: { label: "prod-redis", host: "redis.prod.internal", port: 6379, status: "ok" },
|
|
},
|
|
minio: {
|
|
id: 72,
|
|
kind: "minio",
|
|
label: "Production MinIO",
|
|
resource_name: "minio-production",
|
|
deployment_status: "running",
|
|
replication: { label: "prod-minio", host: "minio.prod.internal", port: 9000, status: "ok" },
|
|
},
|
|
},
|
|
attached_bundles: [],
|
|
};
|
|
state.targets.push(stableFrontend, stableApi, betaFrontend, betaApi);
|
|
state.serviceSets.unshift(stableSet);
|
|
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/releases/overview?channel=beta&app=all&branch=release/beta", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await selectReleaseTab(page, "Deployments");
|
|
await expandActiveReleaseCategory(page);
|
|
|
|
const bundleFlow = page.getByTestId("release-bundle-flow");
|
|
const bundleChannel = bundleFlow.getByTestId("release-bundle-channel");
|
|
await bundleChannel.selectOption("3");
|
|
await expect(bundleChannel).toHaveValue("3");
|
|
await expect(bundleFlow.getByTestId("release-dataset-mode-attach_existing")).not.toBeDisabled();
|
|
await expect(bundleFlow.getByTestId("release-dataset-mode-clone_existing")).toBeDisabled();
|
|
await expect(bundleFlow.getByTestId("release-dataset-mode-fresh_empty")).toBeDisabled();
|
|
await expect(bundleFlow.getByTestId("release-dataset-mode-isolated_stack")).toBeDisabled();
|
|
await expect(bundleFlow).toContainText("Beta is locked to production-shared data");
|
|
await expect(bundleFlow).toContainText("Beta uses production data");
|
|
await expect(bundleFlow.getByTestId("release-bundle-source-service-set")).toHaveValue("50");
|
|
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("production_shared");
|
|
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("api-beta-service");
|
|
|
|
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
|
await expect(bundleFlow.getByPlaceholder("owner/frontend", { exact: true })).toHaveValue("truckwash/front-end-vue");
|
|
await expect(bundleFlow.getByPlaceholder("owner/backend-php", { exact: true })).toHaveValue("truckwash/backend-php");
|
|
await expect(bundleFlow.getByPlaceholder("master").nth(0)).toHaveValue("release/beta");
|
|
await expect(bundleFlow.getByPlaceholder("master").nth(1)).toHaveValue("release/beta");
|
|
|
|
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
|
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("production_shared");
|
|
await page.getByTestId("release-bundle-deploy-submit").click();
|
|
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
|
|
|
const betaSet = state.serviceSets.find((set) => set.name === "beta-production-data");
|
|
expect(betaSet).toBeTruthy();
|
|
expect(betaSet.channel_slug).toBe("beta");
|
|
expect(betaSet.source_service_set_id).toBeNull();
|
|
expect(betaSet.data_source_service_set_id).toBe(stableSet.id);
|
|
expect(betaSet.data_policy).toBe("production_shared");
|
|
expect(betaSet.targets.frontend.id).toBe(betaFrontend.id);
|
|
expect(betaSet.targets.api.id).toBe(betaApi.id);
|
|
expect(betaSet.data_services.database.id).toBe(stableSet.data_services.database.id);
|
|
expect(state.bundlePayloads[0].service_set_id).toBe(betaSet.id);
|
|
expect(state.bundlePayloads[0].service_set_id).not.toBe(stableSet.id);
|
|
});
|
|
|
|
test("isolated stack mode creates fresh Coolify app and data targets without attaching production data", async ({
|
|
page,
|
|
}) => {
|
|
const state = await boot(page);
|
|
state.targets[0].deploy_context = {
|
|
coolify_project_uuid: "project-internal",
|
|
};
|
|
state.targets.push({
|
|
id: state.nextTargetId++,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "api",
|
|
repository: "truckwash/backend-php",
|
|
branch: "canary",
|
|
coolify_instance_id: 3,
|
|
coolify_instance_label: "Production Coolify",
|
|
coolify_service_uuid: "api-canary-service",
|
|
health_url: "https://api-canary.example.test/ping",
|
|
auto_deploy: true,
|
|
deploy_context: {
|
|
coolify_project_uuid: "project-internal",
|
|
coolify_build_pack: "dockerfile",
|
|
},
|
|
});
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=canary", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
|
|
await selectReleaseTab(page, "Deployments");
|
|
await expandActiveReleaseCategory(page);
|
|
|
|
const bundleFlow = page.getByTestId("release-bundle-flow");
|
|
const bundleChannel = bundleFlow.getByTestId("release-bundle-channel");
|
|
await bundleChannel.selectOption("2");
|
|
await expect(bundleChannel).toHaveValue("2");
|
|
await bundleFlow.getByTestId("release-dataset-mode-isolated_stack").click();
|
|
await bundleChannel.selectOption("2");
|
|
await expect(bundleChannel).toHaveValue("2");
|
|
await expect(bundleFlow).toContainText("without attaching production data");
|
|
await expect(bundleFlow.getByTestId("release-bundle-source-service-set")).toBeDisabled();
|
|
await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack");
|
|
await expect(bundleFlow.getByTestId("release-apply-preview")).toContainText("Create isolated stack");
|
|
await expect(bundleFlow.getByTestId("release-apply-steps")).toContainText("new Coolify frontend/API targets");
|
|
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText(
|
|
"release-internal-safe-stack-frontend"
|
|
);
|
|
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("release-internal-safe-stack-api");
|
|
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText(
|
|
"release-internal-safe-stack-database"
|
|
);
|
|
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText("api-v2.truckwash.io:443");
|
|
await expect(bundleFlow.getByTestId("release-service-action-table")).not.toContainText("endpoint not configured");
|
|
await expect(bundleFlow.getByTestId("release-service-action-table")).toContainText(
|
|
"created by Coolify; host/port assigned after deployment"
|
|
);
|
|
|
|
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
|
await expect(bundleFlow.getByPlaceholder("owner/frontend", { exact: true })).toHaveValue("truckwash/front-end-vue");
|
|
await expect(bundleFlow.getByPlaceholder("owner/backend-php", { exact: true })).toHaveValue("truckwash/backend-php");
|
|
await expect(bundleFlow.getByText("Container image")).toHaveCount(0);
|
|
|
|
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
|
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("new isolated service");
|
|
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("production data not attached");
|
|
|
|
await page.getByTestId("release-bundle-deploy-submit").click();
|
|
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
|
|
|
const isolatedTargets = state.targets.filter((target) => target.deploy_context?.isolated_stack);
|
|
expect(isolatedTargets).toHaveLength(2);
|
|
expect(isolatedTargets.map((target) => target.app).sort()).toEqual(["api", "frontend"]);
|
|
for (const target of isolatedTargets) {
|
|
expect(target.coolify_service_uuid || "").toBe("");
|
|
expect(target.deploy_context.coolify_auto_create).toBe(true);
|
|
expect(target.deploy_context.coolify_enable_ssl).toBe(false);
|
|
expect(target.deploy_context.endpoint_mode).toBe("auto");
|
|
expect(target.deploy_context.production_data_attached).toBe(false);
|
|
expect(target.deploy_context.coolify_github_app_uuid).toBe("github-app-copenhagentruckwash-github");
|
|
expect(target.deploy_context.coolify_build_pack).toBe(target.app === "api" ? "dockerfile" : "static");
|
|
if (target.app === "frontend") {
|
|
expect(target.deploy_context.coolify_install_command).toBe("npm ci");
|
|
expect(target.deploy_context.coolify_build_command).toBe("npm run build");
|
|
expect(target.deploy_context.coolify_publish_directory).toBe("dist");
|
|
expect(target.deploy_context.coolify_is_static).toBe(true);
|
|
expect(target.deploy_context.coolify_is_spa).toBe(true);
|
|
expect(target.deploy_context.coolify_ports_exposes).toBe("80");
|
|
}
|
|
expect(target.deploy_context.image || "").toBe("");
|
|
expect(target.deploy_context.coolify_environment_name).toBe("canary");
|
|
expect(target.deploy_context.coolify_service_name).toMatch(/^release-internal-safe-stack-(api|frontend)$/);
|
|
}
|
|
|
|
const isolatedSet = state.serviceSets.find((set) => set.mode === "isolated_stack");
|
|
expect(isolatedSet.source_service_set_id).toBeNull();
|
|
expect(Object.keys(isolatedSet.data_services).sort()).toEqual(["database", "minio", "redis"]);
|
|
for (const service of Object.values(isolatedSet.data_services)) {
|
|
expect(service.options.isolated_stack).toBe(true);
|
|
expect(service.options.production_data_attached).toBe(false);
|
|
expect(service.options.skip_replication_provisioning).toBe(true);
|
|
expect(service.replication.source_host_id).toBeNull();
|
|
}
|
|
|
|
const bundleCount = state.bundles.length;
|
|
const bundlePayloadCount = state.bundlePayloads.length;
|
|
await page.getByTestId("release-bundle-deploy-submit").click();
|
|
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
|
await expect.poll(() => state.bundles.length).toBe(bundleCount + 1);
|
|
expect(state.bundlePayloads).toHaveLength(bundlePayloadCount + 1);
|
|
const repeatedBundlePayload = state.bundlePayloads[state.bundlePayloads.length - 1];
|
|
const repeatedBundleServiceSet = state.serviceSets.find(
|
|
(set) => Number(set.id) === Number(repeatedBundlePayload.service_set_id)
|
|
);
|
|
expect(repeatedBundleServiceSet?.mode).toBe("isolated_stack");
|
|
expect(repeatedBundleServiceSet?.source_service_set_id).toBeNull();
|
|
for (const service of Object.values(repeatedBundleServiceSet?.data_services || {})) {
|
|
expect(service.options.isolated_stack).toBe(true);
|
|
expect(service.options.production_data_attached).toBe(false);
|
|
}
|
|
|
|
page.once("dialog", (dialog) => {
|
|
expect(dialog.message()).toContain("soft-deleted");
|
|
expect(dialog.message()).toContain("Coolify provider resources are not deleted");
|
|
dialog.accept();
|
|
});
|
|
await page.getByTestId(`release-service-set-delete-${isolatedSet.id}`).click();
|
|
await expect(page.getByTestId(`release-service-set-delete-${isolatedSet.id}`)).toHaveCount(0);
|
|
expect(state.serviceSets.find((set) => Number(set.id) === Number(isolatedSet.id))).toBeUndefined();
|
|
});
|
|
|
|
test("isolated stack mode creates a fresh stack even when an active stack has the requested name", async ({ page }) => {
|
|
const state = await boot(page);
|
|
state.targets[0].deploy_context = {
|
|
coolify_project_uuid: "project-internal",
|
|
};
|
|
state.targets.push({
|
|
id: state.nextTargetId++,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "api",
|
|
repository: "truckwash/backend-php",
|
|
branch: "canary",
|
|
coolify_instance_id: 3,
|
|
coolify_instance_label: "Production Coolify",
|
|
coolify_service_uuid: "api-canary-service",
|
|
health_url: "https://api-canary.example.test/ping",
|
|
auto_deploy: true,
|
|
deploy_context: {
|
|
coolify_project_uuid: "project-internal",
|
|
coolify_build_pack: "dockerfile",
|
|
},
|
|
});
|
|
|
|
const activeStack = {
|
|
id: state.nextServiceSetId++,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
name: "Internal safe stack",
|
|
slug: "internal-safe-stack",
|
|
mode: "isolated_stack",
|
|
status: "isolated_stack",
|
|
active: true,
|
|
targets: {
|
|
frontend: {
|
|
id: 201,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "frontend",
|
|
repository: "truckwash/front-end-vue",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
deploy_context: { isolated_stack: true, production_data_attached: false },
|
|
},
|
|
api: {
|
|
id: 202,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "api",
|
|
repository: "truckwash/backend-php",
|
|
branch: "canary",
|
|
deploy_context: { isolated_stack: true, production_data_attached: false },
|
|
},
|
|
},
|
|
data_services: { database: null, redis: null, minio: null },
|
|
attached_bundles: [],
|
|
};
|
|
completeIsolatedDataServices(state, activeStack);
|
|
state.serviceSets.unshift(activeStack);
|
|
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=canary", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await selectReleaseTab(page, "Deployments");
|
|
await expandActiveReleaseCategory(page);
|
|
|
|
const bundleFlow = page.getByTestId("release-bundle-flow");
|
|
await bundleFlow.getByTestId("release-bundle-channel").selectOption("2");
|
|
await bundleFlow.getByTestId("release-dataset-mode-isolated_stack").click();
|
|
await bundleFlow.getByPlaceholder("canary fresh data").fill("Internal safe stack");
|
|
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
|
await bundleFlow.getByRole("button", { name: "Next" }).click();
|
|
|
|
await page.getByTestId("release-bundle-deploy-submit").click();
|
|
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
|
|
|
|
const deployedServiceSetId = state.bundlePayloads[0].service_set_id;
|
|
expect(deployedServiceSetId).not.toBe(activeStack.id);
|
|
expect(state.serviceSets.find((set) => Number(set.id) === Number(activeStack.id))?.active).toBe(true);
|
|
expect(
|
|
state.serviceSets.filter((set) => set.mode === "isolated_stack" && set.slug === "internal-safe-stack")
|
|
).toHaveLength(2);
|
|
expect(
|
|
state.targets.filter((target) => target.deploy_context?.isolated_stack && target.id !== 201 && target.id !== 202)
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
test("existing isolated stacks can add missing data services safely", async ({ page }) => {
|
|
const state = createReleaseState();
|
|
state.serviceSets.unshift({
|
|
id: state.nextServiceSetId++,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
name: "Internal isolated stack",
|
|
slug: "internal-isolated-stack",
|
|
mode: "isolated_stack",
|
|
status: "needs_isolated_targets",
|
|
targets: {
|
|
frontend: {
|
|
id: 101,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "frontend",
|
|
repository: "truckwash/front-end-vue",
|
|
branch: DEFAULT_RELEASE_BRANCH,
|
|
deploy_context: { isolated_stack: true, coolify_auto_create: true, production_data_attached: false },
|
|
},
|
|
api: {
|
|
id: 102,
|
|
channel_id: 2,
|
|
channel_slug: "canary",
|
|
app: "api",
|
|
repository: "truckwash/backend-php",
|
|
branch: "canary",
|
|
deploy_context: { isolated_stack: true, coolify_auto_create: true, production_data_attached: false },
|
|
},
|
|
},
|
|
data_services: { database: null, redis: null, minio: null },
|
|
attached_bundles: [],
|
|
});
|
|
|
|
await boot(page, state);
|
|
await page.goto("/superuser/configuration/releases/overview?channel=canary&app=all&branch=canary", {
|
|
waitUntil: "domcontentloaded",
|
|
});
|
|
await selectReleaseTab(page, "Deployments");
|
|
await expandActiveReleaseCategory(page);
|
|
|
|
await expect(page.getByTestId("release-service-set-cards")).toContainText("Internal isolated stack");
|
|
await page.getByTestId("release-service-set-complete-isolated-data-2").click();
|
|
await expect(page.getByTestId("release-service-set-complete-isolated-data-2")).toHaveCount(0);
|
|
|
|
const serviceSet = state.serviceSets.find((set) => set.slug === "internal-isolated-stack");
|
|
expect(serviceSet.data_services.database.options.production_data_attached).toBe(false);
|
|
expect(serviceSet.data_services.redis.options.skip_replication_provisioning).toBe(true);
|
|
expect(serviceSet.data_services.minio.options.isolated_stack).toBe(true);
|
|
});
|