441 lines
19 KiB
TypeScript
441 lines
19 KiB
TypeScript
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 (
|
|
<>
|
|
<pre data-testid="project-probe">{JSON.stringify(doc)}</pre>
|
|
<span data-testid="dirty-probe">{String(isDirty)}</span>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<HTMLButtonElement>('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(
|
|
<ProjectProvider>
|
|
<ActionInspector />
|
|
<ProjectProbe />
|
|
</ProjectProvider>,
|
|
);
|
|
});
|
|
});
|
|
|
|
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<HTMLSelectElement>('[data-testid="action-authentication"]'),
|
|
'bearerToken',
|
|
);
|
|
expect(currentDocument(container).project.actions[0].authenticationType)
|
|
.toBe('bearerToken');
|
|
|
|
setControlValue(
|
|
container.querySelector<HTMLInputElement>('[data-testid="action-name"]'),
|
|
'Create inventory item',
|
|
);
|
|
setControlValue(
|
|
container.querySelector<HTMLSelectElement>('[data-testid="action-method"]'),
|
|
'POST',
|
|
);
|
|
setControlValue(
|
|
container.querySelector<HTMLInputElement>('[data-testid="action-url"]'),
|
|
'https://api.example.com/items',
|
|
);
|
|
setControlValue(
|
|
container.querySelector<HTMLTextAreaElement>('[data-testid="action-body"]'),
|
|
'{"name":"{{components.nameInput.value}}"}',
|
|
);
|
|
|
|
click(container.querySelector('button[aria-label="Add query parameters row"]'));
|
|
setControlValue(
|
|
container.querySelector<HTMLInputElement>('input[aria-label="Query parameters key"]'),
|
|
'limit',
|
|
);
|
|
setControlValue(
|
|
container.querySelector<HTMLInputElement>('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(
|
|
<ProjectProvider>
|
|
<ProjectSeed legacyBinding />
|
|
<ActionInspector />
|
|
<ProjectProbe />
|
|
</ProjectProvider>,
|
|
);
|
|
});
|
|
|
|
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(
|
|
<ProjectProvider>
|
|
<ProjectSeed />
|
|
<ActionInspector />
|
|
<ProjectProbe />
|
|
</ProjectProvider>,
|
|
);
|
|
});
|
|
click(container.querySelector('button[aria-label="Edit REST action Lookup"]'));
|
|
|
|
setControlValue(
|
|
container.querySelector<HTMLInputElement>('[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<HTMLInputElement>('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<HTMLButtonElement>(
|
|
'[data-testid="add-rest-action"]',
|
|
);
|
|
expect(addButton?.disabled).toBe(true);
|
|
|
|
const nameInput = container.querySelector<HTMLInputElement>(
|
|
'[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<HTMLInputElement>(
|
|
'[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<HTMLInputElement>(
|
|
'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<HTMLInputElement>('[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(<ProjectProvider><ProjectSeed /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
|
});
|
|
|
|
click(container.querySelector('[data-testid="add-variable"]'));
|
|
setControlValue(container.querySelector<HTMLInputElement>('input[aria-label="Variable name"]'), 'capturedId');
|
|
setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Variable type"]'), 'number');
|
|
setControlValue(container.querySelector<HTMLTextAreaElement>('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<HTMLInputElement>('input[aria-label="Variable name"]'), 'enabled');
|
|
setControlValue(container.querySelector<HTMLSelectElement>('select[aria-label="Variable type"]'), 'boolean');
|
|
const booleanDefault = container.querySelector<HTMLSelectElement>('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<HTMLInputElement>('input[aria-label="Binding response path"]'), 'body.id');
|
|
setControlValue(container.querySelector<HTMLSelectElement>('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(<ProjectProvider><ProjectSeed /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
|
});
|
|
click(container.querySelector('[data-testid="add-variable"]'));
|
|
setControlValue(container.querySelector<HTMLInputElement>('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<HTMLSelectElement>('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(<ProjectProvider><ProjectSeed /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
|
});
|
|
click(container.querySelector('[data-testid="add-response-binding"]'));
|
|
const trigger = container.querySelector<HTMLSelectElement>('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<HTMLButtonElement>('[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(<ProjectProvider><ProjectSeed legacyBinding /><ActionInspector /><ProjectProbe /></ProjectProvider>);
|
|
});
|
|
click(container.querySelector('button[aria-label="Edit binding binding_legacy"]'));
|
|
const trigger = container.querySelector<HTMLSelectElement>('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<HTMLSelectElement>('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(<ProjectProvider><DiagnosticSeed trigger="page" /><ActionInspector /></ProjectProvider>);
|
|
});
|
|
|
|
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(<ProjectProvider><DiagnosticSeed trigger="none" /><ActionInspector /></ProjectProvider>);
|
|
});
|
|
|
|
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(<ProjectProvider><DiagnosticSeed trigger="component" /><ActionInspector /></ProjectProvider>);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|