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
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
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
fullyParallel: true,
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 2 : 3,
workers: isCI ? 2 : 1,
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
outputDir: "output/playwright/prod/test-results",
use: {
@@ -240,7 +240,7 @@ const getProductOptionsLabel = (vehicle) => {
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
:displayActionsDirectly="true"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
@@ -197,7 +197,7 @@ onMounted(() => {
<div class="error-state__content">
<p>{{ t('superuser_invoice_distribution.errors.overview_load_failed') }}: {{ errorMessage }}</p>
<b-button type="is-danger" class="control-button is-light" @click="loadOverview">
{{ t('common.retry') }}
{{ t('superuser_invoice_distribution.actions.try_again') }}
</b-button>
</div>
</b-message>
@@ -281,7 +281,7 @@ onMounted(() => {
<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>
<b-button type="is-link" class="control-button is-light" @click="loadOverview">
{{ t('common.retry') }}
{{ t('superuser_invoice_distribution.actions.try_again') }}
</b-button>
</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
.poll(
async () => {
@@ -101,7 +101,7 @@ export async function expectBodyHasContent(page: Page, minimumLength = 20) {
.catch(() => "");
return text.replace(/\s+/g, " ").trim().length;
},
{ timeout: 15_000 }
{ timeout }
)
.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 }) => {
const guards = attachPageHealthGuards(page, baseURL);
await page.goto("/guest/book/wash");
await page.goto(liveUrl("guest/book/wash"));
await settlePage(page);
await expect(page).toHaveURL(/\/guest\/book\/wash(?:\?.*)?$/);
await expectBodyHasContent(page);
+1 -1
View File
@@ -24,7 +24,7 @@ function toLoopbackRequestUrl(url: string) {
}
async function gotoHealthyRoute(page, path: string) {
await page.goto(path);
await page.goto(path, { waitUntil: "domcontentloaded" });
await settlePage(page);
await expectBodyHasContent(page);
await expect(page.locator("body")).not.toContainText(/404/i);
+41 -1
View File
@@ -1,5 +1,5 @@
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(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAukB9pY9ZxQAAAAASUVORK5CYII=",
"base64"
@@ -6042,6 +6042,46 @@ export async function mockApi(page, options = {}) {
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") {
await route.fulfill(
json({
@@ -60,6 +60,7 @@ vi.mock("@/components/session/token/SessionUser.vue", () => {
return {
SessionUser: {
canAccessDepartment: (...args) => canAccessDepartment(...args),
canAccessAssignedDepartment: (...args) => canAccessDepartment(...args),
functions: {
selectDepartment: (...args) => selectDepartment(...args),
getDepartmentIdFromUrl: (...args) => getDepartmentIdFromUrl(...args),
@@ -149,12 +150,12 @@ describe("NavigationMenuDepartment", () => {
it("updates the selected department label when route department changes", async () => {
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);
__setDepartmentId("12");
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, {
messages: {
en: {
common: {
order: "Order",
},
admin: {
pos: {
license_plates: "License plates",
+11 -2
View File
@@ -1,5 +1,5 @@
// @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 ReferenceAutocompletePOS from "@/components/forms/department/pos/input/ReferenceAutocompletePOS.vue";
@@ -16,8 +16,10 @@ const flushPromises = async () => {
await Promise.resolve();
};
let wrapper = null;
function mountAutocomplete(props = {}) {
return mountWithApp(ReferenceAutocompletePOS, {
wrapper = mountWithApp(ReferenceAutocompletePOS, {
props: {
modelValue: "",
departmentId: 12,
@@ -27,6 +29,7 @@ function mountAutocomplete(props = {}) {
...props,
},
});
return wrapper;
}
describe("ReferenceAutocompletePOS", () => {
@@ -35,6 +38,12 @@ describe("ReferenceAutocompletePOS", () => {
requestState.authenticatedRequest.mockResolvedValue({ data: { data: [] } });
});
afterEach(async () => {
wrapper?.unmount();
wrapper = null;
await flushPromises();
});
it("fetches async suggestions with the current POS context", async () => {
requestState.authenticatedRequest.mockResolvedValue({
data: {
+1 -1
View File
@@ -34,7 +34,7 @@ describe("weatherapi superuser wiring", () => {
it("appears in superuser configuration navigation menus", () => {
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.config_endpoint");
+1 -1
View File
@@ -34,7 +34,7 @@ describe("workfeed superuser wiring", () => {
it("appears in superuser configuration navigation menus", () => {
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.config_endpoint");
+55 -3
View File
@@ -37,6 +37,11 @@ const PUBLIC_ASSET_ALIASES = [
]
function getGitCommit() {
const explicitCommit = getExplicitCommitSha()
if (explicitCommit !== 'unknown') {
return explicitCommit.slice(0, 12)
}
try {
return execSync('git rev-parse --short HEAD').toString().trim()
} catch {
@@ -45,6 +50,11 @@ function getGitCommit() {
}
function getGitCommitSha() {
const explicitCommit = getExplicitCommitSha()
if (explicitCommit !== 'unknown') {
return explicitCommit
}
try {
return execSync('git rev-parse HEAD').toString().trim()
} 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) {
const explicitBuildId = process.env.RELEASE_BUILD_ID || process.env.BUILD_ID
if (explicitBuildId) {
@@ -66,6 +87,33 @@ function releaseBuildId(commitSha) {
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 = '/') {
if (!value || value.startsWith('#') || /^(?:data|mailto|tel|javascript):/i.test(value)) {
return ''
@@ -230,6 +278,7 @@ function rootPwaManifestAlias() {
function releaseMetadataManifest() {
let outputDirectory = fromProjectRoot('dist')
let releaseBasePath = '/'
return {
name: 'release-metadata-manifest',
@@ -238,6 +287,7 @@ function releaseMetadataManifest() {
outputDirectory = path.isAbsolute(config.build.outDir)
? config.build.outDir
: path.resolve(config.root, config.build.outDir)
releaseBasePath = config.base || '/'
},
closeBundle() {
const releaseEntryPath = path.join(outputDirectory, 'release-entry.json')
@@ -257,11 +307,12 @@ function releaseMetadataManifest() {
const rootFiles = fs.readdirSync(outputDirectory)
const indexAssetUrls = Array.from(indexHtml.matchAll(/\b(?:href|src)=["']([^"']+)["']/g))
.map((match) => releaseUrlPath(match[1], '/index.html'))
.map((assetPath) => stripReleaseBasePath(assetPath, releaseBasePath))
.filter(Boolean)
const releaseEntryAssetUrls = [
releaseEntry.entry ? `/${releaseEntry.entry}` : '',
...(Array.isArray(releaseEntry.css) ? releaseEntry.css.map((fileName) => `/${fileName}`) : [])
].filter(Boolean)
].map((assetPath) => stripReleaseBasePath(assetPath, releaseBasePath)).filter(Boolean)
const pwaAssetUrls = [
'manifest.json',
'manifest.webmanifest',
@@ -275,6 +326,7 @@ function releaseMetadataManifest() {
]
.filter((fileName) => fs.existsSync(path.join(outputDirectory, fileName)))
.map((fileName) => `/${fileName.replace(/\\/g, '/')}`)
.map((assetPath) => stripReleaseBasePath(assetPath, releaseBasePath))
for (const manifestPath of ['manifest.webmanifest', 'assets/manifest.webmanifest']) {
const absoluteManifestPath = path.join(outputDirectory, manifestPath)
@@ -287,7 +339,7 @@ function releaseMetadataManifest() {
for (const icon of pwaManifest.icons || []) {
const iconPath = releaseUrlPath(icon.src || '', `/${manifestPath}`)
if (iconPath) {
pwaAssetUrls.push(iconPath)
pwaAssetUrls.push(stripReleaseBasePath(iconPath, releaseBasePath))
}
}
} catch {
@@ -372,7 +424,7 @@ export default defineConfig(({ mode }) => {
process.env.IS_DEV = !isProd ? 'true' : 'false'
// Server version updates happen after deploy verification, not during asset builds.
// 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
// Enable single-file build only when explicitly requested (disabled by default for PWA)