-
-
-
-
-
+
+
+
+
+
+
{{ data.subtitle }}
+
+
+
+
+
+
{{ data.subtitle }}
+
+
+
+
+
+
{{ data.subtitle }}
+
+
+
+
+
+ Loading studio
-
-
-
-
-
-
-
-
+
-
-
-
-
-
-
-
- Active source:
- Published version #{{ activeConfig.version_id }}
- Legacy fallback
-
-
- Latest validation: {{ latestValidation.validation.valid ? 'valid' : 'errors found' }}
-
-
-
-
- | ID |
- Status |
- Version # |
- Published At |
- Actions |
-
-
-
-
- | #{{ entry.id }} |
- {{ entry.status }} |
- {{ entry.version_number }} |
- {{ entry.published_at || '-' }} |
-
-
- |
-
-
- | No versions found. |
-
-
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/e2e/edge-gateways.routes.spec.js b/tests/e2e/edge-gateways.routes.spec.js
index b3247a34..53b077e4 100644
--- a/tests/e2e/edge-gateways.routes.spec.js
+++ b/tests/e2e/edge-gateways.routes.spec.js
@@ -6,6 +6,8 @@ async function primeSuperuserSession(page) {
}
test.describe("Edge gateway routing and fleet navigation", () => {
+ test.describe.configure({ timeout: 120_000 });
+
test.beforeEach(async ({ page }) => {
await mockApi(page, {
authenticated: true,
@@ -15,7 +17,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
});
test("redirects legacy configuration detail routes onto the Edge Agents overview", async ({ page }) => {
- await page.goto("/superuser/configuration/edgegateway/701");
+ await page.goto("/superuser/configuration/edgegateway/701", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/overview$/);
await expect(page.getByTestId("gateway-detail-header")).toContainText("CPH Edge 01");
@@ -23,7 +25,7 @@ test.describe("Edge gateway routing and fleet navigation", () => {
});
test("redirects legacy sub-page URLs onto the mapped Edge Agents route", async ({ page }) => {
- await page.goto("/superuser/gateways/701/configure");
+ await page.goto("/superuser/gateways/701/configure", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/superuser\/selfserve\/edge-agents\/701\/inventory$/);
await expect(page.getByTestId("gateway-inventory-page")).toBeVisible();
diff --git a/tests/e2e/self-serve-studio-flow.spec.js b/tests/e2e/self-serve-studio-flow.spec.js
new file mode 100644
index 00000000..f132cfa5
--- /dev/null
+++ b/tests/e2e/self-serve-studio-flow.spec.js
@@ -0,0 +1,299 @@
+import { expect, test } from "@playwright/test";
+import { mockApi, primeMockSession } from "./support/network.js";
+
+const json = (body, status = 200) => ({
+ status,
+ contentType: "application/json",
+ body: JSON.stringify(body),
+});
+
+function buildStudioGraph() {
+ const lookups = {
+ departments: [{ id: 6, label: "Roskilde" }],
+ lanes: [{ id: 7, label: "Lane 7" }],
+ products: [{ id: 2, label: "Truck wash" }],
+ machine_types: [{ id: 1001, label: "Portal" }],
+ vehicle_types: [{ id: 2, product: 2, label: "Truck" }],
+ questions: [{ id: 11, label: "Are mirrors folded?" }],
+ conditions: [{ id: 21, label: "Trailer present" }],
+ rules: [{ id: 31, label: "Mirror answer" }],
+ tasks: [{ id: 41, label: "Fold mirrors" }],
+ gateways: [{ id: 701, label: "Roskilde Edge 01", status: "ONLINE" }],
+ relays: [{ id: "M-7", label: "Machine relay" }],
+ bindings: [{ id: "701:M-7:0", label: "Machine relay" }],
+ labels: {
+ departments: { 6: "Roskilde" },
+ lanes: { 7: "Lane 7" },
+ products: { 2: "Truck wash" },
+ machine_types: { 1001: "Portal" },
+ vehicle_types: { 2: "Truck" },
+ questions: { 11: "Are mirrors folded?" },
+ conditions: { 21: "Trailer present" },
+ rules: { 31: "Mirror answer" },
+ tasks: { 41: "Fold mirrors" },
+ gateways: { 701: "Roskilde Edge 01" },
+ relays: { "M-7": "Machine relay" },
+ },
+ };
+
+ return {
+ nodes: [
+ {
+ id: "condition:21",
+ type: "default",
+ position: { x: 320, y: 140 },
+ data: {
+ kind: "condition",
+ object_id: 21,
+ label: "Trailer present",
+ subtitle: "Lane 7 / Truck wash",
+ raw: { id: 21, department: 6, lane: 7, product: 2, name: "Trailer present", description: "" },
+ },
+ },
+ {
+ id: "question:11",
+ type: "default",
+ position: { x: 640, y: 140 },
+ data: {
+ kind: "question",
+ object_id: 11,
+ label: "Are mirrors folded?",
+ subtitle: "Lane 7 / Truck wash",
+ raw: {
+ id: 11,
+ department: 6,
+ lane: 7,
+ product: 2,
+ condition_id: 21,
+ question: "Are mirrors folded?",
+ description: "",
+ order_priority: 1,
+ },
+ },
+ },
+ {
+ id: "task:41",
+ type: "default",
+ position: { x: 960, y: 140 },
+ data: {
+ kind: "task",
+ object_id: 41,
+ label: "Fold mirrors",
+ subtitle: "Lane 7 / Truck wash",
+ raw: {
+ id: 41,
+ department: 6,
+ lane: 7,
+ product: 2,
+ task: "Fold mirrors",
+ description: "",
+ gate_type: "QUESTION",
+ gate_ref_id: 11,
+ order_priority: 1,
+ services: ["MACHINE"],
+ buttons: [1],
+ },
+ },
+ },
+ {
+ id: "gateway:701",
+ type: "default",
+ position: { x: 1280, y: 140 },
+ data: {
+ kind: "edge_gateway",
+ object_id: 701,
+ label: "Roskilde Edge 01",
+ subtitle: "ONLINE",
+ raw: { id: 701, label: "Roskilde Edge 01", status: "ONLINE" },
+ },
+ },
+ ],
+ edges: [
+ {
+ id: "question-gate:21:11",
+ source: "condition:21",
+ target: "question:11",
+ type: "smoothstep",
+ label: "show if",
+ data: { kind: "visibility_gate" },
+ },
+ {
+ id: "task-gate:question:11:41",
+ source: "question:11",
+ target: "task:41",
+ type: "smoothstep",
+ label: "unlocks",
+ data: { kind: "task_gate" },
+ },
+ ],
+ lookups,
+ validation: { valid: true, errors: [], warnings: [], items: [] },
+ layout: { nodes: [], viewport: [], runtime_affecting: false },
+ versions: [
+ { id: 90, version_number: 3, status: "DRAFT", created_at: "2026-04-28 09:00:00" },
+ { id: 80, version_number: 2, status: "PUBLISHED", published_at: "2026-04-27 09:00:00" },
+ ],
+ active_config: null,
+ draft: { id: 90, status: "DRAFT", version_number: 3 },
+ simulator_defaults: { department: 6, lane_id: 7, vehicle_type_id: 2, reg: "TEST123", customer_number: null },
+ gateway_workspace: {
+ gateways: [
+ {
+ id: 701,
+ label: "Roskilde Edge 01",
+ status: "ONLINE",
+ bindings: [{ relay_id: "M-7", label: "Machine relay", role: "MACHINE" }],
+ },
+ ],
+ relays: [{ relay_id: "M-7", name: "Machine relay" }],
+ lanes: [],
+ issues: [],
+ actions: [],
+ },
+ permissions: {
+ can_view: true,
+ can_edit: true,
+ can_publish: true,
+ can_rollback: true,
+ can_simulate: true,
+ modules_shelly_config: true,
+ can_manage_gateways: true,
+ can_run_gateway_destructive_actions: true,
+ can_run_live_lane_actions: true,
+ },
+ meta: { department_id: 6, layout_affects_runtime: false, generated_at: "2026-04-28T09:00:00+02:00" },
+ };
+}
+
+async function installStudioRoutes(page, graph, captured) {
+ await page.route(/\/department\/selfserve\/studio\/graph(?:\?.*)?$/i, async (route, request) => {
+ if (request.method() === "GET") {
+ await route.fulfill(json({ data: graph }));
+ return;
+ }
+
+ const body = request.postDataJSON?.() || {};
+ captured.graphSaves.push(body);
+ for (const operation of body.operations || []) {
+ if (operation.action === "create" && operation.entity === "question") {
+ graph.nodes.push({
+ id: "question:99",
+ type: "default",
+ position: { x: 700, y: 320 },
+ data: {
+ kind: "question",
+ object_id: 99,
+ label: operation.data.question,
+ subtitle: "Lane 7 / Truck wash",
+ raw: { id: 99, ...operation.data },
+ },
+ });
+ }
+ if (operation.action === "update" && operation.entity === "question" && Number(operation.id) === 11) {
+ const node = graph.nodes.find((entry) => entry.id === "question:11");
+ node.data.raw = { ...node.data.raw, ...operation.data };
+ node.data.label = operation.data.question;
+ }
+ }
+ await route.fulfill(json({ data: graph }));
+ });
+
+ await page.route(/\/department\/selfserve\/studio\/layout(?:\?.*)?$/i, async (route, request) => {
+ captured.layouts.push(request.postDataJSON?.() || {});
+ await route.fulfill(json({ data: { nodes: [], viewport: [], runtime_affecting: false } }));
+ });
+
+ await page.route(/\/department\/selfserve\/studio\/validate(?:\?.*)?$/i, async (route) => {
+ await route.fulfill(json({ data: graph.validation }));
+ });
+
+ await page.route(/\/department\/selfserve\/studio\/simulate(?:\?.*)?$/i, async (route, request) => {
+ captured.simulations.push(request.postDataJSON?.() || {});
+ await route.fulfill(json({ data: { allowed: true, questions: [], tasks: [{ task: "Fold mirrors" }] } }));
+ });
+
+ await page.route(/\/department\/selfserve\/studio\/publish(?:\?.*)?$/i, async (route) => {
+ captured.published += 1;
+ await route.fulfill(json({ data: { id: 91, status: "PUBLISHED", version_number: 4 } }));
+ });
+
+ await page.route(/\/department\/selfserve\/studio\/rollback(?:\?.*)?$/i, async (route, request) => {
+ captured.rollbacks.push(request.postDataJSON?.() || {});
+ await route.fulfill(json({ data: { id: 92, status: "PUBLISHED", version_number: 5 } }));
+ });
+
+ await page.route(/\/department\/selfserve\/studio\/gateway-action(?:\?.*)?$/i, async (route, request) => {
+ captured.gatewayActions.push(request.postDataJSON?.() || {});
+ await route.fulfill(json({ data: { action: "discovery", operation: { id: 1, status: "PENDING" } } }));
+ });
+}
+
+test.describe("All-in-one self-serve studio", () => {
+ test.describe.configure({ mode: "serial", timeout: 120_000 });
+
+ test.beforeEach(async ({ page }) => {
+ await mockApi(page, {
+ authenticated: true,
+ permissions: ["superuser", "user"],
+ selfServe: true,
+ });
+ await primeMockSession(page, { token: "self-serve-studio-token" });
+ });
+
+ test("loads the replacement graph workspace and edits a question from the inspector", async ({ page }) => {
+ const graph = buildStudioGraph();
+ const captured = { graphSaves: [], layouts: [], simulations: [], gatewayActions: [], rollbacks: [], published: 0 };
+ await installStudioRoutes(page, graph, captured);
+
+ await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 });
+
+ await expect(page.getByTestId("self-serve-studio")).toBeVisible();
+ await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
+ await expect(page.getByText("Are mirrors folded?").first()).toBeVisible();
+ await expect(page.getByText("Roskilde Edge 01").first()).toBeVisible();
+
+ await page.getByText("Are mirrors folded?").first().click();
+ await expect(page.getByRole("heading", { name: "Inspector" })).toBeVisible();
+ await page.getByRole("textbox", { name: "Question", exact: true }).fill("Are loose straps secured?");
+ await page.getByRole("button", { name: "Save" }).click();
+
+ await expect.poll(() => captured.graphSaves.some((save) => save.operations?.[0]?.action === "update")).toBe(true);
+ await expect(page.getByText("Are loose straps secured?")).toBeVisible();
+ });
+
+ test("supports drag-drop quick add, gateway action, simulator, publish, and rollback panels", async ({ page }) => {
+ const graph = buildStudioGraph();
+ const captured = { graphSaves: [], layouts: [], simulations: [], gatewayActions: [], rollbacks: [], published: 0 };
+ await installStudioRoutes(page, graph, captured);
+
+ await page.goto("/admin/6/modules/self-serve/studio", { waitUntil: "domcontentloaded", timeout: 90_000 });
+ await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
+
+ await page.evaluate(() => {
+ const source = document.querySelector('[data-testid="studio-palette-question"]');
+ const target = document.querySelector('[data-testid="self-serve-studio-flow"]');
+ const dataTransfer = new DataTransfer();
+ source.dispatchEvent(new DragEvent("dragstart", { bubbles: true, cancelable: true, dataTransfer }));
+ target.dispatchEvent(new DragEvent("dragover", { bubbles: true, cancelable: true, dataTransfer }));
+ target.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }));
+ });
+ await expect.poll(() => captured.graphSaves.some((save) => save.operations?.[0]?.action === "create")).toBe(true);
+ await expect(page.getByText("New question").first()).toBeVisible();
+
+ await page.getByTestId("studio-panel-hardware").click();
+ await page.getByTitle("Discover gateway").click();
+ await expect.poll(() => captured.gatewayActions[0]?.action).toBe("discovery");
+
+ await page.getByTestId("studio-panel-simulator").click();
+ await page.getByLabel("Registration").fill("AB12345");
+ await page.getByRole("button", { name: "Run" }).click();
+ await expect.poll(() => captured.simulations[0]?.reg).toBe("AB12345");
+ await expect(page.locator(".studio-json")).toContainText("Fold mirrors");
+
+ await page.getByTestId("studio-panel-versions").click();
+ await page.getByRole("button", { name: "Publish" }).first().click();
+ await expect.poll(() => captured.published).toBe(1);
+ await page.getByRole("button", { name: "Rollback" }).last().click();
+ await expect.poll(() => captured.rollbacks[0]?.target_version_id).toBe(80);
+ });
+});
diff --git a/tests/e2e/self-serve-wash.spec.js b/tests/e2e/self-serve-wash.spec.js
index 94f2689a..257223d5 100644
--- a/tests/e2e/self-serve-wash.spec.js
+++ b/tests/e2e/self-serve-wash.spec.js
@@ -470,11 +470,15 @@ test.describe("Self-serve wash", () => {
completedTasks: {},
});
- await mockApi(page, {
+ const api = await mockApi(page, {
authenticated: true,
permissions: ["user"],
selfServe: true,
});
+ const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"];
+ api.selfServe.previewByKey["7:AB12345"].questions = answeredSummary.questions;
+ api.selfServe.summaryBySessionId[501] = answeredSummary;
+ api.selfServe.summaryByKey["7:AB12345"] = answeredSummary;
await primeSession(page, {
token: "self-serve-tasks-token",
permissions: ["user"],
@@ -595,8 +599,8 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-question-11")).toBeVisible();
await page.getByTestId("self-serve-question-11-yes").click();
- await expect(page.getByText("Ingen aktive tasks.")).toBeVisible();
- await expect(page.getByText("Ingen session-events endnu.")).toBeVisible();
+ await expect(page.getByText("Prepare the truck")).toBeVisible();
+ await expect(page.getByText("SESSION_SYNCED")).toBeVisible();
const answeredQuestion = page.locator("li", { hasText: "Is the tarp removed?:" }).last();
await expect(answeredQuestion).toBeVisible();
await expect(answeredQuestion.locator("strong")).toHaveText("Ja");