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; } type DiagnosticTrigger = 'page' | 'component' | 'none'; function DiagnosticSeed({ trigger }: { trigger: DiagnosticTrigger }): null { const { setDoc } = useProject(); useEffect(() => setDoc((doc) => ({ ...doc, project: { ...doc.project, pages: [{ ...doc.project.pages[0], events: trigger === 'page' ? [{ event: 'onLoad', actionId: 'action_lookup' }] : [], components: [ { id: 'cmp_result', type: 'JsonViewer', name: 'result', position: { x: 0, y: 0 }, size: { width: 200, height: 100 }, properties: {} }, { id: 'cmp_button', type: 'Button', name: 'submit', position: { x: 0, y: 120 }, size: { width: 120, height: 40 }, properties: {}, events: trigger === 'component' ? [{ event: 'onClick', actionId: 'action_lookup' }] : [] }, ], }], actions: [{ id: 'action_lookup', name: 'Lookup', method: 'GET', url: 'https://example.com', headers: {}, queryParameters: {}, pathParameters: {}, bodyTemplate: '', authenticationType: 'anonymous' }], bindings: [{ id: 'binding_lookup', source: 'actions.action_lookup.response.body', target: 'components.result.value', trigger: 'onSuccess' }], }, })), [setDoc, trigger]); 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('bearerToken'); 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: 'bearerToken', }); }); test('presents actions and response bindings as distinct summarized records', () => { act(() => { root.render( , ); }); expect(container.textContent).toContain('Requests sent through the backend proxy.'); expect(container.textContent).toContain( 'Routes successful action responses into components or runtime variables.', ); expect(container.textContent).toContain('REST action'); expect(container.textContent).toContain('Response binding'); }); test('suggests canonical request references and common header names while preserving custom headers', () => { act(() => { root.render( , ); }); click(container.querySelector('button[aria-label="Edit REST action Lookup"]')); setControlValue( container.querySelector('[data-testid="action-url"]'), 'https://example.com/{{', ); const urlSuggestions = container.querySelector('[data-testid="action-url-suggestions"]'); expect(urlSuggestions?.textContent).toContain('Component: result'); click(urlSuggestions?.querySelector('button') ?? null); expect(currentDocument(container).project.actions[0].url) .toBe('https://example.com/{{components.result.value}}'); click(container.querySelector('button[aria-label="Add headers row"]')); const headerKey = container.querySelector('input[aria-label="Headers key"]'); const listId = headerKey?.getAttribute('list'); expect(listId).toBeTruthy(); const headerOptions = Array.from(container.querySelectorAll(`#${listId} option`)) .map((option) => option.getAttribute('value')); expect(headerOptions).toEqual(expect.arrayContaining(['Accept', 'Content-Type'])); setControlValue(headerKey, 'X-Custom-Vendor-Header'); expect(currentDocument(container).project.actions[0].headers) .toEqual({ 'X-Custom-Vendor-Header': '' }); }); 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']); }); test('treats page onLoad as a valid action and binding trigger', () => { act(() => { root.render(); }); expect(container.textContent).not.toContain( 'Action "Lookup" is not triggered by any component or page event.', ); expect(container.textContent).not.toContain( 'No component or page event fires action "action_lookup".', ); }); test('reports revised diagnostics for a genuinely untriggered action', () => { act(() => { root.render(); }); expect(container.textContent).toContain( 'Action "Lookup" is not triggered by any component or page event.', ); expect(container.textContent).toContain( 'No component or page event fires action "action_lookup".', ); }); test('continues to treat component events as valid triggers', () => { act(() => { root.render(); }); expect(container.textContent).not.toContain('not triggered by any component or page event'); expect(container.textContent).not.toContain('No component or page event fires action'); }); });