Files
pleno-vue/tests/e2e/release-manager.spec.js
T

1188 lines
48 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
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,
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,
},
],
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,
},
api: null,
},
data_services: {
database: {
id: 10,
kind: "database",
label: "Canary MariaDB",
deployment_status: "running",
replication: { id: 100, label: "mariadb-canary", status: "ok", role: "replica" },
},
redis: {
id: 11,
kind: "redis",
label: "Canary Redis",
deployment_status: "running",
replication: { id: 101, label: "redis-canary", status: "ok", role: "replica" },
},
minio: {
id: 12,
kind: "minio",
label: "Canary MinIO",
deployment_status: "running",
replication: { id: 102, label: "minio-canary", status: "ok", role: "replica" },
},
},
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 summary(state) {
return {
channels: state.channels,
assignments: state.assignments,
deployment_targets: state.targets,
service_sets: state.serviceSets.map((set) => ({
...set,
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,
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, "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_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: "release/canary",
health_url: "https://api-canary.example.test/ping",
auto_deploy: true,
},
],
},
};
}
function channelSlug(state, id) {
return state.channels.find((channel) => Number(channel.id) === Number(id))?.slug || "stable";
}
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()) {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
await mockApi(page, {
authenticated: true,
permissions,
sessionData: {
runtime_config: {
release: {
trace_id: "trace-e2e",
channel: { slug: "canary", name: "Canary" },
versions: { frontend: { version_label: "frontend-canary" } },
capture_policy: { enabled: true, capture_level: "full_redacted" },
},
},
},
});
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") {
await route.fulfill(json({ data: summary(state) }));
return;
}
if (pathname.endsWith("/superuser/releases/config") && method === "GET") {
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") {
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: "release/canary",
description: "Truckwash API",
},
],
},
})
);
return;
}
if (pathname.endsWith("/superuser/releases/github/branches") && method === "GET") {
const repository = url.searchParams.get("repository") || "truckwash/front-end-vue";
await route.fulfill(
json({
data: {
token_configured: true,
repository,
branches: [
{ name: DEFAULT_RELEASE_BRANCH, commit_sha: "c0ffee", protected: true },
{ name: "release/canary", commit_sha: "feedface", protected: false },
],
},
})
);
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;
await route.fulfill(
json({
data: {
token_configured: true,
repository,
branch,
commits: [
{
sha: "feedface00000000000000000000000000000000",
short_sha: "feedface0000",
message: "Deploy canary build",
author_name: "Release Bot",
authored_at: "2026-05-19T08:10:00.000Z",
},
{
sha: "c0ffee0000000000000000000000000000000000",
short_sha: "c0ffee000000",
message: "Previous release",
author_name: "Release Bot",
authored_at: "2026-05-19T08:00:00.000Z",
},
],
},
})
);
return;
}
if (pathname.endsWith("/superuser/releases/github/test") && method === "POST") {
const payload = request.postDataJSON?.() || {};
const latestSha = payload.branch === "release/canary" ? "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/assignments") && method === "POST") {
const payload = request.postDataJSON?.() || {};
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?.() || {};
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 || {},
};
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 (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 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: payload.mode || "attach_existing",
source_service_set_id: payload.source_service_set_id || null,
status:
payload.mode === "clone_existing"
? "provisioning"
: payload.mode === "fresh_empty"
? "isolated_empty"
: payload.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(payload.mode)
? { database: null, redis: null, minio: null }
: source?.data_services || {
database: null,
redis: null,
minio: null,
},
attached_bundles: [],
};
if (payload.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+\/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?.() || {};
const serviceSet = state.serviceSets.find((entry) => Number(entry.id) === Number(payload.service_set_id));
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: payload.frontend?.commit_mode === "specific" ? payload.frontend?.commit_sha : "c0ffee",
},
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: payload.api?.commit_mode === "specific" ? payload.api?.commit_sha : "feedface",
},
},
};
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);
if (bundle) {
bundle.status = "promoted";
}
await route.fulfill(json({ data: { bundle } }));
return;
}
if (pathname.endsWith("/superuser/releases/deployments") && method === "POST") {
const payload = request.postDataJSON?.() || {};
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") {
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") {
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 expandActiveReleaseCategory(page) {
await expect(page.getByTestId("release-manager-page")).toBeVisible();
}
async function selectReleaseTab(page, name) {
await page
.getByTestId("release-section-tabs")
.getByRole("tab", { name: new RegExp(name) })
.click();
}
test("superusers manage release channels, assignments, deployments, and replay", async ({ page }) => {
await boot(page);
await page.goto("/superuser/configuration/releases", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("release-manager-page")).toBeVisible({ timeout: 30_000 });
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")).toContainText("https://api.truckwash.io:4433");
await expect(page.getByTestId("release-guided-setup")).toContainText("Guided setup");
await expect(page.getByTestId("release-guided-setup")).toContainText("Frontend target");
await expect(page.getByTestId("release-channels-overview")).toContainText("Canary");
await expect(
page.getByTestId("release-channels-overview").locator('[data-testid^="release-channel-actions-"]').first()
).toBeVisible();
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, "Channels");
await expandActiveReleaseCategory(page);
await page.getByTestId("release-channel-presets").getByText("Internal").click();
await expect(page.getByTestId("release-channel-form").getByPlaceholder("slug")).toHaveValue("internal");
await page.getByTestId("release-channel-form").getByRole("button", { name: "Create" }).click();
await selectReleaseTab(page, "Overview");
await expect(page.getByTestId("release-channels-overview")).toContainText("internal");
await selectReleaseTab(page, "Assignments");
await expandActiveReleaseCategory(page);
await page.getByTestId("release-assignment-subject-type").selectOption("customer");
await page.getByTestId("release-assignment-customer-search").fill("Acme");
await expect(page.getByTestId("release-assignment-customer-results")).toContainText("Acme Transport");
await expect(page.getByTestId("release-assignment-customer-search")).toBeFocused();
await page.getByTestId("release-assignment-customer-option-424242").click();
await expect(page.getByTestId("release-assignment-customer-search")).toHaveValue("Acme Transport (424242)");
await page.getByTestId("release-assignment-form").getByRole("button", { name: "Assign" }).click();
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 selectReleaseTab(page, "Integrations");
await expandActiveReleaseCategory(page);
await page.getByTestId("release-target-presets").getByText("API target").click();
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();
await expect(page.getByRole("button", { name: /api-v2\.truckwash\.io/ })).toBeVisible();
const domainOptions = page
.locator(".autocomplete .dropdown-content")
.filter({ hasText: "api-v2.truckwash.io" })
.last();
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 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(page.getByTestId("release-targets-table")).toContainText("truckwash/backend-php#release/canary");
await expect(
page.getByTestId("release-targets-table").locator('[data-testid^="release-target-actions-"]').first()
).toBeVisible();
await selectReleaseTab(page, "Deployments");
await expandActiveReleaseCategory(page);
await expect(page.getByTestId("release-service-set-cards")).toContainText("Canary shared stack");
const bundleFlow = page.getByTestId("release-bundle-flow");
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(bundleFlow.getByPlaceholder("owner/frontend")).toHaveValue("truckwash/front-end-vue");
await expect(bundleFlow.getByPlaceholder("owner/backend-php")).toHaveValue("truckwash/backend-php");
await expect(bundleFlow.getByPlaceholder(DEFAULT_RELEASE_BRANCH).first()).toHaveValue(DEFAULT_RELEASE_BRANCH);
await expect(bundleFlow.getByPlaceholder(DEFAULT_RELEASE_BRANCH).nth(1)).toHaveValue(DEFAULT_RELEASE_BRANCH);
await bundleFlow.getByRole("button", { name: "Next" }).click();
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Frontend");
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("PHP backend");
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Database");
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Redis");
await expect(page.getByTestId("release-bundle-review-stack")).toContainText("Minio");
await page.getByTestId("release-bundle-deploy-submit").click();
await expect(page.getByTestId("release-created-bundle")).toContainText("Bundle deployed");
await expect(page.getByTestId("release-bundles-table")).toContainText("truckwash/front-end-vue");
await expect(page.getByTestId("release-bundles-table")).toContainText("truckwash/backend-php");
await expect(
page.getByTestId("release-bundles-table").locator('[data-testid^="release-bundle-actions-"]').first()
).toBeVisible();
await expect(page.getByTestId("release-deployments-table")).toContainText("feedface");
await expect(
page.getByTestId("release-deployments-table").locator('[data-testid^="release-deployment-actions-"]').first()
).toBeVisible();
const failedDeploymentRow = page
.getByTestId("release-deployments-table")
.getByRole("row", { name: /Composer install failed in the Coolify build/i });
await expect(failedDeploymentRow).toContainText("Run the PHP test suite in the php1 container");
await failedDeploymentRow.locator('[data-testid^="release-deployment-actions-"] button').first().click();
await expect(failedDeploymentRow.getByRole("button", { name: "Promote" })).toBeDisabled();
await selectReleaseTab(page, "Replay");
await expandActiveReleaseCategory(page);
await page.getByTestId("release-replay-form").getByPlaceholder("Target id or channel slug").fill("42");
await page.getByTestId("release-replay-form").getByRole("button", { name: "Enable" }).click();
await page.getByTestId("release-timeline-filter-form").getByPlaceholder("Trace id").fill("trace-canary-1");
await page.getByTestId("release-timeline-filter-form").getByRole("button", { name: "Search" }).click();
await expect(page.getByTestId("release-timeline-sessions")).toContainText("Acme Dispatcher");
await expect(page.getByTestId("release-timeline-sessions")).toContainText("desktop / Chrome / Windows");
await expect(page.getByTestId("release-timeline-events")).toContainText("request_failed");
const sessionActions = page.getByTestId("release-session-actions-trace-canary-1");
await sessionActions.getByRole("button").first().click();
await sessionActions.getByRole("button", { name: "Inspect" }).click();
await expect(page.getByTestId("release-timeline-session-modal")).toBeVisible();
await page.getByTestId("release-timeline-session-detail").getByRole("button", { name: "Release" }).click();
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("frontend-canary");
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("#21 deployed");
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("#31 canary-bundle");
await page.getByTestId("release-timeline-session-detail").getByRole("button", { name: "Device" }).click();
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("Chrome");
await page.getByTestId("release-timeline-session-detail").getByRole("button", { name: "Error reports" }).click();
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("#17");
await expect(page.getByTestId("release-timeline-session-detail")).toContainText("Visual replay");
});
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",
coolify_github_app_uuid: "github-app-copenhagentruckwash-github",
coolify_build_pack: "nixpacks",
};
state.targets.push({
id: state.nextTargetId++,
channel_id: 2,
channel_slug: "canary",
app: "api",
repository: "truckwash/backend-php",
branch: "release/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_github_app_uuid: "github-app-copenhagentruckwash-github",
coolify_build_pack: "dockerfile",
},
});
await page.goto("/superuser/configuration/releases", { 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-bundle-dataset-mode").selectOption("isolated_stack");
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 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 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.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" : "nixpacks");
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();
}
});
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: "release/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", { 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);
});