test(editor): Improve NDV v2 test performance and coverage (#20789)

This commit is contained in:
Elias Meire 2025-10-15 11:21:45 +02:00 committed by GitHub
parent eb805d2ba7
commit c2949ace55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 260 additions and 165 deletions

View File

@ -57,7 +57,13 @@ function onRename(newNodeName: string) {
<template #content>
{{ i18n.baseText('ndv.close.tooltip') }}
</template>
<N8nIconButton icon="x" type="tertiary" text @click="emit('close')" />
<N8nIconButton
icon="x"
type="tertiary"
text
data-test-id="ndv-close-button"
@click="emit('close')"
/>
</N8nTooltip>
</div>
</header>

View File

@ -1,236 +1,190 @@
import { createPinia, setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { waitFor, fireEvent } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import NodeDetailsViewV2 from '@/components/NodeDetailsViewV2.vue';
import { VIEWS } from '@/constants';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/features/users/users.store';
import { MANUAL_TRIGGER_NODE_TYPE, SET_NODE_TYPE, STICKY_NODE_TYPE, VIEWS } from '@/constants';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createComponentRenderer } from '@/__tests__/render';
import { setupServer } from '@/__tests__/server';
import {
createTestNode,
createTestWorkflow,
createTestWorkflowObject,
defaultNodeDescriptions,
mockNodes,
} from '@/__tests__/mocks';
import type { Workflow } from 'n8n-workflow';
vi.mock('vue-router', () => {
return {
useRouter: () => ({}),
useRoute: () => ({ meta: {} }),
RouterLink: vi.fn(),
};
});
vi.mock('vue-router', () => ({
useRouter: () => ({}),
useRoute: () => ({ meta: {} }),
RouterLink: vi.fn(),
}));
const setupStore = (nodes: Array<ReturnType<typeof createTestNode>>) => {
const pinia = createTestingPinia({
stubActions: false,
});
setActivePinia(pinia);
async function createPiniaStore(
{ activeNodeName }: { activeNodeName: string | null } = { activeNodeName: null },
) {
const workflow = createTestWorkflow({
connections: {},
active: true,
nodes: mockNodes,
nodes,
});
const pinia = createPinia();
setActivePinia(pinia);
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const ndvStore = useNDVStore();
nodeTypesStore.setNodeTypes(defaultNodeDescriptions);
workflowsStore.workflow = workflow;
workflowsStore.workflowObject = createTestWorkflowObject(workflow);
workflowsStore.nodeMetadata = mockNodes.reduce(
workflowsStore.nodeMetadata = nodes.reduce(
(acc, node) => ({ ...acc, [node.name]: { pristine: true } }),
{},
);
if (activeNodeName) {
ndvStore.setActiveNodeName(activeNodeName, 'other');
} else {
ndvStore.unsetActiveNodeName();
}
await useSettingsStore().getSettings();
await useUsersStore().loginWithCookie();
return {
pinia,
workflowObject: workflowsStore.workflowObject as Workflow,
};
}
};
describe('NodeDetailsViewV2', () => {
let server: ReturnType<typeof setupServer>;
let pinia: ReturnType<typeof createTestingPinia>;
let workflowObject: Workflow;
const manualTriggerNode = createTestNode({
name: 'Manual Trigger',
type: MANUAL_TRIGGER_NODE_TYPE,
});
const setNode = createTestNode({ name: 'Set', type: SET_NODE_TYPE });
const stickyNode = createTestNode({ name: 'Sticky', type: STICKY_NODE_TYPE });
beforeAll(() => {
const renderComponent = (props: { readOnly?: boolean; activeNodeName?: string | null } = {}) => {
const { activeNodeName = null, ...componentProps } = props;
const ndvStore = useNDVStore();
if (activeNodeName) {
ndvStore.setActiveNodeName(activeNodeName, 'other');
} else {
ndvStore.unsetActiveNodeName();
}
const render = createComponentRenderer(NodeDetailsViewV2, {
props: {
workflowObject,
...componentProps,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
stubs: {
InputPanel: { template: '<div data-test-id="input-panel"></div>' },
OutputPanel: { template: '<div data-test-id="output-panel"></div>' },
TriggerPanel: {
template: '<div data-test-id="trigger-panel"></div>',
emits: ['execute', 'activate'],
},
NodeSettings: { template: '<div data-test-id="node-settings"></div>' },
},
},
});
return render({ pinia });
};
beforeEach(() => {
HTMLDialogElement.prototype.show = vi.fn();
server = setupServer();
HTMLDialogElement.prototype.close = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
server.shutdown();
});
test('should render correctly', async () => {
const { pinia, workflowObject } = await createPiniaStore({ activeNodeName: 'Manual Trigger' });
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
workflowObject,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
describe('rendering', () => {
beforeEach(() => {
const store = setupStore([manualTriggerNode, setNode, stickyNode]);
pinia = store.pinia;
workflowObject = store.workflowObject;
});
const { getByTestId } = renderComponent({
pinia,
test('should not render when no node is active', () => {
const { queryByTestId } = renderComponent({ activeNodeName: null });
expect(queryByTestId('ndv')).not.toBeInTheDocument();
});
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
});
test('should not render for stickies', async () => {
const { pinia, workflowObject } = await createPiniaStore({ activeNodeName: 'Sticky' });
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
workflowObject,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
test('should not render for sticky nodes', () => {
const { queryByTestId } = renderComponent({ activeNodeName: 'Sticky' });
expect(queryByTestId('ndv')).not.toBeInTheDocument();
});
const { queryByTestId } = renderComponent({
pinia,
});
expect(queryByTestId('ndv')).not.toBeInTheDocument();
});
describe('keyboard listener', () => {
test('should register and unregister keydown listener based on modal open state', async () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const { pinia, workflowObject } = await createPiniaStore({
activeNodeName: 'Manual Trigger',
});
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
workflowObject,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId, unmount } = renderComponent({
pinia,
});
test('should render for regular nodes', async () => {
const { getByTestId } = renderComponent({ activeNodeName: 'Set' });
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
});
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
expect(removeEventListenerSpy).not.toHaveBeenCalledWith(
'keydown',
expect.any(Function),
true,
);
test('should render trigger panel for trigger nodes', async () => {
const { getByTestId, queryByTestId } = renderComponent({ activeNodeName: 'Manual Trigger' });
unmount();
await waitFor(() => {
expect(getByTestId('ndv')).toBeInTheDocument();
expect(getByTestId('trigger-panel')).toBeInTheDocument();
expect(queryByTestId('input-panel')).not.toBeInTheDocument();
});
});
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
test('should render input panel for non-trigger nodes', async () => {
const { getByTestId, queryByTestId } = renderComponent({ activeNodeName: 'Set' });
await waitFor(() => {
expect(getByTestId('ndv')).toBeInTheDocument();
expect(getByTestId('input-panel')).toBeInTheDocument();
expect(queryByTestId('trigger-panel')).not.toBeInTheDocument();
});
});
});
describe('keyboard shortcuts', () => {
beforeEach(() => {
const store = setupStore([manualTriggerNode, setNode, stickyNode]);
pinia = store.pinia;
workflowObject = store.workflowObject;
});
test('should register keydown listener on mount', async () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
renderComponent({ activeNodeName: 'Manual Trigger' });
await waitFor(() => {
expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
});
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});
test('should unregister keydown listener on unmount', async () => {
const { pinia, workflowObject } = await createPiniaStore();
const ndvStore = useNDVStore();
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
workflowObject,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId, unmount } = renderComponent({
pinia,
});
ndvStore.setActiveNodeName('Manual Trigger', 'other');
const { getByTestId, unmount } = renderComponent({ activeNodeName: 'Manual Trigger' });
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
expect(removeEventListenerSpy).not.toHaveBeenCalledWith(
'keydown',
expect.any(Function),
true,
);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), true);
removeEventListenerSpy.mockRestore();
});
test("should emit 'saveKeyboardShortcut' when save shortcut keybind is pressed", async () => {
const { pinia, workflowObject } = await createPiniaStore({
activeNodeName: 'Manual Trigger',
});
const renderComponent = createComponentRenderer(NodeDetailsViewV2, {
props: {
workflowObject,
},
global: {
mocks: {
$route: {
name: VIEWS.WORKFLOW,
},
},
},
});
const { getByTestId, emitted } = renderComponent({
pinia,
});
test('should emit saveKeyboardShortcut when Ctrl+S is pressed', async () => {
const { getByTestId, emitted } = renderComponent({ activeNodeName: 'Manual Trigger' });
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
@ -242,6 +196,136 @@ describe('NodeDetailsViewV2', () => {
});
expect(emitted().saveKeyboardShortcut).toBeTruthy();
expect(emitted().saveKeyboardShortcut).toHaveLength(1);
});
test('should not emit saveKeyboardShortcut in readOnly mode', async () => {
const { getByTestId, emitted } = renderComponent({
activeNodeName: 'Manual Trigger',
readOnly: true,
});
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
await fireEvent.keyDown(getByTestId('ndv'), {
key: 's',
ctrlKey: true,
bubbles: true,
cancelable: true,
});
expect(emitted().saveKeyboardShortcut).toBeFalsy();
});
test('should not emit saveKeyboardShortcut when Ctrl is not pressed', async () => {
const { getByTestId, emitted } = renderComponent({ activeNodeName: 'Manual Trigger' });
await waitFor(() => expect(getByTestId('ndv')).toBeInTheDocument());
await fireEvent.keyDown(getByTestId('ndv'), {
key: 's',
bubbles: true,
cancelable: true,
});
expect(emitted().saveKeyboardShortcut).toBeFalsy();
});
});
describe('lifecycle', () => {
beforeEach(() => {
const store = setupStore([manualTriggerNode, setNode, stickyNode]);
pinia = store.pinia;
workflowObject = store.workflowObject;
});
test('should open dialog on mount', async () => {
const showSpy = vi.spyOn(HTMLDialogElement.prototype, 'show');
renderComponent({ activeNodeName: 'Manual Trigger' });
await waitFor(() => {
expect(showSpy).toHaveBeenCalled();
});
showSpy.mockRestore();
});
test('should handle dynamic node activation', async () => {
const { getByTestId, queryByTestId } = renderComponent({ activeNodeName: null });
// Initially no NDV
expect(queryByTestId('ndv')).not.toBeInTheDocument();
// Activate a node
const ndvStore = useNDVStore();
ndvStore.setActiveNodeName('Set', 'other');
// NDV should appear
await waitFor(() => {
expect(getByTestId('ndv')).toBeInTheDocument();
});
});
});
describe('user interactions', () => {
beforeEach(() => {
const store = setupStore([manualTriggerNode, setNode, stickyNode]);
pinia = store.pinia;
workflowObject = store.workflowObject;
});
test('should close NDV when close button is clicked', async () => {
const user = userEvent.setup();
const { getByRole, getByTestId } = renderComponent({ activeNodeName: 'Manual Trigger' });
await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument());
const closeButton = getByTestId('ndv-close-button');
await user.click(closeButton);
const ndvStore = useNDVStore();
await waitFor(() => {
expect(ndvStore.activeNodeName).toBeNull();
});
});
test('should close NDV when backdrop is clicked', async () => {
const user = userEvent.setup();
const { getByRole, getByTestId } = renderComponent({ activeNodeName: 'Manual Trigger' });
await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument());
const backdrop = getByTestId('ndv-backdrop');
await user.click(backdrop);
const ndvStore = useNDVStore();
await waitFor(() => {
expect(ndvStore.activeNodeName).toBeNull();
});
});
test('should emit renameNode when node name is edited', async () => {
const user = userEvent.setup();
const { emitted, getByRole, getByTestId } = renderComponent({
activeNodeName: 'Manual Trigger',
});
await waitFor(() => expect(getByRole('dialog')).toBeInTheDocument());
const editableArea = getByTestId('inline-editable-area');
await user.click(editableArea);
const input = getByTestId('inline-edit-input');
expect(input).toHaveValue('Manual Trigger');
await user.clear(input);
await user.type(input, 'Renamed Trigger');
await user.tab();
await waitFor(() => {
expect(emitted().renameNode).toEqual([['Renamed Trigger']]);
});
});
});
});

View File

@ -717,7 +717,12 @@ onBeforeUnmount(() => {
<template>
<Teleport v-if="activeNode && !isActiveStickyNode" :to="`#${APP_MODALS_ELEMENT_ID}`">
<div :class="$style.backdrop" :style="{ zIndex: APP_Z_INDEXES.NDV }" @click="close"></div>
<div
data-test-id="ndv-backdrop"
:class="$style.backdrop"
:style="{ zIndex: APP_Z_INDEXES.NDV }"
@click="close"
></div>
<dialog
ref="dialogRef"