From b7ed2c39d373ecec6b55ae243d3f686578d007e4 Mon Sep 17 00:00:00 2001 From: "Ricardo M." Date: Fri, 26 Jul 2024 14:00:27 -0400 Subject: [PATCH] fix(VSCode): Provide timeout and fallback for `KaotoEditorFactory` Currently, VSCode Kaoto build with the main branch is broken due to a missing API implementation that causes for a `promise` to hold forever, preventing the extension to load. The fix is to provide a `timeout` mechanism to unblock the promise, plus a `fallback` mechanism to get sensitive settings default to continue the extension bootstrap process. --- .../KaotoEditorFactory.test.ts | 122 ++++++++++++++++++ .../KaotoEditorFactory.ts | 33 ++++- packages/ui/src/utils/index.ts | 1 + packages/ui/src/utils/promise-timeout.test.ts | 54 ++++++++ packages/ui/src/utils/promise-timeout.ts | 19 +++ 5 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/multiplying-architecture/KaotoEditorFactory.test.ts create mode 100644 packages/ui/src/utils/promise-timeout.test.ts create mode 100644 packages/ui/src/utils/promise-timeout.ts diff --git a/packages/ui/src/multiplying-architecture/KaotoEditorFactory.test.ts b/packages/ui/src/multiplying-architecture/KaotoEditorFactory.test.ts new file mode 100644 index 000000000..185ef70a6 --- /dev/null +++ b/packages/ui/src/multiplying-architecture/KaotoEditorFactory.test.ts @@ -0,0 +1,122 @@ +import { EditorInitArgs, KogitoEditorEnvelopeContextType } from '@kie-tools-core/editor/dist/api'; +import { ISettingsModel, NodeLabelType } from '../models'; +import { KaotoEditorApp } from './KaotoEditorApp'; +import { KaotoEditorChannelApi } from './KaotoEditorChannelApi'; +import { KaotoEditorFactory } from './KaotoEditorFactory'; +jest.mock('./KaotoEditorApp'); + +describe('KaotoEditorFactory', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should create editor', async () => { + const settingsModel: ISettingsModel = { + catalogUrl: 'catalog-url', + nodeLabel: NodeLabelType.Id, + }; + + const envelopeContext = { + channelApi: { + requests: { + getVSCodeKaotoSettings: () => Promise.resolve(settingsModel), + getCatalogURL: function (): Promise { + throw new Error('Function not implemented.'); + }, + }, + }, + } as KogitoEditorEnvelopeContextType; + const initArgs = {} as EditorInitArgs; + const factory = new KaotoEditorFactory(); + + const editor = await factory.createEditor(envelopeContext, initArgs); + + expect(editor).toBeInstanceOf(KaotoEditorApp); + }); + + it('should get settings', async () => { + const settingsModel: ISettingsModel = { + catalogUrl: 'catalog-url', + nodeLabel: NodeLabelType.Id, + }; + + const getVSCodeKaotoSettingsSpy = jest.fn().mockResolvedValue(settingsModel); + const getCatalogURLSpy = jest.fn().mockRejectedValue(settingsModel); + + const envelopeContext = { + channelApi: { + requests: { + getVSCodeKaotoSettings: getVSCodeKaotoSettingsSpy, + getCatalogURL: getCatalogURLSpy, + }, + }, + } as unknown as KogitoEditorEnvelopeContextType; + const initArgs = {} as EditorInitArgs; + const factory = new KaotoEditorFactory(); + + const editor = await factory.createEditor(envelopeContext, initArgs); + + expect(getVSCodeKaotoSettingsSpy).toHaveBeenCalledTimes(1); + expect(getCatalogURLSpy).not.toHaveBeenCalled(); + expect(editor).toBeDefined(); + }); + + it('should fallback to previous API if getVSCodeKaotoSettings is not implemented', async () => { + const getVSCodeKaotoSettingsSpy = jest.fn().mockImplementation(() => new Promise(() => {})); + const getCatalogURLSpy = jest.fn().mockResolvedValue(''); + + const envelopeContext = { + channelApi: { + requests: { + getVSCodeKaotoSettings: getVSCodeKaotoSettingsSpy, + getCatalogURL: getCatalogURLSpy, + }, + }, + } as unknown as KogitoEditorEnvelopeContextType; + const initArgs = {} as EditorInitArgs; + const factory = new KaotoEditorFactory(); + + const editor = await factory.createEditor(envelopeContext, initArgs); + + expect(getVSCodeKaotoSettingsSpy).toHaveBeenCalledTimes(1); + expect(getCatalogURLSpy).toHaveBeenCalledTimes(1); + expect(editor).toBeDefined(); + }); + + it('should update catalog URL', async () => { + const settingsModel: ISettingsModel = { + catalogUrl: '', + nodeLabel: NodeLabelType.Id, + }; + + const getVSCodeKaotoSettingsSpy = jest.fn().mockResolvedValue(settingsModel); + const getCatalogURLSpy = jest.fn().mockRejectedValue(settingsModel); + + const envelopeContext = { + channelApi: { + requests: { + getVSCodeKaotoSettings: getVSCodeKaotoSettingsSpy, + getCatalogURL: getCatalogURLSpy, + }, + }, + } as unknown as KogitoEditorEnvelopeContextType; + const initArgs = { + resourcesPathPrefix: 'path-prefix', + } as EditorInitArgs; + const factory = new KaotoEditorFactory(); + + const editor = await factory.createEditor(envelopeContext, initArgs); + + expect(KaotoEditorApp).toHaveBeenCalledWith( + envelopeContext, + initArgs, + expect.objectContaining({ + settings: { + catalogUrl: 'path-prefix/camel-catalog/index.json', + nodeLabel: NodeLabelType.Id, + }, + }), + ); + expect(editor).toBeDefined(); + }); +}); diff --git a/packages/ui/src/multiplying-architecture/KaotoEditorFactory.ts b/packages/ui/src/multiplying-architecture/KaotoEditorFactory.ts index 5b10328c1..f1c9df14c 100644 --- a/packages/ui/src/multiplying-architecture/KaotoEditorFactory.ts +++ b/packages/ui/src/multiplying-architecture/KaotoEditorFactory.ts @@ -4,8 +4,8 @@ import { EditorInitArgs, KogitoEditorEnvelopeContextType, } from '@kie-tools-core/editor/dist/api'; -import { DefaultSettingsAdapter } from '../models'; -import { CatalogSchemaLoader, isDefined } from '../utils'; +import { DefaultSettingsAdapter, ISettingsModel, SettingsModel } from '../models'; +import { CatalogSchemaLoader, isDefined, promiseTimeout } from '../utils'; import { KaotoEditorApp } from './KaotoEditorApp'; import { KaotoEditorChannelApi } from './KaotoEditorChannelApi'; @@ -14,13 +14,40 @@ export class KaotoEditorFactory implements EditorFactory, initArgs: EditorInitArgs, ): Promise { - const settings = await envelopeContext.channelApi.requests.getVSCodeKaotoSettings(); + const settings = await this.getSettings(envelopeContext); + const settingsAdapter = new DefaultSettingsAdapter(settings); this.updateCatalogUrl(settingsAdapter, initArgs); return Promise.resolve(new KaotoEditorApp(envelopeContext, initArgs, settingsAdapter)); } + /** + * Get the settings from the envelope context + */ + private async getSettings( + envelopeContext: KogitoEditorEnvelopeContextType, + ): Promise { + let settings: ISettingsModel; + + try { + /** + * For non-implemented API methods, the promises won't resolve, for that reason + * we use a timeout to reject the promise and fallback to the previous API + */ + settings = await promiseTimeout(envelopeContext.channelApi.requests.getVSCodeKaotoSettings(), 500); + } catch (error) { + /** + * Reaching this point means that the new API is not available yet, + * so we fallback to the previous API + */ + const catalogUrl = await envelopeContext.channelApi.requests.getCatalogURL(); + settings = new SettingsModel({ catalogUrl }); + } + + return settings; + } + /** * Updates the catalog URL in the settings if it is not defined, to include the embedded catalog. * It uses the resourcesPathPrefix from the initArgs to build the default catalog URL. diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 44fb2af7f..ae8ba10ad 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -12,6 +12,7 @@ export * from './set-value'; export * from './get-custom-schema-from-kamelet'; export * from './update-kamelet-from-custom-schema'; export * from './pipe-custom-schema'; +export * from './promise-timeout'; export * from './get-field-groups'; export * from './get-serialized-model'; export * from './get-user-updated-properties-schema'; diff --git a/packages/ui/src/utils/promise-timeout.test.ts b/packages/ui/src/utils/promise-timeout.test.ts new file mode 100644 index 000000000..fa8913744 --- /dev/null +++ b/packages/ui/src/utils/promise-timeout.test.ts @@ -0,0 +1,54 @@ +import { promiseTimeout } from './promise-timeout'; + +describe('promiseTimeout', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('should resolve the promise if it resolves before the timeout', async () => { + const promise = Promise.resolve('foo'); + const result = await promiseTimeout(promise, 1_000); + + expect(result).toBe('foo'); + }); + + it('should reject the promise if it rejects before the timeout', async () => { + const promise = Promise.reject(new Error('bar')); + + await expect(promiseTimeout(promise, 1_000)).rejects.toThrow('bar'); + }); + + it('should resolve the promise with the defaultValue when provided, if it takes longer than the timeout', async () => { + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve('baz'); + }, 1_000); + }); + + const promiseTimeoutResult = promiseTimeout(promise, 500, 'Lighting fast'); + + jest.advanceTimersByTime(500); + + const result = await promiseTimeoutResult; + + expect(result).toBe('Lighting fast'); + }); + + it('should reject the promise if it takes longer than the timeout', async () => { + const promise = new Promise((resolve) => { + setTimeout(() => { + resolve('baz'); + }, 1_000); + }); + + const promiseTimeoutResult = promiseTimeout(promise, 500); + + jest.advanceTimersByTime(500); + + await expect(promiseTimeoutResult).rejects.toThrow('Promise timed out'); + }); +}); diff --git a/packages/ui/src/utils/promise-timeout.ts b/packages/ui/src/utils/promise-timeout.ts new file mode 100644 index 000000000..4268cc94d --- /dev/null +++ b/packages/ui/src/utils/promise-timeout.ts @@ -0,0 +1,19 @@ +export const promiseTimeout: (promise: Promise, timeout: number, defaultValue?: T) => Promise = ( + promise: Promise, + timeout: number, + defaultValue?: T, +) => { + let timer: number; + + const timeoutPromise = new Promise((resolve, reject) => { + timer = setTimeout(() => { + if (defaultValue !== undefined) resolve(defaultValue); + + reject(new Error('Promise timed out')); + }, timeout) as unknown as number; + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + clearTimeout(timer); + }); +};