Add runtime support for service sets, release management enhancements, and new unit tests
- Extend `releaseTimeline.js` and related modules for runtime-aware service sets and URLs. - Update the release session builder to include `service_set` handling. - Introduce release service definitions, status tones, and enhanced runtime display text. - Add new dataset mode options, preview rows, and warnings for release configurations in `ConfigurationReleaseManager.vue`. - Implement extensive utility methods for data service handling, target context, and deployment endpoints. - Add `vite-api-proxy.spec.js` and extend `release-bootstrap.spec.js` to ensure coverage for new functionality.
This commit is contained in:
@@ -2,7 +2,11 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import axios from "axios";
|
||||
import { REQUEST_QUEUE_CONFIG } from "@/config.js";
|
||||
import { getReleaseRuntimeApiBaseUrl, resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
|
||||
import {
|
||||
buildReleaseSessionSummary,
|
||||
getReleaseRuntimeApiBaseUrl,
|
||||
resolveReleaseApiUrl,
|
||||
} from "@/services/releaseTimeline.js";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import {
|
||||
clearErrorRequests,
|
||||
@@ -280,6 +284,7 @@ const resolvePingUrl = () => {
|
||||
};
|
||||
|
||||
const activeApiUrl = computed(() => getReleaseRuntimeApiBaseUrl());
|
||||
const releaseSessionSummary = computed(() => buildReleaseSessionSummary());
|
||||
|
||||
const measurePingLatency = async () => {
|
||||
if (typeof fetch !== "function") {
|
||||
@@ -641,60 +646,186 @@ onBeforeUnmount(() => {
|
||||
</aside>
|
||||
|
||||
<aside class="request-queue-progress__side request-queue-progress__side--runtime" data-testid="request-queue-runtime-box">
|
||||
<div class="request-queue-progress__section-title">Runtime</div>
|
||||
<ul class="request-queue-progress__meta-list request-queue-progress__section-content request-queue-progress__section-content--meta">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">API URL</span>
|
||||
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Current host</span>
|
||||
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Environment</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Commit</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version time</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
|
||||
<span class="request-queue-progress__meta-label">i18n catalog</span>
|
||||
<button
|
||||
class="request-queue-progress__catalog-switch"
|
||||
data-testid="request-queue-i18n-catalog-switch"
|
||||
type="button"
|
||||
@click="handleToggleI18nCatalogVersion"
|
||||
<div class="request-queue-progress__section-title">Session release</div>
|
||||
<div class="request-queue-progress__section-content request-queue-progress__section-content--release">
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Channel</span>
|
||||
<span
|
||||
class="request-queue-progress__meta-value"
|
||||
:title="releaseSessionSummary.channelSlug || releaseSessionSummary.channelLabel"
|
||||
>
|
||||
{{ releaseSessionSummary.channelLabel }}
|
||||
<span v-if="releaseSessionSummary.channelSlug">({{ releaseSessionSummary.channelSlug }})</span>
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Trace ID</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.traceId">
|
||||
{{ releaseSessionSummary.traceId }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Generated</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.generatedAt">
|
||||
{{ releaseSessionSummary.generatedAt }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Bundle</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.bundleLabel">
|
||||
{{ releaseSessionSummary.bundleLabel }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Service set</span>
|
||||
<span class="request-queue-progress__meta-value" :title="releaseSessionSummary.serviceSetLabel">
|
||||
{{ releaseSessionSummary.serviceSetLabel }}
|
||||
</span>
|
||||
</li>
|
||||
<li
|
||||
v-if="releaseSessionSummary.missingLabels.length > 0"
|
||||
class="request-queue-progress__meta-item"
|
||||
data-testid="request-queue-release-missing"
|
||||
>
|
||||
{{ i18nCatalogVersionLabel }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing requests</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing responses</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<span class="request-queue-progress__meta-label">Missing</span>
|
||||
<span
|
||||
class="request-queue-progress__meta-value request-queue-progress__meta-value--warning"
|
||||
:title="releaseSessionSummary.missingLabels.join(', ')"
|
||||
>
|
||||
{{ releaseSessionSummary.missingLabels.join(", ") }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">App releases</div>
|
||||
<ul class="request-queue-progress__release-list">
|
||||
<li
|
||||
v-for="app in releaseSessionSummary.appRows"
|
||||
:key="app.key"
|
||||
class="request-queue-progress__release-row"
|
||||
:data-testid="`request-queue-release-app-${app.key}`"
|
||||
>
|
||||
<div class="request-queue-progress__release-row-header">
|
||||
<strong>{{ app.label }}</strong>
|
||||
<span
|
||||
class="request-queue-progress__status-pill"
|
||||
:class="`request-queue-progress__status-pill--${app.tone}`"
|
||||
>
|
||||
{{ app.status }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="request-queue-progress__release-primary" :title="app.title">
|
||||
{{ app.primaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.secondaryText"
|
||||
class="request-queue-progress__release-secondary"
|
||||
:title="app.secondaryText"
|
||||
>
|
||||
{{ app.secondaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.url"
|
||||
class="request-queue-progress__release-url"
|
||||
:title="app.url"
|
||||
>
|
||||
{{ app.url }}
|
||||
</span>
|
||||
<span
|
||||
v-if="app.missingLabel"
|
||||
class="request-queue-progress__release-secondary request-queue-progress__release-secondary--warning"
|
||||
>
|
||||
{{ app.missingLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">Connected services</div>
|
||||
<ul class="request-queue-progress__release-list">
|
||||
<li
|
||||
v-for="service in releaseSessionSummary.serviceRows"
|
||||
:key="service.key"
|
||||
class="request-queue-progress__release-row"
|
||||
:data-testid="`request-queue-release-service-${service.key}`"
|
||||
>
|
||||
<div class="request-queue-progress__release-row-header">
|
||||
<strong>{{ service.label }}</strong>
|
||||
<span
|
||||
class="request-queue-progress__status-pill"
|
||||
:class="`request-queue-progress__status-pill--${service.tone}`"
|
||||
>
|
||||
{{ service.status }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="request-queue-progress__release-primary" :title="service.title">
|
||||
{{ service.primaryText }}
|
||||
</span>
|
||||
<span
|
||||
v-if="service.secondaryText"
|
||||
class="request-queue-progress__release-secondary"
|
||||
:title="service.secondaryText"
|
||||
>
|
||||
{{ service.secondaryText }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="request-queue-progress__subsection-title">Runtime details</div>
|
||||
<ul class="request-queue-progress__meta-list">
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">API URL</span>
|
||||
<span class="request-queue-progress__meta-value" :title="activeApiUrl">{{ activeApiUrl }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Current host</span>
|
||||
<span class="request-queue-progress__meta-value" :title="currentUrlHost">{{ currentUrlHost }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Environment</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appEnvironmentLabel }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Commit</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appCommitHash }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appVersion }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Version time</span>
|
||||
<span class="request-queue-progress__meta-value">{{ appBuildTime }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item" data-testid="request-queue-i18n-catalog-row">
|
||||
<span class="request-queue-progress__meta-label">i18n catalog</span>
|
||||
<button
|
||||
class="request-queue-progress__catalog-switch"
|
||||
data-testid="request-queue-i18n-catalog-switch"
|
||||
type="button"
|
||||
@click="handleToggleI18nCatalogVersion"
|
||||
>
|
||||
{{ i18nCatalogVersionLabel }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing requests</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.outgoingRequests }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing responses</span>
|
||||
<span class="request-queue-progress__meta-value">{{ networkTotals.ingoingResponses }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Outgoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.outgoingBytes) }}</span>
|
||||
</li>
|
||||
<li class="request-queue-progress__meta-item">
|
||||
<span class="request-queue-progress__meta-label">Ingoing bandwidth</span>
|
||||
<span class="request-queue-progress__meta-value">{{ formatBytes(networkTotals.ingoingBytes) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside class="request-queue-progress__side request-queue-progress__side--user" data-testid="request-queue-user-box">
|
||||
@@ -931,6 +1062,12 @@ onBeforeUnmount(() => {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.request-queue-progress__section-content--release {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.request-queue-progress__section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -1170,6 +1307,108 @@ onBeforeUnmount(() => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__meta-value--warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.request-queue-progress__subsection-title {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
padding-top: 7px;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 7px;
|
||||
background: rgba(2, 6, 23, 0.28);
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-row-header strong {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill {
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
padding: 3px 7px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--ok {
|
||||
background: rgba(94, 234, 212, 0.16);
|
||||
color: #99f6e4;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--warning {
|
||||
background: rgba(251, 191, 36, 0.16);
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.request-queue-progress__status-pill--danger {
|
||||
background: rgba(248, 113, 113, 0.16);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-primary,
|
||||
.request-queue-progress__release-secondary,
|
||||
.request-queue-progress__release-url {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-primary {
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-secondary,
|
||||
.request-queue-progress__release-url {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.request-queue-progress__release-secondary--warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.request-queue-progress__catalog-switch {
|
||||
border: 1px solid rgba(126, 249, 227, 0.5);
|
||||
border-radius: 999px;
|
||||
|
||||
@@ -859,7 +859,11 @@ export const createOrder = async (options = { isMobile: false }) => {
|
||||
parseError(response, "stepError");
|
||||
return false;
|
||||
}
|
||||
order_id.value = parseInt(response.data.data.id);
|
||||
const createdOrder = response.data.data || {};
|
||||
order_id.value = parseInt(createdOrder.id);
|
||||
if (!isBlankPosMetadataValue(createdOrder.po)) {
|
||||
order_po.value = String(createdOrder.po);
|
||||
}
|
||||
if (selectedDepartmentId) {
|
||||
department_id.value = selectedDepartmentId;
|
||||
}
|
||||
@@ -2078,11 +2082,6 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const currentOrderItems = await loadOrderItems();
|
||||
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let booking = getSelectedPendingOrderBooking();
|
||||
if (!booking || !Array.isArray(booking?.items)) {
|
||||
booking = await SessionUser.objects.order_bookings.get.single(normalizedBookingId, {
|
||||
@@ -2124,11 +2123,16 @@ const hydrateSelectedOrderBookingForDesktop = async () => {
|
||||
}
|
||||
|
||||
const bookingPo = String(booking?.po ?? "").trim();
|
||||
if (bookingPo !== "" && bookingPo !== order_po.value) {
|
||||
if (bookingPo !== "" && isBlankPosMetadataValue(order_po.value)) {
|
||||
await SessionUser.objects.orders.set.po(normalizedOrderId, bookingPo);
|
||||
order_po.value = bookingPo;
|
||||
}
|
||||
|
||||
const currentOrderItems = await loadOrderItems();
|
||||
if (Array.isArray(currentOrderItems) && currentOrderItems.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const bookingItems = Array.isArray(booking?.items) ? booking.items : [];
|
||||
if (bookingItems.length === 0) {
|
||||
return true;
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@ export const IS_DEV = parseBoolean(import.meta.env.VITE_IS_DEV, import.meta.env.
|
||||
// Development mode
|
||||
export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1);
|
||||
export const API_URL = normalizeApiUrl(
|
||||
import.meta.env.VITE_API_URL || (IS_DEV ? "https://api.truckwash.io:4433" : "https://api.truckwash.io")
|
||||
import.meta.env.VITE_API_URL || (IS_DEV ? "/api" : "https://api.truckwash.io")
|
||||
);
|
||||
export const RELEASE_MANAGER_CONTROL_API_URL = normalizeApiUrl(
|
||||
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_URL || API_URL
|
||||
@@ -31,7 +31,7 @@ export const RELEASE_PUBLIC_GATEWAY_API_URL = normalizeApiUrl(
|
||||
);
|
||||
export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String(
|
||||
import.meta.env.VITE_RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS ||
|
||||
(IS_DEV ? "https://api.truckwash.io:4433,https://api.truckwash.io" : "")
|
||||
(IS_DEV ? "https://api-v2.truckwash.io,https://api.truckwash.io" : "")
|
||||
)
|
||||
.split(",")
|
||||
.map((url) => normalizeApiUrl(url.trim()))
|
||||
|
||||
@@ -16,6 +16,24 @@ const normalizeReleaseChannelSlug = (value) =>
|
||||
|
||||
const normalizeBaseUrl = (value) => String(value || "").trim().replace(/\/+$/, "");
|
||||
|
||||
const browserOrigin = () => {
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
return window.location.origin;
|
||||
}
|
||||
return "http://localhost";
|
||||
};
|
||||
|
||||
const resolveRuntimeBaseUrl = (value) => {
|
||||
const baseUrl = normalizeBaseUrl(value);
|
||||
if (!baseUrl) {
|
||||
return "";
|
||||
}
|
||||
if (/^https?:\/\//i.test(baseUrl)) {
|
||||
return baseUrl;
|
||||
}
|
||||
return new URL(`${baseUrl.replace(/^\/+/, "")}/`, `${browserOrigin()}/`).href.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
const browserStorage = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
@@ -64,7 +82,7 @@ const buildRuntimeHeaders = () => {
|
||||
export const runtimeApiUrl = (apiBaseUrl = API_URL, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
|
||||
const selectedChannel = readSelectedReleaseChannel();
|
||||
const selectedChannelApiBaseUrl = releaseChannelGatewayApiBaseUrl(selectedChannel, gatewayBaseUrl);
|
||||
const runtimeBaseUrl = selectedChannelApiBaseUrl || normalizeBaseUrl(apiBaseUrl);
|
||||
const runtimeBaseUrl = selectedChannelApiBaseUrl || resolveRuntimeBaseUrl(apiBaseUrl);
|
||||
const url = new URL("release/runtime", `${runtimeBaseUrl}/`);
|
||||
if (selectedChannel) {
|
||||
url.searchParams.set("release_channel", selectedChannel);
|
||||
|
||||
@@ -44,6 +44,7 @@ const releaseRuntimeStateMutable = reactive({
|
||||
versions: {
|
||||
frontend: null,
|
||||
api: null,
|
||||
service_set: null,
|
||||
bundle_id: null,
|
||||
bundle: null,
|
||||
},
|
||||
@@ -75,10 +76,16 @@ const hasOwn = (value, key) => Boolean(value && Object.prototype.hasOwnProperty.
|
||||
|
||||
const normalizeRuntimeBaseUrl = (value) => {
|
||||
const raw = String(value || "").trim().replace(/\/+$/, "");
|
||||
if (!raw || !/^https?:\/\//i.test(raw)) {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return raw;
|
||||
if (raw.startsWith("/") && !raw.startsWith("//")) {
|
||||
return raw;
|
||||
}
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const runtimeUrls = (runtime = {}) => {
|
||||
@@ -117,6 +124,7 @@ export const configureReleaseRuntime = (runtime = {}) => {
|
||||
releaseRuntimeStateMutable.versions = {
|
||||
frontend: runtime?.versions?.frontend || null,
|
||||
api: runtime?.versions?.api || null,
|
||||
service_set: runtime?.versions?.service_set || null,
|
||||
bundle_id: runtime?.versions?.bundle_id || null,
|
||||
bundle: runtime?.versions?.bundle || null,
|
||||
};
|
||||
@@ -334,6 +342,322 @@ const currentDeviceType = () => {
|
||||
const versionLabel = (version) => version?.version_label || version?.label || null;
|
||||
const commitSha = (version) => version?.commit_sha || version?.commit || null;
|
||||
|
||||
const RELEASE_SERVICE_DEFINITIONS = Object.freeze([
|
||||
{ key: "frontend", label: "Frontend", kind: "app" },
|
||||
{ key: "api", label: "API", kind: "app" },
|
||||
{ key: "database", label: "Database", kind: "data" },
|
||||
{ key: "redis", label: "Redis", kind: "data" },
|
||||
{ key: "minio", label: "MinIO", kind: "data" },
|
||||
]);
|
||||
|
||||
const RELEASE_MISSING_LABELS = Object.freeze({
|
||||
release_bundle: "Release bundle",
|
||||
frontend_version: "Frontend version",
|
||||
frontend_base_url: "Frontend URL",
|
||||
api_version: "API version",
|
||||
api_base_url: "API URL",
|
||||
database_service: "Database service",
|
||||
redis_service: "Redis service",
|
||||
minio_service: "MinIO service",
|
||||
});
|
||||
|
||||
const isPlainRecord = (value) => Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
|
||||
const firstFilledString = (...values) => {
|
||||
for (const value of values) {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const releaseRuntimeUrlsForDisplay = (runtime = {}) => {
|
||||
const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {};
|
||||
return {
|
||||
frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url),
|
||||
api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url),
|
||||
};
|
||||
};
|
||||
|
||||
const releaseRuntimeTraceId = (runtime = {}) => firstFilledString(runtime.traceId, runtime.trace_id);
|
||||
|
||||
const releaseRuntimeGeneratedAt = (runtime = {}) => firstFilledString(runtime.generatedAt, runtime.generated_at);
|
||||
|
||||
const releaseShortText = (value, length = 12) => {
|
||||
const normalized = firstFilledString(value);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized.length > length ? normalized.slice(0, length) : normalized;
|
||||
};
|
||||
|
||||
const releaseCommitValue = (version = null) => {
|
||||
if (!isPlainRecord(version)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const commit = version.commit;
|
||||
if (isPlainRecord(commit)) {
|
||||
return firstFilledString(commit.sha, commit.commit_sha);
|
||||
}
|
||||
return firstFilledString(version.commit_sha, commit);
|
||||
};
|
||||
|
||||
const releaseVersionPrimaryText = (version = null, fallback = "Missing version") => {
|
||||
if (!isPlainRecord(version)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return firstFilledString(version.version_label, version.tag, releaseShortText(releaseCommitValue(version)), fallback);
|
||||
};
|
||||
|
||||
const releaseVersionSecondaryText = (version = null) => {
|
||||
if (!isPlainRecord(version)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
const commit = releaseShortText(releaseCommitValue(version));
|
||||
const repository = firstFilledString(version.repository);
|
||||
const branch = firstFilledString(version.branch);
|
||||
|
||||
if (commit && commit !== firstFilledString(version.version_label, version.tag)) {
|
||||
parts.push(commit);
|
||||
}
|
||||
if (repository || branch) {
|
||||
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
|
||||
}
|
||||
return parts.join(" - ");
|
||||
};
|
||||
|
||||
const releaseStatusTone = (status = "") => {
|
||||
const normalized = String(status || "").trim().toLowerCase();
|
||||
if (
|
||||
[
|
||||
"failed",
|
||||
"error",
|
||||
"critical",
|
||||
"service_unhealthy",
|
||||
"unhealthy",
|
||||
"degraded",
|
||||
"reconcile_failed",
|
||||
"restart_failed",
|
||||
"provision_blocked",
|
||||
].includes(normalized)
|
||||
) {
|
||||
return "danger";
|
||||
}
|
||||
if (
|
||||
[
|
||||
"missing",
|
||||
"missing_value",
|
||||
"not_configured",
|
||||
"unconfigured",
|
||||
"deployment_in_progress",
|
||||
"warning",
|
||||
"pending",
|
||||
"queued",
|
||||
"running",
|
||||
"deploying",
|
||||
"building",
|
||||
"provisioning",
|
||||
"unknown",
|
||||
].includes(normalized)
|
||||
) {
|
||||
return "warning";
|
||||
}
|
||||
return "ok";
|
||||
};
|
||||
|
||||
const releaseServiceStatus = (service = null, fallback = "connected") =>
|
||||
firstFilledString(
|
||||
service?.deployment_status,
|
||||
service?.availability_state,
|
||||
service?.status,
|
||||
service?.state,
|
||||
fallback
|
||||
);
|
||||
|
||||
const releaseServicePrimaryText = (service = null) => {
|
||||
if (!isPlainRecord(service)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const name = firstFilledString(
|
||||
service.resource_name,
|
||||
service.label,
|
||||
service.coolify_service_uuid,
|
||||
service.health_url,
|
||||
service.repository
|
||||
);
|
||||
const id = Number(service.id || service.target_id || 0);
|
||||
return [name, id > 0 ? `#${id}` : ""].filter(Boolean).join(" ");
|
||||
};
|
||||
|
||||
const releaseServiceSecondaryText = (service = null) => {
|
||||
if (!isPlainRecord(service)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
const resourceUuid = firstFilledString(service.resource_uuid);
|
||||
const coolifyServiceUuid = firstFilledString(service.coolify_service_uuid);
|
||||
const instanceLabel = firstFilledString(service.instance_label, service.coolify_instance_label);
|
||||
const repository = firstFilledString(service.repository);
|
||||
const branch = firstFilledString(service.branch);
|
||||
const replication = isPlainRecord(service.replication) ? service.replication : null;
|
||||
const replicationStatus = firstFilledString(replication?.last_status?.status, replication?.status);
|
||||
|
||||
if (resourceUuid) {
|
||||
parts.push(`resource ${releaseShortText(resourceUuid)}`);
|
||||
}
|
||||
if (coolifyServiceUuid && coolifyServiceUuid !== resourceUuid) {
|
||||
parts.push(`service ${releaseShortText(coolifyServiceUuid)}`);
|
||||
}
|
||||
if (repository || branch) {
|
||||
parts.push(branch ? `${repository || "repository"}#${branch}` : repository);
|
||||
}
|
||||
if (instanceLabel) {
|
||||
parts.push(instanceLabel);
|
||||
}
|
||||
if (replicationStatus) {
|
||||
parts.push(`replication ${replicationStatus}`);
|
||||
}
|
||||
return parts.join(" - ");
|
||||
};
|
||||
|
||||
const releaseServiceForKey = (serviceSet = null, key = "") => {
|
||||
if (!isPlainRecord(serviceSet)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stack = isPlainRecord(serviceSet.stack) ? serviceSet.stack : {};
|
||||
const dataServices = isPlainRecord(serviceSet.data_services) ? serviceSet.data_services : {};
|
||||
const targets = isPlainRecord(serviceSet.targets) ? serviceSet.targets : {};
|
||||
const service = stack[key] || dataServices[key] || targets[key] || null;
|
||||
return isPlainRecord(service) ? service : null;
|
||||
};
|
||||
|
||||
export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable) => {
|
||||
const versions = isPlainRecord(runtime?.versions) ? runtime.versions : {};
|
||||
const channel = isPlainRecord(runtime?.channel) ? runtime.channel : null;
|
||||
const availability = isPlainRecord(runtime?.availability) ? runtime.availability : {};
|
||||
const missing = Array.isArray(availability.missing)
|
||||
? availability.missing.map((value) => String(value || "").trim()).filter(Boolean)
|
||||
: [];
|
||||
const missingLookup = new Set(missing);
|
||||
const isDefaultChannel =
|
||||
channel?.default_channel === true
|
||||
|| channel?.default_channel === 1
|
||||
|| String(channel?.slug || "").toLowerCase() === "stable";
|
||||
const urls = releaseRuntimeUrlsForDisplay(runtime);
|
||||
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
|
||||
const apiVersion = isPlainRecord(versions.api) ? versions.api : null;
|
||||
const bundle = isPlainRecord(versions.bundle) ? versions.bundle : null;
|
||||
const bundleId = versions.bundle_id || bundle?.id || null;
|
||||
const serviceSet = isPlainRecord(versions.service_set)
|
||||
? versions.service_set
|
||||
: isPlainRecord(bundle?.service_set)
|
||||
? bundle.service_set
|
||||
: null;
|
||||
const defaultSharedLabel = "Default/shared runtime";
|
||||
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
|
||||
|
||||
const buildAppRow = (key, label, version, url) => {
|
||||
const missingVersionKey = `${key}_version`;
|
||||
const missingUrlKey = `${key}_base_url`;
|
||||
const missingKey = missingLookup.has(missingVersionKey)
|
||||
? missingVersionKey
|
||||
: missingLookup.has(missingUrlKey)
|
||||
? missingUrlKey
|
||||
: "";
|
||||
const fallback = isDefaultChannel ? defaultSharedLabel : `Missing ${label} version`;
|
||||
const status = missingKey
|
||||
? "missing value"
|
||||
: isDefaultChannel && !version && !url
|
||||
? "shared"
|
||||
: firstFilledString(version?.status, url ? "active" : "unknown");
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status,
|
||||
tone: missingKey ? "warning" : releaseStatusTone(status),
|
||||
primaryText: releaseVersionPrimaryText(version, fallback),
|
||||
secondaryText: releaseVersionSecondaryText(version),
|
||||
url: url || "",
|
||||
title: [releaseVersionPrimaryText(version, fallback), releaseVersionSecondaryText(version), url]
|
||||
.filter(Boolean)
|
||||
.join(" - "),
|
||||
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
|
||||
};
|
||||
};
|
||||
|
||||
const buildServiceRow = ({ key, label }) => {
|
||||
const service = releaseServiceForKey(serviceSet, key);
|
||||
const missingKey = missingLookup.has(`${key}_service`) ? `${key}_service` : "";
|
||||
if (service) {
|
||||
const status = releaseServiceStatus(service);
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status,
|
||||
tone: releaseStatusTone(status),
|
||||
primaryText: releaseServicePrimaryText(service) || "Connected service",
|
||||
secondaryText: releaseServiceSecondaryText(service),
|
||||
title: [releaseServicePrimaryText(service), releaseServiceSecondaryText(service), firstFilledString(service.health_url)]
|
||||
.filter(Boolean)
|
||||
.join(" - "),
|
||||
missingLabel: "",
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackText = isDefaultChannel
|
||||
? defaultSharedLabel
|
||||
: bundleId
|
||||
? (serviceSet ? `Missing ${label} service` : "No service set connected")
|
||||
: "Missing release bundle";
|
||||
const status = isDefaultChannel ? "shared" : "missing";
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
status,
|
||||
tone: isDefaultChannel ? "ok" : "warning",
|
||||
primaryText: fallbackText,
|
||||
secondaryText: "",
|
||||
title: fallbackText,
|
||||
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : fallbackText,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
channelLabel: firstFilledString(channel?.name, channel?.slug, isDefaultChannel ? "Stable" : "Unknown channel"),
|
||||
channelSlug: firstFilledString(channel?.slug),
|
||||
traceId: releaseRuntimeTraceId(runtime) || "unknown",
|
||||
generatedAt: releaseRuntimeGeneratedAt(runtime) || "unknown",
|
||||
availabilityStatus: firstFilledString(availability.status, availability.configured === false ? "unconfigured" : "ready"),
|
||||
availabilityTone: availability.configured === false || missing.length > 0
|
||||
? "warning"
|
||||
: releaseStatusTone(availability.status || "ready"),
|
||||
bundleLabel: bundleId
|
||||
? `#${bundleId}${firstFilledString(bundle?.version_label) ? ` ${bundle.version_label}` : ""}`
|
||||
: defaultSharedLabel,
|
||||
bundleStatus: firstFilledString(bundle?.status, bundleId ? "active" : "shared"),
|
||||
serviceSetLabel: serviceSet
|
||||
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
|
||||
: defaultSharedLabel,
|
||||
missingLabels,
|
||||
appRows: [
|
||||
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
|
||||
buildAppRow("api", "API", apiVersion, urls.api),
|
||||
],
|
||||
serviceRows: RELEASE_SERVICE_DEFINITIONS.map(buildServiceRow),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCurrentReleaseHeaders = () => {
|
||||
const frontendVersion = releaseRuntimeStateMutable.versions?.frontend || {};
|
||||
const fallbackFrontendVersion = import.meta.env.VITE_COMMIT_HASH || import.meta.env.VITE_APP_VERSION || "unknown";
|
||||
@@ -542,7 +866,7 @@ export const __resetReleaseTimelineForTests = () => {
|
||||
releaseRuntimeStateMutable.traceId = "test-trace";
|
||||
releaseRuntimeStateMutable.channel = null;
|
||||
releaseRuntimeStateMutable.availableChannels = [];
|
||||
releaseRuntimeStateMutable.versions = { frontend: null, api: null, bundle_id: null, bundle: null };
|
||||
releaseRuntimeStateMutable.versions = { frontend: null, api: null, service_set: null, bundle_id: null, bundle: null };
|
||||
releaseRuntimeStateMutable.frontendBaseUrl = null;
|
||||
releaseRuntimeStateMutable.apiBaseUrl = null;
|
||||
releaseRuntimeStateMutable.availability = {
|
||||
@@ -557,6 +881,7 @@ export const __resetReleaseTimelineForTests = () => {
|
||||
all_failure_metadata: true,
|
||||
retention_days: 14,
|
||||
};
|
||||
releaseRuntimeStateMutable.generatedAt = null;
|
||||
};
|
||||
|
||||
export const __setReleaseTimelineTransportForTests = (transport) => {
|
||||
|
||||
+1248
-137
File diff suppressed because it is too large
Load Diff
@@ -443,6 +443,50 @@ function createRequiredWarningsPosFixture() {
|
||||
});
|
||||
}
|
||||
|
||||
function createBookingPoDefaultPosFixture() {
|
||||
const baseFixture = createPosFixture();
|
||||
const customerNumber = 12345679;
|
||||
const bookingId = 8891;
|
||||
|
||||
return createPosFixture({
|
||||
preloadCreatedOrderItemsFromBooking: true,
|
||||
orderBookingListStripsDetails: true,
|
||||
orderBookingListStripsMetadata: true,
|
||||
customerAttributesByNumber: {
|
||||
[customerNumber]: [
|
||||
...(baseFixture.customerAttributesByNumber[customerNumber] || []),
|
||||
{
|
||||
id: 88,
|
||||
customer_number: customerNumber,
|
||||
attribute: "usePONumbers",
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBookings: [
|
||||
{
|
||||
id: bookingId,
|
||||
customer_number: customerNumber,
|
||||
customer_name: "(TEST) Pleno Vognmandsforretning",
|
||||
department: 12,
|
||||
datetime: "2026-05-21 09:30:00",
|
||||
date: "2026-05-21",
|
||||
reg_1: "BOOKPO1",
|
||||
reg_2: "",
|
||||
reg_3: "",
|
||||
reference: "BOOKING-PO-REF",
|
||||
reference_number: "BOOKING-PO-REF",
|
||||
notes: "Booking PO note",
|
||||
note: "Booking PO note",
|
||||
po: "BOOKING-PO-DEFAULT",
|
||||
pickup: false,
|
||||
items: [{ id: 53, quantity: 1, price: 649 }],
|
||||
order_id: null,
|
||||
created_at: "2026-05-20 08:00:00",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function createReferenceAutocompletePosFixture() {
|
||||
const baseFixture = createPosFixture();
|
||||
const customerNumber = 12345679;
|
||||
@@ -2304,6 +2348,47 @@ test.describe("Admin POS Orders - desktop step 1 customer and vehicle ownership"
|
||||
await createOrderRequest;
|
||||
});
|
||||
|
||||
test("applies booking PO defaults on desktop even when linked booking items are already loaded", async ({ page }) => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: POS_PERMISSIONS,
|
||||
edgeGateways: false,
|
||||
pos: createBookingPoDefaultPosFixture(),
|
||||
});
|
||||
await primeOperatorSession(page, "pos-orders-booking-po-default-token");
|
||||
|
||||
await page.goto(POS_BOOT_URL);
|
||||
await expect(page.getByTestId("pos-step-1")).toBeVisible();
|
||||
|
||||
await page.locator("#reg_1").fill("BOOKPO1");
|
||||
await selectStepOneCustomer(page, 12345679);
|
||||
|
||||
const createOrderRequest = waitForOrderMutation(
|
||||
page,
|
||||
"POST",
|
||||
"/orders",
|
||||
(body) => Number(body.booking_id) === 8891
|
||||
);
|
||||
await page.getByTestId("pos-step-1").getByTestId("pos-next-step").click();
|
||||
const capturedCreateOrderRequest = await createOrderRequest;
|
||||
expect(capturedCreateOrderRequest.postDataJSON()).toMatchObject({
|
||||
booking_id: 8891,
|
||||
po: "",
|
||||
});
|
||||
|
||||
const stepTwo = page.getByTestId("pos-step-2");
|
||||
await expect(stepTwo).toBeVisible();
|
||||
await expect(stepTwo.getByTestId("pos-order-registration-1")).toContainText("BOOKPO1");
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-reference")).toContainText("BOOKING-PO-REF");
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po")).toContainText("BOOKING-PO-DEFAULT");
|
||||
await expect(stepTwo.getByTestId("pos-order-metadata-note")).toContainText("Booking PO note");
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-control")).not.toHaveAttribute(
|
||||
"data-warning-state",
|
||||
"warning"
|
||||
);
|
||||
await expect(stepTwo.getByTestId("pos-order-customer-wishes-po-warning-icon")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("preserves a manually selected required-reference customer and manual reference while editing reg_1", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const PING_URL = /https:\/\/api\.truckwash\.io:4433\/ping(?:\?.*)?$/;
|
||||
const PING_URL =
|
||||
/(?:https:\/\/api\.truckwash\.io:4433|https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?\/api)\/ping(?:\?.*)?$/;
|
||||
const CONNECTIVITY_PAGE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function installPingFailure(page, onPing) {
|
||||
return page.route(PING_URL, async (route) => {
|
||||
@@ -22,7 +24,7 @@ test.describe("Connectivity issue", () => {
|
||||
|
||||
await page.goto("/connectivity-issue");
|
||||
|
||||
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
|
||||
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
|
||||
await expect(page.getByTestId("connectivity-icon")).toBeVisible();
|
||||
await expect(page.getByTestId("connectivity-title")).toHaveText("Forbindelsesproblem");
|
||||
await expect(page.getByTestId("connectivity-subtitle")).toContainText("internetforbindelse");
|
||||
@@ -42,7 +44,7 @@ test.describe("Connectivity issue", () => {
|
||||
});
|
||||
|
||||
await page.goto("/connectivity-issue");
|
||||
await expect(page.getByTestId("connectivity-issue")).toBeVisible();
|
||||
await expect(page.getByTestId("connectivity-issue")).toBeVisible({ timeout: CONNECTIVITY_PAGE_TIMEOUT_MS });
|
||||
await expect.poll(() => pingCount).toBeGreaterThan(0);
|
||||
|
||||
const initialPingCount = pingCount;
|
||||
|
||||
@@ -99,6 +99,14 @@ function createReleaseState() {
|
||||
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: [
|
||||
@@ -118,6 +126,18 @@ function createReleaseState() {
|
||||
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,
|
||||
},
|
||||
@@ -126,22 +146,52 @@ function createReleaseState() {
|
||||
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" },
|
||||
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" },
|
||||
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" },
|
||||
replication: {
|
||||
id: 102,
|
||||
label: "minio-canary",
|
||||
status: "ok",
|
||||
role: "replica",
|
||||
host: "minio-canary.example.test",
|
||||
port: 9000,
|
||||
},
|
||||
},
|
||||
},
|
||||
attached_bundles: [],
|
||||
@@ -1061,6 +1111,20 @@ async function installReleaseMocks(page, state) {
|
||||
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;
|
||||
@@ -1371,7 +1435,18 @@ test("overview status dashboard prioritizes release issues and opens channel det
|
||||
|
||||
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();
|
||||
|
||||
const controlApi = page.getByTestId("release-control-api");
|
||||
await expect(controlApi).toContainText("Control API");
|
||||
await expect(controlApi.locator(".release-control-plane")).toBeHidden();
|
||||
await controlApi.locator("summary").click();
|
||||
await expect(controlApi.locator(".release-control-plane")).toBeVisible();
|
||||
await controlApi.locator("summary").click();
|
||||
await expect(controlApi.locator(".release-control-plane")).toBeHidden();
|
||||
|
||||
const issuePanel = dashboard.getByTestId("release-status-issues");
|
||||
const failedDeploymentIssue = issuePanel.getByRole("button").filter({ hasText: "Composer install failed" }).first();
|
||||
@@ -1381,6 +1456,12 @@ test("overview status dashboard prioritizes release issues and opens channel det
|
||||
expect(issueBox).toBeTruthy();
|
||||
expect(issueBox.y).toBeLessThan((viewport?.height || 900) - 24);
|
||||
|
||||
await page.getByTestId("release-cockpit-primary-action").click();
|
||||
await expect(page.getByTestId("release-bundle-flow")).toBeVisible();
|
||||
await expect(page.getByTestId("release-status-drawer")).toBeVisible();
|
||||
await page.locator(".release-status-drawer .delete").click();
|
||||
await selectReleaseTab(page, "Overview");
|
||||
|
||||
const canaryCard = page.getByTestId("release-status-channel-card").filter({ hasText: "Canary" }).first();
|
||||
await expect(canaryCard).toContainText("Frontend");
|
||||
await expect(canaryCard).toContainText("API");
|
||||
@@ -1650,6 +1731,15 @@ test("superusers manage release channels, assignments, deployments, and replay",
|
||||
.locator("tbody tr")
|
||||
.filter({ hasText: "truckwash/backend-php#release/canary" })
|
||||
.first();
|
||||
await apiTargetRow.locator('[data-testid^="release-target-actions-"] button').first().click();
|
||||
await expect(apiTargetRow.getByRole("button", { name: "Delete" })).toBeVisible();
|
||||
page.once("dialog", (dialog) => {
|
||||
expect(dialog.message()).toContain("Coolify service is not deleted");
|
||||
dialog.dismiss();
|
||||
});
|
||||
await apiTargetRow.getByRole("button", { name: "Delete" }).click();
|
||||
await expect(apiTargetRow).toContainText("truckwash/backend-php#release/canary");
|
||||
await page.keyboard.press("Escape");
|
||||
await apiTargetRow.getByRole("button", { name: "Redeploy" }).click();
|
||||
const redeployDate = new Date().toISOString().slice(0, 10);
|
||||
const savedApiTarget = state.targets.find((target) => target.repository === "truckwash/backend-php");
|
||||
@@ -1673,6 +1763,27 @@ test("superusers manage release channels, assignments, deployments, and replay",
|
||||
await expect(redeployRow).toContainText("latestcommit");
|
||||
await expect(page.getByTestId("release-service-set-cards")).toContainText("Canary shared stack");
|
||||
const bundleFlow = page.getByTestId("release-bundle-flow");
|
||||
await expect(bundleFlow.getByTestId("release-dataset-mode-options")).toContainText("Reuse existing services");
|
||||
await expect(bundleFlow.getByTestId("release-apply-preview")).toContainText("Apply Preview");
|
||||
await expect(bundleFlow.getByTestId("release-apply-steps")).toContainText("Check GitHub access");
|
||||
const serviceActionTable = bundleFlow.getByTestId("release-service-action-table");
|
||||
await expect(serviceActionTable).toContainText("Frontend");
|
||||
await expect(serviceActionTable).toContainText("PHP API");
|
||||
await expect(serviceActionTable).toContainText("MariaDB");
|
||||
await expect(serviceActionTable).toContainText("canary.example.test:443");
|
||||
await expect(serviceActionTable).toContainText("api-v2.truckwash.io:443");
|
||||
await expect(serviceActionTable).toContainText("db-canary.example.test:3306");
|
||||
await expect(serviceActionTable).toContainText("redis-canary.example.test:6379");
|
||||
await expect(serviceActionTable).toContainText("minio-canary.example.test:9000");
|
||||
await expect(bundleFlow.getByTestId("release-bundle-risk-warning").first()).toContainText("Attaching existing data");
|
||||
await bundleFlow.getByTestId("release-dataset-mode-clone_existing").click();
|
||||
await expect(serviceActionTable).toContainText("Clone replica from source");
|
||||
await expect(serviceActionTable).toContainText("host/port assigned after replica provisioning");
|
||||
await expect(bundleFlow.getByTestId("release-apply-steps")).toContainText("plan replica clones");
|
||||
await bundleFlow.getByTestId("release-dataset-mode-fresh_empty").click();
|
||||
await expect(serviceActionTable).toContainText("Create/register empty service");
|
||||
await expect(serviceActionTable).toContainText("host/port assigned after service is configured");
|
||||
await bundleFlow.getByTestId("release-dataset-mode-attach_existing").click();
|
||||
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");
|
||||
@@ -1818,12 +1929,24 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
|
||||
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 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(
|
||||
"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");
|
||||
@@ -1877,7 +2000,11 @@ test("isolated stack mode creates fresh Coolify app and data targets without att
|
||||
expect(state.serviceSets.filter((set) => set.mode === "isolated_stack")).toHaveLength(isolatedServiceSetCount);
|
||||
expect(state.targets.filter((target) => target.deploy_context?.isolated_stack)).toHaveLength(isolatedTargetCount);
|
||||
|
||||
page.once("dialog", (dialog) => dialog.accept());
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { expect, test, type Page, type TestInfo } from "@playwright/test";
|
||||
import { mockApi, primeMockSession } from "./support/network.js";
|
||||
import { isDesktopProject } from "./support/projects";
|
||||
|
||||
const json = (body: unknown, status = 200) => ({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-headers":
|
||||
"Content-Type, Authorization, X-Customer-Number, X-Release-Trace, X-Release-Channel, X-Frontend-Version, *",
|
||||
"access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const releaseRuntime = () => ({
|
||||
generated_at: "2026-05-19T09:30:00.000Z",
|
||||
trace_id: "trace-session-release-e2e",
|
||||
channel: {
|
||||
id: 2,
|
||||
slug: "canary",
|
||||
name: "Canary",
|
||||
enabled: true,
|
||||
default_channel: false,
|
||||
},
|
||||
versions: {
|
||||
frontend: {
|
||||
version_label: "frontend-canary",
|
||||
commit_sha: "c0ffee0000001111222233334444555566667777",
|
||||
repository: "truckwash/front-end-vue",
|
||||
branch: "release/canary",
|
||||
status: "deployed",
|
||||
},
|
||||
api: {
|
||||
version_label: "api-canary",
|
||||
commit_sha: "feedface00001111222233334444555566667777",
|
||||
repository: "truckwash/backend-php",
|
||||
branch: "release/canary",
|
||||
status: "deployed",
|
||||
},
|
||||
service_set: {
|
||||
id: 53,
|
||||
name: "Canary isolated stack",
|
||||
stack: {
|
||||
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
|
||||
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
|
||||
database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid" },
|
||||
redis: { id: 55, resource_name: "canary-redis", resource_uuid: "redis-resource-uuid" },
|
||||
minio: { id: 56, resource_name: "canary-minio", resource_uuid: "minio-resource-uuid" },
|
||||
},
|
||||
},
|
||||
bundle_id: 31,
|
||||
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
|
||||
},
|
||||
urls: {
|
||||
frontend_base_url: "http://127.0.0.1:5173/canary/frontend",
|
||||
api_base_url: "/api",
|
||||
},
|
||||
availability: {
|
||||
configured: true,
|
||||
missing: [],
|
||||
status: "ready",
|
||||
},
|
||||
capture_policy: {
|
||||
enabled: false,
|
||||
capture_level: "metadata",
|
||||
all_failure_metadata: true,
|
||||
retention_days: 14,
|
||||
},
|
||||
});
|
||||
|
||||
async function openHiddenRuntimeMenu(page: Page) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
await page.keyboard.press("Shift");
|
||||
await page.keyboard.press("Shift");
|
||||
await page.keyboard.press("Shift");
|
||||
|
||||
if (
|
||||
await page
|
||||
.getByTestId("request-queue-runtime-box")
|
||||
.isVisible({ timeout: 1000 })
|
||||
.catch(() => false)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.getByTestId("request-queue-runtime-box")).toBeVisible();
|
||||
}
|
||||
|
||||
async function bootAuthenticatedSession(page: Page) {
|
||||
const runtime = releaseRuntime();
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
window.localStorage.setItem(
|
||||
"release_channel_switch_notice_seen",
|
||||
JSON.stringify({ "user:77:canary": "2026-05-19T09:30:00.000Z" })
|
||||
);
|
||||
});
|
||||
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["user"],
|
||||
sessionData: {
|
||||
id: 77,
|
||||
customer_number: 990077,
|
||||
display_name: "Release Inspector",
|
||||
runtime_config: {
|
||||
release: runtime,
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.route("**/release/runtime**", async (route) => {
|
||||
await route.fulfill(json({ data: runtime }));
|
||||
});
|
||||
await primeMockSession(page, { token: "session-release-runtime-token", bootPath: "/user" });
|
||||
}
|
||||
|
||||
test.describe("session release runtime inspector", () => {
|
||||
test.beforeEach(async ({ page }, testInfo: TestInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Hidden runtime menu coverage is desktop-focused.");
|
||||
await bootAuthenticatedSession(page);
|
||||
});
|
||||
|
||||
test("shows the active release and connected services in the Shift menu", async ({ page }) => {
|
||||
await openHiddenRuntimeMenu(page);
|
||||
|
||||
const runtimeBox = page.getByTestId("request-queue-runtime-box");
|
||||
await expect(runtimeBox).toContainText("Session release");
|
||||
await expect(runtimeBox).toContainText("Canary");
|
||||
await expect(runtimeBox).toContainText("trace-session-release-e2e");
|
||||
await expect(runtimeBox).toContainText("#31 canary-bundle");
|
||||
await expect(runtimeBox).toContainText("Canary isolated stack");
|
||||
|
||||
await expect(page.getByTestId("request-queue-release-app-frontend")).toContainText("frontend-canary");
|
||||
await expect(page.getByTestId("request-queue-release-app-frontend")).toContainText(
|
||||
"http://127.0.0.1:5173/canary/frontend"
|
||||
);
|
||||
await expect(page.getByTestId("request-queue-release-app-api")).toContainText("api-canary");
|
||||
await expect(page.getByTestId("request-queue-release-app-api")).toContainText("/api");
|
||||
await expect(page.getByTestId("request-queue-release-service-frontend")).toContainText("canary-frontend #51");
|
||||
await expect(page.getByTestId("request-queue-release-service-api")).toContainText("canary-api #52");
|
||||
await expect(page.getByTestId("request-queue-release-service-database")).toContainText("canary-db #54");
|
||||
await expect(page.getByTestId("request-queue-release-service-redis")).toContainText("canary-redis #55");
|
||||
await expect(page.getByTestId("request-queue-release-service-minio")).toContainText("canary-minio #56");
|
||||
await expect(runtimeBox).toContainText("Runtime details");
|
||||
await expect(page.getByTestId("request-queue-i18n-catalog-switch")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -184,6 +184,41 @@ function normalizePositiveIntegerValue(value) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeOrderPoValue(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function findPosOrderBookingById(posFixture, bookingId) {
|
||||
const normalizedBookingId = normalizePositiveIntegerValue(bookingId);
|
||||
if (!normalizedBookingId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (posFixture.orderBookings || []).find((booking) => Number(booking?.id || 0) === normalizedBookingId) || null;
|
||||
}
|
||||
|
||||
function getPosOrderBookingPo(posFixture, bookingId) {
|
||||
const booking = findPosOrderBookingById(posFixture, bookingId);
|
||||
const bookingPo = normalizeOrderPoValue(booking?.po);
|
||||
return bookingPo || "";
|
||||
}
|
||||
|
||||
function applyBookingPoDefaultToMockOrder(posFixture, order) {
|
||||
if (!order || normalizeOrderPoValue(order.po) !== "") {
|
||||
return order;
|
||||
}
|
||||
|
||||
const bookingPo = getPosOrderBookingPo(posFixture, order.booking_id);
|
||||
if (!bookingPo) {
|
||||
return order;
|
||||
}
|
||||
|
||||
return {
|
||||
...order,
|
||||
po: bookingPo,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReferenceKey(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
@@ -638,7 +673,7 @@ function parseFilterExpressions(filters) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
function toOrderBookingListEntry(booking, stripDetails = false) {
|
||||
function toOrderBookingListEntry(booking, stripDetails = false, options = {}) {
|
||||
if (!stripDetails || !booking || typeof booking !== "object") {
|
||||
return booking;
|
||||
}
|
||||
@@ -646,6 +681,13 @@ function toOrderBookingListEntry(booking, stripDetails = false) {
|
||||
const summaryBooking = { ...booking };
|
||||
delete summaryBooking.items;
|
||||
delete summaryBooking.parsed_services;
|
||||
if (options.stripMetadata === true) {
|
||||
delete summaryBooking.reference;
|
||||
delete summaryBooking.reference_number;
|
||||
delete summaryBooking.notes;
|
||||
delete summaryBooking.note;
|
||||
delete summaryBooking.po;
|
||||
}
|
||||
return summaryBooking;
|
||||
}
|
||||
|
||||
@@ -3092,7 +3134,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
json({
|
||||
success: true,
|
||||
data: bookings.map((booking) =>
|
||||
toOrderBookingListEntry(booking, posFixture.orderBookingListStripsDetails === true)
|
||||
toOrderBookingListEntry(booking, posFixture.orderBookingListStripsDetails === true, {
|
||||
stripMetadata: posFixture.orderBookingListStripsMetadata === true,
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
@@ -3422,28 +3466,53 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
if (pathname.endsWith("/orders") && method === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const orderId = posFixture.nextOrderId++;
|
||||
const bookingId = normalizePositiveIntegerValue(body.booking_id);
|
||||
const orderPo = normalizeOrderPoValue(body.po) || getPosOrderBookingPo(posFixture, bookingId);
|
||||
posFixture.ordersById[orderId] = {
|
||||
id: orderId,
|
||||
customer_id: Number(body.customer_id),
|
||||
department_id: Number(body.department_id || body.department || 12),
|
||||
reference: body.reference || "",
|
||||
po: body.po || "",
|
||||
po: orderPo,
|
||||
safety_seal: normalizeSafetySealValue(body.safety_seal),
|
||||
notes: body.notes || "",
|
||||
reg_1: normalizeRegistrationValue(body.reg_1),
|
||||
reg_2: normalizeRegistrationValue(body.reg_2),
|
||||
reg_3: normalizeRegistrationValue(body.reg_3),
|
||||
invoice_collection_id: null,
|
||||
booking_id: normalizePositiveIntegerValue(body.booking_id),
|
||||
booking_id: bookingId,
|
||||
completed_at: null,
|
||||
closed_at: null,
|
||||
created_at: normalizeCreatedAtValue(body.created_at) || toSqlDateTime(),
|
||||
include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice),
|
||||
};
|
||||
posFixture.orderItemsByOrderId[orderId] = [];
|
||||
const booking = findPosOrderBookingById(posFixture, bookingId);
|
||||
const bookingItems =
|
||||
posFixture.preloadCreatedOrderItemsFromBooking && Array.isArray(booking?.items) ? booking.items : [];
|
||||
posFixture.orderItemsByOrderId[orderId] = bookingItems
|
||||
.map((bookingItem) => {
|
||||
const productId = Number(bookingItem?.id || 0);
|
||||
const product = (posFixture.products || []).find((entry) => Number(entry.id) === productId);
|
||||
if (!product) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildPosOrderItem(
|
||||
product,
|
||||
{
|
||||
order_id: orderId,
|
||||
product_id: productId,
|
||||
quantity: bookingItem?.quantity || 1,
|
||||
price: bookingItem?.price ?? product.price,
|
||||
notes: bookingItem?.notes || "",
|
||||
},
|
||||
posFixture.nextOrderItemId++
|
||||
);
|
||||
})
|
||||
.filter(Boolean);
|
||||
posFixture.economicModuleOrdersByOrderId[orderId] = { invoice_id: null, invoice_draft_id: null };
|
||||
posFixture.stripeModuleOrdersByOrderId[orderId] = {};
|
||||
await route.fulfill(json({ success: true, data: { id: orderId } }));
|
||||
await route.fulfill(json({ success: true, data: { id: orderId, po: orderPo } }));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -3461,7 +3530,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
body
|
||||
);
|
||||
if (posFixture.ordersById[orderId]) {
|
||||
posFixture.ordersById[orderId] = {
|
||||
const updatedOrder = {
|
||||
...posFixture.ordersById[orderId],
|
||||
...body,
|
||||
...(Object.prototype.hasOwnProperty.call(body, "reg_1")
|
||||
@@ -3483,6 +3552,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
? { include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice) }
|
||||
: {}),
|
||||
};
|
||||
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, updatedOrder);
|
||||
}
|
||||
if (shouldRegenerateWashCertificate) {
|
||||
replaceWashCertificateAttachment(posFixture, orderId);
|
||||
@@ -3512,8 +3582,9 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
: body.field === "created_at"
|
||||
? normalizeCreatedAtValue(body.value)
|
||||
: body.value;
|
||||
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, posFixture.ordersById[orderId]);
|
||||
} else {
|
||||
posFixture.ordersById[orderId] = {
|
||||
const updatedOrder = {
|
||||
...posFixture.ordersById[orderId],
|
||||
...body,
|
||||
...(Object.prototype.hasOwnProperty.call(body, "reg_1")
|
||||
@@ -3535,6 +3606,7 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
|
||||
? { include_in_invoice: normalizeIncludeInInvoiceValue(body.include_in_invoice) }
|
||||
: {}),
|
||||
};
|
||||
posFixture.ordersById[orderId] = applyBookingPoDefaultToMockOrder(posFixture, updatedOrder);
|
||||
}
|
||||
}
|
||||
if (shouldRegenerateWashCertificate) {
|
||||
|
||||
@@ -38,6 +38,10 @@ describe("release bootstrap", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves local dev API runtime requests against the current origin", () => {
|
||||
expect(runtimeApiUrl("/api")).toBe(`${window.location.origin}/api/release/runtime`);
|
||||
});
|
||||
|
||||
it("attaches release trace, channel, and frontend headers to runtime requests", async () => {
|
||||
localStorage.setItem("release_trace_id", "trace-runtime");
|
||||
localStorage.setItem("release_channel_selected_slug", "Internal");
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
__resetReleaseTimelineForTests,
|
||||
__setReleaseTimelineTransportForTests,
|
||||
buildReleaseSessionSummary,
|
||||
buildCurrentReleaseHeaders,
|
||||
buildReleaseTimelineContext,
|
||||
configureReleaseRuntime,
|
||||
@@ -45,6 +46,131 @@ describe("release timeline runtime", () => {
|
||||
expect(releaseRuntimeState.versions.bundle_id).toBe(31);
|
||||
});
|
||||
|
||||
it("builds display rows for the active release bundle and connected services", () => {
|
||||
configureReleaseRuntime({
|
||||
generated_at: "2026-05-19T09:30:00.000Z",
|
||||
trace_id: "trace-session-release",
|
||||
channel: { slug: "canary", name: "Canary", default_channel: false },
|
||||
versions: {
|
||||
frontend: {
|
||||
version_label: "frontend-canary",
|
||||
commit_sha: "c0ffee0000001111222233334444555566667777",
|
||||
repository: "truckwash/front-end-vue",
|
||||
branch: "release/canary",
|
||||
status: "deployed",
|
||||
},
|
||||
api: {
|
||||
version_label: "api-canary",
|
||||
commit_sha: "feedface00001111222233334444555566667777",
|
||||
repository: "truckwash/backend-php",
|
||||
branch: "release/canary",
|
||||
status: "deployed",
|
||||
},
|
||||
service_set: {
|
||||
id: 53,
|
||||
name: "Canary isolated stack",
|
||||
stack: {
|
||||
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
|
||||
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
|
||||
database: {
|
||||
id: 54,
|
||||
resource_name: "canary-db",
|
||||
resource_uuid: "database-resource-uuid",
|
||||
deployment_status: "ready",
|
||||
},
|
||||
redis: {
|
||||
id: 55,
|
||||
resource_name: "canary-redis",
|
||||
resource_uuid: "redis-resource-uuid",
|
||||
deployment_status: "ready",
|
||||
},
|
||||
minio: {
|
||||
id: 56,
|
||||
resource_name: "canary-minio",
|
||||
resource_uuid: "minio-resource-uuid",
|
||||
availability_state: "healthy",
|
||||
},
|
||||
},
|
||||
},
|
||||
bundle_id: 31,
|
||||
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
|
||||
},
|
||||
urls: {
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
api_base_url: "https://api-v2.truckwash.io/canary/api",
|
||||
},
|
||||
availability: { configured: true, missing: [], status: "ready" },
|
||||
});
|
||||
|
||||
const summary = buildReleaseSessionSummary();
|
||||
|
||||
expect(releaseRuntimeState.versions.service_set.name).toBe("Canary isolated stack");
|
||||
expect(summary.channelLabel).toBe("Canary");
|
||||
expect(summary.traceId).toBe("trace-session-release");
|
||||
expect(summary.bundleLabel).toBe("#31 canary-bundle");
|
||||
expect(summary.serviceSetLabel).toBe("Canary isolated stack");
|
||||
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
|
||||
primaryText: "frontend-canary",
|
||||
url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
tone: "ok",
|
||||
});
|
||||
expect(summary.appRows.find((row) => row.key === "api")?.secondaryText).toContain("feedface0000");
|
||||
expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({
|
||||
primaryText: "canary-db #54",
|
||||
tone: "ok",
|
||||
});
|
||||
expect(summary.serviceRows.find((row) => row.key === "redis")?.secondaryText).toContain("redis-resour");
|
||||
expect(summary.serviceRows.find((row) => row.key === "minio")?.status).toBe("healthy");
|
||||
});
|
||||
|
||||
it("marks stable sessions without release bundle data as shared runtime", () => {
|
||||
const summary = buildReleaseSessionSummary({
|
||||
trace_id: "trace-stable",
|
||||
channel: { slug: "stable", name: "Stable", default_channel: true },
|
||||
versions: { frontend: null, api: null, bundle_id: null, bundle: null },
|
||||
availability: { configured: true, missing: [], status: "ready" },
|
||||
});
|
||||
|
||||
expect(summary.bundleLabel).toBe("Default/shared runtime");
|
||||
expect(summary.serviceSetLabel).toBe("Default/shared runtime");
|
||||
expect(summary.appRows.every((row) => row.primaryText === "Default/shared runtime")).toBe(true);
|
||||
expect(summary.appRows.every((row) => row.tone === "ok")).toBe(true);
|
||||
expect(summary.serviceRows.every((row) => row.primaryText === "Default/shared runtime")).toBe(true);
|
||||
expect(summary.serviceRows.every((row) => row.tone === "ok")).toBe(true);
|
||||
});
|
||||
|
||||
it("surfaces non-default release runtime missing values as warnings", () => {
|
||||
const summary = buildReleaseSessionSummary({
|
||||
trace_id: "trace-internal",
|
||||
channel: { slug: "internal", name: "Internal", default_channel: false },
|
||||
versions: {
|
||||
frontend: null,
|
||||
api: { version_label: "api-internal" },
|
||||
bundle_id: null,
|
||||
bundle: null,
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
missing: ["release_bundle", "frontend_version", "api_base_url"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.missingLabels).toEqual(["Release bundle", "Frontend version", "API URL"]);
|
||||
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
|
||||
tone: "warning",
|
||||
missingLabel: "Frontend version",
|
||||
});
|
||||
expect(summary.appRows.find((row) => row.key === "api")).toMatchObject({
|
||||
tone: "warning",
|
||||
missingLabel: "API URL",
|
||||
});
|
||||
expect(summary.serviceRows.find((row) => row.key === "database")).toMatchObject({
|
||||
tone: "warning",
|
||||
primaryText: "Missing release bundle",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses release runtime frontend and API URLs when provided", () => {
|
||||
configureReleaseRuntime({
|
||||
trace_id: "trace-urls",
|
||||
@@ -66,6 +192,28 @@ describe("release timeline runtime", () => {
|
||||
expect(resolveReleaseApiUrl("/orders")).toBe("https://api-v2.truckwash.io/canary/api/orders");
|
||||
});
|
||||
|
||||
it("keeps same-origin release API URLs visible in the session summary", () => {
|
||||
configureReleaseRuntime({
|
||||
trace_id: "trace-local-api",
|
||||
channel: { slug: "canary", name: "Canary" },
|
||||
versions: {
|
||||
frontend: { version_label: "frontend-canary" },
|
||||
api: { version_label: "api-canary" },
|
||||
bundle_id: 31,
|
||||
},
|
||||
urls: {
|
||||
api_base_url: "/api/",
|
||||
},
|
||||
});
|
||||
|
||||
const summary = buildReleaseSessionSummary();
|
||||
|
||||
expect(releaseRuntimeState.apiBaseUrl).toBe("/api");
|
||||
expect(getReleaseRuntimeApiBaseUrl()).toBe("/api");
|
||||
expect(resolveReleaseApiUrl("/orders")).toBe("/api/orders");
|
||||
expect(summary.appRows.find((row) => row.key === "api")?.url).toBe("/api");
|
||||
});
|
||||
|
||||
it("builds release headers from the active runtime state", () => {
|
||||
configureReleaseRuntime({
|
||||
trace_id: "trace-headers",
|
||||
|
||||
@@ -5,6 +5,7 @@ import axios from "axios";
|
||||
import RequestQueueProgress from "@/components/global/RequestQueueProgress.vue";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import { API_URL } from "@/config.js";
|
||||
import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js";
|
||||
import {
|
||||
__configureRequestQueueForTests,
|
||||
__resetRequestQueueForTests,
|
||||
@@ -62,6 +63,7 @@ describe("RequestQueueProgress", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
localStorage.clear();
|
||||
__resetReleaseTimelineForTests();
|
||||
__resetRequestQueueForTests();
|
||||
__configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, spacingMs: 0 });
|
||||
vi.stubGlobal(
|
||||
@@ -73,6 +75,7 @@ describe("RequestQueueProgress", () => {
|
||||
|
||||
afterEach(() => {
|
||||
__resetRequestQueueForTests();
|
||||
__resetReleaseTimelineForTests();
|
||||
resetSessionUserState();
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
@@ -253,6 +256,67 @@ describe("RequestQueueProgress", () => {
|
||||
expect(runtimeBox.text()).toContain("Ingoing bandwidth");
|
||||
});
|
||||
|
||||
it("shows the active session release bundle, app URLs, and connected services", async () => {
|
||||
configureReleaseRuntime({
|
||||
generated_at: "2026-05-19T09:30:00.000Z",
|
||||
trace_id: "trace-request-panel",
|
||||
channel: { slug: "canary", name: "Canary", default_channel: false },
|
||||
versions: {
|
||||
frontend: {
|
||||
version_label: "frontend-canary",
|
||||
commit_sha: "c0ffee0000001111222233334444555566667777",
|
||||
repository: "truckwash/front-end-vue",
|
||||
branch: "release/canary",
|
||||
status: "deployed",
|
||||
},
|
||||
api: {
|
||||
version_label: "api-canary",
|
||||
commit_sha: "feedface00001111222233334444555566667777",
|
||||
repository: "truckwash/backend-php",
|
||||
branch: "release/canary",
|
||||
status: "deployed",
|
||||
},
|
||||
service_set: {
|
||||
id: 53,
|
||||
name: "Canary isolated stack",
|
||||
stack: {
|
||||
frontend: { id: 51, label: "canary-frontend", coolify_service_uuid: "frontend-service-uuid" },
|
||||
api: { id: 52, label: "canary-api", coolify_service_uuid: "api-service-uuid" },
|
||||
database: { id: 54, resource_name: "canary-db", resource_uuid: "database-resource-uuid" },
|
||||
redis: { id: 55, resource_name: "canary-redis", resource_uuid: "redis-resource-uuid" },
|
||||
minio: { id: 56, resource_name: "canary-minio", resource_uuid: "minio-resource-uuid" },
|
||||
},
|
||||
},
|
||||
bundle_id: 31,
|
||||
bundle: { id: 31, version_label: "canary-bundle", status: "deployed" },
|
||||
},
|
||||
urls: {
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
api_base_url: "https://api-v2.truckwash.io/canary/api",
|
||||
},
|
||||
availability: { configured: true, missing: [], status: "ready" },
|
||||
});
|
||||
|
||||
const wrapper = mount(RequestQueueProgress);
|
||||
await triggerShiftTriplePress();
|
||||
await flushManyMicrotasks();
|
||||
|
||||
const runtimeBox = wrapper.get("[data-testid='request-queue-runtime-box']");
|
||||
expect(runtimeBox.text()).toContain("Session release");
|
||||
expect(runtimeBox.text()).toContain("Canary");
|
||||
expect(runtimeBox.text()).toContain("trace-request-panel");
|
||||
expect(runtimeBox.text()).toContain("#31 canary-bundle");
|
||||
expect(runtimeBox.text()).toContain("Canary isolated stack");
|
||||
expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain("frontend-canary");
|
||||
expect(wrapper.get("[data-testid='request-queue-release-app-frontend']").text()).toContain(
|
||||
"https://api-v2.truckwash.io/canary/frontend"
|
||||
);
|
||||
expect(wrapper.get("[data-testid='request-queue-release-app-api']").text()).toContain("api-canary");
|
||||
expect(wrapper.get("[data-testid='request-queue-release-service-database']").text()).toContain("canary-db #54");
|
||||
expect(wrapper.get("[data-testid='request-queue-release-service-redis']").text()).toContain("canary-redis #55");
|
||||
expect(wrapper.get("[data-testid='request-queue-release-service-minio']").text()).toContain("canary-minio #56");
|
||||
});
|
||||
|
||||
it("shows subuser details in the user box", async () => {
|
||||
SessionUser.isSubuser.value = true;
|
||||
SessionUser.subuser.name.value = "Sub User";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createApiProxyOptions } from "../../vite.config.js";
|
||||
|
||||
describe("Vite API proxy", () => {
|
||||
it("forwards local /api requests to the working dev API by default", () => {
|
||||
const options = createApiProxyOptions({});
|
||||
|
||||
expect(options.target).toBe("https://api.truckwash.io:4433");
|
||||
expect(options.changeOrigin).toBe(true);
|
||||
expect(options.secure).toBe(false);
|
||||
expect(options.rewrite("/api/ping")).toBe("/ping");
|
||||
expect(options.rewrite("/api/release/runtime")).toBe("/release/runtime");
|
||||
expect(options.rewrite("/api")).toBe("/");
|
||||
});
|
||||
|
||||
it("allows a local gateway override", () => {
|
||||
const options = createApiProxyOptions({
|
||||
VITE_API_PROXY_TARGET: "http://localhost",
|
||||
});
|
||||
|
||||
expect(options.target).toBe("http://localhost");
|
||||
expect(options.rewrite("/api/ping")).toBe("/ping");
|
||||
});
|
||||
|
||||
it("allows disabling prefix stripping for compatible local gateways", () => {
|
||||
const options = createApiProxyOptions({
|
||||
VITE_API_PROXY_TARGET: "http://localhost",
|
||||
VITE_API_PROXY_STRIP_PREFIX: "false",
|
||||
});
|
||||
|
||||
expect(options.target).toBe("http://localhost");
|
||||
expect(options.rewrite).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,21 @@ function releaseEntryManifest() {
|
||||
}
|
||||
}
|
||||
|
||||
export function createApiProxyOptions(env = process.env) {
|
||||
const stripPrefix = env.VITE_API_PROXY_STRIP_PREFIX !== 'false'
|
||||
|
||||
return {
|
||||
target: env.VITE_API_PROXY_TARGET || 'https://api.truckwash.io:4433',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
...(stripPrefix
|
||||
? {
|
||||
rewrite: (requestPath) => requestPath.replace(/^\/api(?=\/|$)/, '') || '/'
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isProd = mode === 'production'
|
||||
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
|
||||
@@ -259,6 +274,9 @@ export default defineConfig(({ mode }) => {
|
||||
isolate: true
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': createApiProxyOptions()
|
||||
},
|
||||
watch: {
|
||||
ignored: ['**/output/playwright/**', '**/node_modules.codex-backup/**']
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user