Add all-in-one self-serve studio with flow editor, simulator, versioning, and enhanced E2E/OpenAPI support. Extend legacy edge gateway workflows.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user