Skip to content

Commit

Permalink
fix(VSCode): Provide timeout and fallback for KaotoEditorFactory
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
lordrip committed Jul 26, 2024
1 parent a17228f commit b7ed2c3
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 3 deletions.
122 changes: 122 additions & 0 deletions packages/ui/src/multiplying-architecture/KaotoEditorFactory.test.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> {
throw new Error('Function not implemented.');
},
},
},
} as KogitoEditorEnvelopeContextType<KaotoEditorChannelApi>;
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<KaotoEditorChannelApi>;
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<KaotoEditorChannelApi>;
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<KaotoEditorChannelApi>;
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();
});
});
33 changes: 30 additions & 3 deletions packages/ui/src/multiplying-architecture/KaotoEditorFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -14,13 +14,40 @@ export class KaotoEditorFactory implements EditorFactory<Editor, KaotoEditorChan
envelopeContext: KogitoEditorEnvelopeContextType<KaotoEditorChannelApi>,
initArgs: EditorInitArgs,
): Promise<Editor> {
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<KaotoEditorChannelApi>,
): Promise<ISettingsModel> {
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.
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
54 changes: 54 additions & 0 deletions packages/ui/src/utils/promise-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
19 changes: 19 additions & 0 deletions packages/ui/src/utils/promise-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const promiseTimeout: <T>(promise: Promise<T>, timeout: number, defaultValue?: T) => Promise<T> = <T>(
promise: Promise<T>,
timeout: number,
defaultValue?: T,
) => {
let timer: number;

const timeoutPromise = new Promise<T>((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);
});
};

0 comments on commit b7ed2c3

Please sign in to comment.