codify-plugin-test 0.0.33 → 0.0.34

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.
@@ -0,0 +1,179 @@
1
+ import Ajv from 'ajv';
2
+ import {
3
+ ApplyRequestData, ImportRequestData, ImportResponseData,
4
+ InitializeResponseData,
5
+ IpcMessageSchema,
6
+ IpcMessageV2,
7
+ MessageCmd, PlanRequestData, PlanResponseData,
8
+ SpawnStatus,
9
+ SudoRequestData,
10
+ SudoRequestDataSchema, ValidateRequestData, ValidateResponseData
11
+ } from 'codify-schemas';
12
+ import { nanoid } from 'nanoid';
13
+ import { ChildProcess, SpawnOptions, fork, spawn } from 'node:child_process';
14
+ import path from 'node:path';
15
+
16
+ import { CodifyTestUtils } from './test-utils.js';
17
+
18
+ const ajv = new Ajv.default({
19
+ strict: true
20
+ });
21
+
22
+ const ipcMessageValidator = ajv.compile(IpcMessageSchema);
23
+ const sudoRequestValidator = ajv.compile(SudoRequestDataSchema);
24
+
25
+ export class PluginProcess {
26
+ childProcess: ChildProcess
27
+
28
+ /**
29
+ * PluginTester is a helper class to integration test plugins. It launches plugins via fork() just like CodifyCLI does.
30
+ *
31
+ * @param pluginPath A fully qualified path
32
+ */
33
+ constructor(pluginPath: string) {
34
+ if (!path.isAbsolute(pluginPath)) {
35
+ throw new Error('A fully qualified path must be supplied to PluginTester');
36
+ }
37
+
38
+ this.childProcess = fork(
39
+ pluginPath,
40
+ [],
41
+ {
42
+ // Use default true to test plugins in secure mode (un-able to request sudo directly)
43
+ // detached: true,
44
+ env: { ...process.env },
45
+ execArgv: ['--import', 'tsx/esm'],
46
+ },
47
+ )
48
+
49
+ this.handleSudoRequests(this.childProcess);
50
+ }
51
+
52
+ async initialize(): Promise<InitializeResponseData> {
53
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
54
+ cmd: 'initialize',
55
+ data: {},
56
+ requestId: nanoid(6),
57
+ });
58
+ }
59
+
60
+ async validate(data: ValidateRequestData): Promise<ValidateResponseData> {
61
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
62
+ cmd: 'validate',
63
+ data,
64
+ requestId: nanoid(6),
65
+ });
66
+ }
67
+
68
+ async plan(data: PlanRequestData): Promise<PlanResponseData> {
69
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
70
+ cmd: 'plan',
71
+ data,
72
+ requestId: nanoid(6),
73
+ });
74
+ }
75
+
76
+ async apply(data: ApplyRequestData): Promise<void> {
77
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
78
+ cmd: 'apply',
79
+ data,
80
+ requestId: nanoid(6),
81
+ });
82
+ }
83
+
84
+ async import(data: ImportRequestData): Promise<ImportResponseData> {
85
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
86
+ cmd: 'import',
87
+ data,
88
+ requestId: nanoid(6),
89
+ });
90
+ }
91
+
92
+ kill() {
93
+ this.childProcess.kill();
94
+ }
95
+
96
+ private handleSudoRequests(process: ChildProcess) {
97
+ // Listen for incoming sudo incoming sudo requests
98
+ process.on('message', async (message) => {
99
+ if (!ipcMessageValidator(message)) {
100
+ throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
101
+ }
102
+
103
+ if (message.cmd === MessageCmd.SUDO_REQUEST) {
104
+ const { data, requestId } = message;
105
+ if (!sudoRequestValidator(data)) {
106
+ throw new Error(`Invalid sudo request from plugin. ${JSON.stringify(sudoRequestValidator.errors, null, 2)}`);
107
+ }
108
+
109
+ const { command, options } = data as unknown as SudoRequestData;
110
+
111
+ console.log(`Running command with sudo: 'sudo ${command}'`)
112
+ const result = await sudoSpawn(command, options);
113
+
114
+ process.send(<IpcMessageV2>{
115
+ cmd: MessageCmd.SUDO_REQUEST + '_Response',
116
+ data: result,
117
+ requestId,
118
+ })
119
+ }
120
+ })
121
+ }
122
+
123
+ }
124
+
125
+ type CodifySpawnOptions = {
126
+ cwd?: string;
127
+ throws?: boolean,
128
+ } & Omit<SpawnOptions, 'detached' | 'shell' | 'stdio'>
129
+
130
+ /**
131
+ *
132
+ * @param cmd Command to run. Ex: `rm -rf`
133
+ * @param opts Options for spawn
134
+ *
135
+ * @see promiseSpawn
136
+ * @see spawn
137
+ *
138
+ * @returns SpawnResult { status: SUCCESS | ERROR; data: string }
139
+ */
140
+ async function sudoSpawn(
141
+ cmd: string,
142
+ opts: CodifySpawnOptions,
143
+ ): Promise<{ data: string, status: SpawnStatus }> {
144
+ return new Promise((resolve) => {
145
+ const output: string[] = [];
146
+
147
+ const _cmd = `sudo ${cmd}`;
148
+
149
+ // Source start up shells to emulate a users environment vs. a non-interactive non-login shell script
150
+ // Ignore all stdin
151
+ const _process = spawn(`source ~/.zshrc; ${_cmd}`, [], {
152
+ ...opts,
153
+ shell: 'zsh',
154
+ stdio: ['ignore', 'pipe', 'pipe'],
155
+ });
156
+
157
+ const { stderr, stdout } = _process
158
+ stdout.setEncoding('utf8');
159
+ stderr.setEncoding('utf8');
160
+
161
+ stdout.on('data', (data) => {
162
+ output.push(data.toString());
163
+ })
164
+
165
+ stderr.on('data', (data) => {
166
+ output.push(data.toString());
167
+ })
168
+
169
+ stdout.pipe(process.stdout);
170
+ stderr.pipe(process.stderr);
171
+
172
+ _process.on('close', (code) => {
173
+ resolve({
174
+ data: output.join(''),
175
+ status: code === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
176
+ })
177
+ })
178
+ })
179
+ }
@@ -1,64 +1,17 @@
1
- import Ajv from 'ajv';
1
+ import chalk from 'chalk';
2
2
  import {
3
- ApplyRequestData,
4
- ImportRequestData,
5
3
  ImportResponseData,
6
- InitializeResponseData,
7
- IpcMessageSchema,
8
- IpcMessageV2,
9
- MessageCmd,
10
- PlanRequestData,
11
4
  PlanResponseData,
12
5
  ResourceConfig,
13
6
  ResourceOperation,
14
- SpawnStatus,
15
- SudoRequestData,
16
- SudoRequestDataSchema,
17
- ValidateRequestData,
18
- ValidateResponseData
19
7
  } from 'codify-schemas';
