codify-plugin-test 0.0.32 → 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,197 +24,161 @@ 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 initializeResult = await this.initialize();
79
-
80
- const unsupportedConfigs = configs.filter((c) =>
81
- !initializeResult.resourceDefinitions.some((rd) => rd.type === c.type)
82
- )
83
- if (unsupportedConfigs.length > 0) {
84
- throw new Error(`The plugin does not support the following configs supplied:\n ${JSON.stringify(unsupportedConfigs, null, 2)}\n Initialize result: ${JSON.stringify(initializeResult)}`)
85
- }
86
-
87
- const validate = await this.validate({ configs });
88
34
 
89
- const invalidConfigs = validate.resourceValidations.filter((v) => !v.isValid)
90
- if (invalidConfigs.length > 0) {
91
- throw new Error(`The following configs did not validate:\n ${JSON.stringify(invalidConfigs, null, 2)}`)
92
- }
35
+ const plugin = new PluginProcess(pluginPath);
36
+ try {
37
+ console.info(chalk.cyan('Testing initialization...'))
38
+ const initializeResult = await plugin.initialize();
93
39
 
94
- const plans = [];
95
- for (const config of configs) {
96
- plans.push(await this.plan({
97
- desired: config,
98
- isStateful: false,
99
- state: undefined,
100
- }));
101
- }
102
-
103
- if (options?.validatePlan) {
104
- await options.validatePlan(plans);
105
- }
106
-
107
- for (const plan of plans) {
108
- await this.apply({
109
- planId: plan.planId
110
- });
111
- }
112
-
113
- if (options?.validateApply) {
114
- await options.validateApply(plans);
115
- }
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
+ }
116
46
 
117
- const importResults = [];
118
- for (const config of configs) {
119
- const importResult = await this.import({ config })
120
- importResults.push(importResult);
121
- }
47
+ console.info(chalk.cyan('Testing validate...'))
48
+ const validate = await plugin.validate({ configs });
122
49
 
123
- if (options?.validateImport) {
124
- await options.validateImport(importResults.map((r) => r.result[0]));
125
- }
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
+ }
126
54
 
