1188 lines
48 KiB
JavaScript
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);
|
|
});
|