nango 0.72.2 → 0.72.3
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/index.js +23 -16
- package/dist/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/zeroYaml/definitions.d.ts +1 -1
- package/dist/zeroYaml/deploy.js +57 -61
- package/dist/zeroYaml/deploy.js.map +1 -1
- package/dist/zeroYaml/deploy.unit.cli-test.js +228 -90
- package/dist/zeroYaml/deploy.unit.cli-test.js.map +1 -1
- package/dist/zeroYaml/project-path.d.ts +8 -0
- package/dist/zeroYaml/project-path.js +16 -0
- package/dist/zeroYaml/project-path.js.map +1 -1
- package/dist/zeroYaml/project-path.unit.cli-test.js +35 -1
- package/dist/zeroYaml/project-path.unit.cli-test.js.map +1 -1
- package/package.json +6 -6
- package/dist/services/clone.service.d.ts +0 -13
- package/dist/services/clone.service.js +0 -184
- package/dist/services/clone.service.js.map +0 -1
- package/dist/utils/githubTemplates.d.ts +0 -69
- package/dist/utils/githubTemplates.js +0 -266
- package/dist/utils/githubTemplates.js.map +0 -1
|
@@ -115,6 +115,13 @@ const noFunctionChanges = {
|
|
|
115
115
|
unchanged: [{ integrationId: 'github', name: 'fetch' }],
|
|
116
116
|
deleted: []
|
|
117
117
|
};
|
|
118
|
+
const emptyFunctionChanges = { created: [], updated: [], unchanged: [], deleted: [] };
|
|
119
|
+
const apiRoutes = {
|
|
120
|
+
legacyPreview: '/sync/deploy/confirmation',
|
|
121
|
+
legacyDeploy: '/sync/deploy',
|
|
122
|
+
functionsPreview: '/functions/deployments/bundle/preview',
|
|
123
|
+
functionsDeploy: '/functions/deployments/bundle'
|
|
124
|
+
};
|
|
118
125
|
describe('deploy', () => {
|
|
119
126
|
let fullPath;
|
|
120
127
|
let fetchMock;
|
|
@@ -155,6 +162,10 @@ describe('deploy', () => {
|
|
|
155
162
|
'/sync/deploy',
|
|
156
163
|
'/functions/deployments/bundle'
|
|
157
164
|
]);
|
|
165
|
+
expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({ reconciliationScope: { kind: 'environment' } });
|
|
166
|
+
expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ reconciliationScope: { kind: 'environment' } });
|
|
167
|
+
expect(requestBody(fetchMock, 'legacyPreview')).not.toHaveProperty('deployMode');
|
|
168
|
+
expect(requestBody(fetchMock, 'legacyDeploy')).not.toHaveProperty('deployMode');
|
|
158
169
|
const output = logOutput(logSpy);
|
|
159
170
|
expect(output.match(/Nango will perform this plan:/g)).toHaveLength(1);
|
|
160
171
|
expect(output.match(/Summary/g)).toHaveLength(1);
|
|
@@ -170,11 +181,12 @@ describe('deploy', () => {
|
|
|
170
181
|
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
171
182
|
});
|
|
172
183
|
it('scopes both families to the selected integration and only overrides sync/action versions', async () => {
|
|
173
|
-
fetchMock
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
184
|
+
mockApi(fetchMock, {
|
|
185
|
+
legacyPreview: legacyChangeConfirmation,
|
|
186
|
+
legacyDeploy: legacyDeploymentResult,
|
|
187
|
+
functionsPreview: noFunctionChanges,
|
|
188
|
+
functionsDeploy: noFunctionChanges
|
|
189
|
+
});
|
|
178
190
|
const result = await deploy({
|
|
179
191
|
fullPath,
|
|
180
192
|
environmentName: 'test',
|
|
@@ -182,26 +194,27 @@ describe('deploy', () => {
|
|
|
182
194
|
options: deployOptions({ integration: 'github', version: '42' })
|
|
183
195
|
});
|
|
184
196
|
expect(result.isOk()).toBe(true);
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
expect(
|
|
197
|
+
const legacyPreviewBody = requestBody(fetchMock, 'legacyPreview');
|
|
198
|
+
const functionsPreviewBody = requestBody(fetchMock, 'functionsPreview');
|
|
199
|
+
expect(legacyPreviewBody).toMatchObject({
|
|
200
|
+
reconciliationScope: { kind: 'integration', integrationId: 'github' },
|
|
201
|
+
flowConfigs: [{ providerConfigKey: 'github', version: '42' }]
|
|
202
|
+
});
|
|
203
|
+
expect(functionsPreviewBody).toMatchObject({
|
|
188
204
|
reconciliationScope: { kind: 'integration', integrationId: 'github' },
|
|
189
205
|
functions: [{ integrationId: 'github', name: 'fetch' }]
|
|
190
206
|
});
|
|
191
|
-
expect(
|
|
192
|
-
expect(
|
|
207
|
+
expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ flowConfigs: legacyPreviewBody?.['flowConfigs'] });
|
|
208
|
+
expect(requestBody(fetchMock, 'functionsDeploy')).toEqual(functionsPreviewBody);
|
|
193
209
|
expect(logOutput(logSpy).match(/The --version option/g)).toHaveLength(1);
|
|
194
210
|
expect(logOutput(logSpy).indexOf('✓ Packaging')).toBeLessThan(logOutput(logSpy).indexOf('The --version option'));
|
|
195
211
|
expect(logOutput(logSpy)).not.toContain('No changes');
|
|
196
212
|
expect(logOutput(logSpy)).toContain('- github → legacy@v42');
|
|
197
213
|
});
|
|
198
|
-
it
|
|
199
|
-
{
|
|
200
|
-
{ name: 'conflicting selectors', options: { sync: 'contacts', action: 'legacy' }, message: 'The --sync and --action options cannot be used together' }
|
|
201
|
-
])('rejects $name without contacting the API', async ({ options, message }) => {
|
|
202
|
-
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(options) });
|
|
214
|
+
it('rejects conflicting selectors without contacting the API', async () => {
|
|
215
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ sync: 'contacts', action: 'legacy' }) });
|
|
203
216
|
expect(result.isErr()).toBe(true);
|
|
204
|
-
expect(logOutput(logSpy)).toContain(
|
|
217
|
+
expect(logOutput(logSpy)).toContain('The --sync and --action options cannot be used together');
|
|
205
218
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
206
219
|
});
|
|
207
220
|
it.each(['legacy', 'functions'])('does not contact the API when %s packaging fails in a mixed project', async (family) => {
|
|
@@ -354,6 +367,9 @@ describe('deploy', () => {
|
|
|
354
367
|
expect(output).toContain('✓ Deployed');
|
|
355
368
|
expect(output).toContain('No destructive changes, proceeding without confirmation');
|
|
356
369
|
expect(output).toContain('--sync and --action are deprecated');
|
|
370
|
+
expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/sync/deploy']);
|
|
371
|
+
expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({ reconciliationScope: { kind: 'function', integrationId: 'github', name: 'legacy' } });
|
|
372
|
+
expect(requestBody(fetchMock, 'legacyPreview')).not.toHaveProperty('deployMode');
|
|
357
373
|
});
|
|
358
374
|
describe('with a file', () => {
|
|
359
375
|
const functionChanges = {
|
|
@@ -399,6 +415,19 @@ describe('deploy', () => {
|
|
|
399
415
|
expect(previewBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'get_issue' });
|
|
400
416
|
expect(previewBody.functions).toEqual([expect.objectContaining({ name: 'get_issue', fileBody: { js: 'compiled tool', ts: 'source tool' } })]);
|
|
401
417
|
});
|
|
418
|
+
it.each([
|
|
419
|
+
{ env: undefined, source: 'repo' },
|
|
420
|
+
{ env: 'standalone', source: 'standalone' }
|
|
421
|
+
])('sends source $source on every function when NANGO_DEPLOY_SOURCE is $env', async ({ env, source }) => {
|
|
422
|
+
vi.stubEnv('NANGO_DEPLOY_SOURCE', env);
|
|
423
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
|
|
424
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/functions/fetch.ts' });
|
|
425
|
+
const bodies = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body));
|
|
426
|
+
expect(bodies).toHaveLength(2);
|
|
427
|
+
for (const body of bodies) {
|
|
428
|
+
expect(body.functions.map((fn) => fn.source)).toEqual([source]);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
402
431
|
it('deploys a legacy action as a single script', async () => {
|
|
403
432
|
fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
404
433
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/actions/legacy.ts' });
|
|
@@ -407,7 +436,7 @@ describe('deploy', () => {
|
|
|
407
436
|
expect(output).not.toContain('--sync and --action are deprecated');
|
|
408
437
|
const confirmationRequest = fetchMock.mock.calls[0]?.[1];
|
|
409
438
|
const confirmationBody = JSON.parse(confirmationRequest.body);
|
|
410
|
-
expect(confirmationBody.
|
|
439
|
+
expect(confirmationBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'legacy' });
|
|
411
440
|
expect(confirmationBody.onEventScriptsByProvider).toBeUndefined();
|
|
412
441
|
expect(confirmationBody.flowConfigs).toEqual([expect.objectContaining({ syncName: 'legacy', providerConfigKey: 'github', type: 'action' })]);
|
|
413
442
|
});
|
|
@@ -427,7 +456,7 @@ describe('deploy', () => {
|
|
|
427
456
|
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
428
457
|
const confirmationRequest = fetchMock.mock.calls[0]?.[1];
|
|
429
458
|
const confirmationBody = JSON.parse(confirmationRequest.body);
|
|
430
|
-
expect(confirmationBody.
|
|
459
|
+
expect(confirmationBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'contacts' });
|
|
431
460
|
expect(confirmationBody.flowConfigs).toEqual([
|
|
432
461
|
expect.objectContaining({
|
|
433
462
|
syncName: 'contacts',
|
|
@@ -437,6 +466,26 @@ describe('deploy', () => {
|
|
|
437
466
|
})
|
|
438
467
|
]);
|
|
439
468
|
});
|
|
469
|
+
it('deploys each match of a deprecated action selector in its own function scope', async () => {
|
|
470
|
+
fetchMock.mockReset();
|
|
471
|
+
mockApi(fetchMock, { legacyPreview: legacyChangeConfirmation });
|
|
472
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ action: 'legacy' }) });
|
|
473
|
+
expect(result.isOk()).toBe(true);
|
|
474
|
+
const scopes = fetchMock.mock.calls.map(([url, init]) => {
|
|
475
|
+
const body = JSON.parse(init.body);
|
|
476
|
+
expect(body).not.toHaveProperty('deployMode');
|
|
477
|
+
expect(body.flowConfigs).toHaveLength(1);
|
|
478
|
+
const [flow] = body.flowConfigs;
|
|
479
|
+
expect(body.reconciliationScope).toEqual({ kind: 'function', integrationId: flow?.providerConfigKey, name: 'legacy' });
|
|
480
|
+
return { path: url.pathname, integrationId: flow?.providerConfigKey };
|
|
481
|
+
});
|
|
482
|
+
expect(scopes).toEqual([
|
|
483
|
+
{ path: apiRoutes.legacyPreview, integrationId: 'github' },
|
|
484
|
+
{ path: apiRoutes.legacyPreview, integrationId: 'slack' },
|
|
485
|
+
{ path: apiRoutes.legacyDeploy, integrationId: 'github' },
|
|
486
|
+
{ path: apiRoutes.legacyDeploy, integrationId: 'slack' }
|
|
487
|
+
]);
|
|
488
|
+
});
|
|
440
489
|
it("deploys only the file's integration when another integration has an action with the same name", async () => {
|
|
441
490
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'slack/actions/legacy.ts' });
|
|
442
491
|
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
@@ -532,7 +581,7 @@ describe('deploy', () => {
|
|
|
532
581
|
unchanged: [],
|
|
533
582
|
deleted: []
|
|
534
583
|
};
|
|
535
|
-
fetchMock
|
|
584
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
536
585
|
await deploy({
|
|
537
586
|
fullPath,
|
|
538
587
|
environmentName: 'test',
|
|
@@ -542,13 +591,16 @@ describe('deploy', () => {
|
|
|
542
591
|
const output = logOutput(logSpy);
|
|
543
592
|
expect(output).toContain('↳ Functions');
|
|
544
593
|
expect(output).toContain('✓ Deployed');
|
|
545
|
-
|
|
546
|
-
|
|
594
|
+
expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({
|
|
595
|
+
flowConfigs: [],
|
|
596
|
+
onEventScriptsByProvider: [],
|
|
597
|
+
reconciliationScope: { kind: 'integration', integrationId: 'slack' }
|
|
598
|
+
});
|
|
599
|
+
const previewBody = requestBody(fetchMock, 'functionsPreview');
|
|
547
600
|
expect(previewBody.reconciliationScope).toEqual({ kind: 'integration', integrationId: 'slack' });
|
|
548
601
|
expect(previewBody.functions).toEqual([expect.objectContaining({ integrationId: 'slack', name: 'other', trigger: { kind: 'http' } })]);
|
|
549
602
|
});
|
|
550
|
-
|
|
551
|
-
it.skip('deletes the functions of an integration when its last function was removed', async () => {
|
|
603
|
+
it('deletes the functions of an integration when its last function was removed', async () => {
|
|
552
604
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [githubFunction] }));
|
|
553
605
|
const functionChanges = {
|
|
554
606
|
created: [],
|
|
@@ -556,7 +608,7 @@ describe('deploy', () => {
|
|
|
556
608
|
unchanged: [],
|
|
557
609
|
deleted: [{ integrationId: 'slack', name: 'other' }]
|
|
558
610
|
};
|
|
559
|
-
fetchMock
|
|
611
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
560
612
|
await deploy({
|
|
561
613
|
fullPath,
|
|
562
614
|
environmentName: 'test',
|
|
@@ -567,8 +619,15 @@ describe('deploy', () => {
|
|
|
567
619
|
expect(output).toContain('- slack → other');
|
|
568
620
|
expect(output).toContain('✓ Deployed');
|
|
569
621
|
expect(output).toContain('Successfully removed:');
|
|
622
|
+
expect(requestBody(fetchMock, 'functionsDeploy')).toEqual({ reconciliationScope: { kind: 'integration', integrationId: 'slack' }, functions: [] });
|
|
623
|
+
expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({
|
|
624
|
+
flowConfigs: [],
|
|
625
|
+
onEventScriptsByProvider: [],
|
|
626
|
+
reconciliationScope: { kind: 'integration', integrationId: 'slack' }
|
|
627
|
+
});
|
|
628
|
+
expect(requestPaths(fetchMock)).not.toContain(apiRoutes.legacyDeploy);
|
|
570
629
|
});
|
|
571
|
-
it
|
|
630
|
+
it('deletes every function of the environment when the last function was removed', async () => {
|
|
572
631
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
|
|
573
632
|
const functionChanges = {
|
|
574
633
|
created: [],
|
|
@@ -579,17 +638,70 @@ describe('deploy', () => {
|
|
|
579
638
|
{ integrationId: 'slack', name: 'other' }
|
|
580
639
|
]
|
|
581
640
|
};
|
|
582
|
-
fetchMock
|
|
641
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
583
642
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
584
643
|
const output = logOutput(logSpy);
|
|
585
644
|
expect(output).toContain('- github → fetch');
|
|
586
645
|
expect(output).toContain('- slack → other');
|
|
587
646
|
expect(output).toContain('✓ Deployed');
|
|
588
647
|
expect(output).toContain('Successfully removed:');
|
|
648
|
+
expect(requestBody(fetchMock, 'functionsDeploy')).toEqual({ reconciliationScope: { kind: 'environment' }, functions: [] });
|
|
649
|
+
});
|
|
650
|
+
it('deletes every legacy script of the environment when the last one was removed', async () => {
|
|
651
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
|
|
652
|
+
const changes = {
|
|
653
|
+
...legacyConfirmation,
|
|
654
|
+
deletedActions: [{ providerConfigKey: 'github', name: 'legacy' }],
|
|
655
|
+
deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
|
|
656
|
+
};
|
|
657
|
+
mockApi(fetchMock, { legacyPreview: changes });
|
|
658
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
659
|
+
expect(result.isOk()).toBe(true);
|
|
660
|
+
const output = logOutput(logSpy);
|
|
661
|
+
expect(output).toContain('Successfully removed:');
|
|
662
|
+
expect(output).toContain('- github → legacy');
|
|
663
|
+
expect(output).toContain('- github → old-event');
|
|
664
|
+
expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({
|
|
665
|
+
flowConfigs: [],
|
|
666
|
+
onEventScriptsByProvider: [],
|
|
667
|
+
reconciliationScope: { kind: 'environment' }
|
|
668
|
+
});
|
|
669
|
+
});
|
|
670
|
+
it('deploys a project that only has on-event scripts', async () => {
|
|
671
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({
|
|
672
|
+
...functionsOnlyParsed,
|
|
673
|
+
integrations: functionsOnlyParsed.integrations.map((integration) => ({
|
|
674
|
+
...integration,
|
|
675
|
+
onEventScripts: { ...integration.onEventScripts, 'post-connection-creation': ['on-create'] }
|
|
676
|
+
})),
|
|
677
|
+
functions: []
|
|
678
|
+
}));
|
|
679
|
+
await fs.mkdir(path.join(fullPath, 'github', 'on-events'), { recursive: true });
|
|
680
|
+
await fs.writeFile(path.join(fullPath, 'build', 'github_on-events_on-create.cjs'), 'compiled on-event');
|
|
681
|
+
await fs.writeFile(path.join(fullPath, 'github', 'on-events', 'on-create.ts'), 'source on-event');
|
|
682
|
+
mockApi(fetchMock, {
|
|
683
|
+
legacyPreview: {
|
|
684
|
+
...legacyConfirmation,
|
|
685
|
+
newOnEventScripts: [{ providerConfigKey: 'github', name: 'on-create', event: 'post-connection-creation' }]
|
|
686
|
+
},
|
|
687
|
+
legacyDeploy: [{ name: 'on-create', version: '0.0.1', providerConfigKey: 'github', type: 'on-event', models: [] }]
|
|
688
|
+
});
|
|
689
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
690
|
+
expect(result.isOk()).toBe(true);
|
|
691
|
+
expect(logOutput(logSpy)).toContain('- github → on-create@v0.0.1');
|
|
692
|
+
expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({
|
|
693
|
+
flowConfigs: [],
|
|
694
|
+
onEventScriptsByProvider: [
|
|
695
|
+
{
|
|
696
|
+
providerConfigKey: 'github',
|
|
697
|
+
scripts: [{ name: 'on-create', event: 'post-connection-creation', fileBody: { js: 'compiled on-event', ts: 'source on-event' } }]
|
|
698
|
+
}
|
|
699
|
+
]
|
|
700
|
+
});
|
|
589
701
|
});
|
|
590
702
|
it('warns about --version for function deployments and still deploys', async () => {
|
|
591
703
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
592
|
-
fetchMock
|
|
704
|
+
mockApi(fetchMock);
|
|
593
705
|
const result = await deploy({
|
|
594
706
|
fullPath,
|
|
595
707
|
environmentName: 'test',
|
|
@@ -598,7 +710,15 @@ describe('deploy', () => {
|
|
|
598
710
|
});
|
|
599
711
|
expect(result.isOk()).toBe(true);
|
|
600
712
|
expect(logOutput(logSpy)).toContain('The --version option only applies to syncs/actions; it has no effect on functions.');
|
|
601
|
-
expect(fetchMock).
|
|
713
|
+
expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.functionsDeploy]);
|
|
714
|
+
});
|
|
715
|
+
it('only previews when no deployment has anything to deploy or delete', async () => {
|
|
716
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
|
|
717
|
+
mockApi(fetchMock);
|
|
718
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
719
|
+
expect(result.isOk()).toBe(true);
|
|
720
|
+
expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview]);
|
|
721
|
+
expect(logOutput(logSpy)).toContain('No changes were deployed.');
|
|
602
722
|
});
|
|
603
723
|
it('prints only the Functions summary row for a functions-only project', async () => {
|
|
604
724
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
@@ -608,7 +728,7 @@ describe('deploy', () => {
|
|
|
608
728
|
unchanged: [{ integrationId: 'slack', name: 'other' }],
|
|
609
729
|
deleted: []
|
|
610
730
|
};
|
|
611
|
-
fetchMock
|
|
731
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
612
732
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
613
733
|
const output = logOutput(logSpy);
|
|
614
734
|
expect(output).not.toContain('↳ Syncs');
|
|
@@ -631,11 +751,10 @@ describe('deploy', () => {
|
|
|
631
751
|
unchanged: [],
|
|
632
752
|
deleted: []
|
|
633
753
|
};
|
|
634
|
-
fetchMock
|
|
754
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
635
755
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
636
756
|
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
637
|
-
const
|
|
638
|
-
const previewBody = JSON.parse(previewRequest.body);
|
|
757
|
+
const previewBody = requestBody(fetchMock, 'functionsPreview');
|
|
639
758
|
expect(previewBody.functions[0]?.fileBody).toEqual({ js: 'compiled with dotted path', ts: 'source with dotted path' });
|
|
640
759
|
});
|
|
641
760
|
it('rejects function source paths outside the project directory', async () => {
|
|
@@ -657,7 +776,7 @@ describe('deploy', () => {
|
|
|
657
776
|
});
|
|
658
777
|
it('prints only legacy summary rows when no functions bundle', async () => {
|
|
659
778
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
660
|
-
fetchMock
|
|
779
|
+
mockApi(fetchMock, { legacyPreview: legacyChangeConfirmation });
|
|
661
780
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
662
781
|
const output = logOutput(logSpy);
|
|
663
782
|
expect(output).toContain('↳ Syncs');
|
|
@@ -670,16 +789,9 @@ describe('deploy', () => {
|
|
|
670
789
|
{ name: 'legacy', definitions: legacyOnlyParsed },
|
|
671
790
|
{ name: 'functions', definitions: functionsOnlyParsed },
|
|
672
791
|
{ name: 'mixed', definitions: parsed }
|
|
673
|
-
])('prints No changes when the $name deployment is unchanged', async ({
|
|
792
|
+
])('prints No changes when the $name deployment is unchanged', async ({ definitions }) => {
|
|
674
793
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(definitions));
|
|
675
|
-
fetchMock
|
|
676
|
-
if (name === 'mixed') {
|
|
677
|
-
fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
|
|
678
|
-
}
|
|
679
|
-
fetchMock.mockResolvedValueOnce(jsonResponse(name === 'functions' ? noFunctionChanges : []));
|
|
680
|
-
if (name === 'mixed') {
|
|
681
|
-
fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
|
|
682
|
-
}
|
|
794
|
+
mockApi(fetchMock, { functionsPreview: noFunctionChanges, functionsDeploy: noFunctionChanges });
|
|
683
795
|
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
684
796
|
const output = logOutput(logSpy);
|
|
685
797
|
expect(result.isOk()).toBe(true);
|
|
@@ -692,38 +804,43 @@ describe('deploy', () => {
|
|
|
692
804
|
});
|
|
693
805
|
it('prints Function preview errors', async () => {
|
|
694
806
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
695
|
-
fetchMock
|
|
807
|
+
mockApi(fetchMock, { functionsPreview: { error: { code: 'functions_deployment_error', message: 'Preview failed' } } });
|
|
696
808
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
697
809
|
expect(logOutput(logSpy)).toContain('Error checking functions state:\nPreview failed');
|
|
698
|
-
expect(fetchMock).
|
|
810
|
+
expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
|
|
699
811
|
});
|
|
700
812
|
it('prints Function deploy errors', async () => {
|
|
701
813
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
702
|
-
fetchMock
|
|
703
|
-
.mockResolvedValueOnce(jsonResponse(noFunctionChanges))
|
|
704
|
-
.mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Apply failed' } }));
|
|
814
|
+
mockApi(fetchMock, { functionsDeploy: { error: { code: 'functions_deployment_error', message: 'Apply failed' } } });
|
|
705
815
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
706
816
|
expect(logOutput(logSpy)).toContain('Error deploying functions');
|
|
707
817
|
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
708
|
-
expect(fetchMock).
|
|
818
|
+
expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.functionsDeploy]);
|
|
709
819
|
});
|
|
710
820
|
it('does not deploy legacy scripts when the preview fails', async () => {
|
|
711
821
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
712
|
-
fetchMock
|
|
822
|
+
mockApi(fetchMock, { legacyPreview: { error: { code: 'server_error', message: 'Preview failed' } } });
|
|
713
823
|
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
714
824
|
expect(result.isErr()).toBe(true);
|
|
715
|
-
expect(fetchMock).
|
|
825
|
+
expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
|
|
716
826
|
expect(logOutput(logSpy)).toContain('Error checking state:\nPreview failed');
|
|
717
827
|
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
718
828
|
});
|
|
829
|
+
it('does not deploy when the selected integration does not exist', async () => {
|
|
830
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
831
|
+
mockApi(fetchMock, { functionsPreview: { error: { code: 'integration_not_found', message: 'Integration(s) not found: unknown' } } });
|
|
832
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ integration: 'unknown' }) });
|
|
833
|
+
expect(result.isErr()).toBe(true);
|
|
834
|
+
expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
|
|
835
|
+
expect(logOutput(logSpy)).toContain('Integration(s) not found: unknown');
|
|
836
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
837
|
+
});
|
|
719
838
|
it('reports legacy deployment errors', async () => {
|
|
720
839
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
721
|
-
fetchMock
|
|
722
|
-
.mockResolvedValueOnce(jsonResponse(legacyConfirmation))
|
|
723
|
-
.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Apply failed' } }));
|
|
840
|
+
mockApi(fetchMock, { legacyDeploy: { error: { code: 'server_error', message: 'Apply failed' } } });
|
|
724
841
|
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
725
842
|
expect(result.isErr()).toBe(true);
|
|
726
|
-
expect(fetchMock).toHaveBeenCalledTimes(
|
|
843
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
727
844
|
expect(logOutput(logSpy)).toContain('Error deploying:\nApply failed');
|
|
728
845
|
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
729
846
|
});
|
|
@@ -732,34 +849,34 @@ describe('deploy', () => {
|
|
|
732
849
|
{ name: 'a model', changes: { deletedModels: ['RemovedModel'] } }
|
|
733
850
|
])('requires destructive confirmation when removing $name', async ({ changes }) => {
|
|
734
851
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
735
|
-
fetchMock
|
|
852
|
+
mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, ...changes } });
|
|
736
853
|
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
737
854
|
expect(result.isErr()).toBe(true);
|
|
738
|
-
expect(fetchMock).toHaveBeenCalledTimes(
|
|
855
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
739
856
|
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
740
857
|
});
|
|
741
858
|
it('deploys legacy destructive changes when confirmation flags are provided', async () => {
|
|
742
859
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
743
|
-
fetchMock
|
|
860
|
+
mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
|
|
744
861
|
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
745
862
|
expect(result.isOk()).toBe(true);
|
|
746
|
-
expect(fetchMock).
|
|
863
|
+
expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.legacyDeploy]);
|
|
747
864
|
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
748
865
|
expect(logOutput(logSpy)).toContain('allowDestructive flag is on');
|
|
749
866
|
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
750
867
|
});
|
|
751
868
|
it('explains the required legacy confirmation flags when non-interactive', async () => {
|
|
752
869
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
753
|
-
fetchMock
|
|
870
|
+
mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
|
|
754
871
|
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
755
872
|
expect(result.isErr()).toBe(true);
|
|
756
873
|
expect(logOutput(logSpy)).toContain('Nothing was deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting');
|
|
757
874
|
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
758
|
-
expect(fetchMock).toHaveBeenCalledTimes(
|
|
875
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
759
876
|
});
|
|
760
877
|
it('prints the legacy abort message when confirmation is declined', async () => {
|
|
761
878
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
762
|
-
fetchMock
|
|
879
|
+
mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
|
|
763
880
|
const result = await deploy({
|
|
764
881
|
fullPath,
|
|
765
882
|
environmentName: 'test',
|
|
@@ -769,58 +886,57 @@ describe('deploy', () => {
|
|
|
769
886
|
expect(result.isErr()).toBe(true);
|
|
770
887
|
expect(logOutput(logSpy)).toContain('Deploy aborted. Exiting');
|
|
771
888
|
expect(promptly.confirm).toHaveBeenCalledTimes(1);
|
|
772
|
-
expect(fetchMock).toHaveBeenCalledTimes(
|
|
889
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
773
890
|
});
|
|
774
891
|
it('preserves the legacy version override in both preview and deployment', async () => {
|
|
775
892
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
776
|
-
fetchMock
|
|
893
|
+
mockApi(fetchMock);
|
|
777
894
|
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ version: '42' }) });
|
|
778
895
|
expect(result.isOk()).toBe(true);
|
|
779
|
-
expect(fetchMock).
|
|
780
|
-
const
|
|
781
|
-
const deployRequest = fetchMock.mock.calls[1]?.[1];
|
|
782
|
-
const previewBody = JSON.parse(previewRequest.body);
|
|
783
|
-
const deployBody = JSON.parse(deployRequest.body);
|
|
896
|
+
expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.legacyDeploy]);
|
|
897
|
+
const previewBody = requestBody(fetchMock, 'legacyPreview');
|
|
784
898
|
expect(previewBody).toMatchObject({ reconcile: false, flowConfigs: [{ version: '42' }] });
|
|
785
|
-
expect(
|
|
899
|
+
expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ reconcile: true, flowConfigs: previewBody?.['flowConfigs'] });
|
|
786
900
|
expect(logOutput(logSpy)).not.toContain('The --version option');
|
|
787
901
|
});
|
|
788
902
|
it.each(['preview', 'deploy'])('prints backend validation details during function %s', async (stage) => {
|
|
789
903
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
904
|
+
mockApi(fetchMock, {
|
|
905
|
+
functionsPreview: noFunctionChanges,
|
|
906
|
+
[stage === 'preview' ? 'functionsPreview' : 'functionsDeploy']: {
|
|
907
|
+
error: {
|
|
908
|
+
code: 'invalid_body',
|
|
909
|
+
errors: [
|
|
910
|
+
{ code: 'invalid_union', path: ['functions', 0, 'tags', 'required_scopes'], message: 'Expected a structured scope requirement' },
|
|
911
|
+
{ code: 'custom', path: [], message: 'Another validation error' }
|
|
912
|
+
]
|
|
913
|
+
}
|
|
800
914
|
}
|
|
801
|
-
})
|
|
915
|
+
});
|
|
802
916
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
803
917
|
const output = logOutput(logSpy);
|
|
804
918
|
expect(output).toContain('invalid_body');
|
|
805
919
|
expect(output).toContain('- functions.0.tags.required_scopes: Expected a structured scope requirement');
|
|
806
920
|
expect(output).toContain('- Another validation error');
|
|
807
921
|
expect(output).not.toContain('✓ Deployed');
|
|
808
|
-
expect(fetchMock).toHaveBeenCalledTimes(stage === 'preview' ?
|
|
922
|
+
expect(fetchMock).toHaveBeenCalledTimes(stage === 'preview' ? 2 : 3);
|
|
809
923
|
});
|
|
810
924
|
it('prints an aborted deployment when function deletions are not allowed', async () => {
|
|
811
925
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
812
|
-
fetchMock
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
926
|
+
mockApi(fetchMock, {
|
|
927
|
+
functionsPreview: {
|
|
928
|
+
created: [],
|
|
929
|
+
updated: [],
|
|
930
|
+
unchanged: [{ integrationId: 'github', name: 'fetch' }],
|
|
931
|
+
deleted: [{ integrationId: 'github', name: 'removed' }]
|
|
932
|
+
}
|
|
933
|
+
});
|
|
818
934
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
819
935
|
const output = logOutput(logSpy);
|
|
820
936
|
expect(output).toContain('↳ Functions');
|
|
821
937
|
expect(output).toContain('Nothing was deployed.');
|
|
822
938
|
expect(output).not.toContain('✓ Deployed');
|
|
823
|
-
expect(fetchMock).toHaveBeenCalledTimes(
|
|
939
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
824
940
|
});
|
|
825
941
|
it.each([false, true])('reports function removals alongside updates: %s', async (withUpdates) => {
|
|
826
942
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
@@ -830,7 +946,7 @@ describe('deploy', () => {
|
|
|
830
946
|
unchanged: [{ integrationId: 'github', name: 'fetch' }],
|
|
831
947
|
deleted: [{ integrationId: 'github', name: 'removed' }]
|
|
832
948
|
};
|
|
833
|
-
fetchMock
|
|
949
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
834
950
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
835
951
|
const output = logOutput(logSpy);
|
|
836
952
|
expect(output).toContain('allowDestructive flag is on');
|
|
@@ -854,7 +970,7 @@ describe('deploy', () => {
|
|
|
854
970
|
deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }],
|
|
855
971
|
deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
|
|
856
972
|
};
|
|
857
|
-
fetchMock
|
|
973
|
+
mockApi(fetchMock, { legacyPreview: changes, legacyDeploy: withDeployments ? legacyDeploymentResult : [] });
|
|
858
974
|
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
859
975
|
const output = logOutput(logSpy);
|
|
860
976
|
expect(result.isOk()).toBe(true);
|
|
@@ -916,6 +1032,28 @@ async function writeProject(fullPath) {
|
|
|
916
1032
|
function jsonResponse(value) {
|
|
917
1033
|
return { json: vi.fn().mockResolvedValue(value) };
|
|
918
1034
|
}
|
|
1035
|
+
function mockApi(fetchMock, responses = {}) {
|
|
1036
|
+
const defaults = {
|
|
1037
|
+
legacyPreview: legacyConfirmation,
|
|
1038
|
+
legacyDeploy: [],
|
|
1039
|
+
functionsPreview: emptyFunctionChanges,
|
|
1040
|
+
functionsDeploy: emptyFunctionChanges
|
|
1041
|
+
};
|
|
1042
|
+
fetchMock.mockImplementation((url) => {
|
|
1043
|
+
const route = Object.keys(apiRoutes).find((key) => apiRoutes[key] === url.pathname);
|
|
1044
|
+
if (!route) {
|
|
1045
|
+
return Promise.reject(new Error(`Unexpected request to ${url.pathname}`));
|
|
1046
|
+
}
|
|
1047
|
+
return Promise.resolve(jsonResponse(responses[route] ?? defaults[route]));
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
function requestPaths(fetchMock) {
|
|
1051
|
+
return fetchMock.mock.calls.map(([url]) => url.pathname);
|
|
1052
|
+
}
|
|
1053
|
+
function requestBody(fetchMock, route) {
|
|
1054
|
+
const call = fetchMock.mock.calls.find(([url]) => url.pathname === apiRoutes[route]);
|
|
1055
|
+
return call ? JSON.parse(call[1].body) : undefined;
|
|
1056
|
+
}
|
|
919
1057
|
function logOutput(logSpy) {
|
|
920
1058
|
const calls = logSpy.mock.calls;
|
|
921
1059
|
return calls.map((args) => args.map(String).join(' ')).join('\n');
|