127
- if (options?.testModify) {
128
- const modifyPlans = [];
129
- for (const config of options.testModify.modifiedConfigs) {
130
- modifyPlans.push(await this.plan({
55
+ console.info(chalk.cyan('Testing plan...'))
56
+ const plans = [];
57
+ for (const config of configs) {
58
+ plans.push(await plugin.plan({
131
59
  desired: config,
132
60
  isStateful: false,
133
61
  state: undefined,
134
62
  }));
135
63
  }
136
64
 
137
- if (modifyPlans.some((p) => p.operation !== ResourceOperation.MODIFY)) {
138
- throw new Error(`Error while testing modify. Non-modify results were found in the plan:
139
- ${JSON.stringify(modifyPlans, null, 2)}`)
65
+ if (options?.validatePlan) {
66
+ await options.validatePlan(plans);
140
67
  }
141
68
 
142
- for (const plan of modifyPlans) {
143
- await this.apply({
69
+ console.info(chalk.cyan('Testing apply...'))
70
+ for (const plan of plans) {
71
+ await plugin.apply({
144
72
  planId: plan.planId
145
73
  });
146
74
  }
147
75
 
148
- if (options.testModify.validateModify) {
149
- await options.testModify.validateModify(modifyPlans);
76
+ if (options?.validateApply) {
77
+ await options.validateApply(plans);
150
78
  }
79
+ } finally {
80
+ plugin.kill();
151
81
  }
152
82
 
153
- if (!skipUninstall) {
154
- // We need to add unique names to multiple configs with the same type or else it breaks the unionBy below.
155
- const configsWithNames = this.addNamesToConfigs(configs);
156
- const modifiedConfigs = this.addNamesToConfigs(options?.testModify?.modifiedConfigs ?? [])
83
+ const importPlugin = new PluginProcess(pluginPath);
84
+ try {
85
+ console.info(chalk.cyan('Testing import...'))
157
86
 
158
- const id = (config: ResourceConfig) => config.type + (config.name ? `.${config.name}` : '')
87
+ const importResults = [];
88
+ for (const config of configs) {
89
+ const importResult = await importPlugin.import({ config })
90
+ importResults.push(importResult);
91
+ }
159
92
 
160
- const configsToDestroy = unionBy(modifiedConfigs, configsWithNames, id);
161
- await this.uninstall(configsToDestroy.toReversed(), options);
93
+ if (options?.validateImport) {
94
+ await options.validateImport(importResults.map((r) => r.result[0]));
95
+ }
96
+ } finally {
97
+ importPlugin.kill();
162
98
  }
163
- }
164
99
 
165
- async uninstall(configs: ResourceConfig[], options?: {
166
- validateDestroy?: (plans: PlanResponseData[]) => Promise<void> | void
167
- }) {
168
- const plans = [];
169
-
170
- for (const config of configs) {
171
- plans.push(await this.plan({
172
- desired: undefined,
173
- isStateful: true,
174
- state: config
175
- }))
176
- }
100
+ if (options?.testModify) {
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
+ }
177
114
 
178
- for (const plan of plans) {
179
- if (plan.operation !== ResourceOperation.DESTROY) {
180
- throw new Error(`Expect resource operation to be 'destory' but instead received plan: \n ${JSON.stringify(plan, null, 2)}`)
181
- }
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:
117
+ ${JSON.stringify(modifyPlans, null, 2)}`)
118
+ }
182
119
 
183
- await this.apply({
184
- planId: plan.planId
185
- });
186
- }
120
+ for (const plan of modifyPlans) {
121
+ await modifyPlugin.apply({
122
+ planId: plan.planId
123
+ });
124
+ }
187
125
 
188
- if (options?.validateDestroy) {
189
- await options.validateDestroy(plans);
126
+ if (options.testModify.validateModify) {
127
+ await options.testModify.validateModify(modifyPlans);
128
+ }
129
+ } finally {
130
+ modifyPlugin.kill();
131
+ }
190
132
  }
191
- }
192
-
193
- async initialize(): Promise<InitializeResponseData> {
194
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
195
- cmd: 'initialize',
196
- data: {},
197
- requestId: nanoid(6),
198
- });
199
- }
200
133
 
201
- async validate(data: ValidateRequestData): Promise<ValidateResponseData> {
202
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
203
- cmd: 'validate',
204
- data,
205
- requestId: nanoid(6),
206
- });
207
- }
208
-
209
- async plan(data: PlanRequestData): Promise<PlanResponseData> {
210
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
211
- cmd: 'plan',
212
- data,
213
- requestId: nanoid(6),
214
- });
215
- }
134
+ if (!skipUninstall) {
135
+ // We need to add unique names to multiple configs with the same type or else it breaks the unionBy below.
136
+ const configsWithNames = this.addNamesToConfigs(configs);
137
+ const modifiedConfigs = this.addNamesToConfigs(options?.testModify?.modifiedConfigs ?? [])
216
138
 
217
- async apply(data: ApplyRequestData): Promise<void> {
218
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
219
- cmd: 'apply',
220
- data,
221
- requestId: nanoid(6),
222
- });
223
- }
139
+ const id = (config: ResourceConfig) => config.type + (config.name ? `.${config.name}` : '')
224
140
 
225
- async import(data: ImportRequestData): Promise<ImportResponseData> {
226
- return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
227
- cmd: 'import',
228
- data,
229
- requestId: nanoid(6),
230
- });
141
+ const configsToDestroy = unionBy(modifiedConfigs, configsWithNames, id);
142
+ await this.uninstall(pluginPath, configsToDestroy.toReversed(), options);
143
+ }
231
144
  }
232
145
 
233
- kill() {
234
- this.childProcess.kill();
235
- }
236
-
237
- private handleSudoRequests(process: ChildProcess) {
238
- // Listen for incoming sudo incoming sudo requests
239
- process.on('message', async (message) => {
240
- if (!ipcMessageValidator(message)) {
241
- throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
146
+ static async uninstall(pluginPath: string, configs: ResourceConfig[], options?: {
147
+ validateDestroy?: (plans: PlanResponseData[]) => Promise<void> | void
148
+ }) {
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
+ }))
242
161
  }
243
162
 
244
- if (message.cmd === MessageCmd.SUDO_REQUEST) {
245
- const { data, requestId } = message;
246
- if (!sudoRequestValidator(data)) {
247
- 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)}`)
248
166
  }
249
167
 
250
- const { command, options } = data as unknown as SudoRequestData;
251
-
252
- console.log(`Running command with sudo: 'sudo ${command}'`)
253
- const result = await sudoSpawn(command, options);
168
+ await destroyPlugin.apply({
169
+ planId: plan.planId
170
+ });
171
+ }
254
172
 
255
- process.send(<IpcMessageV2>{
256
- cmd: MessageCmd.SUDO_REQUEST + '_Response',
257
- data: result,
258
- requestId,
259
- })
173
+ if (options?.validateDestroy) {
174
+ await options.validateDestroy(plans);
260
175
  }
261
- })
176
+ } finally {
177
+ destroyPlugin.kill();
178
+ }
262
179
  }
263
180
 
264
- private addNamesToConfigs(configs: ResourceConfig[]): ResourceConfig[] {
181
+ private static addNamesToConfigs(configs: ResourceConfig[]): ResourceConfig[] {
265
182
  const configsWithNames = new Array<ResourceConfig>();
266
183
 
267
184
  const typeSet = new Set(configs.map((c) => c.type));
@@ -278,59 +195,3 @@ ${JSON.stringify(modifyPlans, null, 2)}`)
278
195
  }
279
196
  }
280
197
 
281
-
282
- type CodifySpawnOptions = {
283
- cwd?: string;
284
- throws?: boolean,
285
- } & Omit<SpawnOptions, 'detached' | 'shell' | 'stdio'>
286
-
287
- /**
288
- *
289
- * @param cmd Command to run. Ex: `rm -rf`
290
- * @param opts Options for spawn
291
- *
292
- * @see promiseSpawn
293
- * @see spawn
294
- *
295
- * @returns SpawnResult { status: SUCCESS | ERROR; data: string }
296
- */
297
- async function sudoSpawn(
298
- cmd: string,
299
- opts: CodifySpawnOptions,
300
- ): Promise<{ data: string, status: SpawnStatus }> {
301
- return new Promise((resolve) => {
302
- const output: string[] = [];
303
-
304
- const _cmd = `sudo ${cmd}`;
305
-
306
- // Source start up shells to emulate a users environment vs. a non-interactive non-login shell script
307
- // Ignore all stdin
308
- const _process = spawn(`source ~/.zshrc; ${_cmd}`, [], {
309
- ...opts,
310
- shell: 'zsh',
311
- stdio: ['ignore', 'pipe', 'pipe'],
312
- });
313
-
314
- const { stderr, stdout } = _process
315
- stdout.setEncoding('utf8');
316
- stderr.setEncoding('utf8');
317
-
318
- stdout.on('data', (data) => {
319
- output.push(data.toString());
320
- })
321
-
322
- stderr.on('data', (data) => {
323
- output.push(data.toString());
324
- })
325
-
326
- stdout.pipe(process.stdout);
327
- stderr.pipe(process.stderr);
328
-
329
- _process.on('close', (code) => {
330
- resolve({
331
- data: output.join(''),
332
- status: code === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
333
- })
334
- })
335
- })
336
- }