nango 0.72.0 → 0.72.2
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 +14 -7
- 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 +7 -0
- 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 +263 -245
- package/dist/zeroYaml/deploy.js.map +1 -1
- package/dist/zeroYaml/deploy.unit.cli-test.js +579 -71
- package/dist/zeroYaml/deploy.unit.cli-test.js.map +1 -1
- package/package.json +6 -6
|
@@ -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: [],
|
|
@@ -99,11 +140,203 @@ describe('deploy', () => {
|
|
|
99
140
|
vi.restoreAllMocks();
|
|
100
141
|
await fs.rm(fullPath, { recursive: true, force: true });
|
|
101
142
|
});
|
|
102
|
-
it('
|
|
103
|
-
|
|
143
|
+
it('previews both families before deploying them in order with one summary', async () => {
|
|
144
|
+
const functionChanges = { ...noFunctionChanges, updated: [{ integrationId: 'github', name: 'fetch' }] };
|
|
145
|
+
fetchMock
|
|
146
|
+
.mockResolvedValueOnce(jsonResponse({ ...legacyChangeConfirmation, deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }] }))
|
|
147
|
+
.mockResolvedValueOnce(jsonResponse(functionChanges))
|
|
148
|
+
.mockResolvedValueOnce(jsonResponse(legacyDeploymentResult))
|
|
149
|
+
.mockResolvedValueOnce(jsonResponse(functionChanges));
|
|
150
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
151
|
+
expect(result.isOk()).toBe(true);
|
|
152
|
+
expect(fetchMock.mock.calls.map(([url]) => url.pathname)).toEqual([
|
|
153
|
+
'/sync/deploy/confirmation',
|
|
154
|
+
'/functions/deployments/bundle/preview',
|
|
155
|
+
'/sync/deploy',
|
|
156
|
+
'/functions/deployments/bundle'
|
|
157
|
+
]);
|
|
104
158
|
const output = logOutput(logSpy);
|
|
105
|
-
expect(output
|
|
159
|
+
expect(output.match(/Nango will perform this plan:/g)).toHaveLength(1);
|
|
160
|
+
expect(output.match(/Summary/g)).toHaveLength(1);
|
|
161
|
+
expect(output).toContain('↳ Actions');
|
|
162
|
+
expect(output).toContain('↳ Functions');
|
|
163
|
+
expect(output).toContain('✓ Deployed');
|
|
164
|
+
expect(output.match(/Successfully deployed:/g)).toHaveLength(1);
|
|
165
|
+
expect(output).toContain('- github → legacy@v42');
|
|
166
|
+
expect(output).toContain('- github → fetch');
|
|
167
|
+
expect(output).toContain('Successfully removed:');
|
|
168
|
+
expect(output).toContain('- github → old-action');
|
|
169
|
+
expect(output.indexOf('✓ Deployed')).toBeLessThan(output.indexOf('Successfully deployed:'));
|
|
170
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
171
|
+
});
|
|
172
|
+
it('scopes both families to the selected integration and only overrides sync/action versions', async () => {
|
|
173
|
+
fetchMock
|
|
174
|
+
.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation))
|
|
175
|
+
.mockResolvedValueOnce(jsonResponse(noFunctionChanges))
|
|
176
|
+
.mockResolvedValueOnce(jsonResponse(legacyDeploymentResult))
|
|
177
|
+
.mockResolvedValueOnce(jsonResponse(noFunctionChanges));
|
|
178
|
+
const result = await deploy({
|
|
179
|
+
fullPath,
|
|
180
|
+
environmentName: 'test',
|
|
181
|
+
interactive: false,
|
|
182
|
+
options: deployOptions({ integration: 'github', version: '42' })
|
|
183
|
+
});
|
|
184
|
+
expect(result.isOk()).toBe(true);
|
|
185
|
+
const bodies = fetchMock.mock.calls.map(([, request]) => JSON.parse(request.body));
|
|
186
|
+
expect(bodies[0]).toMatchObject({ deployMode: 'integration', flowConfigs: [{ providerConfigKey: 'github', version: '42' }] });
|
|
187
|
+
expect(bodies[1]).toMatchObject({
|
|
188
|
+
reconciliationScope: { kind: 'integration', integrationId: 'github' },
|
|
189
|
+
functions: [{ integrationId: 'github', name: 'fetch' }]
|
|
190
|
+
});
|
|
191
|
+
expect(bodies[2]).toMatchObject({ flowConfigs: bodies[0]?.['flowConfigs'] });
|
|
192
|
+
expect(bodies[3]).toEqual(bodies[1]);
|
|
193
|
+
expect(logOutput(logSpy).match(/The --version option/g)).toHaveLength(1);
|
|
194
|
+
expect(logOutput(logSpy).indexOf('✓ Packaging')).toBeLessThan(logOutput(logSpy).indexOf('The --version option'));
|
|
195
|
+
expect(logOutput(logSpy)).not.toContain('No changes');
|
|
196
|
+
expect(logOutput(logSpy)).toContain('- github → legacy@v42');
|
|
197
|
+
});
|
|
198
|
+
it.each([
|
|
199
|
+
{ name: 'unknown integration', options: { integration: 'missing' }, message: 'No local definitions found for integration "missing"' },
|
|
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) });
|
|
203
|
+
expect(result.isErr()).toBe(true);
|
|
204
|
+
expect(logOutput(logSpy)).toContain(message);
|
|
205
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
it.each(['legacy', 'functions'])('does not contact the API when %s packaging fails in a mixed project', async (family) => {
|
|
208
|
+
if (family === 'legacy') {
|
|
209
|
+
await fs.rm(path.join(fullPath, 'github', 'actions', 'legacy.ts'));
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
await fs.rm(path.join(fullPath, 'build', 'github_functions_fetch.cjs'));
|
|
213
|
+
}
|
|
214
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
215
|
+
expect(result.isErr()).toBe(true);
|
|
216
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
it.each(['legacy', 'functions'])('does not deploy either family when the %s preview fails', async (family) => {
|
|
219
|
+
const previewError = jsonResponse({ error: { code: 'server_error', message: 'Preview failed' } });
|
|
220
|
+
fetchMock
|
|
221
|
+
.mockResolvedValueOnce(family === 'legacy' ? previewError : jsonResponse(legacyConfirmation))
|
|
222
|
+
.mockResolvedValueOnce(family === 'functions' ? previewError : jsonResponse(noFunctionChanges));
|
|
223
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
224
|
+
expect(result.isErr()).toBe(true);
|
|
225
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
226
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
227
|
+
});
|
|
228
|
+
it.each(['legacy', 'functions'])('clearly reports each outcome when the %s deployment fails', async (family) => {
|
|
229
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse(noFunctionChanges));
|
|
230
|
+
if (family === 'functions') {
|
|
231
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyDeploymentResult)).mockRejectedValueOnce(new Error('Connection lost'));
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Apply failed' } }));
|
|
235
|
+
}
|
|
236
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
237
|
+
expect(result.isErr()).toBe(true);
|
|
238
|
+
expect(fetchMock).toHaveBeenCalledTimes(family === 'legacy' ? 3 : 4);
|
|
239
|
+
const output = logOutput(logSpy);
|
|
240
|
+
expect(output).toContain(`Syncs/actions/on-events: ${family === 'legacy' ? 'failed' : 'completed'}`);
|
|
241
|
+
expect(output).toContain(`Functions: ${family === 'legacy' ? 'not attempted' : 'failed'}`);
|
|
242
|
+
expect(output).toContain(family === 'legacy' ? 'Deployment failed.' : 'Deployment partially completed.');
|
|
243
|
+
expect(output).toContain('The failed request may have applied changes');
|
|
106
244
|
expect(output).not.toContain('✓ Deployed');
|
|
245
|
+
if (family === 'functions') {
|
|
246
|
+
expect(output).toContain('- github → legacy@v42');
|
|
247
|
+
expect(output.indexOf('✗ Deploying')).toBeLessThan(output.indexOf('Successfully deployed:'));
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
it.each([
|
|
251
|
+
{
|
|
252
|
+
name: 'interactive approval',
|
|
253
|
+
interactive: true,
|
|
254
|
+
autoConfirm: false,
|
|
255
|
+
allowDestructive: false,
|
|
256
|
+
accepted: true,
|
|
257
|
+
succeeds: true,
|
|
258
|
+
prompts: 1
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: 'interactive rejection',
|
|
262
|
+
interactive: true,
|
|
263
|
+
autoConfirm: false,
|
|
264
|
+
allowDestructive: false,
|
|
265
|
+
accepted: false,
|
|
266
|
+
succeeds: false,
|
|
267
|
+
prompts: 1
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
name: 'noninteractive auto-confirm alone',
|
|
271
|
+
interactive: false,
|
|
272
|
+
autoConfirm: true,
|
|
273
|
+
allowDestructive: false,
|
|
274
|
+
accepted: false,
|
|
275
|
+
succeeds: false,
|
|
276
|
+
prompts: 0
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'noninteractive allow-destructive alone',
|
|
280
|
+
interactive: false,
|
|
281
|
+
autoConfirm: false,
|
|
282
|
+
allowDestructive: true,
|
|
283
|
+
accepted: false,
|
|
284
|
+
succeeds: false,
|
|
285
|
+
prompts: 0
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'noninteractive approval flags',
|
|
289
|
+
interactive: false,
|
|
290
|
+
autoConfirm: true,
|
|
291
|
+
allowDestructive: true,
|
|
292
|
+
accepted: false,
|
|
293
|
+
succeeds: true,
|
|
294
|
+
prompts: 0
|
|
295
|
+
}
|
|
296
|
+
])('handles mixed destructive changes with $name', async ({ interactive, autoConfirm, allowDestructive, accepted, succeeds, prompts }) => {
|
|
297
|
+
const changes = { ...noFunctionChanges, deleted: [{ integrationId: 'github', name: 'removed' }] };
|
|
298
|
+
fetchMock
|
|
299
|
+
.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }))
|
|
300
|
+
.mockResolvedValueOnce(jsonResponse(changes))
|
|
301
|
+
.mockResolvedValueOnce(jsonResponse([]))
|
|
302
|
+
.mockResolvedValueOnce(jsonResponse(changes));
|
|
303
|
+
vi.mocked(promptly.confirm).mockImplementationOnce(() => {
|
|
304
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
305
|
+
expect(logOutput(logSpy)).toContain('↳ Actions');
|
|
306
|
+
expect(logOutput(logSpy)).toContain('↳ Functions');
|
|
307
|
+
return Promise.resolve(accepted);
|
|
308
|
+
});
|
|
309
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive, options: deployOptions({ interactive, autoConfirm, allowDestructive }) });
|
|
310
|
+
expect(result.isOk()).toBe(succeeds);
|
|
311
|
+
expect(promptly.confirm).toHaveBeenCalledTimes(prompts);
|
|
312
|
+
expect(fetchMock).toHaveBeenCalledTimes(succeeds ? 4 : 2);
|
|
313
|
+
});
|
|
314
|
+
it.each(['legacy', 'functions'])('requires confirmation when only %s changes are destructive', async (family) => {
|
|
315
|
+
fetchMock
|
|
316
|
+
.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: family === 'legacy' ? ['RemovedModel'] : [] }))
|
|
317
|
+
.mockResolvedValueOnce(jsonResponse({ ...noFunctionChanges, deleted: family === 'functions' ? [{ integrationId: 'github', name: 'removed' }] : [] }));
|
|
318
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
319
|
+
expect(result.isErr()).toBe(true);
|
|
320
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
321
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
322
|
+
});
|
|
323
|
+
it('accepts the environment auto-confirm setting for a mixed deployment', async () => {
|
|
324
|
+
vi.stubEnv('NANGO_DEPLOY_AUTO_CONFIRM', 'true');
|
|
325
|
+
const changes = { ...noFunctionChanges, deleted: [{ integrationId: 'github', name: 'removed' }] };
|
|
326
|
+
fetchMock
|
|
327
|
+
.mockResolvedValueOnce(jsonResponse(legacyConfirmation))
|
|
328
|
+
.mockResolvedValueOnce(jsonResponse(changes))
|
|
329
|
+
.mockResolvedValueOnce(jsonResponse([]))
|
|
330
|
+
.mockResolvedValueOnce(jsonResponse(changes));
|
|
331
|
+
const result = await deploy({
|
|
332
|
+
fullPath,
|
|
333
|
+
environmentName: 'test',
|
|
334
|
+
interactive: false,
|
|
335
|
+
options: deployOptions({ autoConfirm: false, allowDestructive: true })
|
|
336
|
+
});
|
|
337
|
+
expect(result.isOk()).toBe(true);
|
|
338
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
339
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
107
340
|
});
|
|
108
341
|
it('prints only legacy summary rows for a single action deploy', async () => {
|
|
109
342
|
fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
@@ -119,6 +352,168 @@ describe('deploy', () => {
|
|
|
119
352
|
expect(output).toContain('↳ OnEvents');
|
|
120
353
|
expect(output).not.toContain('↳ Functions');
|
|
121
354
|
expect(output).toContain('✓ Deployed');
|
|
355
|
+
expect(output).toContain('No destructive changes, proceeding without confirmation');
|
|
356
|
+
expect(output).toContain('--sync and --action are deprecated');
|
|
357
|
+
});
|
|
358
|
+
describe('with a file', () => {
|
|
359
|
+
const functionChanges = {
|
|
360
|
+
created: [{ integrationId: 'github', name: 'fetch' }],
|
|
361
|
+
updated: [],
|
|
362
|
+
unchanged: [],
|
|
363
|
+
deleted: []
|
|
364
|
+
};
|
|
365
|
+
it.each([
|
|
366
|
+
{ label: 'a relative path', file: () => 'github/functions/fetch.ts' },
|
|
367
|
+
{ label: 'a ./ relative path', file: () => './github/functions/fetch.ts' },
|
|
368
|
+
{ label: 'a path with backslashes', file: () => 'github\\functions\\fetch.ts' },
|
|
369
|
+
{ label: 'a .js path', file: () => 'github/functions/fetch.js' },
|
|
370
|
+
{ label: 'an absolute path', file: () => path.join(fullPath, 'github', 'functions', 'fetch.ts') }
|
|
371
|
+
])('deploys only that function from $label in a mixed project', async ({ file }) => {
|
|
372
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(functionChanges)).mockResolvedValueOnce(jsonResponse(functionChanges));
|
|
373
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: file() });
|
|
374
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
375
|
+
for (const [, request] of fetchMock.mock.calls) {
|
|
376
|
+
const body = JSON.parse(request.body);
|
|
377
|
+
expect(body.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'fetch' });
|
|
378
|
+
expect(body.functions).toEqual([expect.objectContaining({ integrationId: 'github', name: 'fetch' })]);
|
|
379
|
+
}
|
|
380
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
381
|
+
});
|
|
382
|
+
it('deploys a tool under its declared name', async () => {
|
|
383
|
+
const toolConfig = { ...functionConfig({ integrationId: 'github', name: 'get_issue' }), filePath: './github/tools/getIssue.ts' };
|
|
384
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({
|
|
385
|
+
...parsed,
|
|
386
|
+
functions: [githubFunction, toolConfig],
|
|
387
|
+
entryPoints: [...parsed.entryPoints, { filePath: toolConfig.filePath, integrationId: 'github', type: 'function', name: 'get_issue' }]
|
|
388
|
+
}));
|
|
389
|
+
await fs.mkdir(path.join(fullPath, 'github', 'tools'), { recursive: true });
|
|
390
|
+
await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction, toolConfig]));
|
|
391
|
+
await fs.writeFile(path.join(fullPath, 'github', 'tools', 'getIssue.ts'), 'source tool');
|
|
392
|
+
await fs.writeFile(path.join(fullPath, 'build', 'github_tools_getIssue.cjs'), 'compiled tool');
|
|
393
|
+
const toolChanges = { ...functionChanges, created: [{ integrationId: 'github', name: 'get_issue' }] };
|
|
394
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(toolChanges)).mockResolvedValueOnce(jsonResponse(toolChanges));
|
|
395
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/tools/getIssue.ts' });
|
|
396
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
397
|
+
const previewRequest = fetchMock.mock.calls[0]?.[1];
|
|
398
|
+
const previewBody = JSON.parse(previewRequest.body);
|
|
399
|
+
expect(previewBody.reconciliationScope).toEqual({ kind: 'function', integrationId: 'github', name: 'get_issue' });
|
|
400
|
+
expect(previewBody.functions).toEqual([expect.objectContaining({ name: 'get_issue', fileBody: { js: 'compiled tool', ts: 'source tool' } })]);
|
|
401
|
+
});
|
|
402
|
+
it('deploys a legacy action as a single script', async () => {
|
|
403
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
404
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/actions/legacy.ts' });
|
|
405
|
+
const output = logOutput(logSpy);
|
|
406
|
+
expect(output).toContain('✓ Deployed');
|
|
407
|
+
expect(output).not.toContain('--sync and --action are deprecated');
|
|
408
|
+
const confirmationRequest = fetchMock.mock.calls[0]?.[1];
|
|
409
|
+
const confirmationBody = JSON.parse(confirmationRequest.body);
|
|
410
|
+
expect(confirmationBody.deployMode).toBe('single');
|
|
411
|
+
expect(confirmationBody.onEventScriptsByProvider).toBeUndefined();
|
|
412
|
+
expect(confirmationBody.flowConfigs).toEqual([expect.objectContaining({ syncName: 'legacy', providerConfigKey: 'github', type: 'action' })]);
|
|
413
|
+
});
|
|
414
|
+
describe('in a project with legacy scripts sharing names across integrations', () => {
|
|
415
|
+
beforeEach(async () => {
|
|
416
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(multiIntegrationLegacyParsed));
|
|
417
|
+
await fs.mkdir(path.join(fullPath, 'github', 'syncs'), { recursive: true });
|
|
418
|
+
await fs.mkdir(path.join(fullPath, 'slack', 'actions'), { recursive: true });
|
|
419
|
+
await fs.writeFile(path.join(fullPath, 'build', 'github_syncs_contacts.cjs'), 'compiled contacts');
|
|
420
|
+
await fs.writeFile(path.join(fullPath, 'github', 'syncs', 'contacts.ts'), 'source contacts');
|
|
421
|
+
await fs.writeFile(path.join(fullPath, 'build', 'slack_actions_legacy.cjs'), 'compiled slack legacy');
|
|
422
|
+
await fs.writeFile(path.join(fullPath, 'slack', 'actions', 'legacy.ts'), 'source slack legacy');
|
|
423
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
424
|
+
});
|
|
425
|
+
it('deploys a legacy sync as a single script', async () => {
|
|
426
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/syncs/contacts.ts' });
|
|
427
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
428
|
+
const confirmationRequest = fetchMock.mock.calls[0]?.[1];
|
|
429
|
+
const confirmationBody = JSON.parse(confirmationRequest.body);
|
|
430
|
+
expect(confirmationBody.deployMode).toBe('single');
|
|
431
|
+
expect(confirmationBody.flowConfigs).toEqual([
|
|
432
|
+
expect.objectContaining({
|
|
433
|
+
syncName: 'contacts',
|
|
434
|
+
providerConfigKey: 'github',
|
|
435
|
+
type: 'sync',
|
|
436
|
+
fileBody: { js: 'compiled contacts', ts: 'source contacts' }
|
|
437
|
+
})
|
|
438
|
+
]);
|
|
439
|
+
});
|
|
440
|
+
it("deploys only the file's integration when another integration has an action with the same name", async () => {
|
|
441
|
+
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'slack/actions/legacy.ts' });
|
|
442
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
443
|
+
const confirmationRequest = fetchMock.mock.calls[0]?.[1];
|
|
444
|
+
const confirmationBody = JSON.parse(confirmationRequest.body);
|
|
445
|
+
expect(confirmationBody.flowConfigs).toEqual([
|
|
446
|
+
expect.objectContaining({
|
|
447
|
+
syncName: 'legacy',
|
|
448
|
+
providerConfigKey: 'slack',
|
|
449
|
+
type: 'action',
|
|
450
|
+
fileBody: { js: 'compiled slack legacy', ts: 'source slack legacy' }
|
|
451
|
+
})
|
|
452
|
+
]);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
it.each([
|
|
456
|
+
{ label: 'an unknown file', file: 'github/functions/missing.ts', message: "'github/functions/missing.ts' is not a script" },
|
|
457
|
+
{ label: 'a file outside the project', file: '../outside.ts', message: "'../outside.ts' must be inside the project folder" },
|
|
458
|
+
{ label: 'an absolute path outside the project', file: '/outside.ts', message: "'/outside.ts' must be inside the project folder" },
|
|
459
|
+
{
|
|
460
|
+
label: 'an on-event script',
|
|
461
|
+
file: 'github/on-events/cleanup.ts',
|
|
462
|
+
message: "can't be deployed on its own. Use `nango deploy <environment> --integration github`",
|
|
463
|
+
entryPoint: { filePath: './github/on-events/cleanup.ts', integrationId: 'github', type: 'on-event', name: 'cleanup' }
|
|
464
|
+
}
|
|
465
|
+
])('rejects $label', async ({ file, message, entryPoint }) => {
|
|
466
|
+
if (entryPoint) {
|
|
467
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...parsed, entryPoints: [...parsed.entryPoints, entryPoint] }));
|
|
468
|
+
}
|
|
469
|
+
const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file });
|
|
470
|
+
expect(res.isErr()).toBe(true);
|
|
471
|
+
expect(logOutput(logSpy)).toContain(message);
|
|
472
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
473
|
+
});
|
|
474
|
+
it.each([
|
|
475
|
+
{ flag: '--sync', options: { sync: 'legacy' } },
|
|
476
|
+
{ flag: '--action', options: { action: 'legacy' } },
|
|
477
|
+
{ flag: '--integration', options: { integration: 'github' } }
|
|
478
|
+
])('rejects a file combined with $flag', async ({ options }) => {
|
|
479
|
+
const res = await deploy({
|
|
480
|
+
fullPath,
|
|
481
|
+
environmentName: 'test',
|
|
482
|
+
interactive: false,
|
|
483
|
+
options: deployOptions(options),
|
|
484
|
+
file: 'github/functions/fetch.ts'
|
|
485
|
+
});
|
|
486
|
+
expect(res.isErr()).toBe(true);
|
|
487
|
+
expect(logOutput(logSpy)).toContain('A file cannot be combined with --sync, --action or --integration');
|
|
488
|
+
expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
|
|
489
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
490
|
+
});
|
|
491
|
+
it.each(['', ' '])('rejects an empty file argument %j instead of deploying everything', async (file) => {
|
|
492
|
+
const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file });
|
|
493
|
+
expect(res.isErr()).toBe(true);
|
|
494
|
+
expect(logOutput(logSpy)).toContain('The file argument is empty');
|
|
495
|
+
expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
|
|
496
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
497
|
+
});
|
|
498
|
+
it('rejects a file passed in place of the environment', async () => {
|
|
499
|
+
const res = await deploy({ fullPath, environmentName: 'github/syncs/fetchIssues.ts', interactive: false, options: deployOptions() });
|
|
500
|
+
expect(res.isErr()).toBe(true);
|
|
501
|
+
expect(logOutput(logSpy)).toContain("'github/syncs/fetchIssues.ts' is not a valid environment name");
|
|
502
|
+
expect(parseIntegrationDefinitions).not.toHaveBeenCalled();
|
|
503
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
504
|
+
});
|
|
505
|
+
it('does not deploy when the server rejects the function scope', async () => {
|
|
506
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({
|
|
507
|
+
error: {
|
|
508
|
+
code: 'invalid_body',
|
|
509
|
+
errors: [{ code: 'invalid_union_discriminator', message: 'Invalid discriminator value', path: ['reconciliationScope', 'kind'] }]
|
|
510
|
+
}
|
|
511
|
+
}));
|
|
512
|
+
const res = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions(), file: 'github/functions/fetch.ts' });
|
|
513
|
+
expect(res.isErr()).toBe(true);
|
|
514
|
+
expect(logOutput(logSpy)).toContain('reconciliationScope.kind: Invalid discriminator value');
|
|
515
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
516
|
+
});
|
|
122
517
|
});
|
|
123
518
|
it.each([
|
|
124
519
|
{ flag: '--sync', options: { sync: 'missing' } },
|
|
@@ -152,9 +547,9 @@ describe('deploy', () => {
|
|
|
152
547
|
expect(previewBody.reconciliationScope).toEqual({ kind: 'integration', integrationId: 'slack' });
|
|
153
548
|
expect(previewBody.functions).toEqual([expect.objectContaining({ integrationId: 'slack', name: 'other', trigger: { kind: 'http' } })]);
|
|
154
549
|
});
|
|
155
|
-
|
|
550
|
+
// TODO: Re-enable these tests when empty deployments can delete the last definition for both legacy scripts and functions.
|
|
551
|
+
it.skip('deletes the functions of an integration when its last function was removed', async () => {
|
|
156
552
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [githubFunction] }));
|
|
157
|
-
await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([githubFunction]));
|
|
158
553
|
const functionChanges = {
|
|
159
554
|
created: [],
|
|
160
555
|
updated: [],
|
|
@@ -171,11 +566,10 @@ describe('deploy', () => {
|
|
|
171
566
|
const output = logOutput(logSpy);
|
|
172
567
|
expect(output).toContain('- slack → other');
|
|
173
568
|
expect(output).toContain('✓ Deployed');
|
|
174
|
-
expect(output).toContain('Successfully removed
|
|
569
|
+
expect(output).toContain('Successfully removed:');
|
|
175
570
|
});
|
|
176
|
-
it('deletes every function of the environment when the last function was removed', async () => {
|
|
571
|
+
it.skip('deletes every function of the environment when the last function was removed', async () => {
|
|
177
572
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [] }));
|
|
178
|
-
await fs.rm(path.join(fullPath, '.nango', 'functions.json'));
|
|
179
573
|
const functionChanges = {
|
|
180
574
|
created: [],
|
|
181
575
|
updated: [],
|
|
@@ -191,18 +585,20 @@ describe('deploy', () => {
|
|
|
191
585
|
expect(output).toContain('- github → fetch');
|
|
192
586
|
expect(output).toContain('- slack → other');
|
|
193
587
|
expect(output).toContain('✓ Deployed');
|
|
194
|
-
expect(output).toContain('Successfully removed
|
|
588
|
+
expect(output).toContain('Successfully removed:');
|
|
195
589
|
});
|
|
196
|
-
it('
|
|
590
|
+
it('warns about --version for function deployments and still deploys', async () => {
|
|
197
591
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
198
|
-
|
|
592
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(noFunctionChanges)).mockResolvedValueOnce(jsonResponse(noFunctionChanges));
|
|
593
|
+
const result = await deploy({
|
|
199
594
|
fullPath,
|
|
200
595
|
environmentName: 'test',
|
|
201
596
|
interactive: false,
|
|
202
597
|
options: deployOptions({ version: 'v1' })
|
|
203
598
|
});
|
|
204
|
-
expect(
|
|
205
|
-
expect(
|
|
599
|
+
expect(result.isOk()).toBe(true);
|
|
600
|
+
expect(logOutput(logSpy)).toContain('The --version option only applies to syncs/actions; it has no effect on functions.');
|
|
601
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
206
602
|
});
|
|
207
603
|
it('prints only the Functions summary row for a functions-only project', async () => {
|
|
208
604
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
@@ -227,7 +623,6 @@ describe('deploy', () => {
|
|
|
227
623
|
const config = functionConfig({ integrationId: 'github.js', name: 'fetch' });
|
|
228
624
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [config] }));
|
|
229
625
|
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
626
|
await fs.writeFile(path.join(fullPath, 'github.js', 'functions', 'fetch.ts'), 'source with dotted path');
|
|
232
627
|
await fs.writeFile(path.join(fullPath, 'build', 'github.js_functions_fetch.cjs'), 'compiled with dotted path');
|
|
233
628
|
const functionChanges = {
|
|
@@ -243,28 +638,14 @@ describe('deploy', () => {
|
|
|
243
638
|
const previewBody = JSON.parse(previewRequest.body);
|
|
244
639
|
expect(previewBody.functions[0]?.fileBody).toEqual({ js: 'compiled with dotted path', ts: 'source with dotted path' });
|
|
245
640
|
});
|
|
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
641
|
it('rejects function source paths outside the project directory', async () => {
|
|
260
|
-
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
261
642
|
const externalDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nango-cli-external-'));
|
|
262
643
|
try {
|
|
263
644
|
const externalFilePath = path.join(externalDir, 'secret.ts');
|
|
264
645
|
const sourcePath = path.relative(fullPath, externalFilePath);
|
|
265
646
|
const maliciousConfig = { ...githubFunction, filePath: sourcePath };
|
|
647
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok({ ...functionsOnlyParsed, functions: [maliciousConfig] }));
|
|
266
648
|
await fs.writeFile(externalFilePath, 'sensitive contents');
|
|
267
|
-
await fs.writeFile(path.join(fullPath, '.nango', 'functions.json'), JSON.stringify([maliciousConfig]));
|
|
268
649
|
await fs.writeFile(path.join(fullPath, 'build', tsToJsPath(sourcePath.replace(/\.ts$/, '.js'))), 'compiled function');
|
|
269
650
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
270
651
|
expect(logOutput(logSpy)).toContain(`Function source path "${sourcePath}" must be within the project directory`);
|
|
@@ -276,8 +657,7 @@ describe('deploy', () => {
|
|
|
276
657
|
});
|
|
277
658
|
it('prints only legacy summary rows when no functions bundle', async () => {
|
|
278
659
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
279
|
-
|
|
280
|
-
fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
660
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyChangeConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
281
661
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
282
662
|
const output = logOutput(logSpy);
|
|
283
663
|
expect(output).toContain('↳ Syncs');
|
|
@@ -286,21 +666,36 @@ describe('deploy', () => {
|
|
|
286
666
|
expect(output).not.toContain('↳ Functions');
|
|
287
667
|
expect(output).toContain('✓ Deployed');
|
|
288
668
|
});
|
|
289
|
-
it(
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
669
|
+
it.each([
|
|
670
|
+
{ name: 'legacy', definitions: legacyOnlyParsed },
|
|
671
|
+
{ name: 'functions', definitions: functionsOnlyParsed },
|
|
672
|
+
{ name: 'mixed', definitions: parsed }
|
|
673
|
+
])('prints No changes when the $name deployment is unchanged', async ({ name, definitions }) => {
|
|
674
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(definitions));
|
|
675
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(name === 'functions' ? noFunctionChanges : legacyConfirmation));
|
|
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
|
+
}
|
|
683
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
293
684
|
const output = logOutput(logSpy);
|
|
294
|
-
expect(
|
|
295
|
-
expect(output
|
|
296
|
-
expect(output).
|
|
297
|
-
expect(output).not.toContain('
|
|
685
|
+
expect(result.isOk()).toBe(true);
|
|
686
|
+
expect(output.match(/No changes/g)).toHaveLength(2);
|
|
687
|
+
expect(output).toContain('No changes were deployed.');
|
|
688
|
+
expect(output).not.toContain('Successfully');
|
|
689
|
+
expect(output).not.toContain('Summary');
|
|
690
|
+
expect(output).not.toContain('↳');
|
|
691
|
+
expect(output).not.toContain('No destructive changes, proceeding');
|
|
298
692
|
});
|
|
299
693
|
it('prints Function preview errors', async () => {
|
|
300
694
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
301
695
|
fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Preview failed' } }));
|
|
302
696
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
303
697
|
expect(logOutput(logSpy)).toContain('Error checking functions state:\nPreview failed');
|
|
698
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
304
699
|
});
|
|
305
700
|
it('prints Function deploy errors', async () => {
|
|
306
701
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
@@ -309,6 +704,86 @@ describe('deploy', () => {
|
|
|
309
704
|
.mockResolvedValueOnce(jsonResponse({ error: { code: 'functions_deployment_error', message: 'Apply failed' } }));
|
|
310
705
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
311
706
|
expect(logOutput(logSpy)).toContain('Error deploying functions');
|
|
707
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
708
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
709
|
+
});
|
|
710
|
+
it('does not deploy legacy scripts when the preview fails', async () => {
|
|
711
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
712
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Preview failed' } }));
|
|
713
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
714
|
+
expect(result.isErr()).toBe(true);
|
|
715
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
716
|
+
expect(logOutput(logSpy)).toContain('Error checking state:\nPreview failed');
|
|
717
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
718
|
+
});
|
|
719
|
+
it('reports legacy deployment errors', async () => {
|
|
720
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
721
|
+
fetchMock
|
|
722
|
+
.mockResolvedValueOnce(jsonResponse(legacyConfirmation))
|
|
723
|
+
.mockResolvedValueOnce(jsonResponse({ error: { code: 'server_error', message: 'Apply failed' } }));
|
|
724
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
725
|
+
expect(result.isErr()).toBe(true);
|
|
726
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
727
|
+
expect(logOutput(logSpy)).toContain('Error deploying:\nApply failed');
|
|
728
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
729
|
+
});
|
|
730
|
+
it.each([
|
|
731
|
+
{ name: 'a sync with connections', changes: { deletedSyncs: [{ providerConfigKey: 'github', name: 'removed', connections: 1, auto_start: false }] } },
|
|
732
|
+
{ name: 'a model', changes: { deletedModels: ['RemovedModel'] } }
|
|
733
|
+
])('requires destructive confirmation when removing $name', async ({ changes }) => {
|
|
734
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
735
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, ...changes }));
|
|
736
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
737
|
+
expect(result.isErr()).toBe(true);
|
|
738
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
739
|
+
expect(logOutput(logSpy)).not.toContain('✓ Deployed');
|
|
740
|
+
});
|
|
741
|
+
it('deploys legacy destructive changes when confirmation flags are provided', async () => {
|
|
742
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
743
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] })).mockResolvedValueOnce(jsonResponse([]));
|
|
744
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
745
|
+
expect(result.isOk()).toBe(true);
|
|
746
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
747
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
748
|
+
expect(logOutput(logSpy)).toContain('allowDestructive flag is on');
|
|
749
|
+
expect(logOutput(logSpy)).toContain('✓ Deployed');
|
|
750
|
+
});
|
|
751
|
+
it('explains the required legacy confirmation flags when non-interactive', async () => {
|
|
752
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
753
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }));
|
|
754
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
755
|
+
expect(result.isErr()).toBe(true);
|
|
756
|
+
expect(logOutput(logSpy)).toContain('Nothing was deployed. Confirm the deploy by passing the --auto-confirm and --allow-destructive flags. Exiting');
|
|
757
|
+
expect(promptly.confirm).not.toHaveBeenCalled();
|
|
758
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
759
|
+
});
|
|
760
|
+
it('prints the legacy abort message when confirmation is declined', async () => {
|
|
761
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
762
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ ...legacyConfirmation, deletedModels: ['RemovedModel'] }));
|
|
763
|
+
const result = await deploy({
|
|
764
|
+
fullPath,
|
|
765
|
+
environmentName: 'test',
|
|
766
|
+
interactive: true,
|
|
767
|
+
options: deployOptions({ autoConfirm: false, interactive: true })
|
|
768
|
+
});
|
|
769
|
+
expect(result.isErr()).toBe(true);
|
|
770
|
+
expect(logOutput(logSpy)).toContain('Deploy aborted. Exiting');
|
|
771
|
+
expect(promptly.confirm).toHaveBeenCalledTimes(1);
|
|
772
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
773
|
+
});
|
|
774
|
+
it('preserves the legacy version override in both preview and deployment', async () => {
|
|
775
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
776
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(legacyConfirmation)).mockResolvedValueOnce(jsonResponse([]));
|
|
777
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ version: '42' }) });
|
|
778
|
+
expect(result.isOk()).toBe(true);
|
|
779
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
780
|
+
const previewRequest = fetchMock.mock.calls[0]?.[1];
|
|
781
|
+
const deployRequest = fetchMock.mock.calls[1]?.[1];
|
|
782
|
+
const previewBody = JSON.parse(previewRequest.body);
|
|
783
|
+
const deployBody = JSON.parse(deployRequest.body);
|
|
784
|
+
expect(previewBody).toMatchObject({ reconcile: false, flowConfigs: [{ version: '42' }] });
|
|
785
|
+
expect(deployBody).toMatchObject({ reconcile: true, flowConfigs: previewBody.flowConfigs });
|
|
786
|
+
expect(logOutput(logSpy)).not.toContain('The --version option');
|
|
312
787
|
});
|
|
313
788
|
it.each(['preview', 'deploy'])('prints backend validation details during function %s', async (stage) => {
|
|
314
789
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
@@ -343,14 +818,15 @@ describe('deploy', () => {
|
|
|
343
818
|
await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions() });
|
|
344
819
|
const output = logOutput(logSpy);
|
|
345
820
|
expect(output).toContain('↳ Functions');
|
|
346
|
-
expect(output).
|
|
821
|
+
expect(output).toContain('Nothing was deployed.');
|
|
347
822
|
expect(output).not.toContain('✓ Deployed');
|
|
823
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
348
824
|
});
|
|
349
|
-
it('
|
|
825
|
+
it.each([false, true])('reports function removals alongside updates: %s', async (withUpdates) => {
|
|
350
826
|
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(functionsOnlyParsed));
|
|
351
827
|
const functionChanges = {
|
|
352
828
|
created: [],
|
|
353
|
-
updated: [],
|
|
829
|
+
updated: withUpdates ? [{ integrationId: 'slack', name: 'other' }] : [],
|
|
354
830
|
unchanged: [{ integrationId: 'github', name: 'fetch' }],
|
|
355
831
|
deleted: [{ integrationId: 'github', name: 'removed' }]
|
|
356
832
|
};
|
|
@@ -359,8 +835,41 @@ describe('deploy', () => {
|
|
|
359
835
|
const output = logOutput(logSpy);
|
|
360
836
|
expect(output).toContain('allowDestructive flag is on');
|
|
361
837
|
expect(output).toContain('✓ Deployed');
|
|
362
|
-
expect(output).toContain('Successfully removed
|
|
838
|
+
expect(output).toContain('Successfully removed:');
|
|
839
|
+
expect(output).toContain('- github → removed');
|
|
363
840
|
expect(output).not.toContain('- github → fetch');
|
|
841
|
+
if (withUpdates) {
|
|
842
|
+
expect(output).toContain('Successfully deployed:');
|
|
843
|
+
expect(output).toContain('- slack → other');
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
expect(output).not.toContain('Successfully deployed:');
|
|
847
|
+
}
|
|
848
|
+
});
|
|
849
|
+
it.each([false, true])('reports legacy removals alongside deployments: %s', async (withDeployments) => {
|
|
850
|
+
vi.mocked(parseIntegrationDefinitions).mockResolvedValue(Ok(legacyOnlyParsed));
|
|
851
|
+
const changes = {
|
|
852
|
+
...legacyConfirmation,
|
|
853
|
+
deletedSyncs: [{ providerConfigKey: 'github', name: 'old-sync', connections: 1, auto_start: false }],
|
|
854
|
+
deletedActions: [{ providerConfigKey: 'github', name: 'old-action' }],
|
|
855
|
+
deletedOnEventScripts: [{ providerConfigKey: 'github', name: 'old-event', event: 'post-connection-creation' }]
|
|
856
|
+
};
|
|
857
|
+
fetchMock.mockResolvedValueOnce(jsonResponse(changes)).mockResolvedValueOnce(jsonResponse(withDeployments ? legacyDeploymentResult : []));
|
|
858
|
+
const result = await deploy({ fullPath, environmentName: 'test', interactive: false, options: deployOptions({ allowDestructive: true }) });
|
|
859
|
+
const output = logOutput(logSpy);
|
|
860
|
+
expect(result.isOk()).toBe(true);
|
|
861
|
+
expect(output).toContain('Successfully removed:');
|
|
862
|
+
expect(output).toContain('- github → old-sync');
|
|
863
|
+
expect(output).toContain('- github → old-action');
|
|
864
|
+
expect(output).toContain('- github → old-event');
|
|
865
|
+
expect(output).not.toContain('No changes');
|
|
866
|
+
if (withDeployments) {
|
|
867
|
+
expect(output).toContain('Successfully deployed:');
|
|
868
|
+
expect(output).toContain('- github → legacy@v42');
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
expect(output).not.toContain('Successfully deployed:');
|
|
872
|
+
}
|
|
364
873
|
});
|
|
365
874
|
});
|
|
366
875
|
function functionConfig({ integrationId, name }) {
|
|
@@ -397,7 +906,6 @@ async function writeProject(fullPath) {
|
|
|
397
906
|
await fs.mkdir(path.join(fullPath, 'github', 'actions'), { recursive: true });
|
|
398
907
|
await fs.mkdir(path.join(fullPath, 'github', 'functions'), { recursive: true });
|
|
399
908
|
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
909
|
await fs.writeFile(path.join(fullPath, 'build', 'github_actions_legacy.cjs'), 'compiled legacy');
|
|
402
910
|
await fs.writeFile(path.join(fullPath, 'github', 'actions', 'legacy.ts'), 'source legacy');
|
|
403
911
|
await fs.writeFile(path.join(fullPath, 'build', 'github_functions_fetch.cjs'), 'compiled function');
|