Compare commits

...
3 Commits
14 changed files with 133 additions and 18 deletions
+10
View File
@@ -1,6 +1,16 @@
FROM node:24-alpine AS build FROM node:24-alpine AS build
WORKDIR /app WORKDIR /app
ARG RELEASE_COMMIT_SHA=""
ARG COMMIT_SHA=""
ARG GITHUB_SHA=""
ARG SOURCE_COMMIT=""
ARG VITE_BASE_PATH=""
ENV RELEASE_COMMIT_SHA="${RELEASE_COMMIT_SHA}"
ENV COMMIT_SHA="${COMMIT_SHA}"
ENV GITHUB_SHA="${GITHUB_SHA}"
ENV SOURCE_COMMIT="${SOURCE_COMMIT}"
ENV VITE_BASE_PATH="${VITE_BASE_PATH}"
RUN apk add --no-cache git RUN apk add --no-cache git
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
forbidOnly: isCI, forbidOnly: isCI,
retries: isCI ? 2 : 0, retries: isCI ? 2 : 0,
workers: isCI ? 2 : 3, workers: isCI ? 2 : 1,
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]], reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
outputDir: "output/playwright/prod/test-results", outputDir: "output/playwright/prod/test-results",
use: { use: {
@@ -240,7 +240,7 @@ const getProductOptionsLabel = (vehicle) => {
v-if="!props.compact" v-if="!props.compact"
:user_id="object.user_id" :user_id="object.user_id"
:reg_1="object.reg" :reg_1="object.reg"
:displayActionsDirectly="false" :displayActionsDirectly="true"
> >
<template #actions> <template #actions>
<!-- View (Redirect to the vehicle page) --> <!-- View (Redirect to the vehicle page) -->
@@ -197,7 +197,7 @@ onMounted(() => {
<div class="error-state__content"> <div class="error-state__content">
<p>{{ t('superuser_invoice_distribution.errors.overview_load_failed') }}: {{ errorMessage }}</p> <p>{{ t('superuser_invoice_distribution.errors.overview_load_failed') }}: {{ errorMessage }}</p>
<b-button type="is-danger" class="control-button is-light" @click="loadOverview"> <b-button type="is-danger" class="control-button is-light" @click="loadOverview">
{{ t('common.retry') }} {{ t('superuser_invoice_distribution.actions.try_again') }}
</b-button> </b-button>
</div> </div>
</b-message> </b-message>
@@ -281,7 +281,7 @@ onMounted(() => {
<div v-if="!sortedMonthSummaries.length" class="empty-state has-text-centered"> <div v-if="!sortedMonthSummaries.length" class="empty-state has-text-centered">
<p class="has-text-grey mb-3">{{ t('superuser_invoice_distribution.empty.no_months') }}</p> <p class="has-text-grey mb-3">{{ t('superuser_invoice_distribution.empty.no_months') }}</p>
<b-button type="is-link" class="control-button is-light" @click="loadOverview"> <b-button type="is-link" class="control-button is-light" @click="loadOverview">
{{ t('common.retry') }} {{ t('superuser_invoice_distribution.actions.try_again') }}
</b-button> </b-button>
</div> </div>
+2 -2
View File
@@ -91,7 +91,7 @@ export function attachPageHealthGuards(page: Page, baseURL: string) {
}; };
} }
export async function expectBodyHasContent(page: Page, minimumLength = 20) { export async function expectBodyHasContent(page: Page, minimumLength = 20, timeout = 45_000) {
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -101,7 +101,7 @@ export async function expectBodyHasContent(page: Page, minimumLength = 20) {
.catch(() => ""); .catch(() => "");
return text.replace(/\s+/g, " ").trim().length; return text.replace(/\s+/g, " ").trim().length;
}, },
{ timeout: 15_000 } { timeout }
) )
.toBeGreaterThan(minimumLength); .toBeGreaterThan(minimumLength);
} }
+1 -1
View File
@@ -144,7 +144,7 @@ test("@public-live api-v2 gateway and channel API prefixes serve JSON ping respo
test("@public-live guest flow renders on the deployed environment", async ({ page }) => { test("@public-live guest flow renders on the deployed environment", async ({ page }) => {
const guards = attachPageHealthGuards(page, baseURL); const guards = attachPageHealthGuards(page, baseURL);
await page.goto("/guest/book/wash"); await page.goto(liveUrl("guest/book/wash"));
await settlePage(page); await settlePage(page);
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/); await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
await expectBodyHasContent(page); await expectBodyHasContent(page);
+1 -1
View File
@@ -24,7 +24,7 @@ function toLoopbackRequestUrl(url: string) {
} }
async function gotoHealthyRoute(page, path: string) { async function gotoHealthyRoute(page, path: string) {
await page.goto(path); await page.goto(path, { waitUntil: "domcontentloaded" });
await settlePage(page); await settlePage(page);
await expectBodyHasContent(page); await expectBodyHasContent(page);
await expect(page.locator("body")).not.toContainText(/404/i); await expect(page.locator("body")).not.toContainText(/404/i);
+41 -1
View File
@@ -1,5 +1,5 @@
const API_HOST = const API_HOST =
/https?:\/\/(?:api\.truckwash\.io(?::\d+)?\/.*|localhost(?::\d+)?\/api\/.*|127\.0\.0\.1(?::\d+)?\/api\/.*)/i; /https?:\/\/(?:api(?:-v2)?\.truckwash\.io(?::\d+)?\/.*|localhost(?::\d+)?\/api\/.*|127\.0\.0\.1(?::\d+)?\/api\/.*)/i;
const TINY_PNG = Buffer.from( const TINY_PNG = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAukB9pY9ZxQAAAAASUVORK5CYII=", "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAukB9pY9ZxQAAAAASUVORK5CYII=",
"base64" "base64"
@@ -6042,6 +6042,46 @@ export async function mockApi(page, options = {}) {
return; return;
} }
if (pathname.endsWith("/release/runtime") && method === "GET") {
await route.fulfill(
json({
data: {
source: "local",
requested_source: "deployment",
generated_at: new Date().toISOString(),
trace_id: "playwright-local-prod-runtime",
channel: {
slug: "stable",
name: "Stable",
default_channel: true,
route_slug: "master",
},
available_channels: [],
versions: {
frontend: null,
api: null,
service_set: null,
bundle_id: null,
bundle: null,
},
frontend_base_url: "http://127.0.0.1:4173",
api_base_url: "https://api-v2.truckwash.io/master/api",
urls: {
frontend_base_url: "http://127.0.0.1:4173",
api_base_url: "https://api-v2.truckwash.io/master/api",
},
availability: {
configured: true,
missing: [],
status: "ready",
explicit: true,
},
},
})
);
return;
}
if (pathname.endsWith("/worker/version") && method === "GET") { if (pathname.endsWith("/worker/version") && method === "GET") {
await route.fulfill( await route.fulfill(
json({ json({
@@ -60,6 +60,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => {
return { return {
SessionUser: { SessionUser: {
canAccessDepartment: (...args) => canAccessDepartment(...args), canAccessDepartment: (...args) => canAccessDepartment(...args),
canAccessAssignedDepartment: (...args) => canAccessDepartment(...args),
functions: { functions: {
selectDepartment: (...args) => selectDepartment(...args), selectDepartment: (...args) => selectDepartment(...args),
getDepartmentIdFromUrl: (...args) => getDepartmentIdFromUrl(...args), getDepartmentIdFromUrl: (...args) => getDepartmentIdFromUrl(...args),
@@ -149,12 +150,12 @@ describe("NavigationMenuDepartment", () => {
it("updates the selected department label when route department changes", async () => { it("updates the selected department label when route department changes", async () => {
const wrapper = mount(NavigationMenuDepartment); const wrapper = mount(NavigationMenuDepartment);
expect(wrapper.get("[data-testid='mock-button-label']").text()).toBe("Select department"); expect(wrapper.get("[data-testid='mobile-header-department-toggle']").text()).toContain("Select department");
expect(__getDepartmentsMock).toHaveBeenCalledTimes(1); expect(__getDepartmentsMock).toHaveBeenCalledTimes(1);
__setDepartmentId("12"); __setDepartmentId("12");
await nextTick(); await nextTick();
expect(wrapper.get("[data-testid='mock-button-label']").text()).toBe("Taastrup"); expect(wrapper.get("[data-testid='mobile-header-department-toggle']").text()).toContain("Taastrup");
}); });
}); });
+3
View File
@@ -205,6 +205,9 @@ function mountStepOne() {
return mountWithApp(PosDepartmentStep1, { return mountWithApp(PosDepartmentStep1, {
messages: { messages: {
en: { en: {
common: {
order: "Order",
},
admin: { admin: {
pos: { pos: {
license_plates: "License plates", license_plates: "License plates",
+11 -2
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js"; import { mountWithApp } from "./helpers/mountWithApp.js";
import ReferenceAutocompletePOS from "@/components/forms/department/pos/input/ReferenceAutocompletePOS.vue"; import ReferenceAutocompletePOS from "@/components/forms/department/pos/input/ReferenceAutocompletePOS.vue";
@@ -16,8 +16,10 @@ const flushPromises = async () => {
await Promise.resolve(); await Promise.resolve();
}; };
let wrapper = null;
function mountAutocomplete(props = {}) { function mountAutocomplete(props = {}) {
return mountWithApp(ReferenceAutocompletePOS, { wrapper = mountWithApp(ReferenceAutocompletePOS, {
props: { props: {
modelValue: "", modelValue: "",
departmentId: 12, departmentId: 12,
@@ -27,6 +29,7 @@ function mountAutocomplete(props = {}) {
...props, ...props,
}, },
}); });
return wrapper;
} }
describe("ReferenceAutocompletePOS", () => { describe("ReferenceAutocompletePOS", () => {
@@ -35,6 +38,12 @@ describe("ReferenceAutocompletePOS", () => {
requestState.authenticatedRequest.mockResolvedValue({ data: { data: [] } }); requestState.authenticatedRequest.mockResolvedValue({ data: { data: [] } });
}); });
afterEach(async () => {
wrapper?.unmount();
wrapper = null;
await flushPromises();
});
it("fetches async suggestions with the current POS context", async () => { it("fetches async suggestions with the current POS context", async () => {
requestState.authenticatedRequest.mockResolvedValue({ requestState.authenticatedRequest.mockResolvedValue({
data: { data: {
+1 -1
View File
@@ -34,7 +34,7 @@ describe("weatherapi superuser wiring", () => {
it("appears in superuser configuration navigation menus", () => { it("appears in superuser configuration navigation menus", () => {
expect(navItemsSource).toContain("SessionUser.superUser.modules.weatherapi.meta.labels.multiple"); expect(navItemsSource).toContain("SessionUser.superUser.modules.weatherapi.meta.labels.multiple");
expect(navItemsSource).toContain("to: '/superuser/configuration/weatherapi'"); expect(navItemsSource).toMatch(/to:\s*["']\/superuser\/configuration\/weatherapi["']/);
expect(leftMenuSource).toContain("SessionUser.superUser.modules.weatherapi.meta.title"); expect(leftMenuSource).toContain("SessionUser.superUser.modules.weatherapi.meta.title");
expect(leftMenuSource).toContain("SessionUser.superUser.modules.weatherapi.meta.config_endpoint"); expect(leftMenuSource).toContain("SessionUser.superUser.modules.weatherapi.meta.config_endpoint");
+1 -1
View File
@@ -34,7 +34,7 @@ describe("workfeed superuser wiring", () => {
it("appears in superuser configuration navigation menus", () => { it("appears in superuser configuration navigation menus", () => {
expect(navItemsSource).toContain("SessionUser.superUser.modules.workfeed.meta.labels.multiple"); expect(navItemsSource).toContain("SessionUser.superUser.modules.workfeed.meta.labels.multiple");
expect(navItemsSource).toContain("to: '/superuser/configuration/workfeed'"); expect(navItemsSource).toMatch(/to:\s*["']\/superuser\/configuration\/workfeed["']/);
expect(leftMenuSource).toContain("SessionUser.superUser.modules.workfeed.meta.title"); expect(leftMenuSource).toContain("SessionUser.superUser.modules.workfeed.meta.title");
expect(leftMenuSource).toContain("SessionUser.superUser.modules.workfeed.meta.config_endpoint"); expect(leftMenuSource).toContain("SessionUser.superUser.modules.workfeed.meta.config_endpoint");
+55 -3
View File
@@ -37,6 +37,11 @@ const PUBLIC_ASSET_ALIASES = [
] ]
function getGitCommit() { function getGitCommit() {
const explicitCommit = getExplicitCommitSha()
if (explicitCommit !== 'unknown') {
return explicitCommit.slice(0, 12)
}
try { try {
return execSync('git rev-parse --short HEAD').toString().trim() return execSync('git rev-parse --short HEAD').toString().trim()
} catch { } catch {
@@ -45,6 +50,11 @@ function getGitCommit() {
} }
function getGitCommitSha() { function getGitCommitSha() {
const explicitCommit = getExplicitCommitSha()
if (explicitCommit !== 'unknown') {
return explicitCommit
}
try { try {
return execSync('git rev-parse HEAD').toString().trim() return execSync('git rev-parse HEAD').toString().trim()
} catch { } catch {
@@ -52,6 +62,17 @@ function getGitCommitSha() {
} }
} }
function getExplicitCommitSha() {
for (const key of ['RELEASE_COMMIT_SHA', 'COMMIT_SHA', 'GITHUB_SHA', 'SOURCE_COMMIT', 'VITE_COMMIT_HASH']) {
const value = String(process.env[key] || '').trim()
if (/^[a-f0-9]{7,40}$/i.test(value)) {
return value
}
}
return 'unknown'
}
function releaseBuildId(commitSha) { function releaseBuildId(commitSha) {
const explicitBuildId = process.env.RELEASE_BUILD_ID || process.env.BUILD_ID const explicitBuildId = process.env.RELEASE_BUILD_ID || process.env.BUILD_ID
if (explicitBuildId) { if (explicitBuildId) {
@@ -66,6 +87,33 @@ function releaseBuildId(commitSha) {
return `${prefix}-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}` return `${prefix}-${new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)}`
} }
function normalizeViteBase(value) {
const base = String(value || '').trim()
if (!base || base === '/') {
return '/'
}
if (base === './') {
return './'
}
return `/${base.replace(/^\/+|\/+$/g, '')}/`
}
function stripReleaseBasePath(assetPath, base) {
if (!assetPath || !assetPath.startsWith('/')) {
return assetPath
}
const normalizedBase = normalizeViteBase(base)
if (normalizedBase === '/' || normalizedBase === './') {
return assetPath
}
return assetPath.startsWith(normalizedBase)
? `/${assetPath.slice(normalizedBase.length)}`
: assetPath
}
function releaseUrlPath(value, basePath = '/') { function releaseUrlPath(value, basePath = '/') {
if (!value || value.startsWith('#') || /^(?:data|mailto|tel|javascript):/i.test(value)) { if (!value || value.startsWith('#') || /^(?:data|mailto|tel|javascript):/i.test(value)) {
return '' return ''
@@ -230,6 +278,7 @@ function rootPwaManifestAlias() {
function releaseMetadataManifest() { function releaseMetadataManifest() {
let outputDirectory = fromProjectRoot('dist') let outputDirectory = fromProjectRoot('dist')
let releaseBasePath = '/'
return { return {
name: 'release-metadata-manifest', name: 'release-metadata-manifest',
@@ -238,6 +287,7 @@ function releaseMetadataManifest() {
outputDirectory = path.isAbsolute(config.build.outDir) outputDirectory = path.isAbsolute(config.build.outDir)
? config.build.outDir ? config.build.outDir
: path.resolve(config.root, config.build.outDir) : path.resolve(config.root, config.build.outDir)
releaseBasePath = config.base || '/'
}, },
closeBundle() { closeBundle() {
const releaseEntryPath = path.join(outputDirectory, 'release-entry.json') const releaseEntryPath = path.join(outputDirectory, 'release-entry.json')
@@ -257,11 +307,12 @@ function releaseMetadataManifest() {
const rootFiles = fs.readdirSync(outputDirectory) const rootFiles = fs.readdirSync(outputDirectory)
const indexAssetUrls = Array.from(indexHtml.matchAll(/\b(?:href|src)=["']([^"']+)["']/g)) const indexAssetUrls = Array.from(indexHtml.matchAll(/\b(?:href|src)=["']([^"']+)["']/g))
.map((match) => releaseUrlPath(match[1], '/index.html')) .map((match) => releaseUrlPath(match[1], '/index.html'))
.map((assetPath) => stripReleaseBasePath(assetPath, releaseBasePath))
.filter(Boolean) .filter(Boolean)
const releaseEntryAssetUrls = [ const releaseEntryAssetUrls = [
releaseEntry.entry ? `/${releaseEntry.entry}` : '', releaseEntry.entry ? `/${releaseEntry.entry}` : '',
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map((fileName) => `/${fileName}`) : []) ...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map((fileName) => `/${fileName}`) : [])
].filter(Boolean) ].map((assetPath) => stripReleaseBasePath(assetPath, releaseBasePath)).filter(Boolean)
const pwaAssetUrls = [ const pwaAssetUrls = [
'manifest.json', 'manifest.json',
'manifest.webmanifest', 'manifest.webmanifest',
@@ -275,6 +326,7 @@ function releaseMetadataManifest() {
] ]
.filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName))) .filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName)))
.map((fileName) => `/${fileName.replace(/\\/g, '/')}`) .map((fileName) => `/${fileName.replace(/\\/g, '/')}`)
.map((assetPath) => stripReleaseBasePath(assetPath, releaseBasePath))
for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) { for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) {
const absoluteManifestPath = path.join(outputDirectory, manifestPath) const absoluteManifestPath = path.join(outputDirectory, manifestPath)
@@ -287,7 +339,7 @@ function releaseMetadataManifest() {
for (const icon of pwaManifest.icons || []) { for (const icon of pwaManifest.icons || []) {
const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`) const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`)
if (iconPath) { if (iconPath) {
pwaAssetUrls.push(iconPath) pwaAssetUrls.push(stripReleaseBasePath(iconPath, releaseBasePath))
} }
} }
} catch { } catch {
@@ -372,7 +424,7 @@ export default defineConfig(({ mode }) => {
process.env.IS_DEV = !isProd ? 'true' : 'false' process.env.IS_DEV = !isProd ? 'true' : 'false'
// Server version updates happen after deploy verification, not during asset builds. // Server version updates happen after deploy verification, not during asset builds.
// Keep a relative base for static hosting; align manifest with base // Keep a relative base for static hosting; align manifest with base
const base = '/' const base = normalizeViteBase(process.env.VITE_BASE_PATH || process.env.BASE_PATH || '/')
const pwaScope = base === './' ? '.' : base const pwaScope = base === './' ? '.' : base
// Enable single-file build only when explicitly requested (disabled by default for PWA) // Enable single-file build only when explicitly requested (disabled by default for PWA)