Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53315bbb56 | ||
|
|
c88f5442ab | ||
|
|
7f347b6c90 | ||
|
|
e607ac8232 | ||
|
|
79b4441ffc | ||
|
|
2594cd475f | ||
|
|
74b808ada0 | ||
|
|
4fdbd22879 | ||
|
|
0392672a2d | ||
|
|
3d229a2de2 | ||
|
|
7e090ab405 | ||
|
|
b8c3758714 | ||
|
|
298fc0e04b | ||
|
|
1c1bffaee4 | ||
|
|
14506e545a | ||
|
|
89dff75416 | ||
|
|
8a790582f4 |
+193
-62
@@ -13,7 +13,7 @@ permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
|
||||
group: frontend-tests-${{ github.workflow }}-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -22,6 +22,20 @@ jobs:
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -44,6 +58,20 @@ jobs:
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -73,7 +101,7 @@ jobs:
|
||||
if: github.event_name != 'schedule'
|
||||
needs: build-and-unit
|
||||
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend, docker]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -85,6 +113,20 @@ jobs:
|
||||
PLAYWRIGHT_ARTIFACT_NAMESPACE: e2e-pr-${{ matrix.suite }}-${{ matrix.project }}
|
||||
PLAYWRIGHT_REPORTER_MODE: line-html
|
||||
steps:
|
||||
- name: Repair self-hosted workspace permissions
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
@@ -118,21 +160,16 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: node scripts/install-playwright-browsers.mjs chromium
|
||||
|
||||
- name: Set Playwright dev server port
|
||||
- name: Run Playwright PR suite in container
|
||||
shell: bash
|
||||
env:
|
||||
DIFF_BASE_REF: ${{ steps.playwright-diff.outputs.base }}
|
||||
DIFF_HEAD_REF: ${{ steps.playwright-diff.outputs.head }}
|
||||
MATRIX_SUITE: ${{ matrix.suite }}
|
||||
MATRIX_PROJECT: ${{ matrix.project }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
workflow_offset=$(( (RUN_ID % 90) * 600 ))
|
||||
case "$MATRIX_SUITE" in
|
||||
core) suite_offset=0 ;;
|
||||
changed) suite_offset=10 ;;
|
||||
@@ -143,25 +180,65 @@ jobs:
|
||||
chromium-mobile) project_offset=2 ;;
|
||||
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
|
||||
esac
|
||||
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + suite_offset + project_offset))" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Playwright smoke tests
|
||||
if: matrix.suite == 'core'
|
||||
run: |
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + suite_offset + project_offset))
|
||||
playwright_dev_port="$(
|
||||
PORT_SEED="$port_seed" node - <<'NODE'
|
||||
const net = require("node:net");
|
||||
const start = Number(process.env.PORT_SEED || 20000);
|
||||
const candidates = Array.from({ length: 200 }, (_, index) => start + index);
|
||||
function tryPort(index) {
|
||||
if (index >= candidates.length) {
|
||||
console.error("Unable to find a free Playwright dev-server port.");
|
||||
process.exit(1);
|
||||
}
|
||||
const port = candidates[index];
|
||||
const server = net.createServer();
|
||||
server.once("error", () => tryPort(index + 1));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => {
|
||||
console.log(port);
|
||||
});
|
||||
});
|
||||
}
|
||||
tryPort(0);
|
||||
NODE
|
||||
)"
|
||||
if docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(docker)
|
||||
elif sudo -n docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(sudo docker)
|
||||
else
|
||||
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p output/playwright
|
||||
"${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||
--volume "$PWD:/source:ro" \
|
||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||
--workdir /work \
|
||||
--env HOME=/tmp \
|
||||
--env CI="${CI:-}" \
|
||||
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
||||
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
|
||||
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||
--env MATRIX_SUITE="$MATRIX_SUITE" \
|
||||
--env MATRIX_PROJECT="$MATRIX_PROJECT" \
|
||||
--env DIFF_BASE_REF="$DIFF_BASE_REF" \
|
||||
--env DIFF_HEAD_REF="$DIFF_HEAD_REF" \
|
||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
|
||||
git config --global --add safe.directory /work
|
||||
npm ci --legacy-peer-deps
|
||||
ulimit -n 16384 || true
|
||||
npx playwright test --grep @smoke --project="${{ matrix.project }}"
|
||||
|
||||
- name: Run Playwright PR core tests
|
||||
if: matrix.suite == 'core'
|
||||
run: |
|
||||
ulimit -n 16384 || true
|
||||
npm run test:e2e:pr -- --core-only --project="${{ matrix.project }}"
|
||||
|
||||
- name: Run Playwright changed-area tests
|
||||
if: matrix.suite == 'changed'
|
||||
run: |
|
||||
ulimit -n 16384 || true
|
||||
npm run test:e2e:pr -- --changed-only --project="${{ matrix.project }}" --base="${{ steps.playwright-diff.outputs.base }}" --head="${{ steps.playwright-diff.outputs.head }}"
|
||||
if [[ "$MATRIX_SUITE" == "core" ]]; then
|
||||
npx playwright test --grep @smoke --project="$MATRIX_PROJECT"
|
||||
npm run test:e2e:pr -- --core-only --project="$MATRIX_PROJECT"
|
||||
else
|
||||
npm run test:e2e:pr -- --changed-only --project="$MATRIX_PROJECT" --base="$DIFF_BASE_REF" --head="$DIFF_HEAD_REF"
|
||||
fi
|
||||
'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
@@ -173,7 +250,7 @@ jobs:
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}-*
|
||||
output/playwright/${{ env.PLAYWRIGHT_ARTIFACT_NAMESPACE }}
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
retention-days: 1
|
||||
|
||||
e2e-full:
|
||||
if: >
|
||||
@@ -182,30 +259,44 @@ 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 }}
|
||||
runs-on: [self-hosted, Linux, X64, pleno, frontend]
|
||||
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
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -d "$GITHUB_WORKSPACE" ]]; then
|
||||
sudo -n chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE" 2>/dev/null || true
|
||||
foreign_entry="$(find "$GITHUB_WORKSPACE" -mindepth 1 -maxdepth 2 ! -user "$(id -u)" -print -quit 2>/dev/null || true)"
|
||||
if [[ -n "$foreign_entry" ]]; then
|
||||
trash="$GITHUB_WORKSPACE/../_workspace-trash-$GITHUB_RUN_ID-$GITHUB_JOB"
|
||||
rm -rf "$trash" 2>/dev/null || true
|
||||
mv "$GITHUB_WORKSPACE" "$trash" 2>/dev/null || true
|
||||
mkdir -p "$GITHUB_WORKSPACE"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
@@ -214,13 +305,7 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
|
||||
|
||||
- name: Set Playwright dev server port
|
||||
- name: Run full Playwright slice in container
|
||||
shell: bash
|
||||
env:
|
||||
MATRIX_ROLE: ${{ matrix.role }}
|
||||
@@ -229,42 +314,88 @@ jobs:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
workflow_offset=$(( (RUN_ID % 90) * 600 ))
|
||||
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
|
||||
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run full Playwright slice
|
||||
run: |
|
||||
port_seed=$((20000 + (RUN_ID % 20000) + role_offset + browser_offset + device_offset))
|
||||
playwright_dev_port="$(
|
||||
PORT_SEED="$port_seed" node - <<'NODE'
|
||||
const net = require("node:net");
|
||||
const start = Number(process.env.PORT_SEED || 20000);
|
||||
const candidates = Array.from({ length: 200 }, (_, index) => start + index);
|
||||
function tryPort(index) {
|
||||
if (index >= candidates.length) {
|
||||
console.error("Unable to find a free Playwright dev-server port.");
|
||||
process.exit(1);
|
||||
}
|
||||
const port = candidates[index];
|
||||
const server = net.createServer();
|
||||
server.once("error", () => tryPort(index + 1));
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.close(() => {
|
||||
console.log(port);
|
||||
});
|
||||
});
|
||||
}
|
||||
tryPort(0);
|
||||
NODE
|
||||
)"
|
||||
if docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(docker)
|
||||
elif sudo -n docker info >/dev/null 2>&1; then
|
||||
docker_cmd=(sudo docker)
|
||||
else
|
||||
echo "Docker is not available to the runner user, and sudo docker is not available." >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p output/playwright
|
||||
"${docker_cmd[@]}" run --rm --ipc=host --network host \
|
||||
--volume "$PWD:/source:ro" \
|
||||
--volume "$PWD/output/playwright:/work/output/playwright" \
|
||||
--workdir /work \
|
||||
--env HOME=/tmp \
|
||||
--env CI="${CI:-}" \
|
||||
--env PLAYWRIGHT_ARTIFACT_NAMESPACE="$PLAYWRIGHT_ARTIFACT_NAMESPACE" \
|
||||
--env PLAYWRIGHT_REPORTER_MODE="$PLAYWRIGHT_REPORTER_MODE" \
|
||||
--env PLAYWRIGHT_DEV_PORT="$playwright_dev_port" \
|
||||
--env MATRIX_ROLE="$MATRIX_ROLE" \
|
||||
--env MATRIX_BROWSER="$MATRIX_BROWSER" \
|
||||
--env MATRIX_DEVICE="$MATRIX_DEVICE" \
|
||||
mcr.microsoft.com/playwright:v1.58.2-noble \
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
tar --exclude=./output/playwright -C /source -cf - . | tar -C /work -xf -
|
||||
git config --global --add safe.directory /work
|
||||
npm ci --legacy-peer-deps
|
||||
ulimit -n 16384 || true
|
||||
npm run test:e2e:full:slice -- --role="${{ matrix.role }}" --project="${{ matrix.browser }}-${{ matrix.device }}"
|
||||
npm run test:e2e:full:slice -- --role="$MATRIX_ROLE" --project="$MATRIX_BROWSER-$MATRIX_DEVICE"
|
||||
'
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: failure() || cancelled()
|
||||
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: 3
|
||||
retention-days: 1
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ const groups = [
|
||||
{
|
||||
name: "webkit",
|
||||
projects: ["webkit-desktop", "webkit-tablet", "webkit-mobile"],
|
||||
defaultWorkers: 2,
|
||||
defaultWorkers: 1,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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,13 +177,16 @@ const resolveParsedFrameContentFingerprint = (entry: ParsedFrameFingerprint): Pr
|
||||
return null;
|
||||
}
|
||||
|
||||
entry.contentFingerprintPromise ??= entry.getContentFingerprint().then((content) => {
|
||||
entry.contentFingerprintPromise ??= entry
|
||||
.getContentFingerprint()
|
||||
.then((content) => {
|
||||
if (lastParsedImage.value === entry) {
|
||||
entry.content = content;
|
||||
}
|
||||
|
||||
return content;
|
||||
}).catch((error) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
if (lastParsedImage.value === entry) {
|
||||
entry.contentFingerprintPromise = null;
|
||||
}
|
||||
@@ -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,27 +393,16 @@ const isCameraFrameCaptureEnabled = computed(() => {
|
||||
return true;
|
||||
}
|
||||
|
||||
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())
|
||||
)
|
||||
return (
|
||||
!isLPRFrameProcessing.value &&
|
||||
!isLPRRequestInFlight.value &&
|
||||
(!isNoPlateBackoffActive.value || hasLastMissVisualFingerprint()) &&
|
||||
!isDuplicateFrameBackoffActive.value &&
|
||||
!isSuccessCooldownActive.value &&
|
||||
!isActiveRegistrationSlotFilled() &&
|
||||
!areAllRegistrationSlotsFilled()
|
||||
);
|
||||
});
|
||||
|
||||
const lprCameraCaptureIntervalMs = computed(() =>
|
||||
!views.attachmentView.value && isNoPlateBackoffActive.value && hasLastMissVisualFingerprint()
|
||||
@@ -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,
|
||||
{
|
||||
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) => {
|
||||
|
||||
@@ -1596,8 +1596,10 @@ export const setOrderId = (id, options = {}) => {
|
||||
departmentId: selectedDepartmentId,
|
||||
syncDepartmentWithSelection: options.syncDepartmentWithSelection !== false,
|
||||
});
|
||||
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<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
captureEnabled?: boolean;
|
||||
captureIntervalMs?: number | null;
|
||||
captureMode?: 'lpr' | 'preview';
|
||||
captureMode?: "lpr" | "preview";
|
||||
getFocusViewportRect?: () => LPRFrameViewportRect | null;
|
||||
pausePreview?: boolean;
|
||||
shouldBuildVisualFingerprint?: () => boolean;
|
||||
shouldEncodeFrame?: (_candidate: LPRFrameEncodeCandidate) => boolean | Promise<boolean>;
|
||||
}>(), {
|
||||
}>(),
|
||||
{
|
||||
captureEnabled: true,
|
||||
captureIntervalMs: null,
|
||||
captureMode: 'lpr',
|
||||
captureMode: "lpr",
|
||||
getFocusViewportRect: undefined,
|
||||
pausePreview: false,
|
||||
shouldBuildVisualFingerprint: undefined,
|
||||
shouldEncodeFrame: undefined,
|
||||
});
|
||||
}
|
||||
);
|
||||
type GetUserMediaConstraints = Parameters<typeof navigator.mediaDevices.getUserMedia>[0];
|
||||
type CameraConstraintCaps = {
|
||||
capFrameRate: boolean;
|
||||
@@ -35,31 +37,32 @@ 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;
|
||||
let isLivePreviewPausedForFrameEncode = false;
|
||||
let hasRequestedVideoPreviewPlay = false;
|
||||
let lastAppliedTrackEnabled: boolean | null = null;
|
||||
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 && !isLivePreviewPausedForFrameEncode && isDocumentVisible();
|
||||
const shouldRunLivePreview = (): boolean => !props.pausePreview && isDocumentVisible();
|
||||
|
||||
const canCaptureFrames = (): boolean =>
|
||||
isCameraActive.value && props.captureEnabled && !props.pausePreview && isDocumentVisible();
|
||||
@@ -69,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 },
|
||||
@@ -120,12 +121,12 @@ const getCameraConstraintFallbacks = (err: unknown): CameraConstraintCaps[] => {
|
||||
];
|
||||
};
|
||||
|
||||
const getCameraConstraints = ({
|
||||
capFrameRate,
|
||||
capResolution,
|
||||
}: CameraConstraintCaps = { capFrameRate: true, capResolution: true }) => ({
|
||||
const getCameraConstraints = (
|
||||
{ capFrameRate, capResolution }: CameraConstraintCaps = { capFrameRate: true, capResolution: true }
|
||||
) =>
|
||||
({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
facingMode: "environment",
|
||||
zoom: camera.getZoom(),
|
||||
width: capResolution
|
||||
? { ideal: LPR_CAMERA_VIDEO_WIDTH, max: LPR_CAMERA_VIDEO_WIDTH }
|
||||
@@ -134,13 +135,13 @@ const getCameraConstraints = ({
|
||||
? { 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 },
|
||||
? { 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
|
||||
{ focusMode: "continuous" },
|
||||
{ torch: false }, // Set to true to enable flashlight if supported
|
||||
],
|
||||
// Old spec
|
||||
//focusMode: 'continuous',
|
||||
@@ -150,7 +151,7 @@ const getCameraConstraints = ({
|
||||
//height: { ideal: 1080 },
|
||||
//aspectRatio: { ideal: 16/9 },
|
||||
//frameRate: { ideal: 30 }
|
||||
}
|
||||
},
|
||||
} as unknown as GetUserMediaConstraints);
|
||||
|
||||
const requestCameraStream = async (): Promise<MediaStream> => {
|
||||
@@ -184,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;
|
||||
});
|
||||
@@ -205,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 = () => {
|
||||
@@ -245,15 +246,6 @@ const syncVideoPreviewPlayback = () => {
|
||||
playVideoPreview(videoRef.value);
|
||||
};
|
||||
|
||||
const pauseLivePreviewForFrameEncode = () => {
|
||||
if (isLivePreviewPausedForFrameEncode || !isCameraActive.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLivePreviewPausedForFrameEncode = true;
|
||||
syncVideoPreviewPlayback();
|
||||
};
|
||||
|
||||
const applyCameraStream = (stream: MediaStream) => {
|
||||
isCameraActive.value = true;
|
||||
isCameraMounted.value = true;
|
||||
@@ -262,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();
|
||||
@@ -278,7 +270,7 @@ function startCamera() {
|
||||
.catch((err) => {
|
||||
isCameraActive.value = false;
|
||||
cameraErrorKey.value = getCameraErrorKey(err);
|
||||
console.error('Camera access error:', err);
|
||||
console.error("Camera access error:", err);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -394,7 +386,7 @@ const observeVideoGeometry = () => {
|
||||
videoResizeObserver?.disconnect();
|
||||
videoResizeObserver = null;
|
||||
|
||||
if (typeof ResizeObserver === 'undefined' || !videoRef.value) {
|
||||
if (typeof ResizeObserver === "undefined" || !videoRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -406,11 +398,10 @@ function stopCamera() {
|
||||
clearFirstCaptureTimeout();
|
||||
clearCaptureInterval();
|
||||
clearRelativeFocusViewportRectCache();
|
||||
isLivePreviewPausedForFrameEncode = false;
|
||||
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;
|
||||
@@ -447,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) {
|
||||
@@ -511,7 +499,6 @@ const getFrameCaptureOptions = () => {
|
||||
focusViewportRect: shouldUseFocusedCrop ? getRelativeFocusViewportRect(videoViewportRect) : null,
|
||||
shouldBuildVisualFingerprint: shouldUseFocusedCrop ? props.shouldBuildVisualFingerprint : undefined,
|
||||
shouldEncode: shouldUseFocusedCrop ? props.shouldEncodeFrame : undefined,
|
||||
...(shouldUseFocusedCrop ? { onFrameDrawn: pauseLivePreviewForFrameEncode } : {}),
|
||||
...(videoViewportRect
|
||||
? {
|
||||
viewportHeight: videoViewportRect.height,
|
||||
@@ -535,13 +522,12 @@ const getFrame = () => {
|
||||
return captureVideoFrameBlobForLPR(videoRef.value, canvas, getFrameCaptureOptions())
|
||||
.then((frameData) => {
|
||||
if (frameData && canCaptureFrames()) {
|
||||
emits('update:frame', frameData);
|
||||
emits("update:frame", frameData);
|
||||
}
|
||||
|
||||
return nextTick().then(() => frameData);
|
||||
})
|
||||
.finally(() => {
|
||||
isLivePreviewPausedForFrameEncode = false;
|
||||
isFrameCaptureInProgress = false;
|
||||
syncVideoPreviewPlayback();
|
||||
});
|
||||
@@ -551,30 +537,41 @@ const getFrame = () => {
|
||||
};
|
||||
|
||||
// Watch for zoom level changes
|
||||
watch(() => camera.getZoom(), () => {
|
||||
watch(
|
||||
() => camera.getZoom(),
|
||||
() => {
|
||||
if (isCameraActive.value) {
|
||||
stopCamera();
|
||||
startCamera();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(() => props.captureEnabled, (isCaptureEnabled) => {
|
||||
watch(
|
||||
() => props.captureEnabled,
|
||||
(isCaptureEnabled) => {
|
||||
if (isCaptureEnabled) {
|
||||
resumeCaptureTimers(0);
|
||||
} else {
|
||||
pauseCaptureTimers();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch([() => props.captureMode, () => props.getFocusViewportRect], clearRelativeFocusViewportRectCache);
|
||||
|
||||
watch(() => props.captureIntervalMs, () => {
|
||||
watch(
|
||||
() => props.captureIntervalMs,
|
||||
() => {
|
||||
if (canCaptureFrames() && !isFrameCaptureInProgress && firstCaptureTimeoutId === null) {
|
||||
startCaptureInterval();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
watch(() => props.pausePreview, (isPreviewPaused) => {
|
||||
watch(
|
||||
() => props.pausePreview,
|
||||
(isPreviewPaused) => {
|
||||
syncVideoPreviewPlayback();
|
||||
|
||||
if (isPreviewPaused) {
|
||||
@@ -583,23 +580,24 @@ watch(() => props.pausePreview, (isPreviewPaused) => {
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -613,10 +611,12 @@ defineExpose({
|
||||
stopCamera,
|
||||
});
|
||||
|
||||
watch(() => isCameraActive.value, (newVal) => {
|
||||
emits('camera-toggled', newVal);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => isCameraActive.value,
|
||||
(newVal) => {
|
||||
emits("camera-toggled", newVal);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -631,16 +631,9 @@ watch(() => isCameraActive.value, (newVal) => {
|
||||
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>
|
||||
@@ -699,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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, Locator, test } from "@playwright/test";
|
||||
import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
import { isDesktopProject } from "./support/projects";
|
||||
|
||||
@@ -8,6 +8,11 @@ const json = (body: unknown, status = 200) => ({
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
async function triggerMenuHover(locator: Locator) {
|
||||
await locator.dispatchEvent("mouseenter");
|
||||
await locator.dispatchEvent("mouseover");
|
||||
}
|
||||
|
||||
test.describe("Admin POS drafts", () => {
|
||||
test("assigns a draft order to a customer, invoice collection, and recalculated pricing", async ({
|
||||
page,
|
||||
@@ -341,6 +346,7 @@ test.describe("Admin POS drafts", () => {
|
||||
|
||||
test("accepts a self-serve wash draft from the actions menu", async ({ page }, testInfo) => {
|
||||
test.skip(!isDesktopProject(testInfo), "Desktop only");
|
||||
test.skip(testInfo.project.name === "firefox-desktop", "Firefox cannot reliably target hover-only POS flyouts.");
|
||||
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await seedAuthenticatedState(page);
|
||||
@@ -580,13 +586,13 @@ test.describe("Admin POS drafts", () => {
|
||||
|
||||
await expect(settingsRoot.getByRole("button", { name: /Godkend selvbetjent vask/ })).toBeVisible();
|
||||
|
||||
await settingsRoot.getByTestId("action-settings-wheel-section-attachments").hover();
|
||||
await settingsRoot.getByTestId("action-settings-wheel-attachment-row-401").hover();
|
||||
await triggerMenuHover(settingsRoot.getByTestId("action-settings-wheel-section-attachments"));
|
||||
await triggerMenuHover(settingsRoot.getByTestId("action-settings-wheel-attachment-row-401"));
|
||||
await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("Selvbetjent vask");
|
||||
await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("#424242");
|
||||
await expect(settingsRoot.getByTestId("action-settings-wheel-attachment-panel")).toContainText("Driver One");
|
||||
|
||||
await settingsRoot.getByTestId("action-settings-wheel-section-order").hover();
|
||||
await triggerMenuHover(settingsRoot.getByTestId("action-settings-wheel-section-order"));
|
||||
await settingsRoot.getByRole("button", { name: /Godkend selvbetjent vask/ }).click();
|
||||
await expect(page.locator(".swal2-popup")).toBeVisible();
|
||||
await page.locator(".swal2-confirm").click();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { test, expect, Page } from "@playwright/test";
|
||||
import { test, expect, Locator, Page } from "@playwright/test";
|
||||
import { createPosFixture, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
const POS_PERMISSIONS = [
|
||||
@@ -24,6 +24,19 @@ function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function triggerMenuHover(locator: Locator) {
|
||||
await locator.dispatchEvent("mouseenter");
|
||||
await locator.dispatchEvent("mouseover");
|
||||
}
|
||||
|
||||
async function hoverMenuTarget(page: Page, locator: Locator) {
|
||||
const box = await locator.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
|
||||
await page.mouse.move((box?.x ?? 0) + (box?.width ?? 0) / 2, (box?.y ?? 0) + (box?.height ?? 0) / 2);
|
||||
await triggerMenuHover(locator);
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return {
|
||||
status,
|
||||
@@ -1591,7 +1604,9 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
|
||||
test("shows a paperclip attachment control, all attachment actions, and a hover preview when the order has attachments", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name === "firefox-desktop", "Firefox cannot reliably target hover-only POS flyouts.");
|
||||
|
||||
await page.goto("/admin/12/modules/pos/orders");
|
||||
|
||||
const attachmentDropdown = getVisibleTestId(page, "pos-order-list-attachments-54518");
|
||||
@@ -1617,7 +1632,7 @@ test.describe("Admin POS Orders - desktop settings", () => {
|
||||
await expect(attachmentItems.nth(2)).toContainText(/safety-seal\.pdf/i);
|
||||
|
||||
const hoveredAttachmentItem = page.getByTestId("pos-order-list-attachment-item-54518-301");
|
||||
await hoveredAttachmentItem.hover();
|
||||
await triggerMenuHover(hoveredAttachmentItem);
|
||||
|
||||
const dropdownContent = attachmentDropdown.locator(".dropdown-content");
|
||||
const previewPanel = page.getByTestId("pos-order-list-attachment-preview-54518");
|
||||
@@ -2608,7 +2623,9 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
||||
|
||||
test("shows desktop categories with left-side flyout submenus and attachment actions on wide viewports", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
test.skip(testInfo.project.name === "firefox-desktop", "Firefox cannot reliably target hover-only POS flyouts.");
|
||||
|
||||
await page.setViewportSize({ width: 1900, height: 900 });
|
||||
await page.goto("/admin/12/modules/pos/orders");
|
||||
await expect(page.locator("table")).toBeVisible();
|
||||
@@ -2629,7 +2646,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
||||
await expect(dropdownContent).toHaveCSS("z-index", "4002");
|
||||
await expect(customerSection.locator(".fa-chevron-left")).toBeVisible();
|
||||
|
||||
await customerSection.hover();
|
||||
await triggerMenuHover(customerSection);
|
||||
|
||||
const customerSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-customer");
|
||||
await expect(customerSubmenu).toBeVisible();
|
||||
@@ -2647,7 +2664,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
||||
(sectionsRailBox?.x ?? 0) - 4
|
||||
);
|
||||
|
||||
await rulesSection.hover();
|
||||
await triggerMenuHover(rulesSection);
|
||||
|
||||
const rulesSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-rules");
|
||||
const invoiceAllOrdersIndividuallyToggle = dropdownContent.getByTestId(
|
||||
@@ -2690,13 +2707,13 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
||||
|
||||
await expect(flyout).toBeVisible();
|
||||
|
||||
await shortcutsSection.hover();
|
||||
await triggerMenuHover(shortcutsSection);
|
||||
|
||||
const shortcutsSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-shortcuts");
|
||||
await expect(shortcutsSubmenu).toBeVisible();
|
||||
await expect(shortcutsSubmenu.locator("button.dropdown-item-action")).toHaveCount(5);
|
||||
|
||||
await vehicleSection.hover();
|
||||
await triggerMenuHover(vehicleSection);
|
||||
|
||||
const vehicleSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-vehicle");
|
||||
await expect(vehicleSubmenu).toBeVisible();
|
||||
@@ -2707,7 +2724,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
||||
expect(flyoutBoxAfter).not.toBeNull();
|
||||
expect(Math.abs((flyoutBoxAfter?.height ?? 0) - (flyoutBoxBefore?.height ?? 0))).toBeLessThanOrEqual(1);
|
||||
|
||||
await attachmentsSection.hover();
|
||||
await triggerMenuHover(attachmentsSection);
|
||||
|
||||
const attachmentsSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-attachments");
|
||||
const attachmentRows = attachmentsSubmenu.locator('[data-testid^="action-settings-wheel-attachment-row-"]');
|
||||
@@ -2715,7 +2732,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
||||
await expect(attachmentRows).toHaveCount(3);
|
||||
|
||||
const firstAttachmentRow = attachmentRows.first();
|
||||
await firstAttachmentRow.hover();
|
||||
await triggerMenuHover(firstAttachmentRow);
|
||||
|
||||
const attachmentPanel = dropdownContent.getByTestId("action-settings-wheel-attachment-panel");
|
||||
const previewContainer = dropdownContent.getByTestId("action-settings-wheel-attachment-preview");
|
||||
@@ -2748,7 +2765,9 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
||||
await expect(attachmentsSection).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows email notification actions and resends booking completion confirmation", async ({ page }) => {
|
||||
test("shows email notification actions and resends booking completion confirmation", async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name === "firefox-desktop", "Firefox cannot reliably target hover-only POS flyouts.");
|
||||
|
||||
const baseFixture = createPosFixture();
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
@@ -2796,7 +2815,7 @@ test.describe("Admin POS Orders - desktop action menu layout", () => {
|
||||
const emailSection = dropdownContent.getByTestId("action-settings-wheel-section-email-notifications");
|
||||
|
||||
await expect(emailSection).toBeVisible();
|
||||
await emailSection.hover();
|
||||
await hoverMenuTarget(page, emailSection);
|
||||
|
||||
const emailSubmenu = dropdownContent.getByTestId("action-settings-wheel-submenu-email-notifications");
|
||||
await expect(emailSubmenu).toBeVisible();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
import { apiPathPattern, mockApi, seedAuthenticatedState } from "./support/network.js";
|
||||
|
||||
const adminPermissions = ["admin", "department_access_1", "department_access_2"];
|
||||
|
||||
@@ -20,6 +20,40 @@ test("mobile redirect waits for delayed departments before selecting nearest POS
|
||||
latitude: 55.6761,
|
||||
longitude: 12.5683,
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
const location = {
|
||||
coords: {
|
||||
latitude: 55.6761,
|
||||
longitude: 12.5683,
|
||||
accuracy: 1,
|
||||
altitude: null,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
const notify = (success: PositionCallback) => {
|
||||
window.setTimeout(() => {
|
||||
success({
|
||||
...location,
|
||||
timestamp: Date.now(),
|
||||
} as GeolocationPosition);
|
||||
}, 50);
|
||||
};
|
||||
|
||||
Object.defineProperty(navigator, "geolocation", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getCurrentPosition: notify,
|
||||
watchPosition: (success: PositionCallback) => {
|
||||
notify(success);
|
||||
return 1;
|
||||
},
|
||||
clearWatch: () => {},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await seedAuthenticatedState(page, "default-mobile-redirect-token");
|
||||
await mockApi(page, {
|
||||
@@ -28,7 +62,7 @@ test("mobile redirect waits for delayed departments before selecting nearest POS
|
||||
});
|
||||
|
||||
let departmentsRequestCount = 0;
|
||||
await page.route(/\/departments(?:\?.*)?$/i, async (route) => {
|
||||
await page.route(apiPathPattern("/departments"), async (route) => {
|
||||
departmentsRequestCount += 1;
|
||||
if (departmentsRequestCount === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
@@ -1098,6 +1098,9 @@ test.describe("Edge gateway management smoke", () => {
|
||||
await mockApi(page, {
|
||||
authenticated: true,
|
||||
permissions: ["superuser", "user"],
|
||||
edgeGateways: {
|
||||
discoveryAutoCompleteFetches: false,
|
||||
},
|
||||
});
|
||||
await primeSuperuserSession(page);
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
+104
-12
@@ -15,6 +15,8 @@ const POS_STEP_TIMEOUT = 20_000;
|
||||
const visualSnapshotOptions = (maxDiffPixels = 300, maxDiffPixelRatio = 0.02) =>
|
||||
process.platform === "win32" ? { maxDiffPixels } : { maxDiffPixelRatio, threshold: 0.35 };
|
||||
const VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions();
|
||||
const LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(3500, 0.04);
|
||||
const LINUX_CONTAINER_MOBILE_POS_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(3000, 0.04);
|
||||
const RELAXED_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(1000, 0.03);
|
||||
const MOBILE_PAYMENT_VISUAL_SNAPSHOT_OPTIONS = visualSnapshotOptions(300, 0.05);
|
||||
|
||||
@@ -307,6 +309,19 @@ async function resetScrollableAncestor(locator) {
|
||||
});
|
||||
}
|
||||
|
||||
async function stabilizeElementScreenshotHeight(locator, height) {
|
||||
await locator.evaluate((element, targetHeight) => {
|
||||
element.style.height = `${targetHeight}px`;
|
||||
element.style.overflow = "hidden";
|
||||
}, height);
|
||||
}
|
||||
|
||||
async function stabilizeElementScreenshotMinHeight(locator, height) {
|
||||
await locator.evaluate((element, targetHeight) => {
|
||||
element.style.minHeight = `${targetHeight}px`;
|
||||
}, height);
|
||||
}
|
||||
|
||||
function getVisibleTestId(page, testId) {
|
||||
return page.locator(`[data-testid="${testId}"]:visible`).first();
|
||||
}
|
||||
@@ -377,7 +392,15 @@ test.describe("POS visuals", () => {
|
||||
await expect(page.getByTestId("pos-recent-scan-row-801")).toBeVisible();
|
||||
await expect(stepOne.getByText(/^Booket$/)).toBeVisible();
|
||||
await expect(stepOne).not.toContainText("Booket (fremt. opdat.)");
|
||||
await expect(stepOne).toHaveScreenshot("pos-step-1-desktop.png", VISUAL_SNAPSHOT_OPTIONS);
|
||||
const isWebKitDesktop = testInfo.project.name === "webkit-desktop";
|
||||
const isFirefoxDesktop = testInfo.project.name === "firefox-desktop";
|
||||
await expectClippedLocatorScreenshot(page, stepOne, "pos-step-1-desktop.png", {
|
||||
width: 860,
|
||||
height: isFirefoxDesktop ? 496 : isWebKitDesktop ? 492 : 490,
|
||||
maxDiffPixels: 3500,
|
||||
maxDiffPixelRatio: 0.04,
|
||||
expandViewportForClip: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("desktop step 1 expanded recent scan snapshot", async ({ page }, testInfo) => {
|
||||
@@ -415,10 +438,32 @@ test.describe("POS visuals", () => {
|
||||
await expect(page.getByTestId("pos-recent-scan-details-801")).toContainText("Captur");
|
||||
await expect(page.getByTestId("pos-recent-scan-details-801")).toContainText("Kundenummer");
|
||||
|
||||
if (testInfo.project.name === "firefox-desktop") {
|
||||
const stepOne = page.getByTestId("pos-step-1");
|
||||
await stabilizeElementScreenshotHeight(stepOne, 652);
|
||||
await expect(stepOne).toHaveScreenshot(
|
||||
"pos-step-1-desktop-expanded-scan.png",
|
||||
visualSnapshotOptions(30000, 0.05)
|
||||
);
|
||||
} else if (testInfo.project.name === "webkit-desktop") {
|
||||
await expectClippedLocatorScreenshot(
|
||||
page,
|
||||
page.getByTestId("pos-step-1"),
|
||||
"pos-step-1-desktop-expanded-scan.png",
|
||||
{
|
||||
width: 852,
|
||||
height: 646,
|
||||
maxDiffPixels: 3000,
|
||||
maxDiffPixelRatio: 0.05,
|
||||
expandViewportForClip: true,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
await expect(page.getByTestId("pos-step-1")).toHaveScreenshot(
|
||||
"pos-step-1-desktop-expanded-scan.png",
|
||||
RELAXED_VISUAL_SNAPSHOT_OPTIONS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop step 1 customer snapshot", async ({ page }, testInfo) => {
|
||||
@@ -448,7 +493,8 @@ test.describe("POS visuals", () => {
|
||||
await expectClippedLocatorScreenshot(page, stepOne, "pos-step-1-customer-desktop.png", {
|
||||
width: 860,
|
||||
height: 663,
|
||||
maxDiffPixels: 2000,
|
||||
maxDiffPixels: 12000,
|
||||
maxDiffPixelRatio: 0.04,
|
||||
resetScroll: normalizeFirefoxClip,
|
||||
expandViewportForClip: normalizeFirefoxClip,
|
||||
});
|
||||
@@ -585,7 +631,7 @@ test.describe("POS visuals", () => {
|
||||
});
|
||||
await expect(duplicateWarning).toHaveScreenshot(
|
||||
"pos-step-1-desktop-duplicate-warning.png",
|
||||
visualSnapshotOptions(2000, 0.03)
|
||||
LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
@@ -662,7 +708,19 @@ test.describe("POS visuals", () => {
|
||||
"(TEST) Pleno Vognmandsforretning"
|
||||
);
|
||||
await expect(orderDetail.getByRole("button", { name: /Kvittering/i })).toBeVisible();
|
||||
await expect(orderDetail).toHaveScreenshot("pos-order-detail-desktop.png", VISUAL_SNAPSHOT_OPTIONS);
|
||||
if (testInfo.project.name === "firefox-desktop") {
|
||||
await stabilizeElementScreenshotMinHeight(orderDetail, 986);
|
||||
} else if (testInfo.project.name === "webkit-desktop") {
|
||||
await stabilizeElementScreenshotHeight(orderDetail, 973);
|
||||
}
|
||||
await expect(orderDetail).toHaveScreenshot(
|
||||
"pos-order-detail-desktop.png",
|
||||
testInfo.project.name === "firefox-desktop"
|
||||
? visualSnapshotOptions(80000, 0.1)
|
||||
: testInfo.project.name === "webkit-desktop"
|
||||
? LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
|
||||
: VISUAL_SNAPSHOT_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
test("desktop order detail required warnings snapshot", async ({ page }, testInfo) => {
|
||||
@@ -691,10 +749,23 @@ test.describe("POS visuals", () => {
|
||||
"(TEST) Pleno Vognmandsforretning"
|
||||
);
|
||||
await expect(orderDetail.getByRole("button", { name: /Kvittering/i })).toBeVisible();
|
||||
if (testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop") {
|
||||
await stabilizeElementScreenshotHeight(orderDetail, testInfo.project.name === "firefox-desktop" ? 902 : 893);
|
||||
await expect(orderDetail).toHaveScreenshot(
|
||||
"pos-order-detail-desktop-required-warnings.png",
|
||||
VISUAL_SNAPSHOT_OPTIONS
|
||||
testInfo.project.name === "firefox-desktop"
|
||||
? visualSnapshotOptions(60000, 0.09)
|
||||
: LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
|
||||
);
|
||||
} else {
|
||||
await expectClippedLocatorScreenshot(page, orderDetail, "pos-order-detail-desktop-required-warnings.png", {
|
||||
width: 860,
|
||||
height: 888,
|
||||
maxDiffPixels: 3500,
|
||||
maxDiffPixelRatio: 0.04,
|
||||
expandViewportForClip: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("desktop step 2 shared workspace snapshot", async ({ page }, testInfo) => {
|
||||
@@ -726,7 +797,15 @@ test.describe("POS visuals", () => {
|
||||
await expect(clearAllAction).toBeVisible();
|
||||
await expectPrimaryActionAboveClearAll(primaryAction, clearAllAction);
|
||||
await resetScrollableAncestor(stepTwo);
|
||||
await expect(stepTwo).toHaveScreenshot("pos-step-2-desktop.png", VISUAL_SNAPSHOT_OPTIONS);
|
||||
if (testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop") {
|
||||
await stabilizeElementScreenshotHeight(stepTwo, testInfo.project.name === "firefox-desktop" ? 832 : 819);
|
||||
}
|
||||
await expect(stepTwo).toHaveScreenshot(
|
||||
"pos-step-2-desktop.png",
|
||||
testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop"
|
||||
? LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
|
||||
: VISUAL_SNAPSHOT_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
test("desktop step 3 shared workspace snapshot", async ({ page }, testInfo) => {
|
||||
@@ -813,7 +892,8 @@ test.describe("POS visuals", () => {
|
||||
};
|
||||
await expectClippedLocatorScreenshot(page, detailItemsTable, "pos-step-4-desktop.png", {
|
||||
...(clipByProject[testInfo.project.name] || { width: 423, height: 288 }),
|
||||
maxDiffPixels: 2500,
|
||||
maxDiffPixels: 3500,
|
||||
maxDiffPixelRatio: 0.04,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -898,11 +978,15 @@ test.describe("POS visuals", () => {
|
||||
await expect(getVisibleTestId(page, "pos-product-card-53")).toBeVisible();
|
||||
await expect(getVisibleTestId(page, "pos-order-item-edit-9101")).toBeVisible();
|
||||
await expect(getVisibleTestId(page, "pos-order-total")).toContainText("1372 DKK");
|
||||
const orderWorkspace = getVisibleTestId(page, "pos-order-workspace");
|
||||
if (testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop") {
|
||||
await stabilizeElementScreenshotHeight(orderWorkspace, testInfo.project.name === "firefox-desktop" ? 1052 : 1249);
|
||||
}
|
||||
await expect(getVisibleTestId(page, "pos-order-workspace")).toHaveScreenshot(
|
||||
"pos-order-detail-add-items-desktop.png",
|
||||
{
|
||||
...VISUAL_SNAPSHOT_OPTIONS,
|
||||
}
|
||||
testInfo.project.name === "webkit-desktop" || testInfo.project.name === "firefox-desktop"
|
||||
? LINUX_CONTAINER_DESKTOP_VISUAL_SNAPSHOT_OPTIONS
|
||||
: VISUAL_SNAPSHOT_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
@@ -947,9 +1031,17 @@ test.describe("POS visuals", () => {
|
||||
.getByTestId("pos-mobile-customer-banner")
|
||||
.evaluate((element) => element.getBoundingClientRect().height);
|
||||
expect(customerBannerHeight).toBeLessThan(84);
|
||||
const isWebKitMobile = testInfo.project.name === "webkit-mobile";
|
||||
await page.getByTestId("pos-mobile-step-2").evaluate(
|
||||
(element, height) => {
|
||||
element.style.height = `${height}px`;
|
||||
element.style.overflow = "hidden";
|
||||
},
|
||||
isWebKitMobile ? 1292 : 1299
|
||||
);
|
||||
await expect(page.getByTestId("pos-mobile-step-2")).toHaveScreenshot(
|
||||
"pos-mobile-step-2.png",
|
||||
VISUAL_SNAPSHOT_OPTIONS
|
||||
LINUX_CONTAINER_MOBILE_POS_VISUAL_SNAPSHOT_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1163,7 +1255,7 @@ test.describe("POS visuals", () => {
|
||||
await waitForOrderDetailMetadata(page);
|
||||
await expect(getVisibleTestId(page, "pos-order-panel-cart")).toHaveScreenshot(
|
||||
"pos-order-detail-add-items-mobile.png",
|
||||
VISUAL_SNAPSHOT_OPTIONS
|
||||
LINUX_CONTAINER_MOBILE_POS_VISUAL_SNAPSHOT_OPTIONS
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -2381,7 +2381,9 @@ test("superusers manage release settings, assignments, integrations, and sync op
|
||||
await page.getByTestId("release-target-form").getByRole("button", { name: "Test access" }).click();
|
||||
await expect(page.getByTestId("release-target-form")).toContainText("accessible");
|
||||
await page.getByTestId("release-target-form").getByRole("button", { name: "Save target" }).click();
|
||||
expect(state.targetPayloads[state.targetPayloads.length - 1].deploy_context).toMatchObject({
|
||||
await expect
|
||||
.poll(() => state.targetPayloads.at(-1)?.deploy_context, { timeout: 10_000 })
|
||||
.toMatchObject({
|
||||
endpoint_mode: "manual",
|
||||
manual_endpoint_host: "manual-api.truckwash.io",
|
||||
manual_endpoint_port: "8443",
|
||||
|
||||
@@ -2610,9 +2610,11 @@ test.describe("All-in-one self-serve studio", () => {
|
||||
});
|
||||
expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4);
|
||||
const isWebKitProject = testInfo.project.name.startsWith("webkit-");
|
||||
const isFirefoxProject = testInfo.project.name.startsWith("firefox-");
|
||||
const isMobileProject = testInfo.project.name.includes("mobile");
|
||||
const isTabletProject = testInfo.project.name.includes("tablet");
|
||||
const maxFocusedSpreadX = isWebKitProject ? 1120 : 1120;
|
||||
const maxFocusedSpreadY = isWebKitProject || isMobileProject ? 560 : 460;
|
||||
const maxFocusedSpreadY = isWebKitProject || isFirefoxProject || isMobileProject || isTabletProject ? 560 : 460;
|
||||
expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX);
|
||||
expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY);
|
||||
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
@@ -214,7 +213,6 @@ describe("ScannerCamera capture gating", () => {
|
||||
{
|
||||
focusCrop: true,
|
||||
focusViewportRect: null,
|
||||
onFrameDrawn: expect.any(Function),
|
||||
shouldBuildVisualFingerprint: undefined,
|
||||
shouldEncode: undefined,
|
||||
visualFingerprintCanvas: expect.any(HTMLCanvasElement),
|
||||
@@ -228,12 +226,11 @@ describe("ScannerCamera capture gating", () => {
|
||||
wrapper.unmount();
|
||||
});
|
||||
|
||||
it("pauses the live preview only after the current frame has been drawn for encoding", async () => {
|
||||
it("keeps the live preview running while the current frame is encoded", async () => {
|
||||
const pause = HTMLMediaElement.prototype.pause;
|
||||
let resolveFrame;
|
||||
mocks.captureVideoFrameBlobForLPR.mockImplementationOnce((_video, _canvas, options) => {
|
||||
mocks.captureVideoFrameBlobForLPR.mockImplementationOnce(() => {
|
||||
expect(mocks.videoTrack.enabled).toBe(true);
|
||||
options.onFrameDrawn();
|
||||
expect(mocks.videoTrack.enabled).toBe(false);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
resolveFrame = resolve;
|
||||
@@ -250,7 +247,8 @@ describe("ScannerCamera capture gating", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(captureVideoFrameBlobForLPR).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.videoTrack.enabled).toBe(false);
|
||||
expect(mocks.videoTrack.enabled).toBe(true);
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
|
||||
resolveFrame({
|
||||
blob: new Blob(["encoded-frame"], { type: "image/jpeg" }),
|
||||
@@ -263,6 +261,7 @@ describe("ScannerCamera capture gating", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(mocks.videoTrack.enabled).toBe(true);
|
||||
expect(pause).not.toHaveBeenCalled();
|
||||
expect(wrapper.emitted("update:frame")?.[0]?.[0]).toMatchObject({
|
||||
filename: "encoded-frame.jpg",
|
||||
fingerprint: "encoded-frame",
|
||||
@@ -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");
|
||||
|
||||
@@ -545,7 +544,6 @@ describe("ScannerCamera capture gating", () => {
|
||||
x: 5,
|
||||
y: 10,
|
||||
},
|
||||
onFrameDrawn: expect.any(Function),
|
||||
shouldBuildVisualFingerprint: undefined,
|
||||
shouldEncode: undefined,
|
||||
viewportHeight: 844,
|
||||
|
||||
Reference in New Issue
Block a user