mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
fix(editor): Show when update available for community packages if only verified packages are enabled (#39424)
This commit is contained in:
parent
f459f3c400
commit
070d9fea4e
|
|
@ -189,7 +189,7 @@ const nodeType = computed(() =>
|
|||
const { areAllCredentialsSet } = useNodeCredentialOptions(node, nodeType, '');
|
||||
|
||||
const nodeTypeName = computed(() => node.value?.type);
|
||||
const { installedPackage, isUpdateCheckAvailable } = useInstalledCommunityPackage(nodeTypeName);
|
||||
const { canUpdatePackage, hasUpdateAvailable } = useInstalledCommunityPackage(nodeTypeName);
|
||||
|
||||
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
|
||||
|
||||
|
|
@ -827,7 +827,7 @@ function handleSelectAction(params: INodeParameters) {
|
|||
</div>
|
||||
<div v-show="openPanel === 'settings'">
|
||||
<CommunityNodeUpdateInfo
|
||||
v-if="isUpdateCheckAvailable && installedPackage?.updateAvailable"
|
||||
v-if="canUpdatePackage && hasUpdateAvailable"
|
||||
data-test-id="update-available"
|
||||
:package-name="packageName"
|
||||
style="margin-top: var(--spacing--sm)"
|
||||
|
|
@ -887,6 +887,7 @@ function handleSelectAction(params: INodeParameters) {
|
|||
<CommunityNodeFooter
|
||||
v-if="openPanel === 'settings' && isCommunityNode"
|
||||
:package-name="packageName"
|
||||
:node-type-name="nodeTypeName"
|
||||
:show-manage="useUsersStore().isAdminOrOwner"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,33 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import NodeSettingsTabs from './NodeSettingsTabs.vue';
|
||||
import { ref } from 'vue';
|
||||
import type { ExtendedPublicInstalledPackage } from '@/features/settings/communityNodes/communityNodes.utils';
|
||||
import { useInstalledCommunityPackage } from '@/features/settings/communityNodes/composables/useInstalledCommunityPackage';
|
||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
|
||||
const renderComponent = createComponentRenderer(NodeSettingsTabs);
|
||||
const installedPackage = ref<PublicInstalledPackage>();
|
||||
const isCommunityNode = ref(false);
|
||||
const canUpdatePackage = ref(false);
|
||||
const hasUpdateAvailable = ref(false);
|
||||
|
||||
vi.mock('@/features/settings/communityNodes/composables/useInstalledCommunityPackage', () => ({
|
||||
useInstalledCommunityPackage: vi.fn(() => ({
|
||||
installedPackage: ref<ExtendedPublicInstalledPackage | undefined>(undefined),
|
||||
isCommunityNode: ref(false),
|
||||
isUpdateCheckAvailable: ref(false),
|
||||
installedPackage,
|
||||
isCommunityNode,
|
||||
canUpdatePackage,
|
||||
hasUpdateAvailable,
|
||||
initInstalledPackage: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
let installedCommunityPackage: ReturnType<typeof useInstalledCommunityPackage>;
|
||||
|
||||
describe('NodeSettingsTabs', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia({ stubActions: false });
|
||||
installedCommunityPackage = useInstalledCommunityPackage();
|
||||
installedPackage.value = undefined;
|
||||
isCommunityNode.value = false;
|
||||
canUpdatePackage.value = false;
|
||||
hasUpdateAvailable.value = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -37,24 +41,16 @@ describe('NodeSettingsTabs', () => {
|
|||
expect(getByText('Parameters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays notification when updateAvailable', async () => {
|
||||
vi.spyOn(installedCommunityPackage.isUpdateCheckAvailable, 'value', 'get').mockReturnValue(
|
||||
true,
|
||||
);
|
||||
vi.spyOn(installedCommunityPackage.installedPackage, 'value', 'get').mockReturnValue(
|
||||
mock<ExtendedPublicInstalledPackage>({
|
||||
packageName: 'test-package',
|
||||
installedVersion: '1.0.0',
|
||||
updateAvailable: '1.0.1',
|
||||
}),
|
||||
);
|
||||
it('displays notification when an update is available', async () => {
|
||||
canUpdatePackage.value = true;
|
||||
hasUpdateAvailable.value = true;
|
||||
|
||||
const { findByTestId } = renderComponent({
|
||||
props: {},
|
||||
});
|
||||
|
||||
const notification = await findByTestId('tab-settings');
|
||||
expect(notification).toBeDefined();
|
||||
const tab = await findByTestId('tab-settings');
|
||||
expect(tab.querySelector('.notification')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display notification when not updateAvailable', () => {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ const { docsUrl } = useNodeDocsUrl({ nodeType: () => props.nodeType });
|
|||
const activeNode = computed(() => ndvStore.value.activeNode);
|
||||
|
||||
const nodeTypeName = computed(() => props.nodeType?.name);
|
||||
const { installedPackage, isCommunityNode, isUpdateCheckAvailable } =
|
||||
const { isCommunityNode, canUpdatePackage, hasUpdateAvailable } =
|
||||
useInstalledCommunityPackage(nodeTypeName);
|
||||
|
||||
const packageName = computed(() => props.nodeType?.name.split('.')[0] ?? '');
|
||||
|
|
@ -89,8 +89,7 @@ const options = computed(() => {
|
|||
},
|
||||
{
|
||||
value: 'settings',
|
||||
notification:
|
||||
isUpdateCheckAvailable.value && installedPackage.value?.updateAvailable ? true : undefined,
|
||||
notification: canUpdatePackage.value && hasUpdateAvailable.value ? true : undefined,
|
||||
...(props.compact
|
||||
? { icon: 'settings', align: 'right', tooltip: i18n.baseText('nodeSettings.settings') }
|
||||
: { label: i18n.baseText('nodeSettings.settings') }),
|
||||
|
|
|
|||
|
|
@ -1,98 +1,53 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fetchInstalledPackageInfo, isNodesApiVersionError } from './communityNodes.utils';
|
||||
import { useCommunityNodesStore } from './communityNodes.store';
|
||||
import { type NodeTypesStore, useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
import type { CommunityNodeType } from '@n8n/api-types';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isCommunityPackageUpdateAvailable, isNodesApiVersionError } from './communityNodes.utils';
|
||||
|
||||
vi.mock('./communityNodes.store', () => ({
|
||||
useCommunityNodesStore: vi.fn(() => ({
|
||||
getInstalledPackage: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
describe('isCommunityPackageUpdateAvailable', () => {
|
||||
const defaults = {
|
||||
installedVersion: '1.0.0',
|
||||
latestVerifiedVersion: '1.0.0',
|
||||
isCommunityNodesFeatureEnabled: true,
|
||||
isUnverifiedPackagesEnabled: false,
|
||||
isManagedByEnv: false,
|
||||
};
|
||||
|
||||
vi.mock('@/app/stores/nodeTypes.store', () => ({
|
||||
useNodeTypesStore: vi.fn(() => ({
|
||||
communityNodeType: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
type CommunityNodesStore = ReturnType<typeof useCommunityNodesStore>;
|
||||
|
||||
const mockCommunityNodesStore = (mock: Partial<CommunityNodesStore>) => {
|
||||
vi.mocked(useCommunityNodesStore).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
getInstalledPackage: vi.fn(),
|
||||
...mock,
|
||||
}) as unknown as CommunityNodesStore,
|
||||
);
|
||||
};
|
||||
|
||||
const mockNodeTypesStore = (mock: Partial<NodeTypesStore>) => {
|
||||
vi.mocked(useNodeTypesStore).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
communityNodeType: vi.fn(),
|
||||
...mock,
|
||||
}) as unknown as NodeTypesStore,
|
||||
);
|
||||
};
|
||||
|
||||
describe('fetchInstalledPackageInfo', () => {
|
||||
beforeEach(() => {
|
||||
createTestingPinia({ stubActions: false });
|
||||
it('returns true when a newer verified version is available', () => {
|
||||
expect(
|
||||
isCommunityPackageUpdateAvailable({
|
||||
...defaults,
|
||||
latestVerifiedVersion: '1.1.0',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
it('ignores a newer npm version when unverified packages are disabled', () => {
|
||||
expect(
|
||||
isCommunityPackageUpdateAvailable({
|
||||
...defaults,
|
||||
updateAvailable: '1.1.0',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return undefined if no installed package is found', async () => {
|
||||
const packageName = 'test-package';
|
||||
mockCommunityNodesStore({
|
||||
getInstalledPackage: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const result = await fetchInstalledPackageInfo(packageName);
|
||||
expect(result).toBeUndefined();
|
||||
it('returns true for a newer npm version when unverified packages are enabled', () => {
|
||||
expect(
|
||||
isCommunityPackageUpdateAvailable({
|
||||
...defaults,
|
||||
updateAvailable: '1.1.0',
|
||||
isUnverifiedPackagesEnabled: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('should return package info with unverifiedUpdate as false if no update is available', async () => {
|
||||
const packageName = 'test-package';
|
||||
const installedPackage = { packageName, updateAvailable: null };
|
||||
mockCommunityNodesStore({
|
||||
getInstalledPackage: vi
|
||||
.fn()
|
||||
.mockResolvedValue(installedPackage as unknown as PublicInstalledPackage),
|
||||
});
|
||||
mockNodeTypesStore({
|
||||
communityNodeType: vi
|
||||
.fn()
|
||||
.mockReturnValue({ npmVersion: '1.0.0' } as unknown as CommunityNodeType),
|
||||
});
|
||||
|
||||
const result = await fetchInstalledPackageInfo(packageName);
|
||||
expect(result).toEqual({ ...installedPackage, unverifiedUpdate: false });
|
||||
});
|
||||
|
||||
it('should return package info with unverifiedUpdate as true if an update is available', async () => {
|
||||
const packageName = 'test-package';
|
||||
const installedPackage = { packageName, updateAvailable: '1.1.0' };
|
||||
mockCommunityNodesStore({
|
||||
getInstalledPackage: vi
|
||||
.fn()
|
||||
.mockResolvedValue(installedPackage as unknown as PublicInstalledPackage),
|
||||
});
|
||||
mockNodeTypesStore({
|
||||
communityNodeType: vi
|
||||
.fn()
|
||||
.mockReturnValue({ npmVersion: '1.0.0' } as unknown as CommunityNodeType),
|
||||
});
|
||||
|
||||
const result = await fetchInstalledPackageInfo(packageName);
|
||||
expect(result).toEqual({ ...installedPackage, unverifiedUpdate: true });
|
||||
it('returns false when community packages are managed by the environment', () => {
|
||||
expect(
|
||||
isCommunityPackageUpdateAvailable({
|
||||
...defaults,
|
||||
latestVerifiedVersion: '1.1.0',
|
||||
updateAvailable: '1.2.0',
|
||||
isUnverifiedPackagesEnabled: true,
|
||||
isManagedByEnv: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,44 @@
|
|||
import { useCommunityNodesStore } from './communityNodes.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { type PublicInstalledPackage } from 'n8n-workflow';
|
||||
import type { CommunityNodeType } from '@n8n/api-types';
|
||||
import semver from 'semver';
|
||||
|
||||
export type ExtendedPublicInstalledPackage = PublicInstalledPackage & {
|
||||
unverifiedUpdate: boolean;
|
||||
};
|
||||
export async function findVettedCommunityNodeAttributes(
|
||||
nodeTypes: string[],
|
||||
getCommunityNodeAttributes: (nodeType: string) => Promise<CommunityNodeType | null>,
|
||||
) {
|
||||
for (const nodeType of nodeTypes) {
|
||||
const attributes = await getCommunityNodeAttributes(nodeType);
|
||||
if (attributes) return attributes;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface CommunityPackageUpdateAvailability {
|
||||
installedVersion: string;
|
||||
updateAvailable?: string;
|
||||
latestVerifiedVersion?: string;
|
||||
isCommunityNodesFeatureEnabled: boolean;
|
||||
isUnverifiedPackagesEnabled: boolean;
|
||||
isManagedByEnv: boolean;
|
||||
}
|
||||
|
||||
export function isCommunityPackageUpdateAvailable({
|
||||
installedVersion,
|
||||
updateAvailable,
|
||||
latestVerifiedVersion,
|
||||
isCommunityNodesFeatureEnabled,
|
||||
isUnverifiedPackagesEnabled,
|
||||
isManagedByEnv,
|
||||
}: CommunityPackageUpdateAvailability) {
|
||||
if (isManagedByEnv) return false;
|
||||
if (isUnverifiedPackagesEnabled && updateAvailable) return true;
|
||||
|
||||
return Boolean(
|
||||
isCommunityNodesFeatureEnabled &&
|
||||
latestVerifiedVersion &&
|
||||
semver.gt(latestVerifiedVersion, installedVersion),
|
||||
);
|
||||
}
|
||||
|
||||
interface IncompatibleNodesApiVersionErrorResponse {
|
||||
httpStatusCode: number;
|
||||
|
|
@ -23,20 +56,3 @@ export const isNodesApiVersionError = (
|
|||
const e = error as IncompatibleNodesApiVersionErrorResponse | undefined;
|
||||
return e?.httpStatusCode === 400 && 'requiredNodesApiVersion' in (e.meta ?? {});
|
||||
};
|
||||
|
||||
export async function fetchInstalledPackageInfo(
|
||||
packageName: string,
|
||||
): Promise<ExtendedPublicInstalledPackage | undefined> {
|
||||
const installedPackage: PublicInstalledPackage | undefined =
|
||||
await useCommunityNodesStore().getInstalledPackage(packageName);
|
||||
const communityNodeType = useNodeTypesStore().communityNodeType(packageName);
|
||||
if (!installedPackage) {
|
||||
return undefined;
|
||||
}
|
||||
const checkIsUnverifiedUpdate = () => {
|
||||
if (!installedPackage?.updateAvailable || !communityNodeType) return false;
|
||||
return semver.gt(installedPackage.updateAvailable, communityNodeType.npmVersion);
|
||||
};
|
||||
|
||||
return { ...installedPackage, unverifiedUpdate: checkIsUnverifiedUpdate() };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,11 @@ import type { PublicInstalledNode, PublicInstalledPackage } from 'n8n-workflow';
|
|||
const communityPackage: PublicInstalledPackage = {
|
||||
packageName: 'n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
installedNodes: [{ name: 'TestNode' } as PublicInstalledNode],
|
||||
installedNodes: [
|
||||
{ name: 'OldNode', type: 'n8n-nodes-test.oldNode' } as PublicInstalledNode,
|
||||
{ name: 'TestNode', type: 'n8n-nodes-test.testNode' } as PublicInstalledNode,
|
||||
{ name: 'OtherNode', type: 'n8n-nodes-test.otherNode' } as PublicInstalledNode,
|
||||
],
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
|
|
@ -43,18 +47,18 @@ describe('CommunityPackageCard', () => {
|
|||
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
setupPinia();
|
||||
setupPinia({ communityNodesEnabled: true });
|
||||
nodeTypesStore = useNodeTypesStore();
|
||||
});
|
||||
|
||||
it('should call nodeTypesStore methods and update latestVerifiedVersion when packageName changes', async () => {
|
||||
Object.defineProperty(nodeTypesStore, 'visibleNodeTypes', {
|
||||
get: () => [{ name: 'n8n-nodes-test' }],
|
||||
});
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '2.0.0' });
|
||||
it('skips stale node types when loading the verified package version', async () => {
|
||||
nodeTypesStore.getCommunityNodeAttributes = vi
|
||||
.fn()
|
||||
.mockImplementation(async (nodeType) =>
|
||||
nodeType === 'n8n-nodes-test.testNode' ? { npmVersion: '2.0.0' } : null,
|
||||
);
|
||||
|
||||
renderComponent({
|
||||
const { getByText } = renderComponent({
|
||||
props: {
|
||||
communityPackage,
|
||||
},
|
||||
|
|
@ -62,8 +66,30 @@ describe('CommunityPackageCard', () => {
|
|||
|
||||
await flushPromises();
|
||||
|
||||
expect(nodeTypesStore.loadNodeTypesIfNotLoaded).toHaveBeenCalled();
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledWith('n8n-nodes-test');
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'n8n-nodes-test.oldNode',
|
||||
);
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'n8n-nodes-test.testNode',
|
||||
);
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledTimes(2);
|
||||
expect(getByText('Update')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not load verified package data without an installed node type', async () => {
|
||||
nodeTypesStore.getCommunityNodeAttributes = vi.fn();
|
||||
|
||||
renderComponent({
|
||||
props: {
|
||||
communityPackage: { ...communityPackage, installedNodes: [] },
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('uninstall action visibility', () => {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
|
|||
import type { UserAction } from '@n8n/design-system';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import semver from 'semver';
|
||||
import {
|
||||
findVettedCommunityNodeAttributes,
|
||||
isCommunityPackageUpdateAvailable,
|
||||
} from '../communityNodes.utils';
|
||||
|
||||
import {
|
||||
N8nActionToggle,
|
||||
|
|
@ -38,26 +41,20 @@ const settingsStore = useSettingsStore();
|
|||
const nodeTypesStore = useNodeTypesStore();
|
||||
|
||||
const latestVerifiedVersion = ref<string>();
|
||||
const currVersion = computed(() => props.communityPackage?.installedVersion || '');
|
||||
|
||||
const isManagedByEnv = computed(
|
||||
(): boolean => settingsStore.settings.communityNodesManagedByEnv ?? false,
|
||||
);
|
||||
const hasUpdateAvailable = computed(() => {
|
||||
if (!props.communityPackage) return false;
|
||||
|
||||
const hasUnverifiedPackagesUpdate = computed(() => {
|
||||
return (
|
||||
!isManagedByEnv.value &&
|
||||
settingsStore.isUnverifiedPackagesEnabled &&
|
||||
props.communityPackage?.updateAvailable
|
||||
);
|
||||
});
|
||||
|
||||
const hasVerifiedPackageUpdate = computed(() => {
|
||||
if (isManagedByEnv.value) return false;
|
||||
const canUpdate =
|
||||
latestVerifiedVersion.value && semver.gt(latestVerifiedVersion.value || '', currVersion.value);
|
||||
|
||||
return settingsStore.isCommunityNodesFeatureEnabled && canUpdate;
|
||||
return isCommunityPackageUpdateAvailable({
|
||||
installedVersion: props.communityPackage.installedVersion,
|
||||
updateAvailable: props.communityPackage.updateAvailable,
|
||||
latestVerifiedVersion: latestVerifiedVersion.value,
|
||||
isCommunityNodesFeatureEnabled: settingsStore.isCommunityNodesFeatureEnabled,
|
||||
isUnverifiedPackagesEnabled: settingsStore.isUnverifiedPackagesEnabled,
|
||||
isManagedByEnv: isManagedByEnv.value,
|
||||
});
|
||||
});
|
||||
|
||||
const packageActions = computed<Array<UserAction<IUser>>>(() => {
|
||||
|
|
@ -101,19 +98,13 @@ function onUpdateClick() {
|
|||
}
|
||||
|
||||
watch(
|
||||
() => props.communityPackage?.packageName,
|
||||
async (packageName) => {
|
||||
if (packageName) {
|
||||
await nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
const nodeType = nodeTypesStore.visibleNodeTypes.find((node) =>
|
||||
node.name.includes(packageName),
|
||||
);
|
||||
|
||||
const attributes = await nodeTypesStore.getCommunityNodeAttributes(nodeType?.name || '');
|
||||
if (attributes?.npmVersion) {
|
||||
latestVerifiedVersion.value = attributes.npmVersion;
|
||||
}
|
||||
}
|
||||
() => props.communityPackage?.installedNodes.map((node) => node.type) ?? [],
|
||||
async (nodeTypes) => {
|
||||
const attributes = await findVettedCommunityNodeAttributes(
|
||||
nodeTypes,
|
||||
nodeTypesStore.getCommunityNodeAttributes,
|
||||
);
|
||||
latestVerifiedVersion.value = attributes?.npmVersion;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
|
@ -158,10 +149,7 @@ watch(
|
|||
</template>
|
||||
<N8nIcon icon="triangle-alert" color="danger" size="large" />
|
||||
</N8nTooltip>
|
||||
<N8nTooltip
|
||||
v-else-if="hasUnverifiedPackagesUpdate || hasVerifiedPackageUpdate"
|
||||
placement="top"
|
||||
>
|
||||
<N8nTooltip v-else-if="hasUpdateAvailable" placement="top">
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.communityNodes.updateAvailable.tooltip') }}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import CommunityPackageManageConfirmModal from './CommunityPackageManageConfirmM
|
|||
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { defaultSettings } from '@n8n/frontend-test-utils';
|
||||
import { mockNodeTypeDescription } from '@/__tests__/mocks';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { STORES } from '@n8n/stores';
|
||||
import { COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY } from '../communityNodes.constants';
|
||||
import { useCommunityNodesStore } from '../communityNodes.store';
|
||||
import { fireEvent } from '@testing-library/vue';
|
||||
|
||||
const fetchWorkflowsWithNodesIncluded = vi.fn();
|
||||
vi.mock('@/app/stores/workflowsList.store', () => ({
|
||||
|
|
@ -35,16 +36,10 @@ const renderComponent = createComponentRenderer(CommunityPackageManageConfirmMod
|
|||
packageName: 'n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
updateAvailable: '2.0.0',
|
||||
installedNodes: [{ name: 'TestNode' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
[STORES.NODE_TYPES]: {
|
||||
nodeTypes: {
|
||||
['n8n-nodes-test.test']: {
|
||||
1: mockNodeTypeDescription({
|
||||
name: 'n8n-nodes-test.test',
|
||||
}),
|
||||
installedNodes: [
|
||||
{ name: 'OldNode', type: 'n8n-nodes-test.old' },
|
||||
{ name: 'TestNode', type: 'n8n-nodes-test.test' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -65,12 +60,18 @@ describe('CommunityPackageManageConfirmModal', () => {
|
|||
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore().$patch({
|
||||
settings: { ...defaultSettings, communityNodesEnabled: true },
|
||||
});
|
||||
nodeTypesStore = useNodeTypesStore();
|
||||
});
|
||||
|
||||
it('should call nodeTypesStore methods and update latestVerifiedVersion on mount', async () => {
|
||||
nodeTypesStore.loadNodeTypesIfNotLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
nodeTypesStore.getCommunityNodeAttributes = vi.fn().mockResolvedValue({ npmVersion: '2.0.0' });
|
||||
it('skips stale node types when loading package information', async () => {
|
||||
nodeTypesStore.getCommunityNodeAttributes = vi
|
||||
.fn()
|
||||
.mockImplementation(async (nodeType) =>
|
||||
nodeType === 'n8n-nodes-test.test' ? { npmVersion: '2.0.0' } : null,
|
||||
);
|
||||
|
||||
renderComponent({
|
||||
props: {
|
||||
|
|
@ -82,8 +83,50 @@ describe('CommunityPackageManageConfirmModal', () => {
|
|||
|
||||
await flushPromises();
|
||||
|
||||
expect(nodeTypesStore.loadNodeTypesIfNotLoaded).toHaveBeenCalled();
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledWith('n8n-nodes-test.test');
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'n8n-nodes-test.old',
|
||||
);
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'n8n-nodes-test.test',
|
||||
);
|
||||
expect(nodeTypesStore.getCommunityNodeAttributes).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses the exact package version and checksum for a verified-only update', async () => {
|
||||
useSettingsStore().$patch({
|
||||
settings: {
|
||||
...defaultSettings,
|
||||
communityNodesEnabled: true,
|
||||
unverifiedCommunityNodesEnabled: false,
|
||||
},
|
||||
});
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
nodeTypesStore.getCommunityNodeAttributes = vi
|
||||
.fn()
|
||||
.mockImplementation(async (nodeType) =>
|
||||
nodeType === 'n8n-nodes-test.test'
|
||||
? { npmVersion: '2.0.5', checksum: 'correct-checksum' }
|
||||
: null,
|
||||
);
|
||||
|
||||
const { getByRole } = renderComponent({
|
||||
props: {
|
||||
modalName: 'test-modal',
|
||||
activePackageName: 'n8n-nodes-test',
|
||||
mode: 'update',
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await fireEvent.click(getByRole('button', { name: 'Confirm update' }));
|
||||
|
||||
expect(communityNodesStore.updatePackage).toHaveBeenCalledWith(
|
||||
'n8n-nodes-test',
|
||||
'2.0.5',
|
||||
'correct-checksum',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call nodeTypesStore methods and update latestVerifiedVersion on mount', async () => {
|
||||
|
|
@ -162,7 +205,7 @@ describe('CommunityPackageManageConfirmModal', () => {
|
|||
expect(screen.getByText('Active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Confirm update')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
|
||||
expect(screen.getByText('Package includes: OldNode, TestNode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not include table with affected workflows', async () => {
|
||||
|
|
@ -188,7 +231,7 @@ describe('CommunityPackageManageConfirmModal', () => {
|
|||
const testId = screen.getByTestId('communityPackageManageConfirmModal-warning');
|
||||
expect(testId).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('Package includes: TestNode')).toBeInTheDocument();
|
||||
expect(screen.getByText('Package includes: OldNode, TestNode')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText('Nodes from this package are not used in any workflows'),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
} from '../communityNodes.constants';
|
||||
import { useToast } from '@n8n/composables/useToast';
|
||||
import { useCommunityNodesStore } from '../communityNodes.store';
|
||||
import { isNodesApiVersionError } from '../communityNodes.utils';
|
||||
import { findVettedCommunityNodeAttributes, isNodesApiVersionError } from '../communityNodes.utils';
|
||||
import { createEventBus } from '@n8n/utils/event-bus';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useTelemetry } from '@n8n/composables/useTelemetry';
|
||||
|
|
@ -195,17 +195,13 @@ const onUpdate = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
async function fetchPackageInfo(packageName: string) {
|
||||
await nodeTypesStore.loadNodeTypesIfNotLoaded();
|
||||
const nodeType = nodeTypesStore.visibleNodeTypes.find((nodeType) =>
|
||||
nodeType.name.includes(packageName),
|
||||
async function fetchPackageInfo() {
|
||||
const nodeTypes = communityStorePackage.value?.installedNodes.map((node) => node.type) ?? [];
|
||||
const communityNodeAttributes = await findVettedCommunityNodeAttributes(
|
||||
nodeTypes,
|
||||
nodeTypesStore.getCommunityNodeAttributes,
|
||||
);
|
||||
|
||||
if (nodeType) {
|
||||
const communityNodeAttributes = await nodeTypesStore.getCommunityNodeAttributes(nodeType?.name);
|
||||
|
||||
nodeTypeStorePackage.value = communityNodeAttributes ?? undefined;
|
||||
}
|
||||
nodeTypeStorePackage.value = communityNodeAttributes ?? undefined;
|
||||
}
|
||||
|
||||
function setIsVerifiedLatestPackage() {
|
||||
|
|
@ -233,7 +229,7 @@ const onClick = async () => {
|
|||
|
||||
onMounted(async () => {
|
||||
if (props.activePackageName) {
|
||||
await fetchPackageInfo(props.activePackageName);
|
||||
await fetchPackageInfo();
|
||||
}
|
||||
|
||||
if (communityStorePackage.value?.installedNodes.length) {
|
||||
|
|
|
|||
|
|
@ -6,15 +6,17 @@ import CommunityNodeFooter from './CommunityNodeFooter.vue';
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { vi } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import type { ExtendedPublicInstalledPackage } from '../../communityNodes.utils';
|
||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
|
||||
// Mock the useInstalledCommunityPackage composable
|
||||
const mockInstalledPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
|
||||
const mockInstalledPackage = ref<PublicInstalledPackage>();
|
||||
const mockHasUpdateAvailable = ref(false);
|
||||
|
||||
vi.mock('../../composables/useInstalledCommunityPackage', () => ({
|
||||
useInstalledCommunityPackage: vi.fn(() => ({
|
||||
installedPackage: mockInstalledPackage,
|
||||
isUpdateCheckAvailable: ref(false),
|
||||
canUpdatePackage: ref(false),
|
||||
hasUpdateAvailable: mockHasUpdateAvailable,
|
||||
isCommunityNode: ref(true),
|
||||
initInstalledPackage: vi.fn(),
|
||||
})),
|
||||
|
|
@ -48,6 +50,7 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
|
|||
|
||||
// Reset the mock installed package before each test
|
||||
mockInstalledPackage.value = undefined;
|
||||
mockHasUpdateAvailable.value = false;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -56,7 +59,11 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
|
|||
|
||||
it('calls router.push to open settings page when "Manage" is clicked', async () => {
|
||||
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
|
||||
props: { packageName: 'n8n-nodes-test', showManage: true },
|
||||
props: {
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeTypeName: 'n8n-nodes-test.test',
|
||||
showManage: true,
|
||||
},
|
||||
});
|
||||
|
||||
const manageLink = getByText('Manage');
|
||||
|
|
@ -67,26 +74,30 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
|
|||
|
||||
it('Manage should not be in the footer', () => {
|
||||
const { queryByText } = createComponentRenderer(CommunityNodeFooter)({
|
||||
props: { packageName: 'n8n-nodes-test', showManage: false },
|
||||
props: {
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeTypeName: 'n8n-nodes-test.test',
|
||||
showManage: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByText('Manage')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays "Legacy" when updateAvailable', () => {
|
||||
it('displays "Legacy" when an update is available', () => {
|
||||
mockInstalledPackage.value = {
|
||||
packageName: 'n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
updateAvailable: '1.0.1',
|
||||
unverifiedUpdate: false,
|
||||
installedNodes: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockHasUpdateAvailable.value = true;
|
||||
|
||||
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
|
||||
props: {
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeTypeName: 'n8n-nodes-test.test',
|
||||
showManage: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -94,31 +105,10 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
|
|||
expect(getByText('Package version 1.0.0 (Legacy)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays "Latest" when not updateAvailable', () => {
|
||||
it('displays "Latest" when no update is available', () => {
|
||||
mockInstalledPackage.value = {
|
||||
packageName: 'n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
unverifiedUpdate: false,
|
||||
installedNodes: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
|
||||
props: {
|
||||
packageName: 'n8n-nodes-test',
|
||||
showManage: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByText('Package version 1.0.0 (Latest)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays "Latest" when only unverified update is available', () => {
|
||||
mockInstalledPackage.value = {
|
||||
packageName: 'n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
unverifiedUpdate: true,
|
||||
installedNodes: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
|
|
@ -127,6 +117,7 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
|
|||
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
|
||||
props: {
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeTypeName: 'n8n-nodes-test.test',
|
||||
showManage: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -139,6 +130,7 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
|
|||
const { queryByText } = createComponentRenderer(CommunityNodeFooter)({
|
||||
props: {
|
||||
packageName: 'n8n-nodes-test',
|
||||
nodeTypeName: 'n8n-nodes-test.test',
|
||||
showManage: false,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { i18n } from '@n8n/i18n';
|
|||
import { N8nLink, N8nText } from '@n8n/design-system';
|
||||
export interface Props {
|
||||
packageName: string;
|
||||
nodeTypeName: string | undefined;
|
||||
showManage: boolean;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
|
@ -17,7 +18,9 @@ const props = defineProps<Props>();
|
|||
const router = useRouter();
|
||||
|
||||
const bugsUrl = ref<string>(`https://registry.npmjs.org/${props.packageName}`);
|
||||
const { installedPackage } = useInstalledCommunityPackage(props.packageName);
|
||||
const { installedPackage, hasUpdateAvailable } = useInstalledCommunityPackage(
|
||||
() => props.nodeTypeName,
|
||||
);
|
||||
|
||||
async function openSettingsPage() {
|
||||
await router.push({ name: VIEWS.COMMUNITY_NODES });
|
||||
|
|
@ -62,7 +65,7 @@ onMounted(async () => {
|
|||
<div :class="$style.container">
|
||||
<N8nText v-if="installedPackage" size="small" color="text-light" style="margin-right: auto">
|
||||
Package version {{ installedPackage.installedVersion }} ({{
|
||||
installedPackage.updateAvailable && !installedPackage.unverifiedUpdate
|
||||
hasUpdateAvailable
|
||||
? i18n.baseText('communityNodeFooter.legacy')
|
||||
: i18n.baseText('nodeSettings.latest')
|
||||
}})
|
||||
|
|
|
|||
|
|
@ -1,23 +1,20 @@
|
|||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { useInstalledCommunityPackage } from '../../composables/useInstalledCommunityPackage';
|
||||
import type { ExtendedPublicInstalledPackage } from '../../communityNodes.utils';
|
||||
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
|
||||
import { waitFor } from '@testing-library/vue';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { type ComputedRef, ref } from 'vue';
|
||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
import type { CommunityNodeDetails } from '@/features/shared/nodeCreator/composables/useViewStacks';
|
||||
import CommunityNodeInfo from './CommunityNodeInfo.vue';
|
||||
|
||||
// const mockInstalledPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
|
||||
// const isUpdateCheckAvailable = ref(false);
|
||||
|
||||
const defaultUseInstalledCommunityPackage = {
|
||||
installedPackage: ref({
|
||||
installedVersion: '1.0.0',
|
||||
packageName: 'n8n-nodes-test',
|
||||
unverifiedUpdate: false,
|
||||
}) as ComputedRef<ExtendedPublicInstalledPackage>,
|
||||
isUpdateCheckAvailable: ref(false),
|
||||
}) as ComputedRef<PublicInstalledPackage>,
|
||||
canUpdatePackage: ref(false),
|
||||
hasUpdateAvailable: ref(false),
|
||||
isCommunityNode: ref(true),
|
||||
initInstalledPackage: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useInstalledCommunityPackage>;
|
||||
|
|
@ -134,8 +131,7 @@ describe('CommunityNodeInfo', () => {
|
|||
installedPackage: ref({
|
||||
installedVersion: '1.0.0',
|
||||
packageName: 'n8n-nodes-test',
|
||||
unverifiedUpdate: false,
|
||||
}) as ComputedRef<ExtendedPublicInstalledPackage>,
|
||||
}) as ComputedRef<PublicInstalledPackage>,
|
||||
});
|
||||
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
|
@ -172,13 +168,13 @@ describe('CommunityNodeInfo', () => {
|
|||
});
|
||||
vi.mocked(useInstalledCommunityPackage).mockReturnValue({
|
||||
...defaultUseInstalledCommunityPackage,
|
||||
isUpdateCheckAvailable: ref(true) as ComputedRef<boolean>,
|
||||
canUpdatePackage: ref(true) as ComputedRef<boolean>,
|
||||
hasUpdateAvailable: ref(true) as ComputedRef<boolean>,
|
||||
installedPackage: ref({
|
||||
installedVersion: '0.0.9',
|
||||
packageName: 'n8n-nodes-test',
|
||||
updateAvailable: '1.0.1',
|
||||
unverifiedUpdate: false,
|
||||
}) as ComputedRef<ExtendedPublicInstalledPackage>,
|
||||
}) as ComputedRef<PublicInstalledPackage>,
|
||||
});
|
||||
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
|
@ -196,7 +192,7 @@ describe('CommunityNodeInfo', () => {
|
|||
).toEqual('A new node package version is available');
|
||||
});
|
||||
|
||||
it('should NOT display update notice for unverified update', async () => {
|
||||
it('should not display update notice when the user cannot update packages', async () => {
|
||||
const { useViewStacks } = await import(
|
||||
'@/features/shared/nodeCreator/composables/useViewStacks'
|
||||
);
|
||||
|
|
@ -218,12 +214,12 @@ describe('CommunityNodeInfo', () => {
|
|||
});
|
||||
vi.mocked(useInstalledCommunityPackage).mockReturnValue({
|
||||
...defaultUseInstalledCommunityPackage,
|
||||
hasUpdateAvailable: ref(true) as ComputedRef<boolean>,
|
||||
installedPackage: ref({
|
||||
installedVersion: '0.0.9',
|
||||
packageName: 'n8n-nodes-test',
|
||||
updateAvailable: '1.0.1',
|
||||
unverifiedUpdate: true,
|
||||
}) as ComputedRef<ExtendedPublicInstalledPackage>,
|
||||
}) as ComputedRef<PublicInstalledPackage>,
|
||||
});
|
||||
|
||||
const wrapper = renderComponent({ pinia });
|
||||
|
|
@ -299,8 +295,7 @@ describe('CommunityNodeInfo', () => {
|
|||
installedPackage: ref({
|
||||
installedVersion: '1.0.0',
|
||||
packageName: 'n8n-nodes-test',
|
||||
unverifiedUpdate: false,
|
||||
}) as ComputedRef<ExtendedPublicInstalledPackage>,
|
||||
}) as ComputedRef<PublicInstalledPackage>,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,9 @@ const downloads = ref<string | null>(null);
|
|||
const verified = ref(false);
|
||||
const official = ref(false);
|
||||
const packageName = computed(() => communityNodeDetails?.packageName);
|
||||
const { installedPackage, initInstalledPackage, isUpdateCheckAvailable } =
|
||||
useInstalledCommunityPackage(packageName);
|
||||
const nodeTypeName = computed(() => communityNodeDetails?.key);
|
||||
const { installedPackage, initInstalledPackage, canUpdatePackage, hasUpdateAvailable } =
|
||||
useInstalledCommunityPackage(nodeTypeName);
|
||||
const { getQuickConnectOptionByPackageName } = useQuickConnect();
|
||||
const quickConnect = computed(() => {
|
||||
const pkg = packageName.value;
|
||||
|
|
@ -118,7 +119,7 @@ onMounted(async () => {
|
|||
{{ communityNodeDetails?.description }}
|
||||
</N8nText>
|
||||
<CommunityNodeUpdateInfo
|
||||
v-if="isUpdateCheckAvailable && installedPackage?.updateAvailable"
|
||||
v-if="canUpdatePackage && hasUpdateAvailable"
|
||||
data-test-id="update-available"
|
||||
:package-name="communityNodeDetails?.packageName"
|
||||
source="node creator panel"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,17 @@
|
|||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { nextTick } from 'vue';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import type { CommunityNodeType } from '@n8n/api-types';
|
||||
import type { PublicInstalledPackage } from 'n8n-workflow';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import { nextTick, ref } from 'vue';
|
||||
|
||||
import { useInstalledCommunityPackage } from './useInstalledCommunityPackage';
|
||||
import { useCommunityNodesStore } from '../communityNodes.store';
|
||||
import { mockedStore } from '@/__tests__/utils';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import type { ExtendedPublicInstalledPackage } from '../communityNodes.utils';
|
||||
import { useCommunityNodesStore } from '../communityNodes.store';
|
||||
import { useInstalledCommunityPackage } from './useInstalledCommunityPackage';
|
||||
import type * as n8nWorkflow from 'n8n-workflow';
|
||||
|
||||
vi.mock('n8n-workflow', async (importOriginal) => {
|
||||
|
|
@ -18,16 +22,16 @@ vi.mock('n8n-workflow', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
vi.mock('../communityNodes.utils', () => ({
|
||||
fetchInstalledPackageInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import mocked functions
|
||||
import { isCommunityPackageName } from 'n8n-workflow';
|
||||
import { fetchInstalledPackageInfo } from '../communityNodes.utils';
|
||||
|
||||
const mockIsCommunityPackageName = vi.mocked(isCommunityPackageName);
|
||||
const mockFetchInstalledPackageInfo = vi.mocked(fetchInstalledPackageInfo);
|
||||
const communityPackage = (): PublicInstalledPackage => ({
|
||||
packageName: '@test/n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
installedNodes: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('useInstalledCommunityPackage', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -35,228 +39,134 @@ describe('useInstalledCommunityPackage', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should handle nodeTypeName parsing correctly', () => {
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
const composable = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
expect(composable.isCommunityNode.value).toBe(true);
|
||||
});
|
||||
it('identifies community nodes', () => {
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
|
||||
it('should return false for isCommunityNode when nodeTypeName is undefined', () => {
|
||||
const composable = useInstalledCommunityPackage();
|
||||
expect(composable.isCommunityNode.value).toBe(false);
|
||||
});
|
||||
const { isCommunityNode } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
|
||||
it('should compute isCommunityNode correctly when nodeTypeName is provided and is community', () => {
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
|
||||
const { isCommunityNode } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
|
||||
expect(isCommunityNode.value).toBe(true);
|
||||
expect(mockIsCommunityPackageName).toHaveBeenCalledWith('@test/n8n-nodes-test.TestNode');
|
||||
});
|
||||
|
||||
it('should compute isCommunityNode as false when nodeTypeName is provided but not community', () => {
|
||||
mockIsCommunityPackageName.mockReturnValue(false);
|
||||
|
||||
const { isCommunityNode } = useInstalledCommunityPackage('n8n-nodes-base.HttpRequest');
|
||||
|
||||
expect(isCommunityNode.value).toBe(false);
|
||||
expect(mockIsCommunityPackageName).toHaveBeenCalledWith('n8n-nodes-base.HttpRequest');
|
||||
});
|
||||
|
||||
it('should compute isCommunityNode as false when nodeTypeName is undefined', () => {
|
||||
const { isCommunityNode } = useInstalledCommunityPackage();
|
||||
|
||||
expect(isCommunityNode.value).toBe(false);
|
||||
expect(mockIsCommunityPackageName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compute isUpdateCheckAvailable correctly when user is instance owner and node is community', () => {
|
||||
const usersStore = mockedStore(useUsersStore);
|
||||
usersStore.isAdminOrOwner = true;
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
|
||||
const { isUpdateCheckAvailable } = useInstalledCommunityPackage(
|
||||
'@test/n8n-nodes-test.TestNode',
|
||||
);
|
||||
|
||||
expect(isUpdateCheckAvailable.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should compute isUpdateCheckAvailable as false when user is not instance owner', () => {
|
||||
const usersStore = mockedStore(useUsersStore);
|
||||
usersStore.isAdminOrOwner = false;
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
|
||||
const { isUpdateCheckAvailable } = useInstalledCommunityPackage(
|
||||
'@test/n8n-nodes-test.TestNode',
|
||||
);
|
||||
|
||||
expect(isUpdateCheckAvailable.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should compute isUpdateCheckAvailable as false when node is not community', () => {
|
||||
const usersStore = mockedStore(useUsersStore);
|
||||
usersStore.isAdminOrOwner = true;
|
||||
mockIsCommunityPackageName.mockReturnValue(false);
|
||||
|
||||
const { isUpdateCheckAvailable } = useInstalledCommunityPackage('n8n-nodes-base.HttpRequest');
|
||||
|
||||
expect(isUpdateCheckAvailable.value).toBe(false);
|
||||
});
|
||||
expect(isCommunityNode.value).toBe(true);
|
||||
expect(mockIsCommunityPackageName).toHaveBeenCalledWith('@test/n8n-nodes-test.TestNode');
|
||||
});
|
||||
|
||||
describe('initInstalledPackage', () => {
|
||||
it('should return undefined when packageName is empty', async () => {
|
||||
const { initInstalledPackage } = useInstalledCommunityPackage();
|
||||
it('does not identify built-in or missing node types as community nodes', () => {
|
||||
mockIsCommunityPackageName.mockReturnValue(false);
|
||||
|
||||
const result = await initInstalledPackage();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockFetchInstalledPackageInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return undefined when node is not a community node', async () => {
|
||||
mockIsCommunityPackageName.mockReturnValue(false);
|
||||
|
||||
const { initInstalledPackage } = useInstalledCommunityPackage('n8n-nodes-base.HttpRequest');
|
||||
|
||||
const result = await initInstalledPackage();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(mockFetchInstalledPackageInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch and set installedPackage when conditions are met', async () => {
|
||||
const mockPackage: ExtendedPublicInstalledPackage = {
|
||||
packageName: '@test/n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
installedNodes: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
unverifiedUpdate: false,
|
||||
};
|
||||
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
mockFetchInstalledPackageInfo.mockResolvedValue(mockPackage);
|
||||
|
||||
const { initInstalledPackage, installedPackage } = useInstalledCommunityPackage(
|
||||
'@test/n8n-nodes-test.TestNode',
|
||||
);
|
||||
|
||||
const result = await initInstalledPackage();
|
||||
|
||||
expect(mockFetchInstalledPackageInfo).toHaveBeenCalledWith('@test/n8n-nodes-test');
|
||||
expect(result).toStrictEqual(mockPackage);
|
||||
expect(installedPackage.value).toStrictEqual(mockPackage);
|
||||
});
|
||||
|
||||
it('should handle fetchInstalledPackageInfo returning undefined', async () => {
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
mockFetchInstalledPackageInfo.mockResolvedValue(undefined);
|
||||
|
||||
const { initInstalledPackage, installedPackage } = useInstalledCommunityPackage(
|
||||
'@test/n8n-nodes-test.TestNode',
|
||||
);
|
||||
|
||||
const result = await initInstalledPackage();
|
||||
|
||||
expect(mockFetchInstalledPackageInfo).toHaveBeenCalledWith('@test/n8n-nodes-test');
|
||||
expect(result).toBeUndefined();
|
||||
expect(installedPackage.value).toBeUndefined();
|
||||
});
|
||||
expect(useInstalledCommunityPackage('n8n-nodes-base.HttpRequest').isCommunityNode.value).toBe(
|
||||
false,
|
||||
);
|
||||
expect(useInstalledCommunityPackage().isCommunityNode.value).toBe(false);
|
||||
});
|
||||
|
||||
describe('watcher functionality', () => {
|
||||
it('should call initInstalledPackage when installedPackages changes', async () => {
|
||||
const communityNodesStore = mockedStore(useCommunityNodesStore);
|
||||
const mockPackage: ExtendedPublicInstalledPackage = {
|
||||
packageName: '@test/n8n-nodes-test',
|
||||
installedVersion: '1.0.0',
|
||||
installedNodes: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
unverifiedUpdate: false,
|
||||
};
|
||||
it('allows owners and admins to update community packages', () => {
|
||||
const usersStore = mockedStore(useUsersStore);
|
||||
usersStore.isAdminOrOwner = true;
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
mockFetchInstalledPackageInfo.mockResolvedValue(mockPackage);
|
||||
const { canUpdatePackage } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
|
||||
// Initialize the composable
|
||||
const { installedPackage } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
|
||||
// Simulate store change
|
||||
communityNodesStore.installedPackages = {
|
||||
'@test/n8n-nodes-test': mockPackage,
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockFetchInstalledPackageInfo).toHaveBeenCalledWith('@test/n8n-nodes-test');
|
||||
expect(installedPackage.value).toStrictEqual(mockPackage);
|
||||
});
|
||||
|
||||
it('should not call initInstalledPackage when packageName is empty', async () => {
|
||||
const communityNodesStore = mockedStore(useCommunityNodesStore);
|
||||
|
||||
// Initialize the composable without nodeTypeName
|
||||
useInstalledCommunityPackage();
|
||||
|
||||
// Simulate store change
|
||||
const mockSomePackage: ExtendedPublicInstalledPackage = {
|
||||
packageName: 'some-package',
|
||||
installedVersion: '1.0.0',
|
||||
installedNodes: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
unverifiedUpdate: false,
|
||||
};
|
||||
communityNodesStore.installedPackages = {
|
||||
'some-package': mockSomePackage,
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockFetchInstalledPackageInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call initInstalledPackage when package is not in installedPackages', async () => {
|
||||
const communityNodesStore = mockedStore(useCommunityNodesStore);
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
|
||||
// Initialize the composable
|
||||
useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
|
||||
// Simulate store change with different package
|
||||
const mockDifferentPackage: ExtendedPublicInstalledPackage = {
|
||||
packageName: 'different-package',
|
||||
installedVersion: '1.0.0',
|
||||
installedNodes: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
unverifiedUpdate: false,
|
||||
};
|
||||
communityNodesStore.installedPackages = {
|
||||
'different-package': mockDifferentPackage,
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(mockFetchInstalledPackageInfo).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(canUpdatePackage.value).toBe(true);
|
||||
});
|
||||
|
||||
describe('return values', () => {
|
||||
it('should return all expected properties and functions', () => {
|
||||
const result = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
it('does not allow members to update community packages', () => {
|
||||
const usersStore = mockedStore(useUsersStore);
|
||||
usersStore.isAdminOrOwner = false;
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
|
||||
expect(result).toHaveProperty('installedPackage');
|
||||
expect(result).toHaveProperty('isUpdateCheckAvailable');
|
||||
expect(result).toHaveProperty('isCommunityNode');
|
||||
expect(result).toHaveProperty('initInstalledPackage');
|
||||
const { canUpdatePackage } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
|
||||
expect(typeof result.initInstalledPackage).toBe('function');
|
||||
expect(canUpdatePackage.value).toBe(false);
|
||||
});
|
||||
|
||||
it('reacts when a newer verified version becomes available', async () => {
|
||||
const communityNodesStore = mockedStore(useCommunityNodesStore);
|
||||
const nodeTypesStore = mockedStore(useNodeTypesStore);
|
||||
const settingsStore = mockedStore(useSettingsStore);
|
||||
const latestVerifiedVersion = ref('1.0.0');
|
||||
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
communityNodesStore.getInstalledPackage.mockResolvedValue(communityPackage());
|
||||
vi.spyOn(nodeTypesStore, 'communityNodeType', 'get').mockReturnValue(
|
||||
vi.fn(() => mock<CommunityNodeType>({ npmVersion: latestVerifiedVersion.value })),
|
||||
);
|
||||
Object.defineProperty(settingsStore, 'isCommunityNodesFeatureEnabled', {
|
||||
get: () => true,
|
||||
});
|
||||
Object.defineProperty(settingsStore, 'isUnverifiedPackagesEnabled', {
|
||||
get: () => false,
|
||||
});
|
||||
settingsStore.settings.communityNodesManagedByEnv = false;
|
||||
|
||||
const { hasUpdateAvailable, initInstalledPackage } = useInstalledCommunityPackage(
|
||||
'@test/n8n-nodes-test.TestNode',
|
||||
);
|
||||
await initInstalledPackage();
|
||||
expect(hasUpdateAvailable.value).toBe(false);
|
||||
|
||||
latestVerifiedVersion.value = '1.1.0';
|
||||
await nextTick();
|
||||
|
||||
expect(hasUpdateAvailable.value).toBe(true);
|
||||
});
|
||||
|
||||
it('fetches the installed package for a community node', async () => {
|
||||
const communityNodesStore = mockedStore(useCommunityNodesStore);
|
||||
const installed = communityPackage();
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
communityNodesStore.getInstalledPackage.mockResolvedValue(installed);
|
||||
|
||||
const { initInstalledPackage, installedPackage } = useInstalledCommunityPackage(
|
||||
'@test/n8n-nodes-test.TestNode',
|
||||
);
|
||||
const result = await initInstalledPackage();
|
||||
|
||||
expect(communityNodesStore.getInstalledPackage).toHaveBeenCalledWith('@test/n8n-nodes-test');
|
||||
expect(result).toStrictEqual(installed);
|
||||
expect(installedPackage.value).toStrictEqual(installed);
|
||||
});
|
||||
|
||||
it('does not fetch an installed package without a community node type', async () => {
|
||||
const communityNodesStore = mockedStore(useCommunityNodesStore);
|
||||
mockIsCommunityPackageName.mockReturnValue(false);
|
||||
|
||||
expect(await useInstalledCommunityPackage().initInstalledPackage()).toBeUndefined();
|
||||
expect(
|
||||
await useInstalledCommunityPackage('n8n-nodes-base.HttpRequest').initInstalledPackage(),
|
||||
).toBeUndefined();
|
||||
expect(communityNodesStore.getInstalledPackage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates when the installed package changes', async () => {
|
||||
const communityNodesStore = mockedStore(useCommunityNodesStore);
|
||||
const installed = communityPackage();
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
communityNodesStore.getInstalledPackage.mockResolvedValue(installed);
|
||||
|
||||
const { installedPackage } = useInstalledCommunityPackage('@test/n8n-nodes-test.TestNode');
|
||||
communityNodesStore.installedPackages = {
|
||||
'@test/n8n-nodes-test': installed,
|
||||
};
|
||||
await nextTick();
|
||||
|
||||
expect(installedPackage.value).toStrictEqual(installed);
|
||||
});
|
||||
|
||||
it('clears the installed package when the node type changes', async () => {
|
||||
const communityNodesStore = mockedStore(useCommunityNodesStore);
|
||||
const installed = communityPackage();
|
||||
const nodeTypeName = ref('@test/n8n-nodes-test.TestNode');
|
||||
mockIsCommunityPackageName.mockReturnValue(true);
|
||||
communityNodesStore.getInstalledPackage.mockResolvedValue(installed);
|
||||
communityNodesStore.installedPackages = {
|
||||
'@test/n8n-nodes-test': installed,
|
||||
};
|
||||
|
||||
const { installedPackage, initInstalledPackage } = useInstalledCommunityPackage(nodeTypeName);
|
||||
await initInstalledPackage();
|
||||
expect(installedPackage.value).toStrictEqual(installed);
|
||||
|
||||
nodeTypeName.value = '@test/n8n-nodes-other.OtherNode';
|
||||
await nextTick();
|
||||
|
||||
expect(installedPackage.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,18 @@
|
|||
import { useCommunityNodesStore } from '../communityNodes.store';
|
||||
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
|
||||
import { useSettingsStore } from '@n8n/stores/settings.store';
|
||||
import { useUsersStore } from '@n8n/stores/users.store';
|
||||
import { isCommunityPackageName } from 'n8n-workflow';
|
||||
import {
|
||||
type ExtendedPublicInstalledPackage,
|
||||
fetchInstalledPackageInfo,
|
||||
} from '../communityNodes.utils';
|
||||
import { isCommunityPackageName, type PublicInstalledPackage } from 'n8n-workflow';
|
||||
import { isCommunityPackageUpdateAvailable } from '../communityNodes.utils';
|
||||
import { computed, type MaybeRefOrGetter, onMounted, ref, watch, toValue } from 'vue';
|
||||
|
||||
export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<string | undefined>) {
|
||||
const communityNodesStore = useCommunityNodesStore();
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
const installedPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
|
||||
const installedPackage = ref<PublicInstalledPackage>();
|
||||
|
||||
const packageName = computed(() => toValue(nodeTypeName)?.split('.')[0] ?? '');
|
||||
const isCommunityNode = computed(() => {
|
||||
|
|
@ -24,15 +25,18 @@ export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<str
|
|||
|
||||
const initInstalledPackage = async () => {
|
||||
if (!packageName.value || !isCommunityNode.value) return undefined;
|
||||
installedPackage.value = await fetchInstalledPackageInfo(packageName.value);
|
||||
installedPackage.value = await communityNodesStore.getInstalledPackage(packageName.value);
|
||||
return installedPackage.value;
|
||||
};
|
||||
|
||||
// update when installed package changes if it's defined
|
||||
// Keep package data in sync when the store or selected node changes.
|
||||
watch(
|
||||
() => communityNodesStore.installedPackages[packageName.value],
|
||||
async (changedPackage) => {
|
||||
if (!packageName.value || !changedPackage) return;
|
||||
if (!packageName.value || !changedPackage) {
|
||||
installedPackage.value = undefined;
|
||||
return;
|
||||
}
|
||||
await initInstalledPackage();
|
||||
},
|
||||
{ deep: true },
|
||||
|
|
@ -45,20 +49,33 @@ export function useInstalledCommunityPackage(nodeTypeName?: MaybeRefOrGetter<str
|
|||
});
|
||||
|
||||
/**
|
||||
* True when the node is a community node, the user has rights to update the package and the package is not an unverified update.
|
||||
* True when the node is a community node and the user has rights to update the package.
|
||||
* Update dialogs and button should not be shown when this is false.
|
||||
*/
|
||||
const isUpdateCheckAvailable = computed(() => {
|
||||
return (
|
||||
isCommunityNode.value &&
|
||||
usersStore.isAdminOrOwner &&
|
||||
!installedPackage.value?.unverifiedUpdate
|
||||
);
|
||||
const canUpdatePackage = computed(() => {
|
||||
return isCommunityNode.value && usersStore.isAdminOrOwner;
|
||||
});
|
||||
|
||||
const hasUpdateAvailable = computed(() => {
|
||||
const packageInfo = installedPackage.value;
|
||||
if (!packageInfo) return false;
|
||||
|
||||
const communityNodeType = nodeTypesStore.communityNodeType(toValue(nodeTypeName) ?? '');
|
||||
|
||||
return isCommunityPackageUpdateAvailable({
|
||||
installedVersion: packageInfo.installedVersion,
|
||||
updateAvailable: packageInfo.updateAvailable,
|
||||
latestVerifiedVersion: communityNodeType?.npmVersion,
|
||||
isCommunityNodesFeatureEnabled: settingsStore.isCommunityNodesFeatureEnabled,
|
||||
isUnverifiedPackagesEnabled: settingsStore.isUnverifiedPackagesEnabled,
|
||||
isManagedByEnv: settingsStore.settings.communityNodesManagedByEnv ?? false,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
installedPackage,
|
||||
isUpdateCheckAvailable,
|
||||
canUpdatePackage,
|
||||
hasUpdateAvailable,
|
||||
isCommunityNode,
|
||||
initInstalledPackage,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@ const callouts = computed<INodeCreateElement[]>(() => []);
|
|||
v-if="communityNodeDetails"
|
||||
:class="$style.communityNodeFooter"
|
||||
:package-name="communityNodeDetails.packageName"
|
||||
:node-type-name="communityNodeDetails.key"
|
||||
:show-manage="communityNodeDetails.installed && usersStore.isAdminOrOwner"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -332,6 +332,7 @@ function onBackButton() {
|
|||
<CommunityNodeFooter
|
||||
v-if="communityNodeDetails && !isCommunityNodeActionsMode"
|
||||
:package-name="communityNodeDetails.packageName"
|
||||
:node-type-name="communityNodeDetails.key"
|
||||
:show-manage="communityNodeDetails.installed && isAdminOrOwner"
|
||||
/>
|
||||
</aside>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user