Files
pleno-vue/tests/e2e/self-serve-studio-flow.spec.js
T

617 lines
22 KiB
JavaScript

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: "Forvogn", is_wash: 1, subscription_allowed: 1 },
{ id: 8, label: "Bus", is_wash: 1, subscription_allowed: 1 },
],
machine_types: [{ id: 1001, label: "Portal" }],
vehicle_types: [
{ id: 2, product: 2, product_id: 2, label: "Forvogn", source: "products" },
{ id: 8, product: 8, product_id: 8, label: "Bus", source: "products" },
],
questions: [
{ id: 11, label: "Are mirrors folded?" },
{ id: 12, label: "Is the lift lowered?" },
{ id: 18, label: "Bus roof fixtures checked?" },
],
conditions: [
{ id: 21, label: "Trailer present" },
{ id: 28, label: "Bus program allowed" },
],
rules: [{ id: 31, label: "Mirror answer" }],
tasks: [
{ id: 41, label: "Fold mirrors" },
{ id: 48, label: "Start bus wash" },
],
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: "Forvogn", 8: "Bus" },
machine_types: { 1001: "Portal" },
vehicle_types: { 2: "Forvogn", 8: "Bus" },
questions: { 11: "Are mirrors folded?", 12: "Is the lift lowered?", 18: "Bus roof fixtures checked?" },
conditions: { 21: "Trailer present", 28: "Bus program allowed" },
rules: { 31: "Mirror answer" },
tasks: { 41: "Fold mirrors", 48: "Start bus wash" },
gateways: { 701: "Roskilde Edge 01" },
relays: { "M-7": "Machine relay" },
},
};
return {
nodes: [
{
id: "vehicle_type:2",
type: "default",
position: { x: 0, y: 140 },
data: {
kind: "vehicle_type",
object_id: 2,
label: "Forvogn",
subtitle: "Vehicle scope",
raw: { id: 2, product: 2, product_id: 2, name: "Forvogn", label: "Forvogn", source: "products" },
},
},
{
id: "vehicle_type:8",
type: "default",
position: { x: 0, y: 330 },
data: {
kind: "vehicle_type",
object_id: 8,
label: "Bus",
subtitle: "Vehicle scope",
raw: { id: 8, product: 8, product_id: 8, name: "Bus", label: "Bus", source: "products" },
},
},
{
id: "condition:21",
type: "default",
position: { x: 320, y: 140 },
data: {
kind: "condition",
object_id: 21,
label: "Trailer present",
subtitle: "Lane 7 / Forvogn",
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 / Forvogn",
raw: {
id: 11,
department: 6,
lane: 7,
product: 2,
condition_id: 21,
question: "Are mirrors folded?",
description: "",
order_priority: 1,
},
},
},
{
id: "question:12",
type: "default",
position: { x: 642, y: 150 },
data: {
kind: "question",
object_id: 12,
label: "Is the lift lowered?",
subtitle: "Lane 7 / Forvogn",
raw: {
id: 12,
department: 6,
lane: 7,
product: 2,
condition_id: null,
question: "Is the lift lowered?",
description: "",
order_priority: 2,
},
},
},
{
id: "task:41",
type: "default",
position: { x: 960, y: 140 },
data: {
kind: "task",
object_id: 41,
label: "Fold mirrors",
subtitle: "Lane 7 / Forvogn",
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: "condition:28",
type: "default",
position: { x: 1880, y: 900 },
data: {
kind: "condition",
object_id: 28,
label: "Bus program allowed",
subtitle: "Lane 7 / Bus",
raw: { id: 28, department: 6, lane: 7, product: 8, name: "Bus program allowed", description: "" },
},
},
{
id: "question:18",
type: "default",
position: { x: 2440, y: 1380 },
data: {
kind: "question",
object_id: 18,
label: "Bus roof fixtures checked?",
subtitle: "Lane 7 / Bus",
raw: {
id: 18,
department: 6,
lane: 7,
product: 8,
condition_id: 28,
question: "Bus roof fixtures checked?",
description: "",
order_priority: 3,
},
},
},
{
id: "task:48",
type: "default",
position: { x: 3600, y: 1840 },
data: {
kind: "task",
object_id: 48,
label: "Start bus wash",
subtitle: "Lane 7 / Bus",
raw: {
id: 48,
department: 6,
lane: 7,
product: 8,
task: "Start bus wash",
description: "",
gate_type: "QUESTION",
gate_ref_id: 18,
order_priority: 2,
services: ["MACHINE"],
buttons: [2],
},
},
},
{
id: "rule:31",
type: "default",
position: { x: 520, y: 320 },
data: {
kind: "rule",
object_id: 31,
label: "Mirror answer",
subtitle: "IS_TRUE Are mirrors folded?",
raw: {
id: 31,
condition_id: 21,
type: "IS_TRUE",
object_type: "question",
object_id: 11,
name: "Mirror answer",
description: "",
},
},
},
{
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: "scope:vehicle_type:2:condition:21",
source: "vehicle_type:2",
target: "condition:21",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:vehicle_type:2:question:11",
source: "vehicle_type:2",
target: "question:11",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:vehicle_type:2:question:12",
source: "vehicle_type:2",
target: "question:12",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:vehicle_type:2:task:41",
source: "vehicle_type:2",
target: "task:41",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "question-gate:21:11",
source: "condition:21",
target: "question:11",
type: "smoothstep",
label: "show if",
data: { kind: "visibility_gate" },
},
{
id: "rule-input:question:11:31",
source: "question:11",
target: "rule:31",
type: "smoothstep",
label: "IS_TRUE",
data: { kind: "rule_input" },
},
{
id: "rule-owner:21:31",
source: "rule:31",
target: "condition:21",
type: "smoothstep",
label: "rule of",
data: { kind: "condition_rule" },
},
{
id: "task-gate:question:11:41",
source: "question:11",
target: "task:41",
type: "smoothstep",
label: "unlocks",
data: { kind: "task_gate" },
},
{
id: "scope:vehicle_type:8:condition:28",
source: "vehicle_type:8",
target: "condition:28",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:vehicle_type:8:question:18",
source: "vehicle_type:8",
target: "question:18",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:vehicle_type:8:task:48",
source: "vehicle_type:8",
target: "task:48",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "question-gate:28:18",
source: "condition:28",
target: "question:18",
type: "smoothstep",
label: "show if",
data: { kind: "visibility_gate" },
},
{
id: "task-gate:question:18:48",
source: "question:18",
target: "task:48",
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 / Forvogn",
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.getByTestId("studio-flow-actions")).toBeVisible();
await expect(page.getByText("Are mirrors folded?").first()).toBeVisible();
await expect(page.getByText("Roskilde Edge 01").first()).toBeVisible();
await expect(page.getByTestId("studio-custom-node-question").first()).toContainText("Visibility");
await expect(page.getByTestId("studio-custom-node-condition").first()).toContainText("Root condition");
await expect(page.getByTestId("studio-custom-node-rule").first()).toContainText("Input");
await expect(page.getByTestId("studio-custom-node-task").first()).toContainText("services");
await expect(page.getByTestId("studio-custom-node-gateway").first()).toContainText("ONLINE");
await expect(page.getByTestId("studio-custom-node-scope").first()).toContainText("Forvogn");
await page.getByTitle("Auto layout").click();
await page.waitForTimeout(350);
const customNodesDoNotOverlap = await page.evaluate(() => {
const rects = [...document.querySelectorAll(".studio-custom-node")]
.map((node) => node.getBoundingClientRect())
.filter((rect) => rect.width > 0 && rect.height > 0);
for (let i = 0; i < rects.length; i += 1) {
for (let j = i + 1; j < rects.length; j += 1) {
const left = rects[i];
const right = rects[j];
const separated =
left.right <= right.left + 1 ||
right.right <= left.left + 1 ||
left.bottom <= right.top + 1 ||
right.bottom <= left.top + 1;
if (!separated) {
return false;
}
}
}
return true;
});
expect(customNodesDoNotOverlap).toBe(true);
const controlsDoNotCoverDetails = await page.evaluate(() => {
const controls = document.querySelector('[data-testid="studio-flow-actions"]')?.getBoundingClientRect();
const details = document.querySelector(".studio-details")?.getBoundingClientRect();
if (!controls || !details) {
return false;
}
return (
controls.right <= details.left ||
controls.bottom <= details.top ||
controls.left >= details.right ||
controls.top >= details.bottom
);
});
expect(controlsDoNotCoverDetails).toBe(true);
await page.getByTestId("studio-fullscreen-toggle").click();
await expect(page.getByTestId("self-serve-studio")).toHaveClass(/is-studio-fullscreen/);
await expect(page.getByTestId("studio-fullscreen-toggle")).toContainText("Exit fullscreen");
await page.getByTestId("studio-fullscreen-toggle").click();
await expect(page.getByTestId("self-serve-studio")).not.toHaveClass(/is-studio-fullscreen/);
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" });
await expect
.poll(async () => page.locator('[data-testid="studio-custom-node-scope"]:visible h4').allTextContents())
.toEqual(["Bus"]);
await expect(page.getByText("Bus roof fixtures checked?").first()).toBeVisible();
await expect(page.getByText("Start bus wash").first()).toBeVisible();
await expect(page.getByText("Are mirrors folded?").first()).not.toBeVisible();
await expect
.poll(async () =>
page.evaluate(() => {
const widths = [...document.querySelectorAll(".studio-custom-node")]
.filter((node) => node.offsetParent !== null)
.map((node) => node.getBoundingClientRect().width);
return Math.min(...widths);
})
)
.toBeGreaterThan(110);
const focusedNodeMetrics = await page.evaluate(() => {
const rects = [...document.querySelectorAll(".studio-custom-node")]
.filter((node) => node.offsetParent !== null)
.map((node) => node.getBoundingClientRect());
const left = Math.min(...rects.map((rect) => rect.left));
const right = Math.max(...rects.map((rect) => rect.right));
const top = Math.min(...rects.map((rect) => rect.top));
const bottom = Math.max(...rects.map((rect) => rect.bottom));
return {
count: rects.length,
minWidth: Math.min(...rects.map((rect) => rect.width)),
spreadX: right - left,
spreadY: bottom - top,
};
});
expect(focusedNodeMetrics.count).toBeGreaterThanOrEqual(4);
expect(focusedNodeMetrics.spreadX).toBeLessThan(950);
expect(focusedNodeMetrics.spreadY).toBeLessThan(460);
await page.getByTestId("studio-filter-vehicle-type").selectOption("");
await expect(page.getByText("Are mirrors folded?").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);
});
});