@hubspot/cli 7.8.1-experimental.0 → 7.8.3-experimental.0
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/commands/__tests__/getStarted.test.js +2 -2
- package/commands/__tests__/mcp.test.js +1 -1
- package/commands/__tests__/project.test.js +0 -3
- package/commands/app/__tests__/migrate.test.js +0 -1
- package/commands/app/migrate.js +4 -5
- package/commands/app/secret/add.js +2 -1
- package/commands/app/secret/delete.js +2 -1
- package/commands/app/secret/list.js +2 -1
- package/commands/app/secret/update.js +2 -1
- package/commands/app/secret.js +2 -1
- package/commands/app.js +2 -2
- package/commands/config/set.js +0 -1
- package/commands/feedback.js +1 -1
- package/commands/getStarted.d.ts +0 -2
- package/commands/getStarted.js +2 -2
- package/commands/mcp/__tests__/setup.test.js +2 -2
- package/commands/mcp/setup.js +3 -2
- package/commands/mcp.js +3 -3
- package/commands/project/__tests__/create.test.js +6 -6
- package/commands/project/__tests__/deploy.test.js +0 -3
- package/commands/project/__tests__/devUnifiedFlow.test.js +2 -4
- package/commands/project/__tests__/logs.test.js +0 -3
- package/commands/project/__tests__/migrate.test.js +1 -2
- package/commands/project/__tests__/migrateApp.test.js +1 -2
- package/commands/project/__tests__/profile.test.js +1 -1
- package/commands/project/add.js +1 -5
- package/commands/project/create.js +3 -9
- package/commands/project/deploy.js +2 -2
- package/commands/project/dev/index.js +4 -3
- package/commands/project/dev/unifiedFlow.js +5 -3
- package/commands/project/download.js +1 -2
- package/commands/project/installDeps.js +1 -2
- package/commands/project/listBuilds.js +2 -2
- package/commands/project/logs.js +2 -2
- package/commands/project/migrate.js +15 -6
- package/commands/project/migrateApp.js +1 -2
- package/commands/project/open.js +1 -2
- package/commands/project/profile/add.js +3 -3
- package/commands/project/profile/delete.js +1 -2
- package/commands/project/profile.js +2 -3
- package/commands/project/upload.js +2 -2
- package/commands/project/validate.js +1 -1
- package/commands/project/watch.js +2 -2
- package/commands/project.js +1 -2
- package/commands/sandbox/delete.js +1 -1
- package/commands/testAccount/importData.d.ts +1 -1
- package/commands/testAccount/importData.js +1 -1
- package/commands/testAccount.js +1 -1
- package/lang/en.d.ts +11 -3
- package/lang/en.js +13 -5
- package/lib/constants.d.ts +5 -0
- package/lib/constants.js +5 -0
- package/lib/mcp/setup.js +1 -1
- package/lib/middleware/fireAlarmMiddleware.js +15 -5
- package/lib/projects/__tests__/LocalDevProcess.test.js +227 -16
- package/lib/projects/__tests__/LocalDevWebsocketServer.test.js +16 -21
- package/lib/projects/__tests__/deploy.test.js +71 -6
- package/lib/projects/__tests__/localDevProjectHelpers.test.js +4 -2
- package/lib/projects/create/__tests__/v3.test.js +79 -4
- package/lib/projects/create/v3.js +8 -6
- package/lib/projects/localDev/AppDevModeInterface.js +5 -5
- package/lib/projects/localDev/LocalDevLogger.d.ts +4 -0
- package/lib/projects/localDev/LocalDevLogger.js +22 -0
- package/lib/projects/localDev/LocalDevProcess.d.ts +7 -5
- package/lib/projects/localDev/LocalDevProcess.js +90 -19
- package/lib/projects/localDev/LocalDevState.d.ts +9 -8
- package/lib/projects/localDev/LocalDevState.js +18 -17
- package/lib/projects/localDev/LocalDevWebsocketServer.d.ts +2 -0
- package/lib/projects/localDev/LocalDevWebsocketServer.js +55 -23
- package/lib/projects/localDev/helpers/project.js +5 -1
- package/lib/projects/localDev/localDevWebsocketServerUtils.d.ts +4 -0
- package/lib/projects/localDev/localDevWebsocketServerUtils.js +10 -0
- package/lib/projects/pollProjectBuildAndDeploy.js +4 -4
- package/lib/prompts/projectAddPrompt.js +2 -1
- package/lib/prompts/promptUtils.js +3 -0
- package/lib/prompts/selectProjectTemplatePrompt.js +2 -0
- package/package.json +4 -4
- package/types/LocalDev.d.ts +19 -3
- package/ui/index.js +1 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { translateForLocalDev } from '@hubspot/project-parsing-lib';
|
|
3
3
|
import { handleProjectUpload } from '../upload.js';
|
|
4
|
+
import { handleProjectDeploy } from '../deploy.js';
|
|
4
5
|
import { getProjectConfig } from '../config.js';
|
|
6
|
+
import { fetchProject } from '@hubspot/local-dev-lib/api/projects';
|
|
7
|
+
import { isHubSpotHttpError } from '@hubspot/local-dev-lib/errors/index';
|
|
5
8
|
import LocalDevProcess from '../localDev/LocalDevProcess.js';
|
|
6
9
|
import LocalDevLogger from '../localDev/LocalDevLogger.js';
|
|
7
10
|
import DevServerManagerV2 from '../localDev/DevServerManagerV2.js';
|
|
@@ -19,7 +22,10 @@ vi.mock('@hubspot/ui-extensions-dev-server', () => ({
|
|
|
19
22
|
vi.mock('open');
|
|
20
23
|
vi.mock('@hubspot/project-parsing-lib');
|
|
21
24
|
vi.mock('../upload');
|
|
25
|
+
vi.mock('../deploy');
|
|
22
26
|
vi.mock('../config');
|
|
27
|
+
vi.mock('@hubspot/local-dev-lib/api/projects');
|
|
28
|
+
vi.mock('@hubspot/local-dev-lib/errors/index');
|
|
23
29
|
vi.mock('../localDev/LocalDevLogger');
|
|
24
30
|
vi.mock('../localDev/DevServerManagerV2');
|
|
25
31
|
// Tests for LocalDevProcess and LocalDevState
|
|
@@ -37,11 +43,35 @@ describe('LocalDevProcess', () => {
|
|
|
37
43
|
projectConfig: mockProjectConfig,
|
|
38
44
|
targetProjectAccountId: 123,
|
|
39
45
|
targetTestingAccountId: 456,
|
|
40
|
-
|
|
46
|
+
projectData: {
|
|
47
|
+
id: 789,
|
|
48
|
+
name: 'test-project',
|
|
49
|
+
portalId: 123,
|
|
50
|
+
createdAt: 0,
|
|
51
|
+
deletedAt: 0,
|
|
52
|
+
isLocked: false,
|
|
53
|
+
updatedAt: 0,
|
|
54
|
+
latestBuild: {
|
|
55
|
+
activitySource: { type: 'HUBSPOT_USER', userId: 456 },
|
|
56
|
+
buildId: 123,
|
|
57
|
+
createdAt: '2023-01-01T00:00:00Z',
|
|
58
|
+
deployableState: 'DEPLOYABLE',
|
|
59
|
+
deployStatusTaskLocator: { id: 'task-123', links: [] },
|
|
60
|
+
enqueuedAt: '2023-01-01T00:00:00Z',
|
|
61
|
+
finishedAt: '2023-01-01T00:05:00Z',
|
|
62
|
+
isAutoDeployEnabled: false,
|
|
63
|
+
portalId: 123,
|
|
64
|
+
projectName: 'test-project',
|
|
65
|
+
startedAt: '2023-01-01T00:01:00Z',
|
|
66
|
+
status: 'SUCCESS',
|
|
67
|
+
subbuildStatuses: [],
|
|
68
|
+
uploadMessage: 'Build completed',
|
|
69
|
+
autoDeployId: 0,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
41
72
|
initialProjectNodes: {},
|
|
42
73
|
initialProjectProfileData: {},
|
|
43
74
|
env: ENVIRONMENTS.PROD,
|
|
44
|
-
projectName: 'test-project',
|
|
45
75
|
};
|
|
46
76
|
beforeEach(() => {
|
|
47
77
|
vi.clearAllMocks();
|
|
@@ -62,6 +92,9 @@ describe('LocalDevProcess', () => {
|
|
|
62
92
|
uploadSuccess: vi.fn(),
|
|
63
93
|
fileChangeError: vi.fn(),
|
|
64
94
|
uploadWarning: vi.fn(),
|
|
95
|
+
deployInitiated: vi.fn(),
|
|
96
|
+
deployError: vi.fn(),
|
|
97
|
+
deploySuccess: vi.fn(),
|
|
65
98
|
};
|
|
66
99
|
mockDevServerManager = {
|
|
67
100
|
setup: vi.fn().mockResolvedValue(undefined),
|
|
@@ -72,6 +105,8 @@ describe('LocalDevProcess', () => {
|
|
|
72
105
|
// Mock constructors
|
|
73
106
|
LocalDevLogger.mockImplementation(() => mockLocalDevLogger);
|
|
74
107
|
DevServerManagerV2.mockImplementation(() => mockDevServerManager);
|
|
108
|
+
// Mock external functions
|
|
109
|
+
isHubSpotHttpError.mockReturnValue(false);
|
|
75
110
|
// Create process instance
|
|
76
111
|
process = new LocalDevProcess(mockOptions);
|
|
77
112
|
// Mock process.exit
|
|
@@ -141,9 +176,14 @@ describe('LocalDevProcess', () => {
|
|
|
141
176
|
handleProjectUpload.mockResolvedValue({
|
|
142
177
|
uploadError: new Error('Upload failed'),
|
|
143
178
|
});
|
|
144
|
-
const
|
|
179
|
+
const result = await process.uploadProject();
|
|
145
180
|
expect(mockLocalDevLogger.uploadError).toHaveBeenCalledWith(new Error('Upload failed'));
|
|
146
|
-
expect(
|
|
181
|
+
expect(result).toEqual({
|
|
182
|
+
uploadSuccess: false,
|
|
183
|
+
buildSuccess: false,
|
|
184
|
+
deploySuccess: false,
|
|
185
|
+
deployId: undefined,
|
|
186
|
+
});
|
|
147
187
|
});
|
|
148
188
|
it('should handle successful upload', async () => {
|
|
149
189
|
await process.handleConfigFileChange();
|
|
@@ -152,38 +192,120 @@ describe('LocalDevProcess', () => {
|
|
|
152
192
|
});
|
|
153
193
|
handleProjectUpload.mockResolvedValue({
|
|
154
194
|
uploadError: null,
|
|
195
|
+
result: {
|
|
196
|
+
deployResult: {
|
|
197
|
+
id: 'deploy-123',
|
|
198
|
+
deployId: 123,
|
|
199
|
+
status: 'SUCCESS',
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
fetchProject.mockResolvedValue({
|
|
204
|
+
data: {
|
|
205
|
+
id: 789,
|
|
206
|
+
name: 'test-project',
|
|
207
|
+
portalId: 123,
|
|
208
|
+
createdAt: 0,
|
|
209
|
+
deletedAt: 0,
|
|
210
|
+
isLocked: false,
|
|
211
|
+
updatedAt: 0,
|
|
212
|
+
latestBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
213
|
+
deployedBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
214
|
+
},
|
|
155
215
|
});
|
|
156
|
-
const
|
|
216
|
+
const result = await process.uploadProject();
|
|
217
|
+
expect(fetchProject).toHaveBeenCalledWith(mockOptions.targetProjectAccountId, mockOptions.projectConfig.name);
|
|
157
218
|
expect(mockLocalDevLogger.uploadSuccess).toHaveBeenCalled();
|
|
158
219
|
// @ts-expect-error accessing private property for testing
|
|
159
220
|
expect(process.state.uploadWarnings.size).toBe(0);
|
|
160
|
-
expect(
|
|
221
|
+
expect(result).toEqual({
|
|
222
|
+
uploadSuccess: true,
|
|
223
|
+
buildSuccess: true,
|
|
224
|
+
deploySuccess: true,
|
|
225
|
+
deployId: 123,
|
|
226
|
+
});
|
|
161
227
|
});
|
|
162
|
-
it('should reset projectNodesAtLastUpload', async () => {
|
|
163
|
-
const
|
|
164
|
-
|
|
228
|
+
it('should reset projectNodesAtLastUpload if deploy is successful', async () => {
|
|
229
|
+
const mockInitialNodes = {
|
|
230
|
+
node1: {
|
|
231
|
+
uid: 'node1',
|
|
232
|
+
componentType: 'APP',
|
|
233
|
+
localDev: {
|
|
234
|
+
componentRoot: '/test/path',
|
|
235
|
+
componentConfigPath: '/test/path/config.json',
|
|
236
|
+
configUpdatedSinceLastUpload: false,
|
|
237
|
+
},
|
|
238
|
+
componentDeps: {},
|
|
239
|
+
metaFilePath: '/test/path',
|
|
240
|
+
config: { name: 'Node 1' },
|
|
241
|
+
files: [],
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
const mockNewNodes = {
|
|
245
|
+
node1: {
|
|
246
|
+
uid: 'node2',
|
|
247
|
+
componentType: 'APP',
|
|
248
|
+
localDev: {
|
|
249
|
+
componentRoot: '/test/path',
|
|
250
|
+
componentConfigPath: '/test/path/config.json',
|
|
251
|
+
configUpdatedSinceLastUpload: false,
|
|
252
|
+
},
|
|
253
|
+
componentDeps: {},
|
|
254
|
+
metaFilePath: '/test/path',
|
|
255
|
+
config: { name: 'Node 2' },
|
|
256
|
+
files: [],
|
|
257
|
+
},
|
|
258
|
+
};
|
|
165
259
|
// @ts-expect-error accessing private property for testing
|
|
166
|
-
process.state.
|
|
260
|
+
process.state.projectNodesAtLastDeploy = mockInitialNodes;
|
|
167
261
|
getProjectConfig.mockResolvedValue({
|
|
168
262
|
projectConfig: mockOptions.projectConfig,
|
|
169
263
|
});
|
|
170
264
|
handleProjectUpload.mockResolvedValue({
|
|
171
265
|
uploadError: null,
|
|
266
|
+
result: {
|
|
267
|
+
deployResult: {
|
|
268
|
+
id: 'deploy-123',
|
|
269
|
+
deployId: 456,
|
|
270
|
+
status: 'SUCCESS',
|
|
271
|
+
},
|
|
272
|
+
},
|
|
172
273
|
});
|
|
173
274
|
translateForLocalDev.mockResolvedValue({
|
|
174
|
-
intermediateNodesIndexedByUid:
|
|
275
|
+
intermediateNodesIndexedByUid: mockNewNodes,
|
|
175
276
|
});
|
|
176
|
-
|
|
177
|
-
|
|
277
|
+
fetchProject.mockResolvedValue({
|
|
278
|
+
data: {
|
|
279
|
+
id: 789,
|
|
280
|
+
name: 'test-project',
|
|
281
|
+
portalId: 123,
|
|
282
|
+
createdAt: 0,
|
|
283
|
+
deletedAt: 0,
|
|
284
|
+
isLocked: false,
|
|
285
|
+
updatedAt: 0,
|
|
286
|
+
latestBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
287
|
+
deployedBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
const result = await process.uploadProject();
|
|
291
|
+
// Verify translateForLocalDev was called during updateProjectNodesAfterDeploy
|
|
178
292
|
expect(translateForLocalDev).toHaveBeenCalledWith({
|
|
179
293
|
projectSourceDir: path.join(mockOptions.projectDir, mockOptions.projectConfig.srcDir),
|
|
180
294
|
platformVersion: mockOptions.projectConfig.platformVersion,
|
|
181
295
|
accountId: mockOptions.targetProjectAccountId,
|
|
182
|
-
}, {
|
|
296
|
+
}, {
|
|
297
|
+
profile: undefined,
|
|
298
|
+
projectNodesAtLastUpload: undefined,
|
|
299
|
+
});
|
|
183
300
|
// Verify projectNodesAtLastUpload was reset to the new nodes
|
|
184
301
|
// @ts-expect-error accessing private property for testing
|
|
185
|
-
expect(process.state.
|
|
186
|
-
expect(
|
|
302
|
+
expect(process.state.projectNodesAtLastDeploy).toEqual(mockNewNodes);
|
|
303
|
+
expect(result).toEqual({
|
|
304
|
+
uploadSuccess: true,
|
|
305
|
+
buildSuccess: true,
|
|
306
|
+
deploySuccess: true,
|
|
307
|
+
deployId: 456,
|
|
308
|
+
});
|
|
187
309
|
});
|
|
188
310
|
});
|
|
189
311
|
describe('handleFileChange()', () => {
|
|
@@ -252,4 +374,93 @@ describe('LocalDevProcess', () => {
|
|
|
252
374
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
253
375
|
});
|
|
254
376
|
});
|
|
377
|
+
describe('deployLatestBuild()', () => {
|
|
378
|
+
beforeEach(() => {
|
|
379
|
+
vi.clearAllMocks();
|
|
380
|
+
});
|
|
381
|
+
it('should successfully deploy latest build', async () => {
|
|
382
|
+
const mockDeploy = {
|
|
383
|
+
deployId: 456,
|
|
384
|
+
buildId: 123,
|
|
385
|
+
status: 'SUCCESS',
|
|
386
|
+
enqueuedAt: '2023-01-01T00:00:00Z',
|
|
387
|
+
startedAt: '2023-01-01T00:01:00Z',
|
|
388
|
+
finishedAt: '2023-01-01T00:05:00Z',
|
|
389
|
+
portalId: 123,
|
|
390
|
+
projectName: 'test-project',
|
|
391
|
+
userId: 789,
|
|
392
|
+
source: 'HUBSPOT_USER',
|
|
393
|
+
subdeployStatuses: [],
|
|
394
|
+
};
|
|
395
|
+
handleProjectDeploy.mockResolvedValue(mockDeploy);
|
|
396
|
+
const result = await process.deployLatestBuild();
|
|
397
|
+
expect(mockLocalDevLogger.deployInitiated).toHaveBeenCalled();
|
|
398
|
+
expect(handleProjectDeploy).toHaveBeenCalledWith(123, // targetProjectAccountId
|
|
399
|
+
'test-project', // projectName
|
|
400
|
+
123, // buildId
|
|
401
|
+
true, // useV3Api
|
|
402
|
+
false // force
|
|
403
|
+
);
|
|
404
|
+
expect(mockLocalDevLogger.deploySuccess).toHaveBeenCalled();
|
|
405
|
+
expect(result).toEqual({
|
|
406
|
+
success: true,
|
|
407
|
+
deployId: 456,
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
it('should deploy with force parameter', async () => {
|
|
411
|
+
const mockDeploy = {
|
|
412
|
+
deployId: 456,
|
|
413
|
+
buildId: 123,
|
|
414
|
+
status: 'SUCCESS',
|
|
415
|
+
enqueuedAt: '2023-01-01T00:00:00Z',
|
|
416
|
+
startedAt: '2023-01-01T00:01:00Z',
|
|
417
|
+
finishedAt: '2023-01-01T00:05:00Z',
|
|
418
|
+
portalId: 123,
|
|
419
|
+
projectName: 'test-project',
|
|
420
|
+
userId: 789,
|
|
421
|
+
source: 'HUBSPOT_USER',
|
|
422
|
+
subdeployStatuses: [],
|
|
423
|
+
};
|
|
424
|
+
handleProjectDeploy.mockResolvedValue(mockDeploy);
|
|
425
|
+
const result = await process.deployLatestBuild(true);
|
|
426
|
+
expect(handleProjectDeploy).toHaveBeenCalledWith(123, // targetProjectAccountId
|
|
427
|
+
'test-project', // projectName
|
|
428
|
+
123, // buildId
|
|
429
|
+
true, // useV3Api
|
|
430
|
+
true // force
|
|
431
|
+
);
|
|
432
|
+
expect(result).toEqual({
|
|
433
|
+
success: true,
|
|
434
|
+
deployId: 456,
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
it('should return error when no build exists', async () => {
|
|
438
|
+
// Create a process without latestBuild
|
|
439
|
+
const optionsWithoutBuild = {
|
|
440
|
+
...mockOptions,
|
|
441
|
+
projectData: {
|
|
442
|
+
...mockOptions.projectData,
|
|
443
|
+
latestBuild: undefined,
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
const processWithoutBuild = new LocalDevProcess(optionsWithoutBuild);
|
|
447
|
+
const result = await processWithoutBuild.deployLatestBuild();
|
|
448
|
+
expect(mockLocalDevLogger.deployInitiated).toHaveBeenCalled();
|
|
449
|
+
expect(mockLocalDevLogger.deployError).toHaveBeenCalledWith('Error deploying project. No build was found to deploy.');
|
|
450
|
+
expect(result).toEqual({
|
|
451
|
+
success: false,
|
|
452
|
+
});
|
|
453
|
+
expect(handleProjectDeploy).not.toHaveBeenCalled();
|
|
454
|
+
});
|
|
455
|
+
it('should handle deploy failure when no deploy object returned', async () => {
|
|
456
|
+
handleProjectDeploy.mockResolvedValue(undefined);
|
|
457
|
+
const result = await process.deployLatestBuild();
|
|
458
|
+
expect(mockLocalDevLogger.deployInitiated).toHaveBeenCalled();
|
|
459
|
+
expect(handleProjectDeploy).toHaveBeenCalled();
|
|
460
|
+
expect(result).toEqual({
|
|
461
|
+
success: false,
|
|
462
|
+
});
|
|
463
|
+
expect(mockLocalDevLogger.deploySuccess).not.toHaveBeenCalled();
|
|
464
|
+
});
|
|
465
|
+
});
|
|
255
466
|
});
|
|
@@ -30,8 +30,16 @@ describe('LocalDevWebsocketServer', () => {
|
|
|
30
30
|
mockLocalDevProcess = {
|
|
31
31
|
addStateListener: vi.fn(),
|
|
32
32
|
removeStateListener: vi.fn(),
|
|
33
|
-
uploadProject: vi.fn(),
|
|
33
|
+
uploadProject: vi.fn().mockResolvedValue({}),
|
|
34
34
|
sendDevServerMessage: vi.fn(),
|
|
35
|
+
projectData: {
|
|
36
|
+
name: 'test-project',
|
|
37
|
+
id: 123,
|
|
38
|
+
latestBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
39
|
+
deployedBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
40
|
+
},
|
|
41
|
+
targetProjectAccountId: 456,
|
|
42
|
+
targetTestingAccountId: 789,
|
|
35
43
|
};
|
|
36
44
|
// Mock WebSocketServer constructor
|
|
37
45
|
WebSocketServer.mockImplementation(() => mockWebSocketServer);
|
|
@@ -225,23 +233,6 @@ describe('LocalDevWebsocketServer', () => {
|
|
|
225
233
|
expect(mockWebSocket3.close).not.toHaveBeenCalled();
|
|
226
234
|
});
|
|
227
235
|
it('should send project data to each connection independently', () => {
|
|
228
|
-
// Setup mock project data properties as getters
|
|
229
|
-
Object.defineProperty(mockLocalDevProcess, 'projectName', {
|
|
230
|
-
get: () => 'test-project',
|
|
231
|
-
configurable: true,
|
|
232
|
-
});
|
|
233
|
-
Object.defineProperty(mockLocalDevProcess, 'projectId', {
|
|
234
|
-
get: () => 123,
|
|
235
|
-
configurable: true,
|
|
236
|
-
});
|
|
237
|
-
Object.defineProperty(mockLocalDevProcess, 'targetProjectAccountId', {
|
|
238
|
-
get: () => 456,
|
|
239
|
-
configurable: true,
|
|
240
|
-
});
|
|
241
|
-
Object.defineProperty(mockLocalDevProcess, 'targetTestingAccountId', {
|
|
242
|
-
get: () => 789,
|
|
243
|
-
configurable: true,
|
|
244
|
-
});
|
|
245
236
|
// Establish multiple connections
|
|
246
237
|
connectionCallback(mockWebSocket1, {
|
|
247
238
|
headers: { origin: 'https://app.hubspot.com' },
|
|
@@ -255,6 +246,8 @@ describe('LocalDevWebsocketServer', () => {
|
|
|
255
246
|
data: {
|
|
256
247
|
projectName: 'test-project',
|
|
257
248
|
projectId: 123,
|
|
249
|
+
latestBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
250
|
+
deployedBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
258
251
|
targetProjectAccountId: 456,
|
|
259
252
|
targetTestingAccountId: 789,
|
|
260
253
|
},
|
|
@@ -264,6 +257,8 @@ describe('LocalDevWebsocketServer', () => {
|
|
|
264
257
|
data: {
|
|
265
258
|
projectName: 'test-project',
|
|
266
259
|
projectId: 123,
|
|
260
|
+
latestBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
261
|
+
deployedBuild: { id: 'build-1', status: 'SUCCESS' },
|
|
267
262
|
targetProjectAccountId: 456,
|
|
268
263
|
targetTestingAccountId: 789,
|
|
269
264
|
},
|
|
@@ -284,11 +279,11 @@ describe('LocalDevWebsocketServer', () => {
|
|
|
284
279
|
const closeCallbacks2 = mockWebSocket2.on.mock.calls
|
|
285
280
|
.filter(call => call[0] === 'close')
|
|
286
281
|
.map(call => call[1]);
|
|
287
|
-
expect(closeCallbacks1).toHaveLength(3); // projectNodes and
|
|
288
|
-
expect(closeCallbacks2).toHaveLength(3); // projectNodes and
|
|
282
|
+
expect(closeCallbacks1).toHaveLength(3); // projectNodes, appData, and uploadWarnings listeners
|
|
283
|
+
expect(closeCallbacks2).toHaveLength(3); // projectNodes, appData, and uploadWarnings listeners
|
|
289
284
|
// Simulate first connection closing (call all close callbacks)
|
|
290
285
|
closeCallbacks1.forEach(callback => callback());
|
|
291
|
-
// Should have removed listeners for first connection (
|
|
286
|
+
// Should have removed listeners for first connection (3 listeners: projectNodes, appData, and uploadWarnings)
|
|
292
287
|
expect(mockLocalDevProcess.removeStateListener).toHaveBeenCalledTimes(3);
|
|
293
288
|
// Simulate second connection closing
|
|
294
289
|
closeCallbacks2.forEach(callback => callback());
|
|
@@ -126,9 +126,9 @@ describe('lib/projects/deploy', () => {
|
|
|
126
126
|
data: mockDeployResponseData,
|
|
127
127
|
});
|
|
128
128
|
mockPollDeployStatus.mockResolvedValue(mockDeployResult);
|
|
129
|
-
const
|
|
129
|
+
const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
|
|
130
130
|
expect(mockDeployProject).toHaveBeenCalledWith(targetAccountId, projectName, buildId, useV3Api, force);
|
|
131
|
-
expect(
|
|
131
|
+
expect(deploy).toEqual(mockDeployResult);
|
|
132
132
|
});
|
|
133
133
|
it('handles blocked deploy with warnings', async () => {
|
|
134
134
|
const mockBlockedResponse = {
|
|
@@ -150,15 +150,80 @@ describe('lib/projects/deploy', () => {
|
|
|
150
150
|
mockDeployProject.mockResolvedValue({
|
|
151
151
|
data: mockBlockedResponse,
|
|
152
152
|
});
|
|
153
|
-
|
|
153
|
+
await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
|
|
154
154
|
expect(mockUiLogger.warn).toHaveBeenCalledWith(commands.project.deploy.errors.deployWarningsHeader);
|
|
155
|
-
|
|
155
|
+
});
|
|
156
|
+
it('handles blocked deploy with errors (cannot be forced)', async () => {
|
|
157
|
+
const mockBlockedResponse = {
|
|
158
|
+
buildResultType: 'DEPLOY_BLOCKED',
|
|
159
|
+
issues: [
|
|
160
|
+
{
|
|
161
|
+
uid: 'component-1',
|
|
162
|
+
componentTypeName: 'module',
|
|
163
|
+
errorMessages: [],
|
|
164
|
+
blockingMessages: [
|
|
165
|
+
{
|
|
166
|
+
message: 'This is an error',
|
|
167
|
+
isWarning: false,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
};
|
|
173
|
+
mockDeployProject.mockResolvedValue({
|
|
174
|
+
data: mockBlockedResponse,
|
|
175
|
+
});
|
|
176
|
+
await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
|
|
177
|
+
expect(mockUiLogger.error).toHaveBeenCalledWith(commands.project.deploy.errors.deployBlockedHeader);
|
|
178
|
+
expect(mockUiLogger.log).toHaveBeenCalledWith(commands.project.deploy.errors.deployIssueComponentWarning('component-1', 'module', 'This is an error'));
|
|
179
|
+
});
|
|
180
|
+
it('handles blocked deploy with no blocking messages', async () => {
|
|
181
|
+
const mockBlockedResponse = {
|
|
182
|
+
buildResultType: 'DEPLOY_BLOCKED',
|
|
183
|
+
issues: [
|
|
184
|
+
{
|
|
185
|
+
uid: 'component-1',
|
|
186
|
+
componentTypeName: 'module',
|
|
187
|
+
errorMessages: [],
|
|
188
|
+
blockingMessages: [],
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
mockDeployProject.mockResolvedValue({
|
|
193
|
+
data: mockBlockedResponse,
|
|
194
|
+
});
|
|
195
|
+
await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
|
|
196
|
+
expect(mockUiLogger.warn).toHaveBeenCalledWith(commands.project.deploy.errors.deployWarningsHeader);
|
|
197
|
+
expect(mockUiLogger.log).toHaveBeenCalledWith(commands.project.deploy.errors.deployIssueComponentGeneric('component-1', 'module'));
|
|
156
198
|
});
|
|
157
199
|
it('handles general deploy failure', async () => {
|
|
158
200
|
mockDeployProject.mockResolvedValue({ data: null });
|
|
159
|
-
const
|
|
201
|
+
const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
|
|
202
|
+
expect(mockUiLogger.error).toHaveBeenCalledWith(commands.project.deploy.errors.deploy);
|
|
203
|
+
expect(deploy).toBeUndefined();
|
|
204
|
+
});
|
|
205
|
+
it('handles undefined deploy response', async () => {
|
|
206
|
+
mockDeployProject.mockResolvedValue({ data: undefined });
|
|
207
|
+
const deploy = await handleProjectDeploy(targetAccountId, projectName, buildId, useV3Api, force);
|
|
160
208
|
expect(mockUiLogger.error).toHaveBeenCalledWith(commands.project.deploy.errors.deploy);
|
|
161
|
-
expect(
|
|
209
|
+
expect(deploy).toBeUndefined();
|
|
210
|
+
});
|
|
211
|
+
it('passes correct parameters to deployProject', async () => {
|
|
212
|
+
const mockDeployResponseData = {
|
|
213
|
+
id: 'deploy-123',
|
|
214
|
+
buildResultType: 'DEPLOY_QUEUED',
|
|
215
|
+
links: {
|
|
216
|
+
status: 'http://status-url',
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
mockDeployProject.mockResolvedValue({
|
|
220
|
+
data: mockDeployResponseData,
|
|
221
|
+
});
|
|
222
|
+
mockPollDeployStatus.mockResolvedValue({});
|
|
223
|
+
await handleProjectDeploy(targetAccountId, projectName, buildId, false, true);
|
|
224
|
+
expect(mockDeployProject).toHaveBeenCalledWith(targetAccountId, projectName, buildId, false, // useV3Api
|
|
225
|
+
true // force
|
|
226
|
+
);
|
|
162
227
|
});
|
|
163
228
|
});
|
|
164
229
|
});
|
|
@@ -99,7 +99,8 @@ describe('isDeployedProjectUpToDateWithLocal', () => {
|
|
|
99
99
|
it('should clean up temp directory even when errors occur', async () => {
|
|
100
100
|
// Mock downloadProject to throw an error after temp dir is created
|
|
101
101
|
downloadProject.mockRejectedValue(new Error('Download Error'));
|
|
102
|
-
await
|
|
102
|
+
const result = await isDeployedProjectUpToDateWithLocal(mockProjectConfig, mockAccountId, mockBuildId, mockLocalProjectNodes);
|
|
103
|
+
expect(result).toBe(false);
|
|
103
104
|
expect(fs.remove).toHaveBeenCalledWith(mockTempDir);
|
|
104
105
|
});
|
|
105
106
|
it('should handle translateForLocalDev errors', async () => {
|
|
@@ -111,7 +112,8 @@ describe('isDeployedProjectUpToDateWithLocal', () => {
|
|
|
111
112
|
extractZipArchive.mockResolvedValue(undefined);
|
|
112
113
|
// Mock translate to throw an error
|
|
113
114
|
translate.mockRejectedValue(new Error('Translation Error'));
|
|
114
|
-
await
|
|
115
|
+
const result = await isDeployedProjectUpToDateWithLocal(mockProjectConfig, mockAccountId, mockBuildId, mockLocalProjectNodes);
|
|
116
|
+
expect(result).toBe(false);
|
|
115
117
|
expect(fs.remove).toHaveBeenCalledWith(mockTempDir);
|
|
116
118
|
});
|
|
117
119
|
});
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import { calculateComponentTemplateChoices } from '../v3.js';
|
|
2
|
+
import { hasFeature } from '../../../hasFeature.js';
|
|
2
3
|
vi.mock('@hubspot/local-dev-lib/logger');
|
|
3
4
|
vi.mock('@hubspot/local-dev-lib/api/github');
|
|
5
|
+
vi.mock('../../../hasFeature.js');
|
|
6
|
+
const mockHasFeature = vi.mocked(hasFeature);
|
|
4
7
|
describe('lib/projects/create/v3', () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mockHasFeature.mockResolvedValue(true);
|
|
10
|
+
});
|
|
5
11
|
describe('calculateComponentTemplateChoices()', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
mockHasFeature.mockClear();
|
|
14
|
+
});
|
|
6
15
|
const mockComponents = [
|
|
7
16
|
{
|
|
8
17
|
label: 'Module Component',
|
|
@@ -30,7 +39,7 @@ describe('lib/projects/create/v3', () => {
|
|
|
30
39
|
const choices = await calculateComponentTemplateChoices(mockComponents, 'oauth', 'private', 123, mockProjectMetadataForChoices);
|
|
31
40
|
expect(choices).toHaveLength(4); // includes separator
|
|
32
41
|
expect(choices[0]).toEqual({
|
|
33
|
-
name: 'Module Component',
|
|
42
|
+
name: 'Module Component [module]',
|
|
34
43
|
value: mockComponents[0],
|
|
35
44
|
});
|
|
36
45
|
expect(choices[2]).toEqual({
|
|
@@ -70,7 +79,7 @@ describe('lib/projects/create/v3', () => {
|
|
|
70
79
|
components: { module: { count: 0, maxCount: 5, hsMetaFiles: [] } },
|
|
71
80
|
});
|
|
72
81
|
expect(choices[0]).toEqual({
|
|
73
|
-
name: 'Unrestricted Component',
|
|
82
|
+
name: 'Unrestricted Component [module]',
|
|
74
83
|
value: componentsWithoutRestrictions[0],
|
|
75
84
|
});
|
|
76
85
|
});
|
|
@@ -94,7 +103,7 @@ describe('lib/projects/create/v3', () => {
|
|
|
94
103
|
const choices = await calculateComponentTemplateChoices(componentWithCliSelector, 'oauth', 'private', 213, projectMetadataWithWorkflowAction);
|
|
95
104
|
expect(choices).toHaveLength(1); // no disabled components
|
|
96
105
|
expect(choices[0]).toEqual({
|
|
97
|
-
name: 'Workflow Action Tool',
|
|
106
|
+
name: 'Workflow Action Tool [workflow-action-tool]',
|
|
98
107
|
value: componentWithCliSelector[0],
|
|
99
108
|
});
|
|
100
109
|
});
|
|
@@ -137,7 +146,7 @@ describe('lib/projects/create/v3', () => {
|
|
|
137
146
|
const choices = await calculateComponentTemplateChoices(componentWithCliSelector, 'oauth', 'private', 123, undefined);
|
|
138
147
|
expect(choices).toHaveLength(1); // no disabled components
|
|
139
148
|
expect(choices[0]).toEqual({
|
|
140
|
-
name: 'Workflow Action Tool',
|
|
149
|
+
name: 'Workflow Action Tool [workflow-action-tool]',
|
|
141
150
|
value: componentWithCliSelector[0],
|
|
142
151
|
});
|
|
143
152
|
});
|
|
@@ -162,5 +171,71 @@ describe('lib/projects/create/v3', () => {
|
|
|
162
171
|
// @ts-expect-error breaking stuff on purpose
|
|
163
172
|
projectMetadataWithoutComponents)).rejects.toThrow();
|
|
164
173
|
});
|
|
174
|
+
it('disables gated components when hasFeature returns false', async () => {
|
|
175
|
+
mockHasFeature.mockResolvedValue(false);
|
|
176
|
+
const gatedComponent = [
|
|
177
|
+
{
|
|
178
|
+
label: 'Workflow Action Tool',
|
|
179
|
+
path: 'workflow-action-tool',
|
|
180
|
+
type: 'workflow-action',
|
|
181
|
+
cliSelector: 'workflow-action-tool',
|
|
182
|
+
supportedAuthTypes: ['oauth'],
|
|
183
|
+
supportedDistributions: ['private'],
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
const choices = await calculateComponentTemplateChoices(gatedComponent, 'oauth', 'private', 123, mockProjectMetadataForChoices);
|
|
187
|
+
expect(choices).toHaveLength(3); // includes separators
|
|
188
|
+
expect(choices[1]).toEqual({
|
|
189
|
+
name: expect.stringContaining('Workflow Action Tool'),
|
|
190
|
+
value: gatedComponent[0],
|
|
191
|
+
disabled: expect.stringContaining('does not have access to this feature'),
|
|
192
|
+
});
|
|
193
|
+
expect(mockHasFeature).toHaveBeenCalledWith(123, expect.any(String));
|
|
194
|
+
});
|
|
195
|
+
it('enables gated components when hasFeature returns true', async () => {
|
|
196
|
+
mockHasFeature.mockResolvedValue(true);
|
|
197
|
+
const gatedComponent = [
|
|
198
|
+
{
|
|
199
|
+
label: 'Workflow Action Tool',
|
|
200
|
+
path: 'workflow-action-tool',
|
|
201
|
+
type: 'workflow-action',
|
|
202
|
+
cliSelector: 'workflow-action-tool',
|
|
203
|
+
supportedAuthTypes: ['oauth'],
|
|
204
|
+
supportedDistributions: ['private'],
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
const projectMetadataWithWorkflowAction = {
|
|
208
|
+
hsMetaFiles: [],
|
|
209
|
+
components: {
|
|
210
|
+
'workflow-action': { count: 0, maxCount: 3, hsMetaFiles: [] },
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
const choices = await calculateComponentTemplateChoices(gatedComponent, 'oauth', 'private', 123, projectMetadataWithWorkflowAction);
|
|
214
|
+
expect(choices).toHaveLength(1); // no disabled components
|
|
215
|
+
expect(choices[0]).toEqual({
|
|
216
|
+
name: 'Workflow Action Tool [workflow-action-tool]',
|
|
217
|
+
value: gatedComponent[0],
|
|
218
|
+
});
|
|
219
|
+
expect(mockHasFeature).toHaveBeenCalledWith(123, expect.any(String));
|
|
220
|
+
});
|
|
221
|
+
it('handles non-gated components without calling hasFeature', async () => {
|
|
222
|
+
const nonGatedComponent = [
|
|
223
|
+
{
|
|
224
|
+
label: 'Regular Component',
|
|
225
|
+
path: 'regular',
|
|
226
|
+
type: 'module',
|
|
227
|
+
supportedAuthTypes: ['oauth'],
|
|
228
|
+
supportedDistributions: ['private'],
|
|
229
|
+
},
|
|
230
|
+
];
|
|
231
|
+
const choices = await calculateComponentTemplateChoices(nonGatedComponent, 'oauth', 'private', 123, mockProjectMetadataForChoices);
|
|
232
|
+
expect(choices).toHaveLength(1);
|
|
233
|
+
expect(choices[0]).toEqual({
|
|
234
|
+
name: 'Regular Component [module]',
|
|
235
|
+
value: nonGatedComponent[0],
|
|
236
|
+
});
|
|
237
|
+
// hasFeature should not be called for non-gated components
|
|
238
|
+
expect(mockHasFeature).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
165
240
|
});
|
|
166
241
|
});
|