@hubspot/ui-extensions-dev-server 1.1.6 → 1.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/DevServerState.d.ts +1 -1
- package/dist/lib/ExtensionsWebSocket.js +26 -2
- package/dist/lib/__tests__/ExtensionsWebSocket.spec.js +42 -8
- package/dist/lib/__tests__/app-functions/context.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/context.spec.js +101 -0
- package/dist/lib/__tests__/app-functions/errorReporter.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/errorReporter.spec.js +102 -0
- package/dist/lib/__tests__/app-functions/executor_v20231.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/executor_v20231.spec.js +168 -0
- package/dist/lib/__tests__/app-functions/executor_v20232.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/executor_v20232.spec.js +190 -0
- package/dist/lib/__tests__/app-functions/fixtures/constants.d.ts +18 -0
- package/dist/lib/__tests__/app-functions/fixtures/constants.js +139 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-fails.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-fails.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-succeeds.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-async-succeeds.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-rejected.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-rejected.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-resolved.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-callback-on-promise-resolved.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-does-not-export-main.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-does-not-export-main.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-echos-input.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-echos-input.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-logs.cjs +10 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-logs.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-function.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-function.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-rejected.cjs +7 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-rejected.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-resolved.cjs +7 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-promise-resolved.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-text.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-text.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-undefined.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-returns-undefined.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-throws-error.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-throws-error.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-times-out.cjs +10 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-times-out.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-undeclared.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-undeclared.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-uses-secret.cjs +14 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.1/app.functions/func-uses-secret.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-fails.cjs +5 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-fails.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-succeeds.cjs +5 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-async-succeeds.d.cts +3 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-calls-callback.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-calls-callback.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-does-not-export-main.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-does-not-export-main.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-echos-input.cjs +8 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-echos-input.d.cts +5 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-logs.cjs +10 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-logs.d.cts +3 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-function.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-function.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-implicitly.cjs +7 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-implicitly.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-rejected.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-rejected.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-resolved.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-promise-resolved.d.cts +3 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-text.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-text.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-undefined.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-returns-undefined.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-throws-error.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-throws-error.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-times-out.cjs +12 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-times-out.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-undeclared.cjs +4 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-undeclared.d.cts +1 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-uses-secret.cjs +14 -0
- package/dist/lib/__tests__/app-functions/fixtures/v2023.2/app.functions/func-uses-secret.d.cts +4 -0
- package/dist/lib/__tests__/app-functions/secrets.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/secrets.spec.js +278 -0
- package/dist/lib/__tests__/app-functions/services/AppProxyService.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/services/AppProxyService.spec.js +667 -0
- package/dist/lib/__tests__/app-functions/services/PrivateAppUserTokenManager.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/services/PrivateAppUserTokenManager.spec.js +243 -0
- package/dist/lib/__tests__/app-functions/services/services_v20231.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/services/services_v20231.spec.js +319 -0
- package/dist/lib/__tests__/app-functions/services/services_v20232.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/services/services_v20232.spec.js +302 -0
- package/dist/lib/__tests__/app-functions/setup.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/setup.js +7 -0
- package/dist/lib/__tests__/app-functions/signing.spec.d.ts +1 -0
- package/dist/lib/__tests__/app-functions/signing.spec.js +460 -0
- package/dist/lib/__tests__/server.spec.js +24 -2
- package/dist/lib/app-functions/api/privateAppUserToken.d.ts +16 -0
- package/dist/lib/app-functions/api/privateAppUserToken.js +28 -0
- package/dist/lib/app-functions/config.d.ts +4 -0
- package/dist/lib/app-functions/config.js +48 -0
- package/dist/lib/app-functions/constants.d.ts +26 -0
- package/dist/lib/app-functions/constants.js +63 -0
- package/dist/lib/app-functions/context.d.ts +3 -0
- package/dist/lib/app-functions/context.js +65 -0
- package/dist/lib/app-functions/errorReporter.d.ts +22 -0
- package/dist/lib/app-functions/errorReporter.js +42 -0
- package/dist/lib/app-functions/errors.d.ts +44 -0
- package/dist/lib/app-functions/errors.js +82 -0
- package/dist/lib/app-functions/executor.d.ts +3 -0
- package/dist/lib/app-functions/executor.js +131 -0
- package/dist/lib/app-functions/index.d.ts +4 -0
- package/dist/lib/app-functions/index.js +4 -0
- package/dist/lib/app-functions/secrets.d.ts +5 -0
- package/dist/lib/app-functions/secrets.js +56 -0
- package/dist/lib/app-functions/services/AppFunctionExecutionService.d.ts +2 -0
- package/dist/lib/app-functions/services/AppFunctionExecutionService.js +55 -0
- package/dist/lib/app-functions/services/AppProxyService.d.ts +5 -0
- package/dist/lib/app-functions/services/AppProxyService.js +196 -0
- package/dist/lib/app-functions/services/PrivateAppUserTokenManager.d.ts +22 -0
- package/dist/lib/app-functions/services/PrivateAppUserTokenManager.js +185 -0
- package/dist/lib/app-functions/services/constants.d.ts +4 -0
- package/dist/lib/app-functions/services/constants.js +4 -0
- package/dist/lib/app-functions/services/index.d.ts +3 -0
- package/dist/lib/app-functions/services/index.js +3 -0
- package/dist/lib/app-functions/services/messages.d.ts +14 -0
- package/dist/lib/app-functions/services/messages.js +36 -0
- package/dist/lib/app-functions/signing.d.ts +29 -0
- package/dist/lib/app-functions/signing.js +51 -0
- package/dist/lib/app-functions/types.d.ts +172 -0
- package/dist/lib/app-functions/types.js +6 -0
- package/dist/lib/app-functions/utils.d.ts +15 -0
- package/dist/lib/app-functions/utils.js +28 -0
- package/dist/lib/server.js +15 -4
- package/dist/lib/types.d.ts +1 -1
- package/package.json +11 -7
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AppConfig, ExtensionOutputConfig, ExtensionMetadata, Logger, PlatformVersion } from './types.ts';
|
|
2
|
-
import { ServiceConfiguration, ProxyServiceConfig } from '
|
|
2
|
+
import { ServiceConfiguration, ProxyServiceConfig } from './app-functions/types.ts';
|
|
3
3
|
import { ExtensionsWebSocket } from './ExtensionsWebSocket.ts';
|
|
4
4
|
type DevServerStateLocalDevUrlMapping = ProxyServiceConfig['localDevUrlMapping'] | undefined;
|
|
5
5
|
interface DevServerStateArgs {
|
|
@@ -94,10 +94,34 @@ export class ExtensionsWebSocket {
|
|
|
94
94
|
if (this.keepAliveIntervalId) {
|
|
95
95
|
clearInterval(this.keepAliveIntervalId);
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* To gracefully close client connections, we send a close frame and wait for the 'close' event.
|
|
99
|
+
* If the client doesn't respond within a reasonable timeout, we forcefully terminate it.
|
|
100
|
+
* This ensures we don't leave hanging connections and that resources are cleaned up properly.
|
|
101
|
+
*/
|
|
102
|
+
const GRACEFUL_CLOSE_TIMEOUT_MS = 250;
|
|
103
|
+
const closePromises = [];
|
|
97
104
|
this.wss.clients.forEach((client) => {
|
|
98
|
-
this.logger.debug('
|
|
99
|
-
|
|
105
|
+
this.logger.debug('Closing WebSocket client connection');
|
|
106
|
+
closePromises.push(new Promise((resolve) => {
|
|
107
|
+
if (client.readyState !== WebSocket.OPEN) {
|
|
108
|
+
client.terminate();
|
|
109
|
+
resolve();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const timeout = setTimeout(() => {
|
|
113
|
+
client.terminate();
|
|
114
|
+
resolve();
|
|
115
|
+
}, GRACEFUL_CLOSE_TIMEOUT_MS);
|
|
116
|
+
client.once('close', () => {
|
|
117
|
+
clearTimeout(timeout);
|
|
118
|
+
resolve();
|
|
119
|
+
});
|
|
120
|
+
// 1000 = Normal Closure status code
|
|
121
|
+
client.close(1000, 'Server shutting down');
|
|
122
|
+
}));
|
|
100
123
|
});
|
|
124
|
+
await Promise.allSettled(closePromises);
|
|
101
125
|
return new Promise((resolve, reject) => {
|
|
102
126
|
this.wss.close((err) => {
|
|
103
127
|
if (err)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach, } from 'vitest';
|
|
2
2
|
import { ExtensionsWebSocket, isAllowedOrigin, } from "../ExtensionsWebSocket.js";
|
|
3
3
|
import { DevServerState } from "../DevServerState.js";
|
|
4
4
|
import { createMockLogger, createDevServerConfig } from "./factories.js";
|
|
@@ -7,7 +7,7 @@ let mockClients;
|
|
|
7
7
|
let mockWss;
|
|
8
8
|
let upgradeHandler = null;
|
|
9
9
|
vi.mock('ws', () => ({
|
|
10
|
-
WebSocketServer: vi.fn(()
|
|
10
|
+
WebSocketServer: vi.fn(function WebSocketServer() {
|
|
11
11
|
mockClients = new Set();
|
|
12
12
|
const listeners = {};
|
|
13
13
|
let isClosed = false;
|
|
@@ -42,6 +42,14 @@ vi.mock('ws', () => ({
|
|
|
42
42
|
}));
|
|
43
43
|
function createMockWebSocket() {
|
|
44
44
|
const listeners = {};
|
|
45
|
+
const onceListeners = {};
|
|
46
|
+
function fireEvent(event, ...args) {
|
|
47
|
+
listeners[event]?.forEach((handler) => handler(...args));
|
|
48
|
+
if (onceListeners[event]) {
|
|
49
|
+
onceListeners[event].forEach((handler) => handler(...args));
|
|
50
|
+
onceListeners[event] = [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
45
53
|
const mockWs = {
|
|
46
54
|
readyState: 1,
|
|
47
55
|
on: vi.fn((event, handler) => {
|
|
@@ -49,15 +57,19 @@ function createMockWebSocket() {
|
|
|
49
57
|
listeners[event] = [];
|
|
50
58
|
listeners[event].push(handler);
|
|
51
59
|
}),
|
|
52
|
-
once: vi.fn()
|
|
60
|
+
once: vi.fn((event, handler) => {
|
|
61
|
+
if (!onceListeners[event])
|
|
62
|
+
onceListeners[event] = [];
|
|
63
|
+
onceListeners[event].push(handler);
|
|
64
|
+
}),
|
|
53
65
|
emit: vi.fn((event, ...args) => {
|
|
54
|
-
|
|
66
|
+
fireEvent(event, ...args);
|
|
55
67
|
}),
|
|
56
68
|
send: vi.fn(),
|
|
57
69
|
close: vi.fn(() => {
|
|
58
70
|
mockWs.readyState = 3;
|
|
59
71
|
mockClients.delete(mockWs);
|
|
60
|
-
|
|
72
|
+
fireEvent('close');
|
|
61
73
|
}),
|
|
62
74
|
terminate: vi.fn(() => {
|
|
63
75
|
mockWs.readyState = 3;
|
|
@@ -250,16 +262,24 @@ describe('ExtensionsWebSocket', () => {
|
|
|
250
262
|
});
|
|
251
263
|
});
|
|
252
264
|
describe('close', () => {
|
|
253
|
-
it('should
|
|
265
|
+
it('should gracefully close all open clients', async () => {
|
|
254
266
|
extensionsWebSocket = new ExtensionsWebSocket(mockHttpServer, devServerState);
|
|
255
267
|
const { client: client1 } = simulateClientConnection();
|
|
256
268
|
const { client: client2 } = simulateClientConnection();
|
|
257
269
|
expect(extensionsWebSocket.clientCount).toBe(2);
|
|
258
270
|
await extensionsWebSocket.close();
|
|
259
|
-
expect(client1.
|
|
260
|
-
expect(client2.
|
|
271
|
+
expect(client1.close).toHaveBeenCalledWith(1000, 'Server shutting down');
|
|
272
|
+
expect(client2.close).toHaveBeenCalledWith(1000, 'Server shutting down');
|
|
261
273
|
expect(extensionsWebSocket.clientCount).toBe(0);
|
|
262
274
|
});
|
|
275
|
+
it('should terminate clients that are not in OPEN state', async () => {
|
|
276
|
+
extensionsWebSocket = new ExtensionsWebSocket(mockHttpServer, devServerState);
|
|
277
|
+
const { client } = simulateClientConnection();
|
|
278
|
+
client.readyState = 2; // CLOSING
|
|
279
|
+
await extensionsWebSocket.close();
|
|
280
|
+
expect(client.terminate).toHaveBeenCalled();
|
|
281
|
+
expect(client.close).not.toHaveBeenCalled();
|
|
282
|
+
});
|
|
263
283
|
it('should close the WebSocket server after clients disconnect', async () => {
|
|
264
284
|
extensionsWebSocket = new ExtensionsWebSocket(mockHttpServer, devServerState);
|
|
265
285
|
const { client } = simulateClientConnection();
|
|
@@ -290,6 +310,20 @@ describe('ExtensionsWebSocket', () => {
|
|
|
290
310
|
extensionsWebSocket.broadcast({ event: 'test' });
|
|
291
311
|
expect(devServerState.logger.debug).toHaveBeenCalledWith('No clients connected, message not sent');
|
|
292
312
|
});
|
|
313
|
+
it('should forcibly terminate clients that do not close within timeout', async () => {
|
|
314
|
+
vi.useFakeTimers();
|
|
315
|
+
extensionsWebSocket = new ExtensionsWebSocket(mockHttpServer, devServerState);
|
|
316
|
+
const { client } = simulateClientConnection();
|
|
317
|
+
// Override close to NOT trigger the 'close' event (simulates unresponsive client)
|
|
318
|
+
client.close = vi.fn();
|
|
319
|
+
client.readyState = 1;
|
|
320
|
+
const closePromise = extensionsWebSocket.close();
|
|
321
|
+
// Advance past the graceful close timeout
|
|
322
|
+
vi.advanceTimersByTime(250);
|
|
323
|
+
await closePromise;
|
|
324
|
+
expect(client.close).toHaveBeenCalledWith(1000, 'Server shutting down');
|
|
325
|
+
expect(client.terminate).toHaveBeenCalled();
|
|
326
|
+
});
|
|
293
327
|
});
|
|
294
328
|
describe('error handlers', () => {
|
|
295
329
|
it('should log WebSocket server errors', () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { Client } from '@hubspot/api-client';
|
|
3
|
+
import { fetchObjectContext } from "../../app-functions/context.js";
|
|
4
|
+
import { SAMPLE_BAD_REQUEST_API_ERROR, SAMPLE_NOT_FOUND_API_ERROR, SAMPLE_UNAUTHORIZED_API_ERROR, TEST_CONFIG_V20231 as TEST_CONFIG, } from "./fixtures/constants.js";
|
|
5
|
+
/**
|
|
6
|
+
* Validate construction of the context (first argument in an app function)
|
|
7
|
+
*/
|
|
8
|
+
describe('context', () => {
|
|
9
|
+
const customObject = {
|
|
10
|
+
properties: {
|
|
11
|
+
name: 'The most custom of objects',
|
|
12
|
+
shouldBeFiltered: 'this should be filtered out, we do not want it',
|
|
13
|
+
mayOrMayNotBeFiltered: 'we might care about this',
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
const mockObjectApi = (getById) => {
|
|
17
|
+
vi.spyOn(Client.prototype, 'crm', 'get').mockReturnValue({
|
|
18
|
+
objects: {
|
|
19
|
+
basicApi: {
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
21
|
+
// @ts-ignore
|
|
22
|
+
getById,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
});
|
|
30
|
+
describe('fetchObjectContext', () => {
|
|
31
|
+
it('should fetch objects using the correct endpoint', async () => {
|
|
32
|
+
mockObjectApi(() => Promise.resolve(customObject));
|
|
33
|
+
const objectQuery = {
|
|
34
|
+
objectId: 51,
|
|
35
|
+
objectTypeId: '0-2',
|
|
36
|
+
objectPropertyNames: ['name', 'mayOrMayNotBeFiltered'],
|
|
37
|
+
};
|
|
38
|
+
const result = await fetchObjectContext(TEST_CONFIG, objectQuery, 'pat-123');
|
|
39
|
+
const { name, mayOrMayNotBeFiltered } = customObject.properties;
|
|
40
|
+
expect(result.propertiesToSend).toStrictEqual({
|
|
41
|
+
name,
|
|
42
|
+
mayOrMayNotBeFiltered,
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
it('should throw a missing-token exception when access token is missing', async () => {
|
|
46
|
+
const objectQuery = {
|
|
47
|
+
objectId: 51,
|
|
48
|
+
objectTypeId: '0-2',
|
|
49
|
+
objectPropertyNames: ['name', 'mayOrMayNotBeFiltered'],
|
|
50
|
+
};
|
|
51
|
+
await expect(fetchObjectContext(TEST_CONFIG, objectQuery, undefined)).rejects.toThrow("Cannot fetch CRM object properties without a HubSpot API access token. This is not a problem with your code. Please copy the access token for your app at https://app.hubspot.com/private-apps/12345 and save it in an '.env' file in the 'app.functions' folder. For more details, please see https://developers.hubspot.com/docs/guides/crm/private-apps/serverless-functions.");
|
|
52
|
+
});
|
|
53
|
+
it('should throw a wrong-token exception when access token is invalid (401)', async () => {
|
|
54
|
+
mockObjectApi(() => {
|
|
55
|
+
throw SAMPLE_UNAUTHORIZED_API_ERROR;
|
|
56
|
+
});
|
|
57
|
+
const objectQuery = {
|
|
58
|
+
objectId: 51,
|
|
59
|
+
objectTypeId: '0-2',
|
|
60
|
+
objectPropertyNames: ['name', 'mayOrMayNotBeFiltered'],
|
|
61
|
+
};
|
|
62
|
+
await expect(fetchObjectContext(TEST_CONFIG, objectQuery, 'pat-123')).rejects.toThrow("Failed to fetch CRM object properties with objectTypeId~0-2 objectId~51 accountId~12345. Please check that the PRIVATE_APP_ACCESS_TOKEN specified in your '.env' file matches that of your app at https://app.hubspot.com/private-apps/12345.");
|
|
63
|
+
});
|
|
64
|
+
it('should throw a wrong-token exception when the object type id is invalid (400)', async () => {
|
|
65
|
+
mockObjectApi(() => {
|
|
66
|
+
throw SAMPLE_BAD_REQUEST_API_ERROR;
|
|
67
|
+
});
|
|
68
|
+
const objectQuery = {
|
|
69
|
+
objectId: 1999,
|
|
70
|
+
objectTypeId: '0-3-bad',
|
|
71
|
+
objectPropertyNames: ['name'],
|
|
72
|
+
};
|
|
73
|
+
await expect(fetchObjectContext(TEST_CONFIG, objectQuery, 'pat-123')).rejects.toThrow("Failed to fetch CRM object properties with objectTypeId~0-3-bad objectId~1999 accountId~12345. Please check that the PRIVATE_APP_ACCESS_TOKEN specified in your '.env' file matches that of your app at https://app.hubspot.com/private-apps/12345.");
|
|
74
|
+
});
|
|
75
|
+
it('should throw a wrong-token exception when the object id does not exist (404)', async () => {
|
|
76
|
+
mockObjectApi(() => {
|
|
77
|
+
throw SAMPLE_NOT_FOUND_API_ERROR;
|
|
78
|
+
});
|
|
79
|
+
const objectQuery = {
|
|
80
|
+
objectId: 1999,
|
|
81
|
+
objectTypeId: '0-1',
|
|
82
|
+
objectPropertyNames: ['name'],
|
|
83
|
+
};
|
|
84
|
+
await expect(fetchObjectContext(TEST_CONFIG, objectQuery, 'pat-123')).rejects.toThrow("Failed to fetch CRM object properties with objectTypeId~0-1 objectId~1999 accountId~12345. Please check that the PRIVATE_APP_ACCESS_TOKEN specified in your '.env' file matches that of your app at https://app.hubspot.com/private-apps/12345.");
|
|
85
|
+
});
|
|
86
|
+
it('should throw a generic exception for other errors', async () => {
|
|
87
|
+
mockObjectApi(() => {
|
|
88
|
+
throw new Error('Something else when wrong');
|
|
89
|
+
});
|
|
90
|
+
const objectQuery = {
|
|
91
|
+
objectId: 1999,
|
|
92
|
+
objectTypeId: '0-1',
|
|
93
|
+
objectPropertyNames: ['name'],
|
|
94
|
+
};
|
|
95
|
+
await expect(fetchObjectContext(TEST_CONFIG, objectQuery, 'pat-123')).rejects.toThrow(expect.objectContaining({
|
|
96
|
+
name: 'Error',
|
|
97
|
+
message: expect.stringContaining('Failed to fetch CRM object properties with objectTypeId~0-1 objectId~1999 accountId~12345:'),
|
|
98
|
+
}));
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { vi, describe, beforeEach, afterEach, it, expect, } from 'vitest';
|
|
2
|
+
const mockInit = vi.fn();
|
|
3
|
+
const mockCaptureException = vi.fn();
|
|
4
|
+
const mockWithScope = vi.fn((callback) => callback({ setTag: vi.fn(), setExtra: vi.fn() }));
|
|
5
|
+
const mockFlush = vi.fn(() => Promise.resolve());
|
|
6
|
+
vi.mock('@sentry/node', () => ({
|
|
7
|
+
init: mockInit,
|
|
8
|
+
captureException: mockCaptureException,
|
|
9
|
+
withScope: mockWithScope,
|
|
10
|
+
flush: mockFlush,
|
|
11
|
+
}));
|
|
12
|
+
describe('errorReporter', () => {
|
|
13
|
+
let reportError;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
vi.clearAllMocks();
|
|
16
|
+
vi.resetModules();
|
|
17
|
+
});
|
|
18
|
+
describe('module initialization', () => {
|
|
19
|
+
it('initializes Sentry with correct DSN on import', async () => {
|
|
20
|
+
await import('../../app-functions/errorReporter.js');
|
|
21
|
+
expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({
|
|
22
|
+
dsn: expect.stringContaining('exceptions.hubspot.com/v2/1'),
|
|
23
|
+
}));
|
|
24
|
+
});
|
|
25
|
+
it('includes version information in initialization', async () => {
|
|
26
|
+
await import('../../app-functions/errorReporter.js');
|
|
27
|
+
expect(mockInit).toHaveBeenCalledWith(expect.objectContaining({
|
|
28
|
+
release: expect.stringMatching(/@hubspot\/app-functions-dev-server@\d+\.\d+\.\d+/),
|
|
29
|
+
initialScope: expect.objectContaining({
|
|
30
|
+
tags: expect.objectContaining({
|
|
31
|
+
devServerVersion: expect.any(String),
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
describe('reportError', () => {
|
|
38
|
+
beforeEach(async () => {
|
|
39
|
+
const errorReporter = await import('../../app-functions/errorReporter.js');
|
|
40
|
+
reportError = errorReporter.reportError;
|
|
41
|
+
});
|
|
42
|
+
it('reports error with tags and extras', () => {
|
|
43
|
+
const testError = new Error('test error');
|
|
44
|
+
const context = {
|
|
45
|
+
errorType: 'internal_server_error',
|
|
46
|
+
functionName: 'testFunction',
|
|
47
|
+
appId: 123,
|
|
48
|
+
accountId: 456,
|
|
49
|
+
};
|
|
50
|
+
const mockSetTag = vi.fn();
|
|
51
|
+
const mockSetExtra = vi.fn();
|
|
52
|
+
mockWithScope.mockImplementationOnce((callback) => callback({ setTag: mockSetTag, setExtra: mockSetExtra }));
|
|
53
|
+
reportError(testError, context);
|
|
54
|
+
expect(mockSetTag).toHaveBeenCalledWith('errorType', 'internal_server_error');
|
|
55
|
+
expect(mockSetTag).toHaveBeenCalledWith('functionName', 'testFunction');
|
|
56
|
+
expect(mockSetTag).toHaveBeenCalledWith('appId', '123');
|
|
57
|
+
expect(mockSetTag).toHaveBeenCalledWith('accountId', '456');
|
|
58
|
+
expect(mockSetExtra).toHaveBeenCalledWith('errorContext', context);
|
|
59
|
+
expect(mockCaptureException).toHaveBeenCalledWith(testError);
|
|
60
|
+
});
|
|
61
|
+
it('reports error without context', () => {
|
|
62
|
+
const testError = new Error('test error');
|
|
63
|
+
reportError(testError);
|
|
64
|
+
expect(mockCaptureException).toHaveBeenCalledWith(testError);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
describe('process error handlers', () => {
|
|
68
|
+
let mockExit;
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
mockExit = vi
|
|
71
|
+
.spyOn(process, 'exit')
|
|
72
|
+
.mockImplementation(() => undefined);
|
|
73
|
+
await import('../../app-functions/errorReporter.js');
|
|
74
|
+
});
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
mockExit.mockRestore();
|
|
77
|
+
});
|
|
78
|
+
it('captures uncaught exceptions and exits', async () => {
|
|
79
|
+
const error = new Error('uncaught error');
|
|
80
|
+
process.emit('uncaughtException', error);
|
|
81
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
82
|
+
expect(mockCaptureException).toHaveBeenCalledWith(error);
|
|
83
|
+
expect(mockFlush).toHaveBeenCalledWith(2000);
|
|
84
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
85
|
+
});
|
|
86
|
+
it('captures unhandled rejections and exits', async () => {
|
|
87
|
+
const reason = new Error('unhandled rejection');
|
|
88
|
+
process.emit('unhandledRejection', reason);
|
|
89
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
90
|
+
expect(mockCaptureException).toHaveBeenCalledWith(reason);
|
|
91
|
+
expect(mockFlush).toHaveBeenCalledWith(2000);
|
|
92
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
93
|
+
});
|
|
94
|
+
it('exits process even if flush fails', async () => {
|
|
95
|
+
mockFlush.mockImplementationOnce(() => Promise.reject(new Error('flush failed')));
|
|
96
|
+
const error = new Error('uncaught error');
|
|
97
|
+
process.emit('uncaughtException', error);
|
|
98
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
99
|
+
expect(mockExit).toHaveBeenCalledWith(1);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
|
|
2
|
+
import { Client } from '@hubspot/api-client';
|
|
3
|
+
import { Reason, ExecutionError } from "../../app-functions/errors.js";
|
|
4
|
+
import { executeFunction } from "../../app-functions/executor.js";
|
|
5
|
+
import { NOOP_CALLBACK, TEST_CONFIG_V20231 as TEST_CONFIG, } from "./fixtures/constants.js";
|
|
6
|
+
/**
|
|
7
|
+
* Validate function execution using sample functions defined in
|
|
8
|
+
* the app path of TEST_CONFIG, which is fixtures/app.functions/
|
|
9
|
+
*/
|
|
10
|
+
describe('app function executor', () => {
|
|
11
|
+
let actual;
|
|
12
|
+
const initialEnvJson = JSON.stringify(process.env);
|
|
13
|
+
const setActual = (value) => {
|
|
14
|
+
actual = value;
|
|
15
|
+
};
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
setActual(undefined);
|
|
18
|
+
// mock `apiClient.crm.objects.basicApi.getById` to return propertiesToSend for the CRM object
|
|
19
|
+
vi.spyOn(Client.prototype, 'crm', 'get').mockReturnValue({
|
|
20
|
+
objects: {
|
|
21
|
+
basicApi: {
|
|
22
|
+
getById: () =>
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
24
|
+
// @ts-ignore
|
|
25
|
+
Promise.resolve({
|
|
26
|
+
properties: { firstname: 'First', lastname: 'Last' },
|
|
27
|
+
}),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.resetAllMocks();
|
|
34
|
+
// restore process.env
|
|
35
|
+
process.env = JSON.parse(initialEnvJson);
|
|
36
|
+
});
|
|
37
|
+
it('executes a function', async () => {
|
|
38
|
+
await executeFunction(TEST_CONFIG, 'returns-text', {}, setActual);
|
|
39
|
+
expect(actual).toEqual('result');
|
|
40
|
+
});
|
|
41
|
+
describe('handles inputs', () => {
|
|
42
|
+
it('none', async () => {
|
|
43
|
+
await executeFunction(TEST_CONFIG, 'echos-input', {}, setActual);
|
|
44
|
+
expect(actual).toEqual({
|
|
45
|
+
propertiesToSend: {},
|
|
46
|
+
parameters: undefined,
|
|
47
|
+
payload: undefined,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
it('object query', async () => {
|
|
51
|
+
const inputs = {
|
|
52
|
+
objectQuery: {
|
|
53
|
+
objectId: 51,
|
|
54
|
+
objectTypeId: '0-1',
|
|
55
|
+
objectPropertyNames: ['firstname', 'lastname'],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
await executeFunction(TEST_CONFIG, 'echos-input', inputs, setActual);
|
|
59
|
+
expect(actual).toEqual({
|
|
60
|
+
propertiesToSend: { firstname: 'First', lastname: 'Last' },
|
|
61
|
+
parameters: undefined,
|
|
62
|
+
payload: undefined,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
it('parameters', async () => {
|
|
66
|
+
const inputs = { parameters: { firstname: 'Wilbert' } };
|
|
67
|
+
await executeFunction(TEST_CONFIG, 'echos-input', inputs, setActual);
|
|
68
|
+
expect(actual).toEqual({
|
|
69
|
+
propertiesToSend: {},
|
|
70
|
+
parameters: { firstname: 'Wilbert' },
|
|
71
|
+
payload: undefined,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
it('object query and parameters', async () => {
|
|
75
|
+
const inputs = {
|
|
76
|
+
objectQuery: {
|
|
77
|
+
objectId: 51,
|
|
78
|
+
objectTypeId: '0-1',
|
|
79
|
+
objectPropertyNames: ['lastname'],
|
|
80
|
+
},
|
|
81
|
+
parameters: 'Salutations!',
|
|
82
|
+
};
|
|
83
|
+
await executeFunction(TEST_CONFIG, 'echos-input', inputs, setActual);
|
|
84
|
+
expect(actual).toEqual({
|
|
85
|
+
propertiesToSend: { lastname: 'Last' },
|
|
86
|
+
parameters: 'Salutations!',
|
|
87
|
+
payload: undefined,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe('handles secrets', () => {
|
|
92
|
+
it('supplies secrets including the private app access token', async () => {
|
|
93
|
+
await executeFunction(TEST_CONFIG, 'uses-secret', {}, setActual);
|
|
94
|
+
expect(actual).toEqual({
|
|
95
|
+
contextSecrets: 'Listen up: ',
|
|
96
|
+
processEnvs: 'Listen up: Good dog!',
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
it('works with other inputs', async () => {
|
|
100
|
+
const inputs = { parameters: { PHRASE: 'Bella, ' } };
|
|
101
|
+
await executeFunction(TEST_CONFIG, 'uses-secret', inputs, setActual);
|
|
102
|
+
expect(actual).toEqual({
|
|
103
|
+
contextSecrets: 'Listen up: Bella, ',
|
|
104
|
+
processEnvs: 'Listen up: Bella, Good dog!',
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
it('throws an error if the function is not declared', async () => {
|
|
109
|
+
try {
|
|
110
|
+
await executeFunction(TEST_CONFIG, 'func-undeclared', {}, NOOP_CALLBACK);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
expect(err).toBeInstanceOf(ExecutionError);
|
|
114
|
+
expect(err.reason).toBe(Reason.FunctionNotFound);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
it('throws an error if the function does not exist', async () => {
|
|
118
|
+
try {
|
|
119
|
+
await executeFunction(TEST_CONFIG, 'does-not-exist', {}, NOOP_CALLBACK);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
expect(err).toBeInstanceOf(ExecutionError);
|
|
123
|
+
expect(err.reason).toBe(Reason.FunctionNotFound);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
it('throws an error if the function does not export main', async () => {
|
|
127
|
+
try {
|
|
128
|
+
await executeFunction(TEST_CONFIG, 'does-not-export-main', {}, NOOP_CALLBACK);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
expect(err).toBeInstanceOf(ExecutionError);
|
|
132
|
+
expect(err.reason).toBe(Reason.InvalidFunction);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
it('throws an error if the function returns invalid json', async () => {
|
|
136
|
+
try {
|
|
137
|
+
await executeFunction(TEST_CONFIG, 'returns-function', {}, NOOP_CALLBACK);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
expect(err).toBeInstanceOf(ExecutionError);
|
|
141
|
+
expect(err.reason).toBe(Reason.InvalidResponse);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
it('maps response undefined to null', async () => {
|
|
145
|
+
await executeFunction(TEST_CONFIG, 'returns-undefined', {}, setActual);
|
|
146
|
+
expect(actual).toBe(null);
|
|
147
|
+
});
|
|
148
|
+
it('throws an error if the function times out', async () => {
|
|
149
|
+
try {
|
|
150
|
+
await executeFunction(TEST_CONFIG, 'times-out', { parameters: { delayMs: 400 } }, NOOP_CALLBACK);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
expect(err).toBeInstanceOf(ExecutionError);
|
|
154
|
+
expect(err.reason).toBe(Reason.Timeout);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
it('throws an error if the function throws an error', async () => {
|
|
158
|
+
try {
|
|
159
|
+
await executeFunction(TEST_CONFIG, 'throws-error', {}, NOOP_CALLBACK);
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
expect(err).toBeInstanceOf(ExecutionError);
|
|
163
|
+
expect(err.reason).toBe(Reason.UncaughtError);
|
|
164
|
+
expect(err.cause).toBeInstanceOf(Error);
|
|
165
|
+
expect(err.cause).toHaveProperty('message', 'Oops');
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|