Add superuser new customer email notification settings

This commit is contained in:
Jeppe Bundgaard
2026-06-08 12:19:32 +02:00
parent 96d13b2400
commit 8666d17a42
35 changed files with 1325 additions and 1699 deletions
+51 -20
View File
@@ -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,
+6 -32
View File
@@ -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": {
+7 -33
View File
@@ -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": {
+7 -33
View File
@@ -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": {
+7 -33
View File
@@ -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": {
+7 -33
View File
@@ -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": {
+35 -126
View File
@@ -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();
};
+15 -17
View File
@@ -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;
+50 -37
View File
@@ -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
View File
@@ -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(),
});
+2
View File
@@ -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;
}
+1
View File
@@ -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",
+24 -2
View File
@@ -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);
});
+14 -14
View File
@@ -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",
});
});
+16 -43
View File
@@ -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 -34
View File
@@ -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([]);
});
+16 -2
View File
@@ -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();
+23
View File
@@ -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({
+35 -3
View File
@@ -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();
});
+176
View File
@@ -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,
+74 -87
View File
@@ -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([]);
});
});
+1 -37
View File
@@ -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);
});
});
+5 -3
View File
@@ -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",
+43 -128
View File
@@ -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");
});
});
+7 -3
View File
@@ -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);
});
});
+5
View File
@@ -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", () => {