@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.
Files changed (42) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.js.map +1 -1
  3. package/dist/src/commands/init.js +3 -3
  4. package/dist/src/commands/module/add.js +53 -22
  5. package/dist/src/commands/module/add.js.map +1 -1
  6. package/dist/src/commands/module/list.d.ts +1 -0
  7. package/dist/src/commands/module/list.js +54 -45
  8. package/dist/src/commands/module/list.js.map +1 -1
  9. package/dist/src/commands/module/remove.js +37 -12
  10. package/dist/src/commands/module/remove.js.map +1 -1
  11. package/dist/src/commands/module/update.js +15 -3
  12. package/dist/src/commands/module/update.js.map +1 -1
  13. package/dist/src/commands/run.js +18 -1
  14. package/dist/src/commands/run.js.map +1 -1
  15. package/package.json +1 -1
  16. package/src/commands/module/add.ts +74 -31
  17. package/src/commands/module/list.ts +80 -57
  18. package/src/commands/module/remove.ts +50 -14
  19. package/src/commands/module/update.ts +19 -5
  20. package/src/commands/run.ts +21 -1
  21. package/test/e2e/lifecycle.e2e.test.ts +3 -2
  22. package/test/integration/commands/deploy.integration.test.ts +102 -0
  23. package/test/integration/commands/init.integration.test.ts +16 -1
  24. package/test/integration/commands/module.integration.test.ts +81 -55
  25. package/test/integration/commands/run.integration.test.ts +69 -74
  26. package/test/integration/commands/setup.integration.test.ts +53 -0
  27. package/test/unit/commands/deploy.test.ts +285 -0
  28. package/test/unit/commands/init.test.ts +15 -0
  29. package/test/unit/commands/module/add.test.ts +363 -254
  30. package/test/unit/commands/module/list.test.ts +100 -99
  31. package/test/unit/commands/module/remove.test.ts +143 -58
  32. package/test/unit/commands/module/update.test.ts +45 -62
  33. package/test/unit/commands/run.test.ts +16 -1
  34. package/test/unit/commands/setup.test.ts +25 -66
  35. package/test/unit/deploy/config-manager.test.ts +65 -0
  36. package/test/unit/deploy/providers/cloudflare.test.ts +210 -0
  37. package/test/unit/deploy/providers/github.test.ts +139 -0
  38. package/test/unit/deploy/providers/railway.test.ts +328 -0
  39. package/test/unit/deploy/registry.test.ts +227 -0
  40. package/test/unit/deploy/utils.test.ts +30 -0
  41. package/test/unit/utils/command-discovery.test.ts +145 -142
  42. 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
+ });