nango 0.72.1 → 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 +37 -23
- package/dist/index.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/zeroYaml/compile.unit.cli-test.js +9 -0
- package/dist/zeroYaml/compile.unit.cli-test.js.map +1 -1
- package/dist/zeroYaml/definitions.d.ts +8 -1
- package/dist/zeroYaml/definitions.js +12 -1
- package/dist/zeroYaml/definitions.js.map +1 -1
- package/dist/zeroYaml/deploy.d.ts +8 -2
- package/dist/zeroYaml/deploy.js +277 -263
- package/dist/zeroYaml/deploy.js.map +1 -1
- package/dist/zeroYaml/deploy.unit.cli-test.js +747 -101
- 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
|
@@ -11,36 +11,40 @@ vi.mock('promptly', () => ({ default: { confirm: vi.fn() } }));
|
|
|
11
11
|
vi.mock('./definitions.js', () => ({ parseIntegrationDefinitions: vi.fn() }));
|
|
12
12
|
const githubFunction = functionConfig({ integrationId: 'github', name: 'fetch' });
|
|
13
13
|
const slackFunction = functionConfig({ integrationId: 'slack', name: 'other' });
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
const githubIntegration = {
|
|
15
|
+
providerConfigKey: 'github',
|
|
16
|
+
syncs: [],
|
|
17
|
+
actions: [
|
|
18
18
|
{
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
usedModels: [],
|
|
31
|
-
version: '',
|
|
32
|
-
json_schema: { definitions: {} },
|
|
33
|
-
features: []
|
|
34
|
-
}
|
|
35
|
-
],
|
|
36
|
-
onEventScripts: {
|
|
37
|
-
'post-connection-creation': [],
|
|
38
|
-
'pre-connection-deletion': [],
|
|
39
|
-
'validate-connection': []
|
|
40
|
-
}
|
|
19
|
+
name: 'legacy',
|
|
20
|
+
type: 'action',
|
|
21
|
+
description: 'Legacy action',
|
|
22
|
+
input: null,
|
|
23
|
+
output: [],
|
|
24
|
+
endpoint: null,
|
|
25
|
+
scopes: [],
|
|
26
|
+
usedModels: [],
|
|
27
|
+
version: '',
|
|
28
|
+
json_schema: { definitions: {} },
|
|
29
|
+
features: []
|
|
41
30
|
}
|
|
42
31
|
],
|
|
32
|
+
onEventScripts: {
|
|
33
|
+
'post-connection-creation': [],
|
|
34
|
+
'pre-connection-deletion': [],
|
|
35
|
+
'validate-connection': []
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
const parsed = {
|
|
39
|
+
yamlVersion: 'v2',
|
|
40
|
+
models: new Map(),
|
|
41
|
+
integrations: [githubIntegration],
|
|
43
42
|
functions: [githubFunction, slackFunction],
|
|
43
|
+
entryPoints: [
|
|
44
|
+
{ filePath: './github/actions/legacy.ts', integrationId: 'github', type: 'action', name: 'legacy' },
|
|
45
|
+
{ filePath: githubFunction.filePath, integrationId: 'github', type: 'function', name: 'fetch' },
|
|
46
|
+
{ filePath: slackFunction.filePath, integrationId: 'slack', type: 'function', name: 'other' }
|
|
47
|
+
],
|
|
44
48
|
hasExperimentalFunctions: true
|
|
45
49
|
};
|
|
46
50
|
const legacyConfirmation = {
|
|
@@ -59,14 +63,51 @@ const legacyChangeConfirmation = {
|
|
|
59
63
|
...legacyConfirmation,
|
|
60
64
|
updatedActions: [{ providerConfigKey: 'github', name: 'legacy' }]
|
|
61
65
|
};
|
|
66
|
+
const legacyDeploymentResult = [{ name: 'legacy', version: '42', providerConfigKey: 'github', type: 'action', models: [] }];
|
|
62
67
|
const legacyOnlyParsed = {
|
|
63
68
|
...parsed,
|
|
64
69
|
functions: [],
|
|
70
|
+
entryPoints: parsed.entryPoints.filter((entryPoint) => entryPoint.type !== 'function'),
|
|
65
71
|
hasExperimentalFunctions: false
|
|
66
72
|
};
|
|
67
73
|
const functionsOnlyParsed = {
|
|
68
74
|
...parsed,
|
|
69
|
-
integrations: parsed.integrations.map((integration) => ({ ...integration, syncs: [], actions: [] }))
|
|
75
|
+
integrations: parsed.integrations.map((integration) => ({ ...integration, syncs: [], actions: [] })),
|
|
76
|
+
entryPoints: parsed.entryPoints.filter((entryPoint) => entryPoint.type === 'function')
|
|
77
|
+
};
|
|
78
|
+
const multiIntegrationLegacyParsed = {
|
|
79
|
+
...legacyOnlyParsed,
|
|
80
|
+
integrations: [
|
|
81
|
+
{
|
|
82
|
+
...githubIntegration,
|
|
83
|
+
syncs: [
|
|
84
|
+
{
|
|
85
|
+
name: 'contacts',
|
|
86
|
+
type: 'sync',
|
|
87
|
+
endpoints: [],
|
|
88
|
+
description: 'Contacts',
|
|
89
|
+
sync_type: 'full',
|
|
90
|
+
track_deletes: false,
|
|
91
|
+
auto_start: true,
|
|
92
|
+
runs: 'every day',
|
|
93
|
+
scopes: [],
|
|
94
|
+
input: null,
|
|
95
|
+
output: [],
|
|
96
|
+
usedModels: [],
|
|
97
|
+
webhookSubscriptions: [],
|
|
98
|
+
version: '',
|
|
99
|
+
json_schema: { definitions: {} },
|
|
100
|
+
features: []
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
{ ...githubIntegration, providerConfigKey: 'slack' }
|
|
105
|
+
],
|
|
106
|
+
entryPoints: [
|
|
107
|
+
{ filePath: './github/actions/legacy.ts', integrationId: 'github', type: 'action', name: 'legacy' },
|
|
108
|
+
{ filePath: './github/syncs/contacts.ts', integrationId: 'github', type: 'sync', name: 'contacts' },
|
|
109
|
+
{ filePath: './slack/actions/legacy.ts', integrationId: 'slack', type: 'action', name: 'legacy' }
|
|
110
|
+
]
|
|
70
111
|
};
|
|
71
112
|
const noFunctionChanges = {
|
|
72
113
|
created: [],
|
|
@@ -74,6 +115,13 @@ const noFunctionChanges = {
|
|
|
74
115
|
unchanged: [{ integrationId: 'github', name: 'fetch' }],
|
|
75
116
|
deleted: []
|
|
76
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
|
+
};
|
|
77
125
|
describe('deploy', () => {
|
|
78
126
|
let fullPath;
|
|
79
127
|
let fetchMock;
|
|
@@ -99,11 +147,209 @@ describe('deploy', () => {
|
|
|
99
147
|
vi.restoreAllMocks();
|
|
100
148
|
await fs.rm(fullPath, { recursive: true, force: true });
|
|
101
149
|
});
|
|
102
|
-
it('
|
|
103
|
-
|
|
150
|
+
it('previews both families before deploying them in order with one summary', async () => {
|
|
151
|
+
const functionChanges = { ...noFunctionChanges, updated: [{ integrationId: 'github', name: 'fetch' }] };
|
|
152
|
+
fetchMock
|
|
153
|
+
.mockResolvedValueOnce(jsonResponse({ ...legacyChangeConfirmation, deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }] }))
|
|
154
|
+
.mockResolvedValueOnce(jsonResponse(functionChanges))
|
|
155
|
+
.mockResolvedValueOnce(jsonResponse(legacyDeploymentResult))
|
|
156
|
+
.mockResolvedValueOnce(jsonResponse(functionChanges));
|
|
157
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
158
|
+
expect(result.isOk()).toBe(true);
|
|
159
|
+
expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual([
|
|
160
|
+
'/sync/deploy/confirmation',
|
|
161
|
+
'/functions/deployments/bundle/preview',
|
|
162
|
+
'/sync/deploy',
|
|
163
|
+
'/functions/deployments/bundle'
|
|
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');
|
|
169
|
+
const output = logOutput(logSpy);
|
|
170
|
+
expect(output.match(/Nango will perform this plan:/g)).toHaveLength(1);
|
|
171
|
+
expect(output.match(/Summary/g)).toHaveLength(1);
|
|
172
|
+
expect(output).toContain('↳ Actions');
|
|
173
|
+
expect(output).toContain('↳ Functions');
|
|
174
|
+
expect(output).toContain('✓ Deployed');
|
|
175
|
+
expect(output.match(/Successfully deployed:/g)).toHaveLength(1);
|
|
176
|
+
expect(output).toContain('- github → legacy@v42');
|
|
177
|
+
expect(output).toContain('- github → fetch');
|
|
178
|
+
expect(output).toContain('Successfully removed:');
|
|
179
|
+
expect(output).toContain('- github → old-action');
|
|
180
|
+
expect(output.indexOf('✓ Deployed')).toBeLessThan(output.indexOf('Successfully deployed:'));
|
|
181
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
it('scopes both families to the selected integration and only overrides sync/action versions', async () => {
|
|
184
|
+
mockApi(fetchMock, {
|
|
185
|
+
legacyPreview: legacyChangeConfirmation,
|
|
186
|
+
legacyDeploy: legacyDeploymentResult,
|
|
187
|
+
functionsPreview: noFunctionChanges,
|
|
188
|
+
functionsDeploy: noFunctionChanges
|
|
189
|
+
});
|
|
190
|
+
const result = await deploy({
|
|
191
|
+
fullPath,
|
|
192
|
+
environmentName: 'test',
|
|
193
|
+
interactive: false,
|
|
194
|
+
options: deployOptions({ integration: 'github', version: '42' })
|
|
195
|
+
});
|
|
196
|
+
expect(result.isOk()).toBe(true);
|
|
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({
|
|
204
|
+
reconciliationScope: { kind: 'integration', integrationId: 'github' },
|
|
205
|
+
functions: [{ integrationId: 'github', name: 'fetch' }]
|
|
206
|
+
});
|
|
207
|
+
expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ flowConfigs: legacyPreviewBody?.['flowConfigs'] });
|
|
208
|
+
expect(requestBody(fetchMock, 'functionsDeploy')).toEqual(functionsPreviewBody);
|
|
209
|
+
expect(logOutput(logSpy).match(/The --version option/g)).toHaveLength(1);
|
|
210
|
+
expect(logOutput(logSpy).indexOf('✓ Packaging')).toBeLessThan(logOutput(logSpy).indexOf('The --version option'));
|
|
211
|
+
expect(logOutput(logSpy)).not.toContain('No changes');
|
|
212
|
+
expect(logOutput(logSpy)).toContain('- github → legacy@v42');
|
|
213
|
+
});
|
|
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' }) });
|
|
216
|
+
expect(result.isErr()).toBe(true);
|
|
217
|
+
expect(logOutput(logSpy)).toContain('The --sync and --action options cannot be used together');
|
|
218
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
it.each(['legacy', 'functions'])('does not contact the API when %s packaging fails in a mixed project', async (family) => {
|
|
221
|
+
if (family === 'legacy') {
|
|
222
|
+
await fs.rm(path.join(fullPath, 'github', 'actions', 'legacy.ts'));
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
await fs.rm(path.join(fullPath, 'build', 'github_functions_fetch.cjs'));
|
|
226
|
+
}
|
|
227
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
228
|
+
expect(result.isErr()).toBe(true);
|
|
229
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
it.each(['legacy', 'functions'])('does not deploy either family when the %s preview fails', async (family) => {
|
|
232
|
+
const previewError = jsonResponse({ error: { code: 'server_error', message: 'Preview failed' } });
|
|
233
|
+
fetchMock
|
|
234
|
+
.mockResolvedValueOnce(family === 'legacy' ? previewError : jsonResponse(legacyConfirmation))
|
|
235
|
+
.mockResolvedValueOnce(family === 'functions' ? previewError : jsonResponse(noFunctionChanges));
|
|
236
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
237
|
+
expect(result.isErr()).toBe(true);
|
|
238
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
239
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
240
|
+
});
|
|
241
|
+
it.each(['legacy', 'functions'])('clearly reports each outcome when the %s deployment fails', async (family) => {
|
|
242
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse(noFunctionChanges));
|
|
243
|
+
if (family === 'functions') {
|
|
244
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyDeploymentResult)).mockRejectedValueOnce(new Error('Connection lost'));
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Apply failed' } }));
|
|
248
|
+
}
|
|
249
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
250
|
+
expect(result.isErr()).toBe(true);
|
|
251
|
+
expect(fetchMock).toHaveBeenCalledTimes(family === 'legacy' ? 3 : 4);
|
|
104
252
|
const output = logOutput(logSpy);
|
|
105
|
-
expect(output).toContain(
|
|
253
|
+
expect(output).toContain(`Syncs/actions/on-events: ${family === 'legacy' ? 'failed' : 'completed'}`);
|
|
254
|
+
expect(output).toContain(`Functions: ${family === 'legacy' ? 'not attempted' : 'failed'}`);
|
|
255
|
+
expect(output).toContain(family === 'legacy' ? 'Deployment failed.' : 'Deployment partially completed.');
|
|
256
|
+
expect(output).toContain('The failed request may have applied changes');
|
|
106
257
|
expect(output).not.toContain('✓ Deployed');
|
|
258
|
+
if (family === 'functions') {
|
|
259
|
+
expect(output).toContain('- github → legacy@v42');
|
|
260
|
+
expect(output.indexOf('✗ Deploying')).toBeLessThan(output.indexOf('Successfully deployed:'));
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
it.each([
|
|
264
|
+
{
|
|
265
|
+
name: 'interactive approval',
|
|
266
|
+
interactive: true,
|
|
267
|
+
autoConfirm: false,
|
|
268
|
+
allowDestructive: false,
|
|
269
|
+
accepted: true,
|
|
270
|
+
succeeds: true,
|
|
271
|
+
prompts: 1
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: 'interactive rejection',
|
|
275
|
+
interactive: true,
|
|
276
|
+
autoConfirm: false,
|
|
277
|
+
allowDestructive: false,
|
|
278
|
+
accepted: false,
|
|
279
|
+
succeeds: false,
|
|
280
|
+
prompts: 1
|
|
281
|
+
},
|
|
282
|
+
{
|
|
283
|
+
name: 'noninteractive auto-confirm alone',
|
|
284
|
+
interactive: false,
|
|
285
|
+
autoConfirm: true,
|
|
286
|
+
allowDestructive: false,
|
|
287
|
+
accepted: false,
|
|
288
|
+
succeeds: false,
|
|
289
|
+
prompts: 0
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: 'noninteractive allow-destructive alone',
|
|
293
|
+
interactive: false,
|
|
294
|
+
autoConfirm: false,
|
|
295
|
+
allowDestructive: true,
|
|
296
|
+
accepted: false,
|
|
297
|
+
succeeds: false,
|
|
298
|
+
prompts: 0
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
name: 'noninteractive approval flags',
|
|
302
|
+
interactive: false,
|
|
303
|
+
autoConfirm: true,
|
|
304
|
+
allowDestructive: true,
|
|
305
|
+
accepted: false,
|
|
306
|
+
succeeds: true,
|
|
307
|
+
prompts: 0
|
|
308
|
+
}
|
|
309
|
+
])('handles mixed destructive changes with $name', async ({ interactive, autoConfirm, allowDestructive, accepted, succeeds, prompts }) => {
|
|
310
|
+
const changes = { ...noFunctionChanges, deleted: [{ integrationId: 'github', name: 'removed' }] };
|
|
311
|
+
fetchMock
|
|
312
|
+
.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }))
|
|
313
|
+
.mockResolvedValueOnce(jsonResponse(changes))
|
|
314
|
+
.mockResolvedValueOnce(jsonResponse([]))
|
|
315
|
+
.mockResolvedValueOnce(jsonResponse(changes));
|
|
316
|
+
vi.mocked(promptly.confirm).mockImplementationOnce(() => {
|
|
317
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
318
|
+
expect(logOutput(logSpy)).toContain('↳ Actions');
|
|
319
|
+
expect(logOutput(logSpy)).toContain('↳ Functions');
|
|
320
|
+
return Promise.resolve(accepted);
|
|
321
|
+
});
|
|
322
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive, options: deployOptions({ interactive, autoConfirm, allowDestructive }) });
|
|
323
|
+
expect(result.isOk()).toBe(succeeds);
|
|
324
|
+
expect(promptly.confirm).toHaveBeenCalledTimes(prompts);
|
|
325
|
+
expect(fetchMock).toHaveBeenCalledTimes(succeeds ? 4 : 2);
|
|
326
|
+
});
|
|
327
|
+
it.each(['legacy', 'functions'])('requires confirmation when only %s changes are destructive', async (family) => {
|
|
328
|
+
fetchMock
|
|
329
|
+
.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: family === 'legacy' ? ['RemovedModel'] : [] }))
|
|
330
|
+
.mockResolvedValueOnce(jsonResponse({ ...noFunctionChanges, deleted: family === 'functions' ? [{ integrationId: 'github', name: 'removed' }] : [] }));
|
|
331
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
332
|
+
expect(result.isErr()).toBe(true);
|
|
333
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
334
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
it('accepts the environment auto-confirm setting for a mixed deployment', async () => {
|
|
337
|
+
vi.stubEnv('NANGO_DEPLOY_AUTO_CONFIRM', 'true');
|
|
338
|
+
const changes = { ...noFunctionChanges, deleted: [{ integrationId: 'github', name: 'removed' }] };
|
|
339
|
+
fetchMock
|
|
340
|
+
.mockResolvedValueOnce(jsonResponse(legacyConfirmation))
|
|
341
|
+
.mockResolvedValueOnce(jsonResponse(changes))
|
|
342
|
+
.mockResolvedValueOnce(jsonResponse([]))
|
|
343
|
+
.mockResolvedValueOnce(jsonResponse(changes));
|
|
344
|
+
const result = await deploy({
|
|
345
|
+
fullPath,
|
|
346
|
+
environmentName: 'test',
|
|
347
|
+
interactive: false,
|
|
348
|
+
options: deployOptions({ autoConfirm: false, allowDestructive: true })
|
|
349
|
+
});
|
|
350
|
+
expect(result.isOk()).toBe(true);
|
|
351
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
352
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
107
353
|
});
|
|
108
354
|
it('prints only legacy summary rows for a single action deploy', async () => {
|
|
109
355
|
fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
@@ -119,6 +365,204 @@ describe('deploy', () => {
|
|
|
119
365
|
expect(output).toContain('↳ OnEvents');
|
|
120
366
|
expect(output).not.toContain('↳ Functions');
|
|
121
367
|
expect(output).toContain('✓ Deployed');
|
|
368
|
+
expect(output).toContain('No destructive changes, proceeding without confirmation');
|
|
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');
|
|
373
|
+
});
|
|
374
|
+
describe('with a file', () => {
|
|
375
|
+
const functionChanges = {
|
|
376
|
+
created: [{ integrationId: 'github', name: 'fetch' }],
|
|
377
|
+
updated: [],
|
|
378
|
+
unchanged: [],
|
|
379
|
+
deleted: []
|
|
380
|
+
};
|
|
381
|
+
it.each([
|
|
382
|
+
{ label: 'a relative path', file: () => 'github/functions/fetch.ts' },
|
|
383
|
+
{ label: 'a ./ relative path', file: () => './github/functions/fetch.ts' },
|
|
384
|
+
{ label: 'a path with backslashes', file: () => 'github\\functions\\fetch.ts' },
|
|
385
|
+
{ label: 'a .js path', file: () => 'github/functions/fetch.js' },
|
|
386
|
+
{ label: 'an absolute path', file: () => path.join(fullPath, 'github', 'functions', 'fetch.ts') }
|
|
387
|
+
])('deploys only that function from $label in a mixed project', async ({ file }) => {
|
|
388
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
|
|
389
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: file() });
|
|
390
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
391
|
+
for (const [, request] of fetchMock.mock.calls) {
|
|
392
|
+
const body = JSON.parse(request.body);
|
|
393
|
+
expect(body.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'fetch' });
|
|
394
|
+
expect(body.functions).toEqual([expect.objectContaining({ integrationId: 'github', name: 'fetch' })]);
|
|
395
|
+
}
|
|
396
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
397
|
+
});
|
|
398
|
+
it('deploys a tool under its declared name', async () => {
|
|
399
|
+
const toolConfig = { ...functionConfig({ integrationId: 'github', name: 'get_issue' }), filePath: './github/tools/getIssue.ts' };
|
|
400
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({
|
|
401
|
+
...parsed,
|
|
402
|
+
functions: [githubFunction, toolConfig],
|
|
403
|
+
entryPoints: [...parsed.entryPoints, { filePath: toolConfig.filePath, integrationId: 'github', type: 'function', name: 'get_issue' }]
|
|
404
|
+
}));
|
|
405
|
+
await fs.mkdir(path.join(fullPath, 'github', 'tools'), { recursive: true });
|
|
406
|
+
await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction, toolConfig]));
|
|
407
|
+
await fs.writeFile(path.join(fullPath, 'github', 'tools', 'getIssue.ts'), 'source tool');
|
|
408
|
+
await fs.writeFile(path.join(fullPath, 'build', 'github_tools_getIssue.cjs'), 'compiled tool');
|
|
409
|
+
const toolChanges = { ...functionChanges, created: [{ integrationId: 'github', name: 'get_issue' }] };
|
|
410
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(toolChanges)).mockResolvedValueOnce(jsonResponse(toolChanges));
|
|
411
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/tools/getIssue.ts' });
|
|
412
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
413
|
+
const previewRequest = fetchMock.mock.calls[0]?.[1];
|
|
414
|
+
const previewBody = JSON.parse(previewRequest.body);
|
|
415
|
+
expect(previewBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'get_issue' });
|
|
416
|
+
expect(previewBody.functions).toEqual([expect.objectContaining({ name: 'get_issue', fileBody: { js: 'compiled tool', ts: 'source tool' } })]);
|
|
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
|
+
});
|
|
431
|
+
it('deploys a legacy action as a single script', async () => {
|
|
432
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
433
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/actions/legacy.ts' });
|
|
434
|
+
const output = logOutput(logSpy);
|
|
435
|
+
expect(output).toContain('✓ Deployed');
|
|
436
|
+
expect(output).not.toContain('--sync and --action are deprecated');
|
|
437
|
+
const confirmationRequest = fetchMock.mock.calls[0]?.[1];
|
|
438
|
+
const confirmationBody = JSON.parse(confirmationRequest.body);
|
|
439
|
+
expect(confirmationBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'legacy' });
|
|
440
|
+
expect(confirmationBody.onEventScriptsByProvider).toBeUndefined();
|
|
441
|
+
expect(confirmationBody.flowConfigs).toEqual([expect.objectContaining({ syncName: 'legacy', providerConfigKey: 'github', type: 'action' })]);
|
|
442
|
+
});
|
|
443
|
+
describe('in a project with legacy scripts sharing names across integrations', () => {
|
|
444
|
+
beforeEach(async () => {
|
|
445
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(multiIntegrationLegacyParsed));
|
|
446
|
+
await fs.mkdir(path.join(fullPath, 'github', 'syncs'), { recursive: true });
|
|
447
|
+
await fs.mkdir(path.join(fullPath, 'slack', 'actions'), { recursive: true });
|
|
448
|
+
await fs.writeFile(path.join(fullPath, 'build', 'github_syncs_contacts.cjs'), 'compiled contacts');
|
|
449
|
+
await fs.writeFile(path.join(fullPath, 'github', 'syncs', 'contacts.ts'), 'source contacts');
|
|
450
|
+
await fs.writeFile(path.join(fullPath, 'build', 'slack_actions_legacy.cjs'), 'compiled slack legacy');
|
|
451
|
+
await fs.writeFile(path.join(fullPath, 'slack', 'actions', 'legacy.ts'), 'source slack legacy');
|
|
452
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
453
|
+
});
|
|
454
|
+
it('deploys a legacy sync as a single script', async () => {
|
|
455
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/syncs/contacts.ts' });
|
|
456
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
457
|
+
const confirmationRequest = fetchMock.mock.calls[0]?.[1];
|
|
458
|
+
const confirmationBody = JSON.parse(confirmationRequest.body);
|
|
459
|
+
expect(confirmationBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'contacts' });
|
|
460
|
+
expect(confirmationBody.flowConfigs).toEqual([
|
|
461
|
+
expect.objectContaining({
|
|
462
|
+
syncName: 'contacts',
|
|
463
|
+
providerConfigKey: 'github',
|
|
464
|
+
type: 'sync',
|
|
465
|
+
fileBody: { js: 'compiled contacts', ts: 'source contacts' }
|
|
466
|
+
})
|
|
467
|
+
]);
|
|
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
|
+
});
|
|
489
|
+
it("deploys only the file's integration when another integration has an action with the same name", async () => {
|
|
490
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'slack/actions/legacy.ts' });
|
|
491
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
492
|
+
const confirmationRequest = fetchMock.mock.calls[0]?.[1];
|
|
493
|
+
const confirmationBody = JSON.parse(confirmationRequest.body);
|
|
494
|
+
expect(confirmationBody.flowConfigs).toEqual([
|
|
495
|
+
expect.objectContaining({
|
|
496
|
+
syncName: 'legacy',
|
|
497
|
+
providerConfigKey: 'slack',
|
|
498
|
+
type: 'action',
|
|
499
|
+
fileBody: { js: 'compiled slack legacy', ts: 'source slack legacy' }
|
|
500
|
+
})
|
|
501
|
+
]);
|
|
502
|
+
});
|
|
503
|
+
});
|
|
504
|
+
it.each([
|
|
505
|
+
{ label: 'an unknown file', file: 'github/functions/missing.ts', message: "'github/functions/missing.ts' is not a script" },
|
|
506
|
+
{ label: 'a file outside the project', file: '../outside.ts', message: "'../outside.ts' must be inside the project folder" },
|
|
507
|
+
{ label: 'an absolute path outside the project', file: '/outside.ts', message: "'/outside.ts' must be inside the project folder" },
|
|
508
|
+
{
|
|
509
|
+
label: 'an on-event script',
|
|
510
|
+
file: 'github/on-events/cleanup.ts',
|
|
511
|
+
message: "can't be deployed on its own. Use `nango deploy <environment> --integration github`",
|
|
512
|
+
entryPoint: { filePath: './github/on-events/cleanup.ts', integrationId: 'github', type: 'on-event', name: 'cleanup' }
|
|
513
|
+
}
|
|
514
|
+
])('rejects $label', async ({ file, message, entryPoint }) => {
|
|
515
|
+
if (entryPoint) {
|
|
516
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...parsed, entryPoints: [...parsed.entryPoints, entryPoint] }));
|
|
517
|
+
}
|
|
518
|
+
const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file });
|
|
519
|
+
expect(res.isErr()).toBe(true);
|
|
520
|
+
expect(logOutput(logSpy)).toContain(message);
|
|
521
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
522
|
+
});
|
|
523
|
+
it.each([
|
|
524
|
+
{ flag: '--sync', options: { sync: 'legacy' } },
|
|
525
|
+
{ flag: '--action', options: { action: 'legacy' } },
|
|
526
|
+
{ flag: '--integration', options: { integration: 'github' } }
|
|
527
|
+
])('rejects a file combined with $flag', async ({ options }) => {
|
|
528
|
+
const res = await deploy({
|
|
529
|
+
fullPath,
|
|
530
|
+
environmentName: 'test',
|
|
531
|
+
interactive: false,
|
|
532
|
+
options: deployOptions(options),
|
|
533
|
+
file: 'github/functions/fetch.ts'
|
|
534
|
+
});
|
|
535
|
+
expect(res.isErr()).toBe(true);
|
|
536
|
+
expect(logOutput(logSpy)).toContain('A file cannot be combined with --sync, --action or --integration');
|
|
537
|
+
expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
|
|
538
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
539
|
+
});
|
|
540
|
+
it.each(['', ' '])('rejects an empty file argument %j instead of deploying everything', async (file) => {
|
|
541
|
+
const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file });
|
|
542
|
+
expect(res.isErr()).toBe(true);
|
|
543
|
+
expect(logOutput(logSpy)).toContain('The file argument is empty');
|
|
544
|
+
expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
|
|
545
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
546
|
+
});
|
|
547
|
+
it('rejects a file passed in place of the environment', async () => {
|
|
548
|
+
const res = await deploy({ fullPath, environmentName: 'github/syncs/fetchIssues.ts', interactive: false, options: deployOptions() });
|
|
549
|
+
expect(res.isErr()).toBe(true);
|
|
550
|
+
expect(logOutput(logSpy)).toContain("'github/syncs/fetchIssues.ts' is not a valid environment name");
|
|
551
|
+
expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
|
|
552
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
553
|
+
});
|
|
554
|
+
it('does not deploy when the server rejects the function scope', async () => {
|
|
555
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({
|
|
556
|
+
error: {
|
|
557
|
+
code: 'invalid_body',
|
|
558
|
+
errors: [{ code: 'invalid_union_discriminator', message: 'Invalid discriminator value', path: ['reconciliationScope', 'kind'] }]
|
|
559
|
+
}
|
|
560
|
+
}));
|
|
561
|
+
const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/functions/fetch.ts' });
|
|
562
|
+
expect(res.isErr()).toBe(true);
|
|
563
|
+
expect(logOutput(logSpy)).toContain('reconciliationScope.kind: Invalid discriminator value');
|
|
564
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
565
|
+
});
|
|
122
566
|
});
|
|
123
567
|
it.each([
|
|
124
568
|
{ flag: '--sync', options: { sync: 'missing' } },
|
|
@@ -137,7 +581,7 @@ describe('deploy', () => {
|
|
|
137
581
|
unchanged: [],
|
|
138
582
|
deleted: []
|
|
139
583
|
};
|
|
140
|
-
fetchMock
|
|
584
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
141
585
|
await deploy({
|
|
142
586
|
fullPath,
|
|
143
587
|
environmentName: 'test',
|
|
@@ -147,21 +591,24 @@ describe('deploy', () => {
|
|
|
147
591
|
const output = logOutput(logSpy);
|
|
148
592
|
expect(output).toContain('↳ Functions');
|
|
149
593
|
expect(output).toContain('✓ Deployed');
|
|
150
|
-
|
|
151
|
-
|
|
594
|
+
expect(requestBody(fetchMock, 'legacyPreview')).toMatchObject({
|
|
595
|
+
flowConfigs: [],
|
|
596
|
+
onEventScriptsByProvider: [],
|
|
597
|
+
reconciliationScope: { kind: 'integration', integrationId: 'slack' }
|
|
598
|
+
});
|
|
599
|
+
const previewBody = requestBody(fetchMock, 'functionsPreview');
|
|
152
600
|
expect(previewBody.reconciliationScope).toEqual({ kind: 'integration', integrationId: 'slack' });
|
|
153
601
|
expect(previewBody.functions).toEqual([expect.objectContaining({ integrationId: 'slack', name: 'other', trigger: { kind: 'http' } })]);
|
|
154
602
|
});
|
|
155
603
|
it('deletes the functions of an integration when its last function was removed', async () => {
|
|
156
604
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [githubFunction] }));
|
|
157
|
-
await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction]));
|
|
158
605
|
const functionChanges = {
|
|
159
606
|
created: [],
|
|
160
607
|
updated: [],
|
|
161
608
|
unchanged: [],
|
|
162
609
|
deleted: [{ integrationId: 'slack', name: 'other' }]
|
|
163
610
|
};
|
|
164
|
-
fetchMock
|
|
611
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
165
612
|
await deploy({
|
|
166
613
|
fullPath,
|
|
167
614
|
environmentName: 'test',
|
|
@@ -171,11 +618,17 @@ describe('deploy', () => {
|
|
|
171
618
|
const output = logOutput(logSpy);
|
|
172
619
|
expect(output).toContain('- slack → other');
|
|
173
620
|
expect(output).toContain('✓ Deployed');
|
|
174
|
-
expect(output).toContain('Successfully removed
|
|
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);
|
|
175
629
|
});
|
|
176
630
|
it('deletes every function of the environment when the last function was removed', async () => {
|
|
177
631
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
|
|
178
|
-
await fs.rm(path.join(fullPath, '.nango', 'functions.json'));
|
|
179
632
|
const functionChanges = {
|
|
180
633
|
created: [],
|
|
181
634
|
updated: [],
|
|
@@ -185,24 +638,87 @@ describe('deploy', () => {
|
|
|
185
638
|
{ integrationId: 'slack', name: 'other' }
|
|
186
639
|
]
|
|
187
640
|
};
|
|
188
|
-
fetchMock
|
|
641
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
189
642
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
190
643
|
const output = logOutput(logSpy);
|
|
191
644
|
expect(output).toContain('- github → fetch');
|
|
192
645
|
expect(output).toContain('- slack → other');
|
|
193
646
|
expect(output).toContain('✓ Deployed');
|
|
194
|
-
expect(output).toContain('Successfully removed
|
|
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
|
+
});
|
|
195
669
|
});
|
|
196
|
-
it('
|
|
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
|
+
});
|
|
701
|
+
});
|
|
702
|
+
it('warns about --version for function deployments and still deploys', async () => {
|
|
197
703
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
198
|
-
|
|
704
|
+
mockApi(fetchMock);
|
|
705
|
+
const result = await deploy({
|
|
199
706
|
fullPath,
|
|
200
707
|
environmentName: 'test',
|
|
201
708
|
interactive: false,
|
|
202
709
|
options: deployOptions({ version: 'v1' })
|
|
203
710
|
});
|
|
204
|
-
expect(
|
|
205
|
-
expect(
|
|
711
|
+
expect(result.isOk()).toBe(true);
|
|
712
|
+
expect(logOutput(logSpy)).toContain('The --version option only applies to syncs/actions; it has no effect on functions.');
|
|
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.');
|
|
206
722
|
});
|
|
207
723
|
it('prints only the Functions summary row for a functions-only project', async () => {
|
|
208
724
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
@@ -212,7 +728,7 @@ describe('deploy', () => {
|
|
|
212
728
|
unchanged: [{ integrationId: 'slack', name: 'other' }],
|
|
213
729
|
deleted: []
|
|
214
730
|
};
|
|
215
|
-
fetchMock
|
|
731
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
216
732
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
217
733
|
const output = logOutput(logSpy);
|
|
218
734
|
expect(output).not.toContain('↳ Syncs');
|
|
@@ -227,7 +743,6 @@ describe('deploy', () => {
|
|
|
227
743
|
const config = functionConfig({ integrationId: 'github.js', name: 'fetch' });
|
|
228
744
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [config] }));
|
|
229
745
|
await fs.mkdir(path.join(fullPath, 'github.js', 'functions'), { recursive: true });
|
|
230
|
-
await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([config]));
|
|
231
746
|
await fs.writeFile(path.join(fullPath, 'github.js', 'functions', 'fetch.ts'), 'source with dotted path');
|
|
232
747
|
await fs.writeFile(path.join(fullPath, 'build', 'github.js_functions_fetch.cjs'), 'compiled with dotted path');
|
|
233
748
|
const functionChanges = {
|
|
@@ -236,35 +751,20 @@ describe('deploy', () => {
|
|
|
236
751
|
unchanged: [],
|
|
237
752
|
deleted: []
|
|
238
753
|
};
|
|
239
|
-
fetchMock
|
|
754
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
240
755
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
241
756
|
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
242
|
-
const
|
|
243
|
-
const previewBody = JSON.parse(previewRequest.body);
|
|
757
|
+
const previewBody = requestBody(fetchMock, 'functionsPreview');
|
|
244
758
|
expect(previewBody.functions[0]?.fileBody).toEqual({ js: 'compiled with dotted path', ts: 'source with dotted path' });
|
|
245
759
|
});
|
|
246
|
-
it.each([
|
|
247
|
-
{ state: 'malformed', write: async (artifactPath) => await fs.writeFile(artifactPath, '{'.repeat(2)) },
|
|
248
|
-
{ state: 'not an array', write: async (artifactPath) => await fs.writeFile(artifactPath, JSON.stringify({})) }
|
|
249
|
-
])('does not reconcile anything when the functions artifact is $state', async ({ write }) => {
|
|
250
|
-
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
|
|
251
|
-
await write(path.join(fullPath, '.nango', 'functions.json'));
|
|
252
|
-
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
253
|
-
const output = logOutput(logSpy);
|
|
254
|
-
expect(output).not.toContain('↳ Functions');
|
|
255
|
-
expect(output).toContain('functions artifact');
|
|
256
|
-
expect(output).not.toContain('✓ Deployed');
|
|
257
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
258
|
-
});
|
|
259
760
|
it('rejects function source paths outside the project directory', async () => {
|
|
260
|
-
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
261
761
|
const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nango-cli-external-'));
|
|
262
762
|
try {
|
|
263
763
|
const externalFilePath = path.join(externalDir, 'secret.ts');
|
|
264
764
|
const sourcePath = path.relative(fullPath, externalFilePath);
|
|
265
765
|
const maliciousConfig = { ...githubFunction, filePath: sourcePath };
|
|
766
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [maliciousConfig] }));
|
|
266
767
|
await fs.writeFile(externalFilePath, 'sensitive contents');
|
|
267
|
-
await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([maliciousConfig]));
|
|
268
768
|
await fs.writeFile(path.join(fullPath, 'build', tsToJsPath(sourcePath.replace(/\.ts$/, '.js'))), 'compiled function');
|
|
269
769
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
270
770
|
expect(logOutput(logSpy)).toContain(`Function source path "${sourcePath}" must be within the project directory`);
|
|
@@ -276,8 +776,7 @@ describe('deploy', () => {
|
|
|
276
776
|
});
|
|
277
777
|
it('prints only legacy summary rows when no functions bundle', async () => {
|
|
278
778
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
279
|
-
|
|
280
|
-
fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
779
|
+
mockApi(fetchMock, { legacyPreview: legacyChangeConfirmation });
|
|
281
780
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
282
781
|
const output = logOutput(logSpy);
|
|
283
782
|
expect(output).toContain('↳ Syncs');
|
|
@@ -286,81 +785,207 @@ describe('deploy', () => {
|
|
|
286
785
|
expect(output).not.toContain('↳ Functions');
|
|
287
786
|
expect(output).toContain('✓ Deployed');
|
|
288
787
|
});
|
|
289
|
-
it(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
788
|
+
it.each([
|
|
789
|
+
{ name: 'legacy', definitions: legacyOnlyParsed },
|
|
790
|
+
{ name: 'functions', definitions: functionsOnlyParsed },
|
|
791
|
+
{ name: 'mixed', definitions: parsed }
|
|
792
|
+
])('prints No changes when the $name deployment is unchanged', async ({ definitions }) => {
|
|
793
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(definitions));
|
|
794
|
+
mockApi(fetchMock, { functionsPreview: noFunctionChanges, functionsDeploy: noFunctionChanges });
|
|
795
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
293
796
|
const output = logOutput(logSpy);
|
|
294
|
-
expect(
|
|
295
|
-
expect(output
|
|
296
|
-
expect(output).
|
|
297
|
-
expect(output).not.toContain('
|
|
797
|
+
expect(result.isOk()).toBe(true);
|
|
798
|
+
expect(output.match(/No changes/g)).toHaveLength(2);
|
|
799
|
+
expect(output).toContain('No changes were deployed.');
|
|
800
|
+
expect(output).not.toContain('Successfully');
|
|
801
|
+
expect(output).not.toContain('Summary');
|
|
802
|
+
expect(output).not.toContain('↳');
|
|
803
|
+
expect(output).not.toContain('No destructive changes, proceeding');
|
|
298
804
|
});
|
|
299
805
|
it('prints Function preview errors', async () => {
|
|
300
806
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
301
|
-
fetchMock
|
|
807
|
+
mockApi(fetchMock, { functionsPreview: { error: { code: 'functions_deployment_error', message: 'Preview failed' } } });
|
|
302
808
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
303
809
|
expect(logOutput(logSpy)).toContain('Error checking functions state:\nPreview failed');
|
|
810
|
+
expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
|
|
304
811
|
});
|
|
305
812
|
it('prints Function deploy errors', async () => {
|
|
306
813
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
307
|
-
fetchMock
|
|
308
|
-
.mockResolvedValueOnce(jsonResponse(noFunctionChanges))
|
|
309
|
-
.mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Apply failed' } }));
|
|
814
|
+
mockApi(fetchMock, { functionsDeploy: { error: { code: 'functions_deployment_error', message: 'Apply failed' } } });
|
|
310
815
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
311
816
|
expect(logOutput(logSpy)).toContain('Error deploying functions');
|
|
817
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
818
|
+
expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.functionsDeploy]);
|
|
819
|
+
});
|
|
820
|
+
it('does not deploy legacy scripts when the preview fails', async () => {
|
|
821
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
822
|
+
mockApi(fetchMock, { legacyPreview: { error: { code: 'server_error', message: 'Preview failed' } } });
|
|
823
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
824
|
+
expect(result.isErr()).toBe(true);
|
|
825
|
+
expect(requestPaths(fetchMock)).toEqual(['/sync/deploy/confirmation', '/functions/deployments/bundle/preview']);
|
|
826
|
+
expect(logOutput(logSpy)).toContain('Error checking state:\nPreview failed');
|
|
827
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
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
|
+
});
|
|
838
|
+
it('reports legacy deployment errors', async () => {
|
|
839
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
840
|
+
mockApi(fetchMock, { legacyDeploy: { error: { code: 'server_error', message: 'Apply failed' } } });
|
|
841
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
842
|
+
expect(result.isErr()).toBe(true);
|
|
843
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
844
|
+
expect(logOutput(logSpy)).toContain('Error deploying:\nApply failed');
|
|
845
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
846
|
+
});
|
|
847
|
+
it.each([
|
|
848
|
+
{ name: 'a sync with connections', changes: { deletedSyncs: [{ providerConfigKey: 'github', name: 'removed', connections: 1, auto_start: false }] } },
|
|
849
|
+
{ name: 'a model', changes: { deletedModels: ['RemovedModel'] } }
|
|
850
|
+
])('requires destructive confirmation when removing $name', async ({ changes }) => {
|
|
851
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
852
|
+
mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, ...changes } });
|
|
853
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
854
|
+
expect(result.isErr()).toBe(true);
|
|
855
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
856
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
857
|
+
});
|
|
858
|
+
it('deploys legacy destructive changes when confirmation flags are provided', async () => {
|
|
859
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
860
|
+
mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
|
|
861
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
862
|
+
expect(result.isOk()).toBe(true);
|
|
863
|
+
expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.legacyDeploy]);
|
|
864
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
865
|
+
expect(logOutput(logSpy)).toContain('allowDestructive flag is on');
|
|
866
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
867
|
+
});
|
|
868
|
+
it('explains the required legacy confirmation flags when non-interactive', async () => {
|
|
869
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
870
|
+
mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
|
|
871
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
872
|
+
expect(result.isErr()).toBe(true);
|
|
873
|
+
expect(logOutput(logSpy)).toContain('Nothing was deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting');
|
|
874
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
875
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
876
|
+
});
|
|
877
|
+
it('prints the legacy abort message when confirmation is declined', async () => {
|
|
878
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
879
|
+
mockApi(fetchMock, { legacyPreview: { ...legacyConfirmation, deletedModels: ['RemovedModel'] } });
|
|
880
|
+
const result = await deploy({
|
|
881
|
+
fullPath,
|
|
882
|
+
environmentName: 'test',
|
|
883
|
+
interactive: true,
|
|
884
|
+
options: deployOptions({ autoConfirm: false, interactive: true })
|
|
885
|
+
});
|
|
886
|
+
expect(result.isErr()).toBe(true);
|
|
887
|
+
expect(logOutput(logSpy)).toContain('Deploy aborted. Exiting');
|
|
888
|
+
expect(promptly.confirm).toHaveBeenCalledTimes(1);
|
|
889
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
890
|
+
});
|
|
891
|
+
it('preserves the legacy version override in both preview and deployment', async () => {
|
|
892
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
893
|
+
mockApi(fetchMock);
|
|
894
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ version: '42' }) });
|
|
895
|
+
expect(result.isOk()).toBe(true);
|
|
896
|
+
expect(requestPaths(fetchMock)).toEqual([apiRoutes.legacyPreview, apiRoutes.functionsPreview, apiRoutes.legacyDeploy]);
|
|
897
|
+
const previewBody = requestBody(fetchMock, 'legacyPreview');
|
|
898
|
+
expect(previewBody).toMatchObject({ reconcile: false, flowConfigs: [{ version: '42' }] });
|
|
899
|
+
expect(requestBody(fetchMock, 'legacyDeploy')).toMatchObject({ reconcile: true, flowConfigs: previewBody?.['flowConfigs'] });
|
|
900
|
+
expect(logOutput(logSpy)).not.toContain('The --version option');
|
|
312
901
|
});
|
|
313
902
|
it.each(['preview', 'deploy'])('prints backend validation details during function %s', async (stage) => {
|
|
314
903
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
+
}
|
|
325
914
|
}
|
|
326
|
-
})
|
|
915
|
+
});
|
|
327
916
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
328
917
|
const output = logOutput(logSpy);
|
|
329
918
|
expect(output).toContain('invalid_body');
|
|
330
919
|
expect(output).toContain('- functions.0.tags.required_scopes: Expected a structured scope requirement');
|
|
331
920
|
expect(output).toContain('- Another validation error');
|
|
332
921
|
expect(output).not.toContain('✓ Deployed');
|
|
333
|
-
expect(fetchMock).toHaveBeenCalledTimes(stage === 'preview' ?
|
|
922
|
+
expect(fetchMock).toHaveBeenCalledTimes(stage === 'preview' ? 2 : 3);
|
|
334
923
|
});
|
|
335
924
|
it('prints an aborted deployment when function deletions are not allowed', async () => {
|
|
336
925
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
337
|
-
fetchMock
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
926
|
+
mockApi(fetchMock, {
|
|
927
|
+
functionsPreview: {
|
|
928
|
+
created: [],
|
|
929
|
+
updated: [],
|
|
930
|
+
unchanged: [{ integrationId: 'github', name: 'fetch' }],
|
|
931
|
+
deleted: [{ integrationId: 'github', name: 'removed' }]
|
|
932
|
+
}
|
|
933
|
+
});
|
|
343
934
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
344
935
|
const output = logOutput(logSpy);
|
|
345
936
|
expect(output).toContain('↳ Functions');
|
|
346
|
-
expect(output).
|
|
937
|
+
expect(output).toContain('Nothing was deployed.');
|
|
347
938
|
expect(output).not.toContain('✓ Deployed');
|
|
939
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
348
940
|
});
|
|
349
|
-
it('
|
|
941
|
+
it.each([false, true])('reports function removals alongside updates: %s', async (withUpdates) => {
|
|
350
942
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
351
943
|
const functionChanges = {
|
|
352
944
|
created: [],
|
|
353
|
-
updated: [],
|
|
945
|
+
updated: withUpdates ? [{ integrationId: 'slack', name: 'other' }] : [],
|
|
354
946
|
unchanged: [{ integrationId: 'github', name: 'fetch' }],
|
|
355
947
|
deleted: [{ integrationId: 'github', name: 'removed' }]
|
|
356
948
|
};
|
|
357
|
-
fetchMock
|
|
949
|
+
mockApi(fetchMock, { functionsPreview: functionChanges, functionsDeploy: functionChanges });
|
|
358
950
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
359
951
|
const output = logOutput(logSpy);
|
|
360
952
|
expect(output).toContain('allowDestructive flag is on');
|
|
361
953
|
expect(output).toContain('✓ Deployed');
|
|
362
|
-
expect(output).toContain('Successfully removed
|
|
954
|
+
expect(output).toContain('Successfully removed:');
|
|
955
|
+
expect(output).toContain('- github → removed');
|
|
363
956
|
expect(output).not.toContain('- github → fetch');
|
|
957
|
+
if (withUpdates) {
|
|
958
|
+
expect(output).toContain('Successfully deployed:');
|
|
959
|
+
expect(output).toContain('- slack → other');
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
expect(output).not.toContain('Successfully deployed:');
|
|
963
|
+
}
|
|
964
|
+
});
|
|
965
|
+
it.each([false, true])('reports legacy removals alongside deployments: %s', async (withDeployments) => {
|
|
966
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
967
|
+
const changes = {
|
|
968
|
+
...legacyConfirmation,
|
|
969
|
+
deletedSyncs: [{ providerConfigKey: 'github', name: 'old-sync', connections: 1, auto_start: false }],
|
|
970
|
+
deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }],
|
|
971
|
+
deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
|
|
972
|
+
};
|
|
973
|
+
mockApi(fetchMock, { legacyPreview: changes, legacyDeploy: withDeployments ? legacyDeploymentResult : [] });
|
|
974
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
975
|
+
const output = logOutput(logSpy);
|
|
976
|
+
expect(result.isOk()).toBe(true);
|
|
977
|
+
expect(output).toContain('Successfully removed:');
|
|
978
|
+
expect(output).toContain('- github → old-sync');
|
|
979
|
+
expect(output).toContain('- github → old-action');
|
|
980
|
+
expect(output).toContain('- github → old-event');
|
|
981
|
+
expect(output).not.toContain('No changes');
|
|
982
|
+
if (withDeployments) {
|
|
983
|
+
expect(output).toContain('Successfully deployed:');
|
|
984
|
+
expect(output).toContain('- github → legacy@v42');
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
expect(output).not.toContain('Successfully deployed:');
|
|
988
|
+
}
|
|
364
989
|
});
|
|
365
990
|
});
|
|
366
991
|
function functionConfig({ integrationId, name }) {
|
|
@@ -397,7 +1022,6 @@ async function writeProject(fullPath) {
|
|
|
397
1022
|
await fs.mkdir(path.join(fullPath, 'github', 'actions'), { recursive: true });
|
|
398
1023
|
await fs.mkdir(path.join(fullPath, 'github', 'functions'), { recursive: true });
|
|
399
1024
|
await fs.mkdir(path.join(fullPath, 'slack', 'functions'), { recursive: true });
|
|
400
|
-
await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction, slackFunction]));
|
|
401
1025
|
await fs.writeFile(path.join(fullPath, 'build', 'github_actions_legacy.cjs'), 'compiled legacy');
|
|
402
1026
|
await fs.writeFile(path.join(fullPath, 'github', 'actions', 'legacy.ts'), 'source legacy');
|
|
403
1027
|
await fs.writeFile(path.join(fullPath, 'build', 'github_functions_fetch.cjs'), 'compiled function');
|
|
@@ -408,6 +1032,28 @@ async function writeProject(fullPath) {
|
|
|
408
1032
|
function jsonResponse(value) {
|
|
409
1033
|
return { json: vi.fn().mockResolvedValue(value) };
|
|
410
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
|
+
}
|
|
411
1057
|
function logOutput(logSpy) {
|
|
412
1058
|
const calls = logSpy.mock.calls;
|
|
413
1059
|
return calls.map((args) => args.map(String).join(' ')).join('\n');
|