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),
});
const mirrorTaskAttachments = [
{
id: 201,
content: { other: "mirror-guide.pdf", document: "tasks/mirror-guide.pdf" },
download_link: "https://cdn.example.test/mirror-guide.pdf",
},
{
id: 202,
content: { other: "mirror-photo.jpg", image: "tasks/mirror-photo.jpg" },
download_link: "https://cdn.example.test/mirror-photo.jpg",
},
];
const graphTaskRaw = (graph, taskId) => graph.nodes.find((node) => node.id === `task:${taskId}`)?.data?.raw || null;
const setGraphTaskAttachments = (graph, taskId, attachments) => {
const raw = graphTaskRaw(graph, taskId);
if (raw) {
raw.attachments = attachments;
}
};
function buildStudioGraph() {
const lookups = {
departments: [{ id: 6, label: "Roskilde" }],
lanes: [
{
id: 7,
department: 6,
name: "Lane 7",
label: "Lane 7",
relay_in_id: "IN-7",
relay_out_id: "EXIT-7",
relay_machine_id: "M-7",
relay_machine_program_picker_id: "",
relay_machine_cleaner_id: "CLEAN-7",
machine_type_id: 1001,
dynamic_image_id: 1,
selfserve_enabled: true,
},
],
products: [
{
id: 2,
name: "Forvogn",
label: "Forvogn",
description: "Forvogn wash",
price: 649,
category: "6",
piktogram: "05",
is_wash: 1,
subscription_allowed: 1,
},
{
id: 8,
name: "Bus",
label: "Bus",
description: "Bus wash",
price: 799,
category: "8",
piktogram: "08",
is_wash: 1,
subscription_allowed: 1,
},
],
machine_types: [{ id: 1001, label: "Portal" }],
dynamic_images: [
{ id: 1, label: "Machine 1" },
{ id: 2, label: "Machine 2" },
],
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: [],
tasks: [
{ id: 41, label: "Fold mirrors" },
{ id: 48, label: "Start bus wash" },
],
actions: [{ id: 81, label: "Open entry on start" }],
gateways: [{ id: 701, label: "Roskilde Edge 01", status: "ONLINE" }],
relays: [
{ id: "M-7", label: "Machine relay" },
{ id: "IN-7", label: "Entry relay" },
],
bindings: [
{ id: "701:M-7:0", label: "Machine relay", role: "MACHINE", services: ["MACHINE"] },
{ id: "701:IN-7:1", label: "Entry relay", role: "ENTRY", services: ["ENTRY"] },
],
labels: {
departments: { 6: "Roskilde" },
lanes: { 7: "Lane 7" },
products: { 2: "Forvogn", 8: "Bus" },
machine_types: { 1001: "Portal" },
dynamic_images: { 1: "Machine 1", 2: "Machine 2" },
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" },
actions: { 81: "Open entry on start" },
gateways: { 701: "Roskilde Edge 01" },
relays: { "M-7": "Machine relay", "IN-7": "Entry relay" },
bindings: { "701:M-7:0": "Machine relay", "701:IN-7:1": "Entry 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",
description: "Forvogn wash",
price: 649,
category: "6",
piktogram: "05",
is_wash: 1,
subscription_allowed: 1,
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",
description: "Bus wash",
price: 799,
category: "8",
piktogram: "08",
is_wash: 1,
subscription_allowed: 1,
source: "products",
},
},
},
{
id: "lane:7",
type: "default",
position: { x: 0, y: 520 },
data: {
kind: "lane",
object_id: 7,
label: "Lane 7",
subtitle: "Lane scope",
raw: {
id: 7,
name: "Lane 7",
label: "Lane 7",
machine_type_id: 1001,
dynamic_image_id: 1,
selfserve_enabled: true,
},
},
},
{
id: "condition:21",
type: "default",
position: { x: 320, y: 140 },
data: {
kind: "condition",
object_id: 21,
label: "Trailer present",
subtitle: "Lane 7 / Forvogn",
expression_summary: "All: Are mirrors folded? is true",
raw: {
id: 21,
department: 6,
lane: 7,
product: 2,
name: "Trailer present",
description: "",
expression: {
type: "group",
operator: "ALL",
children: [{ type: "predicate", subject_type: "question", subject_id: 11, operator: "IS_TRUE" }],
},
},
},
},
{
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: ["reset", 1, "start"],
dynamic_images_vehicle_type: 5,
attachments: mirrorTaskAttachments,
},
},
},
{
id: "action:81",
type: "default",
position: { x: 1280, y: 140 },
data: {
kind: "action",
object_id: 81,
label: "Open entry on start",
subtitle: "When wash starts / Both / Open lane entrance port",
event_label: "When wash starts",
action_label: "Open lane entrance port",
relay_role: "ENTRY",
raw: {
id: 81,
department: 6,
lane: 7,
product: 2,
machine_type_id: 1001,
condition_id: 21,
name: "Open entry on start",
description: "",
event: "wash_start_command",
wash_mode: "both",
operation: "open_lane_entrance_port",
relay_state: null,
enabled: true,
order_priority: 1,
options: {
delay_ms: 0,
toggle_after_seconds: 2,
retry_count: 0,
failure_policy: "continue",
record_event: true,
},
},
},
},
{
id: "condition:28",
type: "default",
position: { x: 1880, y: 900 },
data: {
kind: "condition",
object_id: 28,
label: "Bus program allowed",
subtitle: "Lane 7 / Bus",
expression_summary: "All: Bus roof fixtures checked? is true",
raw: {
id: 28,
department: 6,
lane: 7,
product: 8,
name: "Bus program allowed",
description: "",
expression: {
type: "group",
operator: "ALL",
children: [{ type: "predicate", subject_type: "question", subject_id: 18, operator: "IS_TRUE" }],
},
},
},
},
{
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, "start"],
dynamic_images_vehicle_type: 8,
},
},
},
{
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" },
},
},
{
id: "binding:701:M-7:0",
type: "default",
position: { x: 1600, y: 140 },
data: {
kind: "relay_binding",
object_id: "M-7",
label: "Machine relay",
subtitle: "MACHINE",
raw: { relay_id: "M-7", label: "Machine relay", role: "MACHINE", services: ["MACHINE"], channel: 0 },
},
},
{
id: "binding:701:IN-7:1",
type: "default",
position: { x: 1600, y: 300 },
data: {
kind: "relay_binding",
object_id: "IN-7",
label: "Entry relay",
subtitle: "ENTRY",
raw: { relay_id: "IN-7", label: "Entry relay", role: "ENTRY", services: ["ENTRY"], channel: 1 },
},
},
{
id: "relay:M-7",
type: "default",
position: { x: 1920, y: 140 },
data: {
kind: "relay",
object_id: "M-7",
label: "Machine relay",
subtitle: "Hardware relay",
raw: { relay_id: "M-7", name: "Machine relay", status: "ONLINE" },
},
},
{
id: "relay:IN-7",
type: "default",
position: { x: 1920, y: 300 },
data: {
kind: "relay",
object_id: "IN-7",
label: "Entry relay",
subtitle: "Hardware relay",
raw: { relay_id: "IN-7", name: "Entry relay", 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:lane:7:condition:21",
source: "lane:7",
target: "condition:21",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:lane:7:question:11",
source: "lane:7",
target: "question:11",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:lane:7:question:12",
source: "lane:7",
target: "question:12",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:lane:7:task:41",
source: "lane:7",
target: "task:41",
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: "scope:lane:7:action:81",
source: "lane:7",
target: "action:81",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:vehicle_type:2:action:81",
source: "vehicle_type:2",
target: "action:81",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "action-gate:21:81",
source: "condition:21",
target: "action:81",
type: "smoothstep",
label: "allows",
data: { kind: "action_gate" },
},
{
id: "question-gate:21:11",
source: "condition:21",
target: "question:11",
type: "smoothstep",
label: "show if",
data: { kind: "visibility_gate" },
},
{
id: "expression:21:question:11:a1e88f96",
source: "question:11",
target: "condition:21",
type: "smoothstep",
label: "IS_TRUE",
data: { kind: "condition_expression", subject_type: "question", subject_id: 11, condition_id: 21 },
},
{
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:lane:7:condition:28",
source: "lane:7",
target: "condition:28",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:lane:7:question:18",
source: "lane:7",
target: "question:18",
type: "smoothstep",
label: "scope",
data: { kind: "scope" },
},
{
id: "scope:lane:7:task:48",
source: "lane:7",
target: "task:48",
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: "expression:28:question:18:a1e88f96",
source: "question:18",
target: "condition:28",
type: "smoothstep",
label: "IS_TRUE",
data: { kind: "condition_expression", subject_type: "question", subject_id: 18, condition_id: 28 },
},
{
id: "task-gate:question:18:48",
source: "question:18",
target: "task:48",
type: "smoothstep",
label: "unlocks",
data: { kind: "task_gate" },
},
{
id: "gateway-binding:701:M-7:0",
source: "gateway:701",
target: "binding:701:M-7:0",
type: "smoothstep",
label: "binds",
data: { kind: "gateway_binding" },
},
{
id: "binding-relay:701:M-7:0",
source: "binding:701:M-7:0",
target: "relay:M-7",
type: "smoothstep",
label: "controls",
data: { kind: "relay_binding" },
},
{
id: "gateway-binding:701:IN-7:1",
source: "gateway:701",
target: "binding:701:IN-7:1",
type: "smoothstep",
label: "binds",
data: { kind: "gateway_binding" },
},
{
id: "binding-relay:701:IN-7:1",
source: "binding:701:IN-7:1",
target: "relay:IN-7",
type: "smoothstep",
label: "controls",
data: { kind: "relay_binding" },
},
{
id: "action-relay:81:IN-7:ENTRY:7",
source: "action:81",
target: "relay:IN-7",
type: "smoothstep",
label: "ENTRY",
data: { kind: "action_relay" },
},
{
id: "task-service:41:MACHINE:701:M-7:0",
source: "task:41",
target: "binding:701:M-7:0",
type: "smoothstep",
label: "MACHINE",
data: { kind: "task_service" },
},
{
id: "task-service:48:MACHINE:701:M-7:0",
source: "task:48",
target: "binding:701:M-7:0",
type: "smoothstep",
label: "MACHINE",
data: { kind: "task_service" },
},
],
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", services: ["MACHINE"], channel: 0 },
{ relay_id: "IN-7", label: "Entry relay", role: "ENTRY", services: ["ENTRY"], channel: 1 },
],
},
],
relays: [
{ relay_id: "M-7", name: "Machine relay" },
{ relay_id: "IN-7", name: "Entry relay" },
],
lanes: [
{
id: 7,
name: "Lane 7",
selfserve_enabled: true,
relay_slots: [
{ slot: "ENTRY", relay_id: "IN-7", coverage: { covered: true, status: "BOUND" } },
{ slot: "MACHINE", relay_id: "M-7", coverage: { covered: true, status: "BOUND" } },
{ slot: "EXIT", relay_id: "EXIT-7", coverage: { covered: false, status: "MISSING" } },
{ slot: "CLEANER", relay_id: "CLEAN-7", coverage: { covered: false, status: "MISSING" } },
],
binding_coverage: { required: 4, bound: 2, missing: 2, state: "MISSING" },
},
],
issues: [],
actions: [],
virtual: {
enabled: true,
has_virtual_hardware: false,
gateway_count: 0,
binding_count: 0,
config: { gateways: [], relays: [], bindings: [] },
},
},
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" },
};
}
function buildSimulationResponse(request = {}) {
const overrides = Array.isArray(request.answer_overrides) ? request.answer_overrides : [];
const mirrorOverride = overrides.find((entry) => Number(entry?.question_id) === 11);
const mirrorAnswer = mirrorOverride ? mirrorOverride.value : null;
const hasMirrorAnswer = mirrorAnswer === true || mirrorAnswer === false;
const taskActive = mirrorAnswer === true;
const resolvedVehicleTypeId = request.vehicle_type_id ?? 2;
const resolvedVehicleType = Number(resolvedVehicleTypeId) === 8 ? "Bus" : "Forvogn";
const reg = request.reg || "TEST123";
const configSource = request.config_source || "draft";
const taskReason = taskActive
? "Task Fold mirrors active because gate QUESTION Are mirrors folded? expected true, actual true."
: `Task Fold mirrors blocked because gate QUESTION Are mirrors folded? expected true, actual ${
mirrorAnswer === false ? "false" : "missing"
}.`;
const taskCause = {
kind: "question",
id: 11,
label: "Are mirrors folded?",
expected: true,
actual: hasMirrorAnswer ? mirrorAnswer : null,
reason: taskReason,
};
const task = {
id: 41,
task: "Fold mirrors",
services: ["PROGRAM_PICKER"],
buttons: [],
dynamic_images_vehicle_type: 4,
attachments: mirrorTaskAttachments,
};
const machineButtonTask = {
id: 42,
task: "Press first button",
services: ["MACHINE"],
buttons: [1, "start"],
dynamic_images_vehicle_type: 4,
attachments: [],
};
const tasks = [task, machineButtonTask];
const debugReasonForTask = (entry) => {
if (entry.id === 41) {
return taskReason;
}
return taskActive
? "Task Press first button active because gate ALWAYS Always expected true, actual true."
: "Task Press first button blocked because the program picker step is not active.";
};
const debugCausesForTask = (entry) => (taskActive || entry.id !== 41 ? [] : [taskCause]);
const debugTasks = tasks.map((entry) => ({
...entry,
node_id: `task:${entry.id}`,
node_ids: [`task:${entry.id}`],
label: entry.task,
active: taskActive,
state: taskActive ? "active" : "blocked",
gate_type: entry.id === 41 ? "QUESTION" : "ALWAYS",
gate_ref_id: entry.id === 41 ? 11 : null,
gate_ref_label: entry.id === 41 ? "Are mirrors folded?" : "Always",
gate_satisfied: taskActive,
reason: debugReasonForTask(entry),
causes: debugCausesForTask(entry),
}));
const dynamicImageButtonsForTask = (entry) =>
entry.services.includes("PROGRAM_PICKER") ? ["program_picker", ...entry.buttons] : entry.buttons;
const dynamicImageButtonLabel = (button) => (button === "program_picker" ? "Program picker" : String(button));
const dynamicButtonDecisions = tasks.flatMap((entry) =>
dynamicImageButtonsForTask(entry).map((button, index) => ({
kind: "dynamic_image_button",
id: `${entry.id}:${index}`,
node_id: `dynamic_image_button:${entry.id}:${index}`,
node_ids: [`task:${entry.id}`, `dynamic_image_button:${entry.id}:${index}`],
label: dynamicImageButtonLabel(button),
task_id: entry.id,
task_label: entry.task,
button_index: index,
button,
state: taskActive ? "active" : "hidden",
reason: taskActive
? `Dynamic-image button ${dynamicImageButtonLabel(button)} is available because task ${entry.task} is active.`
: `Dynamic-image button ${dynamicImageButtonLabel(button)} hidden because task ${entry.task} is blocked.`,
causes: taskActive
? []
: [
{
kind: "task",
id: entry.id,
label: entry.task,
expected: "active",
actual: "blocked",
reason: debugReasonForTask(entry),
},
],
}))
);
const signalTimeline = [
{
kind: "signal",
id: "signal:1",
node_id: "signal:1",
node_ids: ["signal:1"],
label: "eligibility_sync SESSION session_event",
state: "sent",
sequence: 1,
runtime_stage: "eligibility_sync",
signal_type: "session_event",
relay_role: "SESSION",
source: "none",
predicted_status: "sent",
payload: { event: "SESSION_SYNCED", allowed: taskActive },
causes: [],
},
{
kind: "signal",
id: "signal:2",
node_id: "signal:2",
node_ids: ["signal:2", "action:81", "binding:virtual-main:IN-7:3", "relay:IN-7"],
label: "start ENTRY studio_action_relay_pulse",
state: taskActive ? "virtual_only" : "blocked",
sequence: 2,
runtime_stage: "start",
signal_type: "studio_action_relay_pulse",
relay_role: "ENTRY",
relay_id: "IN-7",
target_gateway_label: "Virtual Studio Gateway",
target_binding: "binding:virtual-main:IN-7:3",
source: "virtual",
virtual: true,
predicted_status: taskActive ? "virtual_only" : "blocked",
payload: {
action_id: 81,
action: "Open entry on start",
event: "wash_start_command",
operation: "open_lane_entrance_port",
id: "IN-7",
on: true,
toggle_after: 2,
},
skip_block_reason: taskActive
? "Virtual studio hardware only; live dispatch would require a real gateway binding."
: "Action event would not fire because the dry-run scenario is blocked before this stage.",
causes: taskActive
? []
: [
{
kind: "signal",
id: "signal:2",
label: "start ENTRY studio_action_relay_pulse",
expected: "sent",
actual: "blocked",
reason: "Action event would not fire because the dry-run scenario is blocked before this stage.",
},
],
},
{
kind: "signal",
id: "signal:3",
node_id: "signal:3",
node_ids: ["signal:3", "binding:virtual-main:M-7:0", "relay:M-7"],
label: "eligibility_sync MACHINE relay_switch",
state: taskActive ? "virtual_only" : "blocked",
sequence: 3,
runtime_stage: "eligibility_sync",
signal_type: "relay_switch",
relay_role: "MACHINE",
relay_id: "M-7",
target_gateway_label: "Virtual Studio Gateway",
target_binding: "binding:virtual-main:M-7:0",
source: "virtual",
virtual: true,
predicted_status: taskActive ? "virtual_only" : "blocked",
payload: { id: "M-7", channel: 0, on: true },
skip_block_reason: taskActive
? "Virtual studio hardware only; live dispatch would require a real gateway binding."
: "Eligibility is blocked, so the machine relay would not be enabled.",
causes: taskActive
? []
: [
{
kind: "signal",
id: "signal:3",
label: "eligibility_sync MACHINE relay_switch",
expected: "sent",
actual: "blocked",
reason: "Eligibility is blocked, so the machine relay would not be enabled.",
},
],
},
{
kind: "signal",
id: "signal:4",
node_id: "signal:4",
node_ids: ["signal:4", "binding:virtual-main:M-7:0", "relay:M-7"],
label: "machine_start_signal MACHINE shelly_event",
state: taskActive ? "virtual_only" : "blocked",
sequence: 4,
runtime_stage: "machine_start_signal",
signal_type: "shelly_event",
relay_role: "MACHINE",
relay_id: "M-7",
target_gateway_label: "Virtual Studio Gateway",
target_binding: "binding:virtual-main:M-7:0",
source: "virtual",
virtual: true,
predicted_status: taskActive ? "virtual_only" : "blocked",
payload: { event: "input.toggle_on", alternate_event: "switch.on", bill_machine_wash: true },
skip_block_reason: taskActive
? "Virtual studio hardware only; live dispatch would require a real gateway binding."
: "Machine ON signal would not be accepted before eligibility passes.",
causes: taskActive
? []
: [
{
kind: "signal",
id: "signal:4",
label: "machine_start_signal MACHINE shelly_event",
expected: "sent",
actual: "blocked",
reason: "Machine ON signal would not be accepted before eligibility passes.",
},
],
},
{
kind: "signal",
id: "signal:5",
node_id: "signal:5",
node_ids: ["signal:5", "relay:CLEAN-7"],
label: "machine_start CLEANER relay_switch",
state: taskActive ? "virtual_only" : "blocked",
sequence: 5,
runtime_stage: "machine_start",
signal_type: "relay_switch",
relay_role: "CLEANER",
relay_id: "CLEAN-7",
target_gateway_label: "Virtual Studio Gateway",
source: "virtual",
virtual: true,
predicted_status: taskActive ? "virtual_only" : "blocked",
payload: { id: "CLEAN-7", channel: 0, on: true },
skip_block_reason: taskActive
? "Virtual studio hardware only; live dispatch would require a real gateway binding."
: "Machine start would not run because eligibility is blocked.",
causes: taskActive
? []
: [
{
kind: "signal",
id: "signal:5",
label: "machine_start CLEANER relay_switch",
expected: "sent",
actual: "blocked",
reason: "Machine start would not run because eligibility is blocked.",
},
],
},
{
kind: "signal",
id: "signal:6",
node_id: "signal:6",
node_ids: ["signal:6", "relay:EXIT-7"],
label: "stop EXIT relay_pulse",
state: taskActive ? "virtual_only" : "skipped",
sequence: 6,
runtime_stage: "stop",
signal_type: "relay_pulse",
relay_role: "EXIT",
relay_id: "EXIT-7",
target_gateway_label: "Virtual Studio Gateway",
source: "virtual",
virtual: true,
predicted_status: taskActive ? "virtual_only" : "skipped",
payload: { id: "EXIT-7", on: true, toggle_after: 1 },
skip_block_reason: taskActive
? "Virtual studio hardware only; live dispatch would require a real gateway binding."
: "STOP exit open would not run before a valid wash can start.",
causes: taskActive
? []
: [
{
kind: "signal",
id: "signal:6",
label: "stop EXIT relay_pulse",
expected: "sent",
actual: "skipped",
reason: "STOP exit open would not run before a valid wash can start.",
},
],
},
];
return {
allowed: taskActive,
dry_run: true,
mode: "full_dry_run",
config_source: configSource,
lane: { id: 7, name: "Lane 7", dynamic_image_id: 1 },
vehicle: { type: resolvedVehicleTypeId, name: resolvedVehicleType },
vehicle_type_id: resolvedVehicleTypeId,
questions: [{ id: 11, question: "Are mirrors folded?", answer: hasMirrorAnswer ? mirrorAnswer : null }],
tasks: taskActive ? tasks : [],
allowed_services: taskActive ? ["PROGRAM_PICKER", "MACHINE"] : [],
machine_available: true,
all_visible_questions_answered: hasMirrorAnswer,
debug: {
summary: {
status: taskActive ? "allowed" : "blocked",
title: taskActive ? "Allowed" : "Blocked",
next_action: taskActive
? "No action required."
: hasMirrorAnswer
? "No active task exposes the MACHINE service for this scenario."
: "1 visible question(s) are missing answers.",
primary_blocker: taskActive
? null
: {
title: "Answer required questions",
message: hasMirrorAnswer
? "No active task exposes the MACHINE service for this scenario."
: "1 visible question(s) are missing answers.",
node_ids: ["question:11"],
},
},
parameters: {
department_id: 6,
lane_id: 7,
lane: "Lane 7",
vehicle_type_id: resolvedVehicleTypeId,
vehicle_type: resolvedVehicleType,
registration: reg,
config_source: configSource,
mode: "full_dry_run",
dry_run: true,
},
stages: [
{
id: "input",
title: "Input normalization",
status: "ok",
summary: "Registration normalized.",
node_ids: ["checkpoint:start"],
edge_ids: [],
},
{
id: "questions",
title: "Question visibility and answers",
status: hasMirrorAnswer ? "ok" : "error",
summary: hasMirrorAnswer
? "1 question evaluated; 0 visible question(s) missing answers."
: "1 visible question missing answers.",
node_ids: ["question:11"],
edge_ids: [],
},
{
id: "tasks",
title: "Task gates and services",
status: taskActive ? "ok" : "error",
summary: taskActive ? "2 tasks active; services: PROGRAM_PICKER, MACHINE" : "0 tasks active.",
node_ids: ["task:41", "task:42"],
edge_ids: [],
},
{
id: "hardware",
title: "Gateway and relay readiness",
status: "ok",
summary: "Lane relay and gateway service bindings are ready for the simulated services.",
node_ids: ["lane:7"],
edge_ids: [],
},
],
questions: [
{
id: 11,
node_id: "question:11",
node_ids: ["question:11"],
label: "Are mirrors folded?",
state: hasMirrorAnswer ? "answered" : "missing",
visible: true,
answer: hasMirrorAnswer ? mirrorAnswer : null,
answer_source: "override",
reason: hasMirrorAnswer
? `Question Are mirrors folded? is visible and answered ${mirrorAnswer}.`
: "Question Are mirrors folded? is visible but has no answer.",
causes: hasMirrorAnswer
? []
: [
{
kind: "question",
id: 11,
label: "Are mirrors folded?",
expected: "answered",
actual: "missing",
reason: "Visible question has no simulated answer.",
},
],
},
],
conditions: [
{
id: 21,
node_id: "condition:21",
node_ids: ["condition:21"],
label: "Trailer present",
result: taskActive,
state: taskActive ? "passed" : "failed",
reason: taskActive ? "Condition passed." : "Predicate did not pass.",
expression_summary: "All: Are mirrors folded? is true",
next_fix: "Set Are mirrors folded? so it satisfies is true.",
causes: taskActive ? [] : [taskCause],
},
],
rules: [],
tasks: debugTasks,
actions: [
{
kind: "action",
id: 81,
node_id: "action:81",
node_ids: ["action:81"],
label: "Open entry on start",
active: taskActive,
state: taskActive ? "active" : "skipped",
reason: taskActive
? "Action would run for this simulator event."
: "Action event would not fire because the dry-run scenario is blocked before this stage.",
causes: taskActive ? [] : [taskCause],
},
],
dynamic_image_buttons: dynamicButtonDecisions,
decisions: [
{
kind: "question",
id: 11,
label: "Are mirrors folded?",
state: hasMirrorAnswer ? "answered" : "missing",
reason: hasMirrorAnswer
? `Question Are mirrors folded? is visible and answered ${mirrorAnswer}.`
: "Question Are mirrors folded? is visible but has no answer.",
node_ids: ["question:11"],
causes: hasMirrorAnswer
? []
: [
{
kind: "question",
id: 11,
label: "Are mirrors folded?",
expected: "answered",
actual: "missing",
},
],
},
{
kind: "task",
id: 41,
label: "Fold mirrors",
state: taskActive ? "active" : "blocked",
reason: taskReason,
node_ids: ["task:41"],
causes: taskActive ? [] : [taskCause],
},
{
kind: "task",
id: 42,
label: "Press first button",
state: taskActive ? "active" : "blocked",
reason: debugReasonForTask(machineButtonTask),
node_ids: ["task:42"],
causes: debugCausesForTask(machineButtonTask),
},
{
kind: "action",
id: 81,
label: "Open entry on start",
state: taskActive ? "active" : "skipped",
reason: taskActive
? "Action would run for this simulator event."
: "Action event would not fire because the dry-run scenario is blocked before this stage.",
node_ids: ["action:81"],
causes: taskActive ? [] : [taskCause],
},
...signalTimeline,
...dynamicButtonDecisions,
],
hardware: {
machine_relay_configured: true,
allowed_services: taskActive ? ["PROGRAM_PICKER", "MACHINE"] : [],
missing_service_bindings: [],
virtual: { has_virtual_hardware: true, gateway_count: 1, binding_count: 3 },
signal_timeline: signalTimeline,
summary: "Lane relay and gateway service bindings are ready for the simulated services.",
dry_run_operations: [
{
operation: "machine_relay_enable",
message: taskActive
? "Dry run predicts the machine relay would be enabled."
: "Dry run predicts no machine relay action because eligibility is blocked.",
},
],
},
signal_timeline: signalTimeline,
graph_annotations: {
nodes: {
"question:11": {
state: hasMirrorAnswer ? "active" : "warning",
label: hasMirrorAnswer ? "Answered" : "Missing answer",
},
"task:41": { state: taskActive ? "active" : "blocked", label: taskReason },
"task:42": { state: taskActive ? "active" : "blocked", label: debugReasonForTask(machineButtonTask) },
"action:81": {
state: taskActive ? "active" : "not_applicable",
label: taskActive ? "Action would run for this simulator event." : "Action skipped",
},
"lane:7": { state: "active", label: "Lane relay ready" },
},
edges: {
"task-gate:question:11:41": { state: taskActive ? "active" : "blocked", label: "unlocks" },
},
},
recommendations: taskActive
? [
{
severity: "success",
title: "Flow is ready",
message: "The simulated parameters pass every dry-run check.",
node_ids: ["checkpoint:eligible"],
},
]
: [
{
severity: "error",
title: hasMirrorAnswer ? "Expose MACHINE service" : "Answer required questions",
message: hasMirrorAnswer
? "No active task exposes the MACHINE service for this scenario."
: "1 visible question(s) are missing answers.",
node_ids: ["question:11"],
},
],
},
};
}
function buildPathOutcomesResponse(request = {}) {
const vehicleTypeId = request.vehicle_type_id ?? null;
const vehicleType = vehicleTypeId === 8 ? "Bus" : vehicleTypeId === 2 ? "Forvogn" : "All current vehicle types";
return {
scope: {
department_id: 6,
department: "Roskilde",
lane_id: request.lane_id ?? 7,
lane: "Lane 7",
vehicle_type_id: vehicleTypeId,
vehicle_type: vehicleType,
vehicle_type_count: vehicleTypeId ? 1 : 2,
registration: "TEST123",
config_source: request.config_source || "draft",
hardware_mode: request.hardware_mode || "studio",
max_states: request.max_states ?? null,
},
summary: {
state_count: 5,
terminal_path_count: 3,
outcome_count: 2,
question_count: 2,
question_ids: [11, 12],
max_states: request.max_states ?? null,
path_sample_count: 2,
},
outcomes: [
{
id: "outcome-1",
summary: "Allowed / MACHINE / 1 task / 2 signals",
path_count: 1,
allowed: true,
services: ["MACHINE"],
tasks: [
{
id: 41,
node_id: "task:41",
label: "Fold mirrors",
services: ["MACHINE"],
buttons: ["reset", 1, "start"],
order_priority: 1,
},
],
signals: [
{
sequence: 1,
runtime_stage: "eligibility_sync",
signal_type: "session_event",
relay_role: "SESSION",
source: "none",
virtual: false,
predicted_status: "sent",
payload: { allowed: true, allowed_services: ["MACHINE"] },
},
{
sequence: 2,
runtime_stage: "machine_start_signal",
signal_type: "shelly_event",
relay_role: "MACHINE",
relay_id: "M-7",
target_gateway_label: "Virtual Studio Gateway",
target_binding: "binding:virtual-main:M-7:0",
source: "virtual",
virtual: true,
predicted_status: "virtual_only",
payload: { event: "input.toggle_on", bill_machine_wash: true },
},
],
sample_chains: [
{
scope: { lane: "Lane 7", vehicle_type: vehicleType },
answers: [
{
question_id: 11,
question: "Are mirrors folded?",
node_id: "question:11",
answer: true,
answer_label: "Yes",
},
{
question_id: 12,
question: "Is the lift lowered?",
node_id: "question:12",
answer: true,
answer_label: "Yes",
},
],
},
],
scopes: [{ lane: "Lane 7", vehicle_type: vehicleType }],
node_ids: ["question:11", "question:12", "task:41", "binding:virtual-main:M-7:0"],
},
{
id: "outcome-2",
summary: "Blocked / No services / 0 tasks / 1 signal",
path_count: 2,
allowed: false,
services: [],
tasks: [],
signals: [
{
sequence: 1,
runtime_stage: "eligibility_sync",
signal_type: "session_event",
relay_role: "SESSION",
source: "none",
virtual: false,
predicted_status: "skipped",
payload: { allowed: false, allowed_services: [] },
},
],
sample_chains: [
{
scope: { lane: "Lane 7", vehicle_type: vehicleType },
answers: [
{
question_id: 11,
question: "Are mirrors folded?",
node_id: "question:11",
answer: false,
answer_label: "No",
},
],
},
],
scopes: [{ lane: "Lane 7", vehicle_type: vehicleType }],
node_ids: ["question:11"],
},
],
paths: [
{
id: "path-1",
result: "Allowed",
summary: "Allowed / MACHINE / 1 task / 2 signals",
allowed: true,
services: ["MACHINE"],
tasks: [
{
id: 41,
node_id: "task:41",
label: "Fold mirrors",
services: ["MACHINE"],
buttons: ["reset", 1, "start"],
order_priority: 1,
},
],
signals: [
{
sequence: 1,
runtime_stage: "eligibility_sync",
signal_type: "session_event",
relay_role: "SESSION",
source: "none",
virtual: false,
predicted_status: "sent",
payload: { allowed: true, allowed_services: ["MACHINE"] },
},
{
sequence: 2,
runtime_stage: "machine_start_signal",
signal_type: "shelly_event",
relay_role: "MACHINE",
relay_id: "M-7",
target_gateway_label: "Virtual Studio Gateway",
target_binding: "binding:virtual-main:M-7:0",
source: "virtual",
virtual: true,
predicted_status: "virtual_only",
payload: { event: "input.toggle_on", bill_machine_wash: true },
},
],
task_count: 1,
signal_count: 2,
answers: [
{
question_id: 11,
question: "Are mirrors folded?",
node_id: "question:11",
answer: true,
answer_label: "Yes",
},
{
question_id: 12,
question: "Is the lift lowered?",
node_id: "question:12",
answer: true,
answer_label: "Yes",
},
],
scope: { lane: "Lane 7", vehicle_type: vehicleType },
node_ids: ["question:11", "question:12", "task:41", "binding:virtual-main:M-7:0"],
},
{
id: "path-2",
result: "Blocked",
summary: "Blocked / No services / 0 tasks / 1 signal",
allowed: false,
services: [],
tasks: [],
signals: [
{
sequence: 1,
runtime_stage: "eligibility_sync",
signal_type: "session_event",
relay_role: "SESSION",
source: "none",
virtual: false,
predicted_status: "skipped",
payload: { allowed: false, allowed_services: [] },
},
],
task_count: 0,
signal_count: 1,
answers: [
{
question_id: 11,
question: "Are mirrors folded?",
node_id: "question:11",
answer: false,
answer_label: "No",
},
],
scope: { lane: "Lane 7", vehicle_type: vehicleType },
node_ids: ["question:11"],
},
],
warnings: [],
truncated: false,
progress: {
complete: true,
percent: 100,
state_count: 5,
pending_state_count: 0,
terminal_path_count: 3,
},
};
}
function applyVirtualHardwareToGraph(graph) {
if (!graph.gateway_workspace.virtual?.has_virtual_hardware) {
graph.gateway_workspace.virtual = {
enabled: true,
has_virtual_hardware: true,
gateway_count: 1,
binding_count: 3,
virtual_only_relay_ids: ["M-7", "EXIT-7", "CLEAN-7"],
config: {
gateways: [{ key: "virtual-main", label: "Virtual Studio Gateway", status: "VIRTUAL" }],
relays: [
{ relay_id: "M-7", name: "Lane 7 MACHINE", virtual: true },
{ relay_id: "EXIT-7", name: "Lane 7 EXIT", virtual: true },
{ relay_id: "CLEAN-7", name: "Lane 7 CLEANER", virtual: true },
],
bindings: [
{
id: "virtual-main:M-7:MACHINE",
gateway_key: "virtual-main",
relay_id: "M-7",
role: "MACHINE",
services: ["MACHINE"],
label: "Lane 7 MACHINE",
virtual: true,
},
{
id: "virtual-main:EXIT-7:EXIT",
gateway_key: "virtual-main",
relay_id: "EXIT-7",
role: "EXIT",
services: ["EXIT"],
label: "Lane 7 EXIT",
virtual: true,
},
{
id: "virtual-main:CLEAN-7:CLEANER",
gateway_key: "virtual-main",
relay_id: "CLEAN-7",
role: "CLEANER",
services: ["CLEANER"],
label: "Lane 7 CLEANER",
virtual: true,
},
],
},
};
graph.gateway_workspace.gateways.push({
id: "virtual-main",
key: "virtual-main",
label: "Virtual Studio Gateway",
status: "VIRTUAL",
virtual: true,
studio_only: true,
bindings: [
{
node_id: "binding:virtual-main:M-7:0",
gateway_key: "virtual-main",
relay_id: "M-7",
label: "Lane 7 MACHINE",
role: "MACHINE",
services: ["MACHINE"],
channel: 0,
virtual: true,
},
{
node_id: "binding:virtual-main:EXIT-7:1",
gateway_key: "virtual-main",
relay_id: "EXIT-7",
label: "Lane 7 EXIT",
role: "EXIT",
services: ["EXIT"],
channel: 0,
virtual: true,
},
{
node_id: "binding:virtual-main:CLEAN-7:2",
gateway_key: "virtual-main",
relay_id: "CLEAN-7",
label: "Lane 7 CLEANER",
role: "CLEANER",
services: ["CLEANER"],
channel: 0,
virtual: true,
},
],
});
graph.gateway_workspace.relays.push(
{ relay_id: "EXIT-7", name: "Lane 7 EXIT", virtual: true },
{ relay_id: "CLEAN-7", name: "Lane 7 CLEANER", virtual: true }
);
graph.nodes.push(
{
id: "gateway:virtual-main",
type: "default",
position: { x: 1280, y: 360 },
data: {
kind: "edge_gateway",
object_id: "virtual-main",
label: "Virtual Studio Gateway",
subtitle: "VIRTUAL",
raw: { id: "virtual-main", label: "Virtual Studio Gateway", status: "VIRTUAL", virtual: true },
},
},
{
id: "binding:virtual-main:M-7:0",
type: "default",
position: { x: 1600, y: 360 },
data: {
kind: "relay_binding",
object_id: "M-7",
label: "Lane 7 MACHINE",
subtitle: "MACHINE",
raw: { relay_id: "M-7", label: "Lane 7 MACHINE", role: "MACHINE", services: ["MACHINE"], virtual: true },
},
}
);
graph.edges.push({
id: "task-service:41:MACHINE:virtual-main:M-7:0",
source: "task:41",
target: "binding:virtual-main:M-7:0",
type: "smoothstep",
label: "MACHINE",
data: { kind: "task_service" },
});
graph.lookups.gateways.push({
id: "virtual-main",
label: "Virtual Studio Gateway",
status: "VIRTUAL",
virtual: true,
});
graph.lookups.bindings.push({
id: "virtual-main:M-7:0",
label: "Lane 7 MACHINE",
gateway_id: "virtual-main",
relay_id: "M-7",
role: "MACHINE",
services: ["MACHINE"],
virtual: true,
});
graph.validation = {
valid: true,
errors: [],
warnings: [
"Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.",
],
items: [
{
severity: "warning",
message:
"Studio uses virtual hardware coverage. Publishing is allowed, but live relay dispatch still requires a real edge gateway and real relay bindings.",
},
],
};
}
return graph;
}
async function installStudioRoutes(page, graph, captured) {
const dynamicImageSvg = `
`;
const customerResults = [
{ id: 601001, customerNumber: 601001, name: "Acme Logistics", city: "Roskilde" },
{ id: 602002, customerNumber: 602002, name: "Nordic Wash Transport", city: "Koge" },
];
await page.route(/\/customers(?:\?.*)?$/i, async (route, request) => {
const url = new URL(request.url());
const search = String(url.searchParams.get("search") || "").toLowerCase();
captured.customerSearches ||= [];
captured.customerSearches.push(url.searchParams.get("search") || "");
const filteredCustomers = customerResults.filter(
(customer) => String(customer.customerNumber).includes(search) || customer.name.toLowerCase().includes(search)
);
await route.fulfill(
json({
data: filteredCustomers,
meta: {
pagination: {
page: 1,
limit: 10,
total: filteredCustomers.length,
},
},
})
);
});
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 === "create" && operation.entity === "action") {
graph.nodes.push({
id: "action:99",
type: "default",
position: { x: 1280, y: 320 },
data: {
kind: "action",
object_id: 99,
label: operation.data.name,
subtitle: "When wash starts / Both / Open lane entrance port",
event_label: "When wash starts",
action_label: "Open lane entrance port",
relay_role: "ENTRY",
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;
}
if (operation.action === "update" && operation.entity === "condition") {
const node = graph.nodes.find((entry) => entry.id === `condition:${operation.id}`);
node.data.raw = { ...node.data.raw, ...operation.data };
node.data.label = operation.data.name || node.data.label;
node.data.expression_summary = "All: Are mirrors folded? is true";
}
if (operation.action === "update" && operation.entity === "task" && Number(operation.id) === 41) {
const node = graph.nodes.find((entry) => entry.id === "task:41");
node.data.raw = { ...node.data.raw, ...operation.data };
node.data.label = operation.data.task || node.data.label;
}
if (operation.action === "update" && operation.entity === "action" && Number(operation.id) === 81) {
const node = graph.nodes.find((entry) => entry.id === "action:81");
node.data.raw = { ...node.data.raw, ...operation.data };
node.data.label = operation.data.name || node.data.label;
node.data.action_label =
operation.data.operation === "set_cleaner_relay" ? "Turn OFF CLEANER" : "Open lane entrance port";
node.data.relay_role = operation.data.operation === "set_cleaner_relay" ? "CLEANER" : "ENTRY";
}
if (operation.action === "update" && operation.entity === "lane") {
const lane = graph.lookups.lanes.find((entry) => Number(entry.id) === Number(operation.id));
if (lane) {
Object.assign(lane, operation.data);
lane.label = operation.data.name || lane.label;
graph.lookups.labels.lanes[String(operation.id)] = lane.label;
}
}
if (operation.action === "create" && operation.entity === "lane") {
const newId = Math.max(0, ...graph.lookups.lanes.map((entry) => Number(entry.id) || 0)) + 1;
const lane = {
id: newId,
department: 6,
name: operation.data.name,
label: operation.data.name,
machine_type_id: operation.data.machine_type_id ?? null,
dynamic_image_id: operation.data.dynamic_image_id ?? null,
selfserve_enabled: operation.data.selfserve_enabled ?? true,
};
graph.lookups.lanes.push(lane);
graph.lookups.labels.lanes[String(newId)] = lane.label;
}
}
await route.fulfill(json({ data: graph }));
});
await page.route(/\/department\/selfserve\/tasks\/attachments\/upload(?:\?.*)?$/i, async (route, request) => {
const body = request.postDataJSON?.() || {};
captured.taskAttachmentUploads ||= [];
captured.taskAttachmentUploads.push(body);
const taskId = Number(body.task_id);
const attachments = [...(graphTaskRaw(graph, taskId)?.attachments || [])];
const attachment = {
id: 900 + attachments.length,
object_type: "department_selfserve_tasks",
object_id: taskId,
content: { other: body.file_name, document: `tasks/${body.file_name}` },
download_link: `https://cdn.example.test/${body.file_name}`,
};
setGraphTaskAttachments(graph, taskId, [...attachments, attachment]);
await route.fulfill(json({ data: attachment }));
});
await page.route(/\/department\/selfserve\/tasks\/attachments\/download(?:\?.*)?$/i, async (route, request) => {
const url = new URL(request.url());
captured.taskAttachmentDownloads ||= [];
captured.taskAttachmentDownloads.push({
task_id: Number(url.searchParams.get("task_id")),
attachment_id: Number(url.searchParams.get("attachment_id")),
});
await route.fulfill(json({ data: { download_link: "https://cdn.example.test/generated-task-file.pdf" } }));
});
await page.route(/\/department\/selfserve\/tasks\/attachments(?:\?.*)?$/i, async (route, request) => {
const body = request.postDataJSON?.() || {};
const url = new URL(request.url());
const taskId = Number(body.task_id || url.searchParams.get("id"));
if (request.method() === "DELETE") {
captured.taskAttachmentDeletes ||= [];
captured.taskAttachmentDeletes.push(body);
const attachmentId = Number(body.attachment_id);
setGraphTaskAttachments(
graph,
taskId,
(graphTaskRaw(graph, taskId)?.attachments || []).filter((attachment) => Number(attachment.id) !== attachmentId)
);
await route.fulfill(json({ data: { message: "Attachment deleted successfully" } }));
return;
}
await route.fulfill(json({ data: graphTaskRaw(graph, taskId)?.attachments || [] }));
});
await page.route(/\/products(?:\?.*)?$/i, async (route, request) => {
if (request.method() !== "PUT") {
await route.fulfill(json({ data: graph.lookups.products }));
return;
}
const body = request.postDataJSON?.() || {};
captured.productUpdates ||= [];
captured.productUpdates.push(body);
const product = graph.lookups.products.find((entry) => Number(entry.id) === Number(body.id));
if (product) {
Object.assign(product, body, {
label: body.name || product.label,
name: body.name || product.name,
});
graph.lookups.labels.products[String(body.id)] = product.label;
graph.lookups.labels.vehicle_types[String(body.id)] = product.label;
}
const vehicleType = graph.lookups.vehicle_types.find(
(entry) => Number(entry.product || entry.id) === Number(body.id)
);
if (vehicleType) {
Object.assign(vehicleType, body, {
product: body.id,
product_id: body.id,
label: body.name || vehicleType.label,
});
}
const node = graph.nodes.find((entry) => entry.id === `vehicle_type:${body.id}`);
if (node) {
node.data.label = body.name || node.data.label;
node.data.raw = {
...node.data.raw,
...body,
product: body.id,
product_id: body.id,
label: body.name || node.data.raw?.label,
};
}
await route.fulfill(json({ data: product || body }));
});
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\/virtual-hardware(?:\?.*)?$/i, async (route, request) => {
const body = request.postDataJSON?.() || {};
captured.virtualHardware.push(body);
if (
body.operation === "generate_from_lanes" ||
body.operation === "upsert_binding" ||
body.operation === "upsert_gateway"
) {
applyVirtualHardwareToGraph(graph);
}
if (body.operation === "reset") {
graph.gateway_workspace.virtual = {
enabled: true,
has_virtual_hardware: false,
gateway_count: 0,
binding_count: 0,
config: { gateways: [], relays: [], bindings: [] },
};
}
await route.fulfill(json({ data: graph }));
});
await page.route(/\/department\/selfserve\/studio\/simulate(?:\?.*)?$/i, async (route, request) => {
const body = request.postDataJSON?.() || {};
captured.simulations.push(body);
const delayMs = captured.simulationDelays?.length ? captured.simulationDelays.shift() : 0;
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill(json({ data: buildSimulationResponse(body) }));
});
await page.route(/\/department\/selfserve\/studio\/path-outcomes\/stream(?:\?.*)?$/i, async (route, request) => {
const body = request.postDataJSON?.() || {};
captured.pathOutcomes.push(body);
const complete = buildPathOutcomesResponse(body);
const progress = {
...complete,
summary: {
...complete.summary,
state_count: 3,
terminal_path_count: 1,
outcome_count: 1,
path_sample_count: 1,
},
outcomes: complete.outcomes.slice(0, 1),
paths: complete.paths.slice(0, 1),
progress: {
complete: false,
percent: 55,
state_count: 3,
pending_state_count: 2,
terminal_path_count: 1,
},
};
await new Promise((resolve) => setTimeout(resolve, 500));
await route.fulfill({
status: 200,
contentType: "application/x-ndjson",
body: `${JSON.stringify({ type: "progress", data: progress })}\n
\n${JSON.stringify({
type: "complete",
data: complete,
})}\n`,
});
});
await page.route(/\/department\/selfserve\/studio\/path-outcomes(?:\?.*)?$/i, async (route, request) => {
const body = request.postDataJSON?.() || {};
captured.pathOutcomes.push(body);
await route.fulfill(json({ data: buildPathOutcomesResponse(body) }));
});
await page.route(/\/department\/lanes\/dynamic-image(?:\?.*)?$/i, async (route, request) => {
captured.dynamicImages.push(new URL(request.url()));
await route.fulfill({
status: 200,
contentType: "image/svg+xml",
body: dynamicImageSvg,
});
});
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, warnings: graph.validation.warnings || [] } })
);
});
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("requires URL-backed scope and restores simulator customer answers", async ({ page }) => {
const graph = buildStudioGraph();
const captured = {
graphSaves: [],
layouts: [],
simulations: [],
simulationDelays: [],
pathOutcomes: [],
dynamicImages: [],
gatewayActions: [],
virtualHardware: [],
productUpdates: [],
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({ timeout: 30_000 });
await expect(page.getByTestId("studio-scope-guide")).toBeVisible();
await expect(page.getByTestId("studio-scope-guide")).toContainText("Choose simulator scope");
await page.getByTestId("studio-scope-option-lane-7").click();
await page.getByTestId("studio-scope-option-vehicle-8").click();
await page.getByTestId("studio-scope-apply").click();
await expect(page.getByTestId("studio-scope-guide")).toHaveCount(0);
await expect(page).toHaveURL(/lane_id=7/);
await expect(page).toHaveURL(/vehicle_type_id=8/);
await expect(page.getByTestId("studio-filter-lane")).toHaveValue("7");
await expect(page.getByTestId("studio-filter-vehicle-type")).toHaveValue("8");
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-studio")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("studio-scope-guide")).toHaveCount(0);
await expect(page.getByTestId("studio-filter-lane")).toHaveValue("7");
await expect(page.getByTestId("studio-filter-vehicle-type")).toHaveValue("8");
await page.getByTestId("studio-panel-simulator").click();
await page.getByTestId("studio-simulator-customer-search").fill("Acme");
await expect(page.getByTestId("studio-simulator-customer-results")).toContainText("Acme Logistics");
await page.getByTestId("studio-simulator-customer-option-601001").click();
const mirrorQuestion = page.locator(".studio-simulator-question").filter({ hasText: "Are mirrors folded?" });
await mirrorQuestion.getByRole("button", { name: "Yes" }).click();
await expect
.poll(() => captured.simulations.at(-1)?.answer_overrides?.[0])
.toEqual({ question_id: 11, value: true });
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.getByTestId("self-serve-studio")).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("studio-panel-simulator")).toHaveClass(/is-active/);
await expect(page.getByTestId("studio-simulator-selected-customer")).toContainText("Acme Logistics");
await expect(page.getByTestId("studio-simulator-selected-customer")).toContainText("#601001");
await expect(
page
.locator(".studio-simulator-question")
.filter({ hasText: "Are mirrors folded?" })
.getByRole("button", { name: "Yes" })
).toHaveClass(/is-primary/);
await expect
.poll(() => captured.simulations.at(-1)?.answer_overrides?.[0])
.toEqual({ question_id: 11, value: true });
await expect.poll(() => captured.simulations.at(-1)?.customer_number).toBe(601001);
});
test("loads the replacement graph workspace and edits a question from the inspector", async ({ page }, testInfo) => {
const graph = buildStudioGraph();
const captured = {
graphSaves: [],
layouts: [],
simulations: [],
simulationDelays: [],
pathOutcomes: [],
dynamicImages: [],
gatewayActions: [],
virtualHardware: [],
productUpdates: [],
rollbacks: [],
published: 0,
};
await installStudioRoutes(page, graph, captured);
await page.addInitScript(() => {
Object.defineProperty(Element.prototype, "requestFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
Object.defineProperty(Document.prototype, "exitFullscreen", {
configurable: true,
value: () => Promise.resolve(),
});
});
await page.goto("/admin/6/modules/self-serve/studio?lane_id=7&vehicle_type_id=2", {
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("Expression");
await expect(page.getByTestId("studio-palette-rule")).toHaveCount(0);
await expect(page.getByTestId("studio-custom-node-rule")).toHaveCount(0);
await expect(page.getByTestId("studio-custom-node-task").first()).toContainText("services");
await expect(page.getByTestId("studio-custom-node-task").first()).toContainText("MACHINE");
const foldMirrorsNode = page.locator('.vue-flow__node[data-id="task:41"]');
await expect(foldMirrorsNode.getByTestId("studio-task-node-attachments")).toContainText("mirror-guide.pdf");
await expect(foldMirrorsNode.getByTestId("studio-task-node-attachments")).toContainText("mirror-photo.jpg");
await expect(foldMirrorsNode).toContainText("2 attachments");
await expect(page.getByTestId("studio-palette-action")).toBeVisible();
await expect(page.getByTestId("studio-custom-node-action").first()).toContainText("When wash starts");
await expect(page.getByTestId("studio-custom-node-action").first()).toContainText("Open lane entrance port");
await expect(page.getByTestId("studio-custom-node-gateway").first()).toContainText("ONLINE");
await expect(page.getByTestId("studio-custom-node-binding").first()).toContainText("MACHINE");
await expect(page.locator('[data-id="action-relay:81:IN-7:ENTRY:7"]')).toHaveCount(1);
await expect(page.getByTestId("studio-custom-node-scope").first()).toContainText("Forvogn");
await expect(page.getByTestId("studio-filter-machine-type")).toContainText("Portal");
await expect(page.getByTestId("studio-filter-dynamic-image")).toContainText("Machine 1");
await page.getByTestId("studio-filter-machine-type").selectOption({ label: "Portal" });
await expect(page.getByTestId("studio-custom-node-task").filter({ hasText: "Fold mirrors" }).first()).toBeVisible();
await page.getByTestId("studio-filter-machine-type").selectOption("");
await page.getByTestId("studio-filter-dynamic-image").selectOption({ label: "Machine 1" });
await expect(page.getByTestId("studio-custom-node-task").filter({ hasText: "Fold mirrors" }).first()).toBeVisible();
await page.getByTestId("studio-filter-dynamic-image").selectOption({ label: "Machine 2" });
await expect(
page.getByTestId("studio-custom-node-task").filter({ hasText: "Fold mirrors" }).first()
).not.toBeVisible();
await page.getByTestId("studio-filter-dynamic-image").selectOption("");
await page.getByTestId("studio-panel-lanes").click();
await expect(page.getByTestId("studio-lane-list")).toContainText("Lane 7");
await expect(page.getByTestId("studio-lane-dynamic-preview")).toContainText("Machine 1");
await expect(page.getByTestId("studio-lane-dynamic-image-preview")).toBeVisible();
let lanePreviewUrl = new URL(
await page.getByTestId("studio-lane-dynamic-image-preview").getAttribute("src"),
page.url()
);
expect(lanePreviewUrl.searchParams.get("dynamic_image_id")).toBe("1");
await page.getByTestId("studio-lane-dynamic-image").selectOption({ label: "Machine 2" });
lanePreviewUrl = new URL(
await page.getByTestId("studio-lane-dynamic-image-preview").getAttribute("src"),
page.url()
);
expect(lanePreviewUrl.searchParams.get("dynamic_image_id")).toBe("2");
await expect(page.getByTestId("studio-lane-selfserve-enabled")).toBeChecked();
await page.getByTestId("studio-lane-selfserve-enabled").uncheck();
await page.getByTestId("studio-lane-save").click();
await expect
.poll(() =>
captured.graphSaves.some(
(save) =>
save.operations?.[0]?.entity === "lane" &&
save.operations?.[0]?.action === "update" &&
save.operations?.[0]?.id === 7 &&
save.operations?.[0]?.data?.dynamic_image_id === 2 &&
save.operations?.[0]?.data?.selfserve_enabled === false
)
)
.toBe(true);
await expect(page.getByTestId("studio-lane-row-7")).toContainText("Machine 2");
await expect(page.getByTestId("studio-lane-row-7")).toContainText("Disabled");
await page.getByTestId("studio-panel-flow").click();
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(expect.arrayContaining(["Lane 7", "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 page.getByTestId("studio-panel-tasks").click();
await expect(page.getByTestId("studio-task-list")).toContainText("Start bus wash");
await expect(page.getByTestId("studio-task-list")).not.toContainText("Fold mirrors");
await page.getByTestId("studio-panel-flow").click();
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);
const isWebKitProject = testInfo.project.name.startsWith("webkit-");
const maxFocusedSpreadX = isWebKitProject ? 1120 : 980;
const maxFocusedSpreadY = isWebKitProject ? 560 : 460;
expect(focusedNodeMetrics.spreadX).toBeLessThan(maxFocusedSpreadX);
expect(focusedNodeMetrics.spreadY).toBeLessThan(maxFocusedSpreadY);
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
await expect
.poll(async () => page.locator('[data-testid="studio-custom-node-scope"]:visible h4').allTextContents())
.toEqual(expect.arrayContaining(["Lane 7", "Bus"]));
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Forvogn" });
await expect(page.getByText("Are mirrors folded?").first()).toBeVisible();
await page.locator(".studio-node-list button").filter({ hasText: "Forvogn" }).click();
await expect(page.getByTestId("studio-managed-inspector")).toBeVisible();
await expect(page.getByTestId("studio-vehicle-type-form")).toBeVisible();
await expect(page.getByTestId("studio-vehicle-type-price")).toHaveValue("649");
await page.getByTestId("studio-vehicle-type-name").fill("Forvogn XL");
await page.getByRole("button", { name: "Save vehicle type" }).click();
await expect.poll(() => captured.productUpdates.at(-1)?.name).toBe("Forvogn XL");
await expect(page.locator('.vue-flow__node[data-id="vehicle_type:2"]')).toContainText("Forvogn XL");
await page.getByTestId("studio-panel-flow").click();
await page.locator(".studio-node-list button").filter({ hasText: "Fold mirrors" }).click();
await expect(page.getByRole("heading", { name: "Inspector" })).toBeVisible();
await expect(page.getByTestId("studio-service-picker")).toContainText("MACHINE");
await expect(page.getByTestId("studio-service-picker")).toContainText("Machine relay on Roskilde Edge 01");
await expect(page.getByTestId("studio-service-picker").locator('input[value="MACHINE"]')).toBeChecked();
await expect(page.getByTestId("studio-task-button-picker")).toContainText("Reset");
await expect(page.getByTestId("studio-task-button-picker")).toContainText("Start");
await expect(page.getByTestId("studio-task-button-picker").locator('input[value="reset"]')).toBeChecked();
await expect(page.getByTestId("studio-task-button-picker").locator('input[value="start"]')).toBeChecked();
await expect(page.getByTestId("studio-task-dynamic-preview")).toBeVisible();
await expect(page.getByTestId("studio-task-dynamic-preview")).toContainText("Reset, Program 2, Start");
await expect(page.getByTestId("studio-task-dynamic-image")).toBeVisible();
const taskPreviewUrl = new URL(await page.getByTestId("studio-task-dynamic-image").getAttribute("src"), page.url());
expect(taskPreviewUrl.searchParams.get("lane")).toBe("7");
expect(JSON.parse(taskPreviewUrl.searchParams.get("buttons") || "[]")).toEqual(["reset", 1, "start"]);
expect(taskPreviewUrl.searchParams.get("thumb_position")).toBe("5");
await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("mirror-guide.pdf");
await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("mirror-photo.jpg");
await page.getByTestId("studio-task-attachment-upload").setInputFiles({
name: "driver-checklist.txt",
mimeType: "text/plain",
buffer: Buffer.from("Check mirrors before wash"),
});
await expect.poll(() => captured.taskAttachmentUploads?.at(-1)?.file_name).toBe("driver-checklist.txt");
await expect(page.getByTestId("studio-task-attachment-manager")).toContainText("driver-checklist.txt");
await page
.locator(".studio-task-attachment-item")
.filter({ hasText: "driver-checklist.txt" })
.getByTitle("Remove file")
.click();
await expect.poll(() => captured.taskAttachmentDeletes?.at(-1)?.attachment_id).toBe(902);
await expect(page.getByTestId("studio-task-attachment-manager")).not.toContainText("driver-checklist.txt");
await page.getByTestId("studio-service-picker").locator('input[value="MACHINE"]').uncheck();
await page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() =>
captured.graphSaves.some(
(save) =>
save.operations?.[0]?.entity === "task" &&
Array.isArray(save.operations?.[0]?.data?.services) &&
save.operations[0].data.services.length === 0 &&
!Object.prototype.hasOwnProperty.call(save.operations[0].data, "attachments")
)
)
.toBe(true);
await expect(page.locator('.vue-flow__node[data-id="task:41"]')).toContainText("No service");
await page.getByTestId("studio-panel-tasks").click();
await expect(page.locator(".studio-task-row").filter({ hasText: "Open entry on start" })).toContainText(
"When wash starts"
);
await page.locator(".studio-task-row").filter({ hasText: "Open entry on start" }).getByRole("button").click();
await expect(page.getByTestId("studio-inspector-action-event")).toHaveValue("wash_start_command");
await page.getByTestId("studio-inspector-action-operation").selectOption("set_cleaner_relay");
await page.getByRole("button", { name: "OFF" }).click();
await page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() =>
captured.graphSaves.some(
(save) =>
save.operations?.[0]?.entity === "action" &&
save.operations?.[0]?.data?.operation === "set_cleaner_relay" &&
save.operations?.[0]?.data?.relay_state === false &&
save.operations?.[0]?.data?.options?.failure_policy === "continue"
)
)
.toBe(true);
await page.getByTestId("studio-panel-conditions").click();
await expect(page.locator(".studio-condition-card").filter({ hasText: "Trailer present" })).toContainText(
"Are mirrors folded?"
);
await expect(page.locator(".studio-condition-card").filter({ hasText: "Trailer present" })).toContainText("2 uses");
await page.locator(".studio-condition-card").filter({ hasText: "Trailer present" }).click();
await expect(page.getByTestId("condition-expression-builder")).toBeVisible();
await expect(page.getByTestId("condition-expression-row").first()).toContainText("Are mirrors folded?");
await page.getByTestId("condition-expression-add").click();
await page.getByTestId("condition-expression-subject").last().selectOption({ label: "Is the lift lowered?" });
await page.getByTestId("condition-expression-predicate-operator").last().selectOption("IS_FALSE_OR_NOT_SET");
await expect(page.getByTestId("condition-expression-summary")).toContainText("Is the lift lowered?");
await page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() =>
captured.graphSaves.some(
(save) =>
save.operations?.[0]?.entity === "condition" &&
save.operations?.[0]?.data?.expression?.children?.some(
(child) =>
child.subject_type === "question" && child.subject_id === 12 && child.operator === "IS_FALSE_OR_NOT_SET"
)
)
)
.toBe(true);
await page.getByTestId("studio-panel-flow").click();
await page.locator(".studio-node-list button").filter({ hasText: "Are mirrors folded?" }).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]?.entity === "question" &&
save.operations?.[0]?.data?.question === "Are loose straps secured?"
)
)
.toBe(true);
await expect(page.locator('.vue-flow__node[data-id="question:11"]')).toContainText("Are loose straps secured?");
});
test("edits if/else and case condition operators from the inspector", async ({ page }) => {
const graph = buildStudioGraph();
const captured = {
graphSaves: [],
layouts: [],
simulations: [],
simulationDelays: [],
pathOutcomes: [],
dynamicImages: [],
gatewayActions: [],
virtualHardware: [],
productUpdates: [],
rollbacks: [],
published: 0,
};
await installStudioRoutes(page, graph, captured);
await page.goto("/admin/6/modules/self-serve/studio?lane_id=7&vehicle_type_id=2", {
waitUntil: "domcontentloaded",
timeout: 90_000,
});
await page.getByTestId("studio-panel-conditions").click();
await page.locator(".studio-condition-card").filter({ hasText: "Trailer present" }).click();
await expect(page.getByTestId("condition-expression-builder")).toBeVisible();
await page.getByTestId("condition-expression-type").selectOption("branch");
await expect(page.getByTestId("condition-expression-branch")).toHaveCount(2);
const firstBranch = page.getByTestId("condition-expression-branch").first();
await firstBranch.getByRole("button", { name: "Add then" }).click();
const branchThen = firstBranch.getByTestId("condition-expression-branch-then-row").last();
await branchThen.locator("select").nth(1).selectOption({ label: "Is the lift lowered?" });
await branchThen.locator("select").nth(2).selectOption("IS_FALSE");
await page.getByTestId("condition-expression-add-elseif").click();
await expect(page.getByTestId("condition-expression-summary")).toContainText("Else if");
await page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => {
const expression = captured.graphSaves.findLast((save) => save.operations?.[0]?.entity === "condition")
?.operations?.[0]?.data?.expression;
return (
expression?.type === "branch" &&
expression.branches?.[0]?.kind === "if" &&
expression.branches?.[0]?.when?.children?.[0]?.subject_id === 11 &&
expression.branches?.[0]?.then?.children?.[0]?.subject_id === 12 &&
expression.branches?.[0]?.then?.children?.[0]?.operator === "IS_FALSE" &&
expression.branches?.some((branch) => branch.kind === "else_if") &&
expression.branches?.some((branch) => branch.kind === "else")
);
})
.toBe(true);
await page.getByTestId("condition-expression-type").selectOption("case");
await page.getByTestId("condition-expression-case-subject").selectOption({ label: "Are mirrors folded?" });
const firstCase = page.getByTestId("condition-expression-case").first();
await firstCase.getByTestId("condition-expression-case-value").selectOption({ label: "False" });
await firstCase.getByRole("button", { name: "Add then" }).click();
const caseThen = firstCase.getByTestId("condition-expression-case-then-row").last();
await caseThen.locator("select").nth(1).selectOption({ label: "Is the lift lowered?" });
await caseThen.locator("select").nth(2).selectOption("IS_TRUE_OR_NOT_SET");
await expect(page.getByTestId("condition-expression-summary")).toContainText("Case Are mirrors folded?");
await page.getByRole("button", { name: "Save" }).click();
await expect
.poll(() => {
const expression = captured.graphSaves.findLast((save) => save.operations?.[0]?.entity === "condition")
?.operations?.[0]?.data?.expression;
return (
expression?.type === "case" &&
expression.subject_type === "question" &&
expression.subject_id === 11 &&
expression.cases?.[0]?.value === false &&
expression.cases?.[0]?.then?.children?.[0]?.subject_id === 12 &&
expression.cases?.[0]?.then?.children?.[0]?.operator === "IS_TRUE_OR_NOT_SET"
);
})
.toBe(true);
});
test("supports drag-drop quick add, gateway action, simulator, publish, and rollback panels", async ({ page }) => {
const graph = buildStudioGraph();
const captured = {
graphSaves: [],
layouts: [],
simulations: [],
simulationDelays: [],
pathOutcomes: [],
dynamicImages: [],
gatewayActions: [],
virtualHardware: [],
rollbacks: [],
published: 0,
};
await installStudioRoutes(page, graph, captured);
await page.goto("/admin/6/modules/self-serve/studio?lane_id=7&vehicle_type_id=2", {
waitUntil: "domcontentloaded",
timeout: 90_000,
});
await expect(page.getByTestId("self-serve-studio")).toBeVisible({ timeout: 30_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-filter-vehicle-type").selectOption({ label: "Bus" });
await page.getByTestId("studio-palette-action").click();
await expect
.poll(() =>
captured.graphSaves.some(
(save) =>
save.operations?.[0]?.action === "create" &&
save.operations?.[0]?.entity === "action" &&
save.operations?.[0]?.data?.product === 8
)
)
.toBe(true);
await expect(page.getByTestId("studio-inspector-action-name")).toHaveValue("Open lane entrance port");
await expect(page.getByTestId("studio-inspector-action-event")).toBeVisible();
await expect(page.locator('.vue-flow__node[data-id="action:99"]')).toHaveClass(/selected/);
await expect
.poll(async () =>
page.evaluate(() => {
const node = document.querySelector('.vue-flow__node[data-id="action:99"]');
const surface = document.querySelector('[data-testid="self-serve-studio-flow"]');
if (!node || !surface) {
return false;
}
const nodeRect = node.getBoundingClientRect();
const surfaceRect = surface.getBoundingClientRect();
const nodeCenterX = nodeRect.left + nodeRect.width / 2;
const nodeCenterY = nodeRect.top + nodeRect.height / 2;
const surfaceCenterX = surfaceRect.left + surfaceRect.width / 2;
const surfaceCenterY = surfaceRect.top + surfaceRect.height / 2;
return (
Math.abs(nodeCenterX - surfaceCenterX) < surfaceRect.width * 0.3 &&
Math.abs(nodeCenterY - surfaceCenterY) < surfaceRect.height * 0.3
);
})
)
.toBe(true);
await page.getByTestId("studio-panel-hardware").click();
await expect(page.getByTestId("studio-service-coverage")).toContainText("MACHINE");
await expect(page.getByTestId("studio-service-coverage")).toContainText("Start bus wash");
await page.getByRole("button", { name: "Generate from lanes" }).click();
await expect.poll(() => captured.virtualHardware[0]?.operation).toBe("generate_from_lanes");
await expect(page.getByTestId("studio-virtual-hardware")).toContainText("3 virtual bindings active");
await expect(page.getByText("Virtual Studio Gateway").first()).toBeVisible();
await expect(page.getByTestId("studio-service-coverage")).toContainText("Virtual coverage");
await page.getByTestId("studio-panel-flow").click();
await page.getByTitle("Fit view").click();
await page.locator(".studio-node-list button").filter({ hasText: "Lane 7 MACHINE" }).click();
await expect(page.getByTestId("studio-binding-inspector")).toBeVisible();
await expect(page.getByTestId("studio-binding-services")).toHaveValue("MACHINE");
await page.getByTestId("studio-binding-label").fill("Lane 7 MACHINE updated");
await page.getByRole("button", { name: "Save binding" }).click();
await expect.poll(() => captured.virtualHardware.at(-1)?.operation).toBe("upsert_binding");
await expect.poll(() => captured.virtualHardware.at(-1)?.data?.label).toBe("Lane 7 MACHINE updated");
await page.getByTestId("studio-panel-flow").click();
await page.locator(".studio-node-list button").filter({ hasText: "Roskilde Edge 01" }).click();
await expect(page.getByTestId("studio-managed-inspector")).toContainText("Roskilde Edge 01");
await expect(page.getByTestId("studio-gateway-inspector")).toContainText("Bindings");
await page.getByRole("button", { name: "Discover" }).click();
await expect.poll(() => captured.gatewayActions[0]?.action).toBe("discovery");
await page.getByTestId("studio-filter-lane").selectOption({ label: "Lane 7" });
await page.getByTestId("studio-panel-simulator").click();
await expect(page.getByTestId("studio-simulator-workspace")).toBeVisible();
await expect(page.getByTestId("self-serve-studio-flow")).toHaveCount(0);
await expect(page.getByRole("button", { name: /^Run$/ })).toHaveCount(0);
const simulatorLayoutMetrics = await page.evaluate(() => {
const shell = document.querySelector(".studio-shell")?.getBoundingClientRect();
const sidebar = document.querySelector(".studio-sidebar")?.getBoundingClientRect();
const workspace = document.querySelector('[data-testid="studio-simulator-workspace"]')?.getBoundingClientRect();
return {
shellRight: shell?.right || 0,
shellWidth: shell?.width || 0,
sidebarRight: sidebar?.right || 0,
sidebarWidth: sidebar?.width || 0,
workspaceLeft: workspace?.left || 0,
workspaceRight: workspace?.right || 0,
workspaceWidth: workspace?.width || 0,
};
});
expect(simulatorLayoutMetrics.workspaceLeft).toBeGreaterThanOrEqual(simulatorLayoutMetrics.sidebarRight - 4);
expect(simulatorLayoutMetrics.workspaceRight).toBeGreaterThanOrEqual(simulatorLayoutMetrics.shellRight - 8);
expect(simulatorLayoutMetrics.workspaceWidth).toBeGreaterThan(
simulatorLayoutMetrics.shellWidth - simulatorLayoutMetrics.sidebarWidth - 40
);
await expect(page.getByTestId("studio-hardware-mode")).toContainText("Studio");
await expect(page.getByTestId("studio-simulator-scope")).toContainText("Lane 7");
await expect(page.getByTestId("studio-simulator-scope")).toContainText("Bus");
await expect(page.locator(".studio-simulator-form select")).toHaveCount(0);
await expect.poll(() => captured.simulations.length).toBeGreaterThan(0);
await expect.poll(() => captured.simulations.at(-1)?.vehicle_type_id).toBe(8);
await expect(page.getByTestId("studio-simulator-auto-decision")).toHaveCount(0);
await page.getByTestId("studio-panel-paths").click();
await expect(page.getByTestId("studio-path-outcomes")).toBeVisible();
await expect(page.getByTestId("studio-path-outcomes-progress")).toBeVisible();
await expect.poll(() => captured.pathOutcomes[0]?.lane_id).toBe(7);
await expect.poll(() => captured.pathOutcomes[0]?.vehicle_type_id).toBe(8);
await expect
.poll(() => Object.prototype.hasOwnProperty.call(captured.pathOutcomes[0] || {}, "max_states"))
.toBe(false);
await expect(page.getByTestId("studio-path-outcomes-error")).toHaveCount(0);
await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2");
await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE");
await expect(page.getByTestId("studio-path-results")).toContainText("Paths and Results");
await expect(page.getByTestId("studio-path-results")).toContainText("Allowed / MACHINE / 1 task / 2 signals");
await expect(page.getByTestId("studio-path-results")).toContainText("Are mirrors folded?");
await expect(page.getByTestId("studio-path-results")).toContainText("Yes");
await expect(page.getByTestId("studio-path-signal-timeline")).toContainText("machine_start_signal / MACHINE");
await expect(page.getByTestId("studio-path-signal-timeline")).toContainText('"bill_machine_wash":true');
await page.getByTestId("studio-path-outcome-focus").click();
await expect(page.getByTestId("studio-panel-flow")).toHaveClass(/is-active/);
await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Forvogn" });
await page.getByTestId("studio-panel-paths").click();
await expect(page.getByTestId("studio-path-outcomes-stale")).toBeVisible();
await page.getByTestId("studio-path-outcomes-refresh").click();
await expect.poll(() => captured.pathOutcomes.at(-1)?.vehicle_type_id).toBe(2);
await page.getByTestId("studio-panel-flow").click();
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" });
await page.getByTestId("studio-panel-simulator").click();
await expect(page.getByTestId("studio-simulator-workspace")).toBeVisible();
await expect(page.getByRole("button", { name: /^Run$/ })).toHaveCount(0);
await expect(page.getByTestId("studio-simulator-scope")).toContainText("Bus");
await expect(page.locator(".studio-simulator-form select")).toHaveCount(0);
await expect.poll(() => captured.simulations.at(-1)?.vehicle_type_id).toBe(8);
const beforeRegistrationSimulationCount = captured.simulations.length;
await page.getByLabel("Registration").fill("AB12345");
await expect.poll(() => captured.simulations.length).toBeGreaterThan(beforeRegistrationSimulationCount);
await page.getByTestId("studio-simulator-customer-search").fill("Acme");
await expect.poll(() => captured.customerSearches?.at(-1)).toBe("Acme");
await expect(page.getByTestId("studio-simulator-customer-results")).toContainText("Acme Logistics");
await page.getByTestId("studio-simulator-customer-option-601001").click();
await expect(page.getByTestId("studio-simulator-selected-customer")).toContainText("Acme Logistics");
await expect(page.getByTestId("studio-simulator-selected-customer")).toContainText("#601001");
const beforeConfigSimulationCount = captured.simulations.length;
await page.getByRole("button", { name: "Published" }).click();
await expect.poll(() => captured.simulations.length).toBeGreaterThan(beforeConfigSimulationCount);
await expect.poll(() => captured.simulations.at(-1)?.config_source).toBe("published");
const beforeAnswerSimulationCount = captured.simulations.length;
await page
.locator(".studio-simulator-question")
.filter({ hasText: "Are mirrors folded?" })
.getByRole("button", { name: "No" })
.click();
await expect.poll(() => captured.simulations.length).toBeGreaterThan(beforeAnswerSimulationCount);
await expect.poll(() => captured.simulations.at(-1)?.reg).toBe("AB12345");
await expect.poll(() => captured.simulations.at(-1)?.customer_number).toBe(601001);
await expect.poll(() => captured.simulations.at(-1)?.lane_id).toBe(7);
await expect.poll(() => captured.simulations.at(-1)?.vehicle_type_id).toBe(8);
await expect.poll(() => captured.simulations.at(-1)?.config_source).toBe("published");
await expect.poll(() => captured.simulations.at(-1)?.hardware_mode).toBe("studio");
await expect
.poll(() => captured.simulations.at(-1)?.answer_overrides?.[0])
.toEqual({ question_id: 11, value: false });
await expect(page.getByText("Guided Trace")).toBeVisible();
await expect(page.getByText("Set Are mirrors folded? so it satisfies is true.")).toBeVisible();
await expect(page.getByTestId("studio-simulator-steps")).toContainText(
"Tasks skipped because no active tasks remain after gates and service filtering."
);
await expect(page.getByTestId("studio-simulator-steps")).toContainText(
"Wash in progress disabled because MACHINE service is not allowed by the active tasks."
);
await expect(page.getByTestId("studio-simulator-dynamic-image-reason")).toContainText(
"no active task exposes dynamic image buttons"
);
captured.simulationDelays.push(350, 0);
await page
.locator(".studio-simulator-question")
.filter({ hasText: "Are mirrors folded?" })
.getByRole("button", { name: "No" })
.click();
await page
.locator(".studio-simulator-question")
.filter({ hasText: "Are mirrors folded?" })
.getByRole("button", { name: "Yes" })
.click();
await expect
.poll(() => captured.simulations.at(-1)?.answer_overrides?.[0])
.toEqual({ question_id: 11, value: true });
await expect(page.getByTestId("studio-simulator-dynamic-image")).toBeVisible();
await expect(page.getByTestId("studio-simulator-steps")).toContainText(
"Wash in progress is reachable because MACHINE service is allowed"
);
await page.waitForTimeout(450);
await expect(page.getByTestId("studio-simulator-dynamic-image")).toBeVisible();
const simulatorDynamicImageRequests = () => captured.dynamicImages.filter((url) => url.searchParams.has("buttons"));
await expect
.poll(() => simulatorDynamicImageRequests().at(-1)?.searchParams.get("buttons"))
.toBe(JSON.stringify(["program_picker", 1, "start"]));
await expect.poll(() => simulatorDynamicImageRequests().at(-1)?.searchParams.get("vehicle_type")).toBe("8");
await expect.poll(() => simulatorDynamicImageRequests().at(-1)?.searchParams.get("thumb_position")).toBe("4");
await expect.poll(() => simulatorDynamicImageRequests().at(-1)?.searchParams.get("current_step")).toBe("0");
await expect(page.getByTestId("self-serve-task-41")).toContainText("#4 Fold mirrors");
await expect(page.getByTestId("self-serve-task-42")).toContainText("Press first button");
await expect(page.getByTestId("self-serve-task-42")).not.toContainText("#4 Press first button");
await page.getByTestId("self-serve-task-41-toggle").click({ force: true });
await expect.poll(() => simulatorDynamicImageRequests().at(-1)?.searchParams.get("current_step")).toBe("1");
await expect(page.getByText("Question visibility and answers")).toBeVisible();
await expect(page.getByText("Flow is ready")).toBeVisible();
await expect(page.getByText("Tasks and Services")).toBeVisible();
const foldMirrorsDebugTask = page.locator(".studio-debug-task").filter({ hasText: "Fold mirrors" });
await expect(foldMirrorsDebugTask.locator(".studio-debug-attachment-list")).toContainText("mirror-guide.pdf");
await expect(foldMirrorsDebugTask.locator(".studio-debug-attachment-list")).toContainText("mirror-photo.jpg");
await expect(page.getByText("Gateway Readiness")).toBeVisible();
await expect(page.getByTestId("studio-signal-timeline")).toContainText("Signals");
await expect(page.getByTestId("studio-signal-timeline")).toContainText("start / ENTRY");
await expect(page.getByTestId("studio-signal-timeline")).toContainText("studio_action_relay_pulse");
await expect(page.getByTestId("studio-signal-timeline")).toContainText('"action_id":81');
await expect(page.getByTestId("studio-signal-timeline")).toContainText("eligibility_sync / MACHINE");
await expect(page.getByTestId("studio-signal-timeline")).toContainText("machine_start_signal / MACHINE");
await expect(page.getByTestId("studio-signal-timeline")).toContainText('"bill_machine_wash":true');
await expect(page.getByTestId("studio-signal-timeline")).toContainText("machine_start / CLEANER");
await expect(page.getByTestId("studio-signal-timeline")).toContainText("stop / EXIT");
await expect(page.getByTestId("studio-signal-timeline")).toContainText('"toggle_after":1');
await page.getByRole("button", { name: "Show JSON" }).click();
await expect(page.locator(".studio-json")).toContainText("Fold mirrors");
await expect(page.locator(".studio-json")).toContainText("signal_timeline");
await foldMirrorsDebugTask.locator(".studio-debug-task-main").click();
await expect(page.getByTestId("studio-panel-inspector")).toHaveClass(/is-active/);
await expect(page.getByTestId("self-serve-studio-flow")).toBeVisible();
await expect(page.locator('.vue-flow__node[data-id="question:11"]')).toHaveClass(/studio-node-sim-active/);
await expect(
page.locator('.studio-details[data-panel="inspector"]').getByRole("textbox", { name: "Task" })
).toHaveValue("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.getByTestId("studio-panel-audit").click();
await expect(page.getByText("Studio uses virtual hardware coverage")).toBeVisible();
await page.getByTestId("studio-panel-versions").click();
await page.getByRole("button", { name: "Rollback" }).last().click();
await expect.poll(() => captured.rollbacks[0]?.target_version_id).toBe(80);
});
test("opens legacy condition rule URLs in the studio conditions panel and blocks invalid publishes", async ({
page,
}) => {
const graph = buildStudioGraph();
graph.validation = {
valid: false,
errors: ["Condition 21 is used but has an empty expression."],
warnings: [],
items: [{ severity: "error", message: "Condition 21 is used but has an empty expression." }],
};
const captured = {
graphSaves: [],
layouts: [],
simulations: [],
pathOutcomes: [],
dynamicImages: [],
gatewayActions: [],
virtualHardware: [],
rollbacks: [],
published: 0,
};
await installStudioRoutes(page, graph, captured);
await page.goto("/admin/6/modules/self-serve/condition-rules?lane_id=7&vehicle_type_id=2", {
waitUntil: "domcontentloaded",
timeout: 90_000,
});
await expect(page).toHaveURL(/\/admin\/6\/modules\/self-serve\/studio.*tab=conditions/);
await expect(page.getByTestId("studio-panel-conditions")).toHaveClass(/is-active/);
await expect(page.locator(".studio-condition-card").filter({ hasText: "Trailer present" })).toContainText("error");
await expect(page.getByRole("button", { name: "Publish" }).first()).toBeDisabled();
await expect(page.getByTestId("studio-palette-rule")).toHaveCount(0);
await expect(page.getByText("Rules")).toHaveCount(0);
await page.getByTestId("studio-filter-vehicle-type").selectOption({ label: "Bus" });
await expect(page.getByTestId("studio-filter-vehicle-type")).toHaveValue("8");
await page.getByTestId("studio-panel-audit").click();
await page
.locator(".studio-validation-item")
.filter({ hasText: "Condition 21 is used but has an empty expression." })
.click();
await expect(page.getByRole("heading", { name: "Inspector" })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Name" })).toHaveValue("Trailer present");
await expect(page.getByTestId("studio-filter-vehicle-type")).toHaveValue("2");
});
});