20
8
  import unionBy from 'lodash.unionby';
21
- import { nanoid } from 'nanoid';
22
- import { ChildProcess, SpawnOptions, fork, spawn } from 'node:child_process';
23
- import path from 'node:path';
24
-
25
- import { CodifyTestUtils } from './test-utils.js';
26
-
27
- const ajv = new Ajv.default({
28
- strict: true
29
- });
30
- const ipcMessageValidator = ajv.compile(IpcMessageSchema);
31
- const sudoRequestValidator = ajv.compile(SudoRequestDataSchema);
32
9
 
10
+ import { PluginProcess } from './plugin-process.js';
33
11
 
34
12
  export class PluginTester {
35
- childProcess: ChildProcess
36
-
37
- /**
38
- * PluginTester is a helper class to integration test plugins. It launches plugins via fork() just like CodifyCLI does.
39
- *
40
- * @param pluginPath A fully qualified path
41
- */
42
- constructor(pluginPath: string) {
43
- if (!path.isAbsolute(pluginPath)) {
44
- throw new Error('A fully qualified path must be supplied to PluginTester');
45
- }
46
-
47
- this.childProcess = fork(
48
- pluginPath,
49
- [],
50
- {
51
- // Use default true to test plugins in secure mode (un-able to request sudo directly)
52
- // detached: true,
53
- env: { ...process.env },
54
- execArgv: ['--import', 'tsx/esm', '--inspect=9221'],
55
- },
56
- )
57
-
58
- this.handleSudoRequests(this.childProcess);
59
- }
60
-
61
- async fullTest(
13
+ static async fullTest(
14
+ pluginPath: string,
62
15
  configs: ResourceConfig[],
63
16
  options?: {
64
17
  skipUninstall?: boolean,
@@ -71,91 +24,110 @@ export class PluginTester {
71
24
  validateModify?: (plans: PlanResponseData[]) => Promise<void> | void,
72
25
  }
73
26
  }): Promise<void> {
27
+ const ids = configs.map((c) => c.name ? `${c.type}.${c.name}` : c.type).join(', ')
28
+ console.info(chalk.cyan(`Starting full test of [ ${ids} ]...`))
29
+
74
30
  const {
75
31
  skipUninstall = false,
76
32
  } = options ?? {}
77
33
 
78
- const ids = configs.map((c) => c.name ? `${c.type}.${c.name}` : c.type).join(',')
79
34
 
80
- console.info(`Testing initialization for ${ids}...`)
81
- const initializeResult = await this.initialize();
35
+ const plugin = new PluginProcess(pluginPath);
36
+ try {
37
+ console.info(chalk.cyan('Testing initialization...'))
38
+ const initializeResult = await plugin.initialize();
82
39
 
83
- const unsupportedConfigs = configs.filter((c) =>
84
- !initializeResult.resourceDefinitions.some((rd) => rd.type === c.type)
85
- )
86
- if (unsupportedConfigs.length > 0) {
87
- throw new Error(`The plugin does not support the following configs supplied:\n ${JSON.stringify(unsupportedConfigs, null, 2)}\n Initialize result: ${JSON.stringify(initializeResult)}`)
88
- }
40
+ const unsupportedConfigs = configs.filter((c) =>
41
+ !initializeResult.resourceDefinitions.some((rd) => rd.type === c.type)
42
+ )
43
+ if (unsupportedConfigs.length > 0) {
44
+ throw new Error(`The plugin does not support the following configs supplied:\n ${JSON.stringify(unsupportedConfigs, null, 2)}\n Initialize result: ${JSON.stringify(initializeResult)}`)
45
+ }
89
46
 
90
- console.info(`Testing validate for ${ids}...`)
91
- const validate = await this.validate({ configs });
47
+ console.info(chalk.cyan('Testing validate...'))
48
+ const validate = await plugin.validate({ configs });
92
49
 
93
- const invalidConfigs = validate.resourceValidations.filter((v) => !v.isValid)
94
- if (invalidConfigs.length > 0) {
95
- throw new Error(`The following configs did not validate:\n ${JSON.stringify(invalidConfigs, null, 2)}`)
96
- }
50
+ const invalidConfigs = validate.resourceValidations.filter((v) => !v.isValid)
51
+ if (invalidConfigs.length > 0) {
52
+ throw new Error(`The following configs did not validate:\n ${JSON.stringify(invalidConfigs, null, 2)}`)
53
+ }
97
54
 
98
- console.info(`Testing plan for ${ids}...`)
99
- const plans = [];
100
- for (const config of configs) {
101
- plans.push(await this.plan({
102
- desired: config,
103
- isStateful: false,
104
- state: undefined,
105
- }));
106
- }
55
+ console.info(chalk.cyan('Testing plan...'))
56
+ const plans = [];
57
+ for (const config of configs) {
58
+ plans.push(await plugin.plan({
59
+ desired: config,
60
+ isStateful: false,
61
+ state: undefined,
62
+ }));
63
+ }
107
64
 
108
- if (options?.validatePlan) {
109
- await options.validatePlan(plans);
110
- }
65
+ if (options?.validatePlan) {
66
+ await options.validatePlan(plans);
67
+ }
111
68
 
112
- console.info(`Testing apply for ${ids}...`)
113
- for (const plan of plans) {
114
- await this.apply({
115
- planId: plan.planId
116
- });
117
- }
69
+ console.info(chalk.cyan('Testing apply...'))
70
+ for (const plan of plans) {
71
+ await plugin.apply({
72
+ planId: plan.planId
73
+ });
74
+ }
118
75
 
119
- if (options?.validateApply) {
120
- await options.validateApply(plans);
76
+ if (options?.validateApply) {
77
+ await options.validateApply(plans);
78
+ }
79
+ } finally {
80
+ plugin.kill();
121
81
  }
122
82
 
123
- console.info(`Testing import for ${ids}...`)
124
- const importResults = [];
125
- for (const config of configs) {
126
- const importResult = await this.import({ config })
127
- importResults.push(importResult);
128
- }
83
+ const importPlugin = new PluginProcess(pluginPath);
84
+ try {
85
+ console.info(chalk.cyan('Testing import...'))
86
+
87
+ const importResults = [];
88
+ for (const config of configs) {
89
+ const importResult = await importPlugin.import({ config })
90
+ importResults.push(importResult);
91
+ }
129
92
 
130
- if (options?.validateImport) {
131
- await options.validateImport(importResults.map((r) => r.result[0]));
93
+ if (options?.validateImport) {
94
+ await options.validateImport(importResults.map((r) => r.result[0]));
95
+ }
96
+ } finally {
97
+ importPlugin.kill();
132
98
  }
133
99
 
134
100
  if (options?.testModify) {
135
- console.info(`Testing modify for ${ids}...`)
136
-
137
- const modifyPlans = [];
138
- for (const config of options.testModify.modifiedConfigs) {
139
- modifyPlans.push(await this.plan({
140
- desired: config,
141
- isStateful: false,
142
- state: undefined,
143
- }));
144
- }
101
+ const modifyPlugin = new PluginProcess(pluginPath);
102
+
103
+ try {
104
+ console.info(chalk.cyan('Testing modify...'))
105
+
106
+ const modifyPlans = [];
107
+ for (const config of options.testModify.modifiedConfigs) {
108
+ modifyPlans.push(await modifyPlugin.plan({
109
+ desired: config,
110
+ isStateful: false,
111
+ state: undefined,
112
+ }));
113
+ }
145
114
 
146
- if (modifyPlans.some((p) => p.operation !== ResourceOperation.MODIFY)) {
147
- throw new Error(`Error while testing modify. Non-modify results were found in the plan:
115
+ if (modifyPlans.some((p) => p.operation !== ResourceOperation.MODIFY)) {
116
+ throw new Error(`Error while testing modify. Non-modify results were found in the plan:
148
117
  ${JSON.stringify(modifyPlans, null, 2)}`)
149
- }
118
+ }
150
119
 
151
- for (const plan of modifyPlans) {
152
- await this.apply({
153
- planId: plan.planId
154
- });
155
- }
120
+ for (const plan of modifyPlans) {
121
+ await modifyPlugin.apply({
122
+ planId: plan.planId
123
+ });
124
+ }
156
125
 
157
- if (options.testModify.validateModify) {
158
- await options.testModify.validateModify(modifyPlans);
126
+ if (options.testModify.validateModify) {
127
+ await options.testModify.validateModify(modifyPlans);
128
+ }
129
+ } finally {
130
+ modifyPlugin.kill();
159
131
  }
160
132
  }
161
133
 
@@ -167,112 +139,46 @@ ${JSON.stringify(modifyPlans, null, 2)}`)
167
139
  const id = (config: ResourceConfig) => config.type + (config.name ? `.${config.name}` : '')
168
140
 
169
141
  const configsToDestroy = unionBy(modifiedConfigs, configsWithNames, id);
170
- await this.uninstall(configsToDestroy.toReversed(), options);
142
+ await this.uninstall(pluginPath, configsToDestroy.toReversed(), options);
171
143
  }
172
144
  }
173
145
 
174
- async uninstall(configs: ResourceConfig[], options?: {
146
+ static async uninstall(pluginPath: string, configs: ResourceConfig[], options?: {
175
147
  validateDestroy?: (plans: PlanResponseData[]) => Promise<void> | void
176
148
  }) {
177
- const ids = configs.map((c) => c.name ? `${c.type}.${c.name}` : c.type).join(',')
178
- console.info(`Testing initialization for ${ids}...`)
179
-
180
- const plans = [];
181
- for (const config of configs) {
182
- plans.push(await this.plan({
183
- desired: undefined,
184
- isStateful: true,
185
- state: config
186
- }))
187
- }
188
-
189
- for (const plan of plans) {
190
- if (plan.operation !== ResourceOperation.DESTROY) {
191
- throw new Error(`Expect resource operation to be 'destroy' but instead received plan: \n ${JSON.stringify(plan, null, 2)}`)
192
- }
193
-
194
- await this.apply({
195
- planId: plan.planId
196
- });
197
- }
198
-
199
- if (options?.validateDestroy) {
200
- await options.validateDestroy(plans);
201
- }
202
- }
203
-
204
- async initialize(): Promise<InitializeResponseData> {
205
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
206
- cmd: 'initialize',
207
- data: {},
208
- requestId: nanoid(6),
209
- });
210
- }
211
-
212
- async validate(data: ValidateRequestData): Promise<ValidateResponseData> {
213
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
214
- cmd: 'validate',
215
- data,
216
- requestId: nanoid(6),
217
- });
218
- }
219
-
220
- async plan(data: PlanRequestData): Promise<PlanResponseData> {
221
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
222
- cmd: 'plan',
223
- data,
224
- requestId: nanoid(6),
225
- });
226
- }
227
-
228
- async apply(data: ApplyRequestData): Promise<void> {
229
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
230
- cmd: 'apply',
231
- data,
232
- requestId: nanoid(6),
233
- });
234
- }
235
-
236
- async import(data: ImportRequestData): Promise<ImportResponseData> {
237
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
238
- cmd: 'import',
239
- data,
240
- requestId: nanoid(6),
241
- });
242
- }
243
-
244
- kill() {
245
- this.childProcess.kill();
246
- }
247
-
248
- private handleSudoRequests(process: ChildProcess) {
249
- // Listen for incoming sudo incoming sudo requests
250
- process.on('message', async (message) => {
251
- if (!ipcMessageValidator(message)) {
252
- throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
149
+ const destroyPlugin = new PluginProcess(pluginPath);
150
+
151
+ try {
152
+ console.info(chalk.cyan('Testing destroy...'))
153
+
154
+ const plans = [];
155
+ for (const config of configs) {
156
+ plans.push(await destroyPlugin.plan({
157
+ isStateful: true,
158
+ state: config,
159
+ desired: undefined
160
+ }))
253
161
  }
254
162
 
255
- if (message.cmd === MessageCmd.SUDO_REQUEST) {
256
- const { data, requestId } = message;
257
- if (!sudoRequestValidator(data)) {
258
- throw new Error(`Invalid sudo request from plugin. ${JSON.stringify(sudoRequestValidator.errors, null, 2)}`);
163
+ for (const plan of plans) {
164
+ if (plan.operation !== ResourceOperation.DESTROY) {
165
+ throw new Error(`Expect resource operation to be 'destroy' but instead received plan: \n ${JSON.stringify(plans, null, 2)}`)
259
166
  }
260
167
 
261
- const { command, options } = data as unknown as SudoRequestData;
262
-
263
- console.log(`Running command with sudo: 'sudo ${command}'`)
264
- const result = await sudoSpawn(command, options);
168
+ await destroyPlugin.apply({
169
+ planId: plan.planId
170
+ });
171
+ }
265
172
 
266
- process.send(<IpcMessageV2>{
267
- cmd: MessageCmd.SUDO_REQUEST + '_Response',
268
- data: result,
269
- requestId,
270
- })
173
+ if (options?.validateDestroy) {
174
+ await options.validateDestroy(plans);
271
175
  }
272
- })
176
+ } finally {
177
+ destroyPlugin.kill();
178
+ }
273
179
  }
274
180
 
275
- private addNamesToConfigs(configs: ResourceConfig[]): ResourceConfig[] {
181
+ private static addNamesToConfigs(configs: ResourceConfig[]): ResourceConfig[] {
276
182
  const configsWithNames = new Array<ResourceConfig>();
277
183
 
278
184
  const typeSet = new Set(configs.map((c) => c.type));
@@ -289,59 +195,3 @@ ${JSON.stringify(modifyPlans, null, 2)}`)
289
195
  }
290
196
  }
291
197
 
292
-
293
- type CodifySpawnOptions = {
294
- cwd?: string;
295
- throws?: boolean,
296
- } & Omit<SpawnOptions, 'detached' | 'shell' | 'stdio'>
297
-
298
- /**
299
- *
300
- * @param cmd Command to run. Ex: `rm -rf`
301
- * @param opts Options for spawn
302
- *
303
- * @see promiseSpawn
304
- * @see spawn
305
- *
306
- * @returns SpawnResult { status: SUCCESS | ERROR; data: string }
307
- */
308
- async function sudoSpawn(
309
- cmd: string,
310
- opts: CodifySpawnOptions,
311
- ): Promise<{ data: string, status: SpawnStatus }> {
312
- return new Promise((resolve) => {
313
- const output: string[] = [];
314
-
315
- const _cmd = `sudo ${cmd}`;
316
-
317
- // Source start up shells to emulate a users environment vs. a non-interactive non-login shell script
318
- // Ignore all stdin
319
- const _process = spawn(`source ~/.zshrc; ${_cmd}`, [], {
320
- ...opts,
321
- shell: 'zsh',
322
- stdio: ['ignore', 'pipe', 'pipe'],
323
- });
324
-
325
- const { stderr, stdout } = _process
326
- stdout.setEncoding('utf8');
327
- stderr.setEncoding('utf8');
328
-
329
- stdout.on('data', (data) => {
330
- output.push(data.toString());
331
- })
332
-
333
- stderr.on('data', (data) => {
334
- output.push(data.toString());
335
- })
336
-
337
- stdout.pipe(process.stdout);
338
- stderr.pipe(process.stderr);
339
-
340
- _process.on('close', (code) => {
341
- resolve({
342
- data: output.join(''),
343
- status: code === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
344
- })
345
- })
346
- })
347
- }