diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/e2e/editor.spec.ts b/e2e/editor.spec.ts index 4e6be4e..70d0c93 100644 --- a/e2e/editor.spec.ts +++ b/e2e/editor.spec.ts @@ -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(); + 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'); }); }); @@ -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(); diff --git a/e2e/test-app/main.tsx b/e2e/test-app/main.tsx index bb4662f..9ccf7e0 100644 --- a/e2e/test-app/main.tsx +++ b/e2e/test-app/main.tsx @@ -62,6 +62,13 @@ const MULTIFILE_SOURCE_MAP: Record = { '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'; } @@ -111,7 +118,10 @@ function App() { return (
{ + document.body.dataset.savedYaml = yaml; + }} />
); diff --git a/src/stores/persistenceStorage.test.ts b/src/stores/persistenceStorage.test.ts new file mode 100644 index 0000000..cff77ed --- /dev/null +++ b/src/stores/persistenceStorage.test.ts @@ -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(); + }); +}); diff --git a/src/stores/workflowStore.test.ts b/src/stores/workflowStore.test.ts new file mode 100644 index 0000000..f32a252 --- /dev/null +++ b/src/stores/workflowStore.test.ts @@ -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' }, + }, + ], + }); + }); +}); diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index f3674d0..af81f1e 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -472,10 +472,10 @@ const useWorkflowStore = create()( ? { 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; } if (Object.keys(importedPipelines).length > 0) { diff --git a/src/test/setup.ts b/src/test/setup.ts index 55d3281..90905a5 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1,4 +1,54 @@ import '@testing-library/jest-dom'; +import { beforeEach, vi } from 'vitest'; + +function createLocalStorageMock(): Storage { + let items: Record = {}; + + 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.