From 6027e6dad5692634490eda67b31202754dbcdbb9 Mon Sep 17 00:00:00 2001 From: AlexTemirov Date: Sun, 6 Sep 2026 11:54:34 -0700 Subject: [PATCH] Add visual Workflow App authoring and first-use guidance --- README.md | 23 ++++- docs/operator-apps.md | 30 ++++++ docs/walkthrough.md | 33 +++++-- editor-server/server.py | 40 ++++++++ editor/src/App.tsx | 18 +++- editor/src/api.ts | 2 + editor/src/appDesigner.test.ts | 53 ++++++++++ editor/src/appDesigner.ts | 101 ++++++++++++++++++++ editor/src/components/AppDesignerDialog.css | 12 +++ editor/src/components/AppDesignerDialog.tsx | 99 +++++++++++++++++++ editor/src/components/NodePalette.tsx | 16 ++-- editor/src/components/PointCloudViewer.tsx | 22 +++-- editor/src/components/TemplateGallery.tsx | 24 ++++- editor/src/store.ts | 13 +++ tests/test_editor_app_designer.py | 71 ++++++++++++++ tests/test_editor_package_welcome.py | 10 +- tests/test_editor_template_groups.py | 4 +- 17 files changed, 532 insertions(+), 39 deletions(-) create mode 100644 editor/src/appDesigner.test.ts create mode 100644 editor/src/appDesigner.ts create mode 100644 editor/src/components/AppDesignerDialog.css create mode 100644 editor/src/components/AppDesignerDialog.tsx create mode 100644 tests/test_editor_app_designer.py diff --git a/README.md b/README.md index bbbd069..267e726 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,16 @@ cd Blacknode The launcher installs the local dependencies and opens `http://localhost:3000`. -To try the graph first, follow the [Beginner Walkthrough](docs/walkthrough.md). +In the welcome screen, choose **Open templates**, open **Text Pipeline**, and +press **Run once** in the top bar. The **Output** node shows `Hello World`. +Change a Text node's value and run again to see your own result. + +For a visual result, open **Generic Point Cloud Viewer** and press **Run once** +to inspect the included colored point cloud in 3D. Both workflows run locally +with the built-in nodes. + +Follow the [Beginner Walkthrough](docs/walkthrough.md) to save your work and +choose the next task. ## Pair your first device @@ -114,6 +123,18 @@ The optional robot-learning starter prepares collection, training, and simulation workflows as the Project gains the required data and artifacts. It never starts a physical or compute action automatically. +## Develop your own App + +Build or customize a node workflow, press **Create App**, and choose the +parameters, results, and run buttons your team needs. **Save & open App** turns +that workflow into an operator interface. Switch back with **Edit workflow** +to change its behavior, or use **File → Package App…** to share it. + +Projects organize your application alongside its robots, datasets, training +runs, policies, and deployments. Start with a working workflow and extend it +for your own task. See [Create your own App](docs/operator-apps.md#create-your-own-app) +and [Guided Projects](docs/guided-projects.md). + ## Add capabilities Blacknode core owns the graph, editor, runtime, replay, exports, package system, diff --git a/docs/operator-apps.md b/docs/operator-apps.md index 3df986b..1a4cb9e 100644 --- a/docs/operator-apps.md +++ b/docs/operator-apps.md @@ -8,6 +8,36 @@ Pressing a shortcut for a workflow with `metadata.operator_view` opens its App surface. **Edit workflow** reveals the nodes and connections, and the **App** control in the workflow tab bar returns to the operator surface. +## Create your own App + +1. Build a workflow or open a template and run it to check its result. +2. Press **Create App** beside the workflow tabs, or choose **File → Create App…**. +3. Give the App a name. Use **+ Parameter** to expose a text or numeric task + setting, **+ Result** to display a workflow output, and **+ Run button** to + cook a chosen output. Choose each target from the workflow's available nodes. +4. Give the controls labels your operators understand. Results can display + text, metrics, status, images, or a web viewer. A run button can run once or + start a live service; add a confirmation message when the action requires it. +5. Press **Save & open App**. Configure the task and press your run button to + see the result. Saving and opening the App does not execute the workflow. +6. Use **Edit workflow** to change the nodes and **App** to return to the + controls. Press **Save** after changing parameters. **Edit App** reopens + the designer to change the name or controls you added. +7. Reopen the saved workflow from **Workflows** and select **App**, or use + **File → Package App…** to share an installable ZIP. + +Parameters connected to other nodes are configured by those upstream nodes; +the designer offers unconnected text and numeric parameters. Credential fields +stay in the existing credential configuration. Existing App sections, primary +run targets, settings, and safety controls are retained when adding controls. +Advanced field types and direct service actions use the operator-view contract +below. + +For a robot-learning application, link the saved workflow to a Project. Expose +its task, dataset, and training settings, then display its progress, results, +and viewer outputs. [Guided Projects](guided-projects.md) describes the existing +dataset, training, and policy workflow handoffs. + ## Operator view contract An operator view is declared inside workflow metadata: diff --git a/docs/walkthrough.md b/docs/walkthrough.md index 76eaeff..e43ac69 100644 --- a/docs/walkthrough.md +++ b/docs/walkthrough.md @@ -1,8 +1,25 @@ # Blacknode Beginner Walkthrough -This is the click-by-click path for trying every main Blacknode feature. Start -with the no-key steps first, then add NVIDIA NIM, MCP, or Docker when the local -workflow is working. +Start with a visible result, then choose the capabilities your task needs. + +## Your first result + +1. Run `.\start.bat` on Windows or `./start.sh` on macOS/Linux. The launcher + installs dependencies and opens the editor. +2. Choose **Open templates** in the welcome screen. +3. Open **Text Pipeline** under **Core**. +4. Press **Run once** in the top bar. The **Output** node displays `Hello World`. +5. Select a **Text** node, change its `value` in **Properties**, and press + **Run once** again. Your changed text appears in the result. + +For a 3D result, open **Generic Point Cloud Viewer** and press **Run once**. +The viewer displays the included colored point cloud; right-drag to orbit and scroll +to zoom. Both starting workflows use built-in nodes on your computer. + +Open **Workflows** to name and save your graph, or **Runs** to inspect a previous +result. For a real robot, continue with [Pair your first device](../README.md#pair-your-first-device). +For simulation, install the Newton package from **Packages** and open one of +its templates. The sections below cover the rest of the editor as you need it. ## What You Need @@ -59,12 +76,10 @@ What this does: First-run time depends on network speed and whether pip/npm packages are already cached. Later starts normally complete in less than one minute. -On the first launch of this Blacknode workspace, a welcome message opens the -**Packages** tab. Use it to install the official packages needed by robotics, -ROS 2, vision, CUDA, dataset, and training templates. Choose **Continue with -core graph** when you want to begin with built-in nodes. Blacknode records the -choice in `.blacknode/onboarding.json`; Packages stays available in the left -sidebar. +On the first launch, choose **Open templates** to start with built-in nodes. +Choose **Explore packages** when you already know which robotics, ROS 2, vision, +CUDA, dataset, or training capabilities you need. Blacknode records the choice +in `.blacknode/onboarding.json`; Packages stays available in the left sidebar. The welcome message opens only after the backend confirms that onboarding has not been completed. A backend startup or connectivity error keeps the current diff --git a/editor-server/server.py b/editor-server/server.py index 869ee87..c83a303 100644 --- a/editor-server/server.py +++ b/editor-server/server.py @@ -38,6 +38,7 @@ from blacknode.learned import registry as learned_registry from blacknode.mcp import tools as mcp_tools from blacknode.node import _NODE_REGISTRY +from blacknode.operator_views import OperatorViewValidationError, validate_operator_view from blacknode.nodes import ai as ai_nodes import blacknode.package_index as bn_package_index from blacknode.packages import MANIFEST_NAME as BN_MANIFEST_NAME @@ -2757,6 +2758,45 @@ def update_workflow_requirements(req: UpdateWorkflowRequirementsReq): return {"metadata": dict(metadata)} +@app.patch("/graph/operator-view") +def update_workflow_operator_view(view: dict[str, Any]): + try: + validate_operator_view(view) + except OperatorViewValidationError as exc: + raise HTTPException(400, str(exc)) from exc + if not re.fullmatch(r"[a-z][a-z0-9_-]{0,63}", str(view.get("id") or "")): + raise HTTPException(400, "App ID must start with a lowercase letter and use letters, numbers, hyphens, or underscores.") + nodes = {node["id"]: node for node in get_graph()["nodes"]} + + def check_references(value): + if isinstance(value, list): + for item in value: + check_references(item) + elif isinstance(value, dict): + if "node_id" in value: + node = nodes.get(value["node_id"]) + if node is None: + raise HTTPException(400, f"App references missing node: {value['node_id']}") + outputs = {"value"} if node["type"] == "Output" else set(node["outputs"]) + if "port" in value and value["port"] not in outputs: + raise HTTPException(400, f"App references missing output: {value['node_id']}.{value['port']}") + if "param" in value: + definition = _NODE_REGISTRY.get(node["type"]) + inputs = (set(node["inputs"]) | set(node.get("params", {})) + | set(getattr(definition, "_bn_inputs", [])) + | set(getattr(definition, "_bn_input_defaults", {}))) + if value["param"] not in inputs: + raise HTTPException(400, f"App references missing parameter: {value['node_id']}.{value['param']}") + for key, item in value.items(): + if key not in {"value", "payload"}: + check_references(item) + + check_references(view) + _session.metadata = {**_session.metadata, "operator_view": copy.deepcopy(view)} + _save() + return {"metadata": dict(_session.metadata)} + + @app.post("/nodes") def add_node(req: AddNodeReq): if req.type_name in _SUBGRAPH_NODE_TYPES: diff --git a/editor/src/App.tsx b/editor/src/App.tsx index 820cd5b..d779da3 100644 --- a/editor/src/App.tsx +++ b/editor/src/App.tsx @@ -41,6 +41,7 @@ const LocalFilePicker = lazy(() => import('./components/LocalFilePicker')) const WorkflowOperatorView = lazy(() => import('./components/WorkflowOperatorView')) const CustomerAppShell = lazy(() => import('./components/CustomerAppShell')) const AppPackageDialog = lazy(() => import('./components/AppPackageDialog')) +const AppDesignerDialog = lazy(() => import('./components/AppDesignerDialog')) const NODE_TYPES = { blacknode: BlackNode, @@ -273,7 +274,7 @@ function WorkspaceApp() { beginAltDragCopy, finishAltDragCopy, undoGraph, checkServer, reset, newTab, insertTab, switchTab, closeTab, duplicateTab, openGraphAsTab, openWorkflowAsTab, setActiveTabSurface, renameTab, saveActiveWorkflow, - diveIntoSubnet, exitSubnet, collapseToSubnet, organizeNodes, cookNode, stopCook, stopRuntimeServices, dismissCookStatus, applyRunReplay, + subnetStack, diveIntoSubnet, exitSubnet, collapseToSubnet, organizeNodes, cookNode, stopCook, stopRuntimeServices, dismissCookStatus, applyRunReplay, handleLearnedNodeEvent, updateParam, } = useStore() @@ -321,6 +322,7 @@ function WorkspaceApp() { const [simulationViewerHeight, setSimulationViewerHeight] = useState(loadSimulationViewerHeight) const [fileMenuOpen, setFileMenuOpen] = useState(false) const [appPackageDialogOpen, setAppPackageDialogOpen] = useState(false) + const [appDesignerOpen, setAppDesignerOpen] = useState(false) const [openingAppPackageDialog, setOpeningAppPackageDialog] = useState(false) const [fileMenuPosition, setFileMenuPosition] = useState({ top: 0, left: 0 }) const [simulationViewerMenuOpen, setSimulationViewerMenuOpen] = useState(false) @@ -2065,6 +2067,13 @@ function WorkspaceApp() { Import workflow… JSON or Python + {!hostedPreview && ( + + )} {!hostedPreview && ( + )} {operatorView && ( +

Choose the parameters, results, and buttons people will use. Your workflow runs the App.

+
+
+ + +
+
+ + + +
+ {existing &&

Existing App sections and safety controls are retained. Controls added here can be edited below.

} + {!controls.length &&

Add a parameter to configure the task, a result to see its output, and a button to run it.

} +
+ {controls.map((control, index) => { + const choices = control.kind === 'setting' ? targets.settings : targets.outputs + return
+
{control.kind === 'setting' ? 'Parameter' : control.kind === 'action' ? 'Run button' : 'Result'} +
+ + + {control.kind === 'result' && } + {control.kind === 'action' && <> + + + } +
+ })} +
+
+ {error &&

{error}

} + + + + ) +} diff --git a/editor/src/components/NodePalette.tsx b/editor/src/components/NodePalette.tsx index a6d5ef4..ec366d8 100644 --- a/editor/src/components/NodePalette.tsx +++ b/editor/src/components/NodePalette.tsx @@ -267,7 +267,7 @@ export default function NodePalette() { api.getOnboarding() .then(state => { if (active && !state.package_welcome_seen) { - setActiveTab('packages') + setActiveTab('templates') setShowPackageWelcome(true) } }) @@ -576,28 +576,28 @@ export default function NodePalette() { Welcome to Blacknode

- Prepare your robotics workspace + Run your first workflow

- Start in Packages and install the official Blacknode capabilities for robot hardware, ROS 2, vision, CUDA, datasets, and training. Package-backed templates need their listed packages before they can run. + Open Text Pipeline, press Run once, and see “Hello World” on the Output node. Change the text and run it again to make it yours.

- Core graph workflows are ready immediately. You can return to Packages at any time from the left sidebar. + This first workflow runs on your computer. Add robot hardware, simulation, vision, and AI through Packages when you are ready.

diff --git a/editor/src/components/PointCloudViewer.tsx b/editor/src/components/PointCloudViewer.tsx index 42b66f8..87bef3a 100644 --- a/editor/src/components/PointCloudViewer.tsx +++ b/editor/src/components/PointCloudViewer.tsx @@ -1970,8 +1970,10 @@ export default function PointCloudViewer({ border: '1px solid rgba(86, 217, 145, 0.38)', color: '#8df0b5', fontFamily: 'var(--font-mono)', fontSize: 11, pointerEvents: 'none', }}> - - {hasCurrentPoints + 0)) ? '#56d991' : '#71808d' }} /> + {viewerRole === 'generic' + ? pointCount > 0 ? `POINT CLOUD · ${pointCount.toLocaleString()} POINTS` : 'WAITING FOR POINTS' + : hasCurrentPoints ? parsed.sensor_fusion?.backend === 'warp-hash-grid' ? `SENSOR FUSION · ${Number(parsed.sensor_fusion.matched_points ?? 0).toLocaleString()} ALIGNED` : parsed.reconstruction?.integration?.backend === 'warp' @@ -2054,7 +2056,9 @@ export default function PointCloudViewer({ }}> {pointCount > 0 - ? parsed.sensor_fusion?.backend === 'warp-hash-grid' + ? viewerRole === 'generic' + ? `${pointCount.toLocaleString()} points` + : parsed.sensor_fusion?.backend === 'warp-hash-grid' ? `${pointCount.toLocaleString()} fused sensor points` : parsed.reconstruction?.extraction?.backend === 'warp' ? `${pointCount.toLocaleString()} reconstructed surface voxels` @@ -2064,7 +2068,7 @@ export default function PointCloudViewer({ : currentPoints.length ? 'Live scan; map is empty' : 'Waiting for points'} {currentPointCount > 0 && {currentPointCount.toLocaleString()} current} - {accumulatedScanCount > 0 && (!parsed.depth_projection || parsed.reconstruction) && {accumulatedScanCount.toLocaleString()} {parsed.reconstruction ? 'RGB-D frames' : 'scans'}} + {viewerRole !== 'generic' && accumulatedScanCount > 0 && (!parsed.depth_projection || parsed.reconstruction) && {accumulatedScanCount.toLocaleString()} {parsed.reconstruction ? 'RGB-D frames' : 'scans'}} {displayCount > 0 && displayCount !== pointCount && {displayCount.toLocaleString()} displayed} {kernelMs > 0 && {kernelMs.toFixed(3)} ms Warp} {parsed.occupancy?.backend === 'warp' && ( @@ -2120,8 +2124,8 @@ export default function PointCloudViewer({ {finite(parsed.trajectory_evaluation.speedup) > 0 ? ` · ${finite(parsed.trajectory_evaluation.speedup).toFixed(1)}× CPU` : ''} )} - {!parsed.depth_projection && {scanCoverageDeg >= 359.5 ? `360° scan · ${clockwiseScan ? 'CW' : 'CCW'} paced replay` : `${scanCoverageDeg.toFixed(1)}° scan · ${clockwiseScan ? 'CW' : 'CCW'} paced replay`}} - {parsed.sensor_fusion ? '3D orbit · synchronized LiDAR and colorized depth alignment' : parsed.reconstruction ? '3D orbit · persistent pose-registered RGB-D surface' : parsed.depth_projection ? '3D orbit · calibrated metric surface' : '3D orbit · LaserScan lies on XY plane'} + {viewerRole !== 'generic' && !parsed.depth_projection && {scanCoverageDeg >= 359.5 ? `360° scan · ${clockwiseScan ? 'CW' : 'CCW'} paced replay` : `${scanCoverageDeg.toFixed(1)}° scan · ${clockwiseScan ? 'CW' : 'CCW'} paced replay`}} + {viewerRole === 'generic' ? '3D orbit · point cloud' : parsed.sensor_fusion ? '3D orbit · synchronized LiDAR and colorized depth alignment' : parsed.reconstruction ? '3D orbit · persistent pose-registered RGB-D surface' : parsed.depth_projection ? '3D orbit · calibrated metric surface' : '3D orbit · LaserScan lies on XY plane'}
{showRobot && B robot {Math.round(finite(parsed.robot?.length_m, 0.25) * 100)}×{Math.round(finite(parsed.robot?.width_m, 0.22) * 100)} cm} - {!parsed.depth_projection && — active beam} - {!parsed.depth_projection && !fullCircleScan && ┄ scan limits} - {parsed.sensor_fusion ? '● LiDAR cyan · RGB-D green aligned / red residual' : parsed.reconstruction ? '● reconstructed surface · aligned RGB when available' : parsed.depth_projection?.color_applied === 'rgb' ? '● projected depth · aligned RGB color' : parsed.depth_projection?.color_applied === 'ir' ? '● projected depth · aligned IR intensity' : parsed.depth_projection ? '● projected depth · distance and surface confidence color' : '● filtered laser returns'} + {viewerRole !== 'generic' && !parsed.depth_projection && — active beam} + {viewerRole !== 'generic' && !parsed.depth_projection && !fullCircleScan && ┄ scan limits} + {viewerRole === 'generic' ? '● point cloud colors' : parsed.sensor_fusion ? '● LiDAR cyan · RGB-D green aligned / red residual' : parsed.reconstruction ? '● reconstructed surface · aligned RGB when available' : parsed.depth_projection?.color_applied === 'rgb' ? '● projected depth · aligned RGB color' : parsed.depth_projection?.color_applied === 'ir' ? '● projected depth · aligned IR intensity' : parsed.depth_projection ? '● projected depth · distance and surface confidence color' : '● filtered laser returns'} {parsed.sensor_fusion && calibration Δ {finite(parsed.sensor_fusion.correction?.x_m).toFixed(3)} m X · {finite(parsed.sensor_fusion.correction?.y_m).toFixed(3)} m Y · {finite(parsed.sensor_fusion.correction?.yaw_deg).toFixed(2)}° yaw} {mapFeatures && parsed.occupancy?.fixed_origin === true && ■ unknown map extent} {mapFeatures && parsed.map_render_mode === 'occupancy-texture' && GPU map texture · all cells} diff --git a/editor/src/components/TemplateGallery.tsx b/editor/src/components/TemplateGallery.tsx index c515b5f..234897c 100644 --- a/editor/src/components/TemplateGallery.tsx +++ b/editor/src/components/TemplateGallery.tsx @@ -55,7 +55,7 @@ export default function TemplateGallery({ const [enabling, setEnabling] = useState<{ slug: string; label: string } | null>(null) const [missing, setMissing] = useState>({}) const [error, setError] = useState(null) - const [expandedGroups, setExpandedGroups] = useState>(() => new Set()) + const [expandedGroups, setExpandedGroups] = useState>(() => new Set(['Core'])) const [query, setQuery] = useState(initialQuery) useEffect(() => { @@ -103,7 +103,12 @@ export default function TemplateGallery({ const refreshTemplates = async () => { try { setError(null) - setTemplates(await api.listTemplates()) + const starters = ['text-pipeline', 'generic-viewer'] + const rank = (template: TemplateMeta) => { + const index = starters.indexOf(template.slug) + return index < 0 ? starters.length : index + } + setTemplates((await api.listTemplates()).sort((a, b) => rank(a) - rank(b))) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } @@ -154,7 +159,7 @@ export default function TemplateGallery({ detail: { kind: 'info', title: `${template.name} opened`, - message: 'The deployment workflow remains available in its original tab.', + message: 'Choose Run once in the top bar to see the result. Your previous workflow remains in its own tab.', }, })) } @@ -266,7 +271,7 @@ export default function TemplateGallery({ lineHeight: 1.5, }}> {openInNewTab - ? 'Browse reusable workflow setups. Each one opens in its own workflow tab.' + ? 'Open a template, then press Run once in the top bar. Start with Text Pipeline to see a local result immediately.' : 'Reusable workflow components, organized by capability.'}
@@ -394,6 +399,17 @@ export default function TemplateGallery({ (e.currentTarget as HTMLElement).style.borderColor = dependencyError ? 'var(--warn)' : group.color } }} + role="button" + tabIndex={isBusy ? -1 : 0} + aria-label={`Open ${template.name}`} + aria-disabled={isBusy} + onKeyDown={event => { + if (event.target !== event.currentTarget) return + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + if (!isBusy) void loadTemplate(template) + } + }} onClick={() => !isBusy && loadTemplate(template)} >
diff --git a/editor/src/store.ts b/editor/src/store.ts index be7a172..d74cf64 100644 --- a/editor/src/store.ts +++ b/editor/src/store.ts @@ -289,6 +289,7 @@ interface Store { duplicateSavedWorkflow: (slug: string) => Promise<{ name: string; slug: string }> deleteWorkflow: (slug: string) => Promise saveActiveTabSnapshot: () => Promise + setWorkflowOperatorView: (view: import('./operatorView').WorkflowOperatorView) => Promise setWorkflowRequirements: ( requiredCapabilities: string[], deviceCalibration: { profile_id: string; hardware_id: string } | null, @@ -2086,6 +2087,18 @@ export const useStore = create((set, get) => ({ })) }, + setWorkflowOperatorView: async (view) => { + const tabId = get().activeTabId + const result = await api.updateWorkflowOperatorView(view) + set(s => ({ + ...(s.activeTabId === tabId ? { workflowMetadata: result.metadata } : {}), + tabs: s.tabs.map(tab => tab.id === tabId ? { + ...tab, dirty: true, + graph: tab.graph ? { ...tab.graph, metadata: cloneDeep(result.metadata) } : tab.graph, + } : tab), + })) + }, + newTab: async (name) => { await get().saveActiveTabSnapshot() const id = makeTabId() diff --git a/tests/test_editor_app_designer.py b/tests/test_editor_app_designer.py new file mode 100644 index 0000000..ecc0880 --- /dev/null +++ b/tests/test_editor_app_designer.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import copy +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +from fastapi.testclient import TestClient + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'editor-server')) +import server + + +class AppDesignerTests(unittest.TestCase): + def setUp(self): + self.session = server.Session() + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.enterContext(patch.object(server, '_session', self.session)) + self.enterContext(patch.object(server, '_save', lambda *args, **kwargs: None)) + self.enterContext(patch.object(server, '_WORKFLOWS_DIR', self.temp.name)) + self.client = TestClient(server.app) + workflow = json.loads((ROOT / 'templates' / 'text-pipeline.json').read_text()) + response = self.client.post('/graph', json={ + 'nodes': list(workflow['node_meta'].values()), 'edges': workflow['edges'], + 'metadata': {'project_id': 'my-project'}, 'entrypoint': workflow['entrypoint'], + }) + self.assertEqual(response.status_code, 200, response.text) + self.view = { + 'schema_version': 1, 'id': 'my-app', 'title': 'My App', + 'sections': [{'id': 'main', 'widgets': [ + {'id': 'text', 'type': 'fields', 'items': [{'node_id': 'a', 'param': 'value', 'label': 'Message', 'input': 'text'}]}, + {'id': 'result', 'type': 'metrics', 'items': [{'node_id': 'out', 'port': 'value', 'label': 'Result', 'format': 'text'}]}, + {'id': 'run', 'type': 'actions', 'items': [{'id': 'run', 'label': 'Run', 'cook_target': {'node_id': 'out', 'port': 'value', 'mode': 'once'}}]}, + ]}], + } + + def test_design_preserves_graph_state_and_saves_with_workflow(self): + graph = self.session.graph + graph._cache[('a', 'value')] = 'cached' + response = self.client.patch('/graph/operator-view', json=self.view) + self.assertEqual(response.status_code, 200, response.text) + self.assertIs(self.session.graph, graph) + self.assertEqual(graph._cache[('a', 'value')], 'cached') + self.assertEqual(response.json()['metadata']['project_id'], 'my-project') + self.assertEqual(response.json()['metadata']['operator_view'], self.view) + saved = self.client.post('/workflows', json={'name': 'My App'}) + self.assertEqual(saved.status_code, 200, saved.text) + payload = json.loads(next(Path(self.temp.name).glob('*.json')).read_text()) + self.assertEqual(payload['metadata']['operator_view'], self.view) + + def test_invalid_reference_does_not_replace_previous_app(self): + self.session.metadata['operator_view'] = copy.deepcopy(self.view) + for key, bad_value in [('node_id', 'deleted'), ('param', 'invented')]: + view = copy.deepcopy(self.view) + view['sections'][0]['widgets'][0]['items'][0][key] = bad_value + result = self.client.patch('/graph/operator-view', json=view) + self.assertEqual(result.status_code, 400, result.text) + self.assertEqual(self.session.metadata['operator_view'], self.view) + view = copy.deepcopy(self.view) + view['sections'][0]['widgets'][1]['items'][0]['port'] = 'invented' + self.assertEqual(self.client.patch('/graph/operator-view', json=view).status_code, 400) + + def test_customer_app_cannot_change_its_operator_permissions(self): + with patch.object(server, '_APP_DEPLOYMENT', {'apps': []}): + response = self.client.patch('/graph/operator-view', json=self.view) + self.assertEqual(response.status_code, 403) diff --git a/tests/test_editor_package_welcome.py b/tests/test_editor_package_welcome.py index 52debd0..eab7017 100644 --- a/tests/test_editor_package_welcome.py +++ b/tests/test_editor_package_welcome.py @@ -4,17 +4,17 @@ ROOT = Path(__file__).resolve().parents[1] -def test_editor_starts_in_nodes_and_first_visit_opens_packages_with_welcome(): +def test_editor_first_visit_leads_to_a_local_workflow_result(): source = (ROOT / "editor" / "src" / "components" / "NodePalette.tsx").read_text(encoding="utf-8") assert "api.getOnboarding()" in source assert "!state.package_welcome_seen" in source - assert "setActiveTab('packages')" in source + assert "setActiveTab('templates')" in source assert "await api.setOnboarding(true)" in source assert "localStorage" not in source - assert "Prepare your robotics workspace" in source - assert "Explore essential packages" in source - assert "Explore core templates" in source + assert "Run your first workflow" in source + assert "Explore packages" in source + assert "Open templates" in source assert "useState('nodes')" in source assert "finishPackageWelcome('templates')" in source diff --git a/tests/test_editor_template_groups.py b/tests/test_editor_template_groups.py index 3e0315e..a4452de 100644 --- a/tests/test_editor_template_groups.py +++ b/tests/test_editor_template_groups.py @@ -4,12 +4,12 @@ ROOT = Path(__file__).resolve().parents[1] -def test_template_gallery_groups_are_collapsed_by_default(): +def test_template_gallery_shows_core_templates_by_default(): source = ( ROOT / "editor" / "src" / "components" / "TemplateGallery.tsx" ).read_text(encoding="utf-8") - assert "useState>(() => new Set())" in source + assert "useState>(() => new Set(['Core']))" in source assert "new Set([templateGroups[0].name])" not in source assert "template.group || 'Core'" in source assert "aria-expanded={isExpanded}" in source