328 lines
14 KiB
TypeScript
328 lines
14 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;
|
|
}
|
|
|
|
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('anonymous');
|
|
|
|
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: 'anonymous',
|
|
});
|
|
});
|
|
|
|
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']);
|
|
});
|
|
});
|