Add superuser new customer email notification settings
This commit is contained in:
+51
-20
@@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const baseURL = "http://127.0.0.1:4173";
|
||||
@@ -5,6 +6,55 @@ const isCI = !!process.env.CI;
|
||||
|
||||
process.env.PLAYWRIGHT_BASE_URL = baseURL;
|
||||
|
||||
const osReleaseValue = (key: string) => {
|
||||
try {
|
||||
const body = fs.readFileSync("/etc/os-release", "utf8");
|
||||
const match = body.match(new RegExp(`^${key}=(.*)$`, "m"));
|
||||
return String(match?.[1] || "").replace(/^"|"$/g, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const isUnsupportedWebKitHost = () => {
|
||||
if (process.platform !== "linux") {
|
||||
return false;
|
||||
}
|
||||
const id = osReleaseValue("ID").toLowerCase();
|
||||
const version = Number.parseFloat(osReleaseValue("VERSION_ID"));
|
||||
return id === "ubuntu" && Number.isFinite(version) && version >= 26.04;
|
||||
};
|
||||
|
||||
const webKitOverride = String(process.env.PLAYWRIGHT_PROD_WEBKIT || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const includeWebKit =
|
||||
webKitOverride === "1" || (webKitOverride !== "0" && webKitOverride !== "false" && !isUnsupportedWebKitHost());
|
||||
|
||||
const projects = [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (includeWebKit) {
|
||||
projects.push({
|
||||
name: "webkit-desktop",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e/release",
|
||||
testMatch: /.*\.local-prod\.spec\.ts/,
|
||||
@@ -28,24 +78,5 @@ export default defineConfig({
|
||||
timeout: 240_000,
|
||||
reuseExistingServer: !isCI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile",
|
||||
use: {
|
||||
...devices["Pixel 5"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webkit-desktop",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
],
|
||||
projects,
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ interface Props {
|
||||
iconLeft?: string;
|
||||
loading?: boolean;
|
||||
showRetry?: boolean;
|
||||
retryTestId?: string | null;
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
@@ -26,12 +27,14 @@ withDefaults(
|
||||
iconLeft?: string;
|
||||
loading?: boolean;
|
||||
showRetry?: boolean;
|
||||
retryTestId?: string | null;
|
||||
}>(),
|
||||
{
|
||||
type: "is-danger",
|
||||
iconLeft: "sync-alt",
|
||||
loading: false,
|
||||
showRetry: true,
|
||||
retryTestId: null,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -41,13 +44,7 @@ const emit = defineEmits<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<b-message
|
||||
v-if="message"
|
||||
:type="type"
|
||||
has-icon
|
||||
:closable="false"
|
||||
class="self-serve-error-banner"
|
||||
>
|
||||
<b-message v-if="message" :type="type" has-icon :closable="false" class="self-serve-error-banner">
|
||||
<div class="is-flex is-align-items-center is-justify-content-space-between is-flex-wrap-wrap">
|
||||
<span class="mr-3">{{ message }}</span>
|
||||
<b-button
|
||||
@@ -57,6 +54,7 @@ const emit = defineEmits<{
|
||||
icon-pack="fas"
|
||||
:icon-left="iconLeft"
|
||||
:loading="loading"
|
||||
:data-testid="retryTestId || undefined"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
{{ $t("common.try_again") }}
|
||||
|
||||
@@ -20,16 +20,20 @@ const emit = defineEmits<{
|
||||
|
||||
const isDynamicImageLoading = ref(false);
|
||||
const failedDynamicImageUrl = ref<string | null>(null);
|
||||
const showDynamicImageFrame = computed(
|
||||
() => !!props.dynamicImageUrl
|
||||
&& !isDynamicImageLoading.value
|
||||
&& failedDynamicImageUrl.value !== props.dynamicImageUrl
|
||||
const hasRenderableDynamicImageUrl = computed(
|
||||
() => !!props.dynamicImageUrl && failedDynamicImageUrl.value !== props.dynamicImageUrl
|
||||
);
|
||||
const showDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value && !isDynamicImageLoading.value);
|
||||
const shouldRenderDynamicImageFrame = computed(() => hasRenderableDynamicImageUrl.value);
|
||||
|
||||
watch(() => props.dynamicImageUrl, (dynamicImageUrl) => {
|
||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||
failedDynamicImageUrl.value = null;
|
||||
}, { immediate: true });
|
||||
watch(
|
||||
() => props.dynamicImageUrl,
|
||||
(dynamicImageUrl) => {
|
||||
isDynamicImageLoading.value = !!dynamicImageUrl;
|
||||
failedDynamicImageUrl.value = null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const emitToggleTask = (taskId: number, value: boolean) => {
|
||||
emit("toggle-task", taskId, value);
|
||||
@@ -78,11 +82,20 @@ const onDynamicImageError = () => {
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showDynamicImageFrame"
|
||||
v-if="shouldRenderDynamicImageFrame"
|
||||
class="self-serve-dynamic-image-frame mb-4"
|
||||
data-testid="self-serve-dynamic-image-frame"
|
||||
>
|
||||
<div
|
||||
v-if="isDynamicImageLoading"
|
||||
class="self-serve-dynamic-image-skeleton"
|
||||
data-testid="self-serve-dynamic-image-skeleton"
|
||||
>
|
||||
<b-icon pack="fas" icon="spinner" custom-class="fa-pulse" size="is-large" />
|
||||
<span class="is-sr-only">{{ $t("self_wash.loading_data") }}</span>
|
||||
</div>
|
||||
<img
|
||||
v-if="showDynamicImageFrame"
|
||||
:key="dynamicImageUrl"
|
||||
:src="dynamicImageUrl"
|
||||
alt="Machine status"
|
||||
@@ -117,6 +130,17 @@ const onDynamicImageError = () => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-skeleton {
|
||||
align-items: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #edf2f7;
|
||||
color: #112f5f;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.self-serve-dynamic-image-preload {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
|
||||
@@ -1,42 +1,21 @@
|
||||
<script setup>
|
||||
import { computed, inject, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { SessionUser } from "@/components/session/token/SessionUser.vue";
|
||||
import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.vue";
|
||||
import ReleaseUpdateWidget from "@/components/release/ReleaseUpdateWidget.vue";
|
||||
import {
|
||||
clearSelectedReleaseChannel,
|
||||
releaseChannelSelectorVisible,
|
||||
switchSelectedReleaseChannel,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
import {
|
||||
isReleaseSourceOverrideAvailable,
|
||||
RELEASE_SOURCE_MODES,
|
||||
setReleaseSourceOverride,
|
||||
} from "@/services/releaseBootstrap.js";
|
||||
import { releaseChannelSelectorVisible, switchSelectedReleaseChannel } from "@/services/releaseChannelAvailability.js";
|
||||
import { inspectReleaseRuntimeForUpdate } from "@/services/releaseUpdate.js";
|
||||
import { releaseRuntimeState } from "@/services/releaseTimeline.js";
|
||||
|
||||
const switchingSlug = ref("");
|
||||
const switchError = ref("");
|
||||
const { t, te } = useI18n({ useScope: "global" });
|
||||
const reloadWindow = inject("releaseSourceReload", () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
const tr = (key, fallback) => {
|
||||
const path = `configuration.release_manager.channel_selector.${key}`;
|
||||
return te(path) ? t(path) : fallback;
|
||||
};
|
||||
|
||||
const showUseLocalFrontend = computed(
|
||||
() =>
|
||||
releaseChannelSelectorVisible.value &&
|
||||
isReleaseSourceOverrideAvailable() &&
|
||||
String(releaseRuntimeState.source || "").toLowerCase() !== RELEASE_SOURCE_MODES.LOCAL
|
||||
);
|
||||
|
||||
const switchChannel = async (option) => {
|
||||
if (switchingSlug.value) {
|
||||
return;
|
||||
@@ -48,21 +27,14 @@ const switchChannel = async (option) => {
|
||||
await switchSelectedReleaseChannel(option.channel, SessionUser.refreshReleaseRuntime);
|
||||
void inspectReleaseRuntimeForUpdate(releaseRuntimeState, { autoDownload: true });
|
||||
} catch (error) {
|
||||
switchError.value = tr("switch_error", "Release channel could not be switched. The previous channel is still active.");
|
||||
switchError.value = tr(
|
||||
"switch_error",
|
||||
"Release channel could not be switched. The previous channel is still active."
|
||||
);
|
||||
} finally {
|
||||
switchingSlug.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const useLocalFrontend = () => {
|
||||
if (switchingSlug.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
setReleaseSourceOverride(RELEASE_SOURCE_MODES.LOCAL);
|
||||
clearSelectedReleaseChannel();
|
||||
reloadWindow();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,17 +46,6 @@ const useLocalFrontend = () => {
|
||||
:disabled="Boolean(switchingSlug)"
|
||||
@select="switchChannel"
|
||||
/>
|
||||
<button
|
||||
v-if="showUseLocalFrontend"
|
||||
type="button"
|
||||
class="release-channel-sidebar-selector__local-button"
|
||||
data-testid="release-channel-use-local-frontend"
|
||||
:disabled="Boolean(switchingSlug)"
|
||||
@click="useLocalFrontend"
|
||||
>
|
||||
<i class="fas fa-code" aria-hidden="true"></i>
|
||||
<span>{{ tr("use_local_frontend", "Use local frontend") }}</span>
|
||||
</button>
|
||||
<p v-if="switchError" class="release-channel-sidebar-selector__error" role="alert">
|
||||
{{ switchError }}
|
||||
</p>
|
||||
@@ -93,37 +54,6 @@ const useLocalFrontend = () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.release-channel-sidebar-selector__local-button {
|
||||
width: calc(100% - 32px);
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin: -2px 16px 14px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid #b8c7d9;
|
||||
border-radius: 5px;
|
||||
background: #f8fbff;
|
||||
color: #153554;
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__local-button:hover:not(:disabled) {
|
||||
border-color: #6f8fb4;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__local-button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.release-channel-sidebar-selector__error {
|
||||
margin: 0 16px 14px;
|
||||
color: #b42318;
|
||||
|
||||
@@ -184,6 +184,7 @@ const resetSessionState = () => {
|
||||
SessionUser.user.notifications.wash_certificate_email.value = null;
|
||||
SessionUser.user.notifications.email_notifications_enabled.value = null;
|
||||
SessionUser.user.notifications.sms_notifications_enabled.value = null;
|
||||
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = null;
|
||||
SessionUser.user.created_at.value = null;
|
||||
SessionUser.user.updated_at.value = null;
|
||||
SessionUser.user.cached_at.value = null;
|
||||
@@ -528,6 +529,8 @@ export const getSessionData = async () => {
|
||||
SessionUser.user.notifications.email_notifications_enabled.value =
|
||||
session.notifications.email_notifications_enabled;
|
||||
SessionUser.user.notifications.sms_notifications_enabled.value = session.notifications.sms_notifications_enabled;
|
||||
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value =
|
||||
session.notifications.superuser_new_customer_email_notifications_enabled;
|
||||
SessionUser.user.created_at.value = session.created_at;
|
||||
SessionUser.user.updated_at.value = session.updated_at;
|
||||
SessionUser.user.display_name.value = session.display_name;
|
||||
@@ -623,6 +626,7 @@ export const SessionUser = {
|
||||
wash_certificate_email: ref(null),
|
||||
email_notifications_enabled: ref(null),
|
||||
sms_notifications_enabled: ref(null),
|
||||
superuser_new_customer_email_notifications_enabled: ref(null),
|
||||
setEmailNotificationsEnabled: (enabled) => {
|
||||
return SessionUser.request("/account/notifications", "PUT", {
|
||||
email_notifications_enabled: enabled,
|
||||
@@ -647,6 +651,18 @@ export const SessionUser = {
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
setSuperuserNewCustomerEmailNotificationsEnabled: (enabled) => {
|
||||
return SessionUser.request("/account/notifications", "PUT", {
|
||||
superuser_new_customer_email_notifications_enabled: enabled,
|
||||
})
|
||||
.then(() => {
|
||||
SessionUser.user.notifications.superuser_new_customer_email_notifications_enabled.value = enabled;
|
||||
})
|
||||
.catch((error) => {
|
||||
parseError(error, "user_notifications");
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
setWashCertificateEmail: (email) => {
|
||||
return SessionUser.request("/account/notifications", "PUT", {
|
||||
wash_certificate_email: email,
|
||||
|
||||
@@ -859,24 +859,8 @@
|
||||
},
|
||||
"day": "Dag",
|
||||
"dayHeaderFormat": "ddd D/M",
|
||||
"dayNames": [
|
||||
"Søndag",
|
||||
"Mandag",
|
||||
"Tirsdag",
|
||||
"Onsdag",
|
||||
"Torsdag",
|
||||
"Fredag",
|
||||
"Lørdag"
|
||||
],
|
||||
"dayNamesShort": [
|
||||
"Søn",
|
||||
"Man",
|
||||
"Tir",
|
||||
"Ons",
|
||||
"Tor",
|
||||
"Fre",
|
||||
"Lør"
|
||||
],
|
||||
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
|
||||
"dayNamesShort": ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"],
|
||||
"eventTimeFormat": "HH:mm",
|
||||
"list": "Liste",
|
||||
"month": "Måned",
|
||||
@@ -894,20 +878,7 @@
|
||||
"November",
|
||||
"Desember"
|
||||
],
|
||||
"monthNamesShort": [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"Maj",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Okt",
|
||||
"Nov",
|
||||
"Des"
|
||||
],
|
||||
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Maj", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
|
||||
"next": "I denne",
|
||||
"prev": "Forrige",
|
||||
"slotLabelFormat": "HH:mm",
|
||||
@@ -3863,6 +3834,7 @@
|
||||
"xlvask": "XLVask",
|
||||
"department_gates": "Afdelingsporte",
|
||||
"department_relays": "Afdelingsreleer",
|
||||
"error_reports": "Fejlrapporter",
|
||||
"selfserve": "Selvvask"
|
||||
},
|
||||
"pages": {
|
||||
@@ -4656,6 +4628,8 @@
|
||||
"sms_phone": "Telefonnummer til SMS notifikationer",
|
||||
"sms_phone_desc": "Det telefonnummer, der modtager SMS notifikationer.",
|
||||
"sms_subtitle": "SMS",
|
||||
"superuser_notifications": "Superuser-notifikationer",
|
||||
"superuser_notifications_desc": "Modtag e-mailnotifikationer, når en ny kunde registrerer sig på siden.",
|
||||
"title": "Notifikationer"
|
||||
},
|
||||
"security": {
|
||||
|
||||
@@ -859,24 +859,8 @@
|
||||
},
|
||||
"day": "Tag",
|
||||
"dayHeaderFormat": "ddd D/M",
|
||||
"dayNames": [
|
||||
"Sonntag",
|
||||
"Montag",
|
||||
"Dienstag",
|
||||
"Mittwoch",
|
||||
"Donnerstag",
|
||||
"Freitag",
|
||||
"Samstag"
|
||||
],
|
||||
"dayNamesShort": [
|
||||
"Søn",
|
||||
"Man",
|
||||
"Tir",
|
||||
"Mi",
|
||||
"Do",
|
||||
"Fre",
|
||||
"Lør"
|
||||
],
|
||||
"dayNames": ["Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"],
|
||||
"dayNamesShort": ["Søn", "Man", "Tir", "Mi", "Do", "Fre", "Lør"],
|
||||
"eventTimeFormat": "HH:mm",
|
||||
"list": "Liste",
|
||||
"month": "Monat",
|
||||
@@ -894,20 +878,7 @@
|
||||
"November",
|
||||
"Desember"
|
||||
],
|
||||
"monthNamesShort": [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"Mai",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Okt",
|
||||
"Nov",
|
||||
"Des"
|
||||
],
|
||||
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
|
||||
"next": "Neste",
|
||||
"prev": "Vorherige",
|
||||
"slotLabelFormat": "HH:mm",
|
||||
@@ -3861,7 +3832,8 @@
|
||||
"vehicles": "Fahrzeuge",
|
||||
"xlvask": "XLVask",
|
||||
"department_gates": "Abteilungstore",
|
||||
"department_relays": "Abteilungsrelais"
|
||||
"department_relays": "Abteilungsrelais",
|
||||
"error_reports": "Fehlerberichte"
|
||||
},
|
||||
"pages": {
|
||||
"categories": {
|
||||
@@ -4642,6 +4614,8 @@
|
||||
"sms_phone": "Telefonnummer f?r SMS-Benachrichtigungen",
|
||||
"sms_phone_desc": "Die Telefonnummer, die SMS-Benachrichtigungen erh?lt.",
|
||||
"sms_subtitle": "SMS",
|
||||
"superuser_notifications": "Superuser-Benachrichtigungen",
|
||||
"superuser_notifications_desc": "E-Mail-Benachrichtigungen erhalten, wenn sich ein neuer Kunde auf der Seite registriert.",
|
||||
"title": "Benachrichtigungen"
|
||||
},
|
||||
"security": {
|
||||
|
||||
@@ -859,24 +859,8 @@
|
||||
},
|
||||
"day": "Day",
|
||||
"dayHeaderFormat": "ddd D/M",
|
||||
"dayNames": [
|
||||
"Sunday",
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday"
|
||||
],
|
||||
"dayNamesShort": [
|
||||
"Sun",
|
||||
"Mon",
|
||||
"Tue",
|
||||
"Wed",
|
||||
"Thu",
|
||||
"Fri",
|
||||
"Sat"
|
||||
],
|
||||
"dayNames": ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"],
|
||||
"dayNamesShort": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
|
||||
"eventTimeFormat": "HH:mm",
|
||||
"list": "List",
|
||||
"month": "Month",
|
||||
@@ -894,20 +878,7 @@
|
||||
"November",
|
||||
"December"
|
||||
],
|
||||
"monthNamesShort": [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec"
|
||||
],
|
||||
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
|
||||
"next": "Next",
|
||||
"prev": "Previous",
|
||||
"slotLabelFormat": "HH:mm",
|
||||
@@ -3861,7 +3832,8 @@
|
||||
"vehicles": "Vehicles",
|
||||
"xlvask": "XLVask",
|
||||
"department_gates": "Department Gates",
|
||||
"department_relays": "Department Relays"
|
||||
"department_relays": "Department Relays",
|
||||
"error_reports": "Error reports"
|
||||
},
|
||||
"pages": {
|
||||
"categories": {
|
||||
@@ -4654,6 +4626,8 @@
|
||||
"sms_phone": "Phone number for SMS notifications",
|
||||
"sms_phone_desc": "The phone number that receives SMS notifications.",
|
||||
"sms_subtitle": "SMS",
|
||||
"superuser_notifications": "Superuser notifications",
|
||||
"superuser_notifications_desc": "Receive email notifications when a new customer registers on the site.",
|
||||
"title": "Notifications"
|
||||
},
|
||||
"security": {
|
||||
|
||||
@@ -859,24 +859,8 @@
|
||||
},
|
||||
"day": "Dag",
|
||||
"dayHeaderFormat": "ddd D/M",
|
||||
"dayNames": [
|
||||
"Søndag",
|
||||
"Mandag",
|
||||
"Tirsdag",
|
||||
"Onsdag",
|
||||
"Torsdag",
|
||||
"Fredag",
|
||||
"Lørdag"
|
||||
],
|
||||
"dayNamesShort": [
|
||||
"Søn",
|
||||
"Mann",
|
||||
"Tir",
|
||||
"Ons",
|
||||
"Tor",
|
||||
"Fre",
|
||||
"Lør"
|
||||
],
|
||||
"dayNames": ["Søndag", "Mandag", "Tirsdag", "Onsdag", "Torsdag", "Fredag", "Lørdag"],
|
||||
"dayNamesShort": ["Søn", "Mann", "Tir", "Ons", "Tor", "Fre", "Lør"],
|
||||
"eventTimeFormat": "HH:mm",
|
||||
"list": "Liste",
|
||||
"month": "Måned",
|
||||
@@ -894,20 +878,7 @@
|
||||
"november",
|
||||
"desember"
|
||||
],
|
||||
"monthNamesShort": [
|
||||
"Jan",
|
||||
"feb",
|
||||
"Mar",
|
||||
"apr",
|
||||
"mai",
|
||||
"jun",
|
||||
"jul",
|
||||
"august",
|
||||
"sep",
|
||||
"Okt",
|
||||
"nov",
|
||||
"Av"
|
||||
],
|
||||
"monthNamesShort": ["Jan", "feb", "Mar", "apr", "mai", "jun", "jul", "august", "sep", "Okt", "nov", "Av"],
|
||||
"next": "I dette",
|
||||
"prev": "Forrige",
|
||||
"slotLabelFormat": "HH:mm",
|
||||
@@ -3861,7 +3832,8 @@
|
||||
"vehicles": "Kjøretøy",
|
||||
"xlvask": "XLVask",
|
||||
"department_gates": "Avdelingsporter",
|
||||
"department_relays": "Avdelingsreleer"
|
||||
"department_relays": "Avdelingsreleer",
|
||||
"error_reports": "Feilrapporter"
|
||||
},
|
||||
"pages": {
|
||||
"categories": {
|
||||
@@ -4642,6 +4614,8 @@
|
||||
"sms_phone": "Telefonnummer for SMS-varsler",
|
||||
"sms_phone_desc": "Telefonnummeret som mottar SMS-varsler.",
|
||||
"sms_subtitle": "SMS",
|
||||
"superuser_notifications": "Superbrukervarsler",
|
||||
"superuser_notifications_desc": "Motta e-postvarsler når en ny kunde registrerer seg på siden.",
|
||||
"title": "Varsler"
|
||||
},
|
||||
"security": {
|
||||
|
||||
@@ -859,24 +859,8 @@
|
||||
},
|
||||
"day": "Dag",
|
||||
"dayHeaderFormat": "ddd D/M",
|
||||
"dayNames": [
|
||||
"Söndag",
|
||||
"Måndag",
|
||||
"Tisdag",
|
||||
"Onsdag",
|
||||
"Torsdag",
|
||||
"Fredag",
|
||||
"Lördag"
|
||||
],
|
||||
"dayNamesShort": [
|
||||
"Sön",
|
||||
"Man",
|
||||
"Tir",
|
||||
"Ons",
|
||||
"Tor",
|
||||
"Fre",
|
||||
"Lör"
|
||||
],
|
||||
"dayNames": ["Söndag", "Måndag", "Tisdag", "Onsdag", "Torsdag", "Fredag", "Lördag"],
|
||||
"dayNamesShort": ["Sön", "Man", "Tir", "Ons", "Tor", "Fre", "Lör"],
|
||||
"eventTimeFormat": "HH:mm",
|
||||
"list": "Liste",
|
||||
"month": "Månad",
|
||||
@@ -894,20 +878,7 @@
|
||||
"November",
|
||||
"Desember"
|
||||
],
|
||||
"monthNamesShort": [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"Mai",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Okt",
|
||||
"Nov",
|
||||
"Des"
|
||||
],
|
||||
"monthNamesShort": ["Jan", "Feb", "Mar", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Des"],
|
||||
"next": "Neste",
|
||||
"prev": "Föregående",
|
||||
"slotLabelFormat": "HH:mm",
|
||||
@@ -3861,7 +3832,8 @@
|
||||
"vehicles": "Fordon",
|
||||
"xlvask": "XLVask",
|
||||
"department_gates": "Avdelningsgrindar",
|
||||
"department_relays": "Avdelningsreläer"
|
||||
"department_relays": "Avdelningsreläer",
|
||||
"error_reports": "Felrapporter"
|
||||
},
|
||||
"pages": {
|
||||
"categories": {
|
||||
@@ -4642,6 +4614,8 @@
|
||||
"sms_phone": "Telefonnummer för SMS-aviseringar",
|
||||
"sms_phone_desc": "Telefonnumret som för SMS-aviseringar.",
|
||||
"sms_subtitle": "SMS",
|
||||
"superuser_notifications": "Superanvändaraviseringar",
|
||||
"superuser_notifications_desc": "Ta emot e-postaviseringar när en ny kund registrerar sig på sidan.",
|
||||
"title": "Aviseringar"
|
||||
},
|
||||
"security": {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js";
|
||||
import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
|
||||
import { isTrustedReleaseUrl, sameOriginReleaseUrl } from "@/services/releaseTrust.js";
|
||||
|
||||
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
|
||||
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
|
||||
export const RELEASE_SOURCE_OVERRIDE_STORAGE_KEY = "release_source_override";
|
||||
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
|
||||
export const RELEASE_BOOTSTRAP_REVISION = "2026-06-03-dev-asset-republish";
|
||||
export const RELEASE_BOOTSTRAP_REVISION = "2026-06-08-regular-frontend-dynamic-api";
|
||||
export const RELEASE_SOURCE_MODES = Object.freeze({
|
||||
LOCAL: "local",
|
||||
DEPLOYMENT: "deployment",
|
||||
@@ -22,17 +20,20 @@ const normalizeReleaseChannelSlug = (value) =>
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 64);
|
||||
|
||||
const normalizeBaseUrl = (value) => String(value || "").trim().replace(/\/+$/, "");
|
||||
const normalizeBaseUrl = (value) =>
|
||||
String(value || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
|
||||
const normalizeReleaseSourceValue = (value) => {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return Object.values(RELEASE_SOURCE_MODES).includes(normalized) ? normalized : "";
|
||||
};
|
||||
|
||||
export const normalizeReleaseSourceMode = (value, fallback = RELEASE_SOURCE) =>
|
||||
normalizeReleaseSourceValue(value) ||
|
||||
normalizeReleaseSourceValue(fallback) ||
|
||||
RELEASE_SOURCE_MODES.DEPLOYMENT;
|
||||
normalizeReleaseSourceValue(value) || normalizeReleaseSourceValue(fallback) || RELEASE_SOURCE_MODES.DEPLOYMENT;
|
||||
|
||||
const browserOrigin = () => {
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
@@ -71,10 +72,8 @@ const readReleaseSourceOverride = () => {
|
||||
return override === RELEASE_SOURCE_MODES.AUTO ? "" : override;
|
||||
};
|
||||
|
||||
export const isReleaseSourceOverrideAvailable = ({
|
||||
isDev = IS_DEV,
|
||||
releaseSourceEnv = RELEASE_SOURCE_ENV,
|
||||
} = {}) => Boolean(isDev) && !normalizeReleaseSourceValue(releaseSourceEnv);
|
||||
export const isReleaseSourceOverrideAvailable = ({ isDev = IS_DEV, releaseSourceEnv = RELEASE_SOURCE_ENV } = {}) =>
|
||||
Boolean(isDev) && !normalizeReleaseSourceValue(releaseSourceEnv);
|
||||
|
||||
export const setReleaseSourceOverride = (value) => {
|
||||
const source = normalizeReleaseSourceValue(value);
|
||||
@@ -90,10 +89,7 @@ export const setReleaseSourceOverride = (value) => {
|
||||
return source;
|
||||
};
|
||||
|
||||
export const resolveReleaseSourceMode = (
|
||||
configuredSource = RELEASE_SOURCE,
|
||||
{ allowStorageOverride = false } = {}
|
||||
) => {
|
||||
export const resolveReleaseSourceMode = (configuredSource = RELEASE_SOURCE, { allowStorageOverride = false } = {}) => {
|
||||
const sourceMode = normalizeReleaseSourceMode(configuredSource);
|
||||
if (sourceMode !== RELEASE_SOURCE_MODES.AUTO) {
|
||||
return allowStorageOverride ? readReleaseSourceOverride() || sourceMode : sourceMode;
|
||||
@@ -172,76 +168,7 @@ export const fetchReleaseRuntime = async ({
|
||||
return payload?.data || payload || null;
|
||||
};
|
||||
|
||||
const runtimeFrontendBaseUrl = (runtime = {}) => {
|
||||
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
|
||||
const frontendBaseUrl = normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
|
||||
return isTrustedReleaseUrl(frontendBaseUrl, { allowRelative: false }) ? frontendBaseUrl : "";
|
||||
};
|
||||
|
||||
const runtimeChannel = (runtime = {}) => runtime?.channel || {};
|
||||
|
||||
export const shouldLoadRemoteRelease = (runtime = {}) => {
|
||||
if (normalizeReleaseSourceValue(runtime?.source) === RELEASE_SOURCE_MODES.LOCAL) {
|
||||
return false;
|
||||
}
|
||||
const channel = runtimeChannel(runtime);
|
||||
const slug = normalizeReleaseChannelSlug(channel?.slug || "");
|
||||
const isDefault = channel?.default_channel === true || channel?.default_channel === 1 || slug === "stable";
|
||||
return !isDefault && runtime?.availability?.configured !== false && Boolean(runtimeFrontendBaseUrl(runtime));
|
||||
};
|
||||
|
||||
export const releaseEntryUrl = (frontendBaseUrl) =>
|
||||
new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
|
||||
|
||||
const resolveReleaseAssetUrl = (frontendBaseUrl, value) => {
|
||||
const baseUrl = normalizeBaseUrl(frontendBaseUrl);
|
||||
const resolvedUrl = new URL(String(value || "").replace(/^\/+/, ""), `${baseUrl}/`).href;
|
||||
if (!sameOriginReleaseUrl(resolvedUrl, baseUrl)) {
|
||||
throw new Error("Release entry asset URL is not on the trusted release frontend origin.");
|
||||
}
|
||||
return resolvedUrl;
|
||||
};
|
||||
|
||||
export const loadRemoteReleaseEntry = async ({
|
||||
runtime,
|
||||
fetchFn = globalThis.fetch,
|
||||
documentRef = globalThis.document,
|
||||
importModule = (url) => import(/* @vite-ignore */ url),
|
||||
} = {}) => {
|
||||
const frontendBaseUrl = runtimeFrontendBaseUrl(runtime);
|
||||
if (!frontendBaseUrl) {
|
||||
throw new Error("Release frontend URL is not trusted.");
|
||||
}
|
||||
const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
mode: "cors",
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Release entry request failed with HTTP ${response?.status || 0}.`);
|
||||
}
|
||||
|
||||
const entry = await parseJsonResponse(response, "Release entry");
|
||||
const entryModule = String(entry?.entry || "").trim();
|
||||
if (!entryModule) {
|
||||
throw new Error("Release entry is missing an app module.");
|
||||
}
|
||||
|
||||
for (const cssFile of Array.isArray(entry?.css) ? entry.css : []) {
|
||||
const href = resolveReleaseAssetUrl(frontendBaseUrl, cssFile);
|
||||
if (documentRef?.querySelector?.(`link[data-release-entry-css="${href}"]`)) {
|
||||
continue;
|
||||
}
|
||||
const link = documentRef.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = href;
|
||||
link.crossOrigin = "anonymous";
|
||||
link.dataset.releaseEntryCss = href;
|
||||
documentRef.head.appendChild(link);
|
||||
}
|
||||
|
||||
return importModule(resolveReleaseAssetUrl(frontendBaseUrl, entryModule));
|
||||
};
|
||||
export const shouldLoadRemoteRelease = () => false;
|
||||
|
||||
const unavailableRuntime = (runtime, missing) => ({
|
||||
...(runtime || {}),
|
||||
@@ -297,25 +224,32 @@ const localReleaseRuntime = ({ requestedSource = RELEASE_SOURCE_MODES.LOCAL, mis
|
||||
};
|
||||
};
|
||||
|
||||
const unavailableSelectedRuntime = (missing, { source = RELEASE_SOURCE_MODES.LOCAL, requestedSource = source } = {}) => {
|
||||
const unavailableSelectedRuntime = (
|
||||
missing,
|
||||
{ source = RELEASE_SOURCE_MODES.LOCAL, requestedSource = source } = {}
|
||||
) => {
|
||||
const selectedChannel = readSelectedReleaseChannel();
|
||||
if (!selectedChannel || selectedChannel === "stable") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return runtimeWithSource(unavailableRuntime(
|
||||
{
|
||||
channel: {
|
||||
slug: selectedChannel,
|
||||
name: selectedChannel,
|
||||
default_channel: false,
|
||||
return runtimeWithSource(
|
||||
unavailableRuntime(
|
||||
{
|
||||
channel: {
|
||||
slug: selectedChannel,
|
||||
name: selectedChannel,
|
||||
default_channel: false,
|
||||
},
|
||||
availability: {
|
||||
explicit: true,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
explicit: true,
|
||||
},
|
||||
},
|
||||
missing
|
||||
), source, requestedSource);
|
||||
missing
|
||||
),
|
||||
source,
|
||||
requestedSource
|
||||
);
|
||||
};
|
||||
|
||||
export const setReleaseRuntimeGlobal = (runtime) => {
|
||||
@@ -325,13 +259,7 @@ export const setReleaseRuntimeGlobal = (runtime) => {
|
||||
return runtime;
|
||||
};
|
||||
|
||||
export const bootstrapReleaseApp = async ({
|
||||
loadLocalApp,
|
||||
fetchFn = globalThis.fetch,
|
||||
importModule,
|
||||
documentRef = globalThis.document,
|
||||
sourceMode,
|
||||
} = {}) => {
|
||||
export const bootstrapReleaseApp = async ({ loadLocalApp, fetchFn = globalThis.fetch, sourceMode } = {}) => {
|
||||
if (typeof loadLocalApp !== "function") {
|
||||
throw new Error("Release bootstrap requires a local app loader.");
|
||||
}
|
||||
@@ -340,10 +268,6 @@ export const bootstrapReleaseApp = async ({
|
||||
const resolvedSourceMode = resolveReleaseSourceMode(sourceMode || RELEASE_SOURCE, {
|
||||
allowStorageOverride: isReleaseSourceOverrideAvailable() && !hasExplicitSourceMode,
|
||||
});
|
||||
if (resolvedSourceMode === RELEASE_SOURCE_MODES.LOCAL) {
|
||||
setReleaseRuntimeGlobal(localReleaseRuntime({ requestedSource: resolvedSourceMode }));
|
||||
return loadLocalApp();
|
||||
}
|
||||
|
||||
let runtime = null;
|
||||
try {
|
||||
@@ -365,20 +289,5 @@ export const bootstrapReleaseApp = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldLoadRemoteRelease(runtime)) {
|
||||
return loadLocalApp();
|
||||
}
|
||||
|
||||
try {
|
||||
return await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef });
|
||||
} catch (error) {
|
||||
console.error("Could not load release channel frontend entry.", error);
|
||||
setReleaseRuntimeGlobal(
|
||||
unavailableRuntime(
|
||||
runtimeWithSource(runtime, RELEASE_SOURCE_MODES.LOCAL, resolvedSourceMode),
|
||||
"frontend_entry"
|
||||
)
|
||||
);
|
||||
return loadLocalApp();
|
||||
}
|
||||
return loadLocalApp();
|
||||
};
|
||||
|
||||
@@ -127,14 +127,18 @@ const normalizeReadinessMissingValues = (missing = []) =>
|
||||
new Set(
|
||||
(Array.isArray(missing) ? missing : [])
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter((value) => value && value !== "release_bundle")
|
||||
.filter(
|
||||
(value) =>
|
||||
value && !["release_bundle", "frontend_version", "frontend_base_url", "frontend_entry"].includes(value)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const hasExplicitReleaseRuntime = (runtime) => {
|
||||
return (
|
||||
Boolean(runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false) ||
|
||||
hasOwn(runtime, "versions")
|
||||
Boolean(
|
||||
runtime?.availability && typeof runtime.availability === "object" && runtime.availability.explicit !== false
|
||||
) || hasOwn(runtime, "versions")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -179,11 +183,6 @@ const runtimeAvailability = (runtime, channel) => {
|
||||
|
||||
const versions = runtime?.versions || {};
|
||||
const missing = [];
|
||||
if (!versions.frontend) {
|
||||
missing.push("frontend_version");
|
||||
} else if (!(runtime?.frontend_base_url || runtime?.urls?.frontend_base_url || runtime?.frontendBaseUrl)) {
|
||||
missing.push("frontend_base_url");
|
||||
}
|
||||
if (!versions.api) {
|
||||
missing.push("api_version");
|
||||
} else if (!(runtime?.api_base_url || runtime?.urls?.api_base_url || runtime?.apiBaseUrl)) {
|
||||
@@ -234,8 +233,8 @@ export const getReleaseChannelOptions = (runtime = {}) => {
|
||||
const source = Array.isArray(runtime?.availableChannels)
|
||||
? runtime.availableChannels
|
||||
: Array.isArray(runtime?.available_channels)
|
||||
? runtime.available_channels
|
||||
: [];
|
||||
? runtime.available_channels
|
||||
: [];
|
||||
const entries = source.length > 0 ? source : runtime?.channel ? [{ channel: runtime.channel }] : [];
|
||||
const optionsByKey = new Map();
|
||||
|
||||
@@ -354,10 +353,7 @@ export const isReleaseChannelApiAvailabilityError = (error, runtime = releaseRun
|
||||
return !apiBaseUrl || requestUrl.startsWith(apiBaseUrl);
|
||||
};
|
||||
|
||||
export const markReleaseChannelApiUnavailable = (
|
||||
runtime = releaseRuntimeState,
|
||||
missingKey = "api_base_url"
|
||||
) => {
|
||||
export const markReleaseChannelApiUnavailable = (runtime = releaseRuntimeState, missingKey = "api_base_url") => {
|
||||
const channelSlug = normalizeReleaseChannelSlug(runtime?.channel?.slug || getSelectedReleaseChannelSlug());
|
||||
const isDefaultChannel =
|
||||
runtime?.channel?.default_channel === true || runtime?.channel?.default_channel === 1 || channelSlug === "stable";
|
||||
@@ -366,7 +362,10 @@ export const markReleaseChannelApiUnavailable = (
|
||||
}
|
||||
|
||||
const availability = runtime?.availability && typeof runtime.availability === "object" ? runtime.availability : {};
|
||||
const missing = normalizeReadinessMissingValues([...(Array.isArray(availability.missing) ? availability.missing : []), missingKey]);
|
||||
const missing = normalizeReadinessMissingValues([
|
||||
...(Array.isArray(availability.missing) ? availability.missing : []),
|
||||
missingKey,
|
||||
]);
|
||||
const nextRuntime = {
|
||||
trace_id: runtime?.traceId || runtime?.trace_id || null,
|
||||
channel: runtime?.channel || {
|
||||
@@ -439,8 +438,7 @@ export const clearSelectedReleaseChannel = () => {
|
||||
};
|
||||
|
||||
export const reconcileSelectedReleaseChannel = (runtime = releaseRuntimeState) => {
|
||||
const hasExplicitOptions =
|
||||
Array.isArray(runtime?.availableChannels) || Array.isArray(runtime?.available_channels);
|
||||
const hasExplicitOptions = Array.isArray(runtime?.availableChannels) || Array.isArray(runtime?.available_channels);
|
||||
const selected = getSelectedReleaseChannelSlug();
|
||||
if (!hasExplicitOptions || !selected) {
|
||||
return selected;
|
||||
|
||||
@@ -16,12 +16,16 @@ const RELEASE_RUNTIME_SOURCES = new Set(["local", "deployment"]);
|
||||
const RELEASE_RUNTIME_REQUESTED_SOURCES = new Set(["local", "deployment", "auto"]);
|
||||
|
||||
const normalizeRuntimeSource = (value) => {
|
||||
const source = String(value || "").trim().toLowerCase();
|
||||
const source = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return RELEASE_RUNTIME_SOURCES.has(source) ? source : "";
|
||||
};
|
||||
|
||||
const normalizeRuntimeRequestedSource = (value) => {
|
||||
const source = String(value || "").trim().toLowerCase();
|
||||
const source = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return RELEASE_RUNTIME_REQUESTED_SOURCES.has(source) ? source : "";
|
||||
};
|
||||
|
||||
@@ -94,12 +98,17 @@ const normalizeReadinessMissingValues = (missing = []) =>
|
||||
new Set(
|
||||
(Array.isArray(missing) ? missing : [])
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter((value) => value && value !== "release_bundle")
|
||||
.filter(
|
||||
(value) =>
|
||||
value && !["release_bundle", "frontend_version", "frontend_base_url", "frontend_entry"].includes(value)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const normalizeRuntimeBaseUrl = (value, { allowRelative = true } = {}) => {
|
||||
const raw = String(value || "").trim().replace(/\/+$/, "");
|
||||
const raw = String(value || "")
|
||||
.trim()
|
||||
.replace(/\/+$/, "");
|
||||
if (!raw || !isTrustedReleaseUrl(raw, { allowRelative })) {
|
||||
return null;
|
||||
}
|
||||
@@ -173,11 +182,6 @@ export const configureReleaseRuntime = (runtime = {}) => {
|
||||
channel?.default_channel === true || channel?.default_channel === 1 || String(channel?.slug || "") === "stable";
|
||||
const missingReleaseContent = [];
|
||||
if (!isDefaultChannel && hasOwn(runtime, "versions")) {
|
||||
if (!runtime?.versions?.frontend) {
|
||||
missingReleaseContent.push("frontend_version");
|
||||
} else if (!urls.frontendBaseUrl) {
|
||||
missingReleaseContent.push("frontend_base_url");
|
||||
}
|
||||
if (!runtime?.versions?.api) {
|
||||
missingReleaseContent.push("api_version");
|
||||
} else if (!urls.apiBaseUrl) {
|
||||
@@ -546,7 +550,9 @@ const releaseVersionSecondaryText = (version = null) => {
|
||||
};
|
||||
|
||||
const releaseStatusTone = (status = "") => {
|
||||
const normalized = String(status || "").trim().toLowerCase();
|
||||
const normalized = String(status || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (
|
||||
[
|
||||
"failed",
|
||||
@@ -585,13 +591,7 @@ const releaseStatusTone = (status = "") => {
|
||||
};
|
||||
|
||||
const releaseServiceStatus = (service = null, fallback = "connected") =>
|
||||
firstFilledString(
|
||||
service?.deployment_status,
|
||||
service?.availability_state,
|
||||
service?.status,
|
||||
service?.state,
|
||||
fallback
|
||||
);
|
||||
firstFilledString(service?.deployment_status, service?.availability_state, service?.status, service?.state, fallback);
|
||||
|
||||
const releaseServicePrimaryText = (service = null) => {
|
||||
if (!isPlainRecord(service)) {
|
||||
@@ -660,9 +660,9 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable,
|
||||
const missing = normalizeReadinessMissingValues(availability.missing);
|
||||
const missingLookup = new Set(missing);
|
||||
const isDefaultChannel =
|
||||
channel?.default_channel === true
|
||||
|| channel?.default_channel === 1
|
||||
|| String(channel?.slug || "").toLowerCase() === "stable";
|
||||
channel?.default_channel === true ||
|
||||
channel?.default_channel === 1 ||
|
||||
String(channel?.slug || "").toLowerCase() === "stable";
|
||||
const includeInfrastructureDetails = options?.includeInfrastructureDetails === true;
|
||||
const urls = includeInfrastructureDetails ? releaseRuntimeUrlsForDisplay(runtime) : { frontend: "", api: "" };
|
||||
const frontendVersion = isPlainRecord(versions.frontend) ? versions.frontend : null;
|
||||
@@ -672,11 +672,17 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable,
|
||||
const rawServiceSet = isPlainRecord(versions.service_set)
|
||||
? versions.service_set
|
||||
: isPlainRecord(bundle?.service_set)
|
||||
? bundle.service_set
|
||||
: null;
|
||||
? bundle.service_set
|
||||
: null;
|
||||
const serviceSet = includeInfrastructureDetails ? rawServiceSet : null;
|
||||
const defaultSharedLabel = "Default/shared runtime";
|
||||
const missingLabels = missing.map((key) => RELEASE_MISSING_LABELS[key] || key.replace(/_/g, " "));
|
||||
const availabilityConfigured =
|
||||
missing.length === 0 ? true : availability.configured !== false && availability.status !== "unconfigured";
|
||||
const availabilityStatus =
|
||||
availabilityConfigured && ["", "unconfigured", "missing_target"].includes(String(availability.status || ""))
|
||||
? "ready"
|
||||
: firstFilledString(availability.status, availabilityConfigured ? "ready" : "unconfigured");
|
||||
|
||||
const buildAppRow = (key, label, version, url) => {
|
||||
const missingVersionKey = `${key}_version`;
|
||||
@@ -684,14 +690,21 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable,
|
||||
const missingKey = missingLookup.has(missingVersionKey)
|
||||
? missingVersionKey
|
||||
: missingLookup.has(missingUrlKey)
|
||||
? missingUrlKey
|
||||
: "";
|
||||
const fallback = isDefaultChannel ? defaultSharedLabel : `Missing ${label} version`;
|
||||
? missingUrlKey
|
||||
: "";
|
||||
const usesRegularFrontend = key === "frontend" && !missingKey && !version && !url && !isDefaultChannel;
|
||||
const fallback = isDefaultChannel
|
||||
? defaultSharedLabel
|
||||
: usesRegularFrontend
|
||||
? "Regular frontend"
|
||||
: `Missing ${label} version`;
|
||||
const status = missingKey
|
||||
? "missing value"
|
||||
: usesRegularFrontend
|
||||
? "regular"
|
||||
: isDefaultChannel && !version && !url
|
||||
? "shared"
|
||||
: firstFilledString(version?.status, url ? "active" : "unknown");
|
||||
? "shared"
|
||||
: firstFilledString(version?.status, url ? "active" : "unknown");
|
||||
|
||||
const primaryText = releaseVersionPrimaryText(version, fallback);
|
||||
const secondaryText = includeInfrastructureDetails ? releaseVersionSecondaryText(version) : "";
|
||||
@@ -705,9 +718,7 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable,
|
||||
primaryText,
|
||||
secondaryText,
|
||||
url: displayUrl,
|
||||
title: [primaryText, secondaryText, displayUrl]
|
||||
.filter(Boolean)
|
||||
.join(" - "),
|
||||
title: [primaryText, secondaryText, displayUrl].filter(Boolean).join(" - "),
|
||||
missingLabel: missingKey ? RELEASE_MISSING_LABELS[missingKey] || missingKey : "",
|
||||
};
|
||||
};
|
||||
@@ -724,7 +735,11 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable,
|
||||
tone: releaseStatusTone(status),
|
||||
primaryText: releaseServicePrimaryText(service) || "Connected service",
|
||||
secondaryText: releaseServiceSecondaryText(service),
|
||||
title: [releaseServicePrimaryText(service), releaseServiceSecondaryText(service), firstFilledString(service.health_url)]
|
||||
title: [
|
||||
releaseServicePrimaryText(service),
|
||||
releaseServiceSecondaryText(service),
|
||||
firstFilledString(service.health_url),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" - "),
|
||||
missingLabel: "",
|
||||
@@ -751,10 +766,8 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable,
|
||||
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"),
|
||||
availabilityStatus,
|
||||
availabilityTone: !availabilityConfigured || missing.length > 0 ? "warning" : releaseStatusTone(availabilityStatus),
|
||||
bundleLabel: bundleId
|
||||
? `#${bundleId}${firstFilledString(bundle?.version_label) ? ` ${bundle.version_label}` : ""}`
|
||||
: defaultSharedLabel,
|
||||
@@ -762,8 +775,8 @@ export const buildReleaseSessionSummary = (runtime = releaseRuntimeStateMutable,
|
||||
serviceSetLabel: serviceSet
|
||||
? firstFilledString(serviceSet.name, serviceSet.slug, serviceSet.id ? `#${serviceSet.id}` : "Connected")
|
||||
: rawServiceSet
|
||||
? "Restricted to release operators"
|
||||
: defaultSharedLabel,
|
||||
? "Restricted to release operators"
|
||||
: defaultSharedLabel,
|
||||
missingLabels,
|
||||
appRows: [
|
||||
buildAppRow("frontend", "Frontend", frontendVersion, urls.frontend),
|
||||
|
||||
+25
-382
@@ -1,7 +1,6 @@
|
||||
import { reactive, readonly } from "vue";
|
||||
import { fetchReleaseRuntime } from "@/services/releaseBootstrap.js";
|
||||
|
||||
export const RELEASE_UPDATE_CACHE_NAME = "truckwash-release-update-v1";
|
||||
export const RELEASE_UPDATE_CACHE_NAME = "truckwash-release-update-disabled";
|
||||
export const RELEASE_MANIFEST_FILENAME = "release-manifest.json";
|
||||
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
|
||||
export const RELEASE_UPDATE_STATUSES = Object.freeze({
|
||||
@@ -13,13 +12,9 @@ export const RELEASE_UPDATE_STATUSES = Object.freeze({
|
||||
FAILED: "failed",
|
||||
});
|
||||
|
||||
const AUTO_CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
||||
const MIN_COMMIT_COMPARE_LENGTH = 7;
|
||||
|
||||
const textValue = (value) => String(value ?? "").trim();
|
||||
const normalizeBaseUrl = (value) => textValue(value).replace(/\/+$/, "");
|
||||
const browserOrigin = () =>
|
||||
typeof window !== "undefined" && window.location?.origin ? window.location.origin : "http://localhost";
|
||||
|
||||
export const normalizeReleaseCommit = (value) => {
|
||||
const commit = textValue(value).toLowerCase();
|
||||
@@ -45,8 +40,7 @@ export const isSameReleaseCommit = (left, right) => {
|
||||
}
|
||||
const shortest = Math.min(leftCommit.length, rightCommit.length);
|
||||
return (
|
||||
shortest >= MIN_COMMIT_COMPARE_LENGTH &&
|
||||
(leftCommit.startsWith(rightCommit) || rightCommit.startsWith(leftCommit))
|
||||
shortest >= MIN_COMMIT_COMPARE_LENGTH && (leftCommit.startsWith(rightCommit) || rightCommit.startsWith(leftCommit))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -74,34 +68,10 @@ const initialState = () => ({
|
||||
});
|
||||
|
||||
const state = reactive(initialState());
|
||||
let downloadRunId = 0;
|
||||
let autoCheckConsumers = 0;
|
||||
let autoCheckTimer = null;
|
||||
let autoCheckRun = null;
|
||||
let autoCheckVisibilityHandler = null;
|
||||
let autoCheckFocusHandler = null;
|
||||
|
||||
export const releaseUpdateState = readonly(state);
|
||||
|
||||
const resetTransientProgress = () => {
|
||||
state.progress = 0;
|
||||
state.downloadedAssets = 0;
|
||||
state.totalAssets = 0;
|
||||
state.downloadedBytes = 0;
|
||||
state.totalBytes = 0;
|
||||
state.error = "";
|
||||
};
|
||||
|
||||
const assignCandidateState = (candidate) => {
|
||||
state.latestCommit = candidate?.latestCommit || "";
|
||||
state.channelSlug = candidate?.channelSlug || "";
|
||||
state.channelName = candidate?.channelName || "";
|
||||
state.frontendBaseUrl = candidate?.frontendBaseUrl || "";
|
||||
state.buildId = candidate?.buildId || "";
|
||||
state.candidate = candidate || null;
|
||||
state.candidateKey = candidate ? releaseUpdateCandidateKey(candidate) : "";
|
||||
};
|
||||
|
||||
const versionCommit = (version = null) => {
|
||||
if (!version || typeof version !== "object") {
|
||||
return "";
|
||||
@@ -123,52 +93,6 @@ const versionCommit = (version = null) => {
|
||||
);
|
||||
};
|
||||
|
||||
const versionBuildId = (version = null, runtime = {}) =>
|
||||
textValue(version?.build_id) || textValue(version?.buildId) || textValue(runtime?.versions?.bundle_id);
|
||||
|
||||
const normalizeChannelSlug = (channel = {}) =>
|
||||
textValue(channel?.slug)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 64);
|
||||
|
||||
const normalizeRuntimeFrontendBaseUrl = (runtime = {}) => {
|
||||
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
|
||||
const raw = normalizeBaseUrl(runtime?.frontendBaseUrl || runtime?.frontend_base_url || urls.frontend_base_url);
|
||||
if (!raw) {
|
||||
return "";
|
||||
}
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
return new URL(`${raw.replace(/^\/+/, "")}/`, `${browserOrigin()}/`).href.replace(/\/+$/, "");
|
||||
};
|
||||
|
||||
const releaseUpdateCandidateKey = (candidate = {}) =>
|
||||
[candidate.channelSlug || "", candidate.frontendBaseUrl || "", candidate.latestCommit || ""].join("|");
|
||||
|
||||
export const releaseUpdateCandidateFromRuntime = (runtime = {}) => {
|
||||
const channel = runtime?.channel && typeof runtime.channel === "object" ? runtime.channel : {};
|
||||
const frontendVersion = runtime?.versions?.frontend || null;
|
||||
const latestCommit = normalizeReleaseCommit(versionCommit(frontendVersion));
|
||||
const frontendBaseUrl = normalizeRuntimeFrontendBaseUrl(runtime);
|
||||
const availability = runtime?.availability && typeof runtime.availability === "object" ? runtime.availability : {};
|
||||
|
||||
if (availability.configured === false || !latestCommit || !frontendBaseUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
channelSlug: normalizeChannelSlug(channel),
|
||||
channelName: textValue(channel.name) || textValue(channel.slug) || "Release",
|
||||
latestCommit,
|
||||
frontendBaseUrl,
|
||||
buildId: versionBuildId(frontendVersion, runtime),
|
||||
};
|
||||
};
|
||||
|
||||
const ensureCurrentCommit = (runtime = null) => {
|
||||
if (normalizeReleaseCommit(state.currentCommit)) {
|
||||
return state.currentCommit;
|
||||
@@ -178,337 +102,56 @@ const ensureCurrentCommit = (runtime = null) => {
|
||||
return state.currentCommit;
|
||||
};
|
||||
|
||||
const releaseAssetUrl = (frontendBaseUrl, value) => {
|
||||
const asset = textValue(value);
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
const url = /^https?:\/\//i.test(asset)
|
||||
? new URL(asset).href
|
||||
: new URL(asset.replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
|
||||
return {
|
||||
raw: asset,
|
||||
key: asset.replace(/^\/+/, ""),
|
||||
url,
|
||||
};
|
||||
const resetTransientProgress = () => {
|
||||
state.progress = 0;
|
||||
state.downloadedAssets = 0;
|
||||
state.totalAssets = 0;
|
||||
state.downloadedBytes = 0;
|
||||
state.totalBytes = 0;
|
||||
state.error = "";
|
||||
};
|
||||
|
||||
const uniqueReleaseAssets = (frontendBaseUrl, manifest = {}) => {
|
||||
const values = [
|
||||
RELEASE_MANIFEST_FILENAME,
|
||||
RELEASE_ENTRY_FILENAME,
|
||||
manifest.entry,
|
||||
...(Array.isArray(manifest.css) ? manifest.css : []),
|
||||
...(Array.isArray(manifest.index_asset_urls) ? manifest.index_asset_urls : []),
|
||||
...(Array.isArray(manifest.pwa_asset_urls) ? manifest.pwa_asset_urls : []),
|
||||
...(Array.isArray(manifest.asset_urls) ? manifest.asset_urls : []),
|
||||
];
|
||||
const byUrl = new Map();
|
||||
values.forEach((value) => {
|
||||
const asset = releaseAssetUrl(frontendBaseUrl, value);
|
||||
if (asset?.url && !byUrl.has(asset.url) && asset.raw !== "/index.html") {
|
||||
byUrl.set(asset.url, asset);
|
||||
}
|
||||
});
|
||||
return [...byUrl.values()];
|
||||
};
|
||||
|
||||
const manifestAssetMeta = (manifest = {}, asset = {}) => {
|
||||
const hashes = manifest.asset_hashes && typeof manifest.asset_hashes === "object" ? manifest.asset_hashes : {};
|
||||
return hashes[asset.raw] || hashes[`/${asset.key}`] || hashes[asset.key] || null;
|
||||
};
|
||||
|
||||
const fetchJson = async (fetchFn, url) => {
|
||||
const response = await fetchFn(url, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Release manifest request failed with HTTP ${response?.status || 0}.`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const consumeResponseBody = async (response) => {
|
||||
if (typeof response?.arrayBuffer === "function") {
|
||||
await response.arrayBuffer();
|
||||
return;
|
||||
}
|
||||
if (typeof response?.blob === "function") {
|
||||
await response.blob();
|
||||
return;
|
||||
}
|
||||
if (typeof response?.text === "function") {
|
||||
await response.text();
|
||||
}
|
||||
};
|
||||
|
||||
const releaseCache = async () => {
|
||||
if (typeof caches === "undefined" || typeof caches.open !== "function") {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await caches.open(RELEASE_UPDATE_CACHE_NAME);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const cacheReleaseResponse = async (cacheRef, url, response) => {
|
||||
if (!cacheRef || typeof cacheRef.put !== "function" || typeof response?.clone !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await cacheRef.put(url, response.clone());
|
||||
} catch {
|
||||
// Cache API is opportunistic. Network fetch success is enough to mark the update ready.
|
||||
}
|
||||
};
|
||||
|
||||
const fetchReleaseAsset = async (fetchFn, cacheRef, asset) => {
|
||||
const response = await fetchFn(asset.url, {
|
||||
method: "GET",
|
||||
cache: "reload",
|
||||
mode: "cors",
|
||||
credentials: "omit",
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(`Release asset request failed with HTTP ${response?.status || 0}.`);
|
||||
}
|
||||
await cacheReleaseResponse(cacheRef, asset.url, response);
|
||||
await consumeResponseBody(response);
|
||||
};
|
||||
|
||||
const markUpToDate = (candidate = null) => {
|
||||
const markUpToDate = () => {
|
||||
state.status = RELEASE_UPDATE_STATUSES.UP_TO_DATE;
|
||||
resetTransientProgress();
|
||||
assignCandidateState(candidate);
|
||||
state.latestCommit = candidate?.latestCommit || normalizeReleaseCommit(state.currentCommit);
|
||||
state.latestCommit = normalizeReleaseCommit(state.currentCommit);
|
||||
state.channelSlug = "";
|
||||
state.channelName = "";
|
||||
state.frontendBaseUrl = "";
|
||||
state.buildId = "";
|
||||
state.candidate = null;
|
||||
state.candidateKey = "";
|
||||
state.readyCandidate = null;
|
||||
state.checkedAt = Date.now();
|
||||
return releaseUpdateState;
|
||||
};
|
||||
|
||||
export const downloadReleaseCandidate = async (candidate, { fetchFn = globalThis.fetch } = {}) => {
|
||||
if (!candidate || typeof fetchFn !== "function") {
|
||||
return releaseUpdateState;
|
||||
}
|
||||
export const releaseUpdateCandidateFromRuntime = () => null;
|
||||
|
||||
const candidateKey = releaseUpdateCandidateKey(candidate);
|
||||
if (state.status === RELEASE_UPDATE_STATUSES.DOWNLOADING && state.candidateKey === candidateKey) {
|
||||
return releaseUpdateState;
|
||||
}
|
||||
if (state.status === RELEASE_UPDATE_STATUSES.READY && state.readyCandidate?.key === candidateKey) {
|
||||
return releaseUpdateState;
|
||||
}
|
||||
export const downloadReleaseCandidate = async () => markUpToDate();
|
||||
|
||||
const runId = ++downloadRunId;
|
||||
state.status = RELEASE_UPDATE_STATUSES.DOWNLOADING;
|
||||
resetTransientProgress();
|
||||
assignCandidateState(candidate);
|
||||
|
||||
try {
|
||||
const manifestUrl = new URL(RELEASE_MANIFEST_FILENAME, `${normalizeBaseUrl(candidate.frontendBaseUrl)}/`).href;
|
||||
const manifest = await fetchJson(fetchFn, manifestUrl);
|
||||
const assets = uniqueReleaseAssets(candidate.frontendBaseUrl, manifest);
|
||||
const cacheRef = await releaseCache();
|
||||
const byteTotal = assets.reduce((total, asset) => {
|
||||
const bytes = Number(manifestAssetMeta(manifest, asset)?.bytes || 0);
|
||||
return total + (Number.isFinite(bytes) && bytes > 0 ? bytes : 0);
|
||||
}, 0);
|
||||
|
||||
if (runId !== downloadRunId) {
|
||||
return releaseUpdateState;
|
||||
}
|
||||
|
||||
state.totalAssets = assets.length;
|
||||
state.totalBytes = byteTotal;
|
||||
|
||||
for (const asset of assets) {
|
||||
await fetchReleaseAsset(fetchFn, cacheRef, asset);
|
||||
if (runId !== downloadRunId) {
|
||||
return releaseUpdateState;
|
||||
}
|
||||
const bytes = Number(manifestAssetMeta(manifest, asset)?.bytes || 0);
|
||||
state.downloadedAssets += 1;
|
||||
state.downloadedBytes += Number.isFinite(bytes) && bytes > 0 ? bytes : 0;
|
||||
state.progress = state.totalAssets > 0 ? Math.round((state.downloadedAssets / state.totalAssets) * 100) : 100;
|
||||
}
|
||||
|
||||
state.status = RELEASE_UPDATE_STATUSES.READY;
|
||||
state.progress = 100;
|
||||
state.error = "";
|
||||
state.buildId = textValue(manifest.build_id) || candidate.buildId || "";
|
||||
state.readyCandidate = {
|
||||
...candidate,
|
||||
key: candidateKey,
|
||||
buildId: state.buildId,
|
||||
manifestCommit: normalizeReleaseCommit(manifest.commit_sha),
|
||||
assetCount: assets.length,
|
||||
};
|
||||
state.checkedAt = Date.now();
|
||||
} catch (error) {
|
||||
if (runId === downloadRunId) {
|
||||
state.status = RELEASE_UPDATE_STATUSES.FAILED;
|
||||
state.error = error?.message || "Release update download failed.";
|
||||
state.checkedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
return releaseUpdateState;
|
||||
};
|
||||
|
||||
export const inspectReleaseRuntimeForUpdate = async (
|
||||
runtime = {},
|
||||
{ fetchFn = globalThis.fetch, autoDownload = true } = {}
|
||||
) => {
|
||||
export const inspectReleaseRuntimeForUpdate = async (runtime = {}) => {
|
||||
ensureCurrentCommit(runtime);
|
||||
const candidate = releaseUpdateCandidateFromRuntime(runtime);
|
||||
|
||||
if (!candidate) {
|
||||
return markUpToDate(null);
|
||||
}
|
||||
|
||||
assignCandidateState(candidate);
|
||||
state.checkedAt = Date.now();
|
||||
|
||||
if (!normalizeReleaseCommit(state.currentCommit) || isSameReleaseCommit(state.currentCommit, candidate.latestCommit)) {
|
||||
return markUpToDate(candidate);
|
||||
}
|
||||
|
||||
if (state.status === RELEASE_UPDATE_STATUSES.READY && state.readyCandidate?.key === releaseUpdateCandidateKey(candidate)) {
|
||||
return releaseUpdateState;
|
||||
}
|
||||
|
||||
if (!autoDownload) {
|
||||
state.status = RELEASE_UPDATE_STATUSES.IDLE;
|
||||
return releaseUpdateState;
|
||||
}
|
||||
|
||||
return downloadReleaseCandidate(candidate, { fetchFn });
|
||||
return markUpToDate();
|
||||
};
|
||||
|
||||
export const checkReleaseUpdateNow = async ({
|
||||
runtime = null,
|
||||
fetchFn = globalThis.fetch,
|
||||
autoDownload = true,
|
||||
} = {}) => {
|
||||
if (state.status === RELEASE_UPDATE_STATUSES.DOWNLOADING) {
|
||||
return releaseUpdateState;
|
||||
}
|
||||
export const checkReleaseUpdateNow = async () => markUpToDate();
|
||||
|
||||
state.status = RELEASE_UPDATE_STATUSES.CHECKING;
|
||||
state.error = "";
|
||||
export const installReadyReleaseUpdate = async () => false;
|
||||
|
||||
try {
|
||||
const nextRuntime = runtime || (await fetchReleaseRuntime({ fetchFn }));
|
||||
return inspectReleaseRuntimeForUpdate(nextRuntime || {}, { fetchFn, autoDownload });
|
||||
} catch (error) {
|
||||
state.status = RELEASE_UPDATE_STATUSES.FAILED;
|
||||
state.error = error?.message || "Release update check failed.";
|
||||
state.checkedAt = Date.now();
|
||||
return releaseUpdateState;
|
||||
}
|
||||
};
|
||||
|
||||
export const installReadyReleaseUpdate = async ({
|
||||
reload = () => window.location.reload(),
|
||||
serviceWorker = typeof navigator !== "undefined" ? navigator.serviceWorker : null,
|
||||
} = {}) => {
|
||||
if (state.status !== RELEASE_UPDATE_STATUSES.READY || !state.readyCandidate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration =
|
||||
serviceWorker && typeof serviceWorker.getRegistration === "function"
|
||||
? await serviceWorker.getRegistration()
|
||||
: null;
|
||||
if (registration?.waiting && typeof registration.waiting.postMessage === "function") {
|
||||
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
||||
}
|
||||
} catch {
|
||||
// Reload still lets the normal bootstrap select the latest runtime.
|
||||
}
|
||||
|
||||
if (typeof reload === "function") {
|
||||
reload();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const clearAutoCheck = () => {
|
||||
if (typeof window !== "undefined" && autoCheckTimer !== null) {
|
||||
window.clearInterval(autoCheckTimer);
|
||||
}
|
||||
autoCheckTimer = null;
|
||||
|
||||
if (typeof window !== "undefined" && autoCheckFocusHandler) {
|
||||
window.removeEventListener("focus", autoCheckFocusHandler);
|
||||
}
|
||||
if (typeof document !== "undefined" && autoCheckVisibilityHandler) {
|
||||
document.removeEventListener("visibilitychange", autoCheckVisibilityHandler);
|
||||
}
|
||||
autoCheckFocusHandler = null;
|
||||
autoCheckVisibilityHandler = null;
|
||||
autoCheckRun = null;
|
||||
};
|
||||
|
||||
export const startReleaseUpdateAutoCheck = ({
|
||||
intervalMs = AUTO_CHECK_INTERVAL_MS,
|
||||
fetchFn = globalThis.fetch,
|
||||
immediate = true,
|
||||
} = {}) => {
|
||||
export const startReleaseUpdateAutoCheck = ({ immediate = true } = {}) => {
|
||||
autoCheckConsumers += 1;
|
||||
if (autoCheckConsumers > 1) {
|
||||
return () => {
|
||||
autoCheckConsumers = Math.max(0, autoCheckConsumers - 1);
|
||||
if (autoCheckConsumers === 0) {
|
||||
clearAutoCheck();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
autoCheckRun = () => {
|
||||
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
||||
return;
|
||||
}
|
||||
void checkReleaseUpdateNow({ fetchFn }).catch(() => {});
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
autoCheckRun();
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
autoCheckTimer = window.setInterval(autoCheckRun, intervalMs);
|
||||
autoCheckFocusHandler = () => autoCheckRun?.();
|
||||
window.addEventListener("focus", autoCheckFocusHandler);
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
autoCheckVisibilityHandler = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
autoCheckRun?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", autoCheckVisibilityHandler);
|
||||
markUpToDate();
|
||||
}
|
||||
|
||||
return () => {
|
||||
autoCheckConsumers = Math.max(0, autoCheckConsumers - 1);
|
||||
if (autoCheckConsumers === 0) {
|
||||
clearAutoCheck();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const __resetReleaseUpdateForTests = ({ currentCommit = "" } = {}) => {
|
||||
clearAutoCheck();
|
||||
autoCheckConsumers = 0;
|
||||
downloadRunId += 1;
|
||||
Object.assign(state, initialState(), {
|
||||
currentCommit: normalizeReleaseCommit(currentCommit) || initialFrontendCommit(),
|
||||
});
|
||||
|
||||
@@ -36,6 +36,8 @@ export const normalizeSessionPayload = (payload = {}) => {
|
||||
wash_certificate_email: notifications.wash_certificate_email ?? null,
|
||||
email_notifications_enabled: notifications.email_notifications_enabled ?? null,
|
||||
sms_notifications_enabled: notifications.sms_notifications_enabled ?? null,
|
||||
superuser_new_customer_email_notifications_enabled:
|
||||
notifications.superuser_new_customer_email_notifications_enabled ?? null,
|
||||
},
|
||||
created_at: data.created_at ?? null,
|
||||
updated_at: data.updated_at ?? null,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -475,7 +475,8 @@ const shouldShowWashFlow = computed(
|
||||
);
|
||||
|
||||
const shouldShowDisabledDepartmentWarning = computed(
|
||||
() => !!nearestDepartment.value && !doesCurrentDepartmentSelectionHaveSelfServeEnabled.value && !shouldShowWashFlow.value
|
||||
() =>
|
||||
!!nearestDepartment.value && !doesCurrentDepartmentSelectionHaveSelfServeEnabled.value && !shouldShowWashFlow.value
|
||||
);
|
||||
|
||||
const isLastGuidedWashStep = computed(() => currentGuidedWashStep.value >= guidedWashFlowSteps.length - 1);
|
||||
@@ -558,7 +559,6 @@ const completeGuidedWash = async () => {
|
||||
} catch (error) {
|
||||
console.error("Error opening property exit gate during wash completion:", error);
|
||||
}
|
||||
|
||||
} finally {
|
||||
isCompletingWash.value = false;
|
||||
}
|
||||
@@ -934,7 +934,10 @@ const executeSelfServeFetch = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!options.force && (request.key === latestSuccessfulSelfServeFetchKey || request.key === inFlightSelfServeFetchKey)) {
|
||||
if (
|
||||
!options.force &&
|
||||
(request.key === latestSuccessfulSelfServeFetchKey || request.key === inFlightSelfServeFetchKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1396,11 +1399,7 @@ const syncActiveWashWithServer = async () => {
|
||||
const isWithinStartGracePeriod =
|
||||
washStartTime.value && Date.now() - washStartTime.value < WASH_START_SERVER_SYNC_GRACE_MS;
|
||||
const isBeforeCustomerWashCompletionPhase = currentStep.value < steps.WASH_IN_PROGRESS;
|
||||
if (
|
||||
isWithinStartGracePeriod ||
|
||||
isBeforeCustomerWashCompletionPhase ||
|
||||
!isCompletingWash.value
|
||||
) {
|
||||
if (isWithinStartGracePeriod || isBeforeCustomerWashCompletionPhase || !isCompletingWash.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2030,6 +2029,7 @@ watch(
|
||||
type="is-warning"
|
||||
icon-left="sync-alt"
|
||||
data-testid="self-serve-departments-error"
|
||||
retry-test-id="self-serve-departments-retry"
|
||||
@retry="fetchDepartments"
|
||||
/>
|
||||
|
||||
@@ -2039,6 +2039,7 @@ watch(
|
||||
icon-left="sync-alt"
|
||||
:loading="isSelfServeRetrying"
|
||||
data-testid="self-serve-runtime-error"
|
||||
retry-test-id="self-serve-runtime-retry"
|
||||
@retry="retrySelfServeData"
|
||||
/>
|
||||
|
||||
@@ -2048,6 +2049,7 @@ watch(
|
||||
icon-left="sync-alt"
|
||||
:loading="isStartingWash"
|
||||
data-testid="self-serve-action-error"
|
||||
retry-test-id="self-serve-action-retry"
|
||||
@retry="retryWashAction"
|
||||
/>
|
||||
|
||||
@@ -2057,10 +2059,7 @@ watch(
|
||||
data-testid="self-serve-completed-fallback"
|
||||
>
|
||||
<SelfServeCompletedStep :completed-duration-ms="completedDurationMs" />
|
||||
<div
|
||||
class="buttons is-centered self-serve-prewash-actions"
|
||||
data-testid="self-serve-completed-actions"
|
||||
>
|
||||
<div class="buttons is-centered self-serve-prewash-actions" data-testid="self-serve-completed-actions">
|
||||
<div class="self-serve-bottom-actions__row self-serve-bottom-actions__row--prewash">
|
||||
<b-button
|
||||
class="self-serve-bottom-actions__button"
|
||||
@@ -2166,10 +2165,7 @@ watch(
|
||||
/>
|
||||
|
||||
<div v-if="answerSyncError" data-testid="self-serve-answer-sync-error">
|
||||
<b-message
|
||||
type="is-danger"
|
||||
:aria-close-label="$t('common.close')"
|
||||
>
|
||||
<b-message type="is-danger" :aria-close-label="$t('common.close')">
|
||||
{{ answerSyncError }}
|
||||
</b-message>
|
||||
</div>
|
||||
@@ -2431,7 +2427,6 @@ watch(
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
.self-serve-bottom-actions__row--prewash {
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ function createUserSessionData(overrides: Record<string, unknown> = {}) {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: true,
|
||||
sms_notifications_enabled: false,
|
||||
superuser_new_customer_email_notifications_enabled: false,
|
||||
},
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
|
||||
@@ -3649,7 +3649,7 @@ test.describe("POS mobile order flow", () => {
|
||||
.toBe(53);
|
||||
});
|
||||
|
||||
test("booking completion without safety seal completes and resets", async ({ page }) => {
|
||||
test("booking completion without safety seal sends confirmation email and resets", async ({ page }) => {
|
||||
const orderId = 9407;
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
@@ -3692,12 +3692,23 @@ test.describe("POS mobile order flow", () => {
|
||||
await expect.poll(() => fixture.requestCounters.bookingSetOrderId, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.bookingCompletionConfirmationEmail, { timeout: 10_000 }).toBe(1);
|
||||
expect(fixture.markCompletedOrderIds).toContain(orderId);
|
||||
await expect.poll(() => fixture.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed");
|
||||
await expect
|
||||
.poll(() => fixture.requestLog.bookingCompletionConfirmationEmails[0] ?? null, { timeout: 10_000 })
|
||||
.toMatchObject({
|
||||
booking_id: DEFAULT_BOOKING_ID,
|
||||
order_id: orderId,
|
||||
customer_number: REGULAR_CUSTOMER_ID,
|
||||
recipient: "pos-mobile@example.com",
|
||||
safety_seal: "",
|
||||
has_safety_seal: false,
|
||||
});
|
||||
await waitForStepReset(page);
|
||||
});
|
||||
|
||||
test("booking completion prompts when a safety seal is added to the basket", async ({ page }) => {
|
||||
test("booking completion with safety seal sends confirmation email with seal", async ({ page }) => {
|
||||
const orderId = 9409;
|
||||
const fixture = createMobilePosFixture({
|
||||
ordersById: {
|
||||
@@ -3764,12 +3775,23 @@ test.describe("POS mobile order flow", () => {
|
||||
|
||||
await expect.poll(() => fixture.requestCounters.bookingComplete, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => fixture.requestCounters.bookingCompletionConfirmationEmail, { timeout: 10_000 }).toBe(1);
|
||||
expect(fixture.markCompletedOrderIds).toContain(orderId);
|
||||
await expect
|
||||
.poll(() => fixture.requestLog.bookingCompletions[0]?.safety_seal ?? null, { timeout: 10_000 })
|
||||
.toBe(9090);
|
||||
await expect.poll(() => fixture.bookingsById[DEFAULT_BOOKING_ID]?.status ?? "").toBe("completed");
|
||||
await expect.poll(() => String(fixture.bookingsById[DEFAULT_BOOKING_ID]?.safety_seal ?? "")).toBe("9090");
|
||||
await expect
|
||||
.poll(() => fixture.requestLog.bookingCompletionConfirmationEmails[0] ?? null, { timeout: 10_000 })
|
||||
.toMatchObject({
|
||||
booking_id: DEFAULT_BOOKING_ID,
|
||||
order_id: orderId,
|
||||
customer_number: REGULAR_CUSTOMER_ID,
|
||||
recipient: "pos-mobile@example.com",
|
||||
safety_seal: "9090",
|
||||
has_safety_seal: true,
|
||||
});
|
||||
await waitForStepReset(page);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,7 @@ const json = (body, status = 200) => ({
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
test("non-default release frontend loads from api-v2.truckwash.io without redirecting", async ({ page }) => {
|
||||
const releaseApiRequests = [];
|
||||
test("non-default release channel keeps the regular frontend and dynamic API runtime", async ({ page }) => {
|
||||
const releaseEntryRequests = [];
|
||||
const runtimeRequests = [];
|
||||
const runtime = {
|
||||
@@ -79,21 +78,13 @@ test("non-default release frontend loads from api-v2.truckwash.io without redire
|
||||
contentType: "application/javascript",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: `
|
||||
document.body.dataset.releaseFrontend = 'canary';
|
||||
document.body.dataset.releaseOrigin = window.location.origin;
|
||||
fetch('https://api-v2.truckwash.io/canary/api/ping').catch(() => {});
|
||||
`,
|
||||
});
|
||||
});
|
||||
await page.route("https://api-v2.truckwash.io/canary/api/ping", async (route) => {
|
||||
releaseApiRequests.push(route.request().url());
|
||||
await route.fulfill(json({ data: { ok: true } }));
|
||||
});
|
||||
|
||||
await page.goto("/shared/passkey-safe-link", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.locator("body")).toHaveAttribute("data-release-frontend", "canary");
|
||||
|
||||
const currentOrigin = await page.evaluate(() => window.location.origin);
|
||||
expect(page.url()).toContain("/shared/passkey-safe-link");
|
||||
expect(page.url()).not.toContain("api-v2.truckwash.io");
|
||||
expect(runtimeRequests).toHaveLength(1);
|
||||
@@ -101,8 +92,17 @@ test("non-default release frontend loads from api-v2.truckwash.io without redire
|
||||
expect(["/api/release/runtime", "/master/api/release/runtime"]).toContain(runtimeRequestUrl.pathname);
|
||||
expect(runtimeRequestUrl.pathname).not.toBe("/canary/api/release/runtime");
|
||||
expect(runtimeRequestUrl.searchParams.get("release_channel")).toBe("canary");
|
||||
expect(releaseEntryRequests).toEqual(["https://api-v2.truckwash.io/canary/frontend/release-entry.json"]);
|
||||
await expect.poll(() => releaseApiRequests.length).toBe(1);
|
||||
expect(releaseApiRequests[0]).toBe("https://api-v2.truckwash.io/canary/api/ping");
|
||||
await expect(page.locator("body")).toHaveAttribute("data-release-origin", currentOrigin);
|
||||
expect(releaseEntryRequests).toEqual([]);
|
||||
await expect(page.locator("body")).not.toHaveAttribute("data-release-frontend", "canary");
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => ({
|
||||
channel: window.__TRUCKWASH_RELEASE_RUNTIME__?.channel?.slug || null,
|
||||
apiBaseUrl: window.__TRUCKWASH_RELEASE_RUNTIME__?.urls?.api_base_url || null,
|
||||
}))
|
||||
)
|
||||
.toEqual({
|
||||
channel: "canary",
|
||||
apiBaseUrl: "https://api-v2.truckwash.io/canary/api",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -211,9 +211,9 @@ const runtimeWithReadyBetaSelected = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const internalMissingFrontendEntryRuntime = () => ({
|
||||
const internalMissingApiRuntime = () => ({
|
||||
generated_at: "2026-05-20T17:50:00.000Z",
|
||||
trace_id: "trace-internal-missing-frontend-entry",
|
||||
trace_id: "trace-internal-missing-api",
|
||||
channel: {
|
||||
id: 4,
|
||||
slug: "internal",
|
||||
@@ -232,15 +232,15 @@ const internalMissingFrontendEntryRuntime = () => ({
|
||||
bundle_id: null,
|
||||
},
|
||||
frontend_base_url: null,
|
||||
api_base_url: "https://api-v2.truckwash.io/internal/api",
|
||||
api_base_url: null,
|
||||
urls: {
|
||||
frontend_base_url: null,
|
||||
api_base_url: "https://api-v2.truckwash.io/internal/api",
|
||||
api_base_url: null,
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
explicit: true,
|
||||
missing: ["frontend_entry"],
|
||||
missing: ["api_base_url"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
available_channels: [
|
||||
@@ -285,7 +285,7 @@ const internalMissingFrontendEntryRuntime = () => ({
|
||||
availability: {
|
||||
configured: false,
|
||||
explicit: true,
|
||||
missing: ["frontend_entry"],
|
||||
missing: ["api_base_url"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
},
|
||||
@@ -331,7 +331,7 @@ test("users assigned to an unconfigured release channel can ignore the guard tem
|
||||
await expect(guard).toContainText("Release channel is not ready");
|
||||
await expect(guard).toContainText("Canary");
|
||||
await expect(page.getByTestId("release-channel-missing")).not.toContainText("Release bundle");
|
||||
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend version");
|
||||
await expect(page.getByTestId("release-channel-missing")).not.toContainText("Frontend version");
|
||||
await expect(page.getByTestId("release-channel-missing")).toContainText("API version");
|
||||
await expect(page.getByTestId("release-channel-next-check")).toContainText("Checking again in");
|
||||
|
||||
@@ -440,7 +440,7 @@ test("selected channel auth session 404 shows the release channel guard", async
|
||||
test("login assigned to an unconfigured internal channel shows guard actions without the user-data modal", async ({
|
||||
page,
|
||||
}) => {
|
||||
const runtime = internalMissingFrontendEntryRuntime();
|
||||
const runtime = internalMissingApiRuntime();
|
||||
let releaseRuntimeResponse = runtimeWithFailingBetaSwitch();
|
||||
const sessionRequests = [];
|
||||
|
||||
@@ -491,7 +491,7 @@ test("login assigned to an unconfigured internal channel shows guard actions wit
|
||||
await expect(page).toHaveURL(/\/user/);
|
||||
await expect(guard).toContainText("Release channel is not ready");
|
||||
await expect(guard).toContainText("Intern");
|
||||
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend entry");
|
||||
await expect(page.getByTestId("release-channel-missing")).toContainText("API URL");
|
||||
await expect(page.getByTestId("release-channel-option-stable")).toBeVisible();
|
||||
await expect(page.getByTestId("release-channel-ignore")).toBeVisible();
|
||||
await expect(page.getByTestId("release-channel-check-again")).toBeVisible();
|
||||
@@ -604,7 +604,9 @@ test("ready sidebar release channel switches are confirmed through the control r
|
||||
expect(betaRuntimeRequestUrl.pathname).not.toBe("/beta/api/release/runtime");
|
||||
});
|
||||
|
||||
test("sidebar can switch back to the local frontend runtime", async ({ page }, testInfo) => {
|
||||
test("sidebar does not offer a local frontend switch because the frontend is always regular", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
!isDesktopProject(testInfo.project.name),
|
||||
"The sidebar release selector is only visible in the desktop layout."
|
||||
@@ -620,37 +622,8 @@ test("sidebar can switch back to the local frontend runtime", async ({ page }, t
|
||||
await page.goto("/user", { waitUntil: "domcontentloaded" });
|
||||
|
||||
const beta = page.getByTestId("release-channel-option-beta");
|
||||
const useLocal = page.getByTestId("release-channel-use-local-frontend");
|
||||
await expect(beta).toBeVisible({ timeout: GUARD_TIMEOUT_MS });
|
||||
await expect(useLocal).toBeVisible();
|
||||
await expect(useLocal).toHaveText(/Use local frontend/i);
|
||||
|
||||
const positions = await Promise.all([beta.boundingBox(), useLocal.boundingBox()]);
|
||||
expect(positions[0]).toBeTruthy();
|
||||
expect(positions[1]).toBeTruthy();
|
||||
expect(positions[1].y).toBeGreaterThan(positions[0].y);
|
||||
|
||||
await page.evaluate(() => window.localStorage.setItem("release_channel_selected_slug", "beta"));
|
||||
await useLocal.click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
return await page.evaluate(() => window.localStorage.getItem("release_source_override"));
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.toBe("local");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
try {
|
||||
return await page.evaluate(() => window.localStorage.getItem("release_channel_selected_slug"));
|
||||
} catch (error) {
|
||||
return "navigating";
|
||||
}
|
||||
})
|
||||
.toBeNull();
|
||||
await expect(page.getByTestId("release-channel-use-local-frontend")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("failed sidebar release channel switches keep the previous channel active", async ({ page }, testInfo) => {
|
||||
@@ -718,7 +691,7 @@ test("predefined release channel text is localized on the guard page", async ({
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
missing: ["frontend_base_url"],
|
||||
missing: ["api_base_url"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
};
|
||||
@@ -734,8 +707,8 @@ test("predefined release channel text is localized on the guard page", async ({
|
||||
await expect(guard).toContainText("Release-kanalen er ikke klar");
|
||||
await expect(guard).toContainText("Intern");
|
||||
await expect(guard).toContainText("Intern kanal til medarbejdere");
|
||||
await expect(page.getByTestId("release-channel-missing")).toContainText("Frontend-URL");
|
||||
await expect(page.getByTestId("release-channel-missing")).not.toContainText("frontend_base_url");
|
||||
await expect(page.getByTestId("release-channel-missing")).toContainText("API-URL");
|
||||
await expect(page.getByTestId("release-channel-missing")).not.toContainText("api_base_url");
|
||||
await expect(guard).not.toContainText("Internal staff");
|
||||
await expect(guard).not.toContainText(/\binternal\b/);
|
||||
await expect(guard).not.toContainText(/\p{L}\?\p{L}|\?\p{L}/u);
|
||||
|
||||
@@ -26,6 +26,7 @@ const text = (body, contentType = "text/plain") => ({
|
||||
});
|
||||
|
||||
const releaseRuntime = () => ({
|
||||
source: "deployment",
|
||||
generated_at: "2026-05-27T09:30:00.000Z",
|
||||
trace_id: "trace-release-update-widget",
|
||||
channel: {
|
||||
@@ -121,7 +122,7 @@ const releaseManifest = () => ({
|
||||
});
|
||||
|
||||
async function bootReleaseUpdateScenario(page) {
|
||||
const runtimeRequests = [];
|
||||
const frontendAssetRequests = [];
|
||||
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
@@ -147,20 +148,23 @@ async function bootReleaseUpdateScenario(page) {
|
||||
});
|
||||
|
||||
await page.route("**/release/runtime**", async (route) => {
|
||||
runtimeRequests.push(route.request().url());
|
||||
await route.fulfill(json({ data: releaseRuntime() }));
|
||||
});
|
||||
|
||||
await page.route(`${FRONTEND_BASE_URL}/release-manifest.json`, async (route) => {
|
||||
await page.route("**/release-manifest.json", async (route) => {
|
||||
frontendAssetRequests.push(route.request().url());
|
||||
await route.fulfill(json(releaseManifest()));
|
||||
});
|
||||
await page.route(`${FRONTEND_BASE_URL}/release-entry.json`, async (route) => {
|
||||
await page.route("**/release-entry.json", async (route) => {
|
||||
frontendAssetRequests.push(route.request().url());
|
||||
await route.fulfill(json({ entry: "assets/app.js", css: ["assets/app.css"] }));
|
||||
});
|
||||
await page.route(`${FRONTEND_BASE_URL}/assets/app.css`, async (route) => {
|
||||
await page.route("**/assets/app.css", async (route) => {
|
||||
frontendAssetRequests.push(route.request().url());
|
||||
await route.fulfill(text("body {}", "text/css"));
|
||||
});
|
||||
await page.route(`${FRONTEND_BASE_URL}/assets/app.js`, async (route) => {
|
||||
await page.route("**/assets/app.js", async (route) => {
|
||||
frontendAssetRequests.push(route.request().url());
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
await route.fulfill(text("console.log('latest release');", "application/javascript"));
|
||||
});
|
||||
@@ -169,57 +173,45 @@ async function bootReleaseUpdateScenario(page) {
|
||||
});
|
||||
|
||||
await seedAuthenticatedState(page, "release-update-widget-token");
|
||||
return { runtimeRequests };
|
||||
return { frontendAssetRequests };
|
||||
}
|
||||
|
||||
test("desktop release navigation predownloads an update and shows Install without a popup", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test("desktop release navigation does not predownload frontend updates", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("desktop"), "desktop release navigation coverage");
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 820 });
|
||||
const { runtimeRequests } = await bootReleaseUpdateScenario(page);
|
||||
const { frontendAssetRequests } = await bootReleaseUpdateScenario(page);
|
||||
|
||||
await page.goto("/user", { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(page.getByTestId("release-frontend-version-badge").first()).toContainText("FE", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("release-frontend-version-badge").first()).toContainText("->", { timeout: 30_000 });
|
||||
await expect(page.getByTestId("release-update-widget").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("release-update-progress").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("release-frontend-version-badge").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("release-frontend-version-badge").first()).not.toContainText("->");
|
||||
await expect(page.getByTestId("release-update-widget")).toHaveCount(0);
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0);
|
||||
|
||||
await expect(page.getByTestId("release-update-install").first()).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page.getByTestId("release-update-progress")).toHaveCount(0);
|
||||
await page.waitForTimeout(1000);
|
||||
expect(frontendAssetRequests).toEqual([]);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("release-update-widget")).toHaveCount(0);
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0);
|
||||
await expect.poll(() => runtimeRequests.length).toBeGreaterThanOrEqual(2);
|
||||
await page.waitForTimeout(1000);
|
||||
expect(frontendAssetRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("mobile navigation shows the release update widget once above build info", async ({ page }, testInfo) => {
|
||||
test("mobile navigation keeps frontend update widget hidden above build info", async ({ page }, testInfo) => {
|
||||
test.skip(!testInfo.project.name.includes("mobile"), "mobile release navigation coverage");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 820 });
|
||||
await bootReleaseUpdateScenario(page);
|
||||
const { frontendAssetRequests } = await bootReleaseUpdateScenario(page);
|
||||
|
||||
await page.goto("/user", { waitUntil: "domcontentloaded" });
|
||||
await page.locator(".mobile-navbar-burger[aria-label='menu']").click();
|
||||
|
||||
const menu = page.locator(".mobile-menu-open");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByTestId("release-update-widget")).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(menu.getByTestId("release-update-install")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(menu.getByTestId("release-update-widget")).toHaveCount(0);
|
||||
await expect(menu.getByTestId("mobile-build-info")).toBeVisible();
|
||||
|
||||
const order = await menu.evaluate((node) => {
|
||||
const widget = node.querySelector('[data-testid="release-update-widget"]');
|
||||
const buildInfo = node.querySelector('[data-testid="mobile-build-info"]');
|
||||
return {
|
||||
widgetBeforeBuildInfo:
|
||||
Boolean(widget && buildInfo) &&
|
||||
(widget.compareDocumentPosition(buildInfo) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0,
|
||||
};
|
||||
});
|
||||
expect(order.widgetBeforeBuildInfo).toBe(true);
|
||||
await expect(page.locator(".swal2-popup")).toHaveCount(0);
|
||||
await page.waitForTimeout(1000);
|
||||
expect(frontendAssetRequests).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -305,7 +305,7 @@ test.describe("Self-serve wash", () => {
|
||||
|
||||
await page.getByTestId("self-serve-department-name").click();
|
||||
await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("self-serve-department-search").locator("input").fill("Odense");
|
||||
await page.getByTestId("self-serve-department-search").fill("Odense");
|
||||
await page.getByText("Odense", { exact: true }).click();
|
||||
|
||||
await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense");
|
||||
@@ -1577,7 +1577,9 @@ test.describe("Self-serve wash", () => {
|
||||
await expect(page.getByTestId("self-serve-lane-step")).toBeVisible();
|
||||
});
|
||||
|
||||
test("mocked polling marks active wash completed when backend reports it ended", async ({ page }) => {
|
||||
test("mocked polling keeps active wash local until the customer completes when backend reports it ended", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedSavedProgress(page, {
|
||||
washInProgress: true,
|
||||
washLaneId: 7,
|
||||
@@ -1622,6 +1624,18 @@ test.describe("Self-serve wash", () => {
|
||||
|
||||
await page.goto("/user/wash/start");
|
||||
|
||||
await expect(page.getByTestId("self-serve-bottom-actions")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeHidden();
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6")))
|
||||
.not.toBeNull();
|
||||
|
||||
await advanceGuidedWashToLastStep(page);
|
||||
const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP");
|
||||
await page.getByTestId("self-serve-nav-complete").click();
|
||||
await stopCommandRequestPromise;
|
||||
|
||||
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden();
|
||||
await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull();
|
||||
|
||||
@@ -283,6 +283,7 @@ function createRequestCounters(overrides = {}) {
|
||||
orderItemsPut: 0,
|
||||
orderItemsDelete: 0,
|
||||
markAsCompleted: 0,
|
||||
bookingCompletionConfirmationEmail: 0,
|
||||
attachmentsGet: 0,
|
||||
attachmentsDelete: 0,
|
||||
attachmentUpload: 0,
|
||||
@@ -303,6 +304,7 @@ function createRequestLog(overrides = {}) {
|
||||
orderItemUpdates: [],
|
||||
orderItemDeletes: [],
|
||||
bookingCompletions: [],
|
||||
bookingCompletionConfirmationEmails: [],
|
||||
bookingOrderAssignments: [],
|
||||
attachmentUploads: [],
|
||||
attachmentDeletes: [],
|
||||
@@ -1101,6 +1103,26 @@ function recordLog(fixture, key, value) {
|
||||
fixture.requestLog[key].push(clone(value));
|
||||
}
|
||||
|
||||
function recordBookingCompletionConfirmationEmail(fixture, booking, safetySeal) {
|
||||
if (!booking) {
|
||||
return;
|
||||
}
|
||||
|
||||
const customerNumber = Number(booking.customer_number || 0);
|
||||
const customer = fixture.customersByNumber[customerNumber] || {};
|
||||
const normalizedSafetySeal = normalizeSafetySealValue(safetySeal);
|
||||
|
||||
recordCounter(fixture, "bookingCompletionConfirmationEmail");
|
||||
recordLog(fixture, "bookingCompletionConfirmationEmails", {
|
||||
booking_id: Number(booking.id),
|
||||
order_id: toPositiveInteger(booking.order_id),
|
||||
customer_number: customerNumber || null,
|
||||
recipient: String(booking.contact_email || booking.wash_certificate_email || customer.email || "").trim(),
|
||||
safety_seal: normalizedSafetySeal,
|
||||
has_safety_seal: normalizedSafetySeal !== "",
|
||||
});
|
||||
}
|
||||
|
||||
export async function mockMobilePosApi(page, fixture) {
|
||||
await page.route(API_HOST, async (route) => {
|
||||
const request = route.request();
|
||||
@@ -1403,6 +1425,7 @@ export async function mockMobilePosApi(page, fixture) {
|
||||
}
|
||||
fixture.bookingStatusById[bookingId] = "completed";
|
||||
removeBookingFromPending(fixture, bookingId);
|
||||
recordBookingCompletionConfirmationEmail(fixture, booking, normalizedSafetySeal);
|
||||
}
|
||||
await route.fulfill(
|
||||
json({
|
||||
|
||||
@@ -5974,6 +5974,13 @@ export async function mockApi(page, options = {}) {
|
||||
const edgeGatewayOptions =
|
||||
options.edgeGateways && typeof options.edgeGateways === "object" ? options.edgeGateways : {};
|
||||
const edgeGatewayFixture = options.edgeGateways === false ? null : createHttpEdgeGatewayFixture(edgeGatewayOptions);
|
||||
const notificationState = {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: true,
|
||||
sms_notifications_enabled: false,
|
||||
superuser_new_customer_email_notifications_enabled: false,
|
||||
...(options.sessionData?.notifications || {}),
|
||||
};
|
||||
|
||||
await page.route(/https:\/\/cdn\.example\.test\/.*$/i, async (route) => {
|
||||
const request = route.request();
|
||||
@@ -6100,9 +6107,7 @@ export async function mockApi(page, options = {}) {
|
||||
country_code: 45,
|
||||
},
|
||||
notifications: {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: true,
|
||||
sms_notifications_enabled: false,
|
||||
...notificationState,
|
||||
},
|
||||
created_at: "2026-01-01T00:00:00.000Z",
|
||||
updated_at: "2026-01-01T00:00:00.000Z",
|
||||
@@ -6124,6 +6129,10 @@ export async function mockApi(page, options = {}) {
|
||||
if (options.permissions) {
|
||||
sessionData.permissions = options.permissions;
|
||||
}
|
||||
sessionData.notifications = {
|
||||
...(sessionData.notifications || {}),
|
||||
...notificationState,
|
||||
};
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
@@ -6133,6 +6142,29 @@ export async function mockApi(page, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/account/notifications") && method === "PUT") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
for (const key of [
|
||||
"wash_certificate_email",
|
||||
"email_notifications_enabled",
|
||||
"sms_notifications_enabled",
|
||||
"superuser_new_customer_email_notifications_enabled",
|
||||
]) {
|
||||
if (Object.prototype.hasOwnProperty.call(body, key)) {
|
||||
notificationState[key] = body[key];
|
||||
}
|
||||
}
|
||||
|
||||
await route.fulfill(
|
||||
json({
|
||||
data: {
|
||||
message: "User notification settings updated",
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.endsWith("/guest/validation/customer-number") && method === "POST") {
|
||||
const body = request.postDataJSON?.() || {};
|
||||
const customerNumber = Number(body.customer_number || 0);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { loginAsUser } from "./fixtures";
|
||||
import { mockApi, primeMockSession } from "./support/network.js";
|
||||
|
||||
async function openUserNotifications(page) {
|
||||
await loginAsUser(page);
|
||||
@@ -10,6 +11,39 @@ async function openUserNotifications(page) {
|
||||
await expect(page.getByTestId("user-profile-notifications-edit-booking-email")).toBeVisible();
|
||||
}
|
||||
|
||||
async function openUserNotificationsWithMockSession(
|
||||
page,
|
||||
{
|
||||
permissions = ["user"],
|
||||
notifications = {},
|
||||
}: {
|
||||
permissions?: string[];
|
||||
notifications?: Record<string, unknown>;
|
||||
} = {}
|
||||
) {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions,
|
||||
sessionData: {
|
||||
display_name: "Notification Settings User",
|
||||
permissions,
|
||||
notifications: {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: true,
|
||||
sms_notifications_enabled: false,
|
||||
superuser_new_customer_email_notifications_enabled: false,
|
||||
...notifications,
|
||||
},
|
||||
},
|
||||
});
|
||||
await primeMockSession(page, { bootPath: "/user" });
|
||||
await page.goto("/user/profile");
|
||||
const notificationsCard = page.getByTestId("user-profile-notifications-card");
|
||||
await expect(notificationsCard).toBeVisible();
|
||||
await notificationsCard.locator(".card-header").click();
|
||||
await expect(page.getByTestId("user-profile-notifications-edit-booking-email")).toBeVisible();
|
||||
}
|
||||
|
||||
test("[PROFILE][User][Notifications] renders the current notification controls", async ({ page }) => {
|
||||
await openUserNotifications(page);
|
||||
|
||||
@@ -39,3 +73,37 @@ test("[PROFILE][User][Notifications] validates booking-email input", async ({ pa
|
||||
await page.click(".swal2-confirm");
|
||||
await expect(page.locator(".swal2-validation-message")).toBeVisible();
|
||||
});
|
||||
|
||||
test("[PROFILE][User][Notifications] hides superuser notifications without superuser permission", async ({ page }) => {
|
||||
await openUserNotificationsWithMockSession(page, {
|
||||
permissions: ["user", "user_notifications_update"],
|
||||
});
|
||||
|
||||
await expect(page.getByTestId("user-profile-notifications-superuser-new-customer-email-switch")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("[PROFILE][User][Notifications] saves superuser new customer email notifications", async ({ page }) => {
|
||||
await openUserNotificationsWithMockSession(page, {
|
||||
permissions: ["user", "user_notifications_update", "superuser"],
|
||||
notifications: {
|
||||
superuser_new_customer_email_notifications_enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const switchRoot = page.getByTestId("user-profile-notifications-superuser-new-customer-email-switch");
|
||||
const checkbox = switchRoot.locator('input[type="checkbox"]');
|
||||
const switchLabel = switchRoot.locator("label[for]").last();
|
||||
await expect(switchRoot).toBeVisible();
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
const updateRequest = page.waitForRequest((request) => {
|
||||
return request.method() === "PUT" && request.url().includes("/account/notifications");
|
||||
});
|
||||
await switchLabel.click();
|
||||
const request = await updateRequest;
|
||||
|
||||
expect(request.postDataJSON()).toEqual({
|
||||
superuser_new_customer_email_notifications_enabled: true,
|
||||
});
|
||||
await expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
@@ -355,6 +355,172 @@ const stubComponents = {
|
||||
SelfServeCompletedStep: {
|
||||
template: "<div data-testid='completed-step-stub' />",
|
||||
},
|
||||
ErrorBanner: {
|
||||
props: ["message", "type", "iconLeft", "loading", "showRetry", "retryTestId"],
|
||||
emits: ["retry"],
|
||||
template: `
|
||||
<div v-if="message && (message.value === undefined || message.value)" data-testid="error-banner-stub">
|
||||
<span>{{ message && message.value !== undefined ? message.value : message }}</span>
|
||||
<button v-if="showRetry" :data-testid="retryTestId || 'error-banner-retry'" @click="$emit('retry')">retry</button>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
WashProgressCard: {
|
||||
props: [
|
||||
"currentGuidedWashStep",
|
||||
"guidedWashFlowSteps",
|
||||
"formattedElapsed",
|
||||
"isCompletingWash",
|
||||
"openingPropertyAccessGate",
|
||||
"openingPropertyExitGate",
|
||||
"showProgressActions",
|
||||
],
|
||||
emits: [
|
||||
"update:currentGuidedWashStep",
|
||||
"goPreviousGuidedWashStep",
|
||||
"goNextGuidedWashStep",
|
||||
"completeWash",
|
||||
"openPropertyAccessGate",
|
||||
"openPropertyExitGate",
|
||||
"requestAssistance",
|
||||
],
|
||||
computed: {
|
||||
elapsedText() {
|
||||
return this.formattedElapsed && this.formattedElapsed.value !== undefined
|
||||
? this.formattedElapsed.value
|
||||
: this.formattedElapsed;
|
||||
},
|
||||
normalizedCurrentGuidedWashStep() {
|
||||
return Number(
|
||||
this.currentGuidedWashStep && this.currentGuidedWashStep.value !== undefined
|
||||
? this.currentGuidedWashStep.value
|
||||
: this.currentGuidedWashStep || 0
|
||||
);
|
||||
},
|
||||
normalizedGuidedWashFlowSteps() {
|
||||
return this.guidedWashFlowSteps && this.guidedWashFlowSteps.value !== undefined
|
||||
? this.guidedWashFlowSteps.value
|
||||
: this.guidedWashFlowSteps || [];
|
||||
},
|
||||
isFinishingWash() {
|
||||
return !!(
|
||||
this.isCompletingWash &&
|
||||
(this.isCompletingWash.value !== undefined ? this.isCompletingWash.value : this.isCompletingWash)
|
||||
);
|
||||
},
|
||||
isLastGuidedWashStep() {
|
||||
return (
|
||||
Array.isArray(this.normalizedGuidedWashFlowSteps) &&
|
||||
this.normalizedGuidedWashFlowSteps.length > 0 &&
|
||||
this.normalizedCurrentGuidedWashStep >= this.normalizedGuidedWashFlowSteps.length - 1
|
||||
);
|
||||
},
|
||||
shouldShowProgressActions() {
|
||||
return this.showProgressActions && this.showProgressActions.value !== undefined
|
||||
? this.showProgressActions.value
|
||||
: this.showProgressActions !== false;
|
||||
},
|
||||
isOpeningPropertyAccessGate() {
|
||||
return !!(
|
||||
this.openingPropertyAccessGate &&
|
||||
(this.openingPropertyAccessGate.value !== undefined
|
||||
? this.openingPropertyAccessGate.value
|
||||
: this.openingPropertyAccessGate)
|
||||
);
|
||||
},
|
||||
isOpeningPropertyExitGate() {
|
||||
return !!(
|
||||
this.openingPropertyExitGate &&
|
||||
(this.openingPropertyExitGate.value !== undefined
|
||||
? this.openingPropertyExitGate.value
|
||||
: this.openingPropertyExitGate)
|
||||
);
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div data-testid="self-serve-wash-progress">
|
||||
<span data-testid="wash-progress-elapsed">
|
||||
{{ elapsedText }}
|
||||
</span>
|
||||
<div
|
||||
v-if="isFinishingWash && shouldShowProgressActions"
|
||||
class="notification is-info is-light self-serve-finishing-screen"
|
||||
data-testid="self-serve-finishing-wash"
|
||||
>
|
||||
<p class="title is-5 mb-0">{{ $t("self_wash.finishing_wash_exit_opening") }}</p>
|
||||
</div>
|
||||
<template v-if="!isFinishingWash">
|
||||
<slot
|
||||
v-if="shouldShowProgressActions"
|
||||
name="guided-instructions"
|
||||
:current-step="normalizedCurrentGuidedWashStep"
|
||||
:steps="normalizedGuidedWashFlowSteps"
|
||||
:is-last-step="isLastGuidedWashStep"
|
||||
/>
|
||||
<div
|
||||
v-show="shouldShowProgressActions"
|
||||
class="self-serve-bottom-actions"
|
||||
data-testid="self-serve-bottom-actions"
|
||||
>
|
||||
<div class="self-serve-bottom-actions__row" data-testid="self-serve-session-bottom-actions">
|
||||
<button
|
||||
class="self-serve-bottom-actions__button"
|
||||
data-testid="self-serve-nav-help"
|
||||
@click.prevent="$emit('requestAssistance')"
|
||||
>
|
||||
self_wash.assistance
|
||||
</button>
|
||||
</div>
|
||||
<div class="self-serve-bottom-actions__row" data-testid="self-serve-property-gate-actions">
|
||||
<button
|
||||
class="self-serve-bottom-actions__button"
|
||||
data-testid="self-serve-nav-open-property-access-gate"
|
||||
:disabled="isOpeningPropertyAccessGate"
|
||||
@click.prevent="$emit('openPropertyAccessGate')"
|
||||
>
|
||||
self_wash.open_property_access_gate
|
||||
</button>
|
||||
<button
|
||||
class="self-serve-bottom-actions__button"
|
||||
data-testid="self-serve-nav-open-property-exit-gate"
|
||||
:disabled="isOpeningPropertyExitGate"
|
||||
@click.prevent="$emit('openPropertyExitGate')"
|
||||
>
|
||||
self_wash.open_property_exit_gate
|
||||
</button>
|
||||
</div>
|
||||
<div class="self-serve-bottom-actions__row" data-testid="self-serve-guided-bottom-actions">
|
||||
<button
|
||||
class="self-serve-bottom-actions__button"
|
||||
data-testid="self-serve-guided-prev"
|
||||
:disabled="normalizedCurrentGuidedWashStep === 0"
|
||||
@click.prevent="$emit('goPreviousGuidedWashStep')"
|
||||
>
|
||||
common.previous
|
||||
</button>
|
||||
<button
|
||||
v-if="!isLastGuidedWashStep"
|
||||
class="self-serve-bottom-actions__button"
|
||||
data-testid="self-serve-guided-next"
|
||||
@click.prevent="$emit('goNextGuidedWashStep')"
|
||||
>
|
||||
common.next
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="self-serve-bottom-actions__button"
|
||||
data-testid="self-serve-nav-complete"
|
||||
:disabled="isFinishingWash"
|
||||
@click.prevent="$emit('completeWash')"
|
||||
>
|
||||
common.done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
BSteps: BStepsStub,
|
||||
BStepItem: BStepItemStub,
|
||||
BButton: BButtonStub,
|
||||
@@ -364,12 +530,22 @@ const stubComponents = {
|
||||
};
|
||||
|
||||
describe("MyWashStart", () => {
|
||||
let consoleWarnSpy;
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy?.mockRestore();
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation((...args) => {
|
||||
const warning = args.map((entry) => String(entry)).join(" ");
|
||||
if (warning.includes("[Vue warn]")) {
|
||||
throw new Error(warning);
|
||||
}
|
||||
});
|
||||
|
||||
localStorage.clear();
|
||||
mocks.nearestDepartment.value = {
|
||||
id: 6,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
bootstrapReleaseApp,
|
||||
fetchReleaseRuntime,
|
||||
isReleaseSourceOverrideAvailable,
|
||||
loadRemoteReleaseEntry,
|
||||
RELEASE_SOURCE_MODES,
|
||||
resolveReleaseSourceMode,
|
||||
runtimeApiUrl,
|
||||
@@ -90,14 +89,47 @@ describe("release bootstrap", () => {
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].source).toBe("deployment");
|
||||
});
|
||||
|
||||
it("loads the local app without runtime or release entry requests in local source mode", async () => {
|
||||
it("loads the regular frontend while keeping dynamic API runtime in local source mode", async () => {
|
||||
localStorage.setItem("release_channel_selected_slug", "internal");
|
||||
const loadLocalApp = vi.fn(async () => ({ local: true }));
|
||||
const fetchFn = vi.fn();
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
channel: { slug: "internal", default_channel: false },
|
||||
availability: { configured: true },
|
||||
urls: {
|
||||
api_base_url: "https://api-v2.truckwash.io/internal/api",
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
await bootstrapReleaseApp({ loadLocalApp, fetchFn, sourceMode: "local" });
|
||||
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(loadLocalApp).toHaveBeenCalledTimes(1);
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({
|
||||
source: "deployment",
|
||||
requested_source: "local",
|
||||
channel: { slug: "internal", default_channel: false },
|
||||
urls: { api_base_url: "https://api-v2.truckwash.io/internal/api" },
|
||||
availability: { configured: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to local API runtime only when local source runtime resolution fails", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const loadLocalApp = vi.fn(async () => ({ local: true }));
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => ({}),
|
||||
}));
|
||||
|
||||
await bootstrapReleaseApp({ loadLocalApp, fetchFn, sourceMode: "local" });
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(loadLocalApp).toHaveBeenCalledTimes(1);
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({
|
||||
source: "local",
|
||||
@@ -105,7 +137,7 @@ describe("release bootstrap", () => {
|
||||
channel: { slug: "stable", default_channel: true },
|
||||
api_base_url: "/api",
|
||||
urls: { api_base_url: "/api" },
|
||||
availability: { configured: true, missing: [], status: "ready" },
|
||||
availability: { configured: false, missing: ["release_runtime"], status: "unconfigured" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -163,19 +195,19 @@ describe("release bootstrap", () => {
|
||||
expect(shouldLoadRemoteRelease(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not load a remote release from an untrusted frontend origin", () => {
|
||||
it("does not load remote frontend releases", () => {
|
||||
const runtime = {
|
||||
channel: { slug: "canary", default_channel: false },
|
||||
availability: { configured: true },
|
||||
urls: {
|
||||
frontend_base_url: "https://attacker.example/canary/frontend",
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
},
|
||||
};
|
||||
|
||||
expect(shouldLoadRemoteRelease(runtime)).toBe(false);
|
||||
});
|
||||
|
||||
it("loads a non-default release entry without changing the browser URL", async () => {
|
||||
it("loads the regular frontend for non-default channels while keeping dynamic API runtime", async () => {
|
||||
const runtime = {
|
||||
channel: { slug: "canary", default_channel: false },
|
||||
availability: { configured: true },
|
||||
@@ -184,6 +216,37 @@ describe("release bootstrap", () => {
|
||||
api_base_url: "https://api-v2.truckwash.io/canary/api",
|
||||
},
|
||||
};
|
||||
const loadLocalApp = vi.fn(async () => ({ local: true }));
|
||||
const fetchFn = vi.fn().mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: runtime }),
|
||||
});
|
||||
const importModule = vi.fn(async (url) => ({ url }));
|
||||
const originalHref = window.location.href;
|
||||
|
||||
await bootstrapReleaseApp({ loadLocalApp, fetchFn, importModule, documentRef: document, sourceMode: "deployment" });
|
||||
|
||||
expect(loadLocalApp).toHaveBeenCalledTimes(1);
|
||||
expect(importModule).not.toHaveBeenCalled();
|
||||
expect(document.querySelector("link")).toBeNull();
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY]).toMatchObject({
|
||||
source: "deployment",
|
||||
requested_source: "deployment",
|
||||
channel: { slug: "canary", default_channel: false },
|
||||
urls: {
|
||||
api_base_url: "https://api-v2.truckwash.io/canary/api",
|
||||
},
|
||||
});
|
||||
expect(window.location.href).toBe(originalHref);
|
||||
});
|
||||
|
||||
it("does not request release entry assets during bootstrap", async () => {
|
||||
const loadLocalApp = vi.fn(async () => ({ local: true }));
|
||||
const runtime = {
|
||||
channel: { slug: "canary", default_channel: false },
|
||||
availability: { configured: true },
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
};
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@@ -197,43 +260,6 @@ describe("release bootstrap", () => {
|
||||
css: ["assets/index-canary.css"],
|
||||
}),
|
||||
});
|
||||
const importModule = vi.fn(async (url) => ({ url }));
|
||||
const originalHref = window.location.href;
|
||||
|
||||
await bootstrapReleaseApp({
|
||||
loadLocalApp: vi.fn(),
|
||||
fetchFn,
|
||||
importModule,
|
||||
documentRef: document,
|
||||
sourceMode: "deployment",
|
||||
});
|
||||
|
||||
expect(shouldLoadRemoteRelease(runtime)).toBe(true);
|
||||
expect(importModule).toHaveBeenCalledWith("https://api-v2.truckwash.io/canary/frontend/assets/index-canary.js");
|
||||
expect(document.querySelector("link")?.href).toBe(
|
||||
"https://api-v2.truckwash.io/canary/frontend/assets/index-canary.css"
|
||||
);
|
||||
expect(window.location.href).toBe(originalHref);
|
||||
});
|
||||
|
||||
it("falls back to the local app with unavailable runtime when the release entry fails", async () => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const loadLocalApp = vi.fn(async () => ({ local: true }));
|
||||
const runtime = {
|
||||
channel: { slug: "canary", default_channel: false },
|
||||
availability: { configured: true },
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
};
|
||||
const fetchFn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: runtime }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
await bootstrapReleaseApp({
|
||||
loadLocalApp,
|
||||
@@ -244,50 +270,11 @@ describe("release bootstrap", () => {
|
||||
});
|
||||
|
||||
expect(loadLocalApp).toHaveBeenCalledTimes(1);
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].source).toBe("local");
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].source).toBe("deployment");
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].requested_source).toBe("deployment");
|
||||
expect(window[RELEASE_RUNTIME_GLOBAL_KEY].availability).toMatchObject({
|
||||
configured: false,
|
||||
missing: ["frontend_entry"],
|
||||
status: "unconfigured",
|
||||
configured: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects release entry assets outside the frontend origin", async () => {
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
entry: "https://attacker.example/assets/index-canary.js",
|
||||
css: [],
|
||||
}),
|
||||
}));
|
||||
const importModule = vi.fn(async () => ({}));
|
||||
const runtime = {
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
};
|
||||
|
||||
await expect(loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef: document })).rejects.toThrow(
|
||||
"Release entry asset URL is not on the trusted release frontend origin."
|
||||
);
|
||||
expect(importModule).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("injects release entry CSS only once", async () => {
|
||||
const fetchFn = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
entry: "assets/index-canary.js",
|
||||
css: ["assets/index-canary.css"],
|
||||
}),
|
||||
}));
|
||||
const importModule = vi.fn(async () => ({}));
|
||||
const runtime = {
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
};
|
||||
|
||||
await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef: document });
|
||||
await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef: document });
|
||||
|
||||
expect(document.querySelectorAll("link[data-release-entry-css]")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,12 +65,35 @@ describe("release channel availability", () => {
|
||||
__resetReleaseTimelineForTests();
|
||||
});
|
||||
|
||||
it("blocks a non-default assigned channel when app targets are missing", () => {
|
||||
it("blocks a non-default assigned channel when API targets are missing", () => {
|
||||
const status = getReleaseChannelUnavailableStatus(canaryRuntime, 1_000, 0);
|
||||
|
||||
expect(status.shouldBlock).toBe(true);
|
||||
expect(status.channelSlug).toBe("canary");
|
||||
expect(status.missing).toEqual(["frontend_version", "api_version"]);
|
||||
expect(status.missing).toEqual(["api_version"]);
|
||||
});
|
||||
|
||||
it("does not block non-default channels only because frontend release assets are missing", () => {
|
||||
const status = getReleaseChannelUnavailableStatus(
|
||||
{
|
||||
...canaryRuntime,
|
||||
versions: {
|
||||
frontend: null,
|
||||
api: { version_label: "api-canary", commit_sha: "feedface" },
|
||||
},
|
||||
availability: {
|
||||
configured: false,
|
||||
missing: ["release_bundle", "frontend_version", "frontend_base_url", "frontend_entry"],
|
||||
status: "unconfigured",
|
||||
},
|
||||
},
|
||||
1_000,
|
||||
0
|
||||
);
|
||||
|
||||
expect(status.configured).toBe(true);
|
||||
expect(status.shouldBlock).toBe(false);
|
||||
expect(status.missing).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not block the stable default channel even when runtime targets are absent", () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { getReleaseChannelUnavailableStatus } from "@/services/releaseChannelAvailability.js";
|
||||
|
||||
describe("internal release channel availability", () => {
|
||||
it("blocks assigned internal channel payloads that are missing a frontend entry", () => {
|
||||
it("does not block assigned internal channel payloads only because frontend entry assets are missing", () => {
|
||||
const status = getReleaseChannelUnavailableStatus(
|
||||
{
|
||||
channel: {
|
||||
@@ -28,9 +28,9 @@ describe("internal release channel availability", () => {
|
||||
0
|
||||
);
|
||||
|
||||
expect(status.shouldBlock).toBe(true);
|
||||
expect(status.shouldBlock).toBe(false);
|
||||
expect(status.channelSlug).toBe("internal");
|
||||
expect(status.channelName).toBe("Intern");
|
||||
expect(status.missing).toEqual(["frontend_entry"]);
|
||||
expect(status.missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,10 +6,8 @@ import ReleaseChannelSelector from "@/components/release/ReleaseChannelSelector.
|
||||
import ReleaseChannelSidebarSelector from "@/components/release/ReleaseChannelSidebarSelector.vue";
|
||||
import {
|
||||
RELEASE_CHANNEL_SELECTION_STORAGE_KEY,
|
||||
selectReleaseChannel,
|
||||
__resetReleaseChannelAvailabilityForTests,
|
||||
} from "@/services/releaseChannelAvailability.js";
|
||||
import { RELEASE_SOURCE_OVERRIDE_STORAGE_KEY } from "@/services/releaseBootstrap.js";
|
||||
import { configureReleaseRuntime, __resetReleaseTimelineForTests } from "@/services/releaseTimeline.js";
|
||||
import { __resetReleaseUpdateForTests } from "@/services/releaseUpdate.js";
|
||||
|
||||
@@ -91,7 +89,6 @@ describe("ReleaseChannelSelector", () => {
|
||||
__resetReleaseChannelAvailabilityForTests();
|
||||
__resetReleaseTimelineForTests();
|
||||
__resetReleaseUpdateForTests({ currentCommit: "184ef567" });
|
||||
window.localStorage.removeItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it("renders available release channels and emits the selected channel", async () => {
|
||||
@@ -137,18 +134,13 @@ describe("ReleaseChannelSelector", () => {
|
||||
expect(badge.find(".release-frontend-version-badge__commit").text()).toBe("184ef567");
|
||||
});
|
||||
|
||||
it("shows a local frontend action below sidebar release channels and clears channel selection", async () => {
|
||||
const reload = vi.fn();
|
||||
it("keeps the sidebar focused on dynamic release channels without a local frontend action", () => {
|
||||
configureSelectableChannels({ source: "deployment" });
|
||||
selectReleaseChannel("beta");
|
||||
i18n.global.locale.value = "en";
|
||||
|
||||
const wrapper = mount(ReleaseChannelSidebarSelector, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: {
|
||||
releaseSourceReload: reload,
|
||||
},
|
||||
stubs: {
|
||||
ReleaseUpdateWidget: true,
|
||||
},
|
||||
@@ -160,34 +152,6 @@ describe("ReleaseChannelSelector", () => {
|
||||
expect(wrapper.text()).toContain("Canary");
|
||||
expect(wrapper.text()).toContain("Beta");
|
||||
|
||||
const beta = wrapper.find('[data-testid="release-channel-option-beta"]');
|
||||
const localButton = wrapper.find('[data-testid="release-channel-use-local-frontend"]');
|
||||
expect(localButton.exists()).toBe(true);
|
||||
expect(beta.element.compareDocumentPosition(localButton.element) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
await localButton.trigger("click");
|
||||
|
||||
expect(window.localStorage.getItem(RELEASE_SOURCE_OVERRIDE_STORAGE_KEY)).toBe("local");
|
||||
expect(window.localStorage.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY)).toBeNull();
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides the local frontend action when already using the local runtime", () => {
|
||||
configureSelectableChannels({ source: "local" });
|
||||
i18n.global.locale.value = "en";
|
||||
|
||||
const wrapper = mount(ReleaseChannelSidebarSelector, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
provide: {
|
||||
releaseSourceReload: vi.fn(),
|
||||
},
|
||||
stubs: {
|
||||
ReleaseUpdateWidget: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="release-channel-use-local-frontend"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -223,10 +223,12 @@ describe("release timeline runtime", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(summary.missingLabels).toEqual(["Frontend version", "API URL"]);
|
||||
expect(summary.missingLabels).toEqual(["API URL"]);
|
||||
expect(summary.appRows.find((row) => row.key === "frontend")).toMatchObject({
|
||||
tone: "warning",
|
||||
missingLabel: "Frontend version",
|
||||
tone: "ok",
|
||||
status: "regular",
|
||||
primaryText: "Regular frontend",
|
||||
missingLabel: "",
|
||||
});
|
||||
expect(summary.appRows.find((row) => row.key === "api")).toMatchObject({
|
||||
tone: "warning",
|
||||
|
||||
@@ -6,26 +6,13 @@ import {
|
||||
downloadReleaseCandidate,
|
||||
inspectReleaseRuntimeForUpdate,
|
||||
installReadyReleaseUpdate,
|
||||
releaseUpdateCandidateFromRuntime,
|
||||
releaseUpdateState,
|
||||
} from "@/services/releaseUpdate.js";
|
||||
|
||||
const jsonResponse = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const textResponse = (body = "", status = 200, contentType = "text/plain") =>
|
||||
new Response(body, {
|
||||
status,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
},
|
||||
});
|
||||
import { __resetReleaseTimelineForTests } from "@/services/releaseTimeline.js";
|
||||
|
||||
const releaseRuntime = (overrides = {}) => ({
|
||||
source: "deployment",
|
||||
channel: {
|
||||
slug: "stable",
|
||||
name: "Stable",
|
||||
@@ -37,7 +24,10 @@ const releaseRuntime = (overrides = {}) => ({
|
||||
build_id: "build-2",
|
||||
},
|
||||
},
|
||||
frontend_base_url: "https://static.example.test/stable/frontend",
|
||||
frontend_base_url: "https://api-v2.truckwash.io/master/frontend",
|
||||
urls: {
|
||||
frontend_base_url: "https://api-v2.truckwash.io/master/frontend",
|
||||
},
|
||||
availability: {
|
||||
configured: true,
|
||||
missing: [],
|
||||
@@ -46,152 +36,77 @@ const releaseRuntime = (overrides = {}) => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const releaseManifest = () => ({
|
||||
build_id: "build-2",
|
||||
commit_sha: "bbbbbbb2222222",
|
||||
entry: "assets/app.js",
|
||||
css: ["assets/app.css"],
|
||||
asset_urls: ["assets/chunk.js"],
|
||||
asset_hashes: {
|
||||
"release-manifest.json": { bytes: 10 },
|
||||
"release-entry.json": { bytes: 11 },
|
||||
"assets/app.js": { bytes: 12 },
|
||||
"assets/app.css": { bytes: 13 },
|
||||
"assets/chunk.js": { bytes: 14 },
|
||||
},
|
||||
});
|
||||
|
||||
const mockReleaseFetch = (manifest = releaseManifest()) =>
|
||||
vi.fn(async (url) => {
|
||||
const value = String(url);
|
||||
if (value.endsWith("/release-manifest.json")) {
|
||||
return jsonResponse(manifest);
|
||||
}
|
||||
if (value.endsWith("/release-entry.json")) {
|
||||
return jsonResponse({ entry: manifest.entry, css: manifest.css || [] });
|
||||
}
|
||||
if (value.endsWith(".css")) {
|
||||
return textResponse("body {}", 200, "text/css");
|
||||
}
|
||||
return textResponse("console.log('release');", 200, "application/javascript");
|
||||
});
|
||||
|
||||
describe("release update service", () => {
|
||||
beforeEach(() => {
|
||||
__resetReleaseTimelineForTests();
|
||||
__resetReleaseUpdateForTests({ currentCommit: "aaaaaaa1111111" });
|
||||
vi.stubGlobal("caches", {
|
||||
open: vi.fn(async () => ({
|
||||
put: vi.fn(async () => {}),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
__resetReleaseTimelineForTests();
|
||||
__resetReleaseUpdateForTests({ currentCommit: "aaaaaaa1111111" });
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("detects a newer frontend commit and predownloads manifest assets", async () => {
|
||||
const fetchFn = mockReleaseFetch();
|
||||
it("does not create frontend update candidates from release runtime", () => {
|
||||
expect(releaseUpdateCandidateFromRuntime(releaseRuntime())).toBeNull();
|
||||
expect(
|
||||
releaseUpdateCandidateFromRuntime(
|
||||
releaseRuntime({
|
||||
channel: {
|
||||
slug: "canary",
|
||||
name: "Canary",
|
||||
default_channel: false,
|
||||
},
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
urls: {
|
||||
frontend_base_url: "https://api-v2.truckwash.io/canary/frontend",
|
||||
},
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("treats runtime inspection as up to date without fetching frontend assets", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
|
||||
await inspectReleaseRuntimeForUpdate(releaseRuntime(), { fetchFn });
|
||||
|
||||
expect(releaseUpdateState.status).toBe("ready");
|
||||
expect(releaseUpdateState.status).toBe("up_to_date");
|
||||
expect(releaseUpdateState.currentCommit).toBe("aaaaaaa1111111");
|
||||
expect(releaseUpdateState.latestCommit).toBe("bbbbbbb2222222");
|
||||
expect(releaseUpdateState.progress).toBe(100);
|
||||
expect(releaseUpdateState.downloadedAssets).toBe(5);
|
||||
expect(releaseUpdateState.totalBytes).toBe(60);
|
||||
expect(fetchFn).toHaveBeenCalledWith(
|
||||
"https://static.example.test/stable/frontend/assets/app.js",
|
||||
expect.objectContaining({ cache: "reload" })
|
||||
);
|
||||
expect(caches.open).toHaveBeenCalledWith("truckwash-release-update-v1");
|
||||
});
|
||||
|
||||
it("ignores the same frontend commit", async () => {
|
||||
__resetReleaseUpdateForTests({ currentCommit: "bbbbbbb2222222" });
|
||||
const fetchFn = mockReleaseFetch();
|
||||
|
||||
await inspectReleaseRuntimeForUpdate(releaseRuntime(), { fetchFn });
|
||||
|
||||
expect(releaseUpdateState.status).toBe("up_to_date");
|
||||
expect(releaseUpdateState.latestCommit).toBe("bbbbbbb2222222");
|
||||
expect(releaseUpdateState.latestCommit).toBe("aaaaaaa1111111");
|
||||
expect(releaseUpdateState.readyCandidate).toBeNull();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores missing frontend commit or base URL candidates", async () => {
|
||||
const fetchFn = mockReleaseFetch();
|
||||
it("does not fetch release runtime or frontend manifests during manual checks", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
|
||||
await inspectReleaseRuntimeForUpdate(
|
||||
releaseRuntime({
|
||||
versions: { frontend: null },
|
||||
frontend_base_url: "",
|
||||
}),
|
||||
{ fetchFn }
|
||||
);
|
||||
await checkReleaseUpdateNow({ fetchFn });
|
||||
|
||||
expect(releaseUpdateState.status).toBe("up_to_date");
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports failed predownloads without reloading", async () => {
|
||||
const fetchFn = vi.fn(async () => jsonResponse({ message: "missing" }, 404));
|
||||
it("does not download or install frontend release candidates", async () => {
|
||||
const fetchFn = vi.fn();
|
||||
const reload = vi.fn();
|
||||
|
||||
await inspectReleaseRuntimeForUpdate(releaseRuntime(), { fetchFn });
|
||||
await installReadyReleaseUpdate({ reload });
|
||||
|
||||
expect(releaseUpdateState.status).toBe("failed");
|
||||
expect(releaseUpdateState.error).toContain("HTTP 404");
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads only when installing a ready update", async () => {
|
||||
const fetchFn = mockReleaseFetch();
|
||||
const reload = vi.fn();
|
||||
const postMessage = vi.fn();
|
||||
|
||||
expect(await installReadyReleaseUpdate({ reload })).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
await downloadReleaseCandidate(
|
||||
{
|
||||
channelSlug: "stable",
|
||||
channelName: "Stable",
|
||||
latestCommit: "bbbbbbb2222222",
|
||||
frontendBaseUrl: "https://static.example.test/stable/frontend",
|
||||
frontendBaseUrl: "https://api-v2.truckwash.io/master/frontend",
|
||||
buildId: "build-2",
|
||||
},
|
||||
{ fetchFn }
|
||||
);
|
||||
|
||||
expect(releaseUpdateState.status).toBe("ready");
|
||||
expect(releaseUpdateState.status).toBe("up_to_date");
|
||||
expect(releaseUpdateState.readyCandidate).toBeNull();
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
expect(await installReadyReleaseUpdate({ reload })).toBe(false);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
expect(
|
||||
await installReadyReleaseUpdate({
|
||||
reload,
|
||||
serviceWorker: {
|
||||
getRegistration: vi.fn(async () => ({
|
||||
waiting: { postMessage },
|
||||
})),
|
||||
},
|
||||
})
|
||||
).toBe(true);
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: "SKIP_WAITING" });
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("checks runtime with the release endpoint without mutating the active app runtime", async () => {
|
||||
const fetchFn = mockReleaseFetch();
|
||||
fetchFn.mockResolvedValueOnce(jsonResponse({ data: releaseRuntime() }));
|
||||
|
||||
await checkReleaseUpdateNow({ fetchFn });
|
||||
|
||||
expect(fetchFn.mock.calls[0][0]).toContain("/release/runtime");
|
||||
expect(releaseUpdateState.status).toBe("ready");
|
||||
expect(releaseUpdateState.latestCommit).toBe("bbbbbbb2222222");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,10 +25,11 @@ const mountTasksStep = (props = {}) =>
|
||||
});
|
||||
|
||||
describe("SelfServeTasksStep", () => {
|
||||
it("preloads the dynamic image without reserving frame space until it loads", async () => {
|
||||
it("preloads the dynamic image while reserving frame space until it loads", async () => {
|
||||
const wrapper = mountTasksStep();
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').attributes("src")).toBe(
|
||||
"https://cdn.example.test/dynamic.png"
|
||||
);
|
||||
@@ -36,6 +37,7 @@ describe("SelfServeTasksStep", () => {
|
||||
await wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').trigger("load");
|
||||
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-preload"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image"]').attributes("src")).toBe(
|
||||
"https://cdn.example.test/dynamic.png"
|
||||
@@ -54,7 +56,8 @@ describe("SelfServeTasksStep", () => {
|
||||
dynamicImageUrl: "https://cdn.example.test/dynamic-step-2.png",
|
||||
});
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(true);
|
||||
expect(wrapper.get('[data-testid="self-serve-dynamic-image-preload"]').attributes("src")).toBe(
|
||||
"https://cdn.example.test/dynamic-step-2.png"
|
||||
);
|
||||
@@ -76,6 +79,7 @@ describe("SelfServeTasksStep", () => {
|
||||
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-preload"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-frame"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image-skeleton"]').exists()).toBe(false);
|
||||
expect(wrapper.find('[data-testid="self-serve-dynamic-image"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ describe("session payload normalization", () => {
|
||||
wash_certificate_email: null,
|
||||
email_notifications_enabled: null,
|
||||
sms_notifications_enabled: null,
|
||||
superuser_new_customer_email_notifications_enabled: null,
|
||||
},
|
||||
permissions: [],
|
||||
economic_customer: null,
|
||||
@@ -57,6 +58,9 @@ describe("session payload normalization", () => {
|
||||
},
|
||||
release: releaseRuntime,
|
||||
},
|
||||
notifications: {
|
||||
superuser_new_customer_email_notifications_enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(session.permissions).toEqual(["user"]);
|
||||
@@ -67,6 +71,7 @@ describe("session payload normalization", () => {
|
||||
expect(session.runtime_config.economic.transaction_draft_customer_number).toBe("777");
|
||||
expect(session.runtime_config.economic.default_distribution_department_id).toBe(12);
|
||||
expect(session.runtime_config.release).toBe(releaseRuntime);
|
||||
expect(session.notifications.superuser_new_customer_email_notifications_enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the first object from legacy economic customer arrays", () => {
|
||||
|
||||
Reference in New Issue
Block a user