@nexical/cli 0.11.7 → 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 (46) hide show
  1. package/dist/{chunk-LZ3YQWAR.js → chunk-OUGA4CB4.js} +15 -11
  2. package/dist/chunk-OUGA4CB4.js.map +1 -0
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/src/commands/init.js +1 -1
  6. package/dist/src/commands/module/add.js +51 -20
  7. package/dist/src/commands/module/add.js.map +1 -1
  8. package/dist/src/commands/module/list.d.ts +1 -0
  9. package/dist/src/commands/module/list.js +55 -46
  10. package/dist/src/commands/module/list.js.map +1 -1
  11. package/dist/src/commands/module/remove.js +38 -13
  12. package/dist/src/commands/module/remove.js.map +1 -1
  13. package/dist/src/commands/module/update.js +16 -4
  14. package/dist/src/commands/module/update.js.map +1 -1
  15. package/dist/src/commands/run.js +19 -2
  16. package/dist/src/commands/run.js.map +1 -1
  17. package/dist/src/commands/setup.js +1 -1
  18. package/package.json +1 -1
  19. package/src/commands/module/add.ts +74 -31
  20. package/src/commands/module/list.ts +80 -57
  21. package/src/commands/module/remove.ts +50 -14
  22. package/src/commands/module/update.ts +19 -5
  23. package/src/commands/run.ts +21 -1
  24. package/test/e2e/lifecycle.e2e.test.ts +3 -2
  25. package/test/integration/commands/deploy.integration.test.ts +102 -0
  26. package/test/integration/commands/init.integration.test.ts +16 -1
  27. package/test/integration/commands/module.integration.test.ts +81 -55
  28. package/test/integration/commands/run.integration.test.ts +69 -74
  29. package/test/integration/commands/setup.integration.test.ts +53 -0
  30. package/test/unit/commands/deploy.test.ts +285 -0
  31. package/test/unit/commands/init.test.ts +15 -0
  32. package/test/unit/commands/module/add.test.ts +363 -254
  33. package/test/unit/commands/module/list.test.ts +100 -99
  34. package/test/unit/commands/module/remove.test.ts +143 -58
  35. package/test/unit/commands/module/update.test.ts +45 -62
  36. package/test/unit/commands/run.test.ts +16 -1
  37. package/test/unit/commands/setup.test.ts +25 -66
  38. package/test/unit/deploy/config-manager.test.ts +65 -0
  39. package/test/unit/deploy/providers/cloudflare.test.ts +210 -0
  40. package/test/unit/deploy/providers/github.test.ts +139 -0
  41. package/test/unit/deploy/providers/railway.test.ts +328 -0
  42. package/test/unit/deploy/registry.test.ts +227 -0
  43. package/test/unit/deploy/utils.test.ts +30 -0
  44. package/test/unit/utils/command-discovery.test.ts +145 -142
  45. package/test/unit/utils/git_utils.test.ts +49 -0
  46. package/dist/chunk-LZ3YQWAR.js.map +0 -1
@@ -1,7 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  import SetupCommand from '../../../src/commands/setup.js';
3
3
  import fs from 'fs-extra';
4
- import path from 'path';
5
4
  import { CLI } from '@nexical/cli-core';
6
5
 
7
6
  // Mock fs-extra
@@ -13,25 +12,8 @@ describe('SetupCommand', () => {
13
12
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
13
  let exitSpy: any;
15
14
 
16
- // Mock BaseCommand methods
17
- // We need to extend SetupCommand or mock the prototype to capture error/warn/success
18
- // Or we can just spy on them if we can access the instance methods.
19
-
20
- // Better approach: Spy on the prototype methods of BaseCommand or the instance itself.
21
- // However, BaseCommand methods like `error` might process.exit.
22
-
23
- // Let's create a subclass for testing or mock the CLI and use the standard instantiation.
24
- // The current SetupCommand implementation calls `process.exit(1)` in `error` logic in `run`.
25
- // Wait, looking at `setup.ts`:
26
- // if (!fs.existsSync(path.join(rootDir, 'core'))) {
27
- // this.error('Could not find "core" directory. Are you in the project root?');
28
- // process.exit(1);
29
- // }
30
-
31
- // So we need to stub process.exit to prevent test runner from exiting.
32
-
33
15
  beforeEach(() => {
34
- vi.clearAllMocks();
16
+ vi.resetAllMocks();
35
17
  mockCli = new CLI({ commandName: 'test-cli' });
36
18
  command = new SetupCommand(mockCli);
37
19
 
@@ -46,7 +28,7 @@ describe('SetupCommand', () => {
46
28
 
47
29
  // Mock process.exit
48
30
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
- exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
31
+ exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as unknown as any);
50
32
  });
51
33
 
52
34
  afterEach(() => {
@@ -54,7 +36,6 @@ describe('SetupCommand', () => {
54
36
  });
55
37
 
56
38
  it('should error if "core" directory is missing', async () => {
57
- // specific check: fs.existsSync returns false for core
58
39
  vi.mocked(fs.existsSync).mockReturnValue(false);
59
40
 
60
41
  await command.run();
@@ -66,12 +47,11 @@ describe('SetupCommand', () => {
66
47
  });
67
48
 
68
49
  it('should warn and skip if app directory is missing', async () => {
69
- // Setup fs mocks
70
50
  vi.mocked(fs.existsSync).mockImplementation((p) => {
71
51
  const pStr = p.toString();
72
52
  if (pStr.endsWith('core')) return true;
73
53
  if (pStr.endsWith('apps/frontend')) return true;
74
- if (pStr.endsWith('apps/backend')) return false; // Missing backend
54
+ if (pStr.endsWith('apps/backend')) return false;
75
55
  return false;
76
56
  });
77
57
 
@@ -82,65 +62,33 @@ describe('SetupCommand', () => {
82
62
  });
83
63
 
84
64
  it('should symlink shared assets', async () => {
85
- // Setup fs mocks
86
65
  vi.mocked(fs.existsSync).mockImplementation((p) => {
87
66
  const pStr = p.toString();
88
- // Core exists
89
67
  if (pStr.endsWith('core')) return true;
90
- // Apps exist
91
68
  if (pStr.endsWith('apps/frontend') || pStr.endsWith('apps/backend')) return true;
92
-
93
- // Shared assets in core exist
94
69
  if (pStr.includes('core/') && !pStr.endsWith('core')) return true;
95
-
96
70
  return false;
97
71
  });
98
72
 
99
73
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
- vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as any);
74
+ vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as unknown as any);
101
75
 
102
76
  await command.run();
103
77
 
104
- // Check if verify apps are processed
105
78
  expect(command.info).toHaveBeenCalledWith('Setting up frontend...');
106
79
  expect(command.info).toHaveBeenCalledWith('Setting up backend...');
107
-
108
- // Check symlink calls
109
- // We have 2 apps * 7 shared assets = 14 symlinks
110
- // sharedAssets = ['prisma', 'src', 'public', 'locales', 'scripts']
111
-
112
- const assets = ['prisma', 'src', 'public', 'locales', 'scripts'];
113
-
114
- for (const app of ['frontend', 'backend']) {
115
- for (const asset of assets) {
116
- const dest = path.join('/mock/project/root', 'apps', app, asset);
117
- // const source = path.join('/mock/project/root', 'core', asset);
118
-
119
- // Ensure removeSync called
120
- expect(fs.removeSync).toHaveBeenCalledWith(dest);
121
-
122
- // Ensure symlink called
123
- // valid relative path calculation might vary, but verify arguments
124
- expect(fs.symlink).toHaveBeenCalled();
125
- }
126
- }
127
-
80
+ expect(fs.removeSync).toHaveBeenCalled();
81
+ expect(fs.symlink).toHaveBeenCalled();
128
82
  expect(command.success).toHaveBeenCalledWith('Application setup complete.');
129
83
  });
130
84
 
131
85
  it('should warn if source asset is missing in core', async () => {
132
- // Setup fs mocks
133
86
  vi.mocked(fs.existsSync).mockImplementation((p) => {
134
87
  const pStr = p.toString();
135
88
  if (pStr.endsWith('core')) return true;
136
89
  if (pStr.includes('apps/')) return true;
137
-
138
- // Mock that 'prisma' is missing in core
139
90
  if (pStr.endsWith('core/prisma')) return false;
140
-
141
- // Others exist
142
91
  if (pStr.includes('core/') && !pStr.endsWith('core')) return true;
143
-
144
92
  return false;
145
93
  });
146
94
 
@@ -149,15 +97,15 @@ describe('SetupCommand', () => {
149
97
  expect(command.warn).toHaveBeenCalledWith('Source asset prisma not found in core.');
150
98
  });
151
99
 
152
- it('should throw error if removal fails with non-ENOENT', async () => {
100
+ it('should log error if removal fails with non-ENOENT', async () => {
153
101
  vi.mocked(fs.existsSync).mockReturnValue(true);
154
102
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
155
- vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as any);
103
+ vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as unknown as any);
156
104
 
157
105
  const error = new Error('Permission denied');
158
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
- (error as any).code = 'EACCES';
160
- vi.mocked(fs.removeSync).mockImplementation(() => {
106
+
107
+ (error as unknown as { code: string }).code = 'EACCES';
108
+ vi.mocked(fs.removeSync).mockImplementationOnce(() => {
161
109
  throw error;
162
110
  });
163
111
 
@@ -166,14 +114,25 @@ describe('SetupCommand', () => {
166
114
  expect(command.error).toHaveBeenCalledWith(expect.stringContaining('Failed to symlink'));
167
115
  });
168
116
 
169
- it('should log error if symlink fails', async () => {
117
+ it('should log error if symlink fails with Error object', async () => {
170
118
  vi.mocked(fs.existsSync).mockReturnValue(true);
171
119
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
172
- vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as any);
173
- vi.mocked(fs.symlink).mockRejectedValue(new Error('Symlink failed'));
120
+ vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as unknown as any);
121
+ vi.mocked(fs.symlink).mockRejectedValueOnce(new Error('Symlink failed'));
174
122
 
175
123
  await command.run();
176
124
 
177
125
  expect(command.error).toHaveBeenCalledWith(expect.stringContaining('Failed to symlink'));
178
126
  });
127
+
128
+ it('should log error if symlink fails with non-Error object', async () => {
129
+ vi.mocked(fs.existsSync).mockReturnValue(true);
130
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
+ vi.mocked(fs.lstatSync).mockReturnValue({ isSymbolicLink: () => true } as unknown as any);
132
+ vi.mocked(fs.symlink).mockRejectedValueOnce('String symlink fail');
133
+
134
+ await command.run();
135
+
136
+ expect(command.error).toHaveBeenCalledWith(expect.stringContaining('String symlink fail'));
137
+ });
179
138
  });
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { ConfigManager } from '../../../src/deploy/config-manager.js';
3
+ import fs from 'node:fs/promises';
4
+ import { NexicalConfig } from '../../../src/deploy/types.js';
5
+
6
+ vi.mock('node:fs/promises');
7
+
8
+ describe('ConfigManager', () => {
9
+ let manager: ConfigManager;
10
+
11
+ beforeEach(() => {
12
+ vi.resetAllMocks();
13
+ manager = new ConfigManager('/mock');
14
+ });
15
+
16
+ it('should load config', async () => {
17
+ const mockConfig: NexicalConfig = {
18
+ deploy: {
19
+ backend: {
20
+ provider: 'railway',
21
+ },
22
+ },
23
+ };
24
+ vi.mocked(fs.readFile).mockResolvedValue('deploy:\n backend:\n provider: railway');
25
+ const config = await manager.load();
26
+ expect(config).toEqual(mockConfig);
27
+ });
28
+
29
+ it('should return empty object if config missing', async () => {
30
+ const error = new Error('not found');
31
+ (error as unknown as { code: string }).code = 'ENOENT';
32
+ vi.mocked(fs.readFile).mockRejectedValue(error);
33
+ const config = await manager.load();
34
+ expect(config).toEqual({});
35
+ });
36
+
37
+ it('should throw on other load errors', async () => {
38
+ vi.mocked(fs.readFile).mockRejectedValue(new Error('crash'));
39
+ await expect(manager.load()).rejects.toThrow('crash');
40
+ });
41
+
42
+ it('should save config', async () => {
43
+ const mockConfig: NexicalConfig = {
44
+ deploy: {
45
+ frontend: {
46
+ provider: 'cloudflare',
47
+ },
48
+ },
49
+ };
50
+ await manager.save(mockConfig);
51
+ expect(fs.writeFile).toHaveBeenCalledWith(
52
+ expect.any(String),
53
+ expect.stringContaining('provider: cloudflare'),
54
+ 'utf-8',
55
+ );
56
+ });
57
+
58
+ it('should check existence', async () => {
59
+ vi.mocked(fs.access).mockResolvedValue(undefined);
60
+ expect(await manager.exists()).toBe(true);
61
+
62
+ vi.mocked(fs.access).mockRejectedValue(new Error());
63
+ expect(await manager.exists()).toBe(false);
64
+ });
65
+ });
@@ -0,0 +1,210 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { CloudflareProvider } from '../../../../src/deploy/providers/cloudflare.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
+ warn: vi.fn(),
11
+ error: vi.fn(),
12
+ },
13
+ }));
14
+
15
+ describe('CloudflareProvider', () => {
16
+ let provider: CloudflareProvider;
17
+ let mockContext: { cwd: string; options: Record<string, unknown>; config: any };
18
+
19
+ beforeEach(() => {
20
+ vi.resetAllMocks();
21
+ provider = new CloudflareProvider();
22
+ mockContext = {
23
+ cwd: '/mock',
24
+ options: {}, // Env undefined by default to test 'production' fallback
25
+ config: {
26
+ deploy: {
27
+ frontend: {
28
+ projectName: 'my-app',
29
+ // options intentionally undefined here to test fallback
30
+ },
31
+ },
32
+ },
33
+ };
34
+ (execAsync as unknown as { mockResolvedValue: any }).mockResolvedValue({
35
+ stdout: '',
36
+ stderr: '',
37
+ });
38
+ });
39
+
40
+ afterEach(() => {
41
+ vi.clearAllMocks();
42
+ delete process.env.CLOUDFLARE_API_TOKEN;
43
+ delete process.env.CLOUDFLARE_ACCOUNT_ID;
44
+ delete process.env.CUSTOM_CF_TOKEN;
45
+ delete process.env.CUSTOM_CF_ACC;
46
+ });
47
+
48
+ describe('provision', () => {
49
+ it('should error if project name is missing', async () => {
50
+ mockContext.config.deploy.frontend.projectName = undefined;
51
+ await expect(provider.provision(mockContext)).rejects.toThrow(
52
+ 'Cloudflare project name not found',
53
+ );
54
+ });
55
+
56
+ it('should handle dry run', async () => {
57
+ mockContext.options.dryRun = true;
58
+ await provider.provision(mockContext);
59
+ expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('[Dry Run]'));
60
+ expect(execAsync).not.toHaveBeenCalled();
61
+ });
62
+
63
+ it('should skip if credentials missing', async () => {
64
+ // No env vars set
65
+ await provider.provision(mockContext);
66
+ expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('credentials missing'));
67
+ expect(execAsync).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it('should provision successfully using default env vars', async () => {
71
+ process.env.CLOUDFLARE_API_TOKEN = 'tok';
72
+ process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
73
+
74
+ await provider.provision(mockContext);
75
+
76
+ expect(execAsync).toHaveBeenCalledWith(
77
+ expect.stringContaining('wrangler pages project create my-app --production-branch main'),
78
+ expect.anything(),
79
+ );
80
+ });
81
+
82
+ it('should swallow "project already exists" error', async () => {
83
+ process.env.CLOUDFLARE_API_TOKEN = 'tok';
84
+ process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
85
+ (execAsync as unknown as { mockRejectedValueOnce: any }).mockRejectedValueOnce(
86
+ new Error('Already exists'),
87
+ );
88
+
89
+ await provider.provision(mockContext);
90
+
91
+ expect(logger.info).toHaveBeenCalledWith(
92
+ expect.stringContaining('Cloudflare project might already exist'),
93
+ );
94
+ });
95
+
96
+ it('should rethrow critical provisioning errors', async () => {
97
+ process.env.CLOUDFLARE_API_TOKEN = 'tok';
98
+ process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
99
+
100
+ (logger.info as unknown as { mockImplementationOnce: any }).mockImplementationOnce(() => {}); // Config...
101
+ (logger.info as unknown as { mockImplementationOnce: any }).mockImplementationOnce(() => {
102
+ throw new Error('Critical');
103
+ }); // Ensuring...
104
+
105
+ await expect(provider.provision(mockContext)).rejects.toThrow('Critical');
106
+ expect(logger.warn).toHaveBeenCalledWith('Cloudflare setup failed.');
107
+ });
108
+
109
+ it('should handle non-production environment', async () => {
110
+ mockContext.options.env = 'staging';
111
+ mockContext.config.deploy.frontend.options = {}; // Ensure options exist
112
+ process.env.CLOUDFLARE_API_TOKEN = 'tok';
113
+ process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
114
+
115
+ await provider.provision(mockContext);
116
+
117
+ expect(execAsync).toHaveBeenCalledWith(
118
+ expect.stringContaining('wrangler pages project create my-app-staging'),
119
+ expect.anything(),
120
+ );
121
+ });
122
+
123
+ it('should use configured env vars for credentials', async () => {
124
+ mockContext.config.deploy.frontend.options = {
125
+ apiTokenEnvVar: 'CUSTOM_CF_TOKEN',
126
+ accountIdEnvVar: 'CUSTOM_CF_ACC',
127
+ };
128
+ process.env.CUSTOM_CF_TOKEN = 'custom-tok';
129
+ process.env.CUSTOM_CF_ACC = 'custom-acc';
130
+
131
+ await provider.provision(mockContext);
132
+
133
+ expect(execAsync).toHaveBeenCalledWith(
134
+ expect.anything(),
135
+ expect.objectContaining({
136
+ env: expect.objectContaining({
137
+ CLOUDFLARE_API_TOKEN: 'custom-tok',
138
+ CLOUDFLARE_ACCOUNT_ID: 'custom-acc',
139
+ }),
140
+ }),
141
+ );
142
+ });
143
+ });
144
+
145
+ describe('getSecrets', () => {
146
+ it('should resolve secrets from default env vars', async () => {
147
+ process.env.CLOUDFLARE_API_TOKEN = 'tok';
148
+ process.env.CLOUDFLARE_ACCOUNT_ID = 'acc';
149
+ // options undefined by default in beforeEach
150
+ const secrets = await provider.getSecrets(mockContext);
151
+ expect(secrets['CLOUDFLARE_API_TOKEN']).toBe('tok');
152
+ expect(secrets['CLOUDFLARE_ACCOUNT_ID']).toBe('acc');
153
+ });
154
+
155
+ it('should resolve secrets from configured env vars', async () => {
156
+ mockContext.config.deploy.frontend.options = {
157
+ apiTokenEnvVar: 'CUSTOM_CF_TOKEN',
158
+ accountIdEnvVar: 'CUSTOM_CF_ACC',
159
+ };
160
+ process.env.CUSTOM_CF_TOKEN = 'custom-tok';
161
+ process.env.CUSTOM_CF_ACC = 'custom-acc';
162
+
163
+ const secrets = await provider.getSecrets(mockContext);
164
+ expect(secrets['CLOUDFLARE_API_TOKEN']).toBe('custom-tok');
165
+ expect(secrets['CLOUDFLARE_ACCOUNT_ID']).toBe('custom-acc');
166
+ });
167
+
168
+ it('should error if API Token missing', async () => {
169
+ // No env vars
170
+ await expect(provider.getSecrets(mockContext)).rejects.toThrow(
171
+ 'Cloudflare API Token not found',
172
+ );
173
+ });
174
+
175
+ it('should error if Account ID missing', async () => {
176
+ process.env.CLOUDFLARE_API_TOKEN = 'tok';
177
+ // No account ID
178
+ await expect(provider.getSecrets(mockContext)).rejects.toThrow(
179
+ 'Cloudflare Account ID not found',
180
+ );
181
+ });
182
+ });
183
+
184
+ describe('getVariables', () => {
185
+ it('should return project name for production', async () => {
186
+ const vars = await provider.getVariables(mockContext);
187
+ expect(vars['CLOUDFLARE_PROJECT_NAME']).toBe('my-app');
188
+ });
189
+
190
+ it('should return project name for staging', async () => {
191
+ mockContext.options.env = 'staging';
192
+ const vars = await provider.getVariables(mockContext);
193
+ expect(vars['CLOUDFLARE_PROJECT_NAME']).toBe('my-app-staging');
194
+ });
195
+
196
+ it('should error if project name missing', async () => {
197
+ mockContext.config.deploy.frontend.projectName = undefined;
198
+ await expect(provider.getVariables(mockContext)).rejects.toThrow(
199
+ 'Cloudflare project name not found',
200
+ );
201
+ });
202
+ });
203
+
204
+ describe('getCIConfig', () => {
205
+ it('should return config', () => {
206
+ const config = provider.getCIConfig();
207
+ expect(config.githubActionStep?.uses).toBe('cloudflare/wrangler-action@v3');
208
+ });
209
+ });
210
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { GitHubProvider } from '../../../../src/deploy/providers/github.js';
3
+ import { execAsync } from '../../../../src/deploy/utils.js';
4
+ import { logger } from '@nexical/cli-core';
5
+ import fs from 'node:fs/promises';
6
+
7
+ vi.mock('node:fs/promises');
8
+ vi.mock('../../../../src/deploy/utils.js');
9
+ vi.mock('@nexical/cli-core', () => ({
10
+ logger: {
11
+ info: vi.fn(),
12
+ },
13
+ }));
14
+
15
+ describe('GitHubProvider', () => {
16
+ let provider: GitHubProvider;
17
+ let mockContext: any;
18
+
19
+ beforeEach(() => {
20
+ vi.resetAllMocks();
21
+ provider = new GitHubProvider();
22
+ mockContext = {
23
+ cwd: '/mock',
24
+ options: {},
25
+ config: { deploy: { backend: {}, frontend: {} } },
26
+ } as unknown as any;
27
+ (execAsync as unknown as { mockResolvedValue: (val: unknown) => void }).mockResolvedValue({
28
+ stdout: '',
29
+ stderr: '',
30
+ });
31
+ });
32
+
33
+ afterEach(() => {
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ describe('configureSecrets', () => {
38
+ it('should set secrets', async () => {
39
+ await provider.configureSecrets(mockContext, { KEY: 'VALUE' });
40
+ expect(execAsync).toHaveBeenCalledWith(expect.stringContaining('gh secret set KEY'));
41
+ });
42
+
43
+ it('should skip empty secrets', async () => {
44
+ await provider.configureSecrets(mockContext, { KEY: '' });
45
+ expect(execAsync).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it('should handle dry run', async () => {
49
+ mockContext.options.dryRun = true;
50
+ await provider.configureSecrets(mockContext, { KEY: 'VALUE' });
51
+ expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('[Dry Run]'));
52
+ expect(execAsync).not.toHaveBeenCalled();
53
+ });
54
+ });
55
+
56
+ describe('configureVariables', () => {
57
+ it('should set variables', async () => {
58
+ await provider.configureVariables(mockContext, { KEY: 'VALUE' });
59
+ expect(execAsync).toHaveBeenCalledWith(expect.stringContaining('gh variable set KEY'));
60
+ });
61
+
62
+ it('should skip empty variables', async () => {
63
+ await provider.configureVariables(mockContext, { KEY: '' });
64
+ expect(execAsync).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('should handle dry run', async () => {
68
+ mockContext.options.dryRun = true;
69
+ await provider.configureVariables(mockContext, { KEY: 'VALUE' });
70
+ expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('[Dry Run]'));
71
+ expect(execAsync).not.toHaveBeenCalled();
72
+ });
73
+ });
74
+
75
+ describe('generateWorkflow', () => {
76
+ it('should generate workflow file', async () => {
77
+ const targets = [
78
+ {
79
+ type: 'frontend',
80
+ name: 'cf',
81
+ getCIConfig: () => ({
82
+ installSteps: ['run install'],
83
+ deploySteps: ['run deploy'],
84
+ secrets: ['SEC'],
85
+ githubActionStep: { name: 'Action' },
86
+ }),
87
+ },
88
+ {
89
+ type: 'backend',
90
+ name: 'rw',
91
+ getCIConfig: () => ({
92
+ deploySteps: ['run backend'],
93
+ }),
94
+ },
95
+ ] as unknown as any;
96
+
97
+ await provider.generateWorkflow(mockContext, targets);
98
+
99
+ expect(fs.mkdir).toHaveBeenCalled();
100
+ expect(fs.writeFile).toHaveBeenCalledTimes(2);
101
+ });
102
+
103
+ it('should skip if no config', async () => {
104
+ const targets = [
105
+ {
106
+ type: 'frontend',
107
+ getCIConfig: () => null,
108
+ },
109
+ ] as unknown as any;
110
+ await provider.generateWorkflow(mockContext, targets);
111
+ expect(fs.writeFile).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('should handle no targets', async () => {
115
+ await provider.generateWorkflow(mockContext, []);
116
+ expect(fs.writeFile).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it('should handle target with no deploy steps', async () => {
120
+ const targets = [
121
+ {
122
+ type: 'backend',
123
+ name: 'test',
124
+ getCIConfig: () => ({
125
+ // explicit undefined deploySteps
126
+ deploySteps: undefined,
127
+ secrets: [],
128
+ }),
129
+ },
130
+ ] as unknown as any;
131
+
132
+ await provider.generateWorkflow(mockContext, targets);
133
+ expect(fs.writeFile).toHaveBeenCalled();
134
+ // Verify content doesn't crash
135
+ const content = (fs.writeFile as unknown as { mock: { calls: any[][] } }).mock.calls[0][1];
136
+ expect(content).toContain('Deploy Backend to test');
137
+ });
138
+ });
139
+ });