import React, { act, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; import type { Root } from 'react-dom/client'; import { ProjectProvider, useProject } from '../../context/ProjectContext'; import type { ProjectDocument } from '../../types/project'; import ActionInspector from './ActionInspector'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; function ProjectProbe(): React.ReactElement { const { doc, isDirty } = useProject(); return ( <>
{JSON.stringify(doc)}
{String(isDirty)} ); } function ProjectSeed({ legacyBinding = false }: { legacyBinding?: boolean }): null { const { setDoc } = useProject(); useEffect(() => setDoc((doc) => ({ ...doc, project: { ...doc.project, pages: [{ ...doc.project.pages[0], components: [ { id: 'cmp_result', type: 'JsonViewer', name: 'result', position: { x: 0, y: 0 }, size: { width: 200, height: 100 }, properties: {} }, ] }], actions: [{ id: 'action_lookup', name: 'Lookup', method: 'GET', url: 'https://example.com', headers: {}, queryParameters: {}, pathParameters: {}, bodyTemplate: '', authenticationType: 'anonymous' }], bindings: legacyBinding ? [{ id: 'binding_legacy', source: 'actions.action_lookup.response.body', target: 'components.result.value', trigger: 'onClick' }] : [], }, })), [setDoc]); return null; } function currentDocument(container: HTMLElement): ProjectDocument { const probe = container.querySelector('[data-testid="project-probe"]'); if (!probe?.textContent) throw new Error('Project probe was not rendered.'); return JSON.parse(probe.textContent) as ProjectDocument; } function click(element: Element | null): void { if (!element) throw new Error('Expected clickable element was not found.'); act(() => { element.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); } function doneButton(container: HTMLElement): HTMLButtonElement { const button = container.querySelector('button[type="submit"]'); if (!button) throw new Error('Done button was not found.'); return button; } function setControlValue( element: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null, value: string, ): void { if (!element) throw new Error('Expected form control was not found.'); const prototype = element instanceof HTMLInputElement ? HTMLInputElement.prototype : element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLSelectElement.prototype; const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set; if (!setter) throw new Error('Could not find native value setter.'); act(() => { setter.call(element, value); element.dispatchEvent(new Event( element instanceof HTMLSelectElement ? 'change' : 'input', { bubbles: true }, )); }); } describe('Actions & Bindings visual REST action authoring', () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); act(() => { root.render( , ); }); }); afterEach(() => { act(() => root.unmount()); container.remove(); jest.restoreAllMocks(); }); test('creates and edits an anonymous action in canonical project state', () => { click(container.querySelector('[data-testid="add-rest-action"]')); let doc = currentDocument(container); expect(doc.project.actions).toHaveLength(1); expect(doc.project.actions[0]).toMatchObject({ id: 'action_rest_1', method: 'GET', authenticationType: 'anonymous', }); expect(doc.project.actions[0]).not.toHaveProperty('responseMapping'); expect(container.querySelector('[data-testid="dirty-probe"]')?.textContent) .toBe('true'); setControlValue( container.querySelector('[data-testid="action-authentication"]'), 'bearerToken', ); expect(currentDocument(container).project.actions[0].authenticationType) .toBe('anonymous'); setControlValue( container.querySelector('[data-testid="action-name"]'), 'Create inventory item', ); setControlValue( container.querySelector('[data-testid="action-method"]'), 'POST', ); setControlValue( container.querySelector('[data-testid="action-url"]'), 'https://api.example.com/items', ); setControlValue( container.querySelector('[data-testid="action-body"]'), '{"name":"{{components.nameInput.value}}"}', ); click(container.querySelector('button[aria-label="Add query parameters row"]')); setControlValue( container.querySelector('input[aria-label="Query parameters key"]'), 'limit', ); setControlValue( container.querySelector('input[aria-label="Query parameters value"]'), '25', ); doc = currentDocument(container); expect(doc.project.actions[0]).toMatchObject({ name: 'Create inventory item', method: 'POST', url: 'https://api.example.com/items', queryParameters: { limit: '25' }, bodyTemplate: '{"name":"{{components.nameInput.value}}"}', authenticationType: 'anonymous', }); }); test('keeps invalid drafts local until they are corrected', () => { click(container.querySelector('[data-testid="add-rest-action"]')); const addButton = container.querySelector( '[data-testid="add-rest-action"]', ); expect(addButton?.disabled).toBe(true); const nameInput = container.querySelector( '[data-testid="action-name"]', ); setControlValue(nameInput, ''); expect(currentDocument(container).project.actions[0].name) .toBe('New REST Action'); expect(doneButton(container).disabled).toBe(true); setControlValue(nameInput, 'Valid action name'); expect(doneButton(container).disabled).toBe(false); const urlInput = container.querySelector( '[data-testid="action-url"]', ); setControlValue(urlInput, 'https://'); expect(currentDocument(container).project.actions[0].url) .toBe('https://api.example.com'); expect(doneButton(container).disabled).toBe(true); setControlValue(urlInput, 'https://api.example.com/items'); expect(doneButton(container).disabled).toBe(false); click(container.querySelector( 'button[aria-label="Add query parameters row"]', )); expect(currentDocument(container).project.actions[0].queryParameters) .toEqual({}); expect(doneButton(container).disabled).toBe(true); setControlValue( container.querySelector( 'input[aria-label="Query parameters key"]', ), 'limit', ); expect(currentDocument(container).project.actions[0].queryParameters) .toEqual({ limit: '' }); expect(doneButton(container).disabled).toBe(false); }); test('duplicates an action with a fresh ID and opens the copy for editing', () => { click(container.querySelector('[data-testid="add-rest-action"]')); setControlValue( container.querySelector('[data-testid="action-name"]'), 'Fetch inventory', ); click(doneButton(container)); click(container.querySelector( 'button[aria-label="Duplicate REST action Fetch inventory"]', )); const doc = currentDocument(container); expect(doc.project.actions.map((action) => action.id)).toEqual([ 'action_rest_1', 'action_rest_2', ]); expect(doc.project.actions[1].name).toBe('Fetch inventory Copy'); expect(container.querySelector( '[data-testid="rest-action-editor-action_rest_2"]', )).not.toBeNull(); }); test('requires confirmation and removes only the selected action', () => { click(container.querySelector('[data-testid="add-rest-action"]')); click(doneButton(container)); const confirm = jest.spyOn(window, 'confirm') .mockReturnValueOnce(false) .mockReturnValueOnce(true); const deleteButton = () => container.querySelector( 'button[aria-label="Delete REST action New REST Action"]', ); click(deleteButton()); expect(currentDocument(container).project.actions).toHaveLength(1); click(deleteButton()); expect(currentDocument(container).project.actions).toHaveLength(0); expect(confirm).toHaveBeenCalledWith( 'Delete REST action "New REST Action"? This cannot be undone.', ); }); test('authors variables and onSuccess response bindings in canonical state', () => { act(() => { root.render(); }); click(container.querySelector('[data-testid="add-variable"]')); setControlValue(container.querySelector('input[aria-label="Variable name"]'), 'capturedId'); setControlValue(container.querySelector('select[aria-label="Variable type"]'), 'number'); setControlValue(container.querySelector('textarea[aria-label="Variable default value"]'), '7'); click(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Save variable') ?? null); expect(currentDocument(container).project.variables.capturedId).toEqual({ type: 'number', defaultValue: 7 }); click(container.querySelector('[data-testid="add-variable"]')); setControlValue(container.querySelector('input[aria-label="Variable name"]'), 'enabled'); setControlValue(container.querySelector('select[aria-label="Variable type"]'), 'boolean'); const booleanDefault = container.querySelector('select[aria-label="Variable default value"]'); expect(Array.from(booleanDefault?.options ?? []).map((option) => option.value)).toEqual(['', 'true', 'false']); setControlValue(booleanDefault, 'true'); click(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Save variable') ?? null); expect(currentDocument(container).project.variables.enabled).toEqual({ type: 'boolean', defaultValue: true }); click(container.querySelector('[data-testid="add-response-binding"]')); let binding = currentDocument(container).project.bindings[0]; expect(binding).toMatchObject({ source: 'actions.action_lookup.response.body', target: 'components.result.value', trigger: 'onSuccess' }); setControlValue(container.querySelector('input[aria-label="Binding response path"]'), 'body.id'); setControlValue(container.querySelector('select[aria-label="Binding target"]'), 'variables.capturedId'); binding = currentDocument(container).project.bindings[0]; expect(binding.source).toBe('actions.action_lookup.response.body.id'); expect(binding.target).toBe('variables.capturedId'); expect(binding.trigger).toBe('onSuccess'); }); test('warns about references when deleting a variable', () => { act(() => { root.render(); }); click(container.querySelector('[data-testid="add-variable"]')); setControlValue(container.querySelector('input[aria-label="Variable name"]'), 'capturedId'); click(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Save variable') ?? null); click(container.querySelector('[data-testid="add-response-binding"]')); setControlValue(container.querySelector('select[aria-label="Binding target"]'), 'variables.capturedId'); const confirm = jest.spyOn(window, 'confirm').mockReturnValue(false); click(container.querySelector('button[aria-label="Delete variable capturedId"]')); expect(confirm).toHaveBeenCalledWith(expect.stringContaining('binding "binding_response_1"')); expect(currentDocument(container).project.variables.capturedId).toBeDefined(); }); test('does not offer legacy onClick for new bindings and resets after deletion', () => { act(() => { root.render(); }); click(container.querySelector('[data-testid="add-response-binding"]')); const trigger = container.querySelector('select[aria-label="Binding trigger"]'); expect(Array.from(trigger?.options ?? []).map((option) => option.value)).toEqual(['onSuccess']); const confirm = jest.spyOn(window, 'confirm').mockReturnValue(true); click(container.querySelector('button[aria-label="Delete binding binding_response_1"]')); expect(currentDocument(container).project.bindings).toEqual([]); expect(container.querySelector('[data-testid="add-response-binding"]')?.disabled).toBe(false); expect(confirm).toHaveBeenCalled(); }); test('preserves an existing legacy onClick binding and allows one-way migration', () => { act(() => { root.render(); }); click(container.querySelector('button[aria-label="Edit binding binding_legacy"]')); const trigger = container.querySelector('select[aria-label="Binding trigger"]'); expect(Array.from(trigger?.options ?? []).map((option) => option.value)).toEqual(['onSuccess', 'onClick']); expect(currentDocument(container).project.bindings[0].trigger).toBe('onClick'); setControlValue(trigger, 'onSuccess'); expect(currentDocument(container).project.bindings[0].trigger).toBe('onSuccess'); expect(Array.from(container.querySelectorAll('select[aria-label="Binding trigger"] option')).map((option) => option.value)).toEqual(['onSuccess']); }); });