fix(editor): Don't show update notification for unverified updates (#18910)

Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
yehorkardash
2025-09-05 13:18:56 +03:00
committed by GitHub
parent c5bbb6a96f
commit abaa2c851b
8 changed files with 228 additions and 57 deletions

View File

@@ -26,6 +26,12 @@
"test:postgres": "N8N_LOG_LEVEL=silent DB_TYPE=postgresdb DB_POSTGRESDB_SCHEMA=alt_schema DB_TABLE_PREFIX=test_ jest --no-coverage",
"test:mariadb": "N8N_LOG_LEVEL=silent DB_TYPE=mariadb DB_TABLE_PREFIX=test_ jest --no-coverage",
"test:mysql": "N8N_LOG_LEVEL=silent DB_TYPE=mysqldb DB_TABLE_PREFIX=test_ jest --no-coverage",
"test:win": "pnpm test:sqlite:win",
"test:dev:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest --watch",
"test:sqlite:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=sqlite&& jest",
"test:postgres:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=postgresdb&& set DB_POSTGRESDB_SCHEMA=alt_schema&& set DB_TABLE_PREFIX=test_&& jest --no-coverage",
"test:mariadb:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mariadb&& set DB_TABLE_PREFIX=test_&& jest --no-coverage",
"test:mysql:win": "set N8N_LOG_LEVEL=silent&& set DB_TYPE=mysqldb&& set DB_TABLE_PREFIX=test_&& jest --no-coverage",
"watch": "tsc-watch -p tsconfig.build.json --onCompilationComplete \"tsc-alias -p tsconfig.build.json\""
},
"bin": {

View File

@@ -1,5 +1,5 @@
import type { Logger } from '@n8n/backend-common';
import { randomName, mockInstance } from '@n8n/backend-test-utils';
import { mockInstance, randomName } from '@n8n/backend-test-utils';
import { LICENSE_FEATURES } from '@n8n/constants';
import axios from 'axios';
import { mocked } from 'jest-mock';
@@ -7,8 +7,8 @@ import { mock } from 'jest-mock-extended';
import type { InstanceSettings, PackageDirectoryLoader } from 'n8n-core';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { exec } from 'node:child_process';
import { mkdir, readFile, writeFile, rm, access, constants } from 'node:fs/promises';
import { join } from 'node:path';
import { access, constants, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path, { join } from 'node:path';
import {
NODE_PACKAGE_PREFIX,
@@ -54,7 +54,7 @@ describe('CommunityPackagesService', () => {
const installedNodesRepository = mockInstance(InstalledNodesRepository);
const installedPackageRepository = mockInstance(InstalledPackagesRepository);
const nodesDownloadDir = '/tmp/n8n-jest-global-downloads';
const nodesDownloadDir = path.join('tmp', 'n8n-jest-global-downloads');
const instanceSettings = mock<InstanceSettings>({ nodesDownloadDir });
const logger = mock<Logger>();
@@ -441,7 +441,10 @@ describe('CommunityPackagesService', () => {
// ASSERT:
expect(rm).toHaveBeenCalledTimes(2);
expect(rm).toHaveBeenNthCalledWith(1, testBlockPackageDir, { recursive: true, force: true });
expect(rm).toHaveBeenNthCalledWith(2, `${nodesDownloadDir}/n8n-nodes-test-latest.tgz`);
expect(rm).toHaveBeenNthCalledWith(
2,
path.join(nodesDownloadDir, 'n8n-nodes-test-latest.tgz'),
);
expect(exec).toHaveBeenCalledTimes(3);
expect(exec).toHaveBeenNthCalledWith(
@@ -672,7 +675,7 @@ describe('CommunityPackagesService', () => {
await communityPackagesService.updatePackageJsonDependency('test-package', '1.0.0');
expect(writeFile).toHaveBeenCalledWith(
`${nodesDownloadDir}/package.json`,
path.join(nodesDownloadDir, 'package.json'),
JSON.stringify({ dependencies: { 'test-package': '1.0.0' } }, null, 2),
'utf-8',
);
@@ -682,7 +685,7 @@ describe('CommunityPackagesService', () => {
await communityPackagesService.updatePackageJsonDependency('test-package', '1.0.0');
expect(writeFile).toHaveBeenCalledWith(
`${nodesDownloadDir}/package.json`,
path.join(nodesDownloadDir, 'package.json'),
JSON.stringify({ dependencies: { 'test-package': '1.0.0' } }, null, 2),
'utf-8',
);

View File

@@ -5,18 +5,16 @@ import { setActivePinia } from 'pinia';
import CommunityNodeFooter from './CommunityNodeFooter.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { vi } from 'vitest';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { type ExtendedPublicInstalledPackage, fetchInstalledPackageInfo } from './utils';
const getInstalledPackage = vi.fn();
vi.mock('./utils', () => ({
fetchInstalledPackageInfo: vi.fn(),
}));
const mockedFetchInstalledPackageInfo = vi.mocked(fetchInstalledPackageInfo);
const push = vi.fn();
const communityNodesStore: {
getInstalledPackage: (packageName: string) => Promise<PublicInstalledPackage>;
} = {
getInstalledPackage,
};
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -27,10 +25,6 @@ vi.mock('vue-router', async (importOriginal) => {
};
});
vi.mock('@/stores/communityNodes.store', () => ({
useCommunityNodesStore: vi.fn(() => communityNodesStore),
}));
describe('CommunityNodeInfo - links & bugs URL', () => {
beforeEach(() => {
setActivePinia(createTestingPinia());
@@ -70,25 +64,27 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
});
it('displays "Legacy" when updateAvailable', async () => {
getInstalledPackage.mockResolvedValue({
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '1.0.0',
updateAvailable: '1.0.1',
} as PublicInstalledPackage);
unverifiedUpdate: false,
} as ExtendedPublicInstalledPackage);
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
showManage: false,
},
});
await waitFor(() => expect(getInstalledPackage).toHaveBeenCalled());
await waitFor(() => expect(mockedFetchInstalledPackageInfo).toHaveBeenCalled());
expect(getByText('Package version 1.0.0 (Legacy)')).toBeInTheDocument();
});
it('displays "Latest" when not updateAvailable', async () => {
getInstalledPackage.mockResolvedValue({
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '1.0.0',
} as PublicInstalledPackage);
unverifiedUpdate: false,
} as ExtendedPublicInstalledPackage);
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
@@ -96,7 +92,24 @@ describe('CommunityNodeInfo - links & bugs URL', () => {
},
});
await waitFor(() => expect(getInstalledPackage).toHaveBeenCalled());
await waitFor(() => expect(mockedFetchInstalledPackageInfo).toHaveBeenCalled());
expect(getByText('Package version 1.0.0 (Latest)')).toBeInTheDocument();
});
it('displays "Latest" when only unverified update is available', async () => {
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '1.0.0',
unverifiedUpdate: true,
} as ExtendedPublicInstalledPackage);
const { getByText } = createComponentRenderer(CommunityNodeFooter)({
props: {
packageName: 'n8n-nodes-test',
showManage: false,
},
});
await waitFor(() => expect(mockedFetchInstalledPackageInfo).toHaveBeenCalled());
expect(getByText('Package version 1.0.0 (Latest)')).toBeInTheDocument();
});

View File

@@ -1,12 +1,11 @@
<script setup lang="ts">
import { VIEWS } from '@/constants';
import { captureException } from '@sentry/vue';
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { captureException } from '@sentry/vue';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { N8nText, N8nLink } from '@n8n/design-system';
import { N8nLink, N8nText } from '@n8n/design-system';
import { fetchInstalledPackageInfo, type ExtendedPublicInstalledPackage } from './utils';
export interface Props {
packageName: string;
@@ -17,7 +16,7 @@ const props = defineProps<Props>();
const router = useRouter();
const bugsUrl = ref<string>(`https://registry.npmjs.org/${props.packageName}`);
const installedPackage = ref<PublicInstalledPackage | undefined>(undefined);
const installedPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
async function openSettingsPage() {
await router.push({ name: VIEWS.COMMUNITY_NODES });
@@ -52,7 +51,7 @@ async function getBugsUrl(packageName: string) {
onMounted(async () => {
if (props.packageName) {
await getBugsUrl(props.packageName);
installedPackage.value = await useCommunityNodesStore().getInstalledPackage(props.packageName);
installedPackage.value = await fetchInstalledPackageInfo(props.packageName);
}
});
</script>
@@ -63,7 +62,9 @@ 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 ? 'Legacy' : 'Latest'
installedPackage.updateAvailable && !installedPackage.unverifiedUpdate
? 'Legacy'
: 'Latest'
}})
</N8nText>
<template v-if="props.showManage">

View File

@@ -1,18 +1,18 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type TestingPinia, createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import CommunityNodeInfo from './CommunityNodeInfo.vue';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { waitFor } from '@testing-library/vue';
import { setActivePinia } from 'pinia';
import type { CommunityNodeDetails } from '../composables/useViewStacks';
import CommunityNodeInfo from './CommunityNodeInfo.vue';
import { type ExtendedPublicInstalledPackage, fetchInstalledPackageInfo } from './utils';
vi.mock('./utils', () => ({
fetchInstalledPackageInfo: vi.fn(),
}));
const mockedFetchInstalledPackageInfo = vi.mocked(fetchInstalledPackageInfo);
const getCommunityNodeAttributes = vi.fn();
const getInstalledPackage = vi.fn();
const communityNodesStore: {
getInstalledPackage: (packageName: string) => Promise<PublicInstalledPackage>;
} = {
getInstalledPackage,
};
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: vi.fn(() => ({
@@ -26,10 +26,6 @@ vi.mock('@/stores/users.store', () => ({
})),
}));
vi.mock('@/stores/communityNodes.store', () => ({
useCommunityNodesStore: vi.fn(() => communityNodesStore),
}));
vi.mock('../composables/useViewStacks', () => ({
useViewStacks: vi.fn(),
}));
@@ -98,10 +94,11 @@ describe('CommunityNodeInfo', () => {
numberOfDownloads: 9999,
nodeVersions: [{ npmVersion: '1.0.0' }],
});
getInstalledPackage.mockResolvedValue({
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '1.0.0',
packageName: 'n8n-nodes-test',
} as PublicInstalledPackage);
unverifiedUpdate: false,
} as ExtendedPublicInstalledPackage);
const wrapper = renderComponent({ pinia });
@@ -133,11 +130,12 @@ describe('CommunityNodeInfo', () => {
numberOfDownloads: 9999,
nodeVersions: [{ npmVersion: '1.0.0' }, { npmVersion: '0.0.9' }],
});
getInstalledPackage.mockResolvedValue({
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '0.0.9',
packageName: 'n8n-nodes-test',
updateAvailable: '1.0.1',
} as PublicInstalledPackage);
unverifiedUpdate: false,
} as ExtendedPublicInstalledPackage);
const wrapper = renderComponent({ pinia });
@@ -154,6 +152,38 @@ describe('CommunityNodeInfo', () => {
);
});
it('should NOT display update notice for unverified update', async () => {
const { useViewStacks } = await import('../composables/useViewStacks');
vi.mocked(useViewStacks).mockReturnValue({
activeViewStack: {
...defaultViewStack,
communityNodeDetails: {
...defaultViewStack.communityNodeDetails,
installed: true,
} as CommunityNodeDetails,
},
} as ReturnType<typeof useViewStacks>);
getCommunityNodeAttributes.mockResolvedValue({
npmVersion: '1.0.0',
authorName: 'contributor',
numberOfDownloads: 9999,
nodeVersions: [{ npmVersion: '1.0.0' }, { npmVersion: '0.0.9' }],
});
mockedFetchInstalledPackageInfo.mockResolvedValue({
installedVersion: '0.0.9',
packageName: 'n8n-nodes-test',
updateAvailable: '1.0.1',
unverifiedUpdate: true,
} as ExtendedPublicInstalledPackage);
const wrapper = renderComponent({ pinia });
await waitFor(() => expect(wrapper.queryByTestId('number-of-downloads')).toBeInTheDocument());
expect(wrapper.queryByTestId('update-available')).not.toBeInTheDocument();
});
it('should render correctly with fetched info', async () => {
const packageData = {
maintainers: [{ name: 'testAuthor' }],

View File

@@ -4,11 +4,10 @@ import { useViewStacks } from '../composables/useViewStacks';
import { useUsersStore } from '@/stores/users.store';
import { i18n } from '@n8n/i18n';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { captureException } from '@sentry/vue';
import { N8nText, N8nTooltip, N8nIcon } from '@n8n/design-system';
import ShieldIcon from 'virtual:icons/fa-solid/shield-alt';
import type { PublicInstalledPackage } from 'n8n-workflow';
import { type ExtendedPublicInstalledPackage, fetchInstalledPackageInfo } from './utils';
const { activeViewStack } = useViewStacks();
@@ -22,9 +21,8 @@ const publisherName = ref<string | undefined>(undefined);
const downloads = ref<string | null>(null);
const verified = ref(false);
const official = ref(false);
const installedPackage = ref<PublicInstalledPackage | undefined>(undefined);
const installedPackage = ref<ExtendedPublicInstalledPackage | undefined>(undefined);
const communityNodesStore = useCommunityNodesStore();
const nodeTypesStore = useNodeTypesStore();
const isOwner = computed(() => useUsersStore().isInstanceOwner);
@@ -46,9 +44,7 @@ async function fetchPackageInfo(packageName: string) {
);
if (communityNodeDetails?.installed) {
installedPackage.value = await communityNodesStore.getInstalledPackage(
communityNodeDetails.packageName,
);
installedPackage.value = await fetchInstalledPackageInfo(communityNodeDetails.packageName);
}
if (communityNodeAttributes) {
@@ -116,7 +112,7 @@ onMounted(async () => {
{{ communityNodeDetails?.description }}
</N8nText>
<CommunityNodeUpdateInfo
v-if="isOwner && installedPackage?.updateAvailable"
v-if="isOwner && installedPackage?.updateAvailable && !installedPackage.unverifiedUpdate"
data-test-id="update-available"
/>
<div v-else :class="$style.separator"></div>

View File

@@ -0,0 +1,97 @@
import { describe, it, expect, vi } from 'vitest';
import { fetchInstalledPackageInfo } from './utils';
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { type NodeTypesStore, useNodeTypesStore } from '@/stores/nodeTypes.store';
import type { PublicInstalledPackage } from 'n8n-workflow';
import type { CommunityNodeType } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
vi.mock('@/stores/communityNodes.store', () => ({
useCommunityNodesStore: vi.fn(() => ({
getInstalledPackage: vi.fn(),
})),
}));
vi.mock('@/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 });
});
afterEach(() => {
vi.clearAllMocks();
});
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('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 });
});
});

View File

@@ -0,0 +1,25 @@
import { useCommunityNodesStore } from '@/stores/communityNodes.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { type PublicInstalledPackage } from 'n8n-workflow';
import semver from 'semver';
export type ExtendedPublicInstalledPackage = PublicInstalledPackage & {
unverifiedUpdate: boolean;
};
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() };
}