Merge pull request #47 from copenhagentruckwash/run-tests-and-fix-issues

Fix BOM-safe locale parsing in i18n tests
This commit is contained in:
Jeppe B
2026-04-21 22:52:29 +02:00
committed by GitHub
27 changed files with 655 additions and 374 deletions
+5 -4
View File
@@ -16,7 +16,8 @@ concurrency:
jobs:
format-tests:
runs-on: ubuntu-latest
# Match the labels exposed by the Coolify-managed GitHub runner.
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -38,7 +39,7 @@ jobs:
build-and-unit:
needs: format-tests
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
steps:
- name: Checkout repository
uses: actions/checkout@v5
@@ -63,7 +64,7 @@ jobs:
e2e-smoke:
if: github.event_name != 'schedule'
needs: build-and-unit
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
strategy:
fail-fast: false
matrix:
@@ -102,7 +103,7 @@ jobs:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch
needs: build-and-unit
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: ubuntu-latest
runs-on: [self-hosted, Linux, X64, default]
strategy:
fail-fast: false
matrix:
+2 -1
View File
@@ -2,7 +2,8 @@ import path from "node:path";
import { defineConfig, devices } from "@playwright/test";
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${devPort}`;
const devHost = process.env.PLAYWRIGHT_DEV_HOST || "127.0.0.1";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}`;
const isCI = !!process.env.CI;
const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "").trim();
const artifactRoot = artifactNamespace
+127 -22
View File
@@ -1,17 +1,20 @@
import { execFile, spawn } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import readline from "node:readline";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173);
const baseURL = `http://localhost:${devPort}`;
const devHost = process.env.PLAYWRIGHT_DEV_HOST || "127.0.0.1";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${devHost}:${devPort}`;
const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`)
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "-");
const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`);
const posViewsDirectory = path.resolve(process.cwd(), "src/views/dashboards/departmentDashboard/modules/Pos");
const shopComponentsDirectory = path.resolve(process.cwd(), "src/components/shop");
const serverOutputLimit = 80;
async function getListeningProcessOnWindows(port) {
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], {
@@ -52,8 +55,46 @@ async function getListeningProcessOnUnix(port) {
CommandLine: commandMatch ? commandMatch[1] : "",
};
} catch {
return null;
// Fall through to other tools that are commonly available on Linux runners.
}
try {
const { stdout } = await execFileAsync("ss", ["-ltnp", `sport = :${port}`], {
cwd: process.cwd(),
});
const line = stdout
.split(/\r?\n/)
.find((entry) => entry.includes("LISTEN") && entry.includes(`:${port}`) && entry.includes("pid="));
const pidMatch = line?.match(/pid=(\d+)/);
if (pidMatch) {
return {
Id: Number(pidMatch[1]),
CommandLine: line.match(/users:\(\("([^"]+)"/)?.[1] || "",
};
}
} catch {
// Fall through to netstat.
}
try {
const { stdout } = await execFileAsync("netstat", ["-ltnp"], {
cwd: process.cwd(),
});
const line = stdout
.split(/\r?\n/)
.find((entry) => entry.includes("LISTEN") && entry.match(new RegExp(`:${port}\\s`)));
const pidMatch = line?.match(/\s(\d+)\/([^\s]+)\s*$/);
if (pidMatch) {
return {
Id: Number(pidMatch[1]),
CommandLine: pidMatch[2],
};
}
} catch {
// No more fallbacks available.
}
return null;
}
async function getListeningProcess(port) {
@@ -85,23 +126,82 @@ async function killProcessTree(pid) {
}
}
async function waitForServerReady(url, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) {
return;
}
} catch {
// keep polling until ready
function captureProcessOutput(stream, lines) {
const reader = readline.createInterface({ input: stream });
reader.on("line", (line) => {
lines.push(line);
if (lines.length > serverOutputLimit) {
lines.splice(0, lines.length - serverOutputLimit);
}
});
return reader;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
function formatProcessOutputSection(label, lines) {
if (lines.length === 0) {
return "";
}
throw new Error(`Timed out waiting for the local Playwright dev server at ${url}.`);
return `\n${label}:\n${lines.join("\n")}`;
}
function formatServerExitError(url, exitInfo, stdoutLines, stderrLines) {
const exitLabel = exitInfo.signal ? `signal ${exitInfo.signal}` : `code ${exitInfo.code ?? 0}`;
return new Error(
`The local Playwright dev server exited before ${url} became ready (${exitLabel}).${formatProcessOutputSection(
"stdout",
stdoutLines
)}${formatProcessOutputSection("stderr", stderrLines)}`
);
}
async function waitForServerReady(url, serverProcess, stdoutLines, stderrLines, timeoutMs = 120_000) {
const deadline = Date.now() + timeoutMs;
let exitInfo = null;
const handleExit = (code, signal) => {
exitInfo = {
code,
signal,
};
};
serverProcess.on("exit", handleExit);
try {
while (Date.now() < deadline) {
if (exitInfo || serverProcess.exitCode !== null || serverProcess.signalCode !== null) {
throw formatServerExitError(
url,
exitInfo || {
code: serverProcess.exitCode,
signal: serverProcess.signalCode,
},
stdoutLines,
stderrLines
);
}
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) {
return;
}
} catch {
// keep polling until ready
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error(
`Timed out waiting for the local Playwright dev server at ${url}.${formatProcessOutputSection(
"stdout",
stdoutLines
)}${formatProcessOutputSection("stderr", stderrLines)}`
);
} finally {
serverProcess.off("exit", handleExit);
}
}
async function buildWarmupTargets() {
@@ -180,35 +280,40 @@ export default async function globalSetup() {
const serverProcess =
process.platform === "win32"
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host localhost --port ${devPort} --strictPort`], {
? spawn("cmd.exe", ["/d", "/s", "/c", `npm.cmd run dev -- --host ${devHost} --port ${devPort} --strictPort`], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
})
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
: spawn("npm", ["run", "dev", "--", "--host", devHost, "--port", String(devPort), "--strictPort"], {
cwd: process.cwd(),
detached: true,
env: {
...process.env,
PLAYWRIGHT: "1",
},
stdio: "ignore",
stdio: ["ignore", "pipe", "pipe"],
});
serverProcess.unref();
const stdoutLines = [];
const stderrLines = [];
const stdoutReader = captureProcessOutput(serverProcess.stdout, stdoutLines);
const stderrReader = captureProcessOutput(serverProcess.stderr, stderrLines);
await fs.writeFile(pidFile, JSON.stringify({ pid: serverProcess.pid, port: devPort }), "utf8");
try {
await waitForServerReady(baseURL);
await waitForServerReady(baseURL, serverProcess, stdoutLines, stderrLines);
await warmUpDevServer(baseURL);
} catch (error) {
await killProcessTree(serverProcess.pid);
throw error;
} finally {
stdoutReader.close();
stderrReader.close();
}
}
+2 -5
View File
@@ -1,6 +1,6 @@
import { defineConfig, devices } from "@playwright/test";
const baseURL = "http://localhost:4173";
const baseURL = "http://127.0.0.1:4173";
const isCI = !!process.env.CI;
process.env.PLAYWRIGHT_BASE_URL = baseURL;
@@ -13,10 +13,7 @@ export default defineConfig({
forbidOnly: isCI,
retries: isCI ? 2 : 0,
workers: isCI ? 2 : 3,
reporter: [
["list"],
["html", { open: "never", outputFolder: "output/playwright/prod/report" }],
],
reporter: [["list"], ["html", { open: "never", outputFolder: "output/playwright/prod/report" }]],
outputDir: "output/playwright/prod/test-results",
use: {
baseURL,
+11 -10
View File
@@ -2,19 +2,20 @@ export const IS_DEV = import.meta.env.VITE_IS_DEV; // Set to false in production
// Development mode
export const POS_STEP_1_VERSION = 2; //(IS_DEV ? 2 : 1);
export const API_URL = (IS_DEV ? 'https://api.truckwash.io:4433' : 'https://api.truckwash.io');
export const API_URL = IS_DEV ? "https://api.truckwash.io:4433" : "https://api.truckwash.io";
// Allowed origins
export const ALLOWED_ORIGINS = [
'https://truckwash.io',
'https://www.truckwash.io',
'http://localhost:5173',
'http://localhost:4173',
'http://localhost:4174',
'http://127.0.0.1:4173',
'http://127.0.0.1:4174',
"https://truckwash.io",
"https://www.truckwash.io",
"http://localhost:5173",
"http://localhost:4173",
"http://localhost:4174",
"http://127.0.0.1:5173",
"http://127.0.0.1:4173",
"http://127.0.0.1:4174",
];
export const MIGRATION_ORIGIN = 'https://truckwash.io';
export const MIGRATION_ORIGIN = "https://truckwash.io";
// Global request queue / retry / progress configuration
export const REQUEST_QUEUE_CONFIG = Object.freeze({
@@ -43,7 +44,7 @@ export const REQUEST_QUEUE_CONFIG = Object.freeze({
jitterMs: 50,
}),
ping: Object.freeze({
endpoint: '/ping',
endpoint: "/ping",
intervalMs: 30000,
timeoutMs: 5000,
}),
+44 -73
View File
@@ -57,12 +57,15 @@ const buildOverviewPayload = () => ({
},
});
async function mockAdminDepartmentDependencies(page, options: {
departmentDelayMs?: number;
departments?: Array<Record<string, unknown>>;
permissions?: string[];
sessionData?: Record<string, unknown>;
} = {}) {
async function mockAdminDepartmentDependencies(
page,
options: {
departmentDelayMs?: number;
departments?: Array<Record<string, unknown>>;
permissions?: string[];
sessionData?: Record<string, unknown>;
} = {}
) {
await seedAuthenticatedState(page);
await mockApi(page, {
authenticated: true,
@@ -130,10 +133,7 @@ test.describe("Admin department visibility", () => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"department_access_5",
],
permissions: [...adminPermissions, "department_access_5"],
departments: [
{ id: 1, name: "Visible North", visible: true, priority_order: 20 },
{ id: 2, name: "Ingen data", visible: true, priority_order: 1 },
@@ -208,11 +208,7 @@ test.describe("Admin department visibility", () => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"add_order",
"list_orders",
],
permissions: [...adminPermissions, "add_order", "list_orders"],
departments: [
{ id: 1, name: "Visible North", visible: true },
{ id: 2, name: "Visible South", visible: true },
@@ -236,17 +232,11 @@ test.describe("Admin department visibility", () => {
await expect(page.getByTestId("department-pos-drafts-page")).toBeVisible();
});
test("shows a department-scoped draft count badge in the desktop buefy menu", async ({
page,
}, testInfo) => {
test("shows a department-scoped draft count badge in the desktop buefy menu", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"add_order",
"list_orders",
],
permissions: [...adminPermissions, "add_order", "list_orders"],
sessionData: {
runtime_config: {
economic: {
@@ -315,11 +305,7 @@ test.describe("Admin department visibility", () => {
const requestedFilters: string[] = [];
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"add_order",
"list_orders",
],
permissions: [...adminPermissions, "add_order", "list_orders"],
sessionData: {
runtime_config: {
economic: {
@@ -385,10 +371,7 @@ test.describe("Admin department visibility", () => {
const requestedDepartments: string[] = [];
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"list_bookings",
],
permissions: [...adminPermissions, "list_bookings"],
});
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
@@ -427,10 +410,7 @@ test.describe("Admin department visibility", () => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"list_bookings",
],
permissions: [...adminPermissions, "list_bookings"],
});
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
@@ -471,10 +451,7 @@ test.describe("Admin department visibility", () => {
const requestedDepartments: string[] = [];
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"list_bookings",
],
permissions: [...adminPermissions, "list_bookings"],
});
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
@@ -484,17 +461,18 @@ test.describe("Admin department visibility", () => {
await route.fulfill(
json({
data: department === "1"
? {
past: 2,
current: 3,
future: 1,
}
: {
past: 0,
current: 0,
future: 0,
},
data:
department === "1"
? {
past: 2,
current: 3,
future: 1,
}
: {
past: 0,
current: 0,
future: 0,
},
})
);
});
@@ -535,10 +513,7 @@ test.describe("Admin department visibility", () => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"list_bookings",
],
permissions: [...adminPermissions, "list_bookings"],
});
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
@@ -547,17 +522,18 @@ test.describe("Admin department visibility", () => {
await route.fulfill(
json({
data: department === "1"
? {
past: 2,
current: 0,
future: 1,
}
: {
past: 0,
current: 0,
future: 0,
},
data:
department === "1"
? {
past: 2,
current: 0,
future: 1,
}
: {
past: 0,
current: 0,
future: 0,
},
})
);
});
@@ -575,19 +551,14 @@ test.describe("Admin department visibility", () => {
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-future")).toContainText("1");
});
test("uses the cached bookings counts endpoint for desktop buefy menu badges", async ({
page,
}, testInfo) => {
test("uses the cached bookings counts endpoint for desktop buefy menu badges", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const countRequests: string[] = [];
const listRequests: string[] = [];
await mockAdminDepartmentDependencies(page, {
permissions: [
...adminPermissions,
"list_bookings",
],
permissions: [...adminPermissions, "list_bookings"],
});
await page.route(/\/order-bookings\/counts(\?.*)?$/i, async (route) => {
+24 -25
View File
@@ -529,24 +529,27 @@ function createTallActionMenuPosFixture() {
};
return orders;
}, {}),
attachmentsByOrderId: tallOrderIds.reduce<Record<number, Array<Record<string, unknown>>>>((attachments, orderId) => {
attachments[orderId] = Array.from({ length: 3 }, (_, index) => ({
id: 7000 + orderId * 10 + index,
object_type: "orders",
object_id: orderId,
content: {
image: null,
document: `action-menu-${orderId}-${index + 1}.pdf`,
relation: null,
other: `action-menu-${orderId}-${index + 1}.pdf`,
src: null,
},
created_at: "2026-04-21 08:44:07",
updated_at: "2026-04-21 08:44:07",
deleted_at: null,
}));
return attachments;
}, {}),
attachmentsByOrderId: tallOrderIds.reduce<Record<number, Array<Record<string, unknown>>>>(
(attachments, orderId) => {
attachments[orderId] = Array.from({ length: 3 }, (_, index) => ({
id: 7000 + orderId * 10 + index,
object_type: "orders",
object_id: orderId,
content: {
image: null,
document: `action-menu-${orderId}-${index + 1}.pdf`,
relation: null,
other: `action-menu-${orderId}-${index + 1}.pdf`,
src: null,
},
created_at: "2026-04-21 08:44:07",
updated_at: "2026-04-21 08:44:07",
deleted_at: null,
}));
return attachments;
},
{}
),
});
}
@@ -1537,12 +1540,10 @@ test.describe("Admin POS Orders - desktop settings", () => {
await expect(dropdownContent).toHaveCSS("border-top-style", "solid");
await expect(attachmentItems.nth(0)).toContainText(/vaskecertifikat|wash certificate/i);
await expect(attachmentItems.nth(1)).toContainText(/upload/i);
});
});
});
test("hides the draft export action in the order rail for non-superusers", async ({
page,
}, testInfo) => {
test("hides the draft export action in the order rail for non-superusers", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order rail coverage");
await mockApi(page, {
@@ -1576,9 +1577,7 @@ test("hides the draft export action in the order rail for non-superusers", async
await expect(receiptPopup.locator("body")).not.toContainText("Download Excel");
});
test("shows the draft export and receipt actions in the order rail for superusers", async ({
page,
}, testInfo) => {
test("shows the draft export and receipt actions in the order rail for superusers", async ({ page }, testInfo) => {
test.skip(!testInfo.project.name.includes("desktop"), "Desktop-only order rail coverage");
await mockApi(page, {
@@ -136,8 +136,6 @@ test.describe("Assign draft order modal create invoice collection", () => {
}
expect(createdOptionBox.y).toBeGreaterThanOrEqual(modalBodyBox.y - 1);
expect(createdOptionBox.y + createdOptionBox.height).toBeLessThanOrEqual(
modalBodyBox.y + modalBodyBox.height + 1
);
expect(createdOptionBox.y + createdOptionBox.height).toBeLessThanOrEqual(modalBodyBox.y + modalBodyBox.height + 1);
});
});
+3 -1
View File
@@ -151,7 +151,9 @@ test.describe("Orders change customer", () => {
await expect(modal).toHaveCount(0);
await expect(page.getByTestId("change-customer-harness-status")).toHaveText("assigned");
const assignment = await page.evaluate(() => (window as typeof window & { __changeCustomerAssignment?: unknown }).__changeCustomerAssignment);
const assignment = await page.evaluate(
() => (window as typeof window & { __changeCustomerAssignment?: unknown }).__changeCustomerAssignment
);
expect(assignment).toMatchObject({
customer_id: 12345679,
});
+3 -3
View File
@@ -75,9 +75,9 @@ test.describe("Edge gateway management smoke", () => {
await expect(page.locator("body")).toContainText("Installer command copied.");
await expect(page.getByTestId("gateway-installer-copy")).toContainText("Copied");
await expect.poll(() => page.evaluate(() => window.__copiedInstallerCommand)).toContain(
"install.sh?token=edge-install-token"
);
await expect
.poll(() => page.evaluate(() => window.__copiedInstallerCommand))
.toContain("install.sh?token=edge-install-token");
});
test("@smoke shows fleet usage statistics on the landing page", async ({ page }) => {
+10 -9
View File
@@ -1158,9 +1158,7 @@ test.describe("POS flow", () => {
const cardButton = page.getByTestId("pos-card-payment-inline-action");
await expect(cardButton).toBeVisible();
await expect
.poll(() => fixture.readersGet, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect.poll(() => fixture.readersGet, { timeout: 10_000 }).toBeGreaterThan(0);
await expect(cardButton).toBeDisabled();
fixture.readers = [
@@ -1173,9 +1171,12 @@ test.describe("POS flow", () => {
];
await expect
.poll(async () => {
return await cardButton.isEnabled();
}, { timeout: 12_000 })
.poll(
async () => {
return await cardButton.isEnabled();
},
{ timeout: 12_000 }
)
.toBe(true);
});
@@ -1200,9 +1201,9 @@ test.describe("POS flow", () => {
await expect.poll(() => fixture.ordersById[9300]?.id ?? null, { timeout: 10_000 }).toBe(9300);
await expect.poll(() => fixture.ordersById[9300]?.reg_2 ?? null, { timeout: 10_000 }).toBe("TRAILER9");
await expect.poll(() => fixture.ordersById[9300]?.reference ?? null, { timeout: 10_000 }).toBe(
"MANUAL-DESKTOP-SAVE"
);
await expect
.poll(() => fixture.ordersById[9300]?.reference ?? null, { timeout: 10_000 })
.toBe("MANUAL-DESKTOP-SAVE");
});
test("desktop auto-applies the only previous customer suggestion while selection source is none", async ({
+15 -12
View File
@@ -54,7 +54,9 @@ test.describe("POS mobile card payments", () => {
expect(snapshot?.metadata?.reference).toBe("CARD-CTX-REF");
});
test("card mode stays disabled when no stripe readers are assigned and re-enables after polling", async ({ page }) => {
test("card mode stays disabled when no stripe readers are assigned and re-enables after polling", async ({
page,
}) => {
const fixture = createMobilePosFixture({
readers: [],
});
@@ -77,9 +79,7 @@ test.describe("POS mobile card payments", () => {
const cardButton = page.getByTestId("pos-mobile-direct-card-payment");
await expect(cardButton).toBeVisible({ timeout: 10_000 });
await expect
.poll(() => fixture.requestCounters.readersGet, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect.poll(() => fixture.requestCounters.readersGet, { timeout: 10_000 }).toBeGreaterThan(0);
await expect(cardButton).toBeDisabled();
fixture.readers = [
@@ -92,9 +92,12 @@ test.describe("POS mobile card payments", () => {
];
await expect
.poll(async () => {
return await cardButton.isEnabled();
}, { timeout: 12_000 })
.poll(
async () => {
return await cardButton.isEnabled();
},
{ timeout: 12_000 }
)
.toBe(true);
await cardButton.click();
@@ -228,7 +231,10 @@ test.describe("POS mobile card payments", () => {
await expect(page.getByTestId("pos-stripe-terminal-group-in_use")).toBeVisible();
await expect(page.getByTestId("pos-stripe-terminal-group-offline")).toBeVisible();
await expect(page.getByTestId("pos-stripe-terminal-option-reader_in_use")).toHaveAttribute("aria-disabled", "true");
await expect(page.getByTestId("pos-stripe-terminal-option-reader_offline")).toHaveAttribute("aria-disabled", "true");
await expect(page.getByTestId("pos-stripe-terminal-option-reader_offline")).toHaveAttribute(
"aria-disabled",
"true"
);
await expect(page.getByTestId("pos-stripe-terminal-option-dot-reader_offline")).toHaveCSS(
"background-color",
"rgb(220, 38, 38)"
@@ -260,10 +266,7 @@ test.describe("POS mobile card payments", () => {
await expect(page.getByTestId("pos-stripe-create-intent")).toBeEnabled({ timeout: 10_000 });
await expect(page.getByTestId("pos-stripe-selected-reader")).toContainText("Recovered Reader");
await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveAttribute("data-status-key", "ready");
await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveCSS(
"background-color",
"rgb(22, 163, 74)"
);
await expect(page.getByTestId("pos-stripe-terminal-trigger-dot")).toHaveCSS("background-color", "rgb(22, 163, 74)");
expect(fixture.requestCounters.paymentIntentCreate).toBe(0);
});
+3 -8
View File
@@ -9,9 +9,7 @@ const json = (body: unknown, status = 200) => ({
});
test.describe("Superuser bookings", () => {
test("shows a light grey loading indicator while the bookings count is fetching", async ({
page,
}, testInfo) => {
test("shows a light grey loading indicator while the bookings count is fetching", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
await seedAuthenticatedState(page);
@@ -159,8 +157,7 @@ test.describe("Superuser bookings", () => {
const filters = url.searchParams.get("filters") || "";
const limit = Number(url.searchParams.get("limit") || "100");
const isPendingTodayRequest =
filters.includes("datetime-date_from:") && filters.includes("datetime-date_to:");
const isPendingTodayRequest = filters.includes("datetime-date_from:") && filters.includes("datetime-date_to:");
const isDepartmentScoped = filters.includes("department:");
await route.fulfill(
@@ -326,9 +323,7 @@ test.describe("Superuser bookings", () => {
await expect(page.getByTestId("desktop-buefy-nav-bookings-tooltip-future")).toContainText("1");
});
test("uses the cached bookings counts endpoint for superuser menu badges", async ({
page,
}, testInfo) => {
test("uses the cached bookings counts endpoint for superuser menu badges", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const countRequests: string[] = [];
+6 -12
View File
@@ -9,9 +9,7 @@ const json = (body: unknown, status = 200) => ({
});
test.describe("Superuser drafts", () => {
test("shows a light grey loading indicator while the draft count is fetching", async ({
page,
}, testInfo) => {
test("shows a light grey loading indicator while the draft count is fetching", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const orderFilters: string[] = [];
@@ -94,14 +92,12 @@ test.describe("Superuser drafts", () => {
await expect(loadingBadge).toHaveCount(0);
await expect(draftsBadge).toHaveText("1");
expect(orderFilters.some((filters) => filters.includes("customer_id:6001") && !filters.includes("department_id:"))).toBe(
true
);
expect(
orderFilters.some((filters) => filters.includes("customer_id:6001") && !filters.includes("department_id:"))
).toBe(true);
});
test("shows a drafts menu item with a count and lists drafts across departments", async ({
page,
}, testInfo) => {
test("shows a drafts menu item with a count and lists drafts across departments", async ({ page }, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const orderFilters: string[] = [];
@@ -235,9 +231,7 @@ test.describe("Superuser drafts", () => {
await expect(page.locator("body")).toContainText("DN59443");
expect(
orderFilters.filter(
(filters) => filters.includes("customer_id:6001") && filters.includes("department_id:")
)
orderFilters.filter((filters) => filters.includes("customer_id:6001") && filters.includes("department_id:"))
).toEqual([]);
});
});
+4 -1
View File
@@ -753,7 +753,10 @@ export function createMobilePosFixture(overrides = {}) {
ordersById: mergeObjectMaps(base.ordersById, overrides.ordersById),
orderItemsByOrderId: mergeObjectMaps(base.orderItemsByOrderId, overrides.orderItemsByOrderId),
attachmentsByOrderId: mergeObjectMaps(base.attachmentsByOrderId, overrides.attachmentsByOrderId),
stripeModuleOrdersByOrderId: mergeObjectMaps(base.stripeModuleOrdersByOrderId, overrides.stripeModuleOrdersByOrderId),
stripeModuleOrdersByOrderId: mergeObjectMaps(
base.stripeModuleOrdersByOrderId,
overrides.stripeModuleOrdersByOrderId
),
paymentIntentsByOrderId: mergeObjectMaps(base.paymentIntentsByOrderId, overrides.paymentIntentsByOrderId),
bookingsById: mergeObjectMaps(base.bookingsById, overrides.bookingsById),
cvrSearchResponses: mergeObjectMaps(base.cvrSearchResponses, overrides.cvrSearchResponses),
+276 -120
View File
@@ -730,12 +730,7 @@ function createFixtureOperation(type, request = {}, overrides = {}) {
status: overrides.status || "PENDING",
request,
summary: overrides.summary || {
label:
overrides.status === "COMPLETED"
? "Completed"
: overrides.status === "FAILED"
? "Failed"
: "Queued",
label: overrides.status === "COMPLETED" ? "Completed" : overrides.status === "FAILED" ? "Failed" : "Queued",
progress: overrides.status === "COMPLETED" ? 100 : overrides.status === "FAILED" ? 100 : 0,
retryable: overrides.status !== "FAILED",
},
@@ -810,7 +805,8 @@ function buildEdgeGatewayRuntimeFixture(gateway) {
const cloudRelays = relayHealth.filter((relay) => relay.execution_path === "cloud");
const operations = Array.isArray(gateway.operations) ? gateway.operations : [];
const activeOperation = operations.find((operation) => ["PENDING", "IN_PROGRESS"].includes(String(operation.status))) || null;
const activeOperation =
operations.find((operation) => ["PENDING", "IN_PROGRESS"].includes(String(operation.status))) || null;
const versionDrift =
gateway.installed_version && gateway.target_version && gateway.installed_version !== gateway.target_version;
const credentialFreshnessState = gateway.metadata?.credentials_rotated_at ? "FRESH" : "UNKNOWN";
@@ -867,23 +863,26 @@ function buildEdgeGatewayRuntimeFixture(gateway) {
last_successful_discovery_at: gateway.last_successful_discovery_at || gateway.last_heartbeat_at,
active_operation: gateway.active_operation || activeOperation,
recent_operations_summary: gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations),
version_drift:
gateway.version_drift || {
installed_version: gateway.installed_version || null,
target_version: gateway.target_version || null,
release_channel: gateway.release_channel || "stable",
is_drifted: Boolean(versionDrift),
status: versionDrift ? "UPDATE_AVAILABLE" : "IN_SYNC",
},
credential_freshness:
gateway.credential_freshness || {
rotated_at: gateway.metadata?.credentials_rotated_at || null,
age_days: gateway.metadata?.credentials_rotated_at ? 1 : null,
state: credentialFreshnessState,
},
version_drift: gateway.version_drift || {
installed_version: gateway.installed_version || null,
target_version: gateway.target_version || null,
release_channel: gateway.release_channel || "stable",
is_drifted: Boolean(versionDrift),
status: versionDrift ? "UPDATE_AVAILABLE" : "IN_SYNC",
},
credential_freshness: gateway.credential_freshness || {
rotated_at: gateway.metadata?.credentials_rotated_at || null,
age_days: gateway.metadata?.credentials_rotated_at ? 1 : null,
state: credentialFreshnessState,
},
diagnostics: gateway.diagnostics || diagnostics,
error_state:
gateway.error_state || (diagnostics[0] ? { ...diagnostics[0] } : activeOperation?.error_code ? { code: activeOperation.error_code, message: activeOperation.error_message } : null),
gateway.error_state ||
(diagnostics[0]
? { ...diagnostics[0] }
: activeOperation?.error_code
? { code: activeOperation.error_code, message: activeOperation.error_message }
: null),
};
}
@@ -967,16 +966,32 @@ function createEdgeGatewayFixture(options = {}) {
],
recent_commands: [],
operations: [
createFixtureOperation("DISCOVERY", {}, {
id: 8801,
status: "COMPLETED",
started_at: "2026-04-08 08:14:20",
completed_at: "2026-04-08 08:14:38",
events: [
{ id: 1, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: "2026-04-08 08:14:20" },
{ id: 2, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: "2026-04-08 08:14:38" },
],
}),
createFixtureOperation(
"DISCOVERY",
{},
{
id: 8801,
status: "COMPLETED",
started_at: "2026-04-08 08:14:20",
completed_at: "2026-04-08 08:14:38",
events: [
{
id: 1,
level: "INFO",
code: "OPERATION_STARTED",
message: "Gateway started processing the operation",
created_at: "2026-04-08 08:14:20",
},
{
id: 2,
level: "INFO",
code: "OPERATION_COMPLETED",
message: "Operation completed successfully",
created_at: "2026-04-08 08:14:38",
},
],
}
),
],
audit_logs: [{ id: 501, created_at: "2026-04-08 08:16:00", action: "GATEWAY_CLAIMED", actor_type: "USER" }],
},
@@ -1100,7 +1115,9 @@ function buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail
},
binding_summary: {
total: bindings.length,
fallback_overrides: bindings.filter((binding) => String(binding.fallback_mode || "PREFER_LOCAL") !== "PREFER_LOCAL").length,
fallback_overrides: bindings.filter(
(binding) => String(binding.fallback_mode || "PREFER_LOCAL") !== "PREFER_LOCAL"
).length,
},
agent_runtime: {
hostname: gateway.hostname,
@@ -1112,7 +1129,9 @@ function buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail
},
operations,
active_operation: cloneJson(gateway.active_operation || null),
recent_operations_summary: cloneJson(gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations)),
recent_operations_summary: cloneJson(
gateway.recent_operations_summary || buildEdgeGatewayOperationSummary(operations)
),
version_drift: cloneJson(gateway.version_drift || null),
credential_freshness: cloneJson(gateway.credential_freshness || null),
diagnostics: cloneJson(gateway.diagnostics || []),
@@ -1266,7 +1285,10 @@ function processPendingEdgeGatewayClaims(edgeGatewayFixture) {
function createHttpEdgeGatewayFixture(options = {}) {
const gatewayOverridesById = new Map(
(Array.isArray(options.gatewayOverrides) ? options.gatewayOverrides : []).map((gateway) => [Number(gateway.id), gateway])
(Array.isArray(options.gatewayOverrides) ? options.gatewayOverrides : []).map((gateway) => [
Number(gateway.id),
gateway,
])
);
const extraGateways = Array.isArray(options.gateways) ? options.gateways : [];
@@ -1297,25 +1319,69 @@ function createHttpEdgeGatewayFixture(options = {}) {
},
},
inventory: [
{ id: 1, device_id: "shelly-plus-01", local_ip: "10.1.0.31", model: "Shelly Plus 2PM", channel_count: 2, online: true },
{ id: 2, device_id: "shelly-mini-offline", local_ip: "10.1.0.34", model: "Shelly Mini 1", channel_count: 1, online: false },
{
id: 1,
device_id: "shelly-plus-01",
local_ip: "10.1.0.31",
model: "Shelly Plus 2PM",
channel_count: 2,
online: true,
},
{
id: 2,
device_id: "shelly-mini-offline",
local_ip: "10.1.0.34",
model: "Shelly Mini 1",
channel_count: 1,
online: false,
},
],
bindings: [
{ id: 1, relay_id: "M-7", device_id: "shelly-plus-01", local_ip: "10.1.0.31", channel: 0, fallback_mode: "PREFER_LOCAL" },
{ id: 2, relay_id: "M-7-LEGACY", device_id: "shelly-missing-legacy", local_ip: "10.1.0.99", channel: 1, fallback_mode: "CLOUD_ONLY" },
{
id: 1,
relay_id: "M-7",
device_id: "shelly-plus-01",
local_ip: "10.1.0.31",
channel: 0,
fallback_mode: "PREFER_LOCAL",
},
{
id: 2,
relay_id: "M-7-LEGACY",
device_id: "shelly-missing-legacy",
local_ip: "10.1.0.99",
channel: 1,
fallback_mode: "CLOUD_ONLY",
},
],
recent_commands: [],
operations: [
createFixtureOperation("DISCOVERY", {}, {
id: 8801,
status: "COMPLETED",
started_at: "2026-04-08 08:14:20",
completed_at: "2026-04-08 08:14:38",
events: [
{ id: 1, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: "2026-04-08 08:14:20" },
{ id: 2, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: "2026-04-08 08:14:38" },
],
}),
createFixtureOperation(
"DISCOVERY",
{},
{
id: 8801,
status: "COMPLETED",
started_at: "2026-04-08 08:14:20",
completed_at: "2026-04-08 08:14:38",
events: [
{
id: 1,
level: "INFO",
code: "OPERATION_STARTED",
message: "Gateway started processing the operation",
created_at: "2026-04-08 08:14:20",
},
{
id: 2,
level: "INFO",
code: "OPERATION_COMPLETED",
message: "Operation completed successfully",
created_at: "2026-04-08 08:14:38",
},
],
}
),
],
audit_logs: [{ id: 501, created_at: "2026-04-08 08:16:00", action: "GATEWAY_CLAIMED", actor_type: "USER" }],
},
@@ -1346,7 +1412,9 @@ function createHttpEdgeGatewayFixture(options = {}) {
bindings: [],
recent_commands: [],
operations: [],
audit_logs: [{ id: 502, created_at: "2026-04-07 21:05:00", action: "HEARTBEAT_TIMEOUT", actor_type: "SYSTEM" }],
audit_logs: [
{ id: 502, created_at: "2026-04-07 21:05:00", action: "HEARTBEAT_TIMEOUT", actor_type: "SYSTEM" },
],
},
]
.map((gateway) => mergeEdgeGatewayFixtureRecord(gateway, gatewayOverridesById.get(Number(gateway.id)) || {}))
@@ -2615,14 +2683,19 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
const orderId = Number(body.order_id || 0);
const existingInvoice = posFixture.stripeModuleOrdersByOrderId[orderId] || {};
if (existingInvoice?.invoice_id && !isTerminalStripeInvoiceStatus(existingInvoice.status)) {
await route.fulfill(json({
success: false,
data: {
message: "A Stripe payment link is already active for this order.",
code: "stripe_invoice_exists",
stripeModuleOrders: existingInvoice,
},
}, 409));
await route.fulfill(
json(
{
success: false,
data: {
message: "A Stripe payment link is already active for this order.",
code: "stripe_invoice_exists",
stripeModuleOrders: existingInvoice,
},
},
409
)
);
return true;
}
@@ -2631,18 +2704,20 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
});
posFixture.stripeModuleOrdersByOrderId[orderId] = nextInvoice;
await route.fulfill(json({
success: true,
data: {
id: nextInvoice.invoice_id,
customer: nextInvoice.customer_id,
hosted_invoice_url: nextInvoice.url,
paid: nextInvoice.paid,
status: nextInvoice.status,
amount_due: nextInvoice.amount_due,
amount_paid: nextInvoice.amount_paid,
},
}));
await route.fulfill(
json({
success: true,
data: {
id: nextInvoice.invoice_id,
customer: nextInvoice.customer_id,
hosted_invoice_url: nextInvoice.url,
paid: nextInvoice.paid,
status: nextInvoice.status,
amount_due: nextInvoice.amount_due,
amount_paid: nextInvoice.amount_paid,
},
})
);
return true;
}
@@ -2653,48 +2728,50 @@ async function handlePosRoute({ route, request, parsedUrl, pathname, method, pos
if (!existingInvoice?.invoice_id) {
posFixture.stripeModuleOrdersByOrderId[orderId] = {};
await route.fulfill(json({
success: true,
data: {
stripeModuleOrders: [],
},
}));
await route.fulfill(
json({
success: true,
data: {
stripeModuleOrders: [],
},
})
);
return true;
}
if (Boolean(existingInvoice.paid) || String(existingInvoice.status || "") === "paid") {
await route.fulfill(json({
success: false,
data: {
message: "A paid Stripe payment link cannot be cancelled.",
code: "stripe_invoice_paid",
stripeModuleOrders: existingInvoice,
},
}, 409));
await route.fulfill(
json(
{
success: false,
data: {
message: "A paid Stripe payment link cannot be cancelled.",
code: "stripe_invoice_paid",
stripeModuleOrders: existingInvoice,
},
},
409
)
);
return true;
}
posFixture.stripeModuleOrdersByOrderId[orderId] = {};
await route.fulfill(json({
success: true,
data: {
stripeModuleOrders: [],
},
}));
await route.fulfill(
json({
success: true,
data: {
stripeModuleOrders: [],
},
})
);
return true;
}
return false;
}
async function handleEdgeGatewayRoute({
route,
request,
parsedUrl,
pathname,
method,
edgeGatewayFixture,
}) {
async function handleEdgeGatewayRoute({ route, request, parsedUrl, pathname, method, edgeGatewayFixture }) {
if (!edgeGatewayFixture) {
return false;
}
@@ -2707,7 +2784,9 @@ async function handleEdgeGatewayRoute({
const findGateway = (gatewayId) =>
edgeGatewayFixture.gateways.find((entry) => Number(entry.id) === Number(gatewayId)) || null;
const gatewayResponse = (gateway, includeDetail = true) =>
gateway ? json({ data: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail) }) : json({ message: "Gateway not found" }, 404);
gateway
? json({ data: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, includeDetail) })
: json({ message: "Gateway not found" }, 404);
const createOperation = (gateway, type, request = {}, overrides = {}) => {
const operation = createFixtureOperation(type, request, {
id: edgeGatewayFixture.nextOperationId++,
@@ -2788,8 +2867,20 @@ async function handleEdgeGatewayRoute({
updated_at: now,
summary: { label: "Gateway is processing the operation", progress: 20, retryable: true },
events: [
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now },
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: now },
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "INFO",
code: "OPERATION_QUEUED",
message: "Operation queued for gateway execution",
created_at: now,
},
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "INFO",
code: "OPERATION_STARTED",
message: "Gateway started processing the operation",
created_at: now,
},
],
});
edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = {
@@ -2814,10 +2905,26 @@ async function handleEdgeGatewayRoute({
completed_at: now,
updated_at: now,
summary: { label: "Completed", progress: 100, retryable: true },
result: { installed_version: gateway.installed_version, target_version: gateway.target_version, restart_required: true },
result: {
installed_version: gateway.installed_version,
target_version: gateway.target_version,
restart_required: true,
},
events: [
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now },
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: now },
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "INFO",
code: "OPERATION_QUEUED",
message: "Operation queued for gateway execution",
created_at: now,
},
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "INFO",
code: "OPERATION_COMPLETED",
message: "Operation completed successfully",
created_at: now,
},
],
});
} else if (operationType === "UNINSTALL") {
@@ -2834,12 +2941,29 @@ async function handleEdgeGatewayRoute({
summary: { label: "Completed", progress: 100, retryable: false },
result: { uninstalled: true, manual_cleanup_required: true },
events: [
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now },
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_COMPLETED", message: "Operation completed successfully", created_at: now },
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "INFO",
code: "OPERATION_QUEUED",
message: "Operation queued for gateway execution",
created_at: now,
},
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "INFO",
code: "OPERATION_COMPLETED",
message: "Operation completed successfully",
created_at: now,
},
],
});
} else {
await route.fulfill(json({ data: { message: "Unsupported gateway operation type", error_code: "EDGE_GATEWAY_VALIDATION_FAILED" } }, 422));
await route.fulfill(
json(
{ data: { message: "Unsupported gateway operation type", error_code: "EDGE_GATEWAY_VALIDATION_FAILED" } },
422
)
);
return true;
}
@@ -2848,7 +2972,17 @@ async function handleEdgeGatewayRoute({
...(gateway.audit_logs || []),
];
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
await route.fulfill(json({ data: { operation: cloneJson(operation), gateway: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) } }, 201));
await route.fulfill(
json(
{
data: {
operation: cloneJson(operation),
gateway: buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true),
},
},
201
)
);
return true;
}
@@ -2905,16 +3039,33 @@ async function handleEdgeGatewayRoute({
if (gateway) {
const now = toSqlDateTime();
const operation = createOperation(gateway, "DISCOVERY", {}, {
status: "IN_PROGRESS",
started_at: now,
updated_at: now,
summary: { label: "Gateway is processing the operation", progress: 20, retryable: true },
events: [
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_QUEUED", message: "Operation queued for gateway execution", created_at: now },
{ id: edgeGatewayFixture.nextOperationEventId++, level: "INFO", code: "OPERATION_STARTED", message: "Gateway started processing the operation", created_at: now },
],
});
const operation = createOperation(
gateway,
"DISCOVERY",
{},
{
status: "IN_PROGRESS",
started_at: now,
updated_at: now,
summary: { label: "Gateway is processing the operation", progress: 20, retryable: true },
events: [
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "INFO",
code: "OPERATION_QUEUED",
message: "Operation queued for gateway execution",
created_at: now,
},
{
id: edgeGatewayFixture.nextOperationEventId++,
level: "INFO",
code: "OPERATION_STARTED",
message: "Gateway started processing the operation",
created_at: now,
},
],
}
);
gateway.discovery_status = "PENDING";
edgeGatewayFixture.pendingDiscoveryByGatewayId[gatewayId] = {
operationId: operation.id,
@@ -3013,11 +3164,16 @@ async function handleEdgeGatewayRoute({
}));
Object.assign(gateway, buildEdgeGatewayRuntimeFixture(gateway));
}
await route.fulfill(json({ data: gateway ? buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) : null }));
await route.fulfill(
json({ data: gateway ? buildHttpEdgeGatewayGateway(edgeGatewayFixture, gateway, true) : null })
);
return true;
}
if (/\/edge-gateways\/\d+\/(?:update-jobs|uninstall)(?:\/.*)?$/.test(pathname) || /\/edge-gateways\/\d+\/shell-sessions(?:\/.*)?$/.test(pathname)) {
if (
/\/edge-gateways\/\d+\/(?:update-jobs|uninstall)(?:\/.*)?$/.test(pathname) ||
/\/edge-gateways\/\d+\/shell-sessions(?:\/.*)?$/.test(pathname)
) {
await route.fulfill(json({ message: "Route not found" }, 404));
return true;
}
+4 -2
View File
@@ -8,10 +8,12 @@ describe("edge gateway service contract", () => {
it("wraps the v2 backend edge gateway REST endpoints", () => {
expect(source).toContain('"/departments"');
expect(source).toContain('"/edge-gateways"');
expect(source).toContain("view = \"summary\"");
expect(source).toContain('view = "summary"');
expect(source).toContain('"/edge-gateways/install-token"');
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/operations");
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/operations/${encodeURIComponent(operationId)}/events");
expect(source).toContain(
"/edge-gateways/${encodeURIComponent(gatewayId)}/operations/${encodeURIComponent(operationId)}/events"
);
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/rotate-credentials");
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/discovery");
expect(source).toContain("/edge-gateways/${encodeURIComponent(gatewayId)}/bindings");
@@ -5,15 +5,18 @@ import { describe, expect, it } from "vitest";
import { inferEdgeGatewayErrorCode, normalizeEdgeGatewayError } from "@/features/edgeGateways/edgeGatewayErrors.js";
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
const operationsSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"), "utf8");
const operationsSource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"),
"utf8"
);
const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8");
describe("edge gateway workflow helpers", () => {
it("exposes v2 tabs and management actions without shell controls", () => {
expect(managerSource).toContain("id: \"overview\"");
expect(managerSource).toContain("id: \"inventory\"");
expect(managerSource).toContain("id: \"operations\"");
expect(managerSource).toContain("id: \"manage\"");
expect(managerSource).toContain('id: "overview"');
expect(managerSource).toContain('id: "inventory"');
expect(managerSource).toContain('id: "operations"');
expect(managerSource).toContain('id: "manage"');
expect(managerSource).toContain("`gateway-tab-${tab.id}`");
expect(operationsSource).toContain('data-testid="gateway-operation-update"');
expect(operationsSource).toContain('data-testid="gateway-operation-uninstall"');
+16 -7
View File
@@ -3,9 +3,18 @@ import { join } from "node:path";
import { describe, expect, it } from "vitest";
const managerSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManager.vue"), "utf8");
const overviewSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"), "utf8");
const inventorySource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"), "utf8");
const operationsSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"), "utf8");
const overviewSource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOverviewPage.vue"),
"utf8"
);
const inventorySource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayInventoryPage.vue"),
"utf8"
);
const operationsSource = readFileSync(
join(process.cwd(), "src/features/edgeGateways/EdgeGatewayOperationsPage.vue"),
"utf8"
);
const manageSource = readFileSync(join(process.cwd(), "src/features/edgeGateways/EdgeGatewayManagePage.vue"), "utf8");
const routerSource = readFileSync(join(process.cwd(), "src/router.js"), "utf8");
const edgeGatewaysPageSource = readFileSync(
@@ -23,10 +32,10 @@ describe("edge gateway workspace contract", () => {
expect(managerSource).toContain('data-testid="gateway-error-details"');
expect(managerSource).toContain('data-testid="gateway-fleet-usage"');
expect(managerSource).toContain("response?.data?.meta?.fleet_usage");
expect(managerSource).toContain('import EdgeGatewayOverviewPage');
expect(managerSource).toContain('import EdgeGatewayInventoryPage');
expect(managerSource).toContain('import EdgeGatewayOperationsPage');
expect(managerSource).toContain('import EdgeGatewayManagePage');
expect(managerSource).toContain("import EdgeGatewayOverviewPage");
expect(managerSource).toContain("import EdgeGatewayInventoryPage");
expect(managerSource).toContain("import EdgeGatewayOperationsPage");
expect(managerSource).toContain("import EdgeGatewayManagePage");
expect(overviewSource).toContain('data-testid="gateway-overview-page"');
expect(inventorySource).toContain('data-testid="gateway-inventory-page"');
expect(operationsSource).toContain('data-testid="gateway-operations-page"');
+16
View File
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
const UTF8_BOM = "\uFEFF";
export const stripUtf8Bom = (value) => {
if (typeof value !== "string") {
return value;
}
return value.startsWith(UTF8_BOM) ? value.slice(1) : value;
};
export const readJsonFile = (path) => {
const raw = readFileSync(path, "utf8");
return JSON.parse(stripUtf8Bom(raw));
};
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readJsonFile } from "./helpers/readJsonFile";
const root = process.cwd();
const componentPath = join(root, "src/components/displays/buttons/ActionSettingsWheelButton.vue");
@@ -8,7 +9,7 @@ const activeLocales = ["da", "en", "sv", "de", "no"];
const translationCallPattern = /(?:\$t|\bt)\(\s*(["'])([^"'\\]*(?:\\.[^"'\\]*)*)\1/g;
const readLocale = (locale) => {
return JSON.parse(readFileSync(join(root, `src/i18n/locales/${locale}.json`), "utf8"));
return readJsonFile(join(root, `src/i18n/locales/${locale}.json`));
};
const extractLiteralTranslationKeys = (source) => {
+6 -6
View File
@@ -1,15 +1,15 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readJsonFile } from "./helpers/readJsonFile";
const root = process.cwd();
const locales = {
da: JSON.parse(readFileSync(join(root, "src/i18n/locales/da.json"), "utf8")),
en: JSON.parse(readFileSync(join(root, "src/i18n/locales/en.json"), "utf8")),
sv: JSON.parse(readFileSync(join(root, "src/i18n/locales/sv.json"), "utf8")),
de: JSON.parse(readFileSync(join(root, "src/i18n/locales/de.json"), "utf8")),
no: JSON.parse(readFileSync(join(root, "src/i18n/locales/no.json"), "utf8")),
da: readJsonFile(join(root, "src/i18n/locales/da.json")),
en: readJsonFile(join(root, "src/i18n/locales/en.json")),
sv: readJsonFile(join(root, "src/i18n/locales/sv.json")),
de: readJsonFile(join(root, "src/i18n/locales/de.json")),
no: readJsonFile(join(root, "src/i18n/locales/no.json")),
};
const flattenKeys = (value, prefix = "") => {
+2 -2
View File
@@ -9,7 +9,7 @@ const createAppMock = vi.hoisted(() =>
use: createAppUseMock,
mount: createAppMountMock,
unmount: createAppUnmountMock,
})),
}))
);
vi.mock("sweetalert2", () => ({
@@ -89,7 +89,7 @@ describe("Orders.showChangeCustomerForm", () => {
},
onAssigned: expect.any(Function),
onClose: expect.any(Function),
}),
})
);
expect(Swal.fire).not.toHaveBeenCalled();
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { readJsonFile, stripUtf8Bom } from "./helpers/readJsonFile";
describe("readJsonFile helper", () => {
it("strips UTF-8 BOM prefixes before parsing JSON", () => {
const baseDir = mkdtempSync(join(tmpdir(), "read-json-file-"));
const fixturePath = join(baseDir, "locale.json");
try {
writeFileSync(fixturePath, '\uFEFF{"label":"Hej"}', "utf8");
expect(readJsonFile(fixturePath)).toEqual({ label: "Hej" });
expect(stripUtf8Bom("\uFEFFsample")).toBe("sample");
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});
});
+35 -33
View File
@@ -1,65 +1,67 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from "vitest";
import {
getStripeTerminalStatusKey,
getStripeTerminalReaderById,
normalizeStripeTerminalReaders,
selectPreferredStripeTerminalReaderId,
STRIPE_TERMINAL_STATUS,
} from '@/components/displays/department/pos/displays/stripeTerminalReaders.js';
} from "@/components/displays/department/pos/displays/stripeTerminalReaders.js";
describe('stripeTerminalReaders', () => {
it('maps raw Stripe reader status into ready, in-use, and offline states', () => {
expect(getStripeTerminalStatusKey({ status: 'online', action: null })).toBe(STRIPE_TERMINAL_STATUS.ready);
expect(getStripeTerminalStatusKey({ status: 'busy', action: null })).toBe(STRIPE_TERMINAL_STATUS.inUse);
expect(
getStripeTerminalStatusKey({ status: 'online', action: { type: 'process_payment_intent' } }),
).toBe(STRIPE_TERMINAL_STATUS.inUse);
expect(getStripeTerminalStatusKey({ status: 'offline', action: null })).toBe(STRIPE_TERMINAL_STATUS.offline);
expect(getStripeTerminalStatusKey({ status: 'unknown_state', action: null })).toBe(STRIPE_TERMINAL_STATUS.offline);
describe("stripeTerminalReaders", () => {
it("maps raw Stripe reader status into ready, in-use, and offline states", () => {
expect(getStripeTerminalStatusKey({ status: "online", action: null })).toBe(STRIPE_TERMINAL_STATUS.ready);
expect(getStripeTerminalStatusKey({ status: "busy", action: null })).toBe(STRIPE_TERMINAL_STATUS.inUse);
expect(getStripeTerminalStatusKey({ status: "online", action: { type: "process_payment_intent" } })).toBe(
STRIPE_TERMINAL_STATUS.inUse
);
expect(getStripeTerminalStatusKey({ status: "offline", action: null })).toBe(STRIPE_TERMINAL_STATUS.offline);
expect(getStripeTerminalStatusKey({ status: "unknown_state", action: null })).toBe(STRIPE_TERMINAL_STATUS.offline);
});
it('normalizes readers, keeps blocked readers visible, and sorts them by status priority', () => {
it("normalizes readers, keeps blocked readers visible, and sorts them by status priority", () => {
const normalizedReaders = normalizeStripeTerminalReaders([
{ id: 'offline_terminal', label: 'Offline terminal', status: 'offline', action: null },
{ id: 'busy_terminal', label: 'Busy terminal', status: 'online', action: { type: 'payment_intent' } },
{ id: 'ready_terminal', label: 'Ready terminal', status: 'online', action: null },
{ id: "offline_terminal", label: "Offline terminal", status: "offline", action: null },
{ id: "busy_terminal", label: "Busy terminal", status: "online", action: { type: "payment_intent" } },
{ id: "ready_terminal", label: "Ready terminal", status: "online", action: null },
]);
expect(normalizedReaders.map((reader) => reader.id)).toEqual([
'ready_terminal',
'busy_terminal',
'offline_terminal',
"ready_terminal",
"busy_terminal",
"offline_terminal",
]);
expect(normalizedReaders.map((reader) => reader.selectable)).toEqual([true, false, false]);
expect(normalizedReaders.map((reader) => reader.colorToken)).toEqual(['green', 'yellow', 'red']);
expect(normalizedReaders.map((reader) => reader.colorToken)).toEqual(["green", "yellow", "red"]);
});
it('keeps the current ready reader selected when it remains ready', () => {
it("keeps the current ready reader selected when it remains ready", () => {
const normalizedReaders = normalizeStripeTerminalReaders([
{ id: 'ready_terminal_a', label: 'Ready terminal A', status: 'online', action: null },
{ id: 'ready_terminal_b', label: 'Ready terminal B', status: 'online', action: null },
{ id: "ready_terminal_a", label: "Ready terminal A", status: "online", action: null },
{ id: "ready_terminal_b", label: "Ready terminal B", status: "online", action: null },
]);
expect(selectPreferredStripeTerminalReaderId(normalizedReaders, 'ready_terminal_b')).toBe('ready_terminal_b');
expect(selectPreferredStripeTerminalReaderId(normalizedReaders, "ready_terminal_b")).toBe("ready_terminal_b");
});
it('falls back to the first ready reader when the current reader is no longer selectable', () => {
it("falls back to the first ready reader when the current reader is no longer selectable", () => {
const normalizedReaders = normalizeStripeTerminalReaders([
{ id: 'busy_terminal', label: 'Busy terminal', status: 'busy', action: null },
{ id: 'ready_terminal', label: 'Ready terminal', status: 'online', action: null },
{ id: 'offline_terminal', label: 'Offline terminal', status: 'offline', action: null },
{ id: "busy_terminal", label: "Busy terminal", status: "busy", action: null },
{ id: "ready_terminal", label: "Ready terminal", status: "online", action: null },
{ id: "offline_terminal", label: "Offline terminal", status: "offline", action: null },
]);
expect(selectPreferredStripeTerminalReaderId(normalizedReaders, 'busy_terminal')).toBe('ready_terminal');
expect(getStripeTerminalReaderById(normalizedReaders, 'offline_terminal')?.statusKey).toBe(STRIPE_TERMINAL_STATUS.offline);
expect(selectPreferredStripeTerminalReaderId(normalizedReaders, "busy_terminal")).toBe("ready_terminal");
expect(getStripeTerminalReaderById(normalizedReaders, "offline_terminal")?.statusKey).toBe(
STRIPE_TERMINAL_STATUS.offline
);
});
it('clears the selection when no ready readers remain', () => {
it("clears the selection when no ready readers remain", () => {
const normalizedReaders = normalizeStripeTerminalReaders([
{ id: 'busy_terminal', label: 'Busy terminal', status: 'online', action: { type: 'payment_intent' } },
{ id: 'offline_terminal', label: 'Offline terminal', status: 'offline', action: null },
{ id: "busy_terminal", label: "Busy terminal", status: "online", action: { type: "payment_intent" } },
{ id: "offline_terminal", label: "Offline terminal", status: "offline", action: null },
]);
expect(selectPreferredStripeTerminalReaderId(normalizedReaders, 'busy_terminal')).toBe('');
expect(selectPreferredStripeTerminalReaderId(normalizedReaders, "busy_terminal")).toBe("");
});
});
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readJsonFile } from "./helpers/readJsonFile";
const root = process.cwd();
const modalSource = readFileSync(
@@ -15,9 +16,9 @@ const departmentGoalSupportSource = readFileSync(
join(root, "src/components/viewport/page/headers/menu/systemSearchDepartmentGoalSupport.ts"),
"utf8"
);
const enLocale = JSON.parse(readFileSync(join(root, "src/i18n/locales/en.json"), "utf8"));
const daLocale = JSON.parse(readFileSync(join(root, "src/i18n/locales/da.json"), "utf8"));
const deLocale = JSON.parse(readFileSync(join(root, "src/i18n/locales/de.json"), "utf8"));
const enLocale = readJsonFile(join(root, "src/i18n/locales/en.json"));
const daLocale = readJsonFile(join(root, "src/i18n/locales/da.json"));
const deLocale = readJsonFile(join(root, "src/i18n/locales/de.json"));
const entityTypesMatch = supportSource.match(/export const SYSTEM_SEARCH_ENTITY_TYPES = \[(.*?)\] as const;/s);
const systemSearchEntityTypes = Array.from(entityTypesMatch?.[1]?.matchAll(/'([^']+)'/g) ?? [], (match) => match[1]);
+6 -6
View File
@@ -1,15 +1,15 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { readJsonFile } from "./helpers/readJsonFile";
const root = process.cwd();
const locales = {
da: JSON.parse(readFileSync(join(root, "src/i18n/locales/da.json"), "utf8")),
en: JSON.parse(readFileSync(join(root, "src/i18n/locales/en.json"), "utf8")),
sv: JSON.parse(readFileSync(join(root, "src/i18n/locales/sv.json"), "utf8")),
de: JSON.parse(readFileSync(join(root, "src/i18n/locales/de.json"), "utf8")),
no: JSON.parse(readFileSync(join(root, "src/i18n/locales/no.json"), "utf8")),
da: readJsonFile(join(root, "src/i18n/locales/da.json")),
en: readJsonFile(join(root, "src/i18n/locales/en.json")),
sv: readJsonFile(join(root, "src/i18n/locales/sv.json")),
de: readJsonFile(join(root, "src/i18n/locales/de.json")),
no: readJsonFile(join(root, "src/i18n/locales/no.json")),
};
const flattenKeys = (value, prefix = "") => {