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');
});
});