Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 160 additions & 2 deletions src/components/WorkflowEditor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { render } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { WorkflowEditor } from './WorkflowEditor.tsx';
import { useModuleSchemaStore } from '../stores/moduleSchemaStore.ts';

describe('WorkflowEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
useModuleSchemaStore.getState().resetSchemaState();
});

it('renders without crashing', () => {
const { container } = render(<WorkflowEditor />);
expect(container).toBeTruthy();
Expand Down Expand Up @@ -36,4 +42,156 @@ triggers: {}
render(<WorkflowEditor onPluginSchemaRequest={onPluginSchemaRequest} />);
expect(onPluginSchemaRequest).toHaveBeenCalled();
});

it('prefers onEditorBundleRequest over legacy schema callbacks when provided', async () => {
const onEditorBundleRequest = vi.fn().mockResolvedValue({
version: 'editor-bundle/v1',
workflowVersion: '0.0.0-test',
moduleSchemas: {
'plugin.greeter': {
type: 'plugin.greeter',
label: 'Greeter',
category: 'integration',
configFields: [],
defaultConfig: {},
},
},
stepSchemas: {
'step.sayHello': {
type: 'step.sayHello',
plugin: 'greeter',
description: 'Say hello',
configFields: [],
outputs: [{ key: 'reply', type: 'demo.GreetResponse' }],
},
},
coercionRules: {
'demo.GreetResponse': ['any'],
},
contracts: {
'greeter:module:plugin.greeter': {
id: 'greeter:module:plugin.greeter',
plugin: 'greeter',
ownerType: 'module',
ownerKey: 'plugin.greeter',
mode: 'strict',
requestMessage: 'demo.GreetRequest',
responseMessage: 'demo.GreetResponse',
configMessage: 'demo.GreeterConfig',
source: 'plugin-contracts-json',
},
},
messages: {
'demo.GreetRequest': { id: 'demo.GreetRequest', name: 'GreetRequest', fullName: 'demo.GreetRequest', fields: [] },
'demo.GreetResponse': { id: 'demo.GreetResponse', name: 'GreetResponse', fullName: 'demo.GreetResponse', fields: [] },
'demo.GreeterConfig': { id: 'demo.GreeterConfig', name: 'GreeterConfig', fullName: 'demo.GreeterConfig', fields: [] },
},
schemas: {
app: { type: 'object' },
infra: { type: 'object' },
wfctl: { type: 'object' },
},
});
const onSchemaRequest = vi.fn().mockResolvedValue({ modules: {}, services: [] });
const onPluginSchemaRequest = vi.fn().mockResolvedValue([]);

render(
<WorkflowEditor
onEditorBundleRequest={onEditorBundleRequest}
onSchemaRequest={onSchemaRequest}
onPluginSchemaRequest={onPluginSchemaRequest}
/>,
);

await waitFor(() => expect(onEditorBundleRequest).toHaveBeenCalledTimes(1));
expect(onSchemaRequest).not.toHaveBeenCalled();
expect(onPluginSchemaRequest).not.toHaveBeenCalled();

const state = useModuleSchemaStore.getState();
expect(state.moduleTypeMap['plugin.greeter']?.label).toBe('Greeter');
expect(state.stepTypeMap['step.sayHello']?.description).toBe('Say hello');
expect(state.coercionRules['demo.GreetResponse']).toEqual(['any']);
expect(state.getContractByOwner('module', 'plugin.greeter')?.requestMessage).toBe('demo.GreetRequest');
expect(state.messages['demo.GreetResponse']?.fullName).toBe('demo.GreetResponse');
expect(state.yamlSchemas.wfctl).toEqual({ type: 'object' });
});

it('continues to use legacy schema callbacks when no editor bundle callback exists', async () => {
const onSchemaRequest = vi.fn().mockResolvedValue({
modules: {
'legacy.hosted': {
label: 'Legacy Hosted',
category: 'integration',
configFields: [],
defaultConfig: {},
},
},
services: [],
});
const onPluginSchemaRequest = vi.fn().mockResolvedValue([
{
pluginName: 'legacy-plugin',
modules: {
'legacy.plugin': {
label: 'Legacy Plugin',
category: 'integration',
configFields: [],
defaultConfig: {},
},
},
},
]);

render(<WorkflowEditor onSchemaRequest={onSchemaRequest} onPluginSchemaRequest={onPluginSchemaRequest} />);

await waitFor(() => expect(onSchemaRequest).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onPluginSchemaRequest).toHaveBeenCalledTimes(1));
await waitFor(() => expect(useModuleSchemaStore.getState().moduleTypeMap['legacy.plugin']?.label).toBe('Legacy Plugin'));
});

it('falls back to legacy callbacks when the editor bundle callback returns null', async () => {
const onEditorBundleRequest = vi.fn().mockResolvedValue(null);
const onSchemaRequest = vi.fn().mockResolvedValue({
modules: {
'fallback.hosted': {
label: 'Fallback Hosted',
category: 'integration',
configFields: [],
defaultConfig: {},
},
},
services: [],
});

render(<WorkflowEditor onEditorBundleRequest={onEditorBundleRequest} onSchemaRequest={onSchemaRequest} />);

await waitFor(() => expect(onEditorBundleRequest).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onSchemaRequest).toHaveBeenCalledTimes(1));
expect(useModuleSchemaStore.getState().moduleTypeMap['fallback.hosted']?.label).toBe('Fallback Hosted');
});

it('falls back to legacy callbacks when the editor bundle callback rejects', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const onEditorBundleRequest = vi.fn().mockRejectedValue(new Error('bundle unavailable'));
const onPluginSchemaRequest = vi.fn().mockResolvedValue([
{
pluginName: 'fallback-plugin',
modules: {
'fallback.plugin': {
label: 'Fallback Plugin',
category: 'integration',
configFields: [],
defaultConfig: {},
},
},
},
]);

render(<WorkflowEditor onEditorBundleRequest={onEditorBundleRequest} onPluginSchemaRequest={onPluginSchemaRequest} />);

await waitFor(() => expect(onEditorBundleRequest).toHaveBeenCalledTimes(1));
await waitFor(() => expect(onPluginSchemaRequest).toHaveBeenCalledTimes(1));
expect(useModuleSchemaStore.getState().moduleTypeMap['fallback.plugin']?.label).toBe('Fallback Plugin');
warn.mockRestore();
});
});
49 changes: 37 additions & 12 deletions src/components/WorkflowEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import dslReferenceData from '../generated/dsl-reference.json';
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, showYamlPane, showDslReference } = props;
const { initialYaml, onSave, onNavigateToSource, onSchemaRequest, onPluginSchemaRequest, onEditorBundleRequest, embedded, onAIRequest, onChange, onResolveFile, mode, testResults, onTestRun, sourceMap: sourceMapProp, onSaveToFile, showYamlPane, showDslReference } = props;
const importFromConfig = useWorkflowStore((s) => s.importFromConfig);
const exportToConfig = useWorkflowStore((s) => s.exportToConfig);
const exportToFileMap = useWorkflowStore((s) => s.exportToFileMap);
Expand All @@ -29,6 +29,7 @@ export function WorkflowEditor(props: WorkflowEditorProps) {
const setTestResults = useWorkflowStore((s) => s.setTestResults);
const loadSchemas = useModuleSchemaStore((s) => s.loadSchemas);
const loadPluginSchemas = useModuleSchemaStore((s) => s.loadPluginSchemas);
const loadEditorBundle = useModuleSchemaStore((s) => s.loadEditorBundle);
const importingRef = useRef(false);
const hasMultiFileRef = useRef(false);

Expand Down Expand Up @@ -111,17 +112,41 @@ export function WorkflowEditor(props: WorkflowEditorProps) {

// Request schemas from host
useEffect(() => {
if (onSchemaRequest) {
onSchemaRequest().then((data) => {
if (data) loadSchemas(data.modules as Parameters<typeof loadSchemas>[0]);
});
}
if (onPluginSchemaRequest) {
onPluginSchemaRequest().then((plugins) => {
if (plugins) loadPluginSchemas(plugins);
});
}
}, [onSchemaRequest, onPluginSchemaRequest, loadSchemas, loadPluginSchemas]);
let cancelled = false;

const loadLegacySchemas = async () => {
const [schemaData, pluginData] = await Promise.all([
onSchemaRequest ? onSchemaRequest() : Promise.resolve(null),
onPluginSchemaRequest ? onPluginSchemaRequest() : Promise.resolve(null),
]);
if (cancelled) return;
if (schemaData) loadSchemas(schemaData.modules as Parameters<typeof loadSchemas>[0]);
if (pluginData) loadPluginSchemas(pluginData);
Comment on lines +118 to +124

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loadLegacySchemas uses Promise.all for onSchemaRequest and onPluginSchemaRequest. If either callback rejects, the whole await rejects and you end up loading neither schema set (and you can also get an unhandled rejection from the effect). Consider handling these independently (e.g., Promise.allSettled or separate try/catch per callback) so one failing callback doesn’t prevent the other from being applied.

Suggested change
const [schemaData, pluginData] = await Promise.all([
onSchemaRequest ? onSchemaRequest() : Promise.resolve(null),
onPluginSchemaRequest ? onPluginSchemaRequest() : Promise.resolve(null),
]);
if (cancelled) return;
if (schemaData) loadSchemas(schemaData.modules as Parameters<typeof loadSchemas>[0]);
if (pluginData) loadPluginSchemas(pluginData);
const [schemaResult, pluginResult] = await Promise.allSettled([
onSchemaRequest ? onSchemaRequest() : Promise.resolve(null),
onPluginSchemaRequest ? onPluginSchemaRequest() : Promise.resolve(null),
]);
if (cancelled) return;
if (schemaResult.status === 'fulfilled') {
const schemaData = schemaResult.value;
if (schemaData) loadSchemas(schemaData.modules as Parameters<typeof loadSchemas>[0]);
} else {
console.warn('Failed to load legacy schemas:', schemaResult.reason);
}
if (pluginResult.status === 'fulfilled') {
const pluginData = pluginResult.value;
if (pluginData) loadPluginSchemas(pluginData);
} else {
console.warn('Failed to load legacy plugin schemas:', pluginResult.reason);
}

Copilot uses AI. Check for mistakes.
};

const loadHostSchemas = async () => {
if (onEditorBundleRequest) {
try {
const bundle = await onEditorBundleRequest();
if (cancelled) return;
if (bundle) {
loadEditorBundle(bundle);
return;
}
} catch (error) {
console.warn('Failed to load editor contract bundle, falling back to legacy schemas:', error);
if (cancelled) return;
}
}

await loadLegacySchemas();
};

void loadHostSchemas();
return () => {
cancelled = true;
};
}, [onSchemaRequest, onPluginSchemaRequest, onEditorBundleRequest, loadSchemas, loadPluginSchemas, loadEditorBundle]);

const nodePaletteCollapsed = useUILayoutStore((s) => s.nodePaletteCollapsed);
const propertyPanelCollapsed = useUILayoutStore((s) => s.propertyPanelCollapsed);
Expand Down
107 changes: 107 additions & 0 deletions src/components/properties/PropertyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { act } from '@testing-library/react';
import PropertyPanel from './PropertyPanel.tsx';
import useWorkflowStore from '../../stores/workflowStore.ts';
import { useModuleSchemaStore } from '../../stores/moduleSchemaStore.ts';

function resetStore() {
useWorkflowStore.setState({
Expand All @@ -16,6 +17,7 @@ function resetStore() {
showAIPanel: false,
showComponentBrowser: false,
});
useModuleSchemaStore.getState().resetSchemaState();
}

describe('PropertyPanel', () => {
Expand Down Expand Up @@ -195,4 +197,109 @@ describe('PropertyPanel', () => {
const rpsInput = screen.getByDisplayValue('60');
expect(rpsInput).toHaveAttribute('type', 'number');
});

it('shows contract metadata for the selected node when a descriptor exists', () => {
act(() => {
useModuleSchemaStore.getState().loadEditorBundle({
version: 'editor-bundle/v1',
moduleSchemas: {
'plugin.greeter': {
type: 'plugin.greeter',
label: 'Greeter',
category: 'integration',
configFields: [],
defaultConfig: {},
},
},
coercionRules: {},
contracts: {
'greeter:module:plugin.greeter': {
id: 'greeter:module:plugin.greeter',
plugin: 'greeter',
ownerType: 'module',
ownerKey: 'plugin.greeter',
mode: 'proto_with_legacy',
requestMessage: 'demo.GreetRequest',
responseMessage: 'demo.GreetResponse',
configMessage: 'demo.GreeterConfig',
descriptorSetRef: 'buf.build/demo/greeter',
source: 'plugin-contracts-json',
},
},
messages: {},
schemas: { app: {} },
});
useWorkflowStore.getState().addNode('plugin.greeter', { x: 0, y: 0 });
useWorkflowStore.getState().setSelectedNode(
useWorkflowStore.getState().nodes[0].id
);
});

render(<PropertyPanel />);

const section = screen.getByRole('region', { name: 'Contract metadata' });
expect(section).toHaveTextContent('proto_with_legacy');
expect(section).toHaveTextContent('greeter');
expect(section).toHaveTextContent('plugin-contracts-json');
expect(section).toHaveTextContent('demo.GreetRequest');
expect(section).toHaveTextContent('demo.GreetResponse');
expect(section).toHaveTextContent('demo.GreeterConfig');
expect(section).toHaveTextContent('buf.build/demo/greeter');
});

it('shows step contract metadata for selected pipeline step nodes', () => {
act(() => {
useModuleSchemaStore.getState().loadEditorBundle({
version: 'editor-bundle/v1',
moduleSchemas: {
'step.transform': {
type: 'step.transform',
label: 'Transform',
category: 'pipeline',
configFields: [],
defaultConfig: {},
},
},
stepSchemas: {
'step.transform': {
type: 'step.transform',
plugin: 'transformer',
description: 'Transform payload',
configFields: [],
outputs: [{ key: 'result', type: 'demo.TransformResponse' }],
},
},
coercionRules: {},
contracts: {
'transformer:step:step.transform': {
id: 'transformer:step:step.transform',
plugin: 'transformer',
ownerType: 'step',
ownerKey: 'step.transform',
mode: 'strict',
requestMessage: 'demo.TransformRequest',
responseMessage: 'demo.TransformResponse',
configMessage: 'demo.TransformConfig',
source: 'plugin-manifest',
},
},
messages: {},
schemas: { app: {} },
});
useWorkflowStore.getState().addNode('step.transform', { x: 0, y: 0 });
useWorkflowStore.getState().setSelectedNode(
useWorkflowStore.getState().nodes[0].id
);
});

render(<PropertyPanel />);

const section = screen.getByRole('region', { name: 'Contract metadata' });
expect(section).toHaveTextContent('strict');
expect(section).toHaveTextContent('transformer');
expect(section).toHaveTextContent('plugin-manifest');
expect(section).toHaveTextContent('demo.TransformRequest');
expect(section).toHaveTextContent('demo.TransformResponse');
expect(section).toHaveTextContent('demo.TransformConfig');
});
});
Loading
Loading