k6ctl 1.0.0
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/.github/workflows/ci.yml +61 -0
- package/README.md +45 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +28 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/list.d.ts +4 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +35 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/run.d.ts +9 -0
- package/dist/commands/run.d.ts.map +1 -0
- package/dist/commands/run.js +76 -0
- package/dist/commands/run.js.map +1 -0
- package/dist/services/kubernetes.service.d.ts +20 -0
- package/dist/services/kubernetes.service.d.ts.map +1 -0
- package/dist/services/kubernetes.service.js +278 -0
- package/dist/services/kubernetes.service.js.map +1 -0
- package/dist/services/script.service.d.ts +8 -0
- package/dist/services/script.service.d.ts.map +1 -0
- package/dist/services/script.service.js +82 -0
- package/dist/services/script.service.js.map +1 -0
- package/dist/types/config.types.d.ts +26 -0
- package/dist/types/config.types.d.ts.map +1 -0
- package/dist/types/config.types.js +3 -0
- package/dist/types/config.types.js.map +1 -0
- package/dist/types/kubernetes.types.d.ts +9 -0
- package/dist/types/kubernetes.types.d.ts.map +1 -0
- package/dist/types/kubernetes.types.js +3 -0
- package/dist/types/kubernetes.types.js.map +1 -0
- package/dist/types/script.types.d.ts +11 -0
- package/dist/types/script.types.d.ts.map +1 -0
- package/dist/types/script.types.js +3 -0
- package/dist/types/script.types.js.map +1 -0
- package/dist/types/testRunManifest.types.d.ts +43 -0
- package/dist/types/testRunManifest.types.d.ts.map +1 -0
- package/dist/types/testRunManifest.types.js +3 -0
- package/dist/types/testRunManifest.types.js.map +1 -0
- package/dist/utils/configLoader.d.ts +30 -0
- package/dist/utils/configLoader.d.ts.map +1 -0
- package/dist/utils/configLoader.js +63 -0
- package/dist/utils/configLoader.js.map +1 -0
- package/dist/utils/env.d.ts +17 -0
- package/dist/utils/env.d.ts.map +1 -0
- package/dist/utils/env.js +111 -0
- package/dist/utils/env.js.map +1 -0
- package/dist/utils/logger.d.ts +4 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +16 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/table.util.d.ts +10 -0
- package/dist/utils/table.util.d.ts.map +1 -0
- package/dist/utils/table.util.js +24 -0
- package/dist/utils/table.util.js.map +1 -0
- package/dist/utils/testRunManifestBuilder.d.ts +5 -0
- package/dist/utils/testRunManifestBuilder.d.ts.map +1 -0
- package/dist/utils/testRunManifestBuilder.js +89 -0
- package/dist/utils/testRunManifestBuilder.js.map +1 -0
- package/package.json +44 -0
- package/src/cli.ts +31 -0
- package/src/commands/list.ts +29 -0
- package/src/commands/run.ts +52 -0
- package/src/services/kubernetes.service.ts +238 -0
- package/src/services/script.service.ts +79 -0
- package/src/types/config.types.ts +25 -0
- package/src/types/kubernetes.types.ts +9 -0
- package/src/types/script.types.ts +8 -0
- package/src/types/testRunManifest.types.ts +42 -0
- package/src/utils/configLoader.ts +63 -0
- package/src/utils/env.ts +90 -0
- package/src/utils/logger.ts +17 -0
- package/src/utils/table.util.ts +23 -0
- package/src/utils/testRunManifestBuilder.ts +102 -0
- package/test/integration/env.test.ts +33 -0
- package/test/integration/kubernetes.service.test.ts +70 -0
- package/test/integration/script.service.test.ts +64 -0
- package/test/jest.config.integration.js +20 -0
- package/test/jest.config.unit.js +14 -0
- package/test/samples/data_sample_1.csv +4 -0
- package/test/samples/k6_script_sample_1.js +9 -0
- package/test/samples/k6_script_sample_2.js +18 -0
- package/test/unit/configLoader.test.ts +53 -0
- package/test/unit/env.test.ts +1029 -0
- package/test/unit/kubernetes.service.test.ts +197 -0
- package/test/unit/script.service.test.ts +112 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, jest, test } from '@jest/globals';
|
|
2
|
+
import { existsSync, promises as fs_promises } from 'fs';
|
|
3
|
+
import * as k8s from '@kubernetes/client-node';
|
|
4
|
+
import logger from '../../src/utils/logger';
|
|
5
|
+
import { KubernetesService, createDefaultKubernetesService } from '../../src/services/kubernetes.service';
|
|
6
|
+
import type { ArchivedFile } from '../../src/types/kubernetes.types';
|
|
7
|
+
|
|
8
|
+
jest.mock('fs', () => ({
|
|
9
|
+
existsSync: jest.fn(),
|
|
10
|
+
promises: {
|
|
11
|
+
stat: jest.fn(),
|
|
12
|
+
readFile: jest.fn(),
|
|
13
|
+
},
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
jest.mock('@kubernetes/client-node', () => {
|
|
17
|
+
const mockApiClient = {
|
|
18
|
+
createNamespacedConfigMap: jest.fn(),
|
|
19
|
+
deleteNamespacedConfigMap: jest.fn(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const mockLoadFromDefault = jest.fn();
|
|
23
|
+
const mockSetCurrentContext = jest.fn();
|
|
24
|
+
const mockMakeApiClient = jest.fn(() => mockApiClient);
|
|
25
|
+
|
|
26
|
+
const KubeConfig = jest.fn().mockImplementation(() => ({
|
|
27
|
+
loadFromDefault: mockLoadFromDefault,
|
|
28
|
+
setCurrentContext: mockSetCurrentContext,
|
|
29
|
+
makeApiClient: mockMakeApiClient,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
KubeConfig,
|
|
34
|
+
CoreV1Api: jest.fn(),
|
|
35
|
+
__mocks: {
|
|
36
|
+
mockApiClient,
|
|
37
|
+
mockLoadFromDefault,
|
|
38
|
+
mockSetCurrentContext,
|
|
39
|
+
mockMakeApiClient,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
jest.mock('../../src/utils/logger', () => ({
|
|
45
|
+
__esModule: true,
|
|
46
|
+
default: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
const mockedExistsSync = existsSync as unknown as jest.Mock;
|
|
50
|
+
const mockedStat = fs_promises.stat as unknown as jest.Mock;
|
|
51
|
+
const mockedReadFile = fs_promises.readFile as unknown as jest.Mock;
|
|
52
|
+
const mockedLoggerInfo = logger.info as unknown as jest.Mock;
|
|
53
|
+
|
|
54
|
+
const mockedK8sModule = k8s as unknown as {
|
|
55
|
+
KubeConfig: jest.Mock;
|
|
56
|
+
CoreV1Api: unknown;
|
|
57
|
+
__mocks: {
|
|
58
|
+
mockApiClient: {
|
|
59
|
+
createNamespacedConfigMap: jest.Mock;
|
|
60
|
+
deleteNamespacedConfigMap: jest.Mock;
|
|
61
|
+
};
|
|
62
|
+
mockLoadFromDefault: jest.Mock;
|
|
63
|
+
mockSetCurrentContext: jest.Mock;
|
|
64
|
+
mockMakeApiClient: jest.Mock;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe('KubernetesService', () => {
|
|
69
|
+
let service: KubernetesService;
|
|
70
|
+
let mockCoreV1Api: {
|
|
71
|
+
createNamespacedConfigMap: jest.Mock;
|
|
72
|
+
deleteNamespacedConfigMap: jest.Mock;
|
|
73
|
+
};
|
|
74
|
+
let mockCustomObjectsApi: {
|
|
75
|
+
createNamespacedCustomObject: jest.Mock;
|
|
76
|
+
deleteNamespacedCustomObject: jest.Mock;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const archiveFile: ArchivedFile = {
|
|
80
|
+
archivePath: '/tmp/archive-script.tar',
|
|
81
|
+
archiveFilename: 'archive-script.tar',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
jest.clearAllMocks();
|
|
86
|
+
|
|
87
|
+
mockCoreV1Api = {
|
|
88
|
+
createNamespacedConfigMap: jest.fn(),
|
|
89
|
+
deleteNamespacedConfigMap: jest.fn(),
|
|
90
|
+
};
|
|
91
|
+
mockCustomObjectsApi = {
|
|
92
|
+
createNamespacedCustomObject: jest.fn(),
|
|
93
|
+
deleteNamespacedCustomObject: jest.fn(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
service = new KubernetesService(mockCoreV1Api as unknown as k8s.CoreV1Api, mockCustomObjectsApi as unknown as k8s.CustomObjectsApi);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('createConfigMap', () => {
|
|
100
|
+
test('throws when archive file does not exist', async () => {
|
|
101
|
+
mockedExistsSync.mockReturnValue(false);
|
|
102
|
+
|
|
103
|
+
await expect(service.createConfigMap(archiveFile, 'default'))
|
|
104
|
+
.rejects.toThrow('Archive file not found at path: /tmp/archive-script.tar');
|
|
105
|
+
|
|
106
|
+
expect(mockedStat).not.toHaveBeenCalled();
|
|
107
|
+
expect(mockCoreV1Api.createNamespacedConfigMap).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('throws when archive file is larger than 1MB', async () => {
|
|
111
|
+
mockedExistsSync.mockReturnValue(true);
|
|
112
|
+
mockedStat.mockImplementation(async () => ({ size: 1024 * 1024 + 1 }));
|
|
113
|
+
|
|
114
|
+
await expect(service.createConfigMap(archiveFile, 'default'))
|
|
115
|
+
.rejects.toThrow('Archive file is too large to be stored in a configmap');
|
|
116
|
+
|
|
117
|
+
expect(mockedReadFile).not.toHaveBeenCalled();
|
|
118
|
+
expect(mockCoreV1Api.createNamespacedConfigMap).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('creates configmap with binaryData and returns namespace/name', async () => {
|
|
122
|
+
mockedExistsSync.mockReturnValue(true);
|
|
123
|
+
mockedStat.mockImplementation(async () => ({ size: 512 }));
|
|
124
|
+
mockedReadFile.mockImplementation(async () => 'YmFzZTY0');
|
|
125
|
+
mockCoreV1Api.createNamespacedConfigMap.mockImplementation(async () => ({}));
|
|
126
|
+
|
|
127
|
+
const result = await service.createConfigMap(archiveFile, 'performance');
|
|
128
|
+
|
|
129
|
+
expect(mockedReadFile).toHaveBeenCalledWith('/tmp/archive-script.tar', 'base64');
|
|
130
|
+
expect(mockCoreV1Api.createNamespacedConfigMap).toHaveBeenCalledWith({
|
|
131
|
+
namespace: 'performance',
|
|
132
|
+
body: {
|
|
133
|
+
apiVersion: 'v1',
|
|
134
|
+
kind: 'ConfigMap',
|
|
135
|
+
metadata: {
|
|
136
|
+
name: 'archive-script',
|
|
137
|
+
namespace: 'performance',
|
|
138
|
+
},
|
|
139
|
+
binaryData: {
|
|
140
|
+
'archive-script.tar': 'YmFzZTY0',
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
expect(result).toEqual({
|
|
145
|
+
namespace: 'performance',
|
|
146
|
+
configMapName: 'archive-script',
|
|
147
|
+
});
|
|
148
|
+
expect(mockedLoggerInfo).toHaveBeenCalledWith('ConfigMap archive-script created in namespace performance');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('deleteConfigMap', () => {
|
|
153
|
+
test('deletes configmap and logs message on success', async () => {
|
|
154
|
+
mockCoreV1Api.deleteNamespacedConfigMap.mockImplementation(async () => ({}));
|
|
155
|
+
|
|
156
|
+
await service.deleteConfigMap('archive-script', 'default');
|
|
157
|
+
|
|
158
|
+
expect(mockCoreV1Api.deleteNamespacedConfigMap).toHaveBeenCalledWith({
|
|
159
|
+
name: 'archive-script',
|
|
160
|
+
namespace: 'default',
|
|
161
|
+
});
|
|
162
|
+
expect(mockedLoggerInfo).toHaveBeenCalledWith('ConfigMap archive-script deleted from namespace default');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('wraps and throws error when delete fails', async () => {
|
|
166
|
+
mockCoreV1Api.deleteNamespacedConfigMap.mockImplementation(async () => {
|
|
167
|
+
throw new Error('kaboom');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await expect(service.deleteConfigMap('archive-script', 'default'))
|
|
171
|
+
.rejects.toThrow('Failed to delete ConfigMap archive-script from namespace default: kaboom');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('createDefaultKubernetesService', () => {
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
jest.clearAllMocks();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('loads kube config and creates KubernetesService without context', () => {
|
|
182
|
+
const service = createDefaultKubernetesService();
|
|
183
|
+
|
|
184
|
+
expect(service).toBeInstanceOf(KubernetesService);
|
|
185
|
+
expect(mockedK8sModule.KubeConfig).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(mockedK8sModule.__mocks.mockLoadFromDefault).toHaveBeenCalledTimes(1);
|
|
187
|
+
expect(mockedK8sModule.__mocks.mockSetCurrentContext).not.toHaveBeenCalled();
|
|
188
|
+
expect(mockedK8sModule.__mocks.mockMakeApiClient).toHaveBeenCalledWith(mockedK8sModule.CoreV1Api);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('sets current context when context is provided', () => {
|
|
192
|
+
createDefaultKubernetesService('minikube');
|
|
193
|
+
|
|
194
|
+
expect(mockedK8sModule.__mocks.mockSetCurrentContext).toHaveBeenCalledWith('minikube');
|
|
195
|
+
expect(mockedK8sModule.__mocks.mockMakeApiClient).toHaveBeenCalledWith(mockedK8sModule.CoreV1Api);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, jest, test } from '@jest/globals';
|
|
2
|
+
import { ScriptService } from '../../src/services/script.service';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import type { ExecFn } from '../../src/types/script.types';
|
|
5
|
+
|
|
6
|
+
jest.mock('fs', () => ({
|
|
7
|
+
existsSync: jest.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
jest.mock('../../src/utils/logger', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
default: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
const mockedExistsSync = existsSync as unknown as jest.Mock;
|
|
16
|
+
|
|
17
|
+
describe('ScriptService', () => {
|
|
18
|
+
let mockExec: jest.MockedFunction<ExecFn>;
|
|
19
|
+
let service: ScriptService;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
jest.resetAllMocks();
|
|
23
|
+
mockExec = jest.fn<ExecFn>();
|
|
24
|
+
service = new ScriptService(mockExec);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('archiveTest', () => {
|
|
28
|
+
test('throws error if script does not exist', async () => {
|
|
29
|
+
mockedExistsSync.mockReturnValue(false);
|
|
30
|
+
|
|
31
|
+
await expect(service.archiveTest('/path/to/script.js'))
|
|
32
|
+
.rejects.toThrow('Script file not found at path: /path/to/script.js');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('throws error if output directory does not exist', async () => {
|
|
36
|
+
mockedExistsSync
|
|
37
|
+
.mockReturnValueOnce(true) // script exists
|
|
38
|
+
.mockReturnValueOnce(false); // output dir missing
|
|
39
|
+
|
|
40
|
+
await expect(service.archiveTest('/path/to/script.js', '/fake/output'))
|
|
41
|
+
.rejects.toThrow('Output directory does not exist at path: /fake/output');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('throws error if k6 is not installed', async () => {
|
|
45
|
+
mockedExistsSync.mockReturnValue(true);
|
|
46
|
+
mockExec.mockRejectedValue(new Error('command not found: k6'));
|
|
47
|
+
|
|
48
|
+
await expect(service.archiveTest('/path/to/script.js'))
|
|
49
|
+
.rejects.toThrow('k6 is not installed. Please install k6 to archive scripts.');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('throws error if archive file was not created', async () => {
|
|
53
|
+
mockedExistsSync
|
|
54
|
+
.mockReturnValueOnce(true) // script exists
|
|
55
|
+
.mockReturnValueOnce(false); // archive file not created after command
|
|
56
|
+
mockExec.mockResolvedValue({ stdout: '', stderr: 'k6 internal error' });
|
|
57
|
+
|
|
58
|
+
await expect(service.archiveTest('/path/to/script.js'))
|
|
59
|
+
.rejects.toThrow('Failed to archive the script:');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('returns correct ArchiveResult without output directory', async () => {
|
|
63
|
+
mockedExistsSync
|
|
64
|
+
.mockReturnValueOnce(true) // script exists
|
|
65
|
+
.mockReturnValueOnce(true); // archive file created
|
|
66
|
+
mockExec.mockResolvedValue({ stdout: '', stderr: '' });
|
|
67
|
+
|
|
68
|
+
const result = await service.archiveTest('/path/to/script.js');
|
|
69
|
+
|
|
70
|
+
expect(result.scriptPath).toBe('/path/to/script.js');
|
|
71
|
+
expect(result.scriptFilename).toBe('script.js');
|
|
72
|
+
expect(result.archiveFilename).toMatch(/^archive-script-\d+\.tar$/);
|
|
73
|
+
expect(result.archivePath).toMatch(/^archive-script-\d+\.tar$/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('returns ArchiveResult with the specified output directory', async () => {
|
|
77
|
+
mockedExistsSync
|
|
78
|
+
.mockReturnValueOnce(true) // script exists
|
|
79
|
+
.mockReturnValueOnce(true) // output dir exists
|
|
80
|
+
.mockReturnValueOnce(true); // archive file created
|
|
81
|
+
mockExec.mockResolvedValue({ stdout: '', stderr: '' });
|
|
82
|
+
|
|
83
|
+
const result = await service.archiveTest('/path/to/script.js', '/output/dir');
|
|
84
|
+
|
|
85
|
+
expect(result.archivePath).toMatch(/^\/output\/dir[/\\]archive-script-\d+\.tar$/);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('calls k6 version and then the archive command', async () => {
|
|
89
|
+
mockedExistsSync
|
|
90
|
+
.mockReturnValueOnce(true)
|
|
91
|
+
.mockReturnValueOnce(true);
|
|
92
|
+
mockExec.mockResolvedValue({ stdout: '', stderr: '' });
|
|
93
|
+
|
|
94
|
+
await service.archiveTest('/path/to/script.js');
|
|
95
|
+
|
|
96
|
+
expect(mockExec).toHaveBeenCalledTimes(2);
|
|
97
|
+
expect(mockExec).toHaveBeenNthCalledWith(1, 'k6 version');
|
|
98
|
+
expect((mockExec.mock.calls[1][0] as string)).toMatch(/^k6 archive -v -O .+ \/path\/to\/script\.js$/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('sanitizes underscores in script name', async () => {
|
|
102
|
+
mockedExistsSync
|
|
103
|
+
.mockReturnValueOnce(true)
|
|
104
|
+
.mockReturnValueOnce(true);
|
|
105
|
+
mockExec.mockResolvedValue({ stdout: '', stderr: '' });
|
|
106
|
+
|
|
107
|
+
const result = await service.archiveTest('/path/to/my_script_v2.js');
|
|
108
|
+
|
|
109
|
+
expect(result.archiveFilename).toMatch(/^archive-my-script-v2-\d+\.tar$/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "commonjs",
|
|
5
|
+
"lib": ["ES2022"],
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"moduleResolution": "node",
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"declaration": true,
|
|
15
|
+
"declarationMap": true,
|
|
16
|
+
"sourceMap": true
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist"]
|
|
20
|
+
}
|