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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
43 changes: 29 additions & 14 deletions e2e/editor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,48 @@
import { test, expect } from '@playwright/test';

// E2E tests for the workflow editor.
// Run with: npx playwright test
// 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 }) => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 });
expect(page.url()).toContain('localhost');
await expect(page.locator('.react-flow__node').filter({ hasText: 'my-server' })).toBeVisible();
});

test('editor loads and renders canvas', async ({ page }) => {
await expect(page.locator('.react-flow__viewport')).toBeVisible();
await expect(page.getByText('Modules')).toBeVisible();
await expect(page.getByText('Properties')).toBeVisible();
});

test('loads YAML and renders nodes', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.react-flow__viewport', { timeout: 10_000 });
// TODO: Load a sample workflow config via UI or API
expect(true).toBe(true); // placeholder
const serverNode = page.locator('.react-flow__node').filter({ hasText: 'my-server' });
await expect(serverNode).toContainText('http.server');
await expect(serverNode).toContainText(':8080');
});

test('add node from palette updates canvas', async ({ page }) => {
await page.goto('/');
// TODO: Open node palette, double-click an item to add a node
expect(true).toBe(true); // placeholder
const nodes = page.locator('.react-flow__node');
const initialCount = await nodes.count();

await page.getByPlaceholder('Filter modules...').fill('http.router');
await page.getByText('▶HTTP1').click();
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

The selector page.getByText('▶HTTP1') is brittle because it depends on the disclosure glyph and the filtered type count being rendered as contiguous text. Since the NodePalette header is composed of multiple elements (triangle + label + count), prefer a locator that targets the category label (e.g., locate the category row that contains text HTTP and click it) without hard-coding the glyph/count, to reduce E2E flakiness when UI text/layout changes.

Suggested change
await page.getByText('▶HTTP1').click();
await page.getByText('HTTP', { exact: true }).click();

Copilot uses AI. Check for mistakes.
await page.locator('[title="Drag to canvas or double-click to add"]').filter({ hasText: 'HTTP Router' }).dblclick();

await expect.poll(async () => nodes.count()).toBeGreaterThan(initialCount);
await expect(nodes.filter({ hasText: 'HTTP Router' })).toBeVisible();
});

test('editing node config updates YAML', async ({ page }) => {
await page.goto('/');
// TODO: Select a node, edit a config field in property panel
expect(true).toBe(true); // placeholder
await page.locator('.react-flow__node').filter({ hasText: 'my-server' }).click();
await page.locator('input[value=":8080"]').fill(':9090');

await page.getByText('Save').click();

await expect.poll(
async () => page.evaluate(() => document.body.dataset.savedYaml ?? ''),
).toContain(':9090');
});
});

Expand Down Expand Up @@ -138,7 +153,7 @@ test.describe('YAML line click selects canvas node', () => {
await authTab.click();
await expect(authTab).toHaveClass(/yaml-tab-active/);

const nodeLine = page.locator('.yaml-line code').filter({ hasText: 'auth-server' }).first();
const nodeLine = page.locator('.yaml-line').filter({ hasText: 'auth-server' }).first();
await expect(nodeLine).toBeVisible();
await nodeLine.click();
await expect(page.locator('.yaml-line-highlighted').first()).toBeVisible();
Expand Down
12 changes: 11 additions & 1 deletion e2e/test-app/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ const MULTIFILE_SOURCE_MAP: Record<string, string> = {
'billing-service': 'billing.yaml',
};

const DEFAULT_YAML = `modules:
- name: my-server
type: http.server
config:
address: :8080
`;

function getScenario(): string {
return new URLSearchParams(window.location.search).get('scenario') ?? 'default';
}
Expand Down Expand Up @@ -111,7 +118,10 @@ function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<WorkflowEditor
initialYaml={`modules:\n - name: my-server\n type: http.server\n config:\n address: :8080\n`}
initialYaml={DEFAULT_YAML}
onSave={async (yaml) => {
document.body.dataset.savedYaml = yaml;
}}
/>
</div>
);
Expand Down
38 changes: 38 additions & 0 deletions src/stores/persistenceStorage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import useUILayoutStore from './uiLayoutStore.ts';
import useWorkflowStore from './workflowStore.ts';

describe('persisted store test storage', () => {
it('provides browser-compatible localStorage methods', () => {
expect(globalThis.localStorage).toMatchObject({
getItem: expect.any(Function),
setItem: expect.any(Function),
removeItem: expect.any(Function),
clear: expect.any(Function),
});
});

it('can reset persisted stores without a storage setItem error', () => {
expect(() => {
useWorkflowStore.setState({
nodes: [],
edges: [],
selectedNodeId: null,
selectedEdgeId: null,
nodeCounter: 0,
undoStack: [],
redoStack: [],
toasts: [],
tabs: [],
activeTabId: 'default',
});

useUILayoutStore.setState({
projectSwitcherCollapsed: false,
nodePaletteCollapsed: false,
propertyPanelCollapsed: false,
yamlPaneVisible: true,
});
}).not.toThrow();
});
});
35 changes: 35 additions & 0 deletions src/stores/workflowStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { useWorkflowStore } from './workflowStore.ts';
import type { WorkflowConfig } from '../types/workflow.ts';

describe('workflow store export', () => {
it('exports module-only configs without requiring workflow or trigger sections', () => {
const config: WorkflowConfig = {
modules: [
{
name: 'my-server',
type: 'http.server',
config: { address: ':8080' },
},
],
workflows: {},
triggers: {},
_originalKeys: ['modules'],
};

useWorkflowStore.getState().importFromConfig(config);
const node = useWorkflowStore.getState().nodes[0];
useWorkflowStore.getState().updateNodeConfig(node.id, { address: ':9090' });

expect(() => useWorkflowStore.getState().exportToConfig()).not.toThrow();
expect(useWorkflowStore.getState().exportToConfig()).toMatchObject({
modules: [
{
name: 'my-server',
type: 'http.server',
config: { address: ':9090' },
},
],
});
Comment on lines +24 to +33
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

This test currently uses toMatchObject({ modules: ... }), which will still pass even if exportToConfig() accidentally includes workflows: {} / triggers: {}. To actually protect the module-only export behavior, assert that those keys are absent (or undefined) in the exported config in addition to matching modules.

Copilot uses AI. Check for mistakes.
});
});
4 changes: 2 additions & 2 deletions src/stores/workflowStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,10 +472,10 @@ const useWorkflowStore = create<WorkflowStore>()(
? { modules: [], workflows: {}, triggers: {}, ...importedPassthrough }
: undefined;
const config = nodesToConfig(nodes, edges, moduleTypeMap, originalConfig);
if (Object.keys(config.workflows).length === 0 && Object.keys(importedWorkflows).length > 0) {
if (Object.keys(config.workflows ?? {}).length === 0 && Object.keys(importedWorkflows).length > 0) {
config.workflows = importedWorkflows;
}
if (Object.keys(config.triggers).length === 0 && Object.keys(importedTriggers).length > 0) {
if (Object.keys(config.triggers ?? {}).length === 0 && Object.keys(importedTriggers).length > 0) {
config.triggers = importedTriggers;
Comment on lines +475 to 479
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

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

nodesToConfig() can omit workflows/triggers based on _originalKeys (see src/utils/serialization.ts:503-512), but it is typed as returning WorkflowConfig where those fields are required. Adding ?? {} here avoids a crash, but it also spreads the type/runtime mismatch into callers. Consider fixing the root cause by making workflows/triggers optional in WorkflowConfig (and tightening downstream handling) or by having nodesToConfig() always return them (possibly empty) and using _originalKeys only for YAML ordering.

Copilot uses AI. Check for mistakes.
}
if (Object.keys(importedPipelines).length > 0) {
Expand Down
50 changes: 50 additions & 0 deletions src/test/setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,54 @@
import '@testing-library/jest-dom';
import { beforeEach, vi } from 'vitest';

function createLocalStorageMock(): Storage {
let items: Record<string, string> = {};

return {
get length() {
return Object.keys(items).length;
},
clear: () => {
items = {};
},
getItem: (key: string) => {
return Object.prototype.hasOwnProperty.call(items, key) ? items[key] : null;
},
key: (index: number) => {
return Object.keys(items)[index] ?? null;
},
removeItem: (key: string) => {
delete items[key];
},
setItem: (key: string, value: string) => {
items[key] = String(value);
},
};
}

const localStorageMock = createLocalStorageMock();

Object.defineProperty(globalThis, 'localStorage', {
value: localStorageMock,
configurable: true,
});

if (globalThis.window) {
Object.defineProperty(globalThis.window, 'localStorage', {
value: localStorageMock,
configurable: true,
});
}

beforeEach(() => {
localStorageMock.clear();
});

const openMock = vi.fn();
globalThis.open = openMock;
if (globalThis.window) {
globalThis.window.open = openMock;
}

// Mock fetch for jsdom — relative URLs like /api/... throw ERR_INVALID_URL without a base.
// Return 404 so components fall back to static defaults cleanly.
Expand Down
Loading