230 lines
12 KiB
Python
230 lines
12 KiB
Python
"""Regression tests for the frontend Modal queue shim."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
def _modal_toggle_source() -> str:
|
|
"""Return the current frontend extension source."""
|
|
return (Path(__file__).resolve().parents[1] / "web" / "modal_toggle.js").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_synthetic_status_event_matches_comfyui_status_shape() -> None:
|
|
"""Synthetic status events should use the same detail shape as websocket status events."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert 'dispatchSyntheticApiEvent("status", statusPayload(1));' in source
|
|
assert 'dispatchSyntheticApiEvent("status", statusPayload(0));' in source
|
|
|
|
|
|
def test_synthetic_execution_events_match_comfyui_execution_shapes() -> None:
|
|
"""Synthetic execution events should mirror ComfyUI's websocket adapter payloads."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert 'dispatchSyntheticApiEvent("execution_start", {' in source
|
|
assert "timestamp: nowMs()," in source
|
|
assert 'dispatchSyntheticApiEvent("executing", displayNode);' in source
|
|
assert 'dispatchSyntheticApiEvent("notification", {' in source
|
|
assert "Waiting for a machine on Modal." in source
|
|
|
|
|
|
def test_global_modal_status_badge_is_installed() -> None:
|
|
"""The frontend should expose a dedicated global Modal activity indicator."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert 'element.id = "comfy-modal-global-status";' in source
|
|
assert "Syncing graph with Modal" in source
|
|
assert "Waiting for Modal startup" in source
|
|
assert "Receiving Modal outputs" in source
|
|
assert "Modal workflow running on" in source
|
|
assert "setGlobalStatusBatchProgress(promptId, value, maxValue)" in source
|
|
assert "batchValue: state.batchValue ?? null," in source
|
|
assert "batchMax: state.batchMax ?? null," in source
|
|
assert "statusMessage: state.statusMessage ?? null," in source
|
|
assert "statusCurrent: state.statusCurrent ?? null," in source
|
|
assert "statusTotal: state.statusTotal ?? null," in source
|
|
assert "linear-gradient(90deg" in source
|
|
assert "installGlobalStatusStyles()" in source
|
|
assert "function pruneGlobalStatusStates()" in source
|
|
assert "function effectiveGlobalStatusPhase(promptId, phase)" in source
|
|
|
|
|
|
def test_remote_modal_status_tracks_active_node_ids() -> None:
|
|
"""The frontend should track and highlight the currently active remote node."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "activeNodeId: null" in source
|
|
assert "hasStreamedProgress: false" in source
|
|
assert "descendantNodeIdsByAncestor: new Map()" in source
|
|
assert "function setPromptActiveNode(promptId, activeNodeId)" in source
|
|
assert "detail.active_node_id" in source
|
|
assert "clearPromptRemoteStates(promptId)" in source
|
|
|
|
|
|
def test_remote_modal_uses_distinct_ready_active_and_complete_colors() -> None:
|
|
"""The frontend should distinguish ready, active, and completed remote nodes visually."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert 'const READY_BORDER_COLOR = "#22c55e";' in source
|
|
assert 'const ACTIVE_BORDER_COLOR = "#a855f7";' in source
|
|
assert 'const COMPLETE_BORDER_COLOR = "#16a34a";' in source
|
|
assert 'const STATE_WAITING = "waiting";' in source
|
|
assert 'const STATE_FINALIZING = "finalizing";' in source
|
|
assert 'const STATE_READY = "ready";' in source
|
|
assert 'const STATE_ACTIVE = "active";' in source
|
|
assert 'detail.phase === "execution_success"' in source
|
|
|
|
|
|
def test_global_modal_status_badge_supports_setup_and_finalizing_details() -> None:
|
|
"""The frontend should surface detailed setup and result-receive messages in the global pill."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "detail.status_message ?? null" in source
|
|
assert "detail.status_current ?? null" in source
|
|
assert "detail.status_total ?? null" in source
|
|
assert "setGlobalStatusPhase(promptId, STATE_SETUP, nodeIds.length, {" in source
|
|
assert "if (detail.phase === STATE_FINALIZING) {" in source
|
|
assert 'text.textContent = activeState.statusMessage ?? "Receiving Modal outputs";' in source
|
|
|
|
|
|
def test_queue_success_marks_all_remote_nodes_ready_before_component_execution() -> None:
|
|
"""Once the Modal route accepts the prompt, all remote nodes should flip from setup to ready."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "const resolvedRemoteNodeIds = (responsePayload.modal_remote_node_ids ?? []).map((nodeIdValue) =>" in source
|
|
assert "const resolvedComponents = Array.isArray(responsePayload.modal_components)" in source
|
|
assert "registerPromptComponents(promptId, resolvedRemoteNodeIds, resolvedComponents);" in source
|
|
assert "endSyntheticExecutionUi(promptId);" in source
|
|
assert 'setGlobalStatusPhase(promptId, STATE_WAITING, acceptedRemoteNodeIds.length, {' in source
|
|
assert 'message: "Waiting for Modal startup",' in source
|
|
assert "setNodesPhase(acceptedRemoteNodeIds, STATE_READY, promptId);" in source
|
|
|
|
|
|
def test_streamed_modal_progress_takes_precedence_over_proxy_events() -> None:
|
|
"""Once streamed node progress starts, coarse proxy execution events should stop overriding it."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "promptState.hasStreamedProgress = true;" in source
|
|
assert "if (promptState.hasStreamedProgress && phase === EXECUTION_PHASE)" in source
|
|
|
|
|
|
def test_streamed_modal_execution_ends_synthetic_setup_without_waiting_for_final_executed() -> None:
|
|
"""Real streamed execution should end synthetic setup on progress, not on the first executed node."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "if (detail.phase === EXECUTION_PHASE) {\n endSyntheticExecutionUi(promptId);" in source
|
|
assert "function handleModalProgress(event)" in source
|
|
assert " endSyntheticExecutionUi(promptId);" in source
|
|
assert 'endSyntheticExecutionUi(String(eventDetail(event).prompt_id ?? ""));' not in source
|
|
|
|
|
|
def test_streamed_modal_node_progress_updates_active_overlay() -> None:
|
|
"""The frontend should listen for numeric Modal node progress and render it on the node."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert 'api.addEventListener("modal_progress", handleModalProgress);' in source
|
|
assert "function handleModalProgress(event)" in source
|
|
assert "if (detail.aggregate_only) {" in source
|
|
assert "setNodeBatchProgress(" in source
|
|
assert "function setNodeProgress(nodeIdValue, promptId, value, maxValue)" in source
|
|
assert "function setNodeProgressLane(nodeIdValue, promptId, laneId, value, maxValue, itemIndex)" in source
|
|
assert "function clearNodeProgressLane(nodeIdValue, promptId, laneId)" in source
|
|
assert "function clearNodeProgress(nodeIdValue, promptId)" in source
|
|
assert "state?.progress" in source
|
|
assert "state?.progressLanes" in source
|
|
assert "detail.lane_id != null" in source
|
|
assert "clearNodeProgressLane(progressNodeId, promptId, String(detail.lane_id));" in source
|
|
assert "setNodeProgressLane(" in source
|
|
assert "function deriveRemoteNodePhase(phase, hasLiveProgress)" in source
|
|
assert "isActiveRemoteNode: hasLiveProgress || promptState?.activeNodeId === nodeId(node)," in source
|
|
assert 'const panelY = node.size[1] + 6 / scale;' in source
|
|
assert 'ctx.roundRect(-borderWidth, panelY, barWidth, panelHeight, 10 / scale);' in source
|
|
assert 'const headerText = hasAggregateProgress' in source
|
|
|
|
|
|
def test_mapped_parallel_modal_progress_renders_multiple_lane_bars() -> None:
|
|
"""Parallel mapped Modal runs should render one local progress lane per active worker."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "const modalNodeProgressLanes = new Map();" in source
|
|
assert "const modalNodeBatchProgress = new Map();" in source
|
|
assert "progressLanes.length > 0" in source
|
|
assert "const laneColors = [" in source
|
|
assert 'const badgeText = hasBatchBadge' in source
|
|
assert 'const badgeY = panelY + panelPaddingY;' in source
|
|
assert "if (detail.clear) {" in source
|
|
assert "clearNodeProgress(progressNodeId, promptId);" in source
|
|
assert 'let laneY = barY;' in source
|
|
assert "laneNodeIdsByLane: new Map()," in source
|
|
assert "representativeNodeIdByMember: new Map()," in source
|
|
assert "function deleteNodeProgressLane(nodeIdValue, promptId, laneId)" in source
|
|
assert "function laneOwnerKey(promptId, nodeIdValue, laneId)" in source
|
|
assert "promptState.laneNodeIdsByLane.set(safeLaneKey, safeNodeIdValue);" in source
|
|
|
|
|
|
def test_modal_context_menu_can_expand_required_upstream_nodes() -> None:
|
|
"""Right-clicking a node should offer a dry-run expansion action backed by the backend."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert 'const MODAL_ANALYZE_ROUTE = MODAL_ROUTE.replace(/\\/queue_prompt$/, "/analyze_remote_nodes");' in source
|
|
assert "function workflowNodePath(node)" in source
|
|
assert "function findNodeByWorkflowPath(workflowPath)" in source
|
|
assert "function selectedWorkflowNodePaths(node)" in source
|
|
assert "function analyzeAndMarkRequiredRemoteNodes(node)" in source
|
|
assert "function installModalContextMenu(nodeType, nodeData)" in source
|
|
assert 'api.fetchApi(MODAL_ANALYZE_ROUTE, {' in source
|
|
assert "async beforeRegisterNodeDef(nodeType, nodeData)" in source
|
|
assert "installModalContextMenu(nodeType, nodeData);" in source
|
|
assert '"Modal: Include Required Upstream Nodes"' in source
|
|
assert '"Modal: Include Required Upstream Nodes for Selection"' in source
|
|
|
|
|
|
def test_modal_context_menu_marks_nodes_across_subgraphs() -> None:
|
|
"""The UI action should be able to resolve and mark nested workflow-node paths."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "function rootGraph()" in source
|
|
assert "function findSomethingInAllSubgraphs(matcher)" in source
|
|
assert "function findContainingSubgraphNode(subgraphId)" in source
|
|
assert "candidate.subgraph?.id === subgraphId" in source
|
|
assert "function markWorkflowNodePathsRemote(workflowNodePaths)" in source
|
|
assert "setRemoteFlag(node, true);" in source
|
|
|
|
|
|
def test_prompt_component_registration_does_not_shrink_remote_node_count() -> None:
|
|
"""Per-component status updates should not overwrite the prompt-wide remote node list."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "const mergedRemoteNodeIds = new Set(remoteNodeIds.map((nodeIdValue) => String(nodeIdValue)));" in source
|
|
assert "if (promptState.remoteNodeIds.length === 0) {" in source
|
|
assert "const mergedRemoteNodeIds = new Set(promptState.remoteNodeIds);" in source
|
|
|
|
|
|
def test_prompt_cleanup_prunes_orphaned_global_status_entries() -> None:
|
|
"""Workflow cleanup should remove stale global badge states once prompt state is gone."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "pruneGlobalStatusStates();" in source
|
|
assert "refreshGlobalStatusElement();" in source
|
|
|
|
|
|
def test_subgraph_descendant_states_percolate_to_visible_ancestor_nodes() -> None:
|
|
"""Subgraph-expanded remote prompt ids should aggregate their phase onto visible ancestor nodes."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "function ancestorNodeIds(nodeIdValue)" in source
|
|
assert "function rebuildPromptAncestorMap(promptState)" in source
|
|
assert "function refreshAncestorNodePhase(promptId, ancestorNodeId, errorMessage)" in source
|
|
assert "promptState.descendantNodeIdsByAncestor.get(ancestorNodeId)" in source
|
|
assert "descendantStates.every((state) => state.phase === STATE_COMPLETE)" in source
|
|
|
|
|
|
def test_modal_ui_refreshes_after_visibility_or_focus_returns() -> None:
|
|
"""Background-tab throttling should not leave the status pill stale after refocus."""
|
|
source = _modal_toggle_source()
|
|
|
|
assert "function refreshModalUiAfterVisibilityChange()" in source
|
|
assert 'document.addEventListener("visibilitychange"' in source
|
|
assert 'window.addEventListener("focus", refreshModalUiAfterVisibilityChange);' in source
|