@nexical/cli 0.11.8 → 0.11.9
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 +1 -1
- package/dist/index.js.map +1 -1
- package/dist/src/commands/init.js +3 -3
- package/dist/src/commands/module/add.js +53 -22
- package/dist/src/commands/module/add.js.map +1 -1
- package/dist/src/commands/module/list.d.ts +1 -0
- package/dist/src/commands/module/list.js +54 -45
- package/dist/src/commands/module/list.js.map +1 -1
- package/dist/src/commands/module/remove.js +37 -12
- package/dist/src/commands/module/remove.js.map +1 -1
- package/dist/src/commands/module/update.js +15 -3
- package/dist/src/commands/module/update.js.map +1 -1
- package/dist/src/commands/run.js +18 -1
- package/dist/src/commands/run.js.map +1 -1
- package/package.json +1 -1
- package/src/commands/module/add.ts +74 -31
- package/src/commands/module/list.ts +80 -57
- package/src/commands/module/remove.ts +50 -14
- package/src/commands/module/update.ts +19 -5
- package/src/commands/run.ts +21 -1
- package/test/e2e/lifecycle.e2e.test.ts +3 -2
- package/test/integration/commands/deploy.integration.test.ts +102 -0
- package/test/integration/commands/init.integration.test.ts +16 -1
- package/test/integration/commands/module.integration.test.ts +81 -55
- package/test/integration/commands/run.integration.test.ts +69 -74
- package/test/integration/commands/setup.integration.test.ts +53 -0
- package/test/unit/commands/deploy.test.ts +285 -0
- package/test/unit/commands/init.test.ts +15 -0
- package/test/unit/commands/module/add.test.ts +363 -254
- package/test/unit/commands/module/list.test.ts +100 -99
- package/test/unit/commands/module/remove.test.ts +143 -58
- package/test/unit/commands/module/update.test.ts +45 -62
- package/test/unit/commands/run.test.ts +16 -1
- package/test/unit/commands/setup.test.ts +25 -66
- package/test/unit/deploy/config-manager.test.ts +65 -0
- package/test/unit/deploy/providers/cloudflare.test.ts +210 -0
- package/test/unit/deploy/providers/github.test.ts +139 -0
- package/test/unit/deploy/providers/railway.test.ts +328 -0
- package/test/unit/deploy/registry.test.ts +227 -0
- package/test/unit/deploy/utils.test.ts +30 -0
- package/test/unit/utils/command-discovery.test.ts +145 -142
- package/test/unit/utils/git_utils.test.ts +49 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { RailwayProvider } from '../../../../src/deploy/providers/railway.js';
|
|
3
|
+
import { execAsync } from '../../../../src/deploy/utils.js';
|
|
4
|
+
import { logger } from '@nexical/cli-core';
|
|
5
|
+
|
|
6
|
+
vi.mock('../../../../src/deploy/utils.js');
|
|
7
|
+
vi.mock('@nexical/cli-core', () => ({
|
|
8
|
+
logger: {
|
|
9
|
+
info: vi.fn(),
|
|
10
|
+
error: vi.fn(),
|
|
11
|
+
warn: vi.fn(),
|
|
12
|
+
debug: vi.fn(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
describe('RailwayProvider', () => {
|
|
17
|
+
let provider: RailwayProvider;
|
|
18
|
+
let mockContext: { cwd: string; options: Record<string, unknown>; config: any };
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.resetAllMocks();
|
|
22
|
+
provider = new RailwayProvider();
|
|
23
|
+
mockContext = {
|
|
24
|
+
cwd: '/mock',
|
|
25
|
+
options: {}, // Env undefined by default
|
|
26
|
+
config: { deploy: { backend: { projectName: 'my-proj' } } },
|
|
27
|
+
};
|
|
28
|
+
(execAsync as unknown as { mockResolvedValue: any }).mockResolvedValue({
|
|
29
|
+
stdout: '',
|
|
30
|
+
stderr: '',
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.clearAllMocks();
|
|
36
|
+
delete process.env.RAILWAY_API_TOKEN;
|
|
37
|
+
delete process.env.RAILWAY_TOKEN;
|
|
38
|
+
delete process.env.CUSTOM_RW_TOKEN;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('provision', () => {
|
|
42
|
+
it('should default to production environment if options.env is missing', async () => {
|
|
43
|
+
// mockContext.options.env is undefined
|
|
44
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
45
|
+
stdout: 'Linked',
|
|
46
|
+
}); // status
|
|
47
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
48
|
+
stdout: 'postgres',
|
|
49
|
+
}); // postgres check
|
|
50
|
+
|
|
51
|
+
await provider.provision(mockContext);
|
|
52
|
+
|
|
53
|
+
// Should stick to baseProjectName 'my-proj'
|
|
54
|
+
expect(execAsync).not.toHaveBeenCalledWith(
|
|
55
|
+
expect.stringContaining('my-proj-production'),
|
|
56
|
+
expect.anything(),
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should error if project name missing', async () => {
|
|
61
|
+
mockContext.config.deploy.backend.projectName = undefined;
|
|
62
|
+
await expect(provider.provision(mockContext)).rejects.toThrow(
|
|
63
|
+
'Railway project name not found',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should handle dry run', async () => {
|
|
68
|
+
mockContext.options.dryRun = true;
|
|
69
|
+
await provider.provision(mockContext);
|
|
70
|
+
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('[Dry Run]'));
|
|
71
|
+
expect(execAsync).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should handle deleted project', async () => {
|
|
75
|
+
// Use real Error to trigger 'instanceof Error' branch
|
|
76
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
77
|
+
Object.assign(new Error('Error'), { stderr: 'Project is deleted' }),
|
|
78
|
+
);
|
|
79
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
80
|
+
stdout: 'Linked',
|
|
81
|
+
}); // init
|
|
82
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
83
|
+
stdout: 'postgres',
|
|
84
|
+
}); // postgres check
|
|
85
|
+
|
|
86
|
+
await provider.provision(mockContext);
|
|
87
|
+
|
|
88
|
+
expect(execAsync).toHaveBeenCalledWith('railway unlink', expect.anything());
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should handle unlink failure (deleted project)', async () => {
|
|
92
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
93
|
+
Object.assign(new Error('Error'), { stderr: 'Project is deleted' }),
|
|
94
|
+
);
|
|
95
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
96
|
+
new Error('Unlink failed'),
|
|
97
|
+
); // unlink fail
|
|
98
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
99
|
+
stdout: 'Linked',
|
|
100
|
+
}); // init
|
|
101
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
102
|
+
stdout: 'postgres',
|
|
103
|
+
}); // postgres check
|
|
104
|
+
|
|
105
|
+
await provider.provision(mockContext);
|
|
106
|
+
// Should proceed to init despite unlink fail
|
|
107
|
+
expect(execAsync).toHaveBeenCalledWith(
|
|
108
|
+
expect.stringContaining('railway init'),
|
|
109
|
+
expect.anything(),
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should check status and init if not linked', async () => {
|
|
114
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
115
|
+
Object.assign(new Error('Error'), { stderr: 'Project not found' }),
|
|
116
|
+
);
|
|
117
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
118
|
+
stdout: 'Linked',
|
|
119
|
+
});
|
|
120
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
121
|
+
stdout: 'postgres',
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await provider.provision(mockContext);
|
|
125
|
+
|
|
126
|
+
expect(execAsync).toHaveBeenCalledWith(
|
|
127
|
+
expect.stringContaining('railway init --name my-proj'),
|
|
128
|
+
expect.anything(),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should throw on auth failure', async () => {
|
|
133
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
134
|
+
Object.assign(new Error('Error'), { stderr: 'Unauthorized' }),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
await expect(provider.provision(mockContext)).rejects.toThrow(
|
|
138
|
+
'Railway authentication failed',
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should warn on generic status failure with Error object', async () => {
|
|
143
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
144
|
+
new Error('Timeout'),
|
|
145
|
+
); // generic
|
|
146
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
147
|
+
stdout: 'postgres',
|
|
148
|
+
}); // postgres check pass
|
|
149
|
+
|
|
150
|
+
await provider.provision(mockContext);
|
|
151
|
+
|
|
152
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
153
|
+
expect.stringContaining('Railway status check failed'),
|
|
154
|
+
);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should warn on generic status failure with string error', async () => {
|
|
158
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
159
|
+
'Timeout String',
|
|
160
|
+
);
|
|
161
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
162
|
+
stdout: 'postgres',
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await provider.provision(mockContext);
|
|
166
|
+
|
|
167
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
168
|
+
expect.stringContaining('Railway status check failed: Timeout String'),
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should add postgres if missing', async () => {
|
|
173
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
174
|
+
stdout: 'ok',
|
|
175
|
+
}); // status ok
|
|
176
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
177
|
+
stdout: 'empty',
|
|
178
|
+
}); // postgres missing
|
|
179
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
180
|
+
stdout: 'db added',
|
|
181
|
+
}); // add db
|
|
182
|
+
|
|
183
|
+
await provider.provision(mockContext);
|
|
184
|
+
|
|
185
|
+
expect(execAsync).toHaveBeenCalledWith(
|
|
186
|
+
expect.stringContaining('railway add --database postgres'),
|
|
187
|
+
expect.anything(),
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should handle second status check failure (swallow error)', async () => {
|
|
192
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
193
|
+
stdout: 'ok',
|
|
194
|
+
}); // status ok
|
|
195
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
196
|
+
new Error('Status2 fail'),
|
|
197
|
+
); // status2 fail -> catch -> { stdout: '' }
|
|
198
|
+
// If stdout is empty, it proceeds to add postgres?
|
|
199
|
+
// Line 78: if (!status.includes('postgres')) -> '' doesn't include it -> true.
|
|
200
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
201
|
+
stdout: 'db added',
|
|
202
|
+
}); // add db
|
|
203
|
+
|
|
204
|
+
await provider.provision(mockContext);
|
|
205
|
+
|
|
206
|
+
expect(execAsync).toHaveBeenCalledWith(
|
|
207
|
+
expect.stringContaining('railway add --database postgres'),
|
|
208
|
+
expect.anything(),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should handle postgres status check failure (explicit throw in add logic check)', async () => {
|
|
213
|
+
// This tests the logic flow, but "Status2 fail" catch block return value triggers "add"
|
|
214
|
+
// My previous test above covers the catch block.
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('should warn if auto-add postgres fails', async () => {
|
|
218
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
219
|
+
stdout: 'ok',
|
|
220
|
+
}); // status ok
|
|
221
|
+
(execAsync as unknown as { mockResolvedValueOnce: any }).mockResolvedValueOnce({
|
|
222
|
+
stdout: 'empty',
|
|
223
|
+
}); // postgres missing
|
|
224
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
225
|
+
new Error('Add failed'),
|
|
226
|
+
); // add db fail
|
|
227
|
+
|
|
228
|
+
await provider.provision(mockContext);
|
|
229
|
+
|
|
230
|
+
expect(logger.warn).toHaveBeenCalledWith('Failed to auto-add PostgreSQL.');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('should log generic setup failures', async () => {
|
|
234
|
+
const err = new Error('Generic failure') as any;
|
|
235
|
+
err.stderr = 'some stderr';
|
|
236
|
+
err.stdout = 'some stdout';
|
|
237
|
+
|
|
238
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
239
|
+
Object.assign(new Error('Error'), { stderr: 'Project not found' }),
|
|
240
|
+
);
|
|
241
|
+
(execAsync as any).mockRejectedValueOnce(err);
|
|
242
|
+
|
|
243
|
+
await provider.provision(mockContext);
|
|
244
|
+
|
|
245
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
246
|
+
expect.stringContaining('Railway setup failed with error: Generic failure'),
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should handle setup failure with empty output', async () => {
|
|
251
|
+
const errEmpty = new Error('Empty fail') as any;
|
|
252
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
253
|
+
Object.assign(new Error('Error'), { stderr: 'Project not found' }),
|
|
254
|
+
);
|
|
255
|
+
(execAsync as any).mockRejectedValueOnce(errEmpty);
|
|
256
|
+
|
|
257
|
+
await provider.provision(mockContext);
|
|
258
|
+
|
|
259
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
260
|
+
expect.stringContaining('Railway setup failed with error: Empty fail'),
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should handle non-Error exceptions in outer catch', async () => {
|
|
265
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
266
|
+
Object.assign(new Error('Error'), { stderr: 'Project not found' }),
|
|
267
|
+
);
|
|
268
|
+
(execAsync as any).mockRejectedValueOnce('String setup error');
|
|
269
|
+
|
|
270
|
+
await provider.provision(mockContext);
|
|
271
|
+
|
|
272
|
+
expect(logger.error).toHaveBeenCalledWith(
|
|
273
|
+
expect.stringContaining('Railway setup failed with error: String setup error'),
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should handle non-production environment', async () => {
|
|
278
|
+
mockContext.options.env = 'staging';
|
|
279
|
+
(execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
|
|
280
|
+
Object.assign(new Error('Error'), { stderr: 'Project not found' }),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await provider.provision(mockContext);
|
|
284
|
+
|
|
285
|
+
expect(execAsync).toHaveBeenCalledWith(
|
|
286
|
+
expect.stringContaining('railway init --name my-proj-staging'),
|
|
287
|
+
expect.anything(),
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe('getSecrets', () => {
|
|
293
|
+
it('should resolve token from env', async () => {
|
|
294
|
+
process.env.RAILWAY_API_TOKEN = 'tok';
|
|
295
|
+
const secrets = await provider.getSecrets(mockContext);
|
|
296
|
+
expect(secrets['RAILWAY_API_TOKEN']).toBe('tok');
|
|
297
|
+
delete process.env.RAILWAY_API_TOKEN;
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('should resolve token from configured env var', async () => {
|
|
301
|
+
mockContext.config.deploy.backend.options = { tokenEnvVar: 'CUSTOM_RW_TOKEN' };
|
|
302
|
+
process.env.CUSTOM_RW_TOKEN = 'custom-rw-tok';
|
|
303
|
+
|
|
304
|
+
const secrets = await provider.getSecrets(mockContext);
|
|
305
|
+
expect(secrets['RAILWAY_API_TOKEN']).toBe('custom-rw-tok');
|
|
306
|
+
|
|
307
|
+
delete process.env.CUSTOM_RW_TOKEN;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('should error if token missing', async () => {
|
|
311
|
+
delete process.env.RAILWAY_API_TOKEN;
|
|
312
|
+
delete process.env.RAILWAY_TOKEN;
|
|
313
|
+
await expect(provider.getSecrets(mockContext)).rejects.toThrow('Railway Token not found');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('getVariables', () => {
|
|
318
|
+
it('should return empty', async () => {
|
|
319
|
+
expect(await provider.getVariables(mockContext)).toEqual({});
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('getCIConfig', () => {
|
|
324
|
+
it('should return config', () => {
|
|
325
|
+
expect(provider.getCIConfig()).toHaveProperty('deploySteps');
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ProviderRegistry } from '../../../src/deploy/registry.js';
|
|
3
|
+
import fs from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { logger } from '@nexical/cli-core';
|
|
7
|
+
|
|
8
|
+
vi.mock('node:fs/promises');
|
|
9
|
+
vi.mock('@nexical/cli-core', () => ({
|
|
10
|
+
logger: {
|
|
11
|
+
debug: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
warn: vi.fn(),
|
|
14
|
+
info: vi.fn(),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock jiti for local providers
|
|
19
|
+
const mockJitiRequest = vi.fn();
|
|
20
|
+
vi.mock('jiti', () => ({
|
|
21
|
+
createJiti: () => ({
|
|
22
|
+
import: mockJitiRequest,
|
|
23
|
+
}),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
describe('ProviderRegistry', () => {
|
|
27
|
+
let registry: ProviderRegistry;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.resetAllMocks();
|
|
31
|
+
registry = new ProviderRegistry();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('getDeploymentProvider', () => {
|
|
39
|
+
it('should return undefined for non-existent provider', () => {
|
|
40
|
+
expect(registry.getDeploymentProvider('missing')).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('registerProviderFromModule', () => {
|
|
45
|
+
it('should register a valid deployment provider', () => {
|
|
46
|
+
const MockProvider = class {
|
|
47
|
+
name = 'valid-deploy';
|
|
48
|
+
provision() {}
|
|
49
|
+
getCIConfig() {}
|
|
50
|
+
};
|
|
51
|
+
(
|
|
52
|
+
registry as unknown as { registerProviderFromModule: (mod: any, name: string) => void }
|
|
53
|
+
).registerProviderFromModule({ default: MockProvider }, 'test');
|
|
54
|
+
expect(registry.getDeploymentProvider('valid-deploy')).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should register a valid repository provider', () => {
|
|
58
|
+
const MockProvider = class {
|
|
59
|
+
name = 'valid-repo';
|
|
60
|
+
configureSecrets() {}
|
|
61
|
+
generateWorkflow() {}
|
|
62
|
+
};
|
|
63
|
+
(
|
|
64
|
+
registry as unknown as { registerProviderFromModule: (mod: any, name: string) => void }
|
|
65
|
+
).registerProviderFromModule({ default: MockProvider }, 'test');
|
|
66
|
+
expect(registry.getRepositoryProvider('valid-repo')).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle named exports if default is missing', async () => {
|
|
70
|
+
const MockProvider = class {
|
|
71
|
+
name = 'named-export';
|
|
72
|
+
provision() {}
|
|
73
|
+
getCIConfig() {}
|
|
74
|
+
};
|
|
75
|
+
(
|
|
76
|
+
registry as unknown as { registerProviderFromModule: (mod: any, name: string) => void }
|
|
77
|
+
).registerProviderFromModule({ Named: MockProvider }, 'test');
|
|
78
|
+
expect(registry.getDeploymentProvider('named-export')).toBeDefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should handle missing exported provider', async () => {
|
|
82
|
+
const mockModule = {};
|
|
83
|
+
await (
|
|
84
|
+
registry as unknown as {
|
|
85
|
+
registerProviderFromModule: (mod: any, name: string) => Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
).registerProviderFromModule(mockModule, 'test');
|
|
88
|
+
expect(registry.getDeploymentProvider('test')).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should handle instantiation failure', async () => {
|
|
92
|
+
const MockProvider = vi.fn().mockImplementation(() => {
|
|
93
|
+
throw new Error('Instantiate fail');
|
|
94
|
+
});
|
|
95
|
+
const mockModule = { Provider: MockProvider };
|
|
96
|
+
await (
|
|
97
|
+
registry as unknown as {
|
|
98
|
+
registerProviderFromModule: (mod: any, name: string) => Promise<void>;
|
|
99
|
+
}
|
|
100
|
+
).registerProviderFromModule(mockModule, 'fail');
|
|
101
|
+
expect(registry.getDeploymentProvider('fail')).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should handle non-class provider', async () => {
|
|
105
|
+
const mockModule = { Provider: { name: 'static' } };
|
|
106
|
+
await (
|
|
107
|
+
registry as unknown as {
|
|
108
|
+
registerProviderFromModule: (mod: any, name: string) => Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
).registerProviderFromModule(mockModule, 'static');
|
|
111
|
+
expect(registry.getDeploymentProvider('static')).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('loadCoreProviders', () => {
|
|
116
|
+
it('should load core providers from found directory', async () => {
|
|
117
|
+
// Calculate the path where registry.ts expects providers
|
|
118
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
119
|
+
const __dirname = path.dirname(__filename);
|
|
120
|
+
const dirname = path.resolve(__dirname, '../../../src/deploy/providers');
|
|
121
|
+
const mockProviderPath = path.join(dirname, 'mock.js');
|
|
122
|
+
|
|
123
|
+
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
|
|
124
|
+
vi.spyOn(fs, 'readdir').mockResolvedValue(['mock.js'] as unknown as any);
|
|
125
|
+
|
|
126
|
+
// Mock the specific file that registry will import
|
|
127
|
+
vi.doMock(mockProviderPath, () => ({
|
|
128
|
+
default: class MockCore {
|
|
129
|
+
name = 'core-mock';
|
|
130
|
+
provision() {}
|
|
131
|
+
getCIConfig() {}
|
|
132
|
+
},
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
await registry.loadCoreProviders();
|
|
136
|
+
|
|
137
|
+
// Assert
|
|
138
|
+
expect(fs.readdir).toHaveBeenCalled();
|
|
139
|
+
expect(registry.getDeploymentProvider('core-mock')).toBeDefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should warn if no providers directory found', async () => {
|
|
143
|
+
vi.spyOn(fs, 'access').mockRejectedValue(new Error('ENOENT'));
|
|
144
|
+
await registry.loadCoreProviders();
|
|
145
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
146
|
+
expect.stringContaining('Could not locate core providers'),
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should warn if scanning providers fails', async () => {
|
|
151
|
+
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
|
|
152
|
+
vi.spyOn(fs, 'readdir').mockRejectedValue(new Error('Scan fail'));
|
|
153
|
+
await registry.loadCoreProviders();
|
|
154
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
155
|
+
expect.stringContaining('Failed to scan core providers'),
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should warn if loading a provider fails', async () => {
|
|
160
|
+
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
|
|
161
|
+
vi.spyOn(fs, 'readdir').mockResolvedValue(['bad.js'] as unknown as any);
|
|
162
|
+
// We do NOT mock bad.js, so import() should fail
|
|
163
|
+
await registry.loadCoreProviders();
|
|
164
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
165
|
+
expect.stringContaining('Failed to load core provider'),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should ignore non-js/ts files', async () => {
|
|
170
|
+
vi.spyOn(fs, 'access').mockResolvedValue(undefined);
|
|
171
|
+
vi.spyOn(fs, 'readdir').mockResolvedValue(['README.md', 'type.d.ts'] as unknown as any);
|
|
172
|
+
const spy = vi.spyOn(
|
|
173
|
+
registry as unknown as { registerProviderFromModule: any },
|
|
174
|
+
'registerProviderFromModule',
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await registry.loadCoreProviders();
|
|
178
|
+
expect(spy).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe('loadLocalProviders', () => {
|
|
183
|
+
it('should load local providers using jiti', async () => {
|
|
184
|
+
const mockRoot = '/mock/root';
|
|
185
|
+
const deployDir = path.join(mockRoot, 'deploy');
|
|
186
|
+
vi.spyOn(fs, 'readdir').mockResolvedValue(['custom.ts'] as unknown as any);
|
|
187
|
+
|
|
188
|
+
mockJitiRequest.mockResolvedValue({
|
|
189
|
+
default: class {
|
|
190
|
+
name = 'local-custom';
|
|
191
|
+
provision() {}
|
|
192
|
+
getCIConfig() {}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await registry.loadLocalProviders(mockRoot);
|
|
197
|
+
|
|
198
|
+
expect(mockJitiRequest).toHaveBeenCalledWith(path.join(deployDir, 'custom.ts'));
|
|
199
|
+
expect(registry.getDeploymentProvider('local-custom')).toBeDefined();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should ignore non-js/ts files', async () => {
|
|
203
|
+
const mockRoot = '/mock/root';
|
|
204
|
+
vi.spyOn(fs, 'readdir').mockResolvedValue(['README.md', 'notes.txt'] as any);
|
|
205
|
+
|
|
206
|
+
await registry.loadLocalProviders(mockRoot);
|
|
207
|
+
expect(mockJitiRequest).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should skip if directory missing', async () => {
|
|
211
|
+
vi.spyOn(fs, 'readdir').mockRejectedValue(new Error('ENOENT'));
|
|
212
|
+
await registry.loadLocalProviders('/root');
|
|
213
|
+
expect(mockJitiRequest).not.toHaveBeenCalled();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should warn if loading local provider fails', async () => {
|
|
217
|
+
const mockRoot = '/mock/root';
|
|
218
|
+
vi.spyOn(fs, 'readdir').mockResolvedValue(['broken.ts'] as any);
|
|
219
|
+
mockJitiRequest.mockRejectedValue(new Error('Jiti fail'));
|
|
220
|
+
|
|
221
|
+
await registry.loadLocalProviders(mockRoot);
|
|
222
|
+
expect(logger.warn).toHaveBeenCalledWith(
|
|
223
|
+
expect.stringContaining('Failed to load local provider'),
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { checkCommand } from '../../../src/deploy/utils.js';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
vi.mock('node:child_process', () => ({
|
|
6
|
+
exec: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('deploy utils', () => {
|
|
10
|
+
describe('checkCommand', () => {
|
|
11
|
+
it('should return true if command succeeds', async () => {
|
|
12
|
+
vi.mocked(exec).mockImplementation(((
|
|
13
|
+
_cmd: string,
|
|
14
|
+
callback: (err: Error | null, res: { stdout: string }) => void,
|
|
15
|
+
) => {
|
|
16
|
+
callback(null, { stdout: 'ok' });
|
|
17
|
+
return {} as any;
|
|
18
|
+
}) as unknown as typeof exec);
|
|
19
|
+
expect(await checkCommand('foo')).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should return false if command fails', async () => {
|
|
23
|
+
vi.mocked(exec).mockImplementation(((_cmd: string, callback: (err: Error | null) => void) => {
|
|
24
|
+
callback(new Error('fail'));
|
|
25
|
+
return {} as any;
|
|
26
|
+
}) as unknown as typeof exec);
|
|
27
|
+
expect(await checkCommand('foo')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|