Compare commits

...
29 changed files with 640 additions and 351 deletions
+18 -18
View File
@@ -259,28 +259,28 @@ jobs:
needs.build-and-unit.result == 'success' &&
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 4
matrix:
role: [customer, subuser, admin, superuser]
browser: [chromium, firefox, webkit]
device: [mobile, tablet, desktop]
browser: [chromium, webkit, firefox]
device: [mobile, desktop, tablet]
role: [superuser, admin, customer, subuser]
include:
- browser: chromium
browser_label: Chromium
browser_install: chromium
- browser: firefox
browser_label: Firefox
browser_install: firefox
- browser: webkit
browser_label: WebKit
browser_install: webkit
- browser: firefox
browser_label: Firefox
browser_install: firefox
env:
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
PLAYWRIGHT_REPORTER_MODE: line-html
steps:
- name: Repair self-hosted workspace permissions
@@ -315,22 +315,22 @@ jobs:
run: |
set -euo pipefail
case "$MATRIX_ROLE" in
customer) role_offset=0 ;;
subuser) role_offset=100 ;;
admin) role_offset=200 ;;
superuser) role_offset=300 ;;
superuser) role_offset=0 ;;
admin) role_offset=100 ;;
customer) role_offset=200 ;;
subuser) role_offset=300 ;;
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
esac
case "$MATRIX_BROWSER" in
chromium) browser_offset=0 ;;
firefox) browser_offset=30 ;;
webkit) browser_offset=60 ;;
webkit) browser_offset=30 ;;
firefox) browser_offset=60 ;;
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
esac
case "$MATRIX_DEVICE" in
mobile) device_offset=1 ;;
tablet) device_offset=2 ;;
desktop) device_offset=3 ;;
desktop) device_offset=2 ;;
tablet) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
@@ -392,10 +392,10 @@ jobs:
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: playwright-report-full-${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}
name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}
path: |
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/report
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}/test-results
output/playwright/test-lists/${{ matrix.role }}-${{ matrix.browser }}-${{ matrix.device }}.txt
output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt
if-no-files-found: ignore
retention-days: 1
+14 -7
View File
@@ -96,16 +96,22 @@ Artifacts and summaries:
## Playwright Full E2E
Run the permanent grouped full-suite entrypoint with a hard max of 5 total workers across:
Run the permanent grouped full-suite entrypoint with a hard max of 5 total workers across the browser-engine groups:
- `chromium-(desktop|tablet|mobile)`
- `firefox-(desktop|tablet|mobile)`
- `webkit-(desktop|tablet|mobile)`
- Chromium
- WebKit
- Firefox
```sh
npm run test:e2e:ci
```
The full CI matrix is ordered by browser engine, then device class, then user role:
- browsers: `chromium`, `webkit`, `firefox`
- devices: `mobile`, `desktop`, `tablet`
- roles: `superuser`, `admin`, `customer`, `subuser`
Run a single full-suite slice for one role and one Playwright project:
```sh
@@ -117,7 +123,7 @@ Default worker allocation:
```sh
PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM=2
PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX=1
PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=2
PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=1
```
Optional overrides:
@@ -126,7 +132,7 @@ Optional overrides:
PLAYWRIGHT_PARALLEL_BASE_PORT=5191
PLAYWRIGHT_PARALLEL_WORKERS_CHROMIUM=2
PLAYWRIGHT_PARALLEL_WORKERS_FIREFOX=1
PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=2
PLAYWRIGHT_PARALLEL_WORKERS_WEBKIT=1
```
The runner fails fast if the combined worker count exceeds 5.
@@ -137,7 +143,8 @@ Artifacts and summaries:
- `output/playwright/ci-parallel-chromium/report/index.html`
- `output/playwright/ci-parallel-firefox/report/index.html`
- `output/playwright/ci-parallel-webkit/report/index.html`
- `output/playwright/test-lists/<role>-<project>.txt`
- `output/playwright/test-lists/<project>-<role>.txt`
- `output/playwright/test-lists/<role>-<project>.txt` (legacy compatibility copy)
## Bubblewrap (TWA) Build and Install
+6 -6
View File
@@ -19,7 +19,7 @@ const reporter =
: [["list"], ["html", { open: "never", outputFolder: htmlReportOutputFolder }]];
function buildProject(name: string, browserName: "chromium" | "firefox" | "webkit", deviceName: keyof typeof devices) {
const { defaultBrowserType, ...device } = devices[deviceName];
const { defaultBrowserType: _defaultBrowserType, ...device } = devices[deviceName];
const use = {
browserName,
...device,
@@ -62,14 +62,14 @@ export default defineConfig({
video: "retain-on-failure",
},
projects: [
buildProject("chromium-mobile", "chromium", "Pixel 5"),
buildProject("chromium-desktop", "chromium", "Desktop Chrome"),
buildProject("chromium-tablet", "chromium", "iPad Mini"),
buildProject("chromium-mobile", "chromium", "Pixel 5"),
buildProject("firefox-desktop", "firefox", "Desktop Firefox"),
buildProject("firefox-tablet", "firefox", "iPad Mini"),
buildProject("firefox-mobile", "firefox", "Pixel 5"),
buildProject("webkit-mobile", "webkit", "iPhone 12"),
buildProject("webkit-desktop", "webkit", "Desktop Safari"),
buildProject("webkit-tablet", "webkit", "iPad Mini"),
buildProject("webkit-mobile", "webkit", "iPhone 12"),
buildProject("firefox-mobile", "firefox", "Pixel 5"),
buildProject("firefox-desktop", "firefox", "Desktop Firefox"),
buildProject("firefox-tablet", "firefox", "iPad Mini"),
],
});
+1 -1
View File
@@ -28,7 +28,7 @@ const groups = [
{
name: "webkit",
projects: ["webkit-desktop", "webkit-tablet", "webkit-mobile"],
defaultWorkers: 2,
defaultWorkers: 1,
},
];
+29 -6
View File
@@ -7,7 +7,12 @@ import { promisify } from "node:util";
const workingDirectory = process.cwd();
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
const execFileAsync = promisify(execFile);
export const roles = ["customer", "subuser", "admin", "superuser"];
export const browserEngines = ["chromium", "webkit", "firefox"];
export const deviceClasses = ["mobile", "desktop", "tablet"];
export const roles = ["superuser", "admin", "customer", "subuser"];
export const fullSuiteProjects = browserEngines.flatMap((browser) =>
deviceClasses.map((device) => `${browser}-${device}`)
);
const listEntryPattern = /^\s+\[[^\]]+\]\s+\s+(.+?):(\d+):(\d+)\s+\s+(.+)\s*$/u;
export const ownedFilesByRole = {
@@ -208,6 +213,10 @@ function validateOptions(options, forwardedArgs) {
throw new Error("--project is required.");
}
if (!fullSuiteProjects.includes(options.project)) {
throw new Error(`--project must be one of: ${fullSuiteProjects.join(", ")}`);
}
for (const arg of forwardedArgs) {
if (arg === "--list" || arg === "--test-list" || arg === "--project") {
throw new Error(`Forwarded Playwright argument is not supported here: ${arg}`);
@@ -278,7 +287,7 @@ export function classifyTest(testEntry) {
async function listProjectTests(project, forwardedArgs) {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[playwrightCliPath, "test", "--list", `--project=${project}`, ...forwardedArgs],
[playwrightCliPath, "test", "--list", "--reporter=list", `--project=${project}`, ...forwardedArgs],
{
cwd: workingDirectory,
maxBuffer: 64 * 1024 * 1024,
@@ -292,12 +301,26 @@ async function listProjectTests(project, forwardedArgs) {
return stdout;
}
async function writeTestList(role, project, matchingTests) {
const outputDirectory = path.join(workingDirectory, "output", "playwright", "test-lists");
const getTestListDirectory = () =>
process.env.PLAYWRIGHT_TEST_LIST_DIR || path.join(workingDirectory, "output", "playwright", "test-lists");
export function getPrimaryTestListPath(project, role) {
return path.join(getTestListDirectory(), `${project}-${role}.txt`);
}
export function getLegacyTestListPath(role, project) {
return path.join(getTestListDirectory(), `${role}-${project}.txt`);
}
export async function writeTestList(role, project, matchingTests) {
const outputDirectory = getTestListDirectory();
await fs.mkdir(outputDirectory, { recursive: true });
const testListPath = path.join(outputDirectory, `${role}-${project}.txt`);
await fs.writeFile(testListPath, `${matchingTests.map((testEntry) => testEntry.listLine).join("\n")}\n`, "utf8");
const contents = `${matchingTests.map((testEntry) => testEntry.listLine).join("\n")}\n`;
const testListPath = getPrimaryTestListPath(project, role);
const legacyTestListPath = getLegacyTestListPath(role, project);
await fs.writeFile(testListPath, contents, "utf8");
await fs.writeFile(legacyTestListPath, contents, "utf8");
return testListPath;
}
@@ -9,7 +9,6 @@ import {
setStep,
setOrderId,
searchAndSelectCustomer,
loadOrderItems,
} from "@/components/shop/POSDepartmentProcess.vue";
import PosDepartmentStep1 from "@/components/displays/department/pos/steps/PosDepartmentStep1.vue";
import PosDepartmentStep2 from "@/components/displays/department/pos/steps/PosDepartmentStep2.vue";
@@ -53,7 +52,6 @@ const debugVehicles = () => {
const routeStateHandlers = {
setOrderId,
loadOrderItems,
setStep,
searchAndSelectCustomer,
clearActivePosOrderContext,
@@ -93,9 +93,7 @@ const LPR_ENDPOINT = "/modules/scanner/lpr";
let consecutiveNoPlateResponses = 0;
const nowMs = (): number =>
typeof performance !== "undefined" && typeof performance.now === "function"
? performance.now()
: Date.now();
typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
const activeVehicleIndexNext = () => {
// Increment the active vehicle index, wrapping around if necessary
@@ -129,8 +127,7 @@ type LPRFrameInput = string | LPRFramePayload;
const isLPRFramePayload = (image: LPRFrameInput): image is LPRFramePayload =>
typeof image === "object" && image !== null && image.blob instanceof Blob;
const getLPRFrameFingerprint = (image: LPRFrameInput): string =>
isLPRFramePayload(image) ? image.fingerprint : image;
const getLPRFrameFingerprint = (image: LPRFrameInput): string => (isLPRFramePayload(image) ? image.fingerprint : image);
const getLPRFrameContentFingerprint = (image: LPRFrameInput): (() => Promise<string>) | null =>
isLPRFramePayload(image) ? image.getContentFingerprint ?? null : null;
@@ -180,19 +177,22 @@ const resolveParsedFrameContentFingerprint = (entry: ParsedFrameFingerprint): Pr
return null;
}
entry.contentFingerprintPromise ??= entry.getContentFingerprint().then((content) => {
if (lastParsedImage.value === entry) {
entry.content = content;
}
entry.contentFingerprintPromise ??= entry
.getContentFingerprint()
.then((content) => {
if (lastParsedImage.value === entry) {
entry.content = content;
}
return content;
}).catch((error) => {
if (lastParsedImage.value === entry) {
entry.contentFingerprintPromise = null;
}
return content;
})
.catch((error) => {
if (lastParsedImage.value === entry) {
entry.contentFingerprintPromise = null;
}
throw error;
});
throw error;
});
return entry.contentFingerprintPromise;
};
@@ -200,18 +200,17 @@ const resolveParsedFrameContentFingerprint = (entry: ParsedFrameFingerprint): Pr
const isVisuallySimilarToLastMiss = (visualFingerprint: string | null | undefined): boolean => {
const lastParsed = lastParsedImage.value;
return lastParsed !== null
&& lastParsed.outcome === "miss"
&& getVisualFingerprintDistance(lastParsed.visual, visualFingerprint) <= LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD;
return (
lastParsed !== null &&
lastParsed.outcome === "miss" &&
getVisualFingerprintDistance(lastParsed.visual, visualFingerprint) <= LPR_VISUAL_DUPLICATE_DISTANCE_THRESHOLD
);
};
const hasLastMissVisualFingerprint = (): boolean =>
lastParsedImage.value !== null
&& lastParsedImage.value.outcome === "miss"
&& lastParsedImage.value.visual !== null;
lastParsedImage.value !== null && lastParsedImage.value.outcome === "miss" && lastParsedImage.value.visual !== null;
const shouldBuildLPRVisualFingerprint = (): boolean =>
hasLastMissVisualFingerprint();
const shouldBuildLPRVisualFingerprint = (): boolean => hasLastMissVisualFingerprint();
const shouldSkipDuplicateFrame = async (image: LPRFrameInput): Promise<boolean> => {
const quick = getLPRFrameFingerprint(image);
@@ -236,10 +235,7 @@ const shouldSkipDuplicateFrame = async (image: LPRFrameInput): Promise<boolean>
}
try {
const [lastContent, currentContent] = await Promise.all([
lastContentFingerprint,
currentContentFingerprint(),
]);
const [lastContent, currentContent] = await Promise.all([lastContentFingerprint, currentContentFingerprint()]);
return lastContent === currentContent;
} catch {
@@ -247,11 +243,7 @@ const shouldSkipDuplicateFrame = async (image: LPRFrameInput): Promise<boolean>
}
};
const appendFiniteTimingParam = (
queryParts: string[],
field: string,
value: number | null | undefined
) => {
const appendFiniteTimingParam = (queryParts: string[], field: string, value: number | null | undefined) => {
if (value === null || value === undefined) {
return;
}
@@ -267,11 +259,7 @@ const appendFiniteTimingParam = (
}
};
const appendPositiveIntegerParam = (
queryParts: string[],
field: string,
value: number | null | undefined
) => {
const appendPositiveIntegerParam = (queryParts: string[], field: string, value: number | null | undefined) => {
const numericValue = Number(value);
if (!Number.isFinite(numericValue) || numericValue <= 0) {
return;
@@ -333,9 +321,9 @@ const shouldEncodeLPRFrame = (candidate: LPRFrameEncodeCandidate): boolean => {
}
if (
shouldSkipLPRForCurrentState({ ignoreNoPlateBackoff: true })
|| isLPRFrameProcessing.value
|| isLPRRequestInFlight.value
shouldSkipLPRForCurrentState({ ignoreNoPlateBackoff: true }) ||
isLPRFrameProcessing.value ||
isLPRRequestInFlight.value
) {
return false;
}
@@ -369,19 +357,18 @@ const rememberLatestCameraImage = (image: LPRFrameInput) => {
const hasRegistrationNumber = (registrationNumber: string | null | undefined): boolean =>
String(registrationNumber ?? "").trim().length > 0;
const isActiveRegistrationSlotFilled = (): boolean =>
hasRegistrationNumber(vehicles.getActiveVehicle()?.reg);
const isActiveRegistrationSlotFilled = (): boolean => hasRegistrationNumber(vehicles.getActiveVehicle()?.reg);
const areAllRegistrationSlotsFilled = (): boolean =>
[1, 2, 3].every((vehicleIndex) => hasRegistrationNumber(vehicles.get(vehicleIndex)?.reg));
const shouldSkipLPRForCurrentState = (options: LPRCurrentStateSkipOptions = {}): boolean =>
views.attachmentView.value
|| isActiveRegistrationSlotFilled()
|| areAllRegistrationSlotsFilled()
|| (!options.ignoreNoPlateBackoff && isNoPlateBackoffActive.value)
|| isDuplicateFrameBackoffActive.value
|| isSuccessCooldownActive.value;
views.attachmentView.value ||
isActiveRegistrationSlotFilled() ||
areAllRegistrationSlotsFilled() ||
(!options.ignoreNoPlateBackoff && isNoPlateBackoffActive.value) ||
isDuplicateFrameBackoffActive.value ||
isSuccessCooldownActive.value;
const getScannerFocusViewportRect = (): LPRFrameViewportRect | null => {
if (views.attachmentView.value || scannerFocusRef.value === null) {
@@ -389,12 +376,7 @@ const getScannerFocusViewportRect = (): LPRFrameViewportRect | null => {
}
const rect = scannerFocusRef.value.getBoundingClientRect();
if (
!Number.isFinite(rect.width) ||
!Number.isFinite(rect.height) ||
rect.width <= 0 ||
rect.height <= 0
) {
if (!Number.isFinite(rect.width) || !Number.isFinite(rect.height) || rect.width <= 0 || rect.height <= 0) {
return null;
}
@@ -411,28 +393,17 @@ const isCameraFrameCaptureEnabled = computed(() => {
return true;
}
return !isLPRFrameProcessing.value
&& !isLPRRequestInFlight.value
&& (!isNoPlateBackoffActive.value || hasLastMissVisualFingerprint())
&& !isDuplicateFrameBackoffActive.value
&& !isSuccessCooldownActive.value
&& !isActiveRegistrationSlotFilled()
&& !areAllRegistrationSlotsFilled();
return (
!isLPRFrameProcessing.value &&
!isLPRRequestInFlight.value &&
(!isNoPlateBackoffActive.value || hasLastMissVisualFingerprint()) &&
!isDuplicateFrameBackoffActive.value &&
!isSuccessCooldownActive.value &&
!isActiveRegistrationSlotFilled() &&
!areAllRegistrationSlotsFilled()
);
});
const shouldPauseScannerPreview = computed(() =>
!views.attachmentView.value
&& (
isLPRFrameProcessing.value
|| isLPRRequestInFlight.value
|| isDuplicateFrameBackoffActive.value
|| isSuccessCooldownActive.value
|| isActiveRegistrationSlotFilled()
|| areAllRegistrationSlotsFilled()
|| (isNoPlateBackoffActive.value && !hasLastMissVisualFingerprint())
)
);
const lprCameraCaptureIntervalMs = computed(() =>
!views.attachmentView.value && isNoPlateBackoffActive.value && hasLastMissVisualFingerprint()
? LPR_VISUAL_DUPLICATE_RECHECK_DELAY_MS
@@ -444,8 +415,7 @@ const abortLPRRequest = () => {
lprRequestAbortController = null;
};
const isDocumentHidden = (): boolean =>
typeof document !== "undefined" && document.visibilityState === "hidden";
const isDocumentHidden = (): boolean => typeof document !== "undefined" && document.visibilityState === "hidden";
const handleDocumentVisibilityChange = () => {
if (!isDocumentHidden()) {
@@ -490,9 +460,8 @@ const resetNoPlateBackoff = () => {
const scheduleNoPlateBackoff = () => {
consecutiveNoPlateResponses += 1;
const delay = NO_PLATE_BACKOFF_DELAYS_MS[
Math.min(consecutiveNoPlateResponses - 1, NO_PLATE_BACKOFF_DELAYS_MS.length - 1)
];
const delay =
NO_PLATE_BACKOFF_DELAYS_MS[Math.min(consecutiveNoPlateResponses - 1, NO_PLATE_BACKOFF_DELAYS_MS.length - 1)];
clearNoPlateBackoff();
isNoPlateBackoffActive.value = true;
@@ -525,16 +494,20 @@ const isAbortError = (error: unknown): boolean => {
return true;
}
return typeof error === "object" && error !== null && (error as { name?: string; code?: string }).code === "ERR_CANCELED";
return (
typeof error === "object" && error !== null && (error as { name?: string; code?: string }).code === "ERR_CANCELED"
);
};
const isSameLPRScanContext = (first: LPRScanContext, second: LPRScanContext): boolean =>
first.activeVehicleIndex === second.activeVehicleIndex
&& first.attachmentView === second.attachmentView
&& first.manualInput === second.manualInput
&& first.transactionHistoryView === second.transactionHistoryView
&& first.registrationNumbers.length === second.registrationNumbers.length
&& first.registrationNumbers.every((registrationNumber, index) => registrationNumber === second.registrationNumbers[index]);
first.activeVehicleIndex === second.activeVehicleIndex &&
first.attachmentView === second.attachmentView &&
first.manualInput === second.manualInput &&
first.transactionHistoryView === second.transactionHistoryView &&
first.registrationNumbers.length === second.registrationNumbers.length &&
first.registrationNumbers.every(
(registrationNumber, index) => registrationNumber === second.registrationNumbers[index]
);
const parseImage = async (image: LPRFrameInput) => {
if (views.attachmentView.value) {
@@ -574,18 +547,11 @@ const parseImage = async (image: LPRFrameInput) => {
const lprRequest = buildLPRRequestPayload(image, clientPreflightDurationMs);
try {
const response = await SessionUser.request(
lprRequest.url,
"POST",
lprRequest.payload,
null,
null,
{
...(lprRequest.headers ? { headers: lprRequest.headers } : {}),
signal: abortController.signal,
transport: "fetch",
}
);
const response = await SessionUser.request(lprRequest.url, "POST", lprRequest.payload, null, null, {
...(lprRequest.headers ? { headers: lprRequest.headers } : {}),
signal: abortController.signal,
transport: "fetch",
});
if (debug_mode.value) {
debug_request_results.value.push(response);
@@ -770,7 +736,7 @@ watch(
:capture-interval-ms="lprCameraCaptureIntervalMs"
:capture-mode="views.attachmentView.value ? 'preview' : 'lpr'"
:get-focus-viewport-rect="getScannerFocusViewportRect"
:pause-preview="shouldPauseScannerPreview"
:pause-preview="false"
:should-build-visual-fingerprint="shouldBuildLPRVisualFingerprint"
:should-encode-frame="shouldEncodeLPRFrame"
@update:frame="parseImage"
@@ -800,11 +766,7 @@ watch(
/>
<!-- Scanner outline object -->
<div class="is-align-content-center is-flex is-justify-content-center">
<div
v-if="!views.attachmentView.value"
ref="scannerFocusRef"
class="scanner-focus-target"
>
<div v-if="!views.attachmentView.value" ref="scannerFocusRef" class="scanner-focus-target">
<ScannerOutline :loading="false" />
</div>
</div>
@@ -820,10 +782,7 @@ watch(
<!-- Location -->
<PosDepartmentStepMobile1Location />
<!-- Buttons -->
<PosDepartmentStepMobileFixedBottomControl
variant="pos-step"
:use-backdrop-blur="views.attachmentView.value"
>
<PosDepartmentStepMobileFixedBottomControl variant="pos-step" :use-backdrop-blur="views.attachmentView.value">
<!-- Attachments -->
<div class="is-flex is-justify-content-center">
<PosDepartmentStepMobileAttachments :showDefaultControls="false" v-show="views.attachmentView.value" />
@@ -871,7 +871,10 @@ const assignDraftOrderCustomer = async ({
"GET",
{
id: id
}
},
null,
null,
{ concurrencyLimit: 5 }
).then((response) => {
return response.data.data;
}).catch((error) => {
+4 -2
View File
@@ -1596,8 +1596,10 @@ export const setOrderId = (id, options = {}) => {
departmentId: selectedDepartmentId,
syncDepartmentWithSelection: options.syncDepartmentWithSelection !== false,
});
// Load the order items
loadOrderItems();
if (options.loadItems !== false) {
// Load the order items
loadOrderItems();
}
};
/** Delete everything button */
@@ -1,33 +1,35 @@
<script setup lang="ts">
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { nextTick, ref, onMounted, onUnmounted, watch } from "vue";
import { useI18n } from "vue-i18n";
import {
captureVideoFrameBlobForLPR,
isVideoFrameReadyForLPR,
LPR_CAMERA_FRAME_RATE,
LPR_CAMERA_VIDEO_HEIGHT,
LPR_CAMERA_VIDEO_WIDTH,
type LPRFrameEncodeCandidate,
type LPRFrameViewportRect,
} from "@/components/viewport/page/templates/scanner/lprFrameCapture";
const props = withDefaults(defineProps<{
captureEnabled?: boolean;
captureIntervalMs?: number | null;
captureMode?: 'lpr' | 'preview';
getFocusViewportRect?: () => LPRFrameViewportRect | null;
pausePreview?: boolean;
shouldBuildVisualFingerprint?: () => boolean;
shouldEncodeFrame?: (_candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
}>(), {
captureEnabled: true,
captureIntervalMs: null,
captureMode: 'lpr',
getFocusViewportRect: undefined,
pausePreview: false,
shouldBuildVisualFingerprint: undefined,
shouldEncodeFrame: undefined,
});
const props = withDefaults(
defineProps<{
captureEnabled?: boolean;
captureIntervalMs?: number | null;
captureMode?: "lpr" | "preview";
getFocusViewportRect?: () => LPRFrameViewportRect | null;
pausePreview?: boolean;
shouldBuildVisualFingerprint?: () => boolean;
shouldEncodeFrame?: (_candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
}>(),
{
captureEnabled: true,
captureIntervalMs: null,
captureMode: "lpr",
getFocusViewportRect: undefined,
pausePreview: false,
shouldBuildVisualFingerprint: undefined,
shouldEncodeFrame: undefined,
}
);
type GetUserMediaConstraints = Parameters<typeof navigator.mediaDevices.getUserMedia>[0];
type CameraConstraintCaps = {
capFrameRate: boolean;
@@ -35,14 +37,15 @@ type CameraConstraintCaps = {
};
const LPR_VIDEO_NOT_READY_RETRY_MS = 100;
const LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS = LPR_VIDEO_NOT_READY_RETRY_MS;
const emits = defineEmits(['camera-toggled', 'scanner-toggled', 'update:frame']);
const LPR_CAMERA_PREVIEW_FRAME_RATE = 30;
const emits = defineEmits(["camera-toggled", "scanner-toggled", "update:frame"]);
const { t } = useI18n();
const videoRef = ref<HTMLVideoElement | null>(null);
const canvasRef = ref<HTMLCanvasElement | null>(null);
const visualFingerprintCanvasRef = ref<HTMLCanvasElement | null>(null);
const cameraStream = ref<MediaStream | null>(null);
const isCameraActive = ref(false);
const cameraErrorKey = ref('pos.camera_permission_denied');
const cameraErrorKey = ref("pos.camera_permission_denied");
let captureIntervalId: ReturnType<typeof window.setInterval> | null = null;
let firstCaptureTimeoutId: ReturnType<typeof window.setTimeout> | null = null;
let isFrameCaptureInProgress = false;
@@ -52,13 +55,14 @@ let cachedRelativeFocusViewportRect: LPRFrameViewportRect | null = null;
let hasCachedRelativeFocusViewportRect = false;
let cachedVideoViewportRect: DOMRect | null = null;
let videoResizeObserver: ResizeObserver | null = null;
import { isCameraMounted, camera } from '@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue';
import {
isCameraMounted,
camera,
} from "@/components/displays/department/pos/steps/mobile/objects/PosDepartmentStepMobileFlow.vue";
const isDocumentVisible = (): boolean =>
typeof document === 'undefined' || document.visibilityState !== 'hidden';
const isDocumentVisible = (): boolean => typeof document === "undefined" || document.visibilityState !== "hidden";
const shouldRunLivePreview = (): boolean =>
!props.pausePreview && isDocumentVisible();
const shouldRunLivePreview = (): boolean => !props.pausePreview && isDocumentVisible();
const canCaptureFrames = (): boolean =>
isCameraActive.value && props.captureEnabled && !props.pausePreview && isDocumentVisible();
@@ -68,44 +72,42 @@ const getCameraErrorName = (err: unknown): string => {
return err.name;
}
return typeof err === 'object' && err !== null
? String((err as { name?: unknown }).name ?? '')
: '';
return typeof err === "object" && err !== null ? String((err as { name?: unknown }).name ?? "") : "";
};
const getCameraErrorKey = (err: unknown) => {
const errorName = getCameraErrorName(err);
if (errorName === 'NotFoundError' || errorName === 'DevicesNotFoundError') {
return 'pos.no_camera_found';
if (errorName === "NotFoundError" || errorName === "DevicesNotFoundError") {
return "pos.no_camera_found";
}
return 'pos.camera_permission_denied';
return "pos.camera_permission_denied";
};
const shouldRetryWithRelaxedCameraConstraints = (err: unknown): boolean => {
const errorName = getCameraErrorName(err);
return errorName === 'OverconstrainedError' || errorName === 'ConstraintNotSatisfiedError';
return errorName === "OverconstrainedError" || errorName === "ConstraintNotSatisfiedError";
};
const getRejectedCameraConstraintName = (err: unknown): string => {
if (typeof err !== 'object' || err === null) {
return '';
if (typeof err !== "object" || err === null) {
return "";
}
return String((err as { constraint?: unknown }).constraint ?? '').toLowerCase();
return String((err as { constraint?: unknown }).constraint ?? "").toLowerCase();
};
const getCameraConstraintFallbacks = (err: unknown): CameraConstraintCaps[] => {
const rejectedConstraint = getRejectedCameraConstraintName(err);
if (rejectedConstraint === 'framerate') {
if (rejectedConstraint === "framerate") {
return [
{ capFrameRate: false, capResolution: true },
{ capFrameRate: false, capResolution: false },
];
}
if (['width', 'height', 'aspectratio', 'resizemode'].includes(rejectedConstraint)) {
if (["width", "height", "aspectratio", "resizemode"].includes(rejectedConstraint)) {
return [
{ capFrameRate: true, capResolution: false },
{ capFrameRate: false, capResolution: false },
@@ -119,38 +121,38 @@ const getCameraConstraintFallbacks = (err: unknown): CameraConstraintCaps[] => {
];
};
const getCameraConstraints = ({
capFrameRate,
capResolution,
}: CameraConstraintCaps = { capFrameRate: true, capResolution: true }) => ({
video: {
facingMode: 'environment',
zoom: camera.getZoom(),
width: capResolution
? { ideal: LPR_CAMERA_VIDEO_WIDTH, max: LPR_CAMERA_VIDEO_WIDTH }
: { ideal: LPR_CAMERA_VIDEO_WIDTH },
height: capResolution
? { ideal: LPR_CAMERA_VIDEO_HEIGHT, max: LPR_CAMERA_VIDEO_HEIGHT }
: { ideal: LPR_CAMERA_VIDEO_HEIGHT },
frameRate: capFrameRate
? { ideal: LPR_CAMERA_FRAME_RATE, max: LPR_CAMERA_FRAME_RATE }
: { ideal: LPR_CAMERA_FRAME_RATE },
const getCameraConstraints = (
{ capFrameRate, capResolution }: CameraConstraintCaps = { capFrameRate: true, capResolution: true }
) =>
({
video: {
facingMode: "environment",
zoom: camera.getZoom(),
width: capResolution
? { ideal: LPR_CAMERA_VIDEO_WIDTH, max: LPR_CAMERA_VIDEO_WIDTH }
: { ideal: LPR_CAMERA_VIDEO_WIDTH },
height: capResolution
? { ideal: LPR_CAMERA_VIDEO_HEIGHT, max: LPR_CAMERA_VIDEO_HEIGHT }
: { ideal: LPR_CAMERA_VIDEO_HEIGHT },
frameRate: capFrameRate
? { ideal: LPR_CAMERA_PREVIEW_FRAME_RATE, max: LPR_CAMERA_PREVIEW_FRAME_RATE }
: { ideal: LPR_CAMERA_PREVIEW_FRAME_RATE },
// New spec
advanced: [
{ focusMode: 'continuous' },
{ torch: false } // Set to true to enable flashlight if supported
],
// Old spec
//focusMode: 'continuous',
// Zoom in on the environment camera if available
//facingMode: 'environment',
//width: { ideal: 1920 },
//height: { ideal: 1080 },
//aspectRatio: { ideal: 16/9 },
//frameRate: { ideal: 30 }
}
} as unknown as GetUserMediaConstraints);
// New spec
advanced: [
{ focusMode: "continuous" },
{ torch: false }, // Set to true to enable flashlight if supported
],
// Old spec
//focusMode: 'continuous',
// Zoom in on the environment camera if available
//facingMode: 'environment',
//width: { ideal: 1920 },
//height: { ideal: 1080 },
//aspectRatio: { ideal: 16/9 },
//frameRate: { ideal: 30 }
},
} as unknown as GetUserMediaConstraints);
const requestCameraStream = async (): Promise<MediaStream> => {
try {
@@ -183,7 +185,7 @@ const playVideoPreview = (video: HTMLVideoElement) => {
hasRequestedVideoPreviewPlay = true;
const playResult = video.play();
if (playResult && typeof playResult.catch === 'function') {
if (playResult && typeof playResult.catch === "function") {
void playResult.catch(() => {
hasRequestedVideoPreviewPlay = false;
});
@@ -204,11 +206,11 @@ const getCameraVideoTracks = (): MediaStreamTrack[] => {
return [];
}
if (typeof cameraStream.value.getVideoTracks === 'function') {
if (typeof cameraStream.value.getVideoTracks === "function") {
return cameraStream.value.getVideoTracks();
}
return cameraStream.value.getTracks().filter(track => track.kind === 'video');
return cameraStream.value.getTracks().filter((track) => track.kind === "video");
};
const syncCameraVideoTracksEnabled = () => {
@@ -252,7 +254,7 @@ const applyCameraStream = (stream: MediaStream) => {
lastAppliedTrackEnabled = null;
if (videoRef.value) {
videoRef.value.srcObject = stream;
videoRef.value.setAttribute('playsinline', '');
videoRef.value.setAttribute("playsinline", "");
syncVideoPreviewPlayback();
}
startCaptureTimers();
@@ -264,12 +266,12 @@ function startCamera() {
}
requestCameraStream()
.then(applyCameraStream)
.catch((err) => {
isCameraActive.value = false;
cameraErrorKey.value = getCameraErrorKey(err);
console.error('Camera access error:', err);
});
.then(applyCameraStream)
.catch((err) => {
isCameraActive.value = false;
cameraErrorKey.value = getCameraErrorKey(err);
console.error("Camera access error:", err);
});
}
function clearCaptureInterval() {
@@ -300,12 +302,12 @@ function captureFrameIfReady(): Promise<void> {
clearFirstCaptureTimeout();
return getFrame()
.then(() => undefined)
.finally(() => {
if (canCaptureFrames()) {
startCaptureInterval();
}
});
.then(() => undefined)
.finally(() => {
if (canCaptureFrames()) {
startCaptureInterval();
}
});
}
return Promise.resolve();
@@ -384,7 +386,7 @@ const observeVideoGeometry = () => {
videoResizeObserver?.disconnect();
videoResizeObserver = null;
if (typeof ResizeObserver === 'undefined' || !videoRef.value) {
if (typeof ResizeObserver === "undefined" || !videoRef.value) {
return;
}
@@ -399,7 +401,7 @@ function stopCamera() {
hasRequestedVideoPreviewPlay = false;
lastAppliedTrackEnabled = null;
if (cameraStream.value) {
cameraStream.value.getTracks().forEach(track => track.stop());
cameraStream.value.getTracks().forEach((track) => track.stop());
cameraStream.value = null;
}
isCameraActive.value = false;
@@ -436,13 +438,10 @@ function handleVisibilityChange() {
syncVideoPreviewPlayback();
}
const shouldUseFocusedLPRCrop = () => props.captureMode === 'lpr';
const shouldUseFocusedLPRCrop = () => props.captureMode === "lpr";
const hasUsableRectSize = (rect: { height: number; width: number }): boolean =>
Number.isFinite(rect.width)
&& Number.isFinite(rect.height)
&& rect.width > 0
&& rect.height > 0;
Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0;
const getVideoViewportRect = (): DOMRect | null => {
if (!videoRef.value) {
@@ -502,9 +501,9 @@ const getFrameCaptureOptions = () => {
shouldEncode: shouldUseFocusedCrop ? props.shouldEncodeFrame : undefined,
...(videoViewportRect
? {
viewportHeight: videoViewportRect.height,
viewportWidth: videoViewportRect.width,
}
viewportHeight: videoViewportRect.height,
viewportWidth: videoViewportRect.width,
}
: {}),
visualFingerprintCanvas: shouldUseFocusedCrop ? visualFingerprintCanvasRef.value : null,
};
@@ -521,72 +520,84 @@ const getFrame = () => {
isFrameCaptureInProgress = true;
return captureVideoFrameBlobForLPR(videoRef.value, canvas, getFrameCaptureOptions())
.then((frameData) => {
if (frameData && canCaptureFrames()) {
emits('update:frame', frameData);
}
.then((frameData) => {
if (frameData && canCaptureFrames()) {
emits("update:frame", frameData);
}
return nextTick().then(() => frameData);
})
.finally(() => {
isFrameCaptureInProgress = false;
syncVideoPreviewPlayback();
});
return nextTick().then(() => frameData);
})
.finally(() => {
isFrameCaptureInProgress = false;
syncVideoPreviewPlayback();
});
}
}
return Promise.resolve(null);
};
// Watch for zoom level changes
watch(() => camera.getZoom(), () => {
if (isCameraActive.value) {
stopCamera();
startCamera();
watch(
() => camera.getZoom(),
() => {
if (isCameraActive.value) {
stopCamera();
startCamera();
}
}
});
);
watch(() => props.captureEnabled, (isCaptureEnabled) => {
if (isCaptureEnabled) {
resumeCaptureTimers(0);
} else {
pauseCaptureTimers();
watch(
() => props.captureEnabled,
(isCaptureEnabled) => {
if (isCaptureEnabled) {
resumeCaptureTimers(0);
} else {
pauseCaptureTimers();
}
}
});
);
watch([() => props.captureMode, () => props.getFocusViewportRect], clearRelativeFocusViewportRectCache);
watch(() => props.captureIntervalMs, () => {
if (canCaptureFrames() && !isFrameCaptureInProgress && firstCaptureTimeoutId === null) {
startCaptureInterval();
watch(
() => props.captureIntervalMs,
() => {
if (canCaptureFrames() && !isFrameCaptureInProgress && firstCaptureTimeoutId === null) {
startCaptureInterval();
}
}
});
);
watch(() => props.pausePreview, (isPreviewPaused) => {
syncVideoPreviewPlayback();
watch(
() => props.pausePreview,
(isPreviewPaused) => {
syncVideoPreviewPlayback();
if (isPreviewPaused) {
pauseCaptureTimers();
return;
if (isPreviewPaused) {
pauseCaptureTimers();
return;
}
resumeCaptureTimers(LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS);
}
resumeCaptureTimers(LPR_PREVIEW_RESUME_CAPTURE_DELAY_MS);
});
);
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange);
document.addEventListener('scroll', clearRelativeFocusViewportRectCache, true);
window.addEventListener('orientationchange', clearRelativeFocusViewportRectCache);
window.addEventListener('resize', clearRelativeFocusViewportRectCache);
document.addEventListener("visibilitychange", handleVisibilityChange);
document.addEventListener("scroll", clearRelativeFocusViewportRectCache, true);
window.addEventListener("orientationchange", clearRelativeFocusViewportRectCache);
window.addEventListener("resize", clearRelativeFocusViewportRectCache);
observeVideoGeometry();
isCameraMounted.value = true;
startCamera();
});
onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('scroll', clearRelativeFocusViewportRectCache, true);
window.removeEventListener('orientationchange', clearRelativeFocusViewportRectCache);
window.removeEventListener('resize', clearRelativeFocusViewportRectCache);
document.removeEventListener("visibilitychange", handleVisibilityChange);
document.removeEventListener("scroll", clearRelativeFocusViewportRectCache, true);
window.removeEventListener("orientationchange", clearRelativeFocusViewportRectCache);
window.removeEventListener("resize", clearRelativeFocusViewportRectCache);
videoResizeObserver?.disconnect();
videoResizeObserver = null;
clearRelativeFocusViewportRectCache();
@@ -600,34 +611,29 @@ defineExpose({
stopCamera,
});
watch(() => isCameraActive.value, (newVal) => {
emits('camera-toggled', newVal);
});
watch(
() => isCameraActive.value,
(newVal) => {
emits("camera-toggled", newVal);
}
);
</script>
<template>
<div class="scanner-camera">
<video
ref="videoRef"
autoplay
playsinline
:class="{ 'is-active': isCameraActive }"
@loadeddata="handleVideoLoadedData"
ref="videoRef"
autoplay
playsinline
:class="{ 'is-active': isCameraActive }"
@loadeddata="handleVideoLoadedData"
>
Your browser does not support the video tag.
</video>
<canvas
ref="canvasRef"
class="capture-canvas"
></canvas>
<canvas ref="canvasRef" class="capture-canvas"></canvas>
<canvas
ref="visualFingerprintCanvasRef"
class="visual-fingerprint-canvas"
aria-hidden="true"
></canvas>
<canvas ref="visualFingerprintCanvasRef" class="visual-fingerprint-canvas" aria-hidden="true"></canvas>
<div v-if="!isCameraActive" class="camera-inactive">
<p>{{ t(cameraErrorKey) }}</p>
@@ -686,7 +692,7 @@ video {
}
.capture-button {
background: var(--primary-color, #4CAF50);
background: var(--primary-color, #4caf50);
color: white;
border: none;
padding: 0.8rem 1.5rem;
@@ -1,6 +1,5 @@
export const LPR_CAMERA_VIDEO_WIDTH = 1024;
export const LPR_CAMERA_VIDEO_HEIGHT = 576;
export const LPR_CAMERA_FRAME_RATE = 5;
export const LPR_FRAME_MAX_WIDTH = 1024;
export const LPR_FRAME_MAX_HEIGHT = 576;
export const LPR_FRAME_SCANNER_MAX_SIZE = 384;
@@ -132,6 +132,7 @@ const loadOrder = async () => {
setOrderId(orderId.value, {
departmentId: response.data.data.department_id,
syncDepartmentWithSelection: false,
loadItems: false,
});
await fetchAttachments(orderId.value);
isLoading.value = false;
@@ -343,7 +344,6 @@ const isShowingPrintReceipt = ref(false);
// Load the order, when the page is loaded
onMounted(async () => {
await loadOrder();
await loadOrderItems();
await getInvoiceCollection();
// Check if the query parameter "print_receipt" is set to true
@@ -32,7 +32,6 @@ export const applyPosRouteSearch = (
search,
{
setOrderId,
loadOrderItems,
setStep,
searchAndSelectCustomer,
clearActivePosOrderContext,
@@ -82,9 +81,6 @@ export const applyPosRouteSearch = (
if (parsedState.orderId !== null) {
setOrderId(parsedState.orderId);
if (typeof loadOrderItems === 'function') {
loadOrderItems();
}
}
if (parsedState.step !== null) {
+3
View File
@@ -1098,6 +1098,9 @@ test.describe("Edge gateway management smoke", () => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
discoveryAutoCompleteFetches: false,
},
});
await primeSuperuserSession(page);
+1 -4
View File
@@ -26,6 +26,7 @@ const isBenignNavigationError = (error: unknown) => {
return (
message.includes("interrupted by another navigation") ||
message.includes("ERR_ABORTED") ||
message.includes("NS_BINDING_ABORTED") ||
message.includes("Frame load interrupted")
);
};
@@ -38,10 +39,6 @@ function fulfillJson(route: Route, body: unknown, status = 200) {
});
}
function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
async function hasConnectivityIssue(page: Page) {
return page
.getByText(CONNECTIVITY_ISSUE_PATTERN)
+21 -6
View File
@@ -57,6 +57,20 @@ async function prepareInvoiceDistributionPage(page, overrides = {}) {
await primeSuperuserSession(page);
}
async function gotoInvoiceDistribution(page, url) {
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
await page.goto(url);
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (attempt > 0 || !message.includes("WebKit encountered an internal error")) {
throw error;
}
}
}
}
test.describe("Invoice distribution smoke", () => {
test("@smoke @pr overview loads and quick-open month action works", async ({ page }) => {
const componentWarnings = [];
@@ -68,7 +82,7 @@ test.describe("Invoice distribution smoke", () => {
});
await prepareInvoiceDistributionPage(page);
await page.goto("/superuser/invoices?activeTab=distribution");
await gotoInvoiceDistribution(page, "/superuser/invoices?activeTab=distribution");
await expect(page.getByTestId("distribution-overview-page")).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-testid="distribution-overview-page"] h2').first()).toBeVisible();
@@ -95,7 +109,8 @@ test.describe("Invoice distribution smoke", () => {
test("@smoke monthly tabs keep URL query-state in sync", async ({ page }) => {
await prepareInvoiceDistributionPage(page);
await page.goto(
await gotoInvoiceDistribution(
page,
"/superuser/invoices/distribution/2026/3/customers?customerSearch=acme&customerSource=fixed_pricing&customerDepartment=Copenhagen&compareMode=line_by_line"
);
@@ -118,7 +133,7 @@ test.describe("Invoice distribution smoke", () => {
test("@smoke compare flow shows progress and mismatch-first results", async ({ page }) => {
await prepareInvoiceDistributionPage(page);
await page.goto("/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
await gotoInvoiceDistribution(page, "/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
await page.getByTestId("distribution-compare-submit").click();
const compareTable = page.getByTestId("distribution-compare-table");
@@ -139,7 +154,7 @@ test.describe("Invoice distribution smoke", () => {
test("@smoke mobile layout sanity keeps primary controls visible", async ({ page }) => {
await prepareInvoiceDistributionPage(page);
await page.goto("/superuser/invoices/distribution/2026/3/overview");
await gotoInvoiceDistribution(page, "/superuser/invoices/distribution/2026/3/overview");
await expect(page.getByTestId("distribution-month-toolbar")).toBeVisible();
await expect(page.getByTestId("distribution-month-prev")).toBeVisible();
@@ -155,12 +170,12 @@ test.describe("Invoice distribution smoke", () => {
invoiceDistributionForceCompareFallback: true,
});
await page.goto("/superuser/invoices?activeTab=distribution");
await gotoInvoiceDistribution(page, "/superuser/invoices?activeTab=distribution");
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible({
timeout: 15_000,
});
await page.goto("/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
await gotoInvoiceDistribution(page, "/superuser/invoices/distribution/2026/3/compare?compareMode=line_by_line");
await expect(page.locator(".message.is-warning").filter({ hasText: /v2/i }).first()).toBeVisible({
timeout: 15_000,
});
+1 -1
View File
@@ -87,7 +87,7 @@ test("non-default release channel keeps the regular frontend and dynamic API run
expect(page.url()).toContain("/shared/passkey-safe-link");
expect(page.url()).not.toContain("api-v2.truckwash.io");
expect(runtimeRequests).toHaveLength(1);
await expect.poll(() => runtimeRequests.length).toBe(1);
const runtimeRequestUrl = new URL(runtimeRequests[0]);
expect(["/api/release/runtime", "/master/api/release/runtime"]).toContain(runtimeRequestUrl.pathname);
expect(runtimeRequestUrl.pathname).not.toBe("/canary/api/release/runtime");
+3 -1
View File
@@ -1808,7 +1808,7 @@ function settleEdgeGatewayWork(edgeGatewayFixture, gatewayId) {
const pendingDiscovery = edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId];
if (pendingDiscovery) {
pendingDiscovery.fetchCount = (pendingDiscovery.fetchCount || 0) + 1;
if (pendingDiscovery.fetchCount >= 2) {
if (pendingDiscovery.fetchCount >= edgeGatewayFixture.discoveryAutoCompleteFetches) {
gateway.discovery_status = "READY";
gateway.last_successful_discovery_at = toSqlDateTime();
if (!gateway.inventory.some((device) => device.device_id === pendingDiscovery.device.device_id)) {
@@ -2548,6 +2548,8 @@ function createHttpEdgeGatewayFixture(options = {}) {
options.installSessionFailure && typeof options.installSessionFailure === "object"
? cloneJson(options.installSessionFailure)
: null,
discoveryAutoCompleteFetches:
options.discoveryAutoCompleteFetches === false ? Infinity : Number(options.discoveryAutoCompleteFetches || 2),
config: {
enabled: options.config?.enabled ?? true,
default_release_channel: options.config?.default_release_channel || "stable",
+1
View File
@@ -120,6 +120,7 @@ function isBenignNavigationError(error) {
return (
message.includes("interrupted by another navigation") ||
message.includes("ERR_ABORTED") ||
message.includes("NS_BINDING_ABORTED") ||
message.includes("Frame load interrupted")
);
}
+1
View File
@@ -48,6 +48,7 @@ function isBenignNavigationError(error) {
return (
message.includes("interrupted by another navigation") ||
message.includes("ERR_ABORTED") ||
message.includes("NS_BINDING_ABORTED") ||
message.includes("Frame load interrupted")
);
}
+4 -2
View File
@@ -28,6 +28,8 @@ test("[PAGES][User][/user/bookings] should display the title", async ({ page })
});
test("[BOOKINGS][User][Mobile] should keep booking overview cards within the viewport", async ({ page }) => {
const today = new Date().toISOString().split("T")[0];
await mockApi(page, {
authenticated: true,
sessionData: {
@@ -42,7 +44,7 @@ test("[BOOKINGS][User][Mobile] should keep booking overview cards within the vie
customer_number: 12345679,
customer_name: "Pleno Logistics ApS",
department: 12,
datetime: "2026-05-28 08:30:00",
datetime: `${today} 08:30:00`,
reg_1: "AB12345",
reg_2: "CD67890",
reference: "Lang intern reference der tidligere pressede kortet ud over kanten",
@@ -65,7 +67,7 @@ test("[BOOKINGS][User][Mobile] should keep booking overview cards within the vie
customer_number: 12345679,
customer_name: "Pleno Logistics ApS",
department: 1,
datetime: "2026-05-28 14:15:00",
datetime: `${today} 14:15:00`,
reg_1: "EF24680",
reg_2: "",
reference: "",
@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sessionUserRequestMock = vi.hoisted(() =>
vi.fn(async () => ({
data: {
data: [{ id: 301, file_name: "invoice.pdf" }],
},
}))
);
vi.mock("sweetalert2", () => ({
default: {
fire: vi.fn(() => Promise.resolve()),
close: vi.fn(),
},
}));
vi.mock("@/i18n", () => ({
default: {
global: {
t: (key) => key,
},
},
}));
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
objects: {
orders: {
functions: {
get_customer_id: vi.fn(),
},
set: {
customer_id: vi.fn(),
invoice_collection_id: vi.fn(),
},
},
collectedOrderInvoices: {
functions: {
showInvoiceCollectionPickerForm: vi.fn(),
},
},
},
request: sessionUserRequestMock,
functions: {
parseErrorMessage: vi.fn(() => "error"),
},
},
}));
vi.mock("@/components/session/authenticatedRequest.vue", () => ({
authenticatedRequest: vi.fn(),
}));
import { Orders } from "@/components/session/token/SessionUser/Objects/Orders.vue";
describe("Orders attachments requests", () => {
beforeEach(() => {
sessionUserRequestMock.mockClear();
});
it("limits order attachment list GET requests without assigning a queue group", async () => {
await expect(Orders.functions.fetchAttachments(42)).resolves.toEqual([{ id: 301, file_name: "invoice.pdf" }]);
expect(sessionUserRequestMock).toHaveBeenCalledTimes(1);
expect(sessionUserRequestMock).toHaveBeenCalledWith("/orders/attachments", "GET", { id: 42 }, null, null, {
concurrencyLimit: 5,
});
});
});
@@ -1,12 +1,22 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import fs from "node:fs/promises";
import { readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import {
browserEngines,
classifyTest,
deviceClasses,
fullSuiteProjects,
getLegacyTestListPath,
getPrimaryTestListPath,
ownedFilesByRole,
parseListedTests,
roles,
titleRules,
writeTestList,
} from "../../scripts/run-playwright-full-slice.mjs";
const root = process.cwd();
@@ -15,8 +25,35 @@ const titleRuleFiles = new Set(titleRules.map((rule) => rule.file));
const e2eSpecFiles = readdirSync(join(root, "tests/e2e"))
.filter((file) => /\.spec\.(?:js|ts)$/u.test(file))
.sort();
const generatedTestListPaths = [];
const generatedTestListDirectories = [];
describe("Playwright full-slice ownership", () => {
afterEach(async () => {
await Promise.all(generatedTestListPaths.splice(0).map((filePath) => fs.rm(filePath, { force: true })));
await Promise.all(
generatedTestListDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))
);
delete process.env.PLAYWRIGHT_TEST_LIST_DIR;
});
it("keeps full-suite slices ordered by browser, device, then role", () => {
expect(browserEngines).toEqual(["chromium", "webkit", "firefox"]);
expect(deviceClasses).toEqual(["mobile", "desktop", "tablet"]);
expect(roles).toEqual(["superuser", "admin", "customer", "subuser"]);
expect(fullSuiteProjects).toEqual([
"chromium-mobile",
"chromium-desktop",
"chromium-tablet",
"webkit-mobile",
"webkit-desktop",
"webkit-tablet",
"firefox-mobile",
"firefox-desktop",
"firefox-tablet",
]);
});
it("assigns every top-level e2e spec to one role or a title rule", () => {
const directOwners = new Map();
@@ -65,7 +102,7 @@ describe("Playwright full-slice ownership", () => {
it("classifies every listed test before role filtering", () => {
const listOutput = execFileSync(
process.execPath,
[playwrightCliPath, "test", "--list", "--project=chromium-desktop"],
[playwrightCliPath, "test", "--list", "--reporter=list", "--project=chromium-desktop"],
{
cwd: root,
encoding: "utf8",
@@ -84,4 +121,33 @@ describe("Playwright full-slice ownership", () => {
expect(classificationErrors).toEqual([]);
});
it("writes project-role test lists and a legacy compatibility copy", async () => {
const testListDirectory = await fs.mkdtemp(join(tmpdir(), "playwright-test-lists-"));
process.env.PLAYWRIGHT_TEST_LIST_DIR = testListDirectory;
generatedTestListDirectories.push(testListDirectory);
const matchingTests = [
{
listLine: "[webkit-tablet] tests/e2e/admin-pos-orders.spec.ts:10:1 admin order list",
},
];
const primaryPath = getPrimaryTestListPath("webkit-tablet", "admin");
const legacyPath = getLegacyTestListPath("admin", "webkit-tablet");
generatedTestListPaths.push(primaryPath, legacyPath);
await expect(writeTestList("admin", "webkit-tablet", matchingTests)).resolves.toBe(primaryPath);
expect(readFileSync(primaryPath, "utf8")).toBe(`${matchingTests[0].listLine}\n`);
expect(readFileSync(legacyPath, "utf8")).toBe(`${matchingTests[0].listLine}\n`);
});
});
describe("Playwright full-suite project order", () => {
it("keeps playwright.config projects ordered by browser engine and device class", () => {
const source = readFileSync(join(root, "playwright.config.ts"), "utf8");
const projectNames = [...source.matchAll(/buildProject\("([^"]+)"/gu)].map((match) => match[1]);
expect(projectNames).toEqual(fullSuiteProjects);
});
});
@@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const workflowSource = () => readFileSync(join(root, ".github/workflows/tests.yml"), "utf8");
describe("Playwright full E2E workflow grouping", () => {
it("orders full-suite matrix dimensions by browser, device, then role", () => {
const source = workflowSource();
expect(source).toContain("name: E2E-full-${{ matrix.browser_label }}-${{ matrix.device }}-${{ matrix.role }}");
expect(source).toContain("browser: [chromium, webkit, firefox]");
expect(source).toContain("device: [mobile, desktop, tablet]");
expect(source).toContain("role: [superuser, admin, customer, subuser]");
});
it("uses browser-device-role artifact namespaces and generated test lists", () => {
const source = workflowSource();
expect(source).toContain(
"PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}"
);
expect(source).toContain(
"name: playwright-report-full-${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}"
);
expect(source).toContain(
"output/playwright/test-lists/${{ matrix.browser }}-${{ matrix.device }}-${{ matrix.role }}.txt"
);
});
});
+17
View File
@@ -165,6 +165,7 @@ import {
pendingBookings,
restoreStoredPosOrderId,
searchAndSelectCustomer,
setOrderId,
scan_data,
scans,
selectedOrderBookingId,
@@ -274,6 +275,22 @@ describe("POSDepartmentProcess.loadOrderItems", () => {
});
});
describe("POSDepartmentProcess.setOrderId", () => {
beforeEach(() => {
localStorage.clear();
order_id.value = null;
order_items.value = [];
mocks.getOrderItems.mockReset();
});
it("can skip loading order items when the caller already has them", () => {
setOrderId(9201, { loadItems: false });
expect(order_id.value).toBe(9201);
expect(mocks.getOrderItems).not.toHaveBeenCalled();
});
});
describe("POSDepartmentProcess.searchAndSelectCustomer", () => {
beforeEach(() => {
clearCustomerSelection();
+12 -12
View File
@@ -408,7 +408,7 @@ describe("POS mobile camera LPR", () => {
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
const cameraWrapper = wrapper.findComponent({ name: "ScannerCamera" });
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
let rawRequest = await expectRawLPRRequest(0, "frame-a");
expect(rawRequest.searchParams.get(LPR_FRAME_CLIENT_CAPTURE_MS_FIELD)).toBe("12.345");
expect(Number(rawRequest.searchParams.get(LPR_FRAME_CLIENT_PREFLIGHT_MS_FIELD))).toBeGreaterThanOrEqual(0);
@@ -429,7 +429,7 @@ describe("POS mobile camera LPR", () => {
resolveFirstRequest({ data: { success: false } });
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
vi.advanceTimersByTime(1500);
await flushPromises();
expect(cameraWrapper.props("pausePreview")).toBe(false);
@@ -465,7 +465,7 @@ describe("POS mobile camera LPR", () => {
expect(searchParams.has(LPR_FRAME_CLIENT_VISUAL_FINGERPRINT_MS_FIELD)).toBe(false);
});
it("pauses the camera preview while scanner duplicate preflight is still running", async () => {
it("keeps the camera preview live while scanner duplicate preflight is still running", async () => {
vi.useFakeTimers();
mocks.request.mockResolvedValue({ data: { success: false } });
@@ -476,7 +476,7 @@ describe("POS mobile camera LPR", () => {
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
vi.advanceTimersByTime(2500);
await flushPromises();
@@ -495,13 +495,13 @@ describe("POS mobile camera LPR", () => {
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
resolveContentFingerprint("frame-a-new-content");
await flushPromises();
expect(mocks.request).toHaveBeenCalledTimes(2);
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
vi.advanceTimersByTime(2500);
await flushPromises();
@@ -584,7 +584,7 @@ describe("POS mobile camera LPR", () => {
expect(mocks.request).not.toHaveBeenCalled();
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
});
it("does not capture or post LPR frames while the active registration slot is already filled", async () => {
@@ -599,7 +599,7 @@ describe("POS mobile camera LPR", () => {
expect(mocks.request).not.toHaveBeenCalled();
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
});
it("resumes camera LPR capture after the active registration slot moves to an empty slot", async () => {
@@ -628,7 +628,7 @@ describe("POS mobile camera LPR", () => {
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
@@ -665,7 +665,7 @@ describe("POS mobile camera LPR", () => {
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
@@ -742,7 +742,7 @@ describe("POS mobile camera LPR", () => {
expect(mocks.cameraSetLastSuccess).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).toHaveBeenCalledTimes(1);
expect(mocks.vehicleSelect).not.toHaveBeenCalled();
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
await wrapper.get('[data-testid="camera-frame-b"]').trigger("click");
await flushPromises();
@@ -1005,7 +1005,7 @@ describe("POS mobile camera LPR", () => {
expect(mocks.request).toHaveBeenCalledTimes(1);
expect(mocks.cameraSetLatestImageBlob).not.toHaveBeenCalled();
expect(cameraWrapper.props("captureEnabled")).toBe(false);
expect(cameraWrapper.props("pausePreview")).toBe(true);
expect(cameraWrapper.props("pausePreview")).toBe(false);
vi.advanceTimersByTime(699);
await flushPromises();
+2 -2
View File
@@ -55,7 +55,7 @@ describe("applyPosRouteSearch", () => {
step: 2,
});
expect(handlers.setOrderId).toHaveBeenCalledWith(9201);
expect(handlers.loadOrderItems).toHaveBeenCalledTimes(1);
expect(handlers.loadOrderItems).not.toHaveBeenCalled();
expect(handlers.setStep).toHaveBeenCalledWith(2);
expect(handlers.searchAndSelectCustomer).toHaveBeenCalledWith(12345, { forceRefresh: true });
expect(handlers.clearActivePosOrderContext).not.toHaveBeenCalled();
@@ -155,7 +155,7 @@ describe("applyPosRouteSearch", () => {
step: 3,
});
expect(handlers.setOrderId).toHaveBeenCalledWith(9201);
expect(handlers.loadOrderItems).toHaveBeenCalledTimes(1);
expect(handlers.loadOrderItems).not.toHaveBeenCalled();
expect(handlers.setStep).toHaveBeenCalledWith(3);
expect(handlers.searchAndSelectCustomer).toHaveBeenCalledWith(12345, { forceRefresh: true });
expect(handlers.clearActivePosOrderContext).not.toHaveBeenCalled();
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
__configureRequestQueueForTests,
__resetRequestQueueForTests,
enqueueRequest,
requestQueueState,
} from "@/services/requestQueue.js";
const flushManyMicrotasks = async (rounds = 10) => {
for (let index = 0; index < rounds; index += 1) {
await Promise.resolve();
}
};
const createDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
describe("request queue concurrency limits", () => {
beforeEach(() => {
__resetRequestQueueForTests();
__configureRequestQueueForTests({ maxConcurrentGet: 10, maxConcurrentOther: 1, spacingMs: 0 });
});
afterEach(() => {
__resetRequestQueueForTests();
});
it("keeps attachment GET preloads from filling the shared GET pool", async () => {
const attachmentDeferreds = Array.from({ length: 10 }, () => createDeferred());
const ordinaryGet = createDeferred();
const startedAttachments = [];
let ordinaryStarted = false;
const attachmentRequests = attachmentDeferreds.map((deferred, index) =>
enqueueRequest(
() => {
startedAttachments.push(index);
return deferred.promise;
},
{
method: "GET",
url: "/orders/attachments",
requestData: { params: { id: index + 1 } },
concurrencyLimit: 5,
}
)
);
const ordinaryRequest = enqueueRequest(
() => {
ordinaryStarted = true;
return ordinaryGet.promise;
},
{
method: "GET",
url: "/departments",
}
);
await flushManyMicrotasks();
expect(startedAttachments).toEqual([0, 1, 2, 3, 4]);
expect(ordinaryStarted).toBe(true);
expect(requestQueueState.active).toBe(6);
expect(requestQueueState.pending).toBe(5);
for (let index = 0; index < 5; index += 1) {
attachmentDeferreds[index].resolve({ status: 200, data: { id: index + 1 } });
}
ordinaryGet.resolve({ status: 200, data: { ok: true } });
await flushManyMicrotasks();
expect(startedAttachments).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
expect(requestQueueState.active).toBe(5);
expect(requestQueueState.pending).toBe(0);
for (let index = 5; index < 10; index += 1) {
attachmentDeferreds[index].resolve({ status: 200, data: { id: index + 1 } });
}
await expect(Promise.all([...attachmentRequests, ordinaryRequest])).resolves.toHaveLength(11);
});
});
@@ -21,7 +21,6 @@ vi.mock("@/components/viewport/page/templates/scanner/lprFrameCapture", () => ({
getLPRFrameSourceRect: mocks.getLPRFrameSourceRect,
getLPRFrameTargetSize: mocks.getLPRFrameTargetSize,
isVideoFrameReadyForLPR: mocks.isVideoFrameReadyForLPR,
LPR_CAMERA_FRAME_RATE: 5,
LPR_CAMERA_VIDEO_HEIGHT: 576,
LPR_CAMERA_VIDEO_WIDTH: 1024,
}));
@@ -386,7 +385,7 @@ describe("ScannerCamera capture gating", () => {
expect(mocks.getUserMedia).toHaveBeenCalledWith(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5, max: 5 },
frameRate: { ideal: 30, max: 30 },
height: { ideal: 576, max: 576 },
width: { ideal: 1024, max: 1024 },
}),
@@ -414,7 +413,7 @@ describe("ScannerCamera capture gating", () => {
expect(mocks.getUserMedia.mock.calls[0][0]).toEqual(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5, max: 5 },
frameRate: { ideal: 30, max: 30 },
height: { ideal: 576, max: 576 },
width: { ideal: 1024, max: 1024 },
}),
@@ -423,7 +422,7 @@ describe("ScannerCamera capture gating", () => {
expect(mocks.getUserMedia.mock.calls[1][0]).toEqual(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5 },
frameRate: { ideal: 30 },
height: { ideal: 576, max: 576 },
width: { ideal: 1024, max: 1024 },
}),
@@ -454,7 +453,7 @@ describe("ScannerCamera capture gating", () => {
expect(mocks.getUserMedia.mock.calls[0][0]).toEqual(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5, max: 5 },
frameRate: { ideal: 30, max: 30 },
height: { ideal: 576, max: 576 },
width: { ideal: 1024, max: 1024 },
}),
@@ -463,13 +462,13 @@ describe("ScannerCamera capture gating", () => {
expect(mocks.getUserMedia.mock.calls[1][0]).toEqual(
expect.objectContaining({
video: expect.objectContaining({
frameRate: { ideal: 5, max: 5 },
frameRate: { ideal: 30, max: 30 },
height: { ideal: 576 },
width: { ideal: 1024 },
}),
})
);
expect(mocks.getUserMedia.mock.calls[1][0].video.frameRate).toHaveProperty("max", 5);
expect(mocks.getUserMedia.mock.calls[1][0].video.frameRate).toHaveProperty("max", 30);
expect(mocks.getUserMedia.mock.calls[1][0].video.height).not.toHaveProperty("max");
expect(mocks.getUserMedia.mock.calls[1][0].video.width).not.toHaveProperty("max");