Files
pleno-vue/tests/e2e/edge-gateways.smoke.spec.js

1196 lines
50 KiB
JavaScript

import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js";
async function primeSuperuserSession(page) {
await primeMockSession(page, { token: "superuser-edge-gateway-token" });
}
const transientDynamicImportPattern = /(?:Failed to fetch dynamically imported module|ERR_NETWORK_CHANGED)/iu;
const edgeGatewayNavigationTimeouts = [10_000, 15_000, 20_000];
async function gotoEdgeAgentView(
page,
view = "overview",
{ gatewayId = 701, readyTestId = "gateway-detail-header" } = {}
) {
const viewPath = `/superuser/selfserve/edge-agents/${encodeURIComponent(String(gatewayId))}/${view}`;
const readyLocator = page.getByTestId(readyTestId);
const loadErrors = [];
const recordLoadError = (error) => {
const message = typeof error === "string" ? error : error?.message || String(error || "");
if (transientDynamicImportPattern.test(message)) {
loadErrors.push(message);
}
};
const onConsole = (message) => {
if (message.type() === "error") {
recordLoadError(message.text());
}
};
const onPageError = (error) => recordLoadError(error);
page.on("console", onConsole);
page.on("pageerror", onPageError);
try {
let lastNavigationError = null;
let lastReadyError = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
loadErrors.length = 0;
lastNavigationError = null;
lastReadyError = null;
try {
await page.goto(viewPath, { waitUntil: "domcontentloaded" });
} catch (error) {
lastNavigationError = error;
recordLoadError(error);
}
if (lastNavigationError && loadErrors.length === 0) {
throw lastNavigationError;
}
try {
await readyLocator.waitFor({ state: "visible", timeout: edgeGatewayNavigationTimeouts[attempt] });
return;
} catch (error) {
lastReadyError = error;
}
}
if (lastNavigationError) {
throw lastNavigationError;
}
if (lastReadyError) {
throw lastReadyError;
}
await expect(readyLocator).toBeVisible({ timeout: edgeGatewayNavigationTimeouts.at(-1) });
} finally {
page.off("console", onConsole);
page.off("pageerror", onPageError);
}
}
async function gotoEdgeAgentTerminal(page, gatewayId = 701) {
await gotoEdgeAgentView(page, "terminal", { gatewayId, readyTestId: "gateway-terminal-page" });
}
async function selectLastGatewayBindingRelay(page, relayId) {
const relaySelect = page.locator('[data-testid^="gateway-binding-relay-"]').last();
await expect
.poll(() =>
relaySelect.evaluate(
(select, expectedValue) => Array.from(select.options).some((option) => option.value === expectedValue),
relayId
)
)
.toBe(true);
await relaySelect.selectOption(relayId);
return relaySelect;
}
const json = (body, status = 200) => ({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
async function acceleratePageTimers(page, timerScale = 0.01) {
await page.addInitScript((scale) => {
const nativeSetTimeout = window.setTimeout.bind(window);
const nativeSetInterval = window.setInterval.bind(window);
window.setTimeout = (callback, delay = 0, ...args) =>
nativeSetTimeout(callback, Math.max(1, Number(delay || 0) * scale), ...args);
window.setInterval = (callback, delay = 0, ...args) =>
nativeSetInterval(callback, Math.max(1, Number(delay || 0) * scale), ...args);
}, timerScale);
}
async function dismissUnexpectedSweetAlert(page) {
const overlays = page.locator(".swal2-container.swal2-backdrop-show");
for (let attempt = 0; attempt < 3; attempt += 1) {
const overlay = overlays.first();
if (!(await overlay.isVisible().catch(() => false))) {
return;
}
let dismissed = false;
for (const selector of [".swal2-close", ".swal2-cancel", ".swal2-deny", ".swal2-confirm"]) {
const action = overlay.locator(selector).first();
if (await action.isVisible().catch(() => false)) {
await action.click({ force: true });
dismissed = true;
break;
}
}
if (!dismissed) {
await page.keyboard.press("Escape");
}
await expect(overlays)
.toHaveCount(0, { timeout: 5_000 })
.catch(() => {});
}
}
async function generateGatewayInstaller(page) {
const button = page.getByTestId("gateway-installer-generate");
await expect(button).toBeVisible({ timeout: 15_000 });
await expect(button).toBeEnabled({ timeout: 15_000 });
let lastError;
for (let attempt = 0; attempt < 3; attempt += 1) {
await dismissUnexpectedSweetAlert(page);
try {
await button.click({ timeout: 15_000 });
return;
} catch (error) {
lastError = error;
if (!/swal2-container|intercepts pointer events/i.test(error?.message || "")) {
throw error;
}
}
}
throw lastError;
}
test.describe("Edge gateway management smoke", () => {
test.describe.configure({ mode: "serial" });
test("@smoke @pr shows technical request details for backend errors", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await page.route(/\/(?:modules\/)?edge-gateways(?:\?.*)?$/i, async (route, request) => {
if (request.method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
success: false,
data: {
message: "Internal server error: SQLSTATE[42S22]: Column not found",
error_code: "EDGE_GATEWAY_VALIDATION_FAILED",
},
}),
});
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByText("Column not found")).toBeVisible({ timeout: 15000 });
const errorDetails = page.locator("details").filter({ hasText: "Technical details" }).last();
await errorDetails.click();
await expect(errorDetails).toContainText("Request:");
await expect(errorDetails).toContainText("Status: HTTP 500");
});
test("@smoke saves module defaults on the canonical module workspace", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/configuration/edgegateway");
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible({
timeout: edgeGatewayNavigationTimeouts.at(-1),
});
await page.getByTestId("gateway-module-release-channel").selectOption("canary");
await page.getByTestId("gateway-module-update-window").fill("03:00-05:00");
await page.getByTestId("gateway-module-save").click();
await expect(page.getByTestId("gateway-module-release-channel")).toHaveValue("canary");
await expect(page.getByTestId("gateway-module-update-window")).toHaveValue("03:00-05:00");
});
test("@smoke exposes a copy action for generated installer commands", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (text) => {
window.__copiedInstallerCommand = text;
},
},
});
window.__copiedInstallerCommand = "";
});
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
empty: true,
claimPollsRemaining: 99,
},
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents");
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Copy Test Pi");
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/);
await page.getByTestId("gateway-installer-copy").click();
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");
});
test("@smoke shows fleet usage statistics on the landing page", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-fleet-usage")).toBeVisible();
await expect(page.getByTestId("gateway-fleet-usage-coverage")).toContainText("2 gateways");
await expect(page.getByTestId("gateway-fleet-usage-coverage")).toContainText("1 online");
await expect(page.getByTestId("gateway-fleet-usage-devices")).toContainText("1 online");
await expect(page.getByTestId("gateway-fleet-usage-devices")).toContainText("1 offline");
await expect(page.getByTestId("gateway-fleet-usage-bindings")).toContainText("1 overrides");
await expect(page.getByTestId("gateway-fleet-usage-bindings")).toContainText("1 cloud only");
await expect(page.getByTestId("gateway-fleet-usage-runtime")).toContainText("298 ms");
});
test("@smoke keeps watching slow installers until the first gateway claim completes", async ({ page }) => {
test.setTimeout(90_000);
await acceleratePageTimers(page);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
empty: true,
claimPollsRemaining: 65,
},
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
await expect(page.getByTestId("gateway-onboarding")).toContainText("Install new gateway");
await expect(page.getByTestId("gateway-fleet-usage-coverage")).toContainText("0 gateways");
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Canary Pi");
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running");
await expect
.poll(async () => (await page.getByTestId("gateway-installer-status-step").textContent())?.trim())
.not.toBe("Waiting to start");
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/703\/overview$/, { timeout: 20_000 });
await expect(page.getByTestId("gateway-detail-header")).toContainText("Canary Pi");
await expect(page.locator("body")).toContainText("Gateway connected.");
});
test("@smoke reopens an existing gateway when the installer reconnects it", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
reuseClaimGatewayId: 701,
claimPollsRemaining: 8,
},
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("CPH Edge 01");
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
await expect.poll(() => page.getByTestId("gateway-installer-status-state").textContent()).toContain("Running");
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/overview$/, { timeout: 20_000 });
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
await expect(page.locator("body")).toContainText("Gateway reconnected.");
});
test("@smoke surfaces pre-claim installer failures with diagnostics", async ({ page }) => {
test.setTimeout(90_000);
await acceleratePageTimers(page);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
empty: true,
claimPollsRemaining: 8,
installSessionFailure: {
step: "START_STACK",
failurePoll: 4,
message:
"Job for truckwash-edge-gateway-stack.service failed because the control process exited with error code.",
diagnostics: [
{
name: "systemctl status",
output:
"Job for truckwash-edge-gateway-stack.service failed because the control process exited with error code.",
},
{
name: "journalctl",
output: "Compose rollout failed during build/startup",
},
],
},
},
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents");
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Broken Pi");
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-installer-status")).toBeVisible();
await expect
.poll(() => page.getByTestId("gateway-installer-status-state").textContent(), { timeout: 15_000 })
.toContain("Failed");
await expect(page.getByTestId("gateway-installer-status-step")).toContainText("Starting stack");
await expect(page.getByTestId("gateway-installer-status-error")).toContainText(
"Job for truckwash-edge-gateway-stack.service failed"
);
await page.getByTestId("gateway-installer-status-diagnostics").locator("summary").click();
await expect(page.getByTestId("gateway-installer-status-diagnostics")).toContainText("systemctl status");
await expect(page.getByTestId("gateway-installer-status-diagnostics")).toContainText(
"Compose rollout failed during build/startup"
);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents(?:\?.*)?$/);
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
await expect(page.getByTestId("gateway-onboarding")).toContainText("Install new gateway");
await expect(page.getByTestId("gateway-fleet-usage-coverage")).toContainText("0 gateways");
});
test("@smoke updates metadata and rotates credentials from the settings view", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await gotoEdgeAgentView(page, "overview");
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
await page.getByTestId("gateway-tab-settings").click();
await page.getByTestId("gateway-metadata-label").fill("CPH Edge Prime");
await page.getByTestId("gateway-metadata-save").click();
await expect(page.locator("body")).toContainText("Gateway metadata updated.");
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge Prime");
await page.getByTestId("gateway-rotate-confirmation").fill("ROTATE");
await page.getByTestId("gateway-rotate-credentials").click();
await expect(page.getByTestId("gateway-credential-bundle")).toContainText("rotated-edge-agent-token");
await expect(page.getByTestId("gateway-credential-bundle")).toContainText("truckwash-edge-gateway-stack.service");
await expect(page.locator("body")).toContainText("Gateway credentials rotated.");
});
test("@smoke completes delayed installer onboarding without a local timeout", async ({ page }) => {
test.setTimeout(90_000);
await acceleratePageTimers(page);
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
empty: true,
claimPollsRemaining: 65,
},
});
await primeSuperuserSession(page);
await page.goto("/superuser/selfserve/edge-agents");
await expect(page.getByTestId("gateway-fleet-landing")).toBeVisible();
await page.getByTestId("gateway-installer-department").selectOption("1");
await page.getByTestId("gateway-installer-label").fill("Canary Pi");
await generateGatewayInstaller(page);
await expect(page.getByTestId("gateway-install-command")).toHaveValue(/install\.sh\?token=edge-install-token/);
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/703\/overview$/, { timeout: 20_000 });
await expect(page.getByTestId("gateway-detail-header")).toContainText("Canary Pi");
await expect(page.locator("body")).toContainText("Gateway connected.");
});
test("@smoke shows transport sync diagnostics on the statistics page", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
gatewayOverrides: [
{
id: 701,
metadata: {
control_plane_status: {
last_successful_sync_at: "2026-04-08 08:15:00",
last_transport_failure_at: "2026-04-08 08:14:20",
last_transport_error:
"POST https://api.truckwash.test/edge-agent/gateways/701/heartbeat returned HTTP 502",
},
},
},
],
},
});
await primeSuperuserSession(page);
await gotoEdgeAgentView(page, "overview");
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
await page.getByTestId("gateway-tab-statistics").click();
await expect(page.getByTestId("gateway-statistics-page")).toBeVisible();
await expect(page.getByTestId("gateway-statistics-transport")).toContainText("Last successful sync");
await expect(page.getByTestId("gateway-statistics-transport")).toContainText("2026-04-08 08:15:00");
await expect(page.getByTestId("gateway-statistics-transport")).toContainText("Last transport failure");
await expect(page.getByTestId("gateway-statistics-transport")).toContainText("2026-04-08 08:14:20");
await expect(page.getByTestId("gateway-statistics-transport")).toContainText("returned HTTP 502");
});
test("@smoke renders HTTPS-refreshed gateway health and statistics", async ({ page }) => {
const healthyContainers = {
state: "ONLINE",
summary: "6/6 containers healthy",
services: [
{ name: "edge-agent", status: "healthy" },
{ name: "lan-worker", status: "healthy" },
{ name: "redis", status: "healthy" },
{ name: "mariadb", status: "healthy" },
{ name: "minio", status: "healthy" },
{ name: "auto-updater", status: "healthy" },
],
};
const liveMetrics = {
latency_ms: 88,
cpu_usage_pct: 34,
memory_usage_pct: 55,
disk_usage_pct: 66,
};
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
gatewayOverrides: [
{
id: 701,
status: "ONLINE",
metadata: {
system_metrics: liveMetrics,
container_health: healthyContainers,
outbox_status: {
state: "IN_SYNC",
summary: "Outbox is empty",
},
last_sync_at: "2026-04-27 11:00:00",
},
},
],
},
});
await primeSuperuserSession(page);
await gotoEdgeAgentView(page, "overview", { readyTestId: "gateway-overview-container-health" });
await expect(page.getByTestId("gateway-overview-container-health")).toContainText("ONLINE");
await expect(page.getByTestId("gateway-overview-container-health")).toContainText("6/6 containers healthy");
await page.getByTestId("gateway-tab-statistics").click();
await expect(page.getByTestId("gateway-statistics-page")).toContainText("34%");
await expect(page.getByTestId("gateway-statistics-page")).toContainText("88 ms");
});
test("@smoke manages discovery, bindings, tasks, logs, statistics, uninstall, and delete", async ({ page }) => {
test.slow();
await page.addInitScript(() => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: async (text) => {
window.__copiedGatewayContext = text;
},
},
});
window.__copiedGatewayContext = "";
});
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await gotoEdgeAgentView(page, "inventory", { readyTestId: "gateway-binding-card-0" });
await expect(page.getByTestId("gateway-binding-card-0")).toContainText("Roskilde machine north", {
timeout: 15_000,
});
await expect(page.getByTestId("gateway-binding-status-0")).toHaveAttribute("data-state", "Green");
await page.getByTestId("gateway-discovery-trigger").click();
await expect(page.locator("body")).toContainText("Gateway operation DISCOVERY queued.");
await expect(page.getByTestId("gateway-inventory-table")).toContainText("shelly-plus-new", { timeout: 10_000 });
await page.getByTestId("gateway-binding-add").click();
const newRelaySelect = await selectLastGatewayBindingRelay(page, "M-8");
await expect(newRelaySelect.locator("option:checked")).toContainText("Roskilde machine south");
await page.locator('[data-testid^="gateway-binding-device-"]').last().selectOption("shelly-plus-01");
await page.locator('[data-testid^="gateway-binding-channel-"]').last().selectOption("0");
await page.locator('[data-testid^="gateway-binding-fallback-"]').last().selectOption("CLOUD_ONLY");
await page.getByTestId("gateway-bindings-save").click();
await expect(page.locator("body")).toContainText("Relay bindings saved.");
await expect(page.locator('[data-testid^="gateway-binding-card-"]').last()).toContainText("Roskilde machine south");
await page.getByTestId("gateway-tab-tasks").click();
await page.getByTestId("gateway-update-target-version").fill("php-agent-v2.0");
await page.getByTestId("gateway-operation-update").click();
await expect(page.getByTestId("gateway-operations-list")).toContainText("UPDATE");
await expect(page.getByTestId("gateway-operation-events")).toContainText("Operation completed successfully");
await page.getByTestId("gateway-tab-logs").click();
await expect(page.getByTestId("gateway-logs-page")).toBeVisible();
await expect(page.getByTestId("gateway-logs-timeline")).toContainText("GATEWAY_OPERATION_QUEUED");
await expect(page.getByTestId("gateway-relay-logs")).toContainText("MACHINE ON Roskilde Maskine handled by local");
await expect(page.getByTestId("gateway-relay-logs")).toContainText("Open ENTRY Roskilde indkoerselspc");
await expect(page.getByTestId("gateway-relay-logs")).toContainText("selfserve");
await expect(page.getByTestId("gateway-log-entry-details-0")).toHaveCount(0);
await page.getByTestId("gateway-log-entry-0").click();
await expect(page.getByTestId("gateway-log-entry-details-0")).toContainText("Timestamp");
await expect(page.getByTestId("gateway-log-entry-details-0")).toContainText("Level");
await expect(page.getByTestId("gateway-relay-entry-details-0")).toHaveCount(0);
await expect(page.getByTestId("gateway-relay-logs")).not.toContainText("Customer 700123");
await page.getByTestId("gateway-relay-entry-0").click();
await expect(page.getByTestId("gateway-relay-entry-details-0")).toContainText("Customer 700123");
await expect(page.getByTestId("gateway-relay-entry-details-0")).toContainText("Context");
await expect(page.getByTestId("gateway-relay-entry-details-0")).not.toContainText("SET_RELAY_STATE");
await expect(page.getByTestId("gateway-relay-entry-details-0")).not.toContainText("module_responsible");
await page.getByTestId("gateway-relay-entry-context-copy-0").click();
await expect(page.getByTestId("gateway-relay-entry-context-copy-0")).toContainText("Copied");
expect(await page.evaluate(() => window.__copiedGatewayContext)).toContain('"module_responsible"');
await page.getByTestId("gateway-relay-entry-context-view-0").click();
await expect(page.getByTestId("gateway-context-modal")).toBeVisible();
await expect(page.getByTestId("gateway-context-modal-body")).toContainText("SET_RELAY_STATE");
await expect(page.getByTestId("gateway-context-modal-body")).toContainText("module_responsible");
await page.getByTestId("gateway-context-modal-close").click();
await expect(page.getByTestId("gateway-context-modal")).toHaveCount(0);
await expect(page.getByTestId("gateway-relay-logs")).toContainText("Customer 700123");
await page.getByTestId("gateway-logs-type-filter").selectOption("relay");
await expect(page.getByTestId("gateway-logs-timeline")).toContainText(
"MACHINE ON Roskilde Maskine handled by local"
);
await page.getByTestId("gateway-tab-statistics").click();
await expect(page.getByTestId("gateway-statistics-page")).toBeVisible();
await expect(page.getByTestId("gateway-statistics-version")).toContainText("php-agent-v2.0");
await expect(page.getByTestId("gateway-statistics-transport")).toContainText("Last successful sync");
await expect(page.getByTestId("gateway-statistics-transport")).toContainText("2026-04-08 08:14:56");
await expect(page.getByTestId("gateway-statistics-transport")).toContainText("Last transport error");
await page.getByTestId("gateway-tab-overview").click();
await expect(page.getByTestId("gateway-overview-rollout")).toContainText("php-agent-v2.0");
await expect(page.getByTestId("gateway-overview-update-window")).toContainText("02:00-04:00");
await expect(page.getByTestId("gateway-overview-container-health")).toContainText("ONLINE");
await expect(page.getByTestId("gateway-overview-container-services")).toContainText("auto-updater");
await expect(page.getByTestId("gateway-overview-outbox")).toContainText("IN_SYNC");
await page.getByTestId("gateway-tab-tasks").click();
await page.getByTestId("gateway-operation-uninstall").click();
await expect(page.getByTestId("gateway-operations-list")).toContainText("UNINSTALL");
await page.getByTestId("gateway-tab-settings").click();
await page.getByTestId("gateway-delete-confirmation").fill("CPH Edge 01");
await page.getByTestId("gateway-delete").click();
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents$/, { timeout: 8_000 });
await expect(page.locator("body")).toContainText(/(Gateway deleted\.|Gateway slettet\.)/);
});
test("@smoke updates the integrated workspace after binding and scanner assignment changes", async ({ page }) => {
test.slow();
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/departments/1/gateways?tab=lanes", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("department-hardware-panel-lanes")).toBeVisible();
await expect(page.getByTestId("department-lane-8")).toContainText("MISSING");
await page.getByTestId("department-hardware-tab-gateways").click();
await expect(page.getByTestId("edge-gateway-workspace")).toBeVisible();
await page.getByTestId("gateway-tab-inventory").click();
await page.getByTestId("gateway-binding-add").click();
await selectLastGatewayBindingRelay(page, "M-8");
await page.locator('[data-testid^="gateway-binding-device-"]').last().selectOption("shelly-plus-01");
await page.locator('[data-testid^="gateway-binding-channel-"]').last().selectOption("1");
await page.locator('[data-testid^="gateway-binding-fallback-"]').last().selectOption("PREFER_LOCAL");
await page.getByTestId("gateway-bindings-save").click();
await expect(page.locator("body")).toContainText("Relay bindings saved.");
await page.getByTestId("department-hardware-tab-lanes").click();
await page.getByTestId("department-hardware-refresh").click();
await expect(page.getByTestId("department-lane-8")).toContainText("READY");
await page.getByTestId("department-hardware-tab-scanners").click();
await expect(page.getByTestId("department-hardware-panel-scanners")).toBeVisible();
await page.getByTestId("department-scanner-lane-2").selectOption("8");
await page.getByTestId("department-scanner-save-2").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("lane assignment updated");
await expect(page.getByTestId("department-scanner-2")).toContainText("READY");
await page.getByTestId("department-scanner-rotate-2").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("API key rotated");
await expect(page.getByTestId("department-scanner-key-2")).toContainText("rotated-scanner-key-2");
});
test("@smoke shows all five hardware categories on department lane actions", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
selfServe: true,
});
await primeSuperuserSession(page);
await page.goto("/superuser/department/lanes", { waitUntil: "domcontentloaded" });
const lanesLoadingIndicator = page.getByText("Henter data...");
if (await lanesLoadingIndicator.count()) {
await expect(lanesLoadingIndicator).toBeHidden({ timeout: 20_000 });
}
const laneRow = page
.locator("tbody tr")
.filter({ has: page.locator(".action-settings-wheel-trigger") })
.first();
await expect(laneRow).toBeVisible({ timeout: 20_000 });
await laneRow.locator(".action-settings-wheel-trigger").click();
const actionWheelMenu = page.getByRole("menu").first();
await expect(actionWheelMenu).toBeVisible();
await expect(actionWheelMenu).toContainText(/(Lane|Vaskebane)/);
await expect(actionWheelMenu).toContainText(/(Self-serve Studio|Selvvask Studio)/);
await expect(actionWheelMenu).toContainText(/(Gates|Porte)/);
await expect(actionWheelMenu).toContainText(/(Relays|Relæer)/);
await expect(actionWheelMenu).toContainText("Gateways");
await expect(actionWheelMenu).toContainText(/(Open Studio|Åbn Studio)/);
await expect(actionWheelMenu).toContainText(/(Open Legacy Self-Serve|Åbn ældre selvvask)/);
await expect(actionWheelMenu).toContainText(
/(Open Hardware Workspace \(Lanes\)|Åbn hardware-arbejdsområde \(baner\))/
);
await expect(actionWheelMenu).toContainText(/(Open Gates Tab|Åbn porte-fane)/);
await expect(actionWheelMenu).toContainText(/(Open Relays Tab|Åbn relæ-fane)/);
await expect(actionWheelMenu).toContainText(/(Open Gateways Tab|Åbn gateway-fane)/);
});
test("@smoke renders Danish hardware action labels without mojibake", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 900 });
await page.addInitScript(() => {
window.localStorage.setItem("locale", "da");
});
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
selfServe: true,
});
await primeSuperuserSession(page);
await page.goto("/superuser/department/lanes", { waitUntil: "domcontentloaded" });
const lanesLoadingIndicator = page.getByText("Henter data...");
if (await lanesLoadingIndicator.count()) {
await expect(lanesLoadingIndicator).toBeHidden({ timeout: 20_000 });
}
const laneRow = page
.locator("tbody tr")
.filter({ has: page.locator(".action-settings-wheel-trigger") })
.first();
await expect(laneRow).toBeVisible({ timeout: 20_000 });
await laneRow.locator(".action-settings-wheel-trigger").click();
const actionWheelMenu = page.getByRole("menu").first();
await expect(actionWheelMenu).toBeVisible();
await expect(actionWheelMenu).toContainText("Selvvask Studio");
await expect(actionWheelMenu).toContainText("Porte");
await expect(actionWheelMenu).toContainText("Relæer");
await expect(actionWheelMenu).toContainText("Åbn Studio");
await expect(actionWheelMenu).toContainText("Åbn ældre selvvask");
await expect(actionWheelMenu).toContainText("Åbn hardware-arbejdsområde (baner)");
await expect(actionWheelMenu).toContainText("Åbn relæ-fane");
await expect(actionWheelMenu).toContainText("Åbn ældre relæer");
await expect(actionWheelMenu).toContainText("Tilføj relæ");
await expect(actionWheelMenu).toContainText("Åbn gateway-fane");
await expect(actionWheelMenu).toContainText("Åbn gateway-oversigt");
await expect(actionWheelMenu).toContainText("Åbn primær gateway");
});
test("@smoke surfaces relay contexts and self-serve CTAs in the integrated workspace", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/departments/1/gateways?tab=lanes", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("department-hardware-open-selfserve-studio")).toContainText("Open Studio");
await expect(page.getByTestId("department-hardware-open-legacy-selfserve")).toContainText("Open Legacy Self-Serve");
await expect(page.getByTestId("department-hardware-open-binding-inventory")).toBeVisible();
await expect(page.getByTestId("department-hardware-test-mode")).toContainText("Local");
await expect(page.getByTestId("department-hardware-test-mode")).toContainText("Cloud");
const entryRelaySelect = page.getByTestId("department-lane-relay-7-relay_in_id");
await expect
.poll(() =>
entryRelaySelect.evaluate(
(select, expectedValue) => Array.from(select.options).some((option) => option.value === expectedValue),
"ENTRY-8"
)
)
.toBe(true);
await entryRelaySelect.selectOption("ENTRY-8");
await page.getByTestId("department-lane-save-7").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("Lane 7 relay bindings updated.");
await expect(page.getByTestId("department-lane-test-7-ENTRY-open")).toBeEnabled();
const gateTestRequestPromise = page.waitForRequest(
(request) => request.method() === "POST" && request.url().includes("/modules/self-serve/lane/gate/open")
);
await page.getByTestId("department-lane-test-7-ENTRY-open").click();
const gateTestRequest = await gateTestRequestPromise;
expect(gateTestRequest.postDataJSON()).toMatchObject({
lane_id: 7,
gate: "ENTRANCE",
transport: "local",
toggle_after: 1,
});
await expect(page.getByTestId("department-lane-test-result-7-ENTRY")).toContainText("Local entry gate test sent.");
await page.getByTestId("department-hardware-test-mode-cloud").click();
const relayTestRequestPromise = page.waitForRequest(
(request) => request.method() === "POST" && request.url().includes("/modules/self-serve/lane/relay/machine/set")
);
await page.getByTestId("department-lane-test-7-MACHINE-on").click();
const relayTestRequest = await relayTestRequestPromise;
expect(relayTestRequest.postDataJSON()).toMatchObject({
lane_id: 7,
on: true,
transport: "cloud",
});
await expect(page.getByTestId("department-lane-test-result-7-MACHINE")).toContainText("Cloud Machine ON.");
await page.getByTestId("department-hardware-tab-relays").click();
await expect(page.getByTestId("department-hardware-panel-relays")).toBeVisible();
await expect(page.getByTestId("department-hardware-add-relay")).toBeVisible();
await expect(page.getByTestId("department-hardware-open-legacy-relays")).toBeVisible();
await expect(page.getByTestId("department-relay-52")).toContainText("North machine relay");
await expect(page.getByTestId("department-relay-52")).toContainText("Lane 7");
await expect(page.getByTestId("department-relay-52")).toContainText("North Entrance");
await expect(page.getByTestId("department-relay-52")).toContainText("CPH Edge 01");
});
test("@smoke saves Shelly relay selections from the integrated workspace", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
await page.goto("/superuser/departments/1/gateways?tab=lanes", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("department-hardware-panel-lanes")).toBeVisible({ timeout: 90_000 });
const entryRelaySelect = page.getByTestId("department-lane-relay-7-relay_in_id");
const entryRelayStatus = page.getByTestId("department-lane-relay-status-7-relay_in_id");
const entryRelayCoverage = page.getByTestId("department-lane-relay-coverage-7-relay_in_id");
await expect(entryRelaySelect).toBeVisible({ timeout: 90_000 });
await expect(entryRelaySelect).toHaveValue("");
await expect(entryRelayStatus).toHaveAttribute("data-state", "Unknown");
await expect(entryRelayCoverage).toContainText("Not configured");
await expect
.poll(() =>
entryRelaySelect.evaluate(
(select, expectedValue) => Array.from(select.options).some((option) => option.value === expectedValue),
"ENTRY-8"
)
)
.toBe(true);
await entryRelaySelect.selectOption("ENTRY-8");
await expect(entryRelayStatus).toHaveAttribute("data-state", "Green");
await expect(entryRelayCoverage).toContainText("Unsaved change");
await expect(entryRelaySelect.locator("option:checked")).toContainText("Roskilde entry (Shelly Plus 1PM)");
await page.getByTestId("department-lane-save-7").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("Lane 7 relay bindings updated.");
await expect(page.getByTestId("department-hardware-notice")).toContainText("1 gateway bindings added.");
await expect(entryRelaySelect).toHaveValue("ENTRY-8");
await expect(entryRelayStatus).toHaveAttribute("data-state", "Green");
await expect(entryRelayCoverage).toContainText("CPH Edge 01");
await expect(page.getByTestId("department-lane-7")).toContainText("READY");
await entryRelaySelect.selectOption("");
await expect(entryRelayStatus).toHaveAttribute("data-state", "Unknown");
await expect(entryRelayCoverage).toContainText("Unsaved change");
await page.getByTestId("department-lane-save-7").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("Lane 7 relay bindings updated.");
await expect(entryRelaySelect).toHaveValue("");
await expect(entryRelayStatus).toHaveAttribute("data-state", "Unknown");
await expect(entryRelayCoverage).toContainText("Not configured");
await expect(page.getByTestId("department-lane-7")).toContainText("READY");
});
test("@smoke creates, edits, and deletes department gates from the integrated workspace", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
let nextGateId = 90;
let gates = [
{
id: 41,
department: 1,
name: "North Entrance",
is_entrance: true,
is_exit: false,
config: {
type: "RELAY",
relay_id: "M-7",
pulse_seconds: 1,
},
},
{
id: 42,
department: 1,
name: "Service Exit",
is_entrance: false,
is_exit: true,
config: {
type: "PHONE_CALL",
phone_number: "+4512345678",
call_duration_threshold: 3,
},
},
];
const buildGateWorkspace = () => {
const workspaceGates = gates.map((gate) => {
const transportType = String(gate?.config?.type || "PHONE_CALL").toUpperCase();
const relayId = String(gate?.config?.relay_id || "").trim();
return {
...gate,
transport_type: transportType,
config_complete:
transportType === "PHONE_CALL"
? Boolean(gate?.config?.phone_number) && Number(gate?.config?.call_duration_threshold || 0) > 0
: Boolean(relayId),
relay: relayId ? { relay_id: relayId } : null,
coverage:
transportType === "RELAY" && relayId
? {
covered: true,
primary_binding: {
gateway_label: "CPH Edge 01",
},
}
: null,
};
});
return {
department: {
id: 1,
name: "Copenhagen",
description: "Primary transport department",
order_priority: 1,
},
summary: {
department_id: 1,
department_name: "Copenhagen",
transport_mode: "cloud",
gateway_count: 1,
online_gateway_count: 1,
primary_gateway: {
id: 701,
label: "CPH Edge 01",
status: "ONLINE",
},
required_relay_count: workspaceGates.filter((gate) => gate.transport_type === "RELAY").length,
bound_relay_count: workspaceGates.filter((gate) => gate.transport_type === "RELAY").length,
missing_binding_count: 0,
gate_count: workspaceGates.length,
gate_transport_mix: {
relay: workspaceGates.filter((gate) => gate.transport_type === "RELAY").length,
phone_call: workspaceGates.filter((gate) => gate.transport_type === "PHONE_CALL").length,
},
scanner_count: 0,
assigned_scanner_count: 0,
issue_count: 0,
health: "READY",
},
gateways: [],
lanes: [],
self_serve: {
readiness_state: "DISABLED",
ready_lanes: 0,
lane_count: 0,
configured_task_count: 0,
configured_product_count: 0,
links: {
studio: "/admin/1/modules/self-serve/studio",
},
},
gates: workspaceGates,
scanners: [],
issues: [],
actions: [],
};
};
await page.route(/\/modules\/edge-gateways\/workspace\/departments\/1(?:\?.*)?$/i, async (route) => {
await route.fulfill(json({ data: buildGateWorkspace() }));
});
await page.route(/\/department\/gates(?:\?.*)?$/i, async (route) => {
const request = route.request();
const method = request.method();
if (method === "GET") {
await route.fulfill(json({ data: gates }));
return;
}
if (method === "POST") {
const payload = request.postDataJSON();
const createdGate = {
id: nextGateId++,
...payload,
};
gates = [...gates, createdGate];
await route.fulfill(json({ data: createdGate }));
return;
}
if (method === "PUT") {
const payload = request.postDataJSON();
gates = gates.map((gate) =>
Number(gate.id) === Number(payload.id)
? {
...gate,
...payload,
config: payload.config ?? gate.config,
}
: gate
);
const updatedGate = gates.find((gate) => Number(gate.id) === Number(payload.id));
await route.fulfill(json({ data: updatedGate }));
return;
}
if (method === "DELETE") {
const requestUrl = new URL(request.url());
const gateId = Number(requestUrl.searchParams.get("id") || 0);
gates = gates.filter((gate) => Number(gate.id) !== gateId);
await route.fulfill(json({ data: true }));
return;
}
await route.fulfill(json({ data: [] }));
});
await page.goto("/superuser/departments/1/gateways?tab=gates");
await expect(page.getByTestId("department-hardware-panel-gates")).toBeVisible();
await expect(page.getByTestId("department-hardware-add-gate")).toBeVisible();
await expect(page.getByTestId("department-gate-edit-41")).toBeVisible();
await expect(page.getByTestId("department-gate-delete-42")).toBeVisible();
await page.getByTestId("department-hardware-add-gate").click();
let modal = page.locator(".swal2-popup");
await expect(modal).toBeVisible();
await modal.locator("#department-gate-name").fill("East Exit");
await modal.locator("#department-gate-is-exit").selectOption("true");
await modal.locator("#department-gate-transport-type").selectOption("RELAY");
await expect(modal.locator("#department-gate-relay-id")).toBeVisible();
await modal.locator("#department-gate-relay-id").fill("M-11");
await modal.locator("#department-gate-pulse-seconds").fill("2");
await modal.locator(".swal2-confirm").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("Gate created: East Exit.");
await expect(page.getByTestId("department-gate-90")).toContainText("East Exit");
await expect(page.getByTestId("department-gate-90")).toContainText("Relay M-11");
await page.getByTestId("department-gate-edit-42").click();
modal = page.locator(".swal2-popup");
await expect(modal).toBeVisible();
await modal.locator("#department-gate-name").fill("Service Exit Updated");
await modal.locator("#department-gate-phone-number").fill("+4599999999");
await modal.locator("#department-gate-call-duration-threshold").fill("45");
await modal.locator(".swal2-confirm").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("Gate updated: Service Exit Updated.");
await expect(page.getByTestId("department-gate-42")).toContainText("Service Exit Updated");
await expect(page.getByTestId("department-gate-42")).toContainText("+4599999999");
await expect(page.getByTestId("department-gate-42")).toContainText("threshold 45");
await page.getByTestId("department-gate-delete-90").click();
modal = page.locator(".swal2-popup");
await expect(modal).toBeVisible();
await modal.locator(".swal2-confirm").click();
await expect(page.getByTestId("department-hardware-notice")).toContainText("Gate deleted: East Exit.");
await expect(page.getByTestId("department-gate-90")).toHaveCount(0);
});
test("@smoke cancels an in-progress gateway task so a replacement task can be queued", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
discoveryAutoCompleteFetches: false,
},
});
await primeSuperuserSession(page);
await gotoEdgeAgentView(page, "tasks", { readyTestId: "gateway-operation-discovery" });
await page.getByTestId("gateway-operation-discovery").click();
await expect(page.getByTestId("gateway-operation-cancel")).toBeVisible();
await page.getByTestId("gateway-operation-cancel").click();
await expect(page.locator("body")).toContainText("DISCOVERY cancelled.");
await expect(page.getByTestId("gateway-operations-list")).toContainText("CANCELLED");
await page.getByTestId("gateway-update-target-version").fill("php-agent-v2.1");
await page.getByTestId("gateway-operation-update").click();
await expect(page.locator("body")).toContainText("Gateway operation UPDATE queued.");
});
test("@smoke labels relay command timeouts separately from failures", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
gatewayOverrides: [
{
id: 701,
recent_commands: [
{
id: 9901,
command_type: "GET_RELAY_STATUS",
status: "TIMED_OUT",
created_at: "2026-04-27 14:28:03",
error_message: "Edge gateway command timed out",
},
{
id: 9902,
command_type: "SET_RELAY_STATE",
status: "FAILED",
created_at: "2026-04-27 14:27:30",
error_message: "Shelly relay rejected the request",
},
],
},
],
},
});
await primeSuperuserSession(page);
await gotoEdgeAgentView(page, "tasks", { readyTestId: "gateway-recent-commands" });
await expect(page.getByTestId("gateway-recent-commands")).toContainText("GET_RELAY_STATUS");
await expect(page.getByTestId("gateway-recent-commands")).toContainText("Timed out");
await expect(page.getByTestId("gateway-recent-commands")).toContainText("Edge gateway command timed out");
await expect(page.getByTestId("gateway-recent-commands")).toContainText("Failed");
await expect(page.getByTestId("gateway-recent-commands")).not.toContainText("TIMED_OUT");
});
test("@smoke shows HTTPS diagnostics in the terminal panel without shell transport", async ({ page }) => {
test.slow();
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
edgeGateways: {
gatewayOverrides: [
{
id: 701,
metadata: {
broker_connected: false,
broker_last_error: "Broker unavailable",
broker_presence: {
connected: false,
last_seen_at: "2026-04-08 08:10:00",
last_error: "Broker unavailable",
disconnect_reason: "broker_disconnected",
},
},
},
],
},
});
await primeSuperuserSession(page);
await gotoEdgeAgentTerminal(page);
await expect(page.getByTestId("gateway-terminal-page")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/Session state: idle/i);
await expect(page.getByTestId("gateway-terminal-input")).toHaveCount(0);
await expect(page.getByTestId("gateway-terminal-send")).toHaveCount(0);
await page.getByTestId("gateway-terminal-connect").click();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/HTTPS-only edge agent/i, {
timeout: 5_000,
});
await expect(page.getByTestId("gateway-terminal-output")).toContainText("HTTPS polling is active");
await expect(page.getByTestId("gateway-terminal-diagnostics")).toBeVisible();
await expect(page.getByTestId("gateway-terminal-diagnostics")).toContainText("Broker connected: no");
await expect(page.getByTestId("gateway-terminal-diagnostics")).toContainText(
"Broker last error: Broker unavailable"
);
await expect(page.getByTestId("gateway-terminal-close")).toBeEnabled();
await page.getByTestId("gateway-terminal-close").click();
await expect(page.getByTestId("gateway-terminal-status")).toContainText(/Session state: closed/i);
});
});