From e73d64da07634e481a9ac0f1f315308d9e515ecb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 15:57:17 +0000 Subject: [PATCH 01/37] Initial plan From 3c74e38b89de603c18cc9c5b0a6d6fd3a6179034 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:08:56 +0000 Subject: [PATCH 02/37] Add multi-file config design doc, implementation plan, test fixtures, and serialization tests - Design doc covering multi-file split strategies, YAML side-pane, visual file boundaries, node-to-file navigation, and IDE integration hooks - Implementation plan with 12 phased tasks covering fixtures, tests, components, and IDE bridge updates - Three new fixture sets: domain-split (5 files), layer-split (5 files), nested-directory (8 files, 3+ levels deep) - Three new test files with 36 test cases covering resolveImports, sourceMap, exportToFiles round-trip, configToNodes, cross-file isolation, error handling Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-editor/sessions/21f17a33-a494-45ec-acbc-a587750bdb2a Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- ...3-26-multifile-config-validation-design.md | 529 +++++++++++++++ ...tifile-config-validation-implementation.md | 608 ++++++++++++++++++ .../serialization-multifile-domain.test.ts | 213 ++++++ .../serialization-multifile-layers.test.ts | 167 +++++ .../serialization-multifile-nested.test.ts | 180 ++++++ test-fixtures/multifile-domain/app.yaml | 30 + .../multifile-domain/domains/auth.yaml | 34 + .../multifile-domain/domains/billing.yaml | 37 ++ .../domains/notifications.yaml | 31 + .../multifile-domain/shared/infra.yaml | 12 + test-fixtures/multifile-layers/app.yaml | 9 + .../multifile-layers/layers/api.yaml | 23 + .../layers/infrastructure.yaml | 21 + .../multifile-layers/layers/middleware.yaml | 23 + .../multifile-layers/layers/services.yaml | 31 + test-fixtures/multifile-nested/app.yaml | 6 + .../multifile-nested/platform/core/cache.yaml | 6 + .../multifile-nested/platform/core/core.yaml | 3 + .../platform/core/database.yaml | 14 + .../platform/features/auth.yaml | 27 + .../platform/features/features.yaml | 3 + .../platform/features/payments.yaml | 31 + .../multifile-nested/platform/platform.yaml | 3 + 23 files changed, 2041 insertions(+) create mode 100644 docs/plans/2026-03-26-multifile-config-validation-design.md create mode 100644 docs/plans/2026-03-26-multifile-config-validation-implementation.md create mode 100644 src/utils/serialization-multifile-domain.test.ts create mode 100644 src/utils/serialization-multifile-layers.test.ts create mode 100644 src/utils/serialization-multifile-nested.test.ts create mode 100644 test-fixtures/multifile-domain/app.yaml create mode 100644 test-fixtures/multifile-domain/domains/auth.yaml create mode 100644 test-fixtures/multifile-domain/domains/billing.yaml create mode 100644 test-fixtures/multifile-domain/domains/notifications.yaml create mode 100644 test-fixtures/multifile-domain/shared/infra.yaml create mode 100644 test-fixtures/multifile-layers/app.yaml create mode 100644 test-fixtures/multifile-layers/layers/api.yaml create mode 100644 test-fixtures/multifile-layers/layers/infrastructure.yaml create mode 100644 test-fixtures/multifile-layers/layers/middleware.yaml create mode 100644 test-fixtures/multifile-layers/layers/services.yaml create mode 100644 test-fixtures/multifile-nested/app.yaml create mode 100644 test-fixtures/multifile-nested/platform/core/cache.yaml create mode 100644 test-fixtures/multifile-nested/platform/core/core.yaml create mode 100644 test-fixtures/multifile-nested/platform/core/database.yaml create mode 100644 test-fixtures/multifile-nested/platform/features/auth.yaml create mode 100644 test-fixtures/multifile-nested/platform/features/features.yaml create mode 100644 test-fixtures/multifile-nested/platform/features/payments.yaml create mode 100644 test-fixtures/multifile-nested/platform/platform.yaml diff --git a/docs/plans/2026-03-26-multifile-config-validation-design.md b/docs/plans/2026-03-26-multifile-config-validation-design.md new file mode 100644 index 0000000..1daaa72 --- /dev/null +++ b/docs/plans/2026-03-26-multifile-config-validation-design.md @@ -0,0 +1,529 @@ +# Multi-File Config Validation & YAML Side-Pane — Design Document + +**Date:** 2026-03-26 +**Status:** Draft +**Repos:** workflow-editor, workflow-vscode, workflow-jetbrains + +## Overview + +Extend the workflow editor's multi-file config support with comprehensive test validation across config split permutations, an optional YAML side-pane with multi-file navigation, visual file boundaries on the canvas, and node-to-file navigation hooks. The goal is to ensure the visual editor can faithfully represent the entirety of an application configuration regardless of how the YAML files are organized, and to provide a common interface that IDE plugins can hook into for source-level navigation. + +## 1. Problem Statement + +### Current State + +The editor already supports multi-file configs via `resolveImports()` and tracks source provenance in `sourceMap` (module/pipeline name → source file path). Nodes show a file badge on hover when multiple source files exist. `exportToFiles()` can split config back to per-file YAML. + +### Gaps + +1. **Limited test coverage of split permutations** — Only one multi-file fixture set exists (`test-fixtures/multifile/`), covering a single split strategy (application-level imports). Real projects split configs by domain, by architectural layer, and across nested directory trees. We have no test validation for these patterns. + +2. **No YAML side-pane in the editor itself** — When the editor is used standalone (outside an IDE), there is no way to view the YAML alongside the canvas. When embedded in IDE plugins (workflow-vscode, workflow-jetbrains), the IDE's own text editor shows a single merged file, but there is no multi-file tab navigation or file-scoped view. + +3. **No visual file boundaries on canvas** — When a config spans multiple files, there is no visual grouping on the canvas showing which nodes belong to which file. Users have to hover each node to see the file badge. + +4. **Incomplete node-to-file navigation** — `onNavigateToSource` passes `(line, col)` but not the file path. For multi-file configs, clicking a node from `api.yaml` should navigate to the correct line *in that specific file*, not a line in the merged config. + +5. **No reverse navigation** — When a user is viewing YAML and clicks on a section, the corresponding node should be selected and scrolled into view on the canvas. + +## 2. Multi-File Config Split Strategies + +Real applications split configs in at least three distinct patterns. We must test all of them. + +### 2.1 Split by Domain + +Each business domain owns its modules and pipelines in a separate file. A root config imports all domains. + +``` +project/ +├── app.yaml ← root: application metadata + imports +├── domains/ +│ ├── auth/ +│ │ └── auth.yaml ← modules: [auth-db, auth-cache], pipelines: [login, register, verify] +│ ├── billing/ +│ │ └── billing.yaml ← modules: [billing-db, stripe], pipelines: [charge, refund, invoice] +│ └── notifications/ +│ └── notifications.yaml ← modules: [email-svc, sms-svc], pipelines: [send-email, send-sms] +└── shared/ + └── infra.yaml ← modules: [http-server, router, logger] +``` + +**Characteristics:** +- Each domain file has both `modules:` and `pipelines:` +- The root file has `imports:` + `application:` + `workflows:` (routes reference pipelines from domain files) +- Modules in domain files may reference shared infrastructure modules by name + +### 2.2 Split by Architectural Layer + +Config is split horizontally: infrastructure, middleware, business logic, API surface. + +``` +project/ +├── app.yaml ← root: imports + workflows + application +├── layers/ +│ ├── infrastructure.yaml ← modules: [db, cache, message-queue, logger] +│ ├── middleware.yaml ← pipelines: [auth-middleware, rate-limit, cors] +│ ├── services.yaml ← pipelines: [user-service, order-service, product-service] +│ └── api.yaml ← modules: [http-server, router], workflows: {http: ...} +``` + +**Characteristics:** +- Some files have only `modules:`, some only `pipelines:`, some have `workflows:` +- Cross-layer references (services reference infrastructure modules) +- The API layer defines routes that reference service-layer pipelines + +### 2.3 Split by Nested Directories + +Deep nesting with sub-imports. Each level imports its children. + +``` +project/ +├── app.yaml ← imports: [platform/platform.yaml] +├── platform/ +│ ├── platform.yaml ← imports: [core/core.yaml, features/features.yaml] +│ ├── core/ +│ │ ├── core.yaml ← imports: [database.yaml, cache.yaml] +│ │ ├── database.yaml ← modules: [primary-db, replica-db] +│ │ └── cache.yaml ← modules: [redis-cache] +│ └── features/ +│ ├── features.yaml ← imports: [auth.yaml, payments.yaml] +│ ├── auth.yaml ← modules + pipelines for auth +│ └── payments.yaml ← modules + pipelines for payments +``` + +**Characteristics:** +- 3+ levels of nesting +- Intermediate files are pure import aggregators (no modules/pipelines of their own) +- Relative paths in imports are resolved relative to the importing file's directory + +## 3. YAML Side-Pane (Optional, Embeddable) + +### 3.1 Motivation + +IDE plugins (workflow-vscode, workflow-jetbrains) already have their own text editors, so they don't need the editor to render YAML. But: + +1. The **standalone editor** (browser, Storybook, demos) has no YAML view at all +2. We need a **common interface** for multi-file YAML navigation that IDE plugins can either use or replicate +3. Testing the node↔YAML navigation hooks requires an in-editor YAML view + +### 3.2 Design + +Add an optional YAML side-pane to the right side of the editor (collapsible, like PropertyPanel). The pane shows: + +``` +┌──────────────────────────────────────────────────────────┐ +│ [app.yaml ▼] [auth.yaml] [billing.yaml] [infra.yaml] │ ← file tabs +├──────────────────────────────────────────────────────────┤ +│ 1 application: │ +│ 2 name: my-app │ +│ 3 version: 1.0.0 │ +│ 4 │ +│ 5 imports: │ +│ 6 - domains/auth/auth.yaml │ +│ 7 - domains/billing/billing.yaml │ +│ 8 - shared/infra.yaml │ +│ 9 │ +│ 10 workflows: │ +│ 11 http: │ +│ 12 server: http-server │ +│ 13 router: router │ +│ 14 routes: │ +│ 15 >>> - method: POST │ ← highlighted (selected node) +│ 16 >>> path: /api/auth/login │ +│ 17 >>> handler: login │ +│ 18 - method: POST │ +│ 19 path: /api/auth/register │ +│ 20 handler: register │ +└──────────────────────────────────────────────────────────┘ +``` + +**File tabs:** One tab per file in the workspace. Active tab is highlighted. Clicking a tab switches the YAML view to that file's content. + +**Line highlighting:** When a node is selected on the canvas, the YAML pane scrolls to and highlights the corresponding lines in the appropriate file (auto-switching tabs if the node belongs to a different file). + +**Click-to-select:** Clicking on a YAML line in the pane selects the corresponding node on the canvas and scrolls it into view. + +### 3.3 Component Architecture + +```typescript +// New component: src/components/yaml/YamlSidePane.tsx +interface YamlSidePaneProps { + /** Map of file path → YAML content. null key = main file. */ + files: Map; + /** Currently active file tab */ + activeFile: string | null; + /** Called when user switches file tab */ + onFileSelect: (filePath: string | null) => void; + /** Line range to highlight (1-based) */ + highlightRange?: { startLine: number; endLine: number }; + /** Called when user clicks a line */ + onLineClick?: (filePath: string | null, line: number) => void; + /** Whether the pane is visible */ + visible: boolean; +} +``` + +### 3.4 IDE Plugin Integration + +The YAML side-pane is **optional** — controlled by a new prop: + +```typescript +interface WorkflowEditorProps { + // ... existing props ... + /** When true, shows the built-in YAML side-pane. Default: false. + * IDE plugins typically set this to false and use their own text editor. */ + showYamlPane?: boolean; +} +``` + +IDE plugins do NOT use the built-in YAML pane. Instead, they use the **navigation hooks**: + +```typescript +interface WorkflowEditorProps { + // ... existing props ... + /** Enhanced: now includes filePath for multi-file navigation. + * Called when user clicks a node — host should navigate to the line in the specified file. */ + onNavigateToSource?: (filePath: string | null, line: number, col: number) => void; + /** NEW: Called when the editor wants the host to reveal a specific node. + * The host should select the node on canvas (the editor handles this internally, + * but the host may also want to update its own UI). */ + onNodeFocusRequest?: (nodeId: string) => void; +} +``` + +**Breaking change mitigation:** The `onNavigateToSource` signature changes from `(line, col)` to `(filePath, line, col)`. Since this is a callback the host provides, the host code already knows its signature. IDE plugins will need to update their bridge code to handle the new `filePath` parameter. + +## 4. Visual File Boundaries on Canvas + +### 4.1 Concept + +When a multi-file config is loaded, nodes from different source files should be visually grouped. This uses React Flow's built-in **group node** mechanism (which the editor already supports via `GroupNode.tsx`). + +### 4.2 Implementation + +For each unique source file in the workspace, create a background group node: + +```typescript +interface FileGroupNode { + id: `file-group:${string}`; + type: 'fileGroup'; + data: { + label: string; // e.g., "auth.yaml" + filePath: string; // full relative path + fileType: WorkflowFileType; + }; + style: { + backgroundColor: string; // subtle tint per file (auto-assigned from palette) + borderColor: string; + borderStyle: 'dashed'; + borderRadius: 8; + }; + // Position/size computed from child nodes' bounding box +} +``` + +**Behavior:** +- File group nodes are rendered as dashed-border containers behind their child nodes +- Each file gets a distinct subtle background color from a palette (8 colors, cycling) +- The file name is shown in the top-left corner of the group +- Clicking the group label triggers `onNavigateToSource(filePath, 1, 0)` — jump to line 1 of that file +- Group boundaries auto-resize when nodes are moved +- Groups are created only when `sourceMap` has entries from 2+ distinct files + +### 4.3 File Group Color Palette + +```typescript +const FILE_GROUP_COLORS = [ + { bg: '#EFF6FF', border: '#93C5FD' }, // blue + { bg: '#F0FDF4', border: '#86EFAC' }, // green + { bg: '#FFF7ED', border: '#FDBA74' }, // orange + { bg: '#FAF5FF', border: '#C4B5FD' }, // purple + { bg: '#FEF2F2', border: '#FCA5A5' }, // red + { bg: '#ECFEFF', border: '#67E8F9' }, // cyan + { bg: '#FFFBEB', border: '#FCD34D' }, // yellow + { bg: '#FDF2F8', border: '#F9A8D4' }, // pink +]; +``` + +## 5. Enhanced Node-to-File Navigation + +### 5.1 YAML Line Map Enhancement + +The existing `yamlLineMap.ts` only maps module names within a single `modules:` block. Extend it to also map: + +- **Pipeline names** — line range where each pipeline is defined +- **Workflow names** — line range where each workflow section starts +- **Pipeline step names** — line range for individual steps within a pipeline +- **Trigger names** — line range for trigger definitions + +```typescript +export interface YamlLineRange { + startLine: number; + endLine: number; +} + +export interface MultiFileYamlLineMap { + /** file path → { node/section name → line range } */ + files: Map>; +} + +/** + * Build a comprehensive line map across all files in the workspace. + * Covers modules, pipelines, pipeline steps, workflows, and triggers. + */ +export function buildMultiFileLineMap( + files: Map, +): MultiFileYamlLineMap; +``` + +### 5.2 Navigation Flow + +**Canvas → YAML (clicking a node):** + +1. User clicks a node on the canvas +2. Editor looks up `node.data.sourceFile` to determine which file the node belongs to +3. Editor looks up `node.data.label` in the `MultiFileYamlLineMap` for that file +4. If YAML pane is active: switch to the file tab, scroll to and highlight the line range +5. If IDE embedded: call `onNavigateToSource(filePath, startLine, 0)` + +**YAML → Canvas (clicking a YAML line):** + +1. User clicks a line in the YAML pane (or IDE sends a navigate-to-node message) +2. Determine which node the line corresponds to (reverse lookup in `MultiFileYamlLineMap`) +3. Select the node on the canvas +4. Scroll/pan the canvas to center the node +5. Open the property panel for the node + +### 5.3 IDE Bridge Protocol + +For IDE plugins that use the webview bridge, add new message types: + +```typescript +// Editor → Host (node clicked, navigate to source) +interface NavigateToSourceMessage { + type: 'navigateToSource'; + filePath: string | null; // null = main file + line: number; + col: number; + nodeName: string; +} + +// Host → Editor (user clicked in YAML, navigate to node) +interface NavigateToNodeMessage { + type: 'navigateToNode'; + filePath: string | null; + line: number; +} + +// Host → Editor (file changed externally, reload) +interface FileChangedMessage { + type: 'fileChanged'; + filePath: string | null; + content: string; +} +``` + +## 6. Test Validation Strategy + +### 6.1 Fixture-Based Serialization Tests + +Create three new fixture sets covering each split strategy: + +``` +test-fixtures/ +├── multifile/ ← existing (simple application imports) +├── multifile-domain/ ← NEW: split by domain +│ ├── app.yaml +│ ├── domains/ +│ │ ├── auth.yaml +│ │ ├── billing.yaml +│ │ └── notifications.yaml +│ └── shared/ +│ └── infra.yaml +├── multifile-layers/ ← NEW: split by layer +│ ├── app.yaml +│ ├── layers/ +│ │ ├── infrastructure.yaml +│ │ ├── middleware.yaml +│ │ ├── services.yaml +│ │ └── api.yaml +└── multifile-nested/ ← NEW: deep nesting + ├── app.yaml + └── platform/ + ├── platform.yaml + ├── core/ + │ ├── core.yaml + │ ├── database.yaml + │ └── cache.yaml + └── features/ + ├── features.yaml + ├── auth.yaml + └── payments.yaml +``` + +### 6.2 Test Matrix + +For each fixture set, test: + +| Test | What it validates | +|------|-------------------| +| **resolve-all-modules** | `resolveImports()` finds every module from every file | +| **sourceMap-correctness** | Every module and pipeline gets the correct source file path | +| **round-trip-export** | `exportToFiles()` puts each module/pipeline back in its source file | +| **main-file-imports** | Main file output contains `imports:` references, not inlined content | +| **no-cross-file-bleed** | Modules from file A don't appear in file B's export | +| **no-duplication** | No module or pipeline appears twice after merging | +| **node-creation** | `configToNodes()` creates correct node count with correct labels | +| **sourceFile-on-nodes** | Every node's `data.sourceFile` matches the sourceMap | +| **edge-creation** | Edges connect nodes across file boundaries (e.g., route → pipeline in different file) | +| **pipeline-steps** | Pipeline step nodes are created with correct `pipelineName` | +| **name-version-preserved** | Application name and version survive round-trip | +| **edit-stays-in-file** | Modifying a node and re-exporting keeps it in its original file | +| **cycle-detection** | Circular imports don't cause infinite loops | +| **missing-file-error** | Missing imported files produce errors but don't crash | +| **nested-path-resolution** | 3+ levels of imports resolve relative paths correctly | + +### 6.3 YAML Line Map Tests + +For each fixture, test the `buildMultiFileLineMap`: + +| Test | What it validates | +|------|-------------------| +| **module-lines** | Each module name maps to correct line range in its source file | +| **pipeline-lines** | Each pipeline name maps to correct line range | +| **step-lines** | Each pipeline step maps to correct line range within its pipeline | +| **workflow-lines** | Each workflow section maps to correct line range | +| **cross-file-lookup** | Looking up a node returns the correct file + line | + +### 6.4 Navigation Hook Tests + +Unit tests for the navigation flow: + +| Test | What it validates | +|------|-------------------| +| **node-click-calls-navigate** | Clicking a node with sourceFile calls `onNavigateToSource(filePath, line, col)` | +| **node-click-switches-tab** | When YAML pane is active, clicking a node from a different file switches the tab | +| **yaml-click-selects-node** | Clicking a YAML line selects the corresponding node on canvas | +| **yaml-click-cross-file** | Clicking a line in file B's tab selects a node from file B | +| **no-navigate-without-sourceMap** | Without sourceMap, `onNavigateToSource` passes null filePath | + +### 6.5 Visual Validation Tests (E2E) + +Playwright-based visual tests for the file boundaries and YAML pane: + +| Test | What it validates | +|------|-------------------| +| **file-groups-rendered** | Multi-file config shows dashed group boundaries per file | +| **file-group-labels** | Each group has the correct file name label | +| **file-group-colors** | Groups have distinct background colors | +| **yaml-pane-toggle** | YAML pane shows/hides via toolbar button | +| **yaml-pane-file-tabs** | YAML pane shows tabs for each file | +| **yaml-pane-highlight** | Selecting a node highlights corresponding YAML lines | +| **yaml-pane-tab-switch** | Selecting a node from a different file switches tabs | +| **yaml-click-selects** | Clicking in YAML pane selects node on canvas | +| **file-group-click** | Clicking a file group label triggers navigation | + +### 6.6 Component Tests (Vitest + React Testing Library) + +| Test | What it validates | +|------|-------------------| +| **YamlSidePane-renders** | Component renders file tabs and YAML content | +| **YamlSidePane-tab-switch** | Clicking a tab calls `onFileSelect` | +| **YamlSidePane-highlight** | Line highlight renders at correct position | +| **YamlSidePane-line-click** | Clicking a line calls `onLineClick` | +| **YamlSidePane-hidden** | When `visible=false`, pane is not rendered | +| **FileGroupNode-renders** | File group node renders with correct label and style | +| **FileGroupNode-click** | Clicking group label triggers callback | + +## 7. Additional Editor Functionality + +Beyond the core multi-file and YAML pane features, the following related capabilities should be included in the implementation plan: + +### 7.1 File-Scoped Validation Errors + +When schema validation errors occur, they should be attributed to the correct source file: + +```typescript +interface ValidationError { + nodeId?: string; + message: string; + filePath?: string | null; // NEW: which file the error originates from + line?: number; // NEW: line in the source file +} +``` + +The YAML pane should show inline error markers (red squiggle underline or gutter icon) at the error line. IDE plugins receive the `filePath` and `line` to show errors in their own editors. + +### 7.2 File-Aware Undo/Redo + +Currently undo/redo operates on the merged config. With multi-file awareness: +- Undo/redo should track which file was modified +- The change description should include the file name: "Modified auth.yaml: renamed module 'auth-db' to 'auth-database'" +- The YAML pane should update to show the file that was changed + +### 7.3 Add-Node File Assignment + +When a user adds a new node via the palette, the editor should: +1. If file groups are visible, and the node is dropped inside a file group → assign to that file +2. If dropped outside any group → assign to the main file +3. The PropertyPanel should show a "Source File" field that can be changed via dropdown + +### 7.4 File-Level Export/Import + +Add toolbar actions for file-level operations: +- **"Export File..."** — export a single file's YAML (useful for extracting a domain) +- **"Import File..."** — import YAML into the workspace as a new file (adds an `imports:` entry to the root) +- **"Move to File..."** — right-click a node → move it to a different file (updates sourceMap) + +### 7.5 Workspace Summary Panel + +A small info panel (tooltip or expandable section in the toolbar) showing: +- Total file count +- Module count per file +- Pipeline count per file +- Any unresolved imports or validation errors + +## 8. Implementation Phases + +### Phase 1: Test Fixtures & Serialization Validation +- Create 3 new multi-file fixture sets (domain, layers, nested) +- Write serialization tests for all 3 patterns +- Ensure `resolveImports()` handles nested-directory relative paths +- Fix any bugs discovered by the new test permutations + +### Phase 2: Enhanced YAML Line Map & Navigation Hooks +- Extend `yamlLineMap.ts` to map pipelines, steps, workflows, triggers +- Build `MultiFileYamlLineMap` for cross-file line resolution +- Update `onNavigateToSource` signature to include `filePath` +- Add `onNodeFocusRequest` callback +- Write unit tests for line map and navigation + +### Phase 3: YAML Side-Pane Component +- Implement `YamlSidePane` component with file tabs, syntax coloring, line numbers +- Add `showYamlPane` prop to `WorkflowEditorProps` +- Integrate with `uiLayoutStore` for collapse/resize state +- Wire node selection → YAML highlight (canvas → pane) +- Wire YAML click → node selection (pane → canvas) +- Write component tests + +### Phase 4: Visual File Boundaries +- Implement `FileGroupNode` component +- Auto-generate file group nodes from sourceMap +- Assign colors from palette +- Auto-size groups from child node bounding boxes +- Wire group label click → navigation +- Write component and visual tests + +### Phase 5: IDE Plugin Bridge Updates +- Update webview bridge message protocol in both IDE plugins +- Add `navigateToSource` message with `filePath` field +- Add `navigateToNode` reverse navigation message +- Add `fileChanged` live reload message +- Test with both VSCode and JetBrains plugins + +### Phase 6: Additional Features +- File-scoped validation errors +- Add-node file assignment (drop into file group) +- "Move to File..." context menu +- Workspace summary panel +- File-aware undo/redo descriptions diff --git a/docs/plans/2026-03-26-multifile-config-validation-implementation.md b/docs/plans/2026-03-26-multifile-config-validation-implementation.md new file mode 100644 index 0000000..143473b --- /dev/null +++ b/docs/plans/2026-03-26-multifile-config-validation-implementation.md @@ -0,0 +1,608 @@ +# Multi-File Config Validation & YAML Side-Pane — Implementation Plan (Phases 1-3) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Comprehensive test validation of multi-file workflow configs across domain-split, layer-split, and nested-directory patterns. Enhanced YAML line mapping. Optional YAML side-pane with multi-file navigation. Visual file boundaries on canvas. IDE-compatible navigation hooks. + +**Architecture:** Three new fixture sets validate each split strategy end-to-end through resolveImports → configToNodes → nodesToConfig → exportToFiles round-trip. Extended yamlLineMap covers modules, pipelines, steps, workflows, and triggers for cross-file line resolution. Optional YamlSidePane renders file tabs + syntax coloring + line highlighting. FileGroupNode renders dashed-border containers per source file. + +**Tech Stack:** TypeScript, React, Vitest, Playwright, @xyflow/react, js-yaml + +**Design Doc:** `docs/plans/2026-03-26-multifile-config-validation-design.md` + +--- + +### Task 1: Domain-Split Test Fixtures + +**Files:** +- Create: `test-fixtures/multifile-domain/app.yaml` +- Create: `test-fixtures/multifile-domain/domains/auth.yaml` +- Create: `test-fixtures/multifile-domain/domains/billing.yaml` +- Create: `test-fixtures/multifile-domain/domains/notifications.yaml` +- Create: `test-fixtures/multifile-domain/shared/infra.yaml` + +**Step 1: Create root config** + +`test-fixtures/multifile-domain/app.yaml`: +```yaml +application: + name: my-platform + version: 3.0.0 + +imports: + - domains/auth.yaml + - domains/billing.yaml + - domains/notifications.yaml + - shared/infra.yaml + +workflows: + http: + server: http-server + router: router + routes: + - method: POST + path: /api/auth/login + handler: login + - method: POST + path: /api/auth/register + handler: register + - method: POST + path: /api/billing/charge + handler: charge + - method: POST + path: /api/billing/refund + handler: refund + - method: POST + path: /api/notify/email + handler: send-email +``` + +**Step 2: Create domain files** + +`test-fixtures/multifile-domain/domains/auth.yaml`: +```yaml +modules: + - name: auth-db + type: database.postgres + config: + host: localhost + port: 5432 + database: auth + - name: auth-cache + type: nosql.redis + config: + host: localhost + port: 6379 + +pipelines: + login: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: authenticate + type: step.auth_validate + - name: respond + type: step.json_response + register: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: insert + type: step.db_exec + - name: respond + type: step.json_response +``` + +**Step 3: Create all other domain and shared files following the same pattern** + +**Step 4: Commit** + +--- + +### Task 2: Layer-Split Test Fixtures + +**Files:** +- Create: `test-fixtures/multifile-layers/app.yaml` +- Create: `test-fixtures/multifile-layers/layers/infrastructure.yaml` +- Create: `test-fixtures/multifile-layers/layers/middleware.yaml` +- Create: `test-fixtures/multifile-layers/layers/services.yaml` +- Create: `test-fixtures/multifile-layers/layers/api.yaml` + +**Step 1: Create root config** + +`test-fixtures/multifile-layers/app.yaml`: +```yaml +application: + name: layered-app + version: 1.0.0 + +imports: + - layers/infrastructure.yaml + - layers/middleware.yaml + - layers/services.yaml + - layers/api.yaml +``` + +**Step 2: Create layer files** + +- `infrastructure.yaml` — modules: [primary-db, cache, message-queue, logger] +- `middleware.yaml` — pipelines: [auth-middleware, rate-limit, cors] +- `services.yaml` — pipelines: [user-service, order-service, product-service] +- `api.yaml` — modules: [http-server, router], workflows: {http: routes referencing service pipelines} + +**Step 3: Commit** + +--- + +### Task 3: Nested-Directory Test Fixtures + +**Files:** +- Create: `test-fixtures/multifile-nested/app.yaml` +- Create: `test-fixtures/multifile-nested/platform/platform.yaml` +- Create: `test-fixtures/multifile-nested/platform/core/core.yaml` +- Create: `test-fixtures/multifile-nested/platform/core/database.yaml` +- Create: `test-fixtures/multifile-nested/platform/core/cache.yaml` +- Create: `test-fixtures/multifile-nested/platform/features/features.yaml` +- Create: `test-fixtures/multifile-nested/platform/features/auth.yaml` +- Create: `test-fixtures/multifile-nested/platform/features/payments.yaml` + +**Step 1: Create root config with single top-level import** + +`test-fixtures/multifile-nested/app.yaml`: +```yaml +application: + name: nested-platform + version: 2.0.0 + +imports: + - platform/platform.yaml +``` + +**Step 2: Create platform aggregator** + +`test-fixtures/multifile-nested/platform/platform.yaml`: +```yaml +imports: + - core/core.yaml + - features/features.yaml +``` + +**Step 3: Create core aggregator + leaf files** + +- `core.yaml` — imports: [database.yaml, cache.yaml] +- `database.yaml` — modules: [primary-db, replica-db] +- `cache.yaml` — modules: [redis-cache] + +**Step 4: Create features aggregator + leaf files** + +- `features.yaml` — imports: [auth.yaml, payments.yaml] +- `auth.yaml` — modules: [auth-service], pipelines: [login, register] +- `payments.yaml` — modules: [payment-gateway], pipelines: [charge, refund] + +**Step 5: Commit** + +--- + +### Task 4: Domain-Split Serialization Tests + +**Files:** +- Create: `src/utils/serialization-multifile-domain.test.ts` + +**Step 1: Write tests** + +```typescript +describe('domain-split multi-file config', () => { + // Load all fixtures from test-fixtures/multifile-domain/ + // Build resolver from file map + + it('resolves all modules across domain files and shared infra', async () => { + // Expect: auth-db, auth-cache, billing-db, stripe, email-svc, sms-svc, http-server, router, logger + }); + + it('assigns correct sourceFile for every module', async () => { + // auth-db → domains/auth.yaml, http-server → shared/infra.yaml, etc. + }); + + it('tracks all pipelines in sourceMap', async () => { + // login → domains/auth.yaml, charge → domains/billing.yaml, etc. + }); + + it('round-trip export routes modules to correct domain files', async () => { + // exportToFiles() puts each module back in its domain file + }); + + it('round-trip export routes pipelines to correct domain files', async () => { + // exportToFiles() puts each pipeline back in its domain file + }); + + it('main file has imports: but no modules or pipelines', async () => { + // Main file only has application:, imports:, workflows: + }); + + it('workflows stay in main file (routes reference cross-file pipelines)', async () => { + // Workflows always belong to the main file + }); + + it('creates correct node count from merged config', async () => { + // configToNodes() creates nodes for all modules + synthesized pipeline steps + }); + + it('edges connect cross-file nodes (routes to pipeline handlers)', async () => { + // HTTP route edges connect to pipeline nodes from domain files + }); + + it('no module duplication after merge', async () => { + // Set of module names has no duplicates + }); + + it('editing a domain module keeps it in its domain file', async () => { + // Modify auth-db config, re-export, check it stays in domains/auth.yaml + }); + + it('application name and version preserved', async () => { + // config.name === 'my-platform', config.version === '3.0.0' + }); +}); +``` + +**Step 2: Run tests, fix any resolveImports bugs for this pattern** + +**Step 3: Commit** + +--- + +### Task 5: Layer-Split Serialization Tests + +**Files:** +- Create: `src/utils/serialization-multifile-layers.test.ts` + +**Step 1: Write tests** + +```typescript +describe('layer-split multi-file config', () => { + it('resolves modules from infrastructure and api layers', async () => {}); + it('resolves pipelines from middleware and services layers', async () => {}); + it('sourceMap assigns correct layer file for each module/pipeline', async () => {}); + it('round-trip export: modules stay in their layer file', async () => {}); + it('round-trip export: pipelines stay in their layer file', async () => {}); + it('round-trip export: workflows stay in api layer file', async () => {}); + it('main file only has application: and imports:', async () => {}); + it('no cross-layer bleed in exported files', async () => {}); +}); +``` + +**Step 2: Run tests, fix any bugs** + +**Step 3: Commit** + +--- + +### Task 6: Nested-Directory Serialization Tests + +**Files:** +- Create: `src/utils/serialization-multifile-nested.test.ts` + +**Step 1: Write tests** + +```typescript +describe('nested-directory multi-file config', () => { + it('resolves modules across 3+ levels of nesting', async () => { + // primary-db from platform/core/database.yaml, auth-service from platform/features/auth.yaml + }); + + it('sourceMap uses full relative paths from root', async () => { + // primary-db → platform/core/database.yaml (not just database.yaml) + }); + + it('handles intermediate aggregator files with no modules', async () => { + // platform.yaml, core.yaml, features.yaml are pure import aggregators + }); + + it('round-trip export: leaf modules stay in leaf files', async () => {}); + + it('round-trip export: aggregator files only have imports:', async () => { + // platform.yaml export should only contain imports: [core/core.yaml, features/features.yaml] + }); + + it('relative paths in nested imports resolve correctly', async () => { + // core.yaml imports database.yaml (relative to core/ directory) + // This is resolved as platform/core/database.yaml from the root + }); + + it('missing leaf file in nested structure reports error but resolves siblings', async () => { + // Remove payments.yaml from resolver, auth.yaml modules still appear + }); + + it('main file references only top-level import', async () => { + // Main file has imports: [platform/platform.yaml] only + }); +}); +``` + +**Step 2: Run tests — this is the most complex case, likely to surface path resolution bugs** + +**Step 3: Fix `resolveImports()` if nested relative paths aren't handled correctly** + +Currently `resolveImports()` may not resolve paths relative to the importing file's directory. If `core.yaml` (at `platform/core/core.yaml`) imports `database.yaml`, the resolver should receive `platform/core/database.yaml`, not `database.yaml`. Check and fix the path resolution logic. + +**Step 4: Commit** + +--- + +### Task 7: Enhanced YAML Line Map + +**Files:** +- Modify: `src/utils/yamlLineMap.ts` +- Create: `src/utils/yamlLineMap.test.ts` + +**Step 1: Extend `buildYamlLineMap` to cover all section types** + +The current implementation only maps module names within the `modules:` block. Extend it to also map: +- `pipelines:` → pipeline names → `{ startLine, endLine }` for each pipeline +- Pipeline steps → `pipeline:step` keys → `{ startLine, endLine }` for each step +- `workflows:` → workflow names +- `triggers:` → trigger names + +**Step 2: Add `buildMultiFileLineMap` function** + +```typescript +export function buildMultiFileLineMap( + files: Map, +): MultiFileYamlLineMap { + const result: MultiFileYamlLineMap = { files: new Map() }; + for (const [filePath, content] of files) { + result.files.set(filePath, buildYamlLineMap(content)); + } + return result; +} + +export function lookupNodeInLineMap( + lineMap: MultiFileYamlLineMap, + nodeName: string, + sourceFile?: string, +): { filePath: string | null; range: YamlLineRange } | null; +``` + +**Step 3: Write tests for all section types using the fixture files** + +**Step 4: Commit** + +--- + +### Task 8: Update onNavigateToSource Signature + +**Files:** +- Modify: `src/types/editor.ts` — add filePath parameter +- Modify: `src/components/WorkflowEditor.tsx` — pass filePath from node data +- Modify: `src/components/canvas/WorkflowCanvas.tsx` — update onNodeClick handler +- Create: `src/utils/navigation.ts` — navigation helper functions +- Create: `src/utils/navigation.test.ts` + +**Step 1: Update the type** + +```typescript +// In editor.ts +onNavigateToSource?: (filePath: string | null, line: number, col: number) => void; +``` + +**Step 2: Create navigation helpers** + +```typescript +// src/utils/navigation.ts +export function resolveNodeSourceLocation( + node: WorkflowNode, + lineMap: MultiFileYamlLineMap, + sourceMap: Map, +): { filePath: string | null; line: number; col: number } | null; +``` + +**Step 3: Wire into WorkflowCanvas node click handler** + +When a node is clicked and `onNavigateToSource` is provided, call it with the resolved file path + line. + +**Step 4: Write unit tests for navigation helpers** + +**Step 5: Commit** + +--- + +### Task 9: YAML Side-Pane Component + +**Files:** +- Create: `src/components/yaml/YamlSidePane.tsx` +- Create: `src/components/yaml/YamlSidePane.test.tsx` +- Create: `src/components/yaml/FileTabBar.tsx` +- Create: `src/components/yaml/YamlLineRenderer.tsx` +- Modify: `src/types/editor.ts` — add `showYamlPane` prop +- Modify: `src/components/WorkflowEditor.tsx` — integrate YamlSidePane +- Modify: `src/stores/uiLayoutStore.ts` — add yamlPane collapse/width state + +**Step 1: Create FileTabBar component** + +Simple tab bar showing one tab per file. Active tab highlighted. Click to switch. + +```tsx +interface FileTabBarProps { + files: Array<{ path: string | null; label: string }>; + activeFile: string | null; + onSelect: (filePath: string | null) => void; +} +``` + +**Step 2: Create YamlLineRenderer component** + +Renders YAML content with line numbers, syntax coloring (simple keyword highlighting for YAML keys, strings, comments), and line highlight range. + +**Step 3: Create YamlSidePane component** + +Combines FileTabBar + YamlLineRenderer. Handles scroll-to-line behavior. + +**Step 4: Add `showYamlPane` prop to WorkflowEditorProps** + +**Step 5: Integrate into WorkflowEditor layout** + +When `showYamlPane` is true, add a fourth panel to the right of the property panel (or replace the property panel with a split view). + +Layout: +``` +┌─────────┬───────────────────┬────────────┬──────────────┐ +│ Palette │ Canvas │ Properties │ YAML Pane │ +│ │ │ │ (optional) │ +└─────────┴───────────────────┴────────────┴──────────────┘ +``` + +**Step 6: Wire node selection → YAML highlight** + +When `selectedNodeId` changes in workflowStore, compute the line range and active file, update YamlSidePane props. + +**Step 7: Wire YAML click → node selection** + +When `onLineClick` fires, reverse-lookup the node from the line map, call `setSelectedNodeId()`. + +**Step 8: Write component tests** + +```typescript +describe('YamlSidePane', () => { + it('renders file tabs for each file in the map'); + it('switches content when tab is clicked'); + it('highlights lines in the specified range'); + it('scrolls to highlighted lines'); + it('calls onLineClick when a line is clicked'); + it('does not render when visible=false'); +}); + +describe('FileTabBar', () => { + it('renders one tab per file'); + it('marks active tab with active class'); + it('calls onSelect with file path when tab is clicked'); + it('shows "main" for null file path'); +}); +``` + +**Step 9: Commit** + +--- + +### Task 10: Visual File Boundary Groups + +**Files:** +- Create: `src/components/nodes/FileGroupNode.tsx` +- Create: `src/components/nodes/FileGroupNode.test.tsx` +- Modify: `src/utils/serialization.ts` — add file group generation +- Modify: `src/stores/nodeTypeRegistry.ts` — register FileGroupNode +- Create: `src/utils/fileGroups.ts` — file group computation utilities +- Create: `src/utils/fileGroups.test.ts` + +**Step 1: Create FileGroupNode component** + +A React Flow group node with dashed border, subtle background color, and a file name label. + +**Step 2: Create file group computation** + +```typescript +// src/utils/fileGroups.ts +export function computeFileGroups( + nodes: WorkflowNode[], + sourceMap: Map, +): FileGroupData[]; + +export interface FileGroupData { + filePath: string; + nodeIds: string[]; + bounds: { x: number; y: number; width: number; height: number }; + color: { bg: string; border: string }; +} +``` + +**Step 3: Integrate into configToNodes or as a post-processing step** + +After `configToNodes()` creates all nodes, call `computeFileGroups()` to generate group nodes. Insert them into the nodes array with `type: 'fileGroup'`. + +**Step 4: Register FileGroupNode in node type registry** + +**Step 5: Write tests** + +```typescript +describe('computeFileGroups', () => { + it('creates one group per unique sourceFile'); + it('does not create groups when only one source file'); + it('assigns distinct colors to each group'); + it('computes bounds from child node positions'); + it('handles nodes with no sourceFile (main file group)'); +}); +``` + +**Step 6: Commit** + +--- + +### Task 11: E2E Visual Validation Tests + +**Files:** +- Modify: `e2e/editor.spec.ts` — add multi-file visual tests + +**Step 1: Create a test that loads a multi-file config and checks visual rendering** + +```typescript +test('multi-file config shows file group boundaries', async ({ page }) => { + // Load domain-split config + // Check for file group nodes with dashed borders + // Check for distinct background colors + // Check for file name labels +}); + +test('clicking a node highlights YAML in side-pane', async ({ page }) => { + // Enable showYamlPane + // Load multi-file config + // Click a node + // Check YAML pane shows correct file tab and highlighted lines +}); + +test('clicking YAML line selects node on canvas', async ({ page }) => { + // Enable showYamlPane + // Load multi-file config + // Click a line in the YAML pane + // Check the corresponding node is selected on canvas +}); +``` + +**Step 2: Commit** + +--- + +### Task 12: IDE Plugin Bridge Updates (Design Only) + +**NOTE:** This task describes changes needed in workflow-vscode and workflow-jetbrains repos. The implementation is tracked separately. + +**workflow-vscode changes:** +- Update webview bridge to handle `navigateToSource` with `filePath` parameter +- When filePath is non-null, open the file in a text editor tab and go to the line +- Send `navigateToNode` messages when user clicks in YAML in the IDE text editor +- Listen for `fileChanged` messages to hot-reload when files are edited externally + +**workflow-jetbrains changes:** +- Update JCEF bridge message handler for `navigateToSource` with `filePath` +- Use `FileEditorManager.openFile()` to navigate to the correct file + line +- Send `navigateToNode` messages from `CaretListener` when cursor moves in YAML +- File watcher already exists; extend to send `fileChanged` to webview + +**Commit:** N/A (tracked in other repos) + +--- + +## Summary of Deliverables + +| Task | Type | Files | +|------|------|-------| +| 1-3 | Test fixtures | 17 new YAML files across 3 fixture sets | +| 4-6 | Serialization tests | 3 new test files (~30 test cases each) | +| 7 | YAML line map | Extended yamlLineMap.ts + new test file | +| 8 | Navigation hooks | Updated types + new navigation utils | +| 9 | YAML side-pane | 3 new components + component tests | +| 10 | File boundaries | New FileGroupNode + fileGroups utils | +| 11 | E2E tests | Extended Playwright tests | +| 12 | IDE bridge design | Documentation only (impl in other repos) | diff --git a/src/utils/serialization-multifile-domain.test.ts b/src/utils/serialization-multifile-domain.test.ts new file mode 100644 index 0000000..3d4931a --- /dev/null +++ b/src/utils/serialization-multifile-domain.test.ts @@ -0,0 +1,213 @@ +import { describe, it, expect } from 'vitest'; +import { configToNodes, exportToFiles, resolveImports } from './serialization.ts'; +import { MODULE_TYPE_MAP } from '../types/workflow.ts'; +import { readFileSync } from 'fs'; +import { resolve as resolveFsPath } from 'path'; + +/** Load a fixture file from test-fixtures/multifile-domain/. */ +function loadFixture(name: string): string { + return readFileSync( + resolveFsPath(__dirname, '../../test-fixtures/multifile-domain', name), + 'utf-8', + ); +} + +const FIXTURE_APP = loadFixture('app.yaml'); +const FIXTURE_AUTH = loadFixture('domains/auth.yaml'); +const FIXTURE_BILLING = loadFixture('domains/billing.yaml'); +const FIXTURE_NOTIFICATIONS = loadFixture('domains/notifications.yaml'); +const FIXTURE_INFRA = loadFixture('shared/infra.yaml'); + +function makeResolver(files: Record) { + return async (path: string): Promise => files[path] ?? null; +} + +const ALL_FILES: Record = { + 'domains/auth.yaml': FIXTURE_AUTH, + 'domains/billing.yaml': FIXTURE_BILLING, + 'domains/notifications.yaml': FIXTURE_NOTIFICATIONS, + 'shared/infra.yaml': FIXTURE_INFRA, +}; + +describe('domain-split multi-file config — resolveImports', () => { + const resolver = makeResolver(ALL_FILES); + + it('resolves all modules across domain files and shared infra', async () => { + const { config, error } = await resolveImports(FIXTURE_APP, resolver); + expect(error).toBeUndefined(); + const names = config.modules.map((m) => m.name); + // auth domain + expect(names).toContain('auth-db'); + expect(names).toContain('auth-cache'); + // billing domain + expect(names).toContain('billing-db'); + expect(names).toContain('stripe'); + // notifications domain + expect(names).toContain('email-svc'); + expect(names).toContain('sms-svc'); + // shared infra + expect(names).toContain('http-server'); + expect(names).toContain('router'); + expect(names).toContain('logger'); + }); + + it('assigns correct sourceFile for every module', async () => { + const { sourceMap } = await resolveImports(FIXTURE_APP, resolver); + expect(sourceMap.get('auth-db')).toBe('domains/auth.yaml'); + expect(sourceMap.get('auth-cache')).toBe('domains/auth.yaml'); + expect(sourceMap.get('billing-db')).toBe('domains/billing.yaml'); + expect(sourceMap.get('stripe')).toBe('domains/billing.yaml'); + expect(sourceMap.get('email-svc')).toBe('domains/notifications.yaml'); + expect(sourceMap.get('sms-svc')).toBe('domains/notifications.yaml'); + expect(sourceMap.get('http-server')).toBe('shared/infra.yaml'); + expect(sourceMap.get('router')).toBe('shared/infra.yaml'); + expect(sourceMap.get('logger')).toBe('shared/infra.yaml'); + }); + + it('tracks all pipelines in sourceMap', async () => { + const { sourceMap } = await resolveImports(FIXTURE_APP, resolver); + expect(sourceMap.get('pipeline:login')).toBe('domains/auth.yaml'); + expect(sourceMap.get('pipeline:register')).toBe('domains/auth.yaml'); + expect(sourceMap.get('pipeline:charge')).toBe('domains/billing.yaml'); + expect(sourceMap.get('pipeline:refund')).toBe('domains/billing.yaml'); + expect(sourceMap.get('pipeline:send-email')).toBe('domains/notifications.yaml'); + expect(sourceMap.get('pipeline:send-sms')).toBe('domains/notifications.yaml'); + }); + + it('merges workflows from main file', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + expect(config.workflows).toHaveProperty('http'); + }); + + it('preserves application name and version', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + expect(config.name).toBe('my-platform'); + expect(config.version).toBe('3.0.0'); + }); + + it('does not duplicate modules', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + const names = config.modules.map((m) => m.name); + const unique = new Set(names); + expect(names.length).toBe(unique.size); + }); +}); + +describe('domain-split multi-file config — exportToFiles round-trip', () => { + const resolver = makeResolver(ALL_FILES); + + it('routes modules to correct domain files', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + const authYaml = fileMap.get('domains/auth.yaml')!; + expect(authYaml).toContain('auth-db'); + expect(authYaml).toContain('auth-cache'); + + const billingYaml = fileMap.get('domains/billing.yaml')!; + expect(billingYaml).toContain('billing-db'); + expect(billingYaml).toContain('stripe'); + + const notifyYaml = fileMap.get('domains/notifications.yaml')!; + expect(notifyYaml).toContain('email-svc'); + expect(notifyYaml).toContain('sms-svc'); + + const infraYaml = fileMap.get('shared/infra.yaml')!; + expect(infraYaml).toContain('http-server'); + expect(infraYaml).toContain('router'); + expect(infraYaml).toContain('logger'); + }); + + it('routes pipelines to correct domain files', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + const authYaml = fileMap.get('domains/auth.yaml')!; + expect(authYaml).toContain('login'); + expect(authYaml).toContain('register'); + + const billingYaml = fileMap.get('domains/billing.yaml')!; + expect(billingYaml).toContain('charge'); + expect(billingYaml).toContain('refund'); + + const notifyYaml = fileMap.get('domains/notifications.yaml')!; + expect(notifyYaml).toContain('send-email'); + expect(notifyYaml).toContain('send-sms'); + }); + + it('main file has imports but no domain modules or pipelines', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + const mainYaml = fileMap.get(null)!; + + expect(mainYaml).toContain('imports:'); + expect(mainYaml).toContain('workflows:'); + // All modules belong to imported files, so main file modules list is empty + expect(mainYaml).toMatch(/^modules:\s*\[\]/m); + // No pipelines in main file + expect(mainYaml).not.toMatch(/^pipelines:/m); + }); + + it('no cross-file bleed — auth modules do not appear in billing file', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + const billingYaml = fileMap.get('domains/billing.yaml')!; + expect(billingYaml).not.toContain('auth-db'); + expect(billingYaml).not.toContain('auth-cache'); + + const authYaml = fileMap.get('domains/auth.yaml')!; + expect(authYaml).not.toContain('billing-db'); + expect(authYaml).not.toContain('stripe'); + }); + + it('editing a domain module keeps it in its domain file', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + + // Simulate renaming auth-db to auth-database + const updatedConfig = { + ...config, + modules: config.modules.map((m) => + m.name === 'auth-db' ? { ...m, name: 'auth-database' } : m, + ), + }; + const updatedSourceMap = new Map(sourceMap); + updatedSourceMap.delete('auth-db'); + updatedSourceMap.set('auth-database', 'domains/auth.yaml'); + + const fileMap = exportToFiles(updatedConfig, updatedSourceMap); + expect(fileMap.get('domains/auth.yaml')).toContain('auth-database'); + expect(fileMap.get(null)).not.toContain('auth-database'); + }); +}); + +describe('domain-split multi-file config — configToNodes', () => { + const resolver = makeResolver(ALL_FILES); + + it('creates nodes for all modules with correct sourceFile', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const { nodes } = configToNodes(config, MODULE_TYPE_MAP, sourceMap); + + // Module nodes (non-synthesized) + const moduleNodes = nodes.filter((n) => !n.data.synthesized); + expect(moduleNodes.length).toBe(9); // 2+2+2+3 modules across all files + + const authDbNode = moduleNodes.find((n) => n.data.label === 'auth-db'); + expect(authDbNode?.data.sourceFile).toBe('domains/auth.yaml'); + + const httpServerNode = moduleNodes.find((n) => n.data.label === 'http-server'); + expect(httpServerNode?.data.sourceFile).toBe('shared/infra.yaml'); + }); + + it('creates edges for HTTP routes connecting to pipeline handlers', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const { edges } = configToNodes(config, MODULE_TYPE_MAP, sourceMap); + + // Should have http-route edges connecting routes to pipeline handlers + const routeEdges = edges.filter((e) => { + const data = e.data as Record | undefined; + return data?.edgeType === 'http-route'; + }); + expect(routeEdges.length).toBeGreaterThan(0); + }); +}); diff --git a/src/utils/serialization-multifile-layers.test.ts b/src/utils/serialization-multifile-layers.test.ts new file mode 100644 index 0000000..9510050 --- /dev/null +++ b/src/utils/serialization-multifile-layers.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from 'vitest'; +import { configToNodes, exportToFiles, resolveImports } from './serialization.ts'; +import { MODULE_TYPE_MAP } from '../types/workflow.ts'; +import { readFileSync } from 'fs'; +import { resolve as resolveFsPath } from 'path'; + +/** Load a fixture file from test-fixtures/multifile-layers/. */ +function loadFixture(name: string): string { + return readFileSync( + resolveFsPath(__dirname, '../../test-fixtures/multifile-layers', name), + 'utf-8', + ); +} + +const FIXTURE_APP = loadFixture('app.yaml'); +const FIXTURE_INFRA = loadFixture('layers/infrastructure.yaml'); +const FIXTURE_MIDDLEWARE = loadFixture('layers/middleware.yaml'); +const FIXTURE_SERVICES = loadFixture('layers/services.yaml'); +const FIXTURE_API = loadFixture('layers/api.yaml'); + +function makeResolver(files: Record) { + return async (path: string): Promise => files[path] ?? null; +} + +const ALL_FILES: Record = { + 'layers/infrastructure.yaml': FIXTURE_INFRA, + 'layers/middleware.yaml': FIXTURE_MIDDLEWARE, + 'layers/services.yaml': FIXTURE_SERVICES, + 'layers/api.yaml': FIXTURE_API, +}; + +describe('layer-split multi-file config — resolveImports', () => { + const resolver = makeResolver(ALL_FILES); + + it('resolves modules from infrastructure and api layers', async () => { + const { config, error } = await resolveImports(FIXTURE_APP, resolver); + expect(error).toBeUndefined(); + const names = config.modules.map((m) => m.name); + // infrastructure layer + expect(names).toContain('primary-db'); + expect(names).toContain('cache'); + expect(names).toContain('message-queue'); + expect(names).toContain('logger'); + // api layer + expect(names).toContain('http-server'); + expect(names).toContain('router'); + }); + + it('resolves pipelines from middleware and services layers', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + const pipelineNames = Object.keys(config.pipelines ?? {}); + // middleware layer + expect(pipelineNames).toContain('auth-middleware'); + expect(pipelineNames).toContain('rate-limit'); + expect(pipelineNames).toContain('cors'); + // services layer + expect(pipelineNames).toContain('user-service'); + expect(pipelineNames).toContain('order-service'); + expect(pipelineNames).toContain('product-service'); + }); + + it('sourceMap assigns correct layer file for each module', async () => { + const { sourceMap } = await resolveImports(FIXTURE_APP, resolver); + expect(sourceMap.get('primary-db')).toBe('layers/infrastructure.yaml'); + expect(sourceMap.get('cache')).toBe('layers/infrastructure.yaml'); + expect(sourceMap.get('message-queue')).toBe('layers/infrastructure.yaml'); + expect(sourceMap.get('logger')).toBe('layers/infrastructure.yaml'); + expect(sourceMap.get('http-server')).toBe('layers/api.yaml'); + expect(sourceMap.get('router')).toBe('layers/api.yaml'); + }); + + it('sourceMap assigns correct layer file for each pipeline', async () => { + const { sourceMap } = await resolveImports(FIXTURE_APP, resolver); + expect(sourceMap.get('pipeline:auth-middleware')).toBe('layers/middleware.yaml'); + expect(sourceMap.get('pipeline:rate-limit')).toBe('layers/middleware.yaml'); + expect(sourceMap.get('pipeline:cors')).toBe('layers/middleware.yaml'); + expect(sourceMap.get('pipeline:user-service')).toBe('layers/services.yaml'); + expect(sourceMap.get('pipeline:order-service')).toBe('layers/services.yaml'); + expect(sourceMap.get('pipeline:product-service')).toBe('layers/services.yaml'); + }); + + it('preserves application name and version', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + expect(config.name).toBe('layered-app'); + expect(config.version).toBe('1.0.0'); + }); + + it('does not duplicate modules', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + const names = config.modules.map((m) => m.name); + expect(names.length).toBe(new Set(names).size); + }); +}); + +describe('layer-split multi-file config — exportToFiles round-trip', () => { + const resolver = makeResolver(ALL_FILES); + + it('modules stay in their layer file', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + const infraYaml = fileMap.get('layers/infrastructure.yaml')!; + expect(infraYaml).toContain('primary-db'); + expect(infraYaml).toContain('cache'); + expect(infraYaml).toContain('message-queue'); + expect(infraYaml).toContain('logger'); + + const apiYaml = fileMap.get('layers/api.yaml')!; + expect(apiYaml).toContain('http-server'); + expect(apiYaml).toContain('router'); + }); + + it('pipelines stay in their layer file', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + const mwYaml = fileMap.get('layers/middleware.yaml')!; + expect(mwYaml).toContain('auth-middleware'); + expect(mwYaml).toContain('rate-limit'); + expect(mwYaml).toContain('cors'); + + const svcYaml = fileMap.get('layers/services.yaml')!; + expect(svcYaml).toContain('user-service'); + expect(svcYaml).toContain('order-service'); + expect(svcYaml).toContain('product-service'); + }); + + it('main file only has application metadata and imports', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + const mainYaml = fileMap.get(null)!; + + expect(mainYaml).toContain('imports:'); + expect(mainYaml).toMatch(/^modules:\s*\[\]/m); + expect(mainYaml).not.toMatch(/^pipelines:/m); + }); + + it('no cross-layer bleed', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + // Infrastructure modules should not appear in middleware file + const mwYaml = fileMap.get('layers/middleware.yaml')!; + expect(mwYaml).not.toContain('primary-db'); + expect(mwYaml).not.toContain('http-server'); + + // Middleware pipelines should not appear in services file + const svcYaml = fileMap.get('layers/services.yaml')!; + expect(svcYaml).not.toContain('auth-middleware'); + expect(svcYaml).not.toContain('rate-limit'); + }); +}); + +describe('layer-split multi-file config — configToNodes', () => { + const resolver = makeResolver(ALL_FILES); + + it('creates module nodes with correct sourceFile', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const { nodes } = configToNodes(config, MODULE_TYPE_MAP, sourceMap); + + const moduleNodes = nodes.filter((n) => !n.data.synthesized); + expect(moduleNodes.length).toBe(6); // 4 infra + 2 api + + const dbNode = moduleNodes.find((n) => n.data.label === 'primary-db'); + expect(dbNode?.data.sourceFile).toBe('layers/infrastructure.yaml'); + }); +}); diff --git a/src/utils/serialization-multifile-nested.test.ts b/src/utils/serialization-multifile-nested.test.ts new file mode 100644 index 0000000..a183ead --- /dev/null +++ b/src/utils/serialization-multifile-nested.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; +import { configToNodes, exportToFiles, resolveImports } from './serialization.ts'; +import { MODULE_TYPE_MAP } from '../types/workflow.ts'; +import { readFileSync } from 'fs'; +import { resolve as resolveFsPath } from 'path'; + +/** Load a fixture file from test-fixtures/multifile-nested/. */ +function loadFixture(name: string): string { + return readFileSync( + resolveFsPath(__dirname, '../../test-fixtures/multifile-nested', name), + 'utf-8', + ); +} + +const FIXTURE_APP = loadFixture('app.yaml'); +const FIXTURE_PLATFORM = loadFixture('platform/platform.yaml'); +const FIXTURE_CORE = loadFixture('platform/core/core.yaml'); +const FIXTURE_DATABASE = loadFixture('platform/core/database.yaml'); +const FIXTURE_CACHE = loadFixture('platform/core/cache.yaml'); +const FIXTURE_FEATURES = loadFixture('platform/features/features.yaml'); +const FIXTURE_AUTH = loadFixture('platform/features/auth.yaml'); +const FIXTURE_PAYMENTS = loadFixture('platform/features/payments.yaml'); + +function makeResolver(files: Record) { + return async (path: string): Promise => files[path] ?? null; +} + +const ALL_FILES: Record = { + 'platform/platform.yaml': FIXTURE_PLATFORM, + 'platform/core/core.yaml': FIXTURE_CORE, + 'platform/core/database.yaml': FIXTURE_DATABASE, + 'platform/core/cache.yaml': FIXTURE_CACHE, + 'platform/features/features.yaml': FIXTURE_FEATURES, + 'platform/features/auth.yaml': FIXTURE_AUTH, + 'platform/features/payments.yaml': FIXTURE_PAYMENTS, +}; + +describe('nested-directory multi-file config — resolveImports', () => { + const resolver = makeResolver(ALL_FILES); + + it('resolves modules across 3+ levels of nesting', async () => { + const { config, error } = await resolveImports(FIXTURE_APP, resolver); + expect(error).toBeUndefined(); + const names = config.modules.map((m) => m.name); + // From platform/core/database.yaml (3 levels deep) + expect(names).toContain('primary-db'); + expect(names).toContain('replica-db'); + // From platform/core/cache.yaml (3 levels deep) + expect(names).toContain('redis-cache'); + // From platform/features/auth.yaml (3 levels deep) + expect(names).toContain('auth-service'); + // From platform/features/payments.yaml (3 levels deep) + expect(names).toContain('payment-gateway'); + }); + + it('sourceMap uses full relative paths from root', async () => { + const { sourceMap } = await resolveImports(FIXTURE_APP, resolver); + expect(sourceMap.get('primary-db')).toBe('platform/core/database.yaml'); + expect(sourceMap.get('replica-db')).toBe('platform/core/database.yaml'); + expect(sourceMap.get('redis-cache')).toBe('platform/core/cache.yaml'); + expect(sourceMap.get('auth-service')).toBe('platform/features/auth.yaml'); + expect(sourceMap.get('payment-gateway')).toBe('platform/features/payments.yaml'); + }); + + it('tracks pipelines from nested feature files', async () => { + const { sourceMap } = await resolveImports(FIXTURE_APP, resolver); + expect(sourceMap.get('pipeline:login')).toBe('platform/features/auth.yaml'); + expect(sourceMap.get('pipeline:register')).toBe('platform/features/auth.yaml'); + expect(sourceMap.get('pipeline:charge')).toBe('platform/features/payments.yaml'); + expect(sourceMap.get('pipeline:refund')).toBe('platform/features/payments.yaml'); + }); + + it('handles intermediate aggregator files with no modules', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + // platform.yaml, core.yaml, features.yaml are pure aggregators — no modules of their own + // All 5 modules come from leaf files only + expect(config.modules.length).toBe(5); + }); + + it('preserves application name and version', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + expect(config.name).toBe('nested-platform'); + expect(config.version).toBe('2.0.0'); + }); + + it('does not duplicate modules', async () => { + const { config } = await resolveImports(FIXTURE_APP, resolver); + const names = config.modules.map((m) => m.name); + expect(names.length).toBe(new Set(names).size); + }); +}); + +describe('nested-directory multi-file config — exportToFiles round-trip', () => { + const resolver = makeResolver(ALL_FILES); + + it('leaf modules stay in leaf files', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + expect(fileMap.get('platform/core/database.yaml')).toContain('primary-db'); + expect(fileMap.get('platform/core/database.yaml')).toContain('replica-db'); + expect(fileMap.get('platform/core/cache.yaml')).toContain('redis-cache'); + expect(fileMap.get('platform/features/auth.yaml')).toContain('auth-service'); + expect(fileMap.get('platform/features/payments.yaml')).toContain('payment-gateway'); + }); + + it('pipelines stay in their feature files', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + const authYaml = fileMap.get('platform/features/auth.yaml')!; + expect(authYaml).toContain('login'); + expect(authYaml).toContain('register'); + + const payYaml = fileMap.get('platform/features/payments.yaml')!; + expect(payYaml).toContain('charge'); + expect(payYaml).toContain('refund'); + }); + + it('main file references only top-level import', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + const mainYaml = fileMap.get(null)!; + + expect(mainYaml).toContain('imports:'); + expect(mainYaml).toMatch(/^modules:\s*\[\]/m); + // All modules are in imported files + expect(mainYaml).not.toContain('primary-db'); + expect(mainYaml).not.toContain('auth-service'); + }); + + it('no cross-file bleed between feature files', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const fileMap = exportToFiles(config, sourceMap); + + const authYaml = fileMap.get('platform/features/auth.yaml')!; + expect(authYaml).not.toContain('payment-gateway'); + expect(authYaml).not.toContain('charge'); + + const payYaml = fileMap.get('platform/features/payments.yaml')!; + expect(payYaml).not.toContain('auth-service'); + expect(payYaml).not.toContain('login'); + }); +}); + +describe('nested-directory multi-file config — error handling', () => { + it('missing leaf file reports error but resolves siblings', async () => { + // Remove payments.yaml from the resolver + const partialFiles = { ...ALL_FILES }; + delete partialFiles['platform/features/payments.yaml']; + const resolver = makeResolver(partialFiles); + + const { config, error } = await resolveImports(FIXTURE_APP, resolver); + expect(error).toBeTruthy(); + // Auth modules and pipelines from auth.yaml should still resolve + const names = config.modules.map((m) => m.name); + expect(names).toContain('auth-service'); + expect(names).toContain('primary-db'); + // Payment modules should not be present + expect(names).not.toContain('payment-gateway'); + }); +}); + +describe('nested-directory multi-file config — configToNodes', () => { + const resolver = makeResolver(ALL_FILES); + + it('creates module nodes with full nested paths as sourceFile', async () => { + const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); + const { nodes } = configToNodes(config, MODULE_TYPE_MAP, sourceMap); + + const moduleNodes = nodes.filter((n) => !n.data.synthesized); + expect(moduleNodes.length).toBe(5); + + const dbNode = moduleNodes.find((n) => n.data.label === 'primary-db'); + expect(dbNode?.data.sourceFile).toBe('platform/core/database.yaml'); + + const authNode = moduleNodes.find((n) => n.data.label === 'auth-service'); + expect(authNode?.data.sourceFile).toBe('platform/features/auth.yaml'); + }); +}); diff --git a/test-fixtures/multifile-domain/app.yaml b/test-fixtures/multifile-domain/app.yaml new file mode 100644 index 0000000..300fed7 --- /dev/null +++ b/test-fixtures/multifile-domain/app.yaml @@ -0,0 +1,30 @@ +application: + name: my-platform + version: 3.0.0 + +imports: + - domains/auth.yaml + - domains/billing.yaml + - domains/notifications.yaml + - shared/infra.yaml + +workflows: + http: + server: http-server + router: router + routes: + - method: POST + path: /api/auth/login + handler: login + - method: POST + path: /api/auth/register + handler: register + - method: POST + path: /api/billing/charge + handler: charge + - method: POST + path: /api/billing/refund + handler: refund + - method: POST + path: /api/notify/email + handler: send-email diff --git a/test-fixtures/multifile-domain/domains/auth.yaml b/test-fixtures/multifile-domain/domains/auth.yaml new file mode 100644 index 0000000..d372302 --- /dev/null +++ b/test-fixtures/multifile-domain/domains/auth.yaml @@ -0,0 +1,34 @@ +modules: + - name: auth-db + type: database.postgres + config: + host: localhost + port: 5432 + database: auth + - name: auth-cache + type: nosql.redis + config: + host: localhost + port: 6379 + +pipelines: + login: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: authenticate + type: step.auth_validate + - name: respond + type: step.json_response + register: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: insert + type: step.db_exec + - name: respond + type: step.json_response diff --git a/test-fixtures/multifile-domain/domains/billing.yaml b/test-fixtures/multifile-domain/domains/billing.yaml new file mode 100644 index 0000000..1671d45 --- /dev/null +++ b/test-fixtures/multifile-domain/domains/billing.yaml @@ -0,0 +1,37 @@ +modules: + - name: billing-db + type: database.postgres + config: + host: localhost + port: 5432 + database: billing + - name: stripe + type: integration.http_client + config: + base_url: https://api.stripe.com + +pipelines: + charge: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: create-charge + type: step.http_request + - name: record + type: step.db_exec + - name: respond + type: step.json_response + refund: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: process-refund + type: step.http_request + - name: record + type: step.db_exec + - name: respond + type: step.json_response diff --git a/test-fixtures/multifile-domain/domains/notifications.yaml b/test-fixtures/multifile-domain/domains/notifications.yaml new file mode 100644 index 0000000..89d1f93 --- /dev/null +++ b/test-fixtures/multifile-domain/domains/notifications.yaml @@ -0,0 +1,31 @@ +modules: + - name: email-svc + type: integration.http_client + config: + base_url: https://api.sendgrid.com + - name: sms-svc + type: integration.http_client + config: + base_url: https://api.twilio.com + +pipelines: + send-email: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: send + type: step.http_request + - name: respond + type: step.json_response + send-sms: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: send + type: step.http_request + - name: respond + type: step.json_response diff --git a/test-fixtures/multifile-domain/shared/infra.yaml b/test-fixtures/multifile-domain/shared/infra.yaml new file mode 100644 index 0000000..c9fd579 --- /dev/null +++ b/test-fixtures/multifile-domain/shared/infra.yaml @@ -0,0 +1,12 @@ +modules: + - name: http-server + type: http.server + config: + port: 8080 + - name: router + type: http.router + config: {} + - name: logger + type: observability.logger + config: + level: info diff --git a/test-fixtures/multifile-layers/app.yaml b/test-fixtures/multifile-layers/app.yaml new file mode 100644 index 0000000..29180d3 --- /dev/null +++ b/test-fixtures/multifile-layers/app.yaml @@ -0,0 +1,9 @@ +application: + name: layered-app + version: 1.0.0 + +imports: + - layers/infrastructure.yaml + - layers/middleware.yaml + - layers/services.yaml + - layers/api.yaml diff --git a/test-fixtures/multifile-layers/layers/api.yaml b/test-fixtures/multifile-layers/layers/api.yaml new file mode 100644 index 0000000..0d2709d --- /dev/null +++ b/test-fixtures/multifile-layers/layers/api.yaml @@ -0,0 +1,23 @@ +modules: + - name: http-server + type: http.server + config: + port: 8080 + - name: router + type: http.router + config: {} + +workflows: + http: + server: http-server + router: router + routes: + - method: GET + path: /api/users + handler: user-service + - method: POST + path: /api/orders + handler: order-service + - method: GET + path: /api/products + handler: product-service diff --git a/test-fixtures/multifile-layers/layers/infrastructure.yaml b/test-fixtures/multifile-layers/layers/infrastructure.yaml new file mode 100644 index 0000000..264b05f --- /dev/null +++ b/test-fixtures/multifile-layers/layers/infrastructure.yaml @@ -0,0 +1,21 @@ +modules: + - name: primary-db + type: database.postgres + config: + host: localhost + port: 5432 + database: app + - name: cache + type: nosql.redis + config: + host: localhost + port: 6379 + - name: message-queue + type: messaging.rabbitmq + config: + host: localhost + port: 5672 + - name: logger + type: observability.logger + config: + level: info diff --git a/test-fixtures/multifile-layers/layers/middleware.yaml b/test-fixtures/multifile-layers/layers/middleware.yaml new file mode 100644 index 0000000..62de109 --- /dev/null +++ b/test-fixtures/multifile-layers/layers/middleware.yaml @@ -0,0 +1,23 @@ +pipelines: + auth-middleware: + steps: + - name: extract-token + type: step.request_parse + - name: validate-token + type: step.auth_validate + - name: attach-user + type: step.set + rate-limit: + steps: + - name: check-limit + type: step.branch + - name: increment + type: step.set + - name: reject + type: step.json_response + cors: + steps: + - name: check-origin + type: step.branch + - name: set-headers + type: step.set diff --git a/test-fixtures/multifile-layers/layers/services.yaml b/test-fixtures/multifile-layers/layers/services.yaml new file mode 100644 index 0000000..3016aff --- /dev/null +++ b/test-fixtures/multifile-layers/layers/services.yaml @@ -0,0 +1,31 @@ +pipelines: + user-service: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: query + type: step.db_query + - name: respond + type: step.json_response + order-service: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: create-order + type: step.db_exec + - name: publish-event + type: step.publish + - name: respond + type: step.json_response + product-service: + steps: + - name: parse + type: step.request_parse + - name: query + type: step.db_query + - name: respond + type: step.json_response diff --git a/test-fixtures/multifile-nested/app.yaml b/test-fixtures/multifile-nested/app.yaml new file mode 100644 index 0000000..34038ad --- /dev/null +++ b/test-fixtures/multifile-nested/app.yaml @@ -0,0 +1,6 @@ +application: + name: nested-platform + version: 2.0.0 + +imports: + - platform/platform.yaml diff --git a/test-fixtures/multifile-nested/platform/core/cache.yaml b/test-fixtures/multifile-nested/platform/core/cache.yaml new file mode 100644 index 0000000..ffd2c8c --- /dev/null +++ b/test-fixtures/multifile-nested/platform/core/cache.yaml @@ -0,0 +1,6 @@ +modules: + - name: redis-cache + type: nosql.redis + config: + host: localhost + port: 6379 diff --git a/test-fixtures/multifile-nested/platform/core/core.yaml b/test-fixtures/multifile-nested/platform/core/core.yaml new file mode 100644 index 0000000..27a79b1 --- /dev/null +++ b/test-fixtures/multifile-nested/platform/core/core.yaml @@ -0,0 +1,3 @@ +imports: + - database.yaml + - cache.yaml diff --git a/test-fixtures/multifile-nested/platform/core/database.yaml b/test-fixtures/multifile-nested/platform/core/database.yaml new file mode 100644 index 0000000..8a84f53 --- /dev/null +++ b/test-fixtures/multifile-nested/platform/core/database.yaml @@ -0,0 +1,14 @@ +modules: + - name: primary-db + type: database.postgres + config: + host: localhost + port: 5432 + database: platform + - name: replica-db + type: database.postgres + config: + host: replica.localhost + port: 5432 + database: platform + read_only: true diff --git a/test-fixtures/multifile-nested/platform/features/auth.yaml b/test-fixtures/multifile-nested/platform/features/auth.yaml new file mode 100644 index 0000000..56269c9 --- /dev/null +++ b/test-fixtures/multifile-nested/platform/features/auth.yaml @@ -0,0 +1,27 @@ +modules: + - name: auth-service + type: http.server + config: + port: 9001 + +pipelines: + login: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: authenticate + type: step.auth_validate + - name: respond + type: step.json_response + register: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: insert + type: step.db_exec + - name: respond + type: step.json_response diff --git a/test-fixtures/multifile-nested/platform/features/features.yaml b/test-fixtures/multifile-nested/platform/features/features.yaml new file mode 100644 index 0000000..68364d9 --- /dev/null +++ b/test-fixtures/multifile-nested/platform/features/features.yaml @@ -0,0 +1,3 @@ +imports: + - auth.yaml + - payments.yaml diff --git a/test-fixtures/multifile-nested/platform/features/payments.yaml b/test-fixtures/multifile-nested/platform/features/payments.yaml new file mode 100644 index 0000000..90b791c --- /dev/null +++ b/test-fixtures/multifile-nested/platform/features/payments.yaml @@ -0,0 +1,31 @@ +modules: + - name: payment-gateway + type: integration.http_client + config: + base_url: https://api.stripe.com + +pipelines: + charge: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: process + type: step.http_request + - name: record + type: step.db_exec + - name: respond + type: step.json_response + refund: + steps: + - name: parse + type: step.request_parse + - name: validate + type: step.validate + - name: process + type: step.http_request + - name: record + type: step.db_exec + - name: respond + type: step.json_response diff --git a/test-fixtures/multifile-nested/platform/platform.yaml b/test-fixtures/multifile-nested/platform/platform.yaml new file mode 100644 index 0000000..1c007d1 --- /dev/null +++ b/test-fixtures/multifile-nested/platform/platform.yaml @@ -0,0 +1,3 @@ +imports: + - core/core.yaml + - features/features.yaml From 60355fefb400481a97c0d429e5d412c47e17cf63 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:10:56 +0000 Subject: [PATCH 03/37] Fix implementation plan title to not reference specific phases Agent-Logs-Url: https://github.com/GoCodeAlone/workflow-editor/sessions/21f17a33-a494-45ec-acbc-a587750bdb2a Co-authored-by: intel352 <77607+intel352@users.noreply.github.com> --- .../2026-03-26-multifile-config-validation-implementation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-03-26-multifile-config-validation-implementation.md b/docs/plans/2026-03-26-multifile-config-validation-implementation.md index 143473b..0a5608d 100644 --- a/docs/plans/2026-03-26-multifile-config-validation-implementation.md +++ b/docs/plans/2026-03-26-multifile-config-validation-implementation.md @@ -1,4 +1,4 @@ -# Multi-File Config Validation & YAML Side-Pane — Implementation Plan (Phases 1-3) +# Multi-File Config Validation & YAML Side-Pane — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. From 18ae37ba2746e644c39425d49e2529f420337941 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 01:40:54 -0400 Subject: [PATCH 04/37] =?UTF-8?q?fix:=20address=20PR=20#5=20review=20comme?= =?UTF-8?q?nts=20=E2=80=94=20fixture=20handlers,=20test=20assertions,=20ID?= =?UTF-8?q?E=20bridge=20compat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add HTTP handler modules (api.query/api.command) to domain and layers fixtures so route→handler edges are created by extractWorkflowEdges() - Strengthen edge assertion to verify METHOD /path labels, not just edge type existence - Fix misleading test names: layers export includes workflows in main file; nested export flattens import graph to leaf files - Correct YAML file count in implementation plan (17→18) - Make onNavigateToSource backward-compatible via overloaded signature instead of breaking (filePath, line, col) change - Make NavigateToSourceMessage.filePath optional for IDE bridge compat Co-Authored-By: Claude Opus 4.6 (1M context) --- ...3-26-multifile-config-validation-design.md | 15 ++++--- ...tifile-config-validation-implementation.md | 2 +- .../serialization-multifile-domain.test.ts | 42 +++++++++++++++++-- .../serialization-multifile-layers.test.ts | 22 ++++++++-- .../serialization-multifile-nested.test.ts | 12 +++++- test-fixtures/multifile-domain/app.yaml | 10 ++--- .../multifile-domain/domains/auth.yaml | 8 ++++ .../multifile-domain/domains/billing.yaml | 8 ++++ .../domains/notifications.yaml | 4 ++ .../multifile-layers/layers/api.yaml | 18 ++++++-- 10 files changed, 117 insertions(+), 24 deletions(-) diff --git a/docs/plans/2026-03-26-multifile-config-validation-design.md b/docs/plans/2026-03-26-multifile-config-validation-design.md index 1daaa72..ebdd7fe 100644 --- a/docs/plans/2026-03-26-multifile-config-validation-design.md +++ b/docs/plans/2026-03-26-multifile-config-validation-design.md @@ -181,9 +181,12 @@ IDE plugins do NOT use the built-in YAML pane. Instead, they use the **navigatio ```typescript interface WorkflowEditorProps { // ... existing props ... - /** Enhanced: now includes filePath for multi-file navigation. - * Called when user clicks a node — host should navigate to the line in the specified file. */ - onNavigateToSource?: (filePath: string | null, line: number, col: number) => void; + /** Enhanced navigation callback. When filePath is provided, navigate to + * the specified line in that file. Backward-compatible: hosts that only + * handle (line, col) can ignore the first argument. + * Overloaded: (line: number, col: number) => void — legacy single-file + * | (filePath: string | null, line: number, col: number) => void — multi-file */ + onNavigateToSource?: (...args: [number, number] | [string | null, number, number]) => void; /** NEW: Called when the editor wants the host to reveal a specific node. * The host should select the node on canvas (the editor handles this internally, * but the host may also want to update its own UI). */ @@ -191,7 +194,7 @@ interface WorkflowEditorProps { } ``` -**Breaking change mitigation:** The `onNavigateToSource` signature changes from `(line, col)` to `(filePath, line, col)`. Since this is a callback the host provides, the host code already knows its signature. IDE plugins will need to update their bridge code to handle the new `filePath` parameter. +**Backward compatibility:** The `onNavigateToSource` callback uses a discriminated overload: callers detect the arity or first-argument type to distinguish `(line, col)` from `(filePath, line, col)`. This means existing IDE plugins (workflow-vscode, workflow-jetbrains) continue to work without changes. They can adopt the `filePath` parameter incrementally by checking `typeof args[0] === 'string'` in their bridge handlers. ## 4. Visual File Boundaries on Canvas @@ -302,10 +305,10 @@ For IDE plugins that use the webview bridge, add new message types: // Editor → Host (node clicked, navigate to source) interface NavigateToSourceMessage { type: 'navigateToSource'; - filePath: string | null; // null = main file + filePath?: string | null; // optional — omitted for single-file configs line: number; col: number; - nodeName: string; + nodeName?: string; } // Host → Editor (user clicked in YAML, navigate to node) diff --git a/docs/plans/2026-03-26-multifile-config-validation-implementation.md b/docs/plans/2026-03-26-multifile-config-validation-implementation.md index 0a5608d..e678751 100644 --- a/docs/plans/2026-03-26-multifile-config-validation-implementation.md +++ b/docs/plans/2026-03-26-multifile-config-validation-implementation.md @@ -598,7 +598,7 @@ test('clicking YAML line selects node on canvas', async ({ page }) => { | Task | Type | Files | |------|------|-------| -| 1-3 | Test fixtures | 17 new YAML files across 3 fixture sets | +| 1-3 | Test fixtures | 18 new YAML files across 3 fixture sets (domain=5, layers=5, nested=8) | | 4-6 | Serialization tests | 3 new test files (~30 test cases each) | | 7 | YAML line map | Extended yamlLineMap.ts + new test file | | 8 | Navigation hooks | Updated types + new navigation utils | diff --git a/src/utils/serialization-multifile-domain.test.ts b/src/utils/serialization-multifile-domain.test.ts index 3d4931a..73dc415 100644 --- a/src/utils/serialization-multifile-domain.test.ts +++ b/src/utils/serialization-multifile-domain.test.ts @@ -39,12 +39,17 @@ describe('domain-split multi-file config — resolveImports', () => { // auth domain expect(names).toContain('auth-db'); expect(names).toContain('auth-cache'); + expect(names).toContain('login-handler'); + expect(names).toContain('register-handler'); // billing domain expect(names).toContain('billing-db'); expect(names).toContain('stripe'); + expect(names).toContain('charge-handler'); + expect(names).toContain('refund-handler'); // notifications domain expect(names).toContain('email-svc'); expect(names).toContain('sms-svc'); + expect(names).toContain('send-email-handler'); // shared infra expect(names).toContain('http-server'); expect(names).toContain('router'); @@ -55,10 +60,15 @@ describe('domain-split multi-file config — resolveImports', () => { const { sourceMap } = await resolveImports(FIXTURE_APP, resolver); expect(sourceMap.get('auth-db')).toBe('domains/auth.yaml'); expect(sourceMap.get('auth-cache')).toBe('domains/auth.yaml'); + expect(sourceMap.get('login-handler')).toBe('domains/auth.yaml'); + expect(sourceMap.get('register-handler')).toBe('domains/auth.yaml'); expect(sourceMap.get('billing-db')).toBe('domains/billing.yaml'); expect(sourceMap.get('stripe')).toBe('domains/billing.yaml'); + expect(sourceMap.get('charge-handler')).toBe('domains/billing.yaml'); + expect(sourceMap.get('refund-handler')).toBe('domains/billing.yaml'); expect(sourceMap.get('email-svc')).toBe('domains/notifications.yaml'); expect(sourceMap.get('sms-svc')).toBe('domains/notifications.yaml'); + expect(sourceMap.get('send-email-handler')).toBe('domains/notifications.yaml'); expect(sourceMap.get('http-server')).toBe('shared/infra.yaml'); expect(sourceMap.get('router')).toBe('shared/infra.yaml'); expect(sourceMap.get('logger')).toBe('shared/infra.yaml'); @@ -103,14 +113,19 @@ describe('domain-split multi-file config — exportToFiles round-trip', () => { const authYaml = fileMap.get('domains/auth.yaml')!; expect(authYaml).toContain('auth-db'); expect(authYaml).toContain('auth-cache'); + expect(authYaml).toContain('login-handler'); + expect(authYaml).toContain('register-handler'); const billingYaml = fileMap.get('domains/billing.yaml')!; expect(billingYaml).toContain('billing-db'); expect(billingYaml).toContain('stripe'); + expect(billingYaml).toContain('charge-handler'); + expect(billingYaml).toContain('refund-handler'); const notifyYaml = fileMap.get('domains/notifications.yaml')!; expect(notifyYaml).toContain('email-svc'); expect(notifyYaml).toContain('sms-svc'); + expect(notifyYaml).toContain('send-email-handler'); const infraYaml = fileMap.get('shared/infra.yaml')!; expect(infraYaml).toContain('http-server'); @@ -155,10 +170,14 @@ describe('domain-split multi-file config — exportToFiles round-trip', () => { const billingYaml = fileMap.get('domains/billing.yaml')!; expect(billingYaml).not.toContain('auth-db'); expect(billingYaml).not.toContain('auth-cache'); + expect(billingYaml).not.toContain('login-handler'); + expect(billingYaml).not.toContain('register-handler'); const authYaml = fileMap.get('domains/auth.yaml')!; expect(authYaml).not.toContain('billing-db'); expect(authYaml).not.toContain('stripe'); + expect(authYaml).not.toContain('charge-handler'); + expect(authYaml).not.toContain('refund-handler'); }); it('editing a domain module keeps it in its domain file', async () => { @@ -190,7 +209,7 @@ describe('domain-split multi-file config — configToNodes', () => { // Module nodes (non-synthesized) const moduleNodes = nodes.filter((n) => !n.data.synthesized); - expect(moduleNodes.length).toBe(9); // 2+2+2+3 modules across all files + expect(moduleNodes.length).toBe(14); // 4+4+3+3 modules across all files const authDbNode = moduleNodes.find((n) => n.data.label === 'auth-db'); expect(authDbNode?.data.sourceFile).toBe('domains/auth.yaml'); @@ -199,15 +218,30 @@ describe('domain-split multi-file config — configToNodes', () => { expect(httpServerNode?.data.sourceFile).toBe('shared/infra.yaml'); }); - it('creates edges for HTTP routes connecting to pipeline handlers', async () => { + it('creates edges for HTTP routes connecting to handler modules', async () => { const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); const { edges } = configToNodes(config, MODULE_TYPE_MAP, sourceMap); - // Should have http-route edges connecting routes to pipeline handlers + // Filter for http-route edges (includes server→router "http" edge AND route→handler edges) const routeEdges = edges.filter((e) => { const data = e.data as Record | undefined; return data?.edgeType === 'http-route'; }); - expect(routeEdges.length).toBeGreaterThan(0); + + // Must have the server→router edge plus actual route→handler edges + expect(routeEdges.length).toBeGreaterThan(1); + + // Route→handler edges carry "METHOD /path" labels (not just "http") + const handlerRouteEdges = routeEdges.filter((e) => { + const label = e.label as string | undefined; + return label && /^[A-Z]+ \//.test(label); + }); + expect(handlerRouteEdges.length).toBeGreaterThan(0); + + // Verify specific cross-file routes exist + const labels = handlerRouteEdges.map((e) => e.label); + expect(labels).toContain('POST /api/auth/login'); + expect(labels).toContain('POST /api/billing/charge'); + expect(labels).toContain('POST /api/notify/email'); }); }); diff --git a/src/utils/serialization-multifile-layers.test.ts b/src/utils/serialization-multifile-layers.test.ts index 9510050..5b4b3af 100644 --- a/src/utils/serialization-multifile-layers.test.ts +++ b/src/utils/serialization-multifile-layers.test.ts @@ -44,6 +44,9 @@ describe('layer-split multi-file config — resolveImports', () => { // api layer expect(names).toContain('http-server'); expect(names).toContain('router'); + expect(names).toContain('user-handler'); + expect(names).toContain('order-handler'); + expect(names).toContain('product-handler'); }); it('resolves pipelines from middleware and services layers', async () => { @@ -67,6 +70,9 @@ describe('layer-split multi-file config — resolveImports', () => { expect(sourceMap.get('logger')).toBe('layers/infrastructure.yaml'); expect(sourceMap.get('http-server')).toBe('layers/api.yaml'); expect(sourceMap.get('router')).toBe('layers/api.yaml'); + expect(sourceMap.get('user-handler')).toBe('layers/api.yaml'); + expect(sourceMap.get('order-handler')).toBe('layers/api.yaml'); + expect(sourceMap.get('product-handler')).toBe('layers/api.yaml'); }); it('sourceMap assigns correct layer file for each pipeline', async () => { @@ -108,6 +114,9 @@ describe('layer-split multi-file config — exportToFiles round-trip', () => { const apiYaml = fileMap.get('layers/api.yaml')!; expect(apiYaml).toContain('http-server'); expect(apiYaml).toContain('router'); + expect(apiYaml).toContain('user-handler'); + expect(apiYaml).toContain('order-handler'); + expect(apiYaml).toContain('product-handler'); }); it('pipelines stay in their layer file', async () => { @@ -125,14 +134,14 @@ describe('layer-split multi-file config — exportToFiles round-trip', () => { expect(svcYaml).toContain('product-service'); }); - it('main file only has application metadata and imports', async () => { + it('main file has application metadata, imports, and merged workflows', async () => { const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); const fileMap = exportToFiles(config, sourceMap); const mainYaml = fileMap.get(null)!; expect(mainYaml).toContain('imports:'); expect(mainYaml).toMatch(/^modules:\s*\[\]/m); - expect(mainYaml).not.toMatch(/^pipelines:/m); + expect(mainYaml).toMatch(/^workflows:/m); }); it('no cross-layer bleed', async () => { @@ -148,6 +157,13 @@ describe('layer-split multi-file config — exportToFiles round-trip', () => { const svcYaml = fileMap.get('layers/services.yaml')!; expect(svcYaml).not.toContain('auth-middleware'); expect(svcYaml).not.toContain('rate-limit'); + + // Infrastructure modules should not appear in api file + const apiYaml = fileMap.get('layers/api.yaml')!; + expect(apiYaml).not.toContain('primary-db'); + expect(apiYaml).not.toContain('cache'); + expect(apiYaml).not.toContain('message-queue'); + expect(apiYaml).not.toContain('logger'); }); }); @@ -159,7 +175,7 @@ describe('layer-split multi-file config — configToNodes', () => { const { nodes } = configToNodes(config, MODULE_TYPE_MAP, sourceMap); const moduleNodes = nodes.filter((n) => !n.data.synthesized); - expect(moduleNodes.length).toBe(6); // 4 infra + 2 api + expect(moduleNodes.length).toBe(9); // 4 infra + 5 api const dbNode = moduleNodes.find((n) => n.data.label === 'primary-db'); expect(dbNode?.data.sourceFile).toBe('layers/infrastructure.yaml'); diff --git a/src/utils/serialization-multifile-nested.test.ts b/src/utils/serialization-multifile-nested.test.ts index a183ead..305df13 100644 --- a/src/utils/serialization-multifile-nested.test.ts +++ b/src/utils/serialization-multifile-nested.test.ts @@ -117,14 +117,22 @@ describe('nested-directory multi-file config — exportToFiles round-trip', () = expect(payYaml).toContain('refund'); }); - it('main file references only top-level import', async () => { + it('main file imports leaf files that contain modules and pipelines', async () => { const { config, sourceMap } = await resolveImports(FIXTURE_APP, resolver); const fileMap = exportToFiles(config, sourceMap); const mainYaml = fileMap.get(null)!; expect(mainYaml).toContain('imports:'); expect(mainYaml).toMatch(/^modules:\s*\[\]/m); - // All modules are in imported files + + // Export flattens the import graph — main file references leaf files directly, + // not the intermediate aggregator files (platform.yaml, core.yaml, features.yaml) + expect(mainYaml).toContain('platform/core/database.yaml'); + expect(mainYaml).toContain('platform/core/cache.yaml'); + expect(mainYaml).toContain('platform/features/auth.yaml'); + expect(mainYaml).toContain('platform/features/payments.yaml'); + + // All modules are in imported files, not in the main file expect(mainYaml).not.toContain('primary-db'); expect(mainYaml).not.toContain('auth-service'); }); diff --git a/test-fixtures/multifile-domain/app.yaml b/test-fixtures/multifile-domain/app.yaml index 300fed7..54cc4f3 100644 --- a/test-fixtures/multifile-domain/app.yaml +++ b/test-fixtures/multifile-domain/app.yaml @@ -15,16 +15,16 @@ workflows: routes: - method: POST path: /api/auth/login - handler: login + handler: login-handler - method: POST path: /api/auth/register - handler: register + handler: register-handler - method: POST path: /api/billing/charge - handler: charge + handler: charge-handler - method: POST path: /api/billing/refund - handler: refund + handler: refund-handler - method: POST path: /api/notify/email - handler: send-email + handler: send-email-handler diff --git a/test-fixtures/multifile-domain/domains/auth.yaml b/test-fixtures/multifile-domain/domains/auth.yaml index d372302..46e710a 100644 --- a/test-fixtures/multifile-domain/domains/auth.yaml +++ b/test-fixtures/multifile-domain/domains/auth.yaml @@ -10,6 +10,14 @@ modules: config: host: localhost port: 6379 + - name: login-handler + type: api.query + config: + pipeline: login + - name: register-handler + type: api.command + config: + pipeline: register pipelines: login: diff --git a/test-fixtures/multifile-domain/domains/billing.yaml b/test-fixtures/multifile-domain/domains/billing.yaml index 1671d45..bf747ae 100644 --- a/test-fixtures/multifile-domain/domains/billing.yaml +++ b/test-fixtures/multifile-domain/domains/billing.yaml @@ -9,6 +9,14 @@ modules: type: integration.http_client config: base_url: https://api.stripe.com + - name: charge-handler + type: api.command + config: + pipeline: charge + - name: refund-handler + type: api.command + config: + pipeline: refund pipelines: charge: diff --git a/test-fixtures/multifile-domain/domains/notifications.yaml b/test-fixtures/multifile-domain/domains/notifications.yaml index 89d1f93..d10fddc 100644 --- a/test-fixtures/multifile-domain/domains/notifications.yaml +++ b/test-fixtures/multifile-domain/domains/notifications.yaml @@ -7,6 +7,10 @@ modules: type: integration.http_client config: base_url: https://api.twilio.com + - name: send-email-handler + type: api.command + config: + pipeline: send-email pipelines: send-email: diff --git a/test-fixtures/multifile-layers/layers/api.yaml b/test-fixtures/multifile-layers/layers/api.yaml index 0d2709d..22a2f5d 100644 --- a/test-fixtures/multifile-layers/layers/api.yaml +++ b/test-fixtures/multifile-layers/layers/api.yaml @@ -6,6 +6,18 @@ modules: - name: router type: http.router config: {} + - name: user-handler + type: api.query + config: + pipeline: user-service + - name: order-handler + type: api.command + config: + pipeline: order-service + - name: product-handler + type: api.query + config: + pipeline: product-service workflows: http: @@ -14,10 +26,10 @@ workflows: routes: - method: GET path: /api/users - handler: user-service + handler: user-handler - method: POST path: /api/orders - handler: order-service + handler: order-handler - method: GET path: /api/products - handler: product-service + handler: product-handler From 60a0016e9fcafdcab75fc81e2db9e433291e81b2 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 02:25:40 -0400 Subject: [PATCH 05/37] feat: implement YAML side-pane component (Task 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add FileTabBar: tab bar with one tab per file, active tab highlighted - Add YamlLineRenderer: YAML with line numbers, syntax tokens, clickable lines - Add YamlSidePane: combines tab bar + renderer, scroll-to-highlighted-range - Add showYamlPane prop to WorkflowEditorProps - Add yamlPaneVisible/yamlPaneWidth to uiLayoutStore - Wire into WorkflowEditor: node selection → active file + highlight range, YAML line click → select corresponding canvas node - 9 tests covering all specified behaviors Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/WorkflowEditor.tsx | 71 ++++++++++- src/components/yaml/FileTabBar.tsx | 44 +++++++ src/components/yaml/YamlLineRenderer.tsx | 100 +++++++++++++++ src/components/yaml/YamlSidePane.test.tsx | 144 ++++++++++++++++++++++ src/components/yaml/YamlSidePane.tsx | 55 +++++++++ src/stores/uiLayoutStore.ts | 11 ++ src/types/editor.ts | 3 + 7 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 src/components/yaml/FileTabBar.tsx create mode 100644 src/components/yaml/YamlLineRenderer.tsx create mode 100644 src/components/yaml/YamlSidePane.test.tsx create mode 100644 src/components/yaml/YamlSidePane.tsx diff --git a/src/components/WorkflowEditor.tsx b/src/components/WorkflowEditor.tsx index 8b9818c..f2ce3c7 100644 --- a/src/components/WorkflowEditor.tsx +++ b/src/components/WorkflowEditor.tsx @@ -10,10 +10,12 @@ import useUILayoutStore from '../stores/uiLayoutStore.ts'; import ToastContainer from './ToastContainer.tsx'; import { parseYamlSafe, configToYaml, resolveImports, hasFileReferences } from '../utils/serialization.ts'; import { applyMode } from '../modes/defaultMode.ts'; -import { useEffect, useRef } from 'react'; +import { buildYamlLineMap } from '../utils/yamlLineMap.ts'; +import { YamlSidePane } from './yaml/YamlSidePane.tsx'; +import { useEffect, useRef, useState, useMemo } from 'react'; export function WorkflowEditor(props: WorkflowEditorProps) { - const { initialYaml, onSave, onNavigateToSource, onSchemaRequest, onPluginSchemaRequest, embedded, onAIRequest, onChange, onResolveFile, mode, testResults, onTestRun, sourceMap: sourceMapProp, onSaveToFile } = props; + const { initialYaml, onSave, onNavigateToSource, onSchemaRequest, onPluginSchemaRequest, embedded, onAIRequest, onChange, onResolveFile, mode, testResults, onTestRun, sourceMap: sourceMapProp, onSaveToFile, showYamlPane } = props; const importFromConfig = useWorkflowStore((s) => s.importFromConfig); const exportToConfig = useWorkflowStore((s) => s.exportToConfig); const exportToFileMap = useWorkflowStore((s) => s.exportToFileMap); @@ -111,8 +113,61 @@ export function WorkflowEditor(props: WorkflowEditorProps) { const nodePaletteCollapsed = useUILayoutStore((s) => s.nodePaletteCollapsed); const propertyPanelCollapsed = useUILayoutStore((s) => s.propertyPanelCollapsed); + const yamlPaneVisible = useUILayoutStore((s) => s.yamlPaneVisible); const panelWidths = useUILayoutStore((s) => s.panelWidths); + // YAML side pane state + const [activeYamlFile, setActiveYamlFile] = useState(null); + const selectedNodeId = useWorkflowStore((s) => s.selectedNodeId); + const nodes = useWorkflowStore((s) => s.nodes); + const storeSourceMap = useWorkflowStore((s) => s.sourceMap); + + // Compute file map for YAML pane from current store state + const yamlFiles = useMemo(() => { + if (!showYamlPane) return new Map(); + return exportToFileMap(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [showYamlPane, nodes, exportToFileMap]); + + // When selected node changes, update active file + useEffect(() => { + if (!showYamlPane || !selectedNodeId) return; + const node = nodes.find((n) => n.id === selectedNodeId); + const label = node?.data?.label as string | undefined; + if (!label) return; + const filePath = storeSourceMap.get(label) ?? null; + setActiveYamlFile(filePath); + }, [selectedNodeId, nodes, storeSourceMap, showYamlPane]); + + // Compute highlight range for the selected node in the active file + const highlightRange = useMemo(() => { + if (!showYamlPane || !selectedNodeId) return undefined; + const node = nodes.find((n) => n.id === selectedNodeId); + const label = node?.data?.label as string | undefined; + if (!label) return undefined; + const fileContent = yamlFiles.get(activeYamlFile); + if (!fileContent) return undefined; + return buildYamlLineMap(fileContent)[label]; + }, [showYamlPane, selectedNodeId, nodes, yamlFiles, activeYamlFile]); + + // When a YAML line is clicked, select the corresponding node on canvas + const setSelectedNode = useWorkflowStore((s) => s.setSelectedNode); + const handleYamlLineClick = useMemo(() => { + if (!showYamlPane) return undefined; + return (filePath: string | null, line: number) => { + const fileContent = yamlFiles.get(filePath); + if (!fileContent) return; + const lineMap = buildYamlLineMap(fileContent); + for (const [label, range] of Object.entries(lineMap)) { + if (line >= range.startLine && line <= range.endLine) { + const node = nodes.find((n) => (n.data?.label as string) === label); + if (node) setSelectedNode(node.id); + break; + } + } + }; + }, [showYamlPane, yamlFiles, nodes, setSelectedNode]); + return (
@@ -160,6 +215,18 @@ export function WorkflowEditor(props: WorkflowEditorProps) {
)} + {showYamlPane && ( +
+ +
+ )}
); diff --git a/src/components/yaml/FileTabBar.tsx b/src/components/yaml/FileTabBar.tsx new file mode 100644 index 0000000..fd84f56 --- /dev/null +++ b/src/components/yaml/FileTabBar.tsx @@ -0,0 +1,44 @@ +export interface FileTabBarProps { + files: Array<{ path: string | null; label: string }>; + activeFile: string | null; + onSelect: (filePath: string | null) => void; +} + +export function FileTabBar({ files, activeFile, onSelect }: FileTabBarProps) { + return ( +
+ {files.map((file) => { + const isActive = file.path === activeFile; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/yaml/YamlLineRenderer.tsx b/src/components/yaml/YamlLineRenderer.tsx new file mode 100644 index 0000000..59ac398 --- /dev/null +++ b/src/components/yaml/YamlLineRenderer.tsx @@ -0,0 +1,100 @@ +import { useRef, useEffect } from 'react'; + +export interface YamlLineRendererProps { + content: string; + highlightRange?: { startLine: number; endLine: number }; + onLineClick?: (line: number) => void; + scrollToLine?: number; +} + +function renderYamlLine(line: string): React.ReactNode { + if (line.trimStart().startsWith('#')) { + return {line}; + } + const keyMatch = line.match(/^(\s*)([\w.-]+)(:)(.*)/); + if (keyMatch) { + const [, indent, key, colon, rest] = keyMatch; + return ( + <> + {indent} + {key} + {colon} + {rest} + + ); + } + if (line.trimStart().startsWith('-')) { + return {line}; + } + return {line}; +} + +export function YamlLineRenderer({ + content, + highlightRange, + onLineClick, + scrollToLine, +}: YamlLineRendererProps) { + const containerRef = useRef(null); + const lines = content.split('\n'); + + useEffect(() => { + if (!scrollToLine || !containerRef.current) return; + const lineEl = containerRef.current.querySelector(`[data-line="${scrollToLine}"]`); + if (lineEl && typeof (lineEl as Element & { scrollIntoView?: unknown }).scrollIntoView === 'function') { + (lineEl as Element).scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }, [scrollToLine]); + + return ( +
+
+        {lines.map((line, i) => {
+          const lineNum = i + 1;
+          const isHighlighted =
+            highlightRange != null &&
+            lineNum >= highlightRange.startLine &&
+            lineNum <= highlightRange.endLine;
+          return (
+            
onLineClick?.(lineNum)} + style={{ + display: 'flex', + alignItems: 'baseline', + background: isHighlighted ? '#313244' : 'transparent', + cursor: onLineClick ? 'pointer' : 'default', + padding: '0 8px', + }} + > + + {lineNum} + + {renderYamlLine(line)} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/yaml/YamlSidePane.test.tsx b/src/components/yaml/YamlSidePane.test.tsx new file mode 100644 index 0000000..57c99fa --- /dev/null +++ b/src/components/yaml/YamlSidePane.test.tsx @@ -0,0 +1,144 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { YamlSidePane } from './YamlSidePane.tsx'; +import { FileTabBar } from './FileTabBar.tsx'; + +const sampleFiles = new Map([ + [null, 'modules:\n - name: main-step\n type: http.server'], + ['/path/to/auth.yaml', 'modules:\n - name: auth-step\n type: step.auth'], +]); + +const singleFile = new Map([ + [null, 'modules:\n - name: only-step\n type: http.server'], +]); + +describe('YamlSidePane', () => { + it('renders file tabs for each file in the map', () => { + render( + , + ); + expect(screen.getByText('main')).toBeTruthy(); + expect(screen.getByText('auth.yaml')).toBeTruthy(); + }); + + it('switches content when tab is clicked', () => { + const onFileSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('auth.yaml')); + expect(onFileSelect).toHaveBeenCalledWith('/path/to/auth.yaml'); + }); + + it('highlights lines in the specified range', () => { + const { container } = render( + , + ); + const highlighted = container.querySelectorAll('.yaml-line-highlighted'); + expect(highlighted.length).toBeGreaterThan(0); + }); + + it('calls onLineClick when a line is clicked', () => { + const onLineClick = vi.fn(); + render( + , + ); + const lines = document.querySelectorAll('.yaml-line'); + expect(lines.length).toBeGreaterThan(0); + fireEvent.click(lines[0]); + expect(onLineClick).toHaveBeenCalledWith(null, 1); + }); + + it('does not render when visible=false', () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); +}); + +describe('FileTabBar', () => { + it('renders one tab per file', () => { + render( + , + ); + expect(screen.getByText('main')).toBeTruthy(); + expect(screen.getByText('auth.yaml')).toBeTruthy(); + }); + + it('marks active tab with active class', () => { + const { container } = render( + , + ); + const activeTabs = container.querySelectorAll('.yaml-tab-active'); + expect(activeTabs.length).toBe(1); + }); + + it('calls onSelect with file path when tab is clicked', () => { + const onSelect = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText('auth.yaml')); + expect(onSelect).toHaveBeenCalledWith('/auth.yaml'); + }); + + it('shows "main" for null file path', () => { + render( + , + ); + expect(screen.getByText('main')).toBeTruthy(); + }); +}); diff --git a/src/components/yaml/YamlSidePane.tsx b/src/components/yaml/YamlSidePane.tsx new file mode 100644 index 0000000..5de5844 --- /dev/null +++ b/src/components/yaml/YamlSidePane.tsx @@ -0,0 +1,55 @@ +import { FileTabBar } from './FileTabBar.tsx'; +import { YamlLineRenderer } from './YamlLineRenderer.tsx'; + +export interface YamlSidePaneProps { + files: Map; + activeFile: string | null; + onFileSelect: (filePath: string | null) => void; + highlightRange?: { startLine: number; endLine: number }; + onLineClick?: (filePath: string | null, line: number) => void; + visible: boolean; +} + +function fileLabel(path: string | null): string { + if (path === null) return 'main'; + return path.split('/').pop() ?? path; +} + +export function YamlSidePane({ + files, + activeFile, + onFileSelect, + highlightRange, + onLineClick, + visible, +}: YamlSidePaneProps) { + if (!visible) return null; + + const tabFiles = Array.from(files.keys()).map((path) => ({ + path, + label: fileLabel(path), + })); + + const activeContent = files.get(activeFile) ?? ''; + + return ( +
+ + onLineClick(activeFile, line) : undefined} + /> +
+ ); +} diff --git a/src/stores/uiLayoutStore.ts b/src/stores/uiLayoutStore.ts index 1d2e19e..3c66517 100644 --- a/src/stores/uiLayoutStore.ts +++ b/src/stores/uiLayoutStore.ts @@ -5,22 +5,26 @@ interface PanelWidths { projectSwitcher: number; nodePalette: number; propertyPanel: number; + yamlPane: number; } interface UILayoutStore { projectSwitcherCollapsed: boolean; nodePaletteCollapsed: boolean; propertyPanelCollapsed: boolean; + yamlPaneVisible: boolean; panelWidths: PanelWidths; toggleProjectSwitcher: () => void; toggleNodePalette: () => void; togglePropertyPanel: () => void; + toggleYamlPane: () => void; setProjectSwitcherCollapsed: (collapsed: boolean) => void; setNodePaletteCollapsed: (collapsed: boolean) => void; setPropertyPanelCollapsed: (collapsed: boolean) => void; + setYamlPaneVisible: (visible: boolean) => void; setPanelWidth: (panel: keyof PanelWidths, width: number) => void; } @@ -29,12 +33,14 @@ const DEFAULT_WIDTHS: PanelWidths = { projectSwitcher: 200, nodePalette: 240, propertyPanel: 280, + yamlPane: 320, }; const PANEL_WIDTH_LIMITS: Record = { projectSwitcher: { min: 150, max: 350 }, nodePalette: { min: 180, max: 400 }, propertyPanel: { min: 200, max: 500 }, + yamlPane: { min: 240, max: 600 }, }; export { PANEL_WIDTH_LIMITS }; @@ -45,6 +51,7 @@ const useUILayoutStore = create()( projectSwitcherCollapsed: false, nodePaletteCollapsed: false, propertyPanelCollapsed: false, + yamlPaneVisible: true, panelWidths: { ...DEFAULT_WIDTHS }, @@ -54,6 +61,8 @@ const useUILayoutStore = create()( set({ nodePaletteCollapsed: !get().nodePaletteCollapsed }), togglePropertyPanel: () => set({ propertyPanelCollapsed: !get().propertyPanelCollapsed }), + toggleYamlPane: () => + set({ yamlPaneVisible: !get().yamlPaneVisible }), setProjectSwitcherCollapsed: (collapsed) => set({ projectSwitcherCollapsed: collapsed }), @@ -61,6 +70,8 @@ const useUILayoutStore = create()( set({ nodePaletteCollapsed: collapsed }), setPropertyPanelCollapsed: (collapsed) => set({ propertyPanelCollapsed: collapsed }), + setYamlPaneVisible: (visible) => + set({ yamlPaneVisible: visible }), setPanelWidth: (panel, width) => { const limits = PANEL_WIDTH_LIMITS[panel]; diff --git a/src/types/editor.ts b/src/types/editor.ts index 9a4bb72..86a30fa 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -96,6 +96,9 @@ export interface WorkflowEditorProps { /** Called to save changes to a specific imported file. * When provided and sourceMap is set, the editor calls this for each non-main file on save. */ onSaveToFile?: (filePath: string, content: string) => void; + /** When true, renders a YAML side-pane to the right of the canvas showing file contents. + * Node selection highlights the corresponding line range in the active file tab. */ + showYamlPane?: boolean; } /** Context sent to the host IDE's AI when user clicks AI Design */ From b4ef625feb970f192fa2f3b4d1788baada709221 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 02:27:04 -0400 Subject: [PATCH 06/37] feat: extend yamlLineMap with pipeline, workflow, trigger sections and multi-file support - buildYamlLineMap now maps pipelines (pipelineName keys), pipeline steps (pipelineName:stepName keys), workflows, and triggers in addition to modules - Fix module inline-name detection ( - name: foo pattern) - Add buildMultiFileLineMap for per-file maps across a Map - Add lookupNodeInLineMap for cross-file node lookup by name and optional file - Add yamlLineMap.test.ts with 19 tests covering all sections and edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- src/utils/yamlLineMap.test.ts | 203 ++++++++++++++++++++++++++++++++ src/utils/yamlLineMap.ts | 215 +++++++++++++++++++++++++++++----- 2 files changed, 386 insertions(+), 32 deletions(-) create mode 100644 src/utils/yamlLineMap.test.ts diff --git a/src/utils/yamlLineMap.test.ts b/src/utils/yamlLineMap.test.ts new file mode 100644 index 0000000..e7d0c0b --- /dev/null +++ b/src/utils/yamlLineMap.test.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { readFileSync } from 'fs'; +import { resolve as resolveFsPath } from 'path'; +import { + buildYamlLineMap, + buildMultiFileLineMap, + lookupNodeInLineMap, + type MultiFileYamlLineMap, +} from './yamlLineMap.ts'; + +function loadFixture(name: string): string { + return readFileSync( + resolveFsPath(__dirname, '../../test-fixtures/multifile-domain', name), + 'utf-8', + ); +} + +const FIXTURE_AUTH = loadFixture('domains/auth.yaml'); +const FIXTURE_BILLING = loadFixture('domains/billing.yaml'); +const FIXTURE_INFRA = loadFixture('shared/infra.yaml'); +const FIXTURE_APP = loadFixture('app.yaml'); + +// ── buildYamlLineMap ──────────────────────────────────────────────────────── + +describe('buildYamlLineMap – modules', () => { + it('maps module names in auth.yaml to correct line ranges', () => { + const map = buildYamlLineMap(FIXTURE_AUTH); + + expect(map['auth-db']).toEqual({ startLine: 2, endLine: 7 }); + expect(map['auth-cache']).toEqual({ startLine: 8, endLine: 12 }); + expect(map['login-handler']).toEqual({ startLine: 13, endLine: 16 }); + // register-handler ends at the blank line before pipelines: + expect(map['register-handler']?.startLine).toBe(17); + }); + + it('maps module names in infra.yaml to correct line ranges', () => { + const map = buildYamlLineMap(FIXTURE_INFRA); + + expect(map['http-server']?.startLine).toBe(2); + expect(map['router']?.startLine).toBe(6); + expect(map['logger']?.startLine).toBe(9); + }); +}); + +describe('buildYamlLineMap – pipelines', () => { + it('maps pipeline names in auth.yaml', () => { + const map = buildYamlLineMap(FIXTURE_AUTH); + + expect(map['login']?.startLine).toBe(23); + expect(map['register']?.startLine).toBe(33); + }); + + it('pipeline endLine is the last line of the pipeline block', () => { + const map = buildYamlLineMap(FIXTURE_AUTH); + + // login pipeline ends just before register: (line 33), so endLine = 32 + expect(map['login']?.endLine).toBe(32); + }); + + it('maps pipeline step names with pipelineName:stepName keys', () => { + const map = buildYamlLineMap(FIXTURE_AUTH); + + expect(map['login:parse']).toEqual({ startLine: 25, endLine: 26 }); + expect(map['login:validate']).toEqual({ startLine: 27, endLine: 28 }); + expect(map['login:authenticate']).toEqual({ startLine: 29, endLine: 30 }); + expect(map['login:respond']?.startLine).toBe(31); + }); + + it('maps steps in billing.yaml pipelines with hyphenated step names', () => { + const map = buildYamlLineMap(FIXTURE_BILLING); + + expect(map['charge:create-charge']).toBeDefined(); + expect(map['charge:create-charge']?.startLine).toBe(28); + expect(map['refund:process-refund']).toBeDefined(); + }); + + it('maps register pipeline steps', () => { + const map = buildYamlLineMap(FIXTURE_AUTH); + + expect(map['register:parse']?.startLine).toBe(35); + expect(map['register:insert']?.startLine).toBe(39); + expect(map['register:respond']?.startLine).toBe(41); + }); +}); + +describe('buildYamlLineMap – workflows', () => { + it('maps workflow names in app.yaml', () => { + const map = buildYamlLineMap(FIXTURE_APP); + + expect(map['http']?.startLine).toBe(12); + }); + + it('does not include non-workflow top-level keys', () => { + const map = buildYamlLineMap(FIXTURE_APP); + + expect(map['application']).toBeUndefined(); + expect(map['imports']).toBeUndefined(); + }); +}); + +describe('buildYamlLineMap – no cross-section pollution', () => { + it('files with only modules do not produce pipeline entries', () => { + const map = buildYamlLineMap(FIXTURE_INFRA); + + const keys = Object.keys(map); + expect(keys.every((k) => !k.includes(':'))).toBe(true); + }); +}); + +// ── buildMultiFileLineMap ─────────────────────────────────────────────────── + +describe('buildMultiFileLineMap', () => { + it('builds per-file maps', () => { + const files = new Map([ + ['domains/auth.yaml', FIXTURE_AUTH], + ['shared/infra.yaml', FIXTURE_INFRA], + ]); + + const multi = buildMultiFileLineMap(files); + + expect(multi.files.get('domains/auth.yaml')).toBeDefined(); + expect(multi.files.get('shared/infra.yaml')).toBeDefined(); + }); + + it('each file map is independently correct', () => { + const files = new Map([ + ['domains/auth.yaml', FIXTURE_AUTH], + ['shared/infra.yaml', FIXTURE_INFRA], + ]); + + const multi = buildMultiFileLineMap(files); + + expect(multi.files.get('domains/auth.yaml')!['auth-db']).toEqual({ startLine: 2, endLine: 7 }); + expect(multi.files.get('shared/infra.yaml')!['http-server']?.startLine).toBe(2); + }); + + it('supports null as a file path key', () => { + const files = new Map([[null, FIXTURE_AUTH]]); + + const multi = buildMultiFileLineMap(files); + + expect(multi.files.get(null)).toBeDefined(); + expect(multi.files.get(null)!['auth-db']).toBeDefined(); + }); +}); + +// ── lookupNodeInLineMap ───────────────────────────────────────────────────── + +describe('lookupNodeInLineMap', () => { + let multi: MultiFileYamlLineMap; + + beforeEach(() => { + multi = buildMultiFileLineMap( + new Map([ + ['domains/auth.yaml', FIXTURE_AUTH], + ['domains/billing.yaml', FIXTURE_BILLING], + ['shared/infra.yaml', FIXTURE_INFRA], + ]), + ); + }); + + it('finds a node by name and specific file', () => { + const result = lookupNodeInLineMap(multi, 'auth-db', 'domains/auth.yaml'); + + expect(result).not.toBeNull(); + expect(result!.filePath).toBe('domains/auth.yaml'); + expect(result!.range.startLine).toBe(2); + }); + + it('finds a node by name across all files when sourceFile is omitted', () => { + const result = lookupNodeInLineMap(multi, 'http-server'); + + expect(result).not.toBeNull(); + expect(result!.filePath).toBe('shared/infra.yaml'); + expect(result!.range.startLine).toBe(2); + }); + + it('returns null when node does not exist in the specified file', () => { + const result = lookupNodeInLineMap(multi, 'http-server', 'domains/auth.yaml'); + + expect(result).toBeNull(); + }); + + it('returns null when node does not exist in any file', () => { + const result = lookupNodeInLineMap(multi, 'nonexistent-node'); + + expect(result).toBeNull(); + }); + + it('returns null when specified file does not exist in map', () => { + const result = lookupNodeInLineMap(multi, 'auth-db', 'domains/missing.yaml'); + + expect(result).toBeNull(); + }); + + it('finds pipeline step keys across files', () => { + const result = lookupNodeInLineMap(multi, 'login:parse', 'domains/auth.yaml'); + + expect(result).not.toBeNull(); + expect(result!.filePath).toBe('domains/auth.yaml'); + expect(result!.range.startLine).toBe(25); + }); +}); diff --git a/src/utils/yamlLineMap.ts b/src/utils/yamlLineMap.ts index 63a1e6b..b48908b 100644 --- a/src/utils/yamlLineMap.ts +++ b/src/utils/yamlLineMap.ts @@ -1,65 +1,216 @@ /** - * Build a map from node name → { startLine, endLine } by parsing the YAML structure. - * Each entry in the top-level `modules` list is tracked by its `name` field. + * Build a map from node name → { startLine, endLine } by scanning YAML structure. + * + * Handles `modules:`, `pipelines:`, `workflows:`, and `triggers:` top-level sections. + * Pipeline steps are keyed as `pipelineName:stepName`. + * Line numbers are 1-based. */ export interface YamlLineRange { startLine: number; endLine: number; } +export interface MultiFileYamlLineMap { + files: Map>; +} + export function buildYamlLineMap(yaml: string): Record { const lines = yaml.split('\n'); const result: Record = {}; - // Find the `modules:` top-level key - let inModules = false; - let currentName: string | null = null; - let currentStart = -1; - const moduleIndent = 2; // expected indent for list items under `modules:` - - const flush = (endLine: number) => { - if (currentName !== null && currentStart >= 0) { - result[currentName] = { startLine: currentStart, endLine }; - currentName = null; - currentStart = -1; + type Section = 'none' | 'modules' | 'pipelines' | 'workflows' | 'triggers'; + let section: Section = 'none'; + + // Module tracking + let moduleName: string | null = null; + let moduleStart = -1; + + // Pipeline tracking + let pipelineName: string | null = null; + let pipelineStart = -1; + + // Step tracking (within current pipeline) + let stepName: string | null = null; + let stepStart = -1; + + // Workflow / trigger tracking (same structure: named map keys at 2-space indent) + let workflowName: string | null = null; + let workflowStart = -1; + let triggerName: string | null = null; + let triggerStart = -1; + + const flushModule = (endLine: number) => { + if (moduleName !== null && moduleStart >= 0) { + result[moduleName] = { startLine: moduleStart, endLine }; + moduleName = null; + moduleStart = -1; + } + }; + + const flushStep = (endLine: number) => { + if (stepName !== null && stepStart >= 0 && pipelineName !== null) { + result[`${pipelineName}:${stepName}`] = { startLine: stepStart, endLine }; + stepName = null; + stepStart = -1; + } + }; + + const flushPipeline = (endLine: number) => { + flushStep(endLine); + if (pipelineName !== null && pipelineStart >= 0) { + result[pipelineName] = { startLine: pipelineStart, endLine }; + pipelineName = null; + pipelineStart = -1; + } + }; + + const flushWorkflow = (endLine: number) => { + if (workflowName !== null && workflowStart >= 0) { + result[workflowName] = { startLine: workflowStart, endLine }; + workflowName = null; + workflowStart = -1; + } + }; + + const flushTrigger = (endLine: number) => { + if (triggerName !== null && triggerStart >= 0) { + result[triggerName] = { startLine: triggerStart, endLine }; + triggerName = null; + triggerStart = -1; } }; + const flushAll = (endLine: number) => { + flushModule(endLine); + flushPipeline(endLine); + flushWorkflow(endLine); + flushTrigger(endLine); + }; + for (let i = 0; i < lines.length; i++) { const line = lines[i]; const lineNum = i + 1; // 1-based + // Detect known top-level section headers if (/^modules:/.test(line)) { - inModules = true; + flushAll(lineNum - 1); + section = 'modules'; + continue; + } + if (/^pipelines:/.test(line)) { + flushAll(lineNum - 1); + section = 'pipelines'; + continue; + } + if (/^workflows:/.test(line)) { + flushAll(lineNum - 1); + section = 'workflows'; + continue; + } + if (/^triggers:/.test(line)) { + flushAll(lineNum - 1); + section = 'triggers'; continue; } - if (inModules) { - // Detect top-level key that ends the modules block (non-indented, non-empty) - if (line.length > 0 && !/^\s/.test(line) && !/^-/.test(line)) { - flush(lineNum - 1); - inModules = false; - continue; - } + // Non-indented non-empty line → unknown top-level key, end current section + if (line.length > 0 && !/^\s/.test(line)) { + flushAll(lineNum - 1); + section = 'none'; + continue; + } - // Detect list item start: ` - name: foo` or ` -` - const itemMatch = line.match(/^(\s+)-\s*/); + if (section === 'modules') { + // List item at 2-space indent: ` - name: foo` (inline) or ` -` (name on next line) + const itemMatch = line.match(/^ -\s*/); if (itemMatch) { - const indent = itemMatch[1].length; - if (indent === moduleIndent) { - flush(lineNum - 1); - currentStart = lineNum; + flushModule(lineNum - 1); + moduleStart = lineNum; + moduleName = null; + // Attempt to capture inline name: ` - name: foo` + const inlineNameMatch = line.match(/^ -\s+name:\s+(\S+)/); + if (inlineNameMatch) { + moduleName = inlineNameMatch[1].replace(/['"]/g, ''); } + } else if (moduleStart >= 0 && moduleName === null) { + // Name on its own indented line: ` name: foo` + const nameMatch = line.match(/^\s+name:\s+(\S+)/); + if (nameMatch) { + moduleName = nameMatch[1].replace(/['"]/g, ''); + } + } + } + + if (section === 'pipelines') { + // Pipeline name: exactly 2-space indent + identifier + colon, e.g. ` login:` + const pipelineNameMatch = line.match(/^ ([a-zA-Z][\w-]*):/); + if (pipelineNameMatch) { + flushPipeline(lineNum - 1); + pipelineName = pipelineNameMatch[1]; + pipelineStart = lineNum; + stepName = null; + stepStart = -1; + } else if (pipelineName !== null) { + // Step list item (deeper indent): ` - name: stepName` + const stepMatch = line.match(/^(\s+)-\s+name:\s+(\S+)/); + if (stepMatch && stepMatch[1].length >= 4) { + flushStep(lineNum - 1); + stepName = stepMatch[2].replace(/['"]/g, ''); + stepStart = lineNum; + } + } + } + + if (section === 'workflows') { + // Workflow name: exactly 2-space indent + identifier + colon + const workflowNameMatch = line.match(/^ ([a-zA-Z][\w-]*):/); + if (workflowNameMatch) { + flushWorkflow(lineNum - 1); + workflowName = workflowNameMatch[1]; + workflowStart = lineNum; } + } - // Detect `name:` field in a module item - const nameMatch = line.match(/^\s+name:\s+(\S+)/); - if (nameMatch && currentStart >= 0) { - currentName = nameMatch[1].replace(/['"]/g, ''); + if (section === 'triggers') { + // Trigger name: exactly 2-space indent + identifier + colon + const triggerNameMatch = line.match(/^ ([a-zA-Z][\w-]*):/); + if (triggerNameMatch) { + flushTrigger(lineNum - 1); + triggerName = triggerNameMatch[1]; + triggerStart = lineNum; } } } - flush(lines.length); + flushAll(lines.length); + return result; +} + +export function buildMultiFileLineMap( + files: Map, +): MultiFileYamlLineMap { + const result: MultiFileYamlLineMap = { files: new Map() }; + for (const [filePath, content] of files) { + result.files.set(filePath, buildYamlLineMap(content)); + } return result; } + +export function lookupNodeInLineMap( + lineMap: MultiFileYamlLineMap, + nodeName: string, + sourceFile?: string, +): { filePath: string | null; range: YamlLineRange } | null { + if (sourceFile !== undefined) { + const fileMap = lineMap.files.get(sourceFile); + if (!fileMap) return null; + const range = fileMap[nodeName]; + return range ? { filePath: sourceFile, range } : null; + } + + for (const [filePath, fileMap] of lineMap.files) { + const range = fileMap[nodeName]; + if (range) return { filePath, range }; + } + return null; +} From 341f5e16ea2025bfb4a7d0543a20e36efe9fc2f3 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 02:28:38 -0400 Subject: [PATCH 07/37] feat: implement visual file boundary groups (Task 4) - Add computeFileGroups() utility: groups nodes by sourceFile, computes bounding boxes with 40px padding, assigns cycling colors from 8-color palette, falls back to sourceMap when node.data.sourceFile is absent, skips groups when <2 distinct source files - Add FileGroupNode component: dashed border, subtle background tint, filename label in top-left, pointer-events none so it doesn't interfere with canvas - Register 'fileGroup' type in src/components/nodes/index.ts - Wire into WorkflowCanvas: prepend file group overlays to displayNodes when sourceMap has 2+ distinct files; groups appear behind canvas nodes (zIndex -1) - 7 tests covering all specified behaviors (684 total pass) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/canvas/WorkflowCanvas.tsx | 39 +++++++- src/components/nodes/FileGroupNode.tsx | 48 ++++++++++ src/components/nodes/index.ts | 2 + src/utils/fileGroups.test.ts | 116 +++++++++++++++++++++++ src/utils/fileGroups.ts | 86 +++++++++++++++++ 5 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 src/components/nodes/FileGroupNode.tsx create mode 100644 src/utils/fileGroups.test.ts create mode 100644 src/utils/fileGroups.ts diff --git a/src/components/canvas/WorkflowCanvas.tsx b/src/components/canvas/WorkflowCanvas.tsx index 3737c19..049a7db 100644 --- a/src/components/canvas/WorkflowCanvas.tsx +++ b/src/components/canvas/WorkflowCanvas.tsx @@ -23,6 +23,7 @@ import useUILayoutStore from '../../stores/uiLayoutStore.ts'; import { configToYaml } from '../../utils/serialization.ts'; import type { WorkflowEdgeData } from '../../types/workflow.ts'; import { computeContainerView } from '../../utils/grouping.ts'; +import { computeFileGroups } from '../../utils/fileGroups.ts'; import { isTypeCompatible, getOutputTypes, getInputTypes, getCompatibleNodes, canAcceptIncoming, canAcceptOutgoing } from '../../utils/connectionCompatibility.ts'; import { findSnapCandidate } from '../../utils/snapToConnect.ts'; import ConnectionPicklist from './ConnectionPicklist.tsx'; @@ -78,6 +79,7 @@ export default function WorkflowCanvas(props: WorkflowCanvasProps) { const propertyPanelCollapsed = useUILayoutStore((s) => s.propertyPanelCollapsed); const setPropertyPanelCollapsed = useUILayoutStore((s) => s.setPropertyPanelCollapsed); + const storeSourceMap = useWorkflowStore((s) => s.sourceMap); const { screenToFlowPosition, getViewport } = useReactFlow(); const wrapperRef = useRef(null); @@ -149,11 +151,42 @@ export default function WorkflowCanvas(props: WorkflowCanvasProps) { }, [edges, selectedEdgeId, getEdgeStyle]); const { nodes: displayNodes, edges: displayEdges } = useMemo(() => { + let baseNodes = nodes; + let baseEdges = styledEdges; + if (viewLevel === 'container' && nodes.length > 0) { - return computeContainerView(nodes, styledEdges); + const containerView = computeContainerView(nodes, styledEdges); + baseNodes = containerView.nodes; + baseEdges = containerView.edges; } - return { nodes, edges: styledEdges }; - }, [viewLevel, nodes, styledEdges]); + + // Prepend file group overlay nodes when multiple source files are present + const fileGroups = computeFileGroups(nodes, storeSourceMap); + if (fileGroups.length > 0) { + const groupOverlays: RFNode[] = fileGroups.map((group) => ({ + id: `__file-group__${group.filePath}`, + type: 'fileGroup', + position: { x: group.bounds.x, y: group.bounds.y }, + style: { + width: group.bounds.width, + height: group.bounds.height, + pointerEvents: 'none' as const, + zIndex: -1, + }, + data: { + label: group.filePath.split('/').pop() ?? group.filePath, + filePath: group.filePath, + color: group.color, + }, + selectable: false, + draggable: false, + focusable: false, + })); + return { nodes: [...groupOverlays, ...baseNodes], edges: baseEdges }; + } + + return { nodes: baseNodes, edges: baseEdges }; + }, [viewLevel, nodes, styledEdges, storeSourceMap]); const handleDragOver = useCallback((event: DragEvent) => { event.preventDefault(); diff --git a/src/components/nodes/FileGroupNode.tsx b/src/components/nodes/FileGroupNode.tsx new file mode 100644 index 0000000..769ed3f --- /dev/null +++ b/src/components/nodes/FileGroupNode.tsx @@ -0,0 +1,48 @@ +import { memo } from 'react'; +import type { NodeProps } from '@xyflow/react'; + +export interface FileGroupNodeData extends Record { + label: string; + filePath: string; + color: { bg: string; border: string }; +} + +function FileGroupNode({ data }: NodeProps) { + const d = data as FileGroupNodeData; + const filename = d.label; + + return ( +
+ + {filename} + +
+ ); +} + +export default memo(FileGroupNode); diff --git a/src/components/nodes/index.ts b/src/components/nodes/index.ts index 547426b..1e28b09 100644 --- a/src/components/nodes/index.ts +++ b/src/components/nodes/index.ts @@ -13,11 +13,13 @@ import SecurityNode from './SecurityNode.tsx'; import ObservabilityNode from './ObservabilityNode.tsx'; import GroupNode from './GroupNode.tsx'; import ConditionalNode from './ConditionalNode.tsx'; +import FileGroupNode from './FileGroupNode.tsx'; // All HTTP-type nodes use the same general component but with different configs // We register them by the category key used in workflowStore's nodeComponentType() export const nodeTypes: NodeTypes = { groupNode: GroupNode, + fileGroup: FileGroupNode, httpNode: HTTPServerNode, httpRouterNode: HTTPRouterNode, messagingNode: MessagingBrokerNode, diff --git a/src/utils/fileGroups.test.ts b/src/utils/fileGroups.test.ts new file mode 100644 index 0000000..c59430a --- /dev/null +++ b/src/utils/fileGroups.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { computeFileGroups } from './fileGroups.ts'; +import type { WorkflowNode } from '../stores/workflowStore.ts'; + +function makeNode( + id: string, + label: string, + sourceFile: string | undefined, + position: { x: number; y: number }, + width = 180, + height = 80, +): WorkflowNode { + return { + id, + type: 'httpNode', + position, + measured: { width, height }, + data: { + moduleType: 'http.server', + label, + config: {}, + sourceFile, + }, + } as WorkflowNode; +} + +describe('computeFileGroups', () => { + it('creates one group per unique sourceFile', () => { + const nodes = [ + makeNode('n1', 'alpha', 'config.yaml', { x: 0, y: 0 }), + makeNode('n2', 'beta', 'auth.yaml', { x: 300, y: 0 }), + makeNode('n3', 'gamma', 'config.yaml', { x: 0, y: 200 }), + ]; + const groups = computeFileGroups(nodes, new Map()); + expect(groups.length).toBe(2); + const paths = groups.map((g) => g.filePath).sort(); + expect(paths).toEqual(['auth.yaml', 'config.yaml']); + }); + + it('does not create groups when only one source file', () => { + const nodes = [ + makeNode('n1', 'alpha', 'config.yaml', { x: 0, y: 0 }), + makeNode('n2', 'beta', 'config.yaml', { x: 200, y: 0 }), + ]; + const groups = computeFileGroups(nodes, new Map()); + expect(groups.length).toBe(0); + }); + + it('does not create groups when no sourceFile on any node', () => { + const nodes = [ + makeNode('n1', 'alpha', undefined, { x: 0, y: 0 }), + makeNode('n2', 'beta', undefined, { x: 200, y: 0 }), + ]; + const groups = computeFileGroups(nodes, new Map()); + expect(groups.length).toBe(0); + }); + + it('assigns distinct colors to each group', () => { + const nodes = [ + makeNode('n1', 'a', 'file1.yaml', { x: 0, y: 0 }), + makeNode('n2', 'b', 'file2.yaml', { x: 300, y: 0 }), + makeNode('n3', 'c', 'file3.yaml', { x: 600, y: 0 }), + ]; + const groups = computeFileGroups(nodes, new Map()); + const borders = groups.map((g) => g.color.border); + const uniqueBorders = new Set(borders); + expect(uniqueBorders.size).toBe(3); + }); + + it('computes bounds from child node positions with padding', () => { + const PADDING = 40; + const nodes = [ + makeNode('n1', 'alpha', 'a.yaml', { x: 100, y: 100 }, 180, 80), + makeNode('n2', 'beta', 'a.yaml', { x: 400, y: 300 }, 180, 80), + makeNode('n3', 'other', 'b.yaml', { x: 900, y: 900 }, 180, 80), + ]; + const groups = computeFileGroups(nodes, new Map()); + const groupA = groups.find((g) => g.filePath === 'a.yaml'); + expect(groupA).toBeDefined(); + // bounds should encompass n1 and n2 with padding + expect(groupA!.bounds.x).toBe(100 - PADDING); + expect(groupA!.bounds.y).toBe(100 - PADDING); + // maxX = 400 + 180 = 580; width = 580 - 100 + 2*PADDING + expect(groupA!.bounds.width).toBe(400 + 180 - 100 + PADDING * 2); + // maxY = 300 + 80 = 380; height = 380 - 100 + 2*PADDING + expect(groupA!.bounds.height).toBe(300 + 80 - 100 + PADDING * 2); + }); + + it('handles nodes with no sourceFile (excludes them from groups)', () => { + const nodes = [ + makeNode('n1', 'alpha', 'file1.yaml', { x: 0, y: 0 }), + makeNode('n2', 'beta', undefined, { x: 200, y: 0 }), + makeNode('n3', 'gamma', 'file2.yaml', { x: 400, y: 0 }), + ]; + const groups = computeFileGroups(nodes, new Map()); + // 2 source files → groups are created + expect(groups.length).toBe(2); + // n2 (no sourceFile) should not be in any group + for (const group of groups) { + expect(group.nodeIds).not.toContain('n2'); + } + }); + + it('falls back to sourceMap when node.data.sourceFile is not set', () => { + const nodes = [ + makeNode('n1', 'alpha', undefined, { x: 0, y: 0 }), + makeNode('n2', 'beta', undefined, { x: 300, y: 0 }), + ]; + const sourceMap = new Map([ + ['alpha', 'config.yaml'], + ['beta', 'auth.yaml'], + ]); + const groups = computeFileGroups(nodes, sourceMap); + expect(groups.length).toBe(2); + }); +}); diff --git a/src/utils/fileGroups.ts b/src/utils/fileGroups.ts new file mode 100644 index 0000000..e98c154 --- /dev/null +++ b/src/utils/fileGroups.ts @@ -0,0 +1,86 @@ +import type { WorkflowNode } from '../stores/workflowStore.ts'; + +export interface FileGroupData { + filePath: string; + nodeIds: string[]; + bounds: { x: number; y: number; width: number; height: number }; + color: { bg: string; border: string }; +} + +const FILE_GROUP_COLORS = [ + { bg: '#1a2332', border: '#93C5FD' }, // blue + { bg: '#1a2e1a', border: '#86EFAC' }, // green + { bg: '#2e2517', border: '#FDBA74' }, // orange + { bg: '#251a2e', border: '#C4B5FD' }, // purple + { bg: '#2e1a1a', border: '#FCA5A5' }, // red + { bg: '#1a2e2e', border: '#67E8F9' }, // cyan + { bg: '#2e2e17', border: '#FCD34D' }, // yellow + { bg: '#2e1a25', border: '#F9A8D4' }, // pink +]; + +const PADDING = 40; + +export function computeFileGroups( + nodes: WorkflowNode[], + sourceMap: Map, +): FileGroupData[] { + // Resolve sourceFile for each node: prefer node.data.sourceFile, fall back to sourceMap + const nodeFileMap = new Map(); + for (const node of nodes) { + const label = node.data.label as string; + const sourceFile = (node.data.sourceFile as string | undefined) ?? sourceMap.get(label); + if (sourceFile) { + nodeFileMap.set(node.id, sourceFile); + } + } + + // Group node IDs by source file + const fileToNodeIds = new Map(); + for (const [nodeId, filePath] of nodeFileMap.entries()) { + const ids = fileToNodeIds.get(filePath) ?? []; + ids.push(nodeId); + fileToNodeIds.set(filePath, ids); + } + + // Only create groups when 2+ distinct source files + if (fileToNodeIds.size < 2) return []; + + const result: FileGroupData[] = []; + let colorIndex = 0; + + for (const [filePath, nodeIds] of fileToNodeIds.entries()) { + const groupNodes = nodes.filter((n) => nodeIds.includes(n.id)); + if (groupNodes.length === 0) continue; + + // Compute bounding box from node positions and measured dimensions + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const node of groupNodes) { + const x = node.position.x; + const y = node.position.y; + const w = node.measured?.width ?? 180; + const h = node.measured?.height ?? 80; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x + w > maxX) maxX = x + w; + if (y + h > maxY) maxY = y + h; + } + + const bounds = { + x: minX - PADDING, + y: minY - PADDING, + width: maxX - minX + PADDING * 2, + height: maxY - minY + PADDING * 2, + }; + + const color = FILE_GROUP_COLORS[colorIndex % FILE_GROUP_COLORS.length]; + colorIndex++; + + result.push({ filePath, nodeIds, bounds, color }); + } + + return result; +} From 9824a2f3f7bc9b592f177a1bf53c78df425c02ec Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 02:31:53 -0400 Subject: [PATCH 08/37] feat: add navigation helpers, update onNavigateToSource to multi-file overload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - editor.ts: onNavigateToSource now accepts (line, col) | (filePath, line, col) overload for backward-compatible multi-file navigation; add onNodeFocusRequest hook - navigation.ts: resolveNodeSourceLocation (node → filePath+line) and resolveLineToNode (filePath+line → node) using MultiFileYamlLineMap - WorkflowCanvas: add onNodeClick handler that resolves source location and calls onNavigateToSource(filePath, line, col); accept lineMap + sourceMap props - WorkflowEditor: compute multiFileLineMap from exportToFileMap() and pass to canvas - navigation.test.ts: 12 unit tests covering all navigation helper edge cases Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/WorkflowEditor.tsx | 13 +- src/components/canvas/WorkflowCanvas.tsx | 21 ++- src/types/editor.ts | 7 +- src/utils/navigation.test.ts | 156 +++++++++++++++++++++++ src/utils/navigation.ts | 48 +++++++ 5 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 src/utils/navigation.test.ts create mode 100644 src/utils/navigation.ts diff --git a/src/components/WorkflowEditor.tsx b/src/components/WorkflowEditor.tsx index f2ce3c7..b0c68ba 100644 --- a/src/components/WorkflowEditor.tsx +++ b/src/components/WorkflowEditor.tsx @@ -10,7 +10,7 @@ import useUILayoutStore from '../stores/uiLayoutStore.ts'; import ToastContainer from './ToastContainer.tsx'; import { parseYamlSafe, configToYaml, resolveImports, hasFileReferences } from '../utils/serialization.ts'; import { applyMode } from '../modes/defaultMode.ts'; -import { buildYamlLineMap } from '../utils/yamlLineMap.ts'; +import { buildYamlLineMap, buildMultiFileLineMap } from '../utils/yamlLineMap.ts'; import { YamlSidePane } from './yaml/YamlSidePane.tsx'; import { useEffect, useRef, useState, useMemo } from 'react'; @@ -150,6 +150,15 @@ export function WorkflowEditor(props: WorkflowEditorProps) { return buildYamlLineMap(fileContent)[label]; }, [showYamlPane, selectedNodeId, nodes, yamlFiles, activeYamlFile]); + // Multi-file line map for node click → navigate to source + const multiFileLineMap = useMemo(() => { + if (!onNavigateToSource) return undefined; + const fileMap = exportToFileMap(); + if (fileMap.size === 0) return undefined; + return buildMultiFileLineMap(fileMap); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onNavigateToSource, nodes, exportToFileMap]); + // When a YAML line is clicked, select the corresponding node on canvas const setSelectedNode = useWorkflowStore((s) => s.setSelectedNode); const handleYamlLineClick = useMemo(() => { @@ -208,6 +217,8 @@ export function WorkflowEditor(props: WorkflowEditorProps) { {!propertyPanelCollapsed && ( diff --git a/src/components/canvas/WorkflowCanvas.tsx b/src/components/canvas/WorkflowCanvas.tsx index 049a7db..6abff0f 100644 --- a/src/components/canvas/WorkflowCanvas.tsx +++ b/src/components/canvas/WorkflowCanvas.tsx @@ -22,6 +22,8 @@ import useModuleSchemaStore from '../../stores/moduleSchemaStore.ts'; import useUILayoutStore from '../../stores/uiLayoutStore.ts'; import { configToYaml } from '../../utils/serialization.ts'; import type { WorkflowEdgeData } from '../../types/workflow.ts'; +import type { MultiFileYamlLineMap } from '../../utils/yamlLineMap.ts'; +import { resolveNodeSourceLocation } from '../../utils/navigation.ts'; import { computeContainerView } from '../../utils/grouping.ts'; import { computeFileGroups } from '../../utils/fileGroups.ts'; import { isTypeCompatible, getOutputTypes, getInputTypes, getCompatibleNodes, canAcceptIncoming, canAcceptOutgoing } from '../../utils/connectionCompatibility.ts'; @@ -43,7 +45,9 @@ interface ContextMenuState { interface WorkflowCanvasProps { onSave?: (yaml: string) => Promise; - onNavigateToSource?: (line: number, col: number) => void; + onNavigateToSource?: (...args: [number, number] | [string | null, number, number]) => void; + lineMap?: MultiFileYamlLineMap; + sourceMap?: Map; } export default function WorkflowCanvas(props: WorkflowCanvasProps) { @@ -219,6 +223,20 @@ export default function WorkflowCanvas(props: WorkflowCanvasProps) { [onConnect] ); + const handleNodeClick = useCallback( + (_event: React.MouseEvent, node: RFNode) => { + if (!props.onNavigateToSource || !props.lineMap) return; + const workflowNode = nodes.find((n) => n.id === node.id); + if (!workflowNode) return; + const sm = props.sourceMap ?? new Map(); + const loc = resolveNodeSourceLocation(workflowNode, props.lineMap, sm); + if (loc) { + props.onNavigateToSource(loc.filePath, loc.line, loc.col); + } + }, + [nodes, props.onNavigateToSource, props.lineMap, props.sourceMap] + ); + const handleNodeDoubleClick = useCallback( (_event: React.MouseEvent, node: { id: string }) => { setSelectedNode(node.id); @@ -553,6 +571,7 @@ export default function WorkflowCanvas(props: WorkflowCanvasProps) { onEdgeContextMenu={handleEdgeContextMenu} onNodeContextMenu={handleNodeContextMenu} onPaneClick={handlePaneClick} + onNodeClick={handleNodeClick} onNodeDoubleClick={handleNodeDoubleClick} nodeTypes={nodeTypes} edgeTypes={edgeTypes} diff --git a/src/types/editor.ts b/src/types/editor.ts index 86a30fa..99d1558 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -66,8 +66,11 @@ export interface WorkflowEditorProps { * If multi-file resolution is active, fileMap contains relative-path → YAML for each file. * The null key in fileMap represents the main/open file. */ onSave?: (yaml: string, fileMap?: Map) => Promise; - /** Called when user clicks a node — host should navigate to the YAML line */ - onNavigateToSource?: (line: number, col: number) => void; + /** Called when user clicks a node — host should navigate to the YAML line. + * Backward-compatible overload: (line, col) for single-file; (filePath, line, col) for multi-file. */ + onNavigateToSource?: (...args: [number, number] | [string | null, number, number]) => void; + /** Called when the editor requests focus on a specific node by id (e.g. from IDE → editor direction). */ + onNodeFocusRequest?: (nodeId: string) => void; /** Called when editor needs schema data (module types, step types) */ onSchemaRequest?: () => Promise; /** Called when editor needs plugin schemas */ diff --git a/src/utils/navigation.test.ts b/src/utils/navigation.test.ts new file mode 100644 index 0000000..b23460e --- /dev/null +++ b/src/utils/navigation.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import { resolveNodeSourceLocation, resolveLineToNode } from './navigation.ts'; +import type { MultiFileYamlLineMap } from './yamlLineMap.ts'; +import type { WorkflowNode } from '../stores/workflowStore.ts'; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeNode(id: string, label: string, sourceFile?: string): WorkflowNode { + return { + id, + type: 'default', + position: { x: 0, y: 0 }, + data: { + moduleType: 'test', + label, + config: {}, + ...(sourceFile !== undefined ? { sourceFile } : {}), + }, + }; +} + +function makeLineMap(entries: Record>): MultiFileYamlLineMap { + const files = new Map>(); + for (const [path, map] of Object.entries(entries)) { + files.set(path === '__null__' ? null : path, map); + } + return { files }; +} + +// ── resolveNodeSourceLocation ──────────────────────────────────────────────── + +describe('resolveNodeSourceLocation', () => { + const lineMap = makeLineMap({ + 'domains/auth.yaml': { + 'auth-db': { startLine: 2, endLine: 7 }, + 'login-handler': { startLine: 13, endLine: 16 }, + }, + 'shared/infra.yaml': { + 'http-server': { startLine: 2, endLine: 5 }, + }, + }); + + it('resolves location using sourceFile from node data', () => { + const node = makeNode('n1', 'auth-db', 'domains/auth.yaml'); + const result = resolveNodeSourceLocation(node, lineMap, new Map()); + + expect(result).not.toBeNull(); + expect(result!.filePath).toBe('domains/auth.yaml'); + expect(result!.line).toBe(2); + expect(result!.col).toBe(0); + }); + + it('resolves location using sourceMap when node has no sourceFile', () => { + const node = makeNode('n1', 'http-server'); + const sourceMap = new Map([['http-server', 'shared/infra.yaml']]); + const result = resolveNodeSourceLocation(node, lineMap, sourceMap); + + expect(result).not.toBeNull(); + expect(result!.filePath).toBe('shared/infra.yaml'); + expect(result!.line).toBe(2); + }); + + it('prefers node.data.sourceFile over sourceMap', () => { + const node = makeNode('n1', 'auth-db', 'domains/auth.yaml'); + // sourceMap says a different file, but node.data.sourceFile should win + const sourceMap = new Map([['auth-db', 'shared/infra.yaml']]); + const result = resolveNodeSourceLocation(node, lineMap, sourceMap); + + expect(result!.filePath).toBe('domains/auth.yaml'); + }); + + it('returns null filePath when node has no sourceFile and no sourceMap entry', () => { + const lineMapWithNull = makeLineMap({ + __null__: { 'auth-db': { startLine: 2, endLine: 7 } }, + }); + const node = makeNode('n1', 'auth-db'); + const result = resolveNodeSourceLocation(node, lineMapWithNull, new Map()); + + expect(result).not.toBeNull(); + expect(result!.filePath).toBeNull(); + }); + + it('returns null when node label is not in the resolved file map', () => { + const node = makeNode('n1', 'unknown-node', 'domains/auth.yaml'); + const result = resolveNodeSourceLocation(node, lineMap, new Map()); + + expect(result).toBeNull(); + }); + + it('returns null when the resolved file is not in the line map', () => { + const node = makeNode('n1', 'auth-db', 'missing/file.yaml'); + const result = resolveNodeSourceLocation(node, lineMap, new Map()); + + expect(result).toBeNull(); + }); +}); + +// ── resolveLineToNode ──────────────────────────────────────────────────────── + +describe('resolveLineToNode', () => { + const lineMap = makeLineMap({ + 'domains/auth.yaml': { + 'auth-db': { startLine: 2, endLine: 7 }, + 'login-handler': { startLine: 13, endLine: 16 }, + }, + }); + + const nodes: WorkflowNode[] = [ + makeNode('n1', 'auth-db', 'domains/auth.yaml'), + makeNode('n2', 'login-handler', 'domains/auth.yaml'), + makeNode('n3', 'http-server', 'shared/infra.yaml'), + ]; + + const sourceMap = new Map([ + ['auth-db', 'domains/auth.yaml'], + ['login-handler', 'domains/auth.yaml'], + ['http-server', 'shared/infra.yaml'], + ]); + + it('finds node whose line range contains the given line', () => { + const result = resolveLineToNode('domains/auth.yaml', 4, lineMap, nodes, sourceMap); + + expect(result).not.toBeNull(); + expect(result!.data.label).toBe('auth-db'); + }); + + it('finds node at the start line of its range', () => { + const result = resolveLineToNode('domains/auth.yaml', 2, lineMap, nodes, sourceMap); + + expect(result!.data.label).toBe('auth-db'); + }); + + it('finds node at the end line of its range', () => { + const result = resolveLineToNode('domains/auth.yaml', 7, lineMap, nodes, sourceMap); + + expect(result!.data.label).toBe('auth-db'); + }); + + it('finds a different node for a line in a different range', () => { + const result = resolveLineToNode('domains/auth.yaml', 14, lineMap, nodes, sourceMap); + + expect(result!.data.label).toBe('login-handler'); + }); + + it('returns null for a line between ranges', () => { + const result = resolveLineToNode('domains/auth.yaml', 10, lineMap, nodes, sourceMap); + + expect(result).toBeNull(); + }); + + it('returns null for a file not in the line map', () => { + const result = resolveLineToNode('missing/file.yaml', 1, lineMap, nodes, sourceMap); + + expect(result).toBeNull(); + }); +}); diff --git a/src/utils/navigation.ts b/src/utils/navigation.ts new file mode 100644 index 0000000..08a5f7d --- /dev/null +++ b/src/utils/navigation.ts @@ -0,0 +1,48 @@ +import type { WorkflowNode } from '../stores/workflowStore.ts'; +import type { MultiFileYamlLineMap, YamlLineRange } from './yamlLineMap.ts'; + +export function resolveNodeSourceLocation( + node: WorkflowNode, + lineMap: MultiFileYamlLineMap, + sourceMap: Map, +): { filePath: string | null; line: number; col: number } | null { + const label = node.data.label; + const filePath = node.data.sourceFile ?? sourceMap.get(label) ?? null; + + const fileMap = lineMap.files.get(filePath); + if (!fileMap) return null; + + const range: YamlLineRange | undefined = fileMap[label]; + if (!range) return null; + + return { filePath, line: range.startLine, col: 0 }; +} + +export function resolveLineToNode( + filePath: string | null, + line: number, + lineMap: MultiFileYamlLineMap, + nodes: WorkflowNode[], + sourceMap: Map, +): WorkflowNode | null { + const fileMap = lineMap.files.get(filePath); + if (!fileMap) return null; + + let matchName: string | null = null; + for (const [name, range] of Object.entries(fileMap)) { + if (line >= range.startLine && line <= range.endLine) { + matchName = name; + break; + } + } + if (!matchName) return null; + + const name = matchName; + return ( + nodes.find((n) => { + if (n.data.label !== name) return false; + const nodeFilePath = n.data.sourceFile ?? sourceMap.get(n.data.label) ?? null; + return nodeFilePath === filePath; + }) ?? null + ); +} From c898775af4d7b80df604446cbd711cd78594c135 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 02:38:01 -0400 Subject: [PATCH 09/37] feat: implement E2E visual validation tests (Task 5) - Add playwright.config.ts: chromium-only, starts test app on port 5174 - Add e2e/test-app/: minimal Vite+React harness serving WorkflowEditor with multi-file config scenarios (?scenario=multifile-groups|yaml-pane) - Add e2e/multifile.spec.ts: 11 Playwright tests covering: - File group overlay nodes rendered for each source file (2 groups) - File group labels show correct filename - File group overlays don't block canvas interaction - YAML side-pane renders tabs for each file (main/auth.yaml/billing.yaml) - Tab switching works, active tab marked correctly - Node click switches to correct file tab + highlights YAML lines - YAML line click activates node selection feedback - Fix group node width/height: set both node.width/height and style.width/height so React Flow correctly renders measured dimensions; move zIndex out of style to top-level node property All 11 E2E tests pass, 696 unit tests unaffected. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/multifile.spec.ts | 150 +++++++++++++++++++++++ e2e/test-app/index.html | 22 ++++ e2e/test-app/main.tsx | 64 ++++++++++ e2e/test-app/vite.config.ts | 17 +++ playwright.config.ts | 22 ++++ src/components/canvas/WorkflowCanvas.tsx | 4 +- 6 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 e2e/multifile.spec.ts create mode 100644 e2e/test-app/index.html create mode 100644 e2e/test-app/main.tsx create mode 100644 e2e/test-app/vite.config.ts create mode 100644 playwright.config.ts diff --git a/e2e/multifile.spec.ts b/e2e/multifile.spec.ts new file mode 100644 index 0000000..b187955 --- /dev/null +++ b/e2e/multifile.spec.ts @@ -0,0 +1,150 @@ +import { test, expect } from '@playwright/test'; + +// E2E tests for multi-file visual features (file group boundaries + YAML side-pane). +// Run with: npx playwright test e2e/multifile.spec.ts +// Prerequisites: test server at http://localhost:5174 (started by playwright.config.ts webServer) + +test.describe('multi-file file group boundaries', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?scenario=multifile-groups'); + // Wait for React Flow canvas to be present + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); + // Allow time for nodes to render and layout + await page.waitForTimeout(500); + }); + + test('renders file group overlay nodes for each source file', async ({ page }) => { + // FileGroupNode nodes have data-id starting with __file-group__ + const groupNodes = page.locator('[data-id^="__file-group__"]'); + await expect(groupNodes).toHaveCount(2); + }); + + test('file group nodes have distinct labels matching source files', async ({ page }) => { + const authGroup = page.locator('[data-id="__file-group__auth.yaml"]'); + const billingGroup = page.locator('[data-id="__file-group__billing.yaml"]'); + // Group nodes are overlay elements (zIndex:-1), so use toBeAttached rather than toBeVisible + await expect(authGroup).toBeAttached(); + await expect(billingGroup).toBeAttached(); + // Labels should show the filename + await expect(authGroup.locator('span').first()).toContainText('auth.yaml'); + await expect(billingGroup.locator('span').first()).toContainText('billing.yaml'); + }); + + test('file group nodes do not block interaction with canvas nodes', async ({ page }) => { + // Find the auth-server node (a real workflow node) + const authNode = page.locator('.react-flow__node').filter({ hasText: 'auth-server' }).first(); + await expect(authNode).toBeVisible(); + // File group overlays use pointer-events:none so the real node should be clickable + await authNode.click(); + // Node should be selected after click (no error thrown) + }); +}); + +test.describe('YAML side-pane', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?scenario=yaml-pane'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); + await page.waitForTimeout(500); + }); + + test('renders file tabs for each source file', async ({ page }) => { + // YamlSidePane renders FileTabBar which has .yaml-tab buttons + const tabs = page.locator('.yaml-tab'); + // Should have tabs for: main (null), auth.yaml, billing.yaml + await expect(tabs).toHaveCount(3); + }); + + test('shows "main" tab for the root config file', async ({ page }) => { + const mainTab = page.locator('.yaml-tab').filter({ hasText: 'main' }); + await expect(mainTab).toBeVisible(); + }); + + test('shows a tab for each source file', async ({ page }) => { + const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); + const billingTab = page.locator('.yaml-tab').filter({ hasText: 'billing.yaml' }); + await expect(authTab).toBeVisible(); + await expect(billingTab).toBeVisible(); + }); + + test('switches active tab when tab is clicked', async ({ page }) => { + // auth.yaml tab is initially not active + const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); + await expect(authTab).not.toHaveClass(/yaml-tab-active/); + + // Click it + await authTab.click(); + + // Now it should be active + await expect(authTab).toHaveClass(/yaml-tab-active/); + }); + + test('YAML content area is present', async ({ page }) => { + const lines = page.locator('.yaml-line'); + await expect(lines.first()).toBeVisible(); + }); +}); + +test.describe('node selection highlights YAML in side-pane', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?scenario=yaml-pane'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); + await page.waitForTimeout(500); + }); + + test('clicking a node switches to its source file tab', async ({ page }) => { + // Click the billing-service node (which belongs to billing.yaml) + const billingNode = page.locator('.react-flow__node').filter({ hasText: 'billing-service' }).first(); + await expect(billingNode).toBeVisible(); + await billingNode.click(); + await page.waitForTimeout(200); + + // The billing.yaml tab should become active + const billingTab = page.locator('.yaml-tab').filter({ hasText: 'billing.yaml' }); + await expect(billingTab).toHaveClass(/yaml-tab-active/); + }); + + test('clicking a node creates highlighted lines in YAML pane', async ({ page }) => { + // Click the auth-server node + const authNode = page.locator('.react-flow__node').filter({ hasText: 'auth-server' }).first(); + await expect(authNode).toBeVisible(); + await authNode.click(); + await page.waitForTimeout(200); + + // Some lines should be highlighted + const highlightedLines = page.locator('.yaml-line-highlighted'); + const count = await highlightedLines.count(); + expect(count).toBeGreaterThan(0); + }); +}); + +test.describe('YAML line click selects canvas node', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?scenario=yaml-pane'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); + await page.waitForTimeout(500); + }); + + test('clicking a YAML line activates corresponding node selection (highlights YAML range)', async ({ page }) => { + // Switch to auth.yaml tab + const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); + await authTab.click(); + await page.waitForTimeout(200); + + // Click a line in auth.yaml that belongs to a module definition + // This should call setSelectedNode → re-render with highlight range + const nodeLine = page.locator('.yaml-line code').filter({ hasText: 'auth-server' }).first(); + await expect(nodeLine).toBeVisible(); + await nodeLine.click(); + await page.waitForTimeout(300); + + // After clicking, the selection feedback should appear: + // Either highlighted lines (if same file) or auth.yaml tab stays active + const authTabActive = page.locator('.yaml-tab-active').filter({ hasText: 'auth.yaml' }); + await expect(authTabActive).toBeVisible(); + + // The highlighted lines in the YAML pane should correspond to the selected node + const highlightedLines = page.locator('.yaml-line-highlighted'); + const highlightCount = await highlightedLines.count(); + expect(highlightCount).toBeGreaterThan(0); + }); +}); diff --git a/e2e/test-app/index.html b/e2e/test-app/index.html new file mode 100644 index 0000000..2cd77c6 --- /dev/null +++ b/e2e/test-app/index.html @@ -0,0 +1,22 @@ + + + + + + Workflow Editor Test Harness + + + +
+ + + diff --git a/e2e/test-app/main.tsx b/e2e/test-app/main.tsx new file mode 100644 index 0000000..b674c31 --- /dev/null +++ b/e2e/test-app/main.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { WorkflowEditor } from '@workflow-editor/components/WorkflowEditor.tsx'; + +// Multi-file YAML: two modules from different source files +const MULTIFILE_YAML = `modules: + - name: auth-server + type: http.server + config: + address: :8080 + - name: billing-service + type: http.server + config: + address: :8081 +`; + +// sourceMap: each module name → source file path +const MULTIFILE_SOURCE_MAP: Record = { + 'auth-server': 'auth.yaml', + 'billing-service': 'billing.yaml', +}; + +function getScenario(): string { + return new URLSearchParams(window.location.search).get('scenario') ?? 'default'; +} + +function App() { + const scenario = getScenario(); + + if (scenario === 'multifile-groups') { + return ( +
+ +
+ ); + } + + if (scenario === 'yaml-pane') { + return ( +
+ +
+ ); + } + + // Default: single file + return ( +
+ +
+ ); +} + +const root = createRoot(document.getElementById('root')!); +root.render(); diff --git a/e2e/test-app/vite.config.ts b/e2e/test-app/vite.config.ts new file mode 100644 index 0000000..eabf625 --- /dev/null +++ b/e2e/test-app/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import { resolve } from 'path'; + +export default defineConfig({ + root: __dirname, + plugins: [react()], + resolve: { + alias: { + // Import from library source directly + '@workflow-editor': resolve(__dirname, '../../src'), + }, + }, + server: { + port: 5174, + }, +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..d4dfd50 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + use: { + baseURL: 'http://localhost:5174', + headless: true, + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npx vite --config e2e/test-app/vite.config.ts --port 5174', + port: 5174, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + }, +}); diff --git a/src/components/canvas/WorkflowCanvas.tsx b/src/components/canvas/WorkflowCanvas.tsx index 6abff0f..ac41fc1 100644 --- a/src/components/canvas/WorkflowCanvas.tsx +++ b/src/components/canvas/WorkflowCanvas.tsx @@ -171,12 +171,14 @@ export default function WorkflowCanvas(props: WorkflowCanvasProps) { id: `__file-group__${group.filePath}`, type: 'fileGroup', position: { x: group.bounds.x, y: group.bounds.y }, + width: group.bounds.width, + height: group.bounds.height, style: { width: group.bounds.width, height: group.bounds.height, pointerEvents: 'none' as const, - zIndex: -1, }, + zIndex: -1, data: { label: group.filePath.split('/').pop() ?? group.filePath, filePath: group.filePath, From 7572f0dbc1987b4595363e4fd4b9722a3d83f536 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 02:39:54 -0400 Subject: [PATCH 10/37] test: add missing scroll and FileGroupNode tests per review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - YamlSidePane.test.tsx: add 'scrolls to highlighted lines' test — mocks HTMLElement.prototype.scrollIntoView and verifies it is called when highlightRange is provided (10 tests total) - FileGroupNode.test.tsx: new file with 4 render tests: - renders label text correctly - applies border color from color prop (dashed + color value) - applies background color from color prop - uses dashed border style Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/nodes/FileGroupNode.test.tsx | 88 +++++++++++++++++++++ src/components/yaml/YamlSidePane.test.tsx | 19 +++++ 2 files changed, 107 insertions(+) create mode 100644 src/components/nodes/FileGroupNode.test.tsx diff --git a/src/components/nodes/FileGroupNode.test.tsx b/src/components/nodes/FileGroupNode.test.tsx new file mode 100644 index 0000000..f968d81 --- /dev/null +++ b/src/components/nodes/FileGroupNode.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from '@testing-library/react'; +import { ReactFlowProvider } from '@xyflow/react'; +import { describe, it, expect } from 'vitest'; +import FileGroupNode from './FileGroupNode.tsx'; +import type { NodeProps } from '@xyflow/react'; +import type { FileGroupNodeData } from './FileGroupNode.tsx'; + +function makeProps(data: FileGroupNodeData): NodeProps { + return { + id: 'test-group', + type: 'fileGroup', + data, + selected: false, + isConnectable: false, + dragging: false, + zIndex: -1, + width: 400, + height: 200, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + } as NodeProps; +} + +describe('FileGroupNode', () => { + it('renders the label text', () => { + render( + + + , + ); + expect(screen.getByText('auth.yaml')).toBeTruthy(); + }); + + it('applies the border color from the color prop', () => { + const { container } = render( + + + , + ); + const outer = container.firstChild as HTMLElement; + // jsdom normalizes hex to rgb; verify the border includes the color value + expect(outer.style.border).toMatch(/rgb\(134,\s*239,\s*172\)/); + }); + + it('applies the background color from the color prop', () => { + const { container } = render( + + + , + ); + const outer = container.firstChild as HTMLElement; + expect(outer.style.background).toMatch(/rgb\(37,\s*26,\s*46\)/); + }); + + it('uses dashed border style', () => { + const { container } = render( + + + , + ); + const outer = container.firstChild as HTMLElement; + expect(outer.style.border).toContain('dashed'); + }); +}); diff --git a/src/components/yaml/YamlSidePane.test.tsx b/src/components/yaml/YamlSidePane.test.tsx index 57c99fa..75bf616 100644 --- a/src/components/yaml/YamlSidePane.test.tsx +++ b/src/components/yaml/YamlSidePane.test.tsx @@ -71,6 +71,25 @@ describe('YamlSidePane', () => { expect(onLineClick).toHaveBeenCalledWith(null, 1); }); + it('scrolls to highlighted lines when highlightRange changes', () => { + const scrollIntoViewMock = vi.fn(); + // Install mock before render so all elements created get it + HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + + render( + , + ); + + // scrollIntoView should have been called on the element at startLine + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); + it('does not render when visible=false', () => { const { container } = render( Date: Fri, 27 Mar 2026 02:42:20 -0400 Subject: [PATCH 11/37] fix: move multifile E2E tests into editor.spec.ts per spec requirement - Merge all multi-file describe blocks into e2e/editor.spec.ts - Remove e2e/multifile.spec.ts (now redundant) - Update legacy placeholder tests to use relative URL (test harness on :5174) - All 15 E2E tests pass Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/editor.spec.ts | 143 +++++++++++++++++++++++++++++++++++----- e2e/multifile.spec.ts | 150 ------------------------------------------ 2 files changed, 127 insertions(+), 166 deletions(-) delete mode 100644 e2e/multifile.spec.ts diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 3c242db..d0f5eb4 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -1,40 +1,151 @@ import { test, expect } from '@playwright/test'; -// E2E tests for the workflow editor embedded in the workflow/ui app. +// E2E tests for the workflow editor. // Run with: npx playwright test -// Prerequisites: npm run dev in workflow/ui (serves on http://localhost:5173) - -const BASE_URL = 'http://localhost:5173'; +// The playwright.config.ts webServer starts the test harness on http://localhost:5174 test.describe('Workflow Editor E2E', () => { test('editor loads and renders canvas', async ({ page }) => { - // TODO: Navigate to a workflow that has nodes loaded - await page.goto(BASE_URL); - // TODO: Verify the ReactFlow canvas is present - // expect(await page.locator('[data-testid="rf__wrapper"]').count()).toBeGreaterThan(0); + await page.goto('/'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); expect(page.url()).toContain('localhost'); }); test('loads YAML and renders nodes', async ({ page }) => { - await page.goto(BASE_URL); + await page.goto('/'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); // TODO: Load a sample workflow config via UI or API - // TODO: Verify that nodes appear on the canvas - // const nodes = page.locator('.react-flow__node'); - // await expect(nodes).toHaveCountGreaterThan(0); expect(true).toBe(true); // placeholder }); test('add node from palette updates canvas', async ({ page }) => { - await page.goto(BASE_URL); + await page.goto('/'); // TODO: Open node palette, double-click an item to add a node - // TODO: Verify the new node appears on canvas expect(true).toBe(true); // placeholder }); test('editing node config updates YAML', async ({ page }) => { - await page.goto(BASE_URL); + await page.goto('/'); // TODO: Select a node, edit a config field in property panel - // TODO: Verify the YAML representation updates accordingly expect(true).toBe(true); // placeholder }); }); + +// --------------------------------------------------------------------------- +// Multi-file visual features: file group boundaries + YAML side-pane +// These tests use the test harness app served by the playwright webServer. +// --------------------------------------------------------------------------- + +test.describe('multi-file file group boundaries', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?scenario=multifile-groups'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); + await page.waitForTimeout(500); + }); + + test('renders file group overlay nodes for each source file', async ({ page }) => { + const groupNodes = page.locator('[data-id^="__file-group__"]'); + await expect(groupNodes).toHaveCount(2); + }); + + test('file group nodes have distinct labels matching source files', async ({ page }) => { + const authGroup = page.locator('[data-id="__file-group__auth.yaml"]'); + const billingGroup = page.locator('[data-id="__file-group__billing.yaml"]'); + // Group nodes are overlay elements (zIndex:-1), use toBeAttached rather than toBeVisible + await expect(authGroup).toBeAttached(); + await expect(billingGroup).toBeAttached(); + await expect(authGroup.locator('span').first()).toContainText('auth.yaml'); + await expect(billingGroup.locator('span').first()).toContainText('billing.yaml'); + }); + + test('file group nodes do not block interaction with canvas nodes', async ({ page }) => { + const authNode = page.locator('.react-flow__node').filter({ hasText: 'auth-server' }).first(); + await expect(authNode).toBeVisible(); + // File group overlays use pointer-events:none so the real node should be clickable + await authNode.click(); + }); +}); + +test.describe('YAML side-pane', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?scenario=yaml-pane'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); + await page.waitForTimeout(500); + }); + + test('renders file tabs for each source file', async ({ page }) => { + const tabs = page.locator('.yaml-tab'); + // Tabs for: main (null), auth.yaml, billing.yaml + await expect(tabs).toHaveCount(3); + }); + + test('shows "main" tab for the root config file', async ({ page }) => { + const mainTab = page.locator('.yaml-tab').filter({ hasText: 'main' }); + await expect(mainTab).toBeVisible(); + }); + + test('shows a tab for each source file', async ({ page }) => { + await expect(page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' })).toBeVisible(); + await expect(page.locator('.yaml-tab').filter({ hasText: 'billing.yaml' })).toBeVisible(); + }); + + test('switches active tab when tab is clicked', async ({ page }) => { + const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); + await expect(authTab).not.toHaveClass(/yaml-tab-active/); + await authTab.click(); + await expect(authTab).toHaveClass(/yaml-tab-active/); + }); + + test('YAML content area is present', async ({ page }) => { + await expect(page.locator('.yaml-line').first()).toBeVisible(); + }); +}); + +test.describe('node selection highlights YAML in side-pane', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?scenario=yaml-pane'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); + await page.waitForTimeout(500); + }); + + test('clicking a node switches to its source file tab', async ({ page }) => { + const billingNode = page.locator('.react-flow__node').filter({ hasText: 'billing-service' }).first(); + await expect(billingNode).toBeVisible(); + await billingNode.click(); + await page.waitForTimeout(200); + const billingTab = page.locator('.yaml-tab').filter({ hasText: 'billing.yaml' }); + await expect(billingTab).toHaveClass(/yaml-tab-active/); + }); + + test('clicking a node creates highlighted lines in YAML pane', async ({ page }) => { + const authNode = page.locator('.react-flow__node').filter({ hasText: 'auth-server' }).first(); + await expect(authNode).toBeVisible(); + await authNode.click(); + await page.waitForTimeout(200); + const count = await page.locator('.yaml-line-highlighted').count(); + expect(count).toBeGreaterThan(0); + }); +}); + +test.describe('YAML line click selects canvas node', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/?scenario=yaml-pane'); + await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); + await page.waitForTimeout(500); + }); + + test('clicking a YAML line activates corresponding node selection (highlights YAML range)', async ({ page }) => { + const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); + await authTab.click(); + await page.waitForTimeout(200); + + const nodeLine = page.locator('.yaml-line code').filter({ hasText: 'auth-server' }).first(); + await expect(nodeLine).toBeVisible(); + await nodeLine.click(); + await page.waitForTimeout(300); + + await expect(page.locator('.yaml-tab-active').filter({ hasText: 'auth.yaml' })).toBeVisible(); + const highlightCount = await page.locator('.yaml-line-highlighted').count(); + expect(highlightCount).toBeGreaterThan(0); + }); +}); diff --git a/e2e/multifile.spec.ts b/e2e/multifile.spec.ts deleted file mode 100644 index b187955..0000000 --- a/e2e/multifile.spec.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { test, expect } from '@playwright/test'; - -// E2E tests for multi-file visual features (file group boundaries + YAML side-pane). -// Run with: npx playwright test e2e/multifile.spec.ts -// Prerequisites: test server at http://localhost:5174 (started by playwright.config.ts webServer) - -test.describe('multi-file file group boundaries', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/?scenario=multifile-groups'); - // Wait for React Flow canvas to be present - await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); - // Allow time for nodes to render and layout - await page.waitForTimeout(500); - }); - - test('renders file group overlay nodes for each source file', async ({ page }) => { - // FileGroupNode nodes have data-id starting with __file-group__ - const groupNodes = page.locator('[data-id^="__file-group__"]'); - await expect(groupNodes).toHaveCount(2); - }); - - test('file group nodes have distinct labels matching source files', async ({ page }) => { - const authGroup = page.locator('[data-id="__file-group__auth.yaml"]'); - const billingGroup = page.locator('[data-id="__file-group__billing.yaml"]'); - // Group nodes are overlay elements (zIndex:-1), so use toBeAttached rather than toBeVisible - await expect(authGroup).toBeAttached(); - await expect(billingGroup).toBeAttached(); - // Labels should show the filename - await expect(authGroup.locator('span').first()).toContainText('auth.yaml'); - await expect(billingGroup.locator('span').first()).toContainText('billing.yaml'); - }); - - test('file group nodes do not block interaction with canvas nodes', async ({ page }) => { - // Find the auth-server node (a real workflow node) - const authNode = page.locator('.react-flow__node').filter({ hasText: 'auth-server' }).first(); - await expect(authNode).toBeVisible(); - // File group overlays use pointer-events:none so the real node should be clickable - await authNode.click(); - // Node should be selected after click (no error thrown) - }); -}); - -test.describe('YAML side-pane', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/?scenario=yaml-pane'); - await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); - await page.waitForTimeout(500); - }); - - test('renders file tabs for each source file', async ({ page }) => { - // YamlSidePane renders FileTabBar which has .yaml-tab buttons - const tabs = page.locator('.yaml-tab'); - // Should have tabs for: main (null), auth.yaml, billing.yaml - await expect(tabs).toHaveCount(3); - }); - - test('shows "main" tab for the root config file', async ({ page }) => { - const mainTab = page.locator('.yaml-tab').filter({ hasText: 'main' }); - await expect(mainTab).toBeVisible(); - }); - - test('shows a tab for each source file', async ({ page }) => { - const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); - const billingTab = page.locator('.yaml-tab').filter({ hasText: 'billing.yaml' }); - await expect(authTab).toBeVisible(); - await expect(billingTab).toBeVisible(); - }); - - test('switches active tab when tab is clicked', async ({ page }) => { - // auth.yaml tab is initially not active - const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); - await expect(authTab).not.toHaveClass(/yaml-tab-active/); - - // Click it - await authTab.click(); - - // Now it should be active - await expect(authTab).toHaveClass(/yaml-tab-active/); - }); - - test('YAML content area is present', async ({ page }) => { - const lines = page.locator('.yaml-line'); - await expect(lines.first()).toBeVisible(); - }); -}); - -test.describe('node selection highlights YAML in side-pane', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/?scenario=yaml-pane'); - await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); - await page.waitForTimeout(500); - }); - - test('clicking a node switches to its source file tab', async ({ page }) => { - // Click the billing-service node (which belongs to billing.yaml) - const billingNode = page.locator('.react-flow__node').filter({ hasText: 'billing-service' }).first(); - await expect(billingNode).toBeVisible(); - await billingNode.click(); - await page.waitForTimeout(200); - - // The billing.yaml tab should become active - const billingTab = page.locator('.yaml-tab').filter({ hasText: 'billing.yaml' }); - await expect(billingTab).toHaveClass(/yaml-tab-active/); - }); - - test('clicking a node creates highlighted lines in YAML pane', async ({ page }) => { - // Click the auth-server node - const authNode = page.locator('.react-flow__node').filter({ hasText: 'auth-server' }).first(); - await expect(authNode).toBeVisible(); - await authNode.click(); - await page.waitForTimeout(200); - - // Some lines should be highlighted - const highlightedLines = page.locator('.yaml-line-highlighted'); - const count = await highlightedLines.count(); - expect(count).toBeGreaterThan(0); - }); -}); - -test.describe('YAML line click selects canvas node', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/?scenario=yaml-pane'); - await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); - await page.waitForTimeout(500); - }); - - test('clicking a YAML line activates corresponding node selection (highlights YAML range)', async ({ page }) => { - // Switch to auth.yaml tab - const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); - await authTab.click(); - await page.waitForTimeout(200); - - // Click a line in auth.yaml that belongs to a module definition - // This should call setSelectedNode → re-render with highlight range - const nodeLine = page.locator('.yaml-line code').filter({ hasText: 'auth-server' }).first(); - await expect(nodeLine).toBeVisible(); - await nodeLine.click(); - await page.waitForTimeout(300); - - // After clicking, the selection feedback should appear: - // Either highlighted lines (if same file) or auth.yaml tab stays active - const authTabActive = page.locator('.yaml-tab-active').filter({ hasText: 'auth.yaml' }); - await expect(authTabActive).toBeVisible(); - - // The highlighted lines in the YAML pane should correspond to the selected node - const highlightedLines = page.locator('.yaml-line-highlighted'); - const highlightCount = await highlightedLines.count(); - expect(highlightCount).toBeGreaterThan(0); - }); -}); From ad690c9cfcdfdcdb92040c095b52488a2ef1748f Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 02:43:05 -0400 Subject: [PATCH 12/37] test(yamlLineMap): add triggers section test as requested by spec-reviewer Co-Authored-By: Claude Sonnet 4.6 --- src/utils/yamlLineMap.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/utils/yamlLineMap.test.ts b/src/utils/yamlLineMap.test.ts index e7d0c0b..06f6204 100644 --- a/src/utils/yamlLineMap.test.ts +++ b/src/utils/yamlLineMap.test.ts @@ -201,3 +201,12 @@ describe('lookupNodeInLineMap', () => { expect(result!.range.startLine).toBe(25); }); }); + +describe('buildYamlLineMap – triggers', () => { + it('maps trigger names to line ranges', () => { + const yaml = `triggers:\n on-save:\n type: cron\n schedule: "* * * * *"\n on-deploy:\n type: webhook\n`; + const map = buildYamlLineMap(yaml); + expect(map['on-save']?.startLine).toBe(2); + expect(map['on-deploy']?.startLine).toBeGreaterThan(2); + }); +}); From 71dd1989c295ae36189a21765e5a3a53b2f3ec24 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 02:48:03 -0400 Subject: [PATCH 13/37] fix(e2e): replace waitForTimeout with proper Playwright DOM-state waiters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates all sleep-based waits in editor.spec.ts to prevent CI flakiness: - beforeEach blocks: waitForTimeout(500) → expect first node toBeVisible - Post-click waits: waitForTimeout(200/300) → wait for specific DOM state changes (tab active class, highlighted lines visible) Co-Authored-By: Claude Sonnet 4.6 --- e2e/editor.spec.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index d0f5eb4..74ba29c 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -40,7 +40,7 @@ test.describe('multi-file file group boundaries', () => { test.beforeEach(async ({ page }) => { await page.goto('/?scenario=multifile-groups'); await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); - await page.waitForTimeout(500); + await expect(page.locator('.react-flow__node').first()).toBeVisible(); }); test('renders file group overlay nodes for each source file', async ({ page }) => { @@ -70,7 +70,7 @@ test.describe('YAML side-pane', () => { test.beforeEach(async ({ page }) => { await page.goto('/?scenario=yaml-pane'); await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); - await page.waitForTimeout(500); + await expect(page.locator('.react-flow__node').first()).toBeVisible(); }); test('renders file tabs for each source file', async ({ page }) => { @@ -105,14 +105,13 @@ test.describe('node selection highlights YAML in side-pane', () => { test.beforeEach(async ({ page }) => { await page.goto('/?scenario=yaml-pane'); await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); - await page.waitForTimeout(500); + await expect(page.locator('.react-flow__node').first()).toBeVisible(); }); test('clicking a node switches to its source file tab', async ({ page }) => { const billingNode = page.locator('.react-flow__node').filter({ hasText: 'billing-service' }).first(); await expect(billingNode).toBeVisible(); await billingNode.click(); - await page.waitForTimeout(200); const billingTab = page.locator('.yaml-tab').filter({ hasText: 'billing.yaml' }); await expect(billingTab).toHaveClass(/yaml-tab-active/); }); @@ -121,7 +120,7 @@ test.describe('node selection highlights YAML in side-pane', () => { const authNode = page.locator('.react-flow__node').filter({ hasText: 'auth-server' }).first(); await expect(authNode).toBeVisible(); await authNode.click(); - await page.waitForTimeout(200); + await expect(page.locator('.yaml-line-highlighted').first()).toBeVisible(); const count = await page.locator('.yaml-line-highlighted').count(); expect(count).toBeGreaterThan(0); }); @@ -131,18 +130,18 @@ test.describe('YAML line click selects canvas node', () => { test.beforeEach(async ({ page }) => { await page.goto('/?scenario=yaml-pane'); await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 }); - await page.waitForTimeout(500); + await expect(page.locator('.react-flow__node').first()).toBeVisible(); }); test('clicking a YAML line activates corresponding node selection (highlights YAML range)', async ({ page }) => { const authTab = page.locator('.yaml-tab').filter({ hasText: 'auth.yaml' }); await authTab.click(); - await page.waitForTimeout(200); + await expect(authTab).toHaveClass(/yaml-tab-active/); const nodeLine = page.locator('.yaml-line code').filter({ hasText: 'auth-server' }).first(); await expect(nodeLine).toBeVisible(); await nodeLine.click(); - await page.waitForTimeout(300); + await expect(page.locator('.yaml-line-highlighted').first()).toBeVisible(); await expect(page.locator('.yaml-tab-active').filter({ hasText: 'auth.yaml' })).toBeVisible(); const highlightCount = await page.locator('.yaml-line-highlighted').count(); From fde372def59a04f8fdc2f5d79cb696fceabc8e9d Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 12:33:42 -0400 Subject: [PATCH 14/37] =?UTF-8?q?docs:=20editor=20completeness=20design=20?= =?UTF-8?q?=E2=80=94=20schema=20tests,=20typed=20forms,=20DSL=20reference,?= =?UTF-8?q?=20navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five workstreams: schema-driven test matrix, typed schema generation from Go structs, 3-layer DSL documentation system, breadcrumb + interactive file group navigation, and property panel completeness testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-27-editor-completeness-design.md | 220 ++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 docs/plans/2026-03-27-editor-completeness-design.md diff --git a/docs/plans/2026-03-27-editor-completeness-design.md b/docs/plans/2026-03-27-editor-completeness-design.md new file mode 100644 index 0000000..b1c67b4 --- /dev/null +++ b/docs/plans/2026-03-27-editor-completeness-design.md @@ -0,0 +1,220 @@ +# Editor Completeness: Schema-Driven Testing, Typed Forms, DSL Reference & Navigation + +**Date:** 2026-03-27 +**Status:** Approved +**Repos:** workflow-editor, workflow, workflow-vscode, workflow-jetbrains + +## Overview + +Five interconnected workstreams to ensure every aspect of the workflow YAML DSL renders correctly in the visual editor, every node's attributes are visible and editable with proper form widgets, and users can understand and navigate the DSL whether they're in the visual editor, an IDE, or writing raw YAML. + +## 1. Schema-Driven Test Matrix + +Auto-generate rendering and property panel tests for every module type from `engine-schemas.json`. New types added via schema sync automatically get test coverage. + +### Architecture + +```mermaid +graph LR + A[engine-schemas.json] -->|read at test time| B[test matrix generator] + B --> C[per-type rendering test] + B --> D[per-type property panel test] + C --> E[correct node component] + C --> F[correct icon/color/category] + D --> G[all ConfigFieldDef fields visible] + D --> H[correct widget per field type] + D --> I[field editable + value roundtrips] +``` + +### Implementation + +- `src/utils/schema-matrix.test.ts` — reads `engine-schemas.json`, iterates every module type +- For each type: creates a minimal config → `configToNodes()` → asserts correct `nodeComponentType` mapping → asserts node data has correct category color +- For each type with `configFields`: mounts `PropertyPanel` with a selected node of that type → asserts every field renders with the correct widget type → asserts editing a field calls `updateNodeConfig` +- `describe.each()` pattern so vitest shows one named test per module type +- When schema sync adds a new type, the test matrix auto-expands + +### Coverage + +- Pipeline step nodes (synthesized) — verify they render as `integrationNode` +- Conditional nodes — verify diamond shape, handle layout per subtype +- Partial YAML — verify modules-only, pipelines-only, imports-only all render without errors + +## 2. Typed Schema Generation (Workflow Engine) + +Eliminate `type: "json"` catch-all fields by generating proper typed schemas from Go structs. This becomes the authoritative schema source and represents a major version bump. + +### Architecture + +```mermaid +graph TB + A[Go module factory] -->|reflect/struct tags| B[schema generator] + B --> C[ConfigFieldDef per field] + C --> D[wfctl editor-schemas output] + D -->|sync-schema CI| E[engine-schemas.json in workflow-editor] + E --> F[PropertyPanel renders typed forms] + E --> G[test matrix validates coverage] +``` + +### Workflow Engine Changes + +- Each module's config struct already has `json`/`yaml` tags. Add struct tags for editor metadata: `editor:"type=select,options=postgres|mysql|sqlite"`, `editor:"description=Database connection string"`, `editor:"required"` +- New `pkg/schema/` package that reflects on config structs → produces `[]ConfigFieldDef` +- `wfctl editor-schemas` enhanced to include full typed fields instead of bare type names +- Every module factory registers its config struct type so the generator can iterate all + +### Validation Contract + +- CI test in workflow repo: for every registered module type, assert `editor-schemas` produces a non-empty `configFields` array with zero `type: "json"` fields +- Any new module that ships with a `json`-typed field fails CI +- Go type safety flows all the way through to the visual editor + +### Migration Path + +1. Add schema generator + struct tags to workflow engine (one module at a time) +2. When all ~70 types have proper schemas → major version bump +3. workflow-editor removes the static `MODULE_TYPES` fallback array — engine schema is authoritative +4. The test matrix catches any regressions + +## 3. DSL Documentation + Reference System + +Make the DSL spec understandable everywhere — in the visual editor, in IDE YAML editing, and in standalone docs. + +### DSL Hierarchy + +```mermaid +graph TD + A[application] --> B[modules] + A --> C[workflows] + A --> D[pipelines] + A --> E[triggers] + A --> F[imports] + C --> G["http: server + router + routes"] + C --> H["messaging: broker + subscriptions"] + C --> I["statemachine: engine + definitions"] + C --> J["event: processor + handlers"] + G -->|route.handler references| B + B -->|dependsOn references| B + D -->|steps array| K["step.* types"] + E -->|fires| D + F -->|merges from files| B + F -->|merges from files| D +``` + +### Layer 1: Canonical DSL Reference (workflow repo) + +- `docs/dsl-reference.md` — the authoritative spec +- Sections: application, modules, workflows (http/messaging/statemachine/event), pipelines, triggers, imports, config providers, sidecars, platform, infrastructure +- Each section: purpose, required fields, optional fields, relationship to other sections, minimal example +- Machine-parseable frontmatter per section so consumers can extract structured data +- Generated from engine source where possible (module type list, step type list, trigger type list) +- `wfctl docs` command to render the reference locally + +### Layer 2: Editor DSL Reference Pane (workflow-editor) + +- Collapsible sidebar pane (like PropertyPanel) with a book icon in toolbar +- Content loaded from bundled `dsl-reference.json` (extracted from markdown at build time, synced alongside engine-schemas.json) +- **Context-sensitive:** when a node is selected, pane auto-scrolls to the relevant section +- **Section hierarchy:** mirrors the YAML structure — click through application → modules → specific type +- Includes inline YAML examples that match the visual canvas representation +- Shares the right panel area as a tab alongside YAML pane when both are active + +### Layer 3: IDE Plugin Integration (workflow-vscode + workflow-jetbrains) + +- **Hover tooltips:** cursor on a YAML key (`modules:`, `workflows:`, `pipelines:`) shows the DSL reference description +- **Autocomplete descriptions:** YAML language server suggestions include DSL reference detail +- **Command palette:** `Workflow: Show DSL Reference` opens the reference as a webview or markdown preview +- Both plugins already have the webview bridge — the reference pane from Layer 2 works inside the IDE webview + +## 4. Navigation — Breadcrumbs + Interactive File Groups + +### Breadcrumb Bar + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 📁 app.yaml › domains/ › auth.yaml › login pipeline │ +└──────────────────────────────────────────────────────────────┘ +``` + +- Renders above the canvas, below the toolbar +- Shows current file context path based on selected node's `sourceFile` +- Each segment clickable: root config, directories, files, pipeline names +- When no node selected, shows just the root config path +- In IDE mode: clicks call `onNavigateToSource(filePath, 1, 0)` to open in IDE + +### Interactive File Groups + +Enhance existing `FileGroupNode` (currently `pointer-events: none`): + +- **Group header clickable:** clicking the filename label navigates to that file +- **Group border clickable:** clicking the border pans + zooms canvas to fit that file's nodes +- **Double-click group:** opens file in YAML pane or triggers IDE navigation +- **Visual affordance:** pointer cursor on hover, subtle highlight on border +- Keep `pointer-events: none` on group background so contained nodes remain clickable + +### Cross-File Node Interaction + +When clicking a node in a different file group: +1. Selects the node +2. Updates breadcrumb to reflect new file context +3. YAML pane switches to that file's tab and highlights node's lines +4. IDE mode calls `onNavigateToSource(filePath, line, col)` + +### Partial Config "Navigate to Parent" + +- Breadcrumb shows `? › domains/ › auth.yaml` when parent unknown +- If root config resolved via `discoverConfigRoot`, shows `app.yaml › domains/ › auth.yaml` +- Clicking root in breadcrumb: standalone calls `onNavigateToSource`, IDE opens the file + loads merged view +- "View full config" button in toolbar when partial loaded + +## 5. Property Panel Completeness Testing + +Assert every node type's attributes are visible and editable when selected. + +### Test Structure (per module type) + +1. **All fields rendered:** visible field editor count === `configFields.length` in schema +2. **Correct widget type:** string→text input, number→number input, boolean→checkbox, select→dropdown with correct options, array→ArrayFieldEditor, map→MapFieldEditor, json→textarea (flagged as tech debt), sql→SqlEditor, sensitive→password input, filepath→FilePicker +3. **Field metadata:** label matches `field.label`, description/placeholder shown, required indicator when `field.required` +4. **Editing roundtrips:** change value → `updateNodeConfig` called with correct key/value → node config reflects change +5. **Inheritance rendering:** fields with `inheritFrom` show inherited value in italic green +6. **Special editors:** ConditionalCasesEditor for `conditional.switch`, MiddlewareChainEditor for `http.router`, HandlerRoutesEditor for `api.query`/`api.command` + +### Coverage Contract + +- Module type with zero `configFields` → assert just name + type badge + delete (no config section) +- Module type with `json`-typed field → test passes but logs warning: `TECH DEBT: ${type}.${field} uses json textarea` +- CI can fail on `json` fields once engine migration complete — controlled by `STRICT_SCHEMA=true` env var + +## Implementation Phases + +### Phase 1: Schema-Driven Tests (workflow-editor) +- Schema matrix test generator +- Property panel completeness tests +- Partial config rendering tests +- All driven from engine-schemas.json + +### Phase 2: Navigation UX (workflow-editor) +- Breadcrumb bar component +- Interactive file groups (enhance FileGroupNode) +- Cross-file node click → breadcrumb + YAML pane sync +- Partial config "navigate to parent" + "View full config" button + +### Phase 3: DSL Reference (workflow + workflow-editor) +- DSL reference markdown in workflow repo +- `dsl-reference.json` build/sync pipeline +- DSL Reference pane component in workflow-editor +- Context-sensitive section linking + +### Phase 4: Typed Schema Generation (workflow engine) +- `pkg/schema/` generator from Go struct tags +- Editor struct tags on module configs (incremental per module) +- `wfctl editor-schemas` enhancement +- CI contract: no `json`-typed fields +- Major version bump when complete + +### Phase 5: IDE Integration (workflow-vscode + workflow-jetbrains) +- Breadcrumb click → file navigation +- DSL reference hover tooltips +- Autocomplete with DSL descriptions +- `Workflow: Show DSL Reference` command From 88fb788507226e35156b0d9d615b3cc84e057f01 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Fri, 27 Mar 2026 12:38:29 -0400 Subject: [PATCH 15/37] docs: editor completeness implementation plan (phases 1-2) 8 tasks: schema-driven test matrix (279 types), property panel schema completeness, rendering tests, partial config tests, JSON field audit, breadcrumb navigation, interactive file groups, E2E navigation tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...3-27-editor-completeness-implementation.md | 961 ++++++++++++++++++ 1 file changed, 961 insertions(+) create mode 100644 docs/plans/2026-03-27-editor-completeness-implementation.md diff --git a/docs/plans/2026-03-27-editor-completeness-implementation.md b/docs/plans/2026-03-27-editor-completeness-implementation.md new file mode 100644 index 0000000..46e07e4 --- /dev/null +++ b/docs/plans/2026-03-27-editor-completeness-implementation.md @@ -0,0 +1,961 @@ +# Editor Completeness Implementation Plan (Phases 1-2) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Schema-driven test matrix covering all 279 module types, property panel completeness tests, partial config rendering tests, breadcrumb navigation, and interactive file groups. + +**Architecture:** Auto-generate vitest tests from engine-schemas.json using `describe.each()` to cover every module type's rendering and property panel. Add BreadcrumbBar component for file hierarchy navigation. Enhance existing FileGroupNode to be interactive. All work in workflow-editor on branch `copilot/extend-usability-test-validation`. + +**Tech Stack:** TypeScript, React, Vitest, @testing-library/react, @xyflow/react, Playwright + +**Design Doc:** `docs/plans/2026-03-27-editor-completeness-design.md` + +--- + +### Task 1: Schema-Driven Node Rendering Test Matrix + +**Files:** +- Create: `src/utils/schema-rendering-matrix.test.ts` +- Reference: `src/generated/engine-schemas.json`, `src/utils/serialization.ts` + +**Step 1: Write the test file** + +This test auto-generates a rendering test for every module type in engine-schemas.json. It verifies that `configToNodes()` produces a node with the correct component type and category. + +```typescript +import { describe, it, expect } from 'vitest'; +import engineData from '../generated/engine-schemas.json'; +import { configToNodes, nodeComponentType } from './serialization.ts'; +import { getEngineModuleTypes } from '../generated/load-schemas.ts'; +import type { WorkflowConfig } from '../types/workflow.ts'; + +const moduleTypeMap = getEngineModuleTypes(); +const allTypes = Object.keys((engineData as any).moduleSchemas); + +// Category → expected node component type mapping +const CATEGORY_NODE_MAP: Record = { + http: 'httpNode', // only http.server + messaging: 'messagingNode', + statemachine: 'stateMachineNode', + events: 'eventNode', + scheduling: 'schedulerNode', + middleware: 'middlewareNode', + database: 'databaseNode', + observability: 'observabilityNode', + security: 'securityNode', + integration: 'integrationNode', + infrastructure: 'infrastructureNode', + pipeline: 'integrationNode', + cicd: 'integrationNode', + deployment: 'integrationNode', + platform: 'infrastructureNode', +}; + +describe('schema-driven node rendering matrix', () => { + it(`covers all ${allTypes.length} module types from engine-schemas.json`, () => { + expect(allTypes.length).toBeGreaterThan(0); + }); + + describe.each(allTypes)('module type: %s', (moduleType) => { + it('produces a node via configToNodes', () => { + const config: WorkflowConfig = { + modules: [{ name: 'test-node', type: moduleType, config: {} }], + workflows: {}, + triggers: {}, + }; + const { nodes } = configToNodes(config, moduleTypeMap); + expect(nodes.length).toBe(1); + expect(nodes[0].data.label).toBe('test-node'); + expect(nodes[0].data.moduleType).toBe(moduleType); + }); + + it('maps to a valid node component type', () => { + const componentType = nodeComponentType(moduleType); + const validTypes = [ + 'httpNode', 'httpRouterNode', 'messagingNode', 'stateMachineNode', + 'schedulerNode', 'eventNode', 'integrationNode', 'middlewareNode', + 'infrastructureNode', 'databaseNode', 'securityNode', 'observabilityNode', + 'conditionalNode', + ]; + expect(validTypes).toContain(componentType); + }); + + it('has a category in the schema', () => { + const schema = (engineData as any).moduleSchemas[moduleType]; + expect(schema.category).toBeTruthy(); + }); + }); +}); +``` + +**Step 2: Run tests to verify they pass** + +Run: `npx vitest run src/utils/schema-rendering-matrix.test.ts` +Expected: All 279 × 3 = 837 test cases pass (or close — some types may have quirks to fix). + +**Step 3: Fix any failing tests** + +If certain module types fail `configToNodes` (e.g., conditional types need special config), add exception handling in the test or fix the serialization code. + +**Step 4: Commit** + +```bash +git add src/utils/schema-rendering-matrix.test.ts +git commit -m "test: schema-driven rendering matrix for all 279 module types" +``` + +--- + +### Task 2: Property Panel Schema Completeness Matrix + +**Files:** +- Modify: `src/components/properties/PropertyPanel.schema.test.ts` (expand from 10 types to all 279) + +**Step 1: Refactor the existing schema test to iterate all types** + +Replace the hardcoded `typesToAudit` array with `Object.keys(engineData.moduleSchemas)`. The existing test structure (field count, types, required flags, defaults, options) is already correct — just expand the scope. + +```typescript +// Replace: +// const typesToAudit = ['http.server', 'http.middleware.cors', ...]; +// With: +const typesToAudit = Object.keys((engineData as any).moduleSchemas); +``` + +Keep the existing per-type assertions: +- Field count match (engine vs editor) +- Field types match (with duration→string mapping) +- Required flags match +- Default values match +- Select options match + +**Step 2: Run tests** + +Run: `npx vitest run src/components/properties/PropertyPanel.schema.test.ts` +Expected: Most pass. Some may fail if static MODULE_TYPES is missing types that engine-schemas.json has. + +**Step 3: Fix failures** + +If types exist in engine-schemas.json but not in the editor's moduleTypeMap, they still load via `getEngineModuleTypes()` which reads engine-schemas.json directly. The test should pass because moduleTypeMap includes engine types. If not, investigate the merging logic. + +**Step 4: Commit** + +```bash +git add src/components/properties/PropertyPanel.schema.test.ts +git commit -m "test: expand property panel schema fidelity to all 279 module types" +``` + +--- + +### Task 3: Property Panel Rendering Tests (Component Tests) + +**Files:** +- Create: `src/components/properties/PropertyPanel.rendering.test.tsx` + +**Step 1: Write component rendering tests** + +For a representative set of module types (one per field type combination), mount PropertyPanel with a selected node and verify the correct widgets render. + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { act } from '@testing-library/react'; +import PropertyPanel from './PropertyPanel.tsx'; +import useWorkflowStore from '../../stores/workflowStore.ts'; +import engineData from '../../generated/engine-schemas.json'; +import { getEngineModuleTypes } from '../../generated/load-schemas.ts'; + +const engineSchemas = (engineData as any).moduleSchemas; +const moduleTypeMap = getEngineModuleTypes(); + +function resetStore() { + useWorkflowStore.setState({ + nodes: [], + edges: [], + selectedNodeId: null, + nodeCounter: 0, + undoStack: [], + redoStack: [], + toasts: [], + showAIPanel: false, + showComponentBrowser: false, + }); +} + +function selectNodeOfType(moduleType: string) { + act(() => { + useWorkflowStore.getState().addNode(moduleType, { x: 0, y: 0 }); + }); + const nodeId = useWorkflowStore.getState().nodes[0].id; + act(() => { + useWorkflowStore.getState().setSelectedNode(nodeId); + }); + return nodeId; +} + +// Widget type expectations per ConfigFieldDef.type +const FIELD_TYPE_WIDGET: Record = { + string: 'textbox', // role="textbox" or input[type="text"] + number: 'spinbutton', // input[type="number"] has role spinbutton + boolean: 'checkbox', // input[type="checkbox"] + select: 'combobox', //