@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
|
@@ -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.
|
|
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;
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
(error as
|
|
160
|
-
vi.mocked(fs.removeSync).
|
|
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).
|
|
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
|
+
});
|