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"); }); });