codify-plugin-test 0.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/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ /dist
package/.eslintrc.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "extends": [
3
+ "oclif",
4
+ "oclif-typescript",
5
+ "prettier"
6
+ ],
7
+ "rules": {
8
+ "object-curly-spacing": [
9
+ "warn",
10
+ "always"
11
+ ],
12
+ "perfectionist/sort-classes": [
13
+ "off"
14
+ ],
15
+ "quotes": [
16
+ "error",
17
+ "single"
18
+ ]
19
+ },
20
+ "ignorePatterns": [
21
+ "*.test.ts"
22
+ ]
23
+ }
@@ -0,0 +1 @@
1
+ "@oclif/prettier-config"
package/codify.json ADDED
@@ -0,0 +1,9 @@
1
+ [
2
+ {
3
+ "type": "nvm",
4
+ "global": "18.20",
5
+ "nodeVersions": [
6
+ "18.20"
7
+ ]
8
+ }
9
+ ]
@@ -0,0 +1,2 @@
1
+ export * from './plugin-tester.js';
2
+ export * from './test-utils.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './plugin-tester.js';
2
+ export * from './test-utils.js';
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAA;AAClC,cAAc,iBAAiB,CAAA"}
@@ -0,0 +1,13 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import { ApplyRequestData, InitializeResponseData, PlanRequestData, PlanResponseData, ResourceConfig, ValidateRequestData, ValidateResponseData } from 'codify-schemas';
3
+ import { ChildProcess } from 'node:child_process';
4
+ export declare class PluginTester {
5
+ childProcess: ChildProcess;
6
+ constructor(pluginPath: string);
7
+ test(configs: ResourceConfig[]): Promise<void>;
8
+ initialize(): Promise<InitializeResponseData>;
9
+ validate(data: ValidateRequestData): Promise<ValidateResponseData>;
10
+ plan(data: PlanRequestData): Promise<PlanResponseData>;
11
+ apply(data: ApplyRequestData): Promise<void>;
12
+ private handleSudoRequests;
13
+ }
@@ -0,0 +1,126 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import { IpcMessageSchema, MessageCmd, ResourceOperation, SpawnStatus, SudoRequestDataSchema } from 'codify-schemas';
3
+ import { fork, spawn } from 'node:child_process';
4
+ import { CodifyTestUtils } from './test-utils.js';
5
+ import path from 'node:path';
6
+ const ajv = new Ajv2020.default({
7
+ strict: true
8
+ });
9
+ const ipcMessageValidator = ajv.compile(IpcMessageSchema);
10
+ const sudoRequestValidator = ajv.compile(SudoRequestDataSchema);
11
+ export class PluginTester {
12
+ childProcess;
13
+ constructor(pluginPath) {
14
+ if (!path.isAbsolute(pluginPath)) {
15
+ throw new Error('A fully qualified path must be supplied to PluginTester');
16
+ }
17
+ this.childProcess = fork(pluginPath, [], {
18
+ detached: true,
19
+ env: { ...process.env },
20
+ execArgv: ['--import', 'tsx/esm'],
21
+ });
22
+ this.handleSudoRequests(this.childProcess);
23
+ }
24
+ async test(configs) {
25
+ const initializeResult = await this.initialize();
26
+ const unsupportedConfigs = configs.filter((c) => !initializeResult.resourceDefinitions.some((rd) => rd.type === c.type));
27
+ if (unsupportedConfigs.length > 0) {
28
+ throw new Error(`The plugin does not support the following configs supplied:\n ${JSON.stringify(unsupportedConfigs, null, 2)}\n Initialize result: ${JSON.stringify(initializeResult)}`);
29
+ }
30
+ const validate = await this.validate({ configs });
31
+ const invalidConfigs = validate.validationResults.filter((v) => !v.isValid);
32
+ if (invalidConfigs.length > 0) {
33
+ throw new Error(`The following configs did not validate:\n ${JSON.stringify(invalidConfigs, null, 2)}`);
34
+ }
35
+ const plans = [];
36
+ for (const config of configs) {
37
+ plans.push(await this.plan(config));
38
+ }
39
+ for (const plan of plans) {
40
+ await this.apply({
41
+ planId: plan.planId
42
+ });
43
+ }
44
+ const validationPlans = [];
45
+ for (const config of configs) {
46
+ validationPlans.push(await this.plan(config));
47
+ }
48
+ const unsuccessfulPlans = validationPlans.filter((p) => p.operation !== ResourceOperation.NOOP);
49
+ if (unsuccessfulPlans.length > 0) {
50
+ throw new Error(`The following applies were not successful.\n ${JSON.stringify(unsuccessfulPlans, null, 2)}`);
51
+ }
52
+ }
53
+ async initialize() {
54
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
55
+ cmd: 'initialize',
56
+ data: {},
57
+ });
58
+ }
59
+ async validate(data) {
60
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
61
+ cmd: 'validate',
62
+ data,
63
+ });
64
+ }
65
+ async plan(data) {
66
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
67
+ cmd: 'plan',
68
+ data,
69
+ });
70
+ }
71
+ async apply(data) {
72
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
73
+ cmd: 'apply',
74
+ data,
75
+ });
76
+ }
77
+ handleSudoRequests(process) {
78
+ process.on('message', async (message) => {
79
+ if (!ipcMessageValidator(message)) {
80
+ throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
81
+ }
82
+ if (message.cmd === MessageCmd.SUDO_REQUEST) {
83
+ const { data } = message;
84
+ if (!sudoRequestValidator(data)) {
85
+ throw new Error(`Invalid sudo request from plugin. ${JSON.stringify(sudoRequestValidator.errors, null, 2)}`);
86
+ }
87
+ const { command, options } = data;
88
+ console.log(`Running command with sudo: 'sudo ${command}'`);
89
+ const result = await sudoSpawn(command, options);
90
+ process.send({
91
+ cmd: MessageCmd.SUDO_REQUEST + '_Response',
92
+ data: result,
93
+ });
94
+ }
95
+ });
96
+ }
97
+ }
98
+ async function sudoSpawn(cmd, opts) {
99
+ return new Promise((resolve) => {
100
+ const output = [];
101
+ const _cmd = `sudo ${cmd}`;
102
+ const _process = spawn(`source ~/.zshrc; ${_cmd}`, [], {
103
+ ...opts,
104
+ shell: 'zsh',
105
+ stdio: ['ignore', 'pipe', 'pipe'],
106
+ });
107
+ const { stderr, stdout } = _process;
108
+ stdout.setEncoding('utf8');
109
+ stderr.setEncoding('utf8');
110
+ stdout.on('data', (data) => {
111
+ output.push(data.toString());
112
+ });
113
+ stderr.on('data', (data) => {
114
+ output.push(data.toString());
115
+ });
116
+ stdout.pipe(process.stdout);
117
+ stderr.pipe(process.stderr);
118
+ _process.on('close', (code) => {
119
+ resolve({
120
+ data: output.join(''),
121
+ status: code === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
122
+ });
123
+ });
124
+ });
125
+ }
126
+ //# sourceMappingURL=plugin-tester.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-tester.js","sourceRoot":"","sources":["../src/plugin-tester.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ /// <reference types="node" resolution-mode="require"/>
2
+ import { IpcMessage } from 'codify-schemas';
3
+ import { ChildProcess } from 'node:child_process';
4
+ export declare const CodifyTestUtils: {
5
+ sendMessageAndAwaitResponse(process: ChildProcess, message: IpcMessage): Promise<any>;
6
+ };
@@ -0,0 +1,27 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import { IpcMessageSchema, MessageStatus } from 'codify-schemas';
3
+ const ajv = new Ajv2020.default({
4
+ strict: true
5
+ });
6
+ const ipcMessageValidator = ajv.compile(IpcMessageSchema);
7
+ export const CodifyTestUtils = {
8
+ sendMessageAndAwaitResponse(process, message) {
9
+ return new Promise((resolve, reject) => {
10
+ process.on('message', (response) => {
11
+ if (!ipcMessageValidator(response)) {
12
+ throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
13
+ }
14
+ if (response.cmd === message.cmd + '_Response') {
15
+ if (response.status === MessageStatus.SUCCESS) {
16
+ resolve(response.data);
17
+ }
18
+ else {
19
+ reject(new Error(String(response.data)));
20
+ }
21
+ }
22
+ });
23
+ process.send(message);
24
+ });
25
+ },
26
+ };
27
+ //# sourceMappingURL=test-utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test-utils.js","sourceRoot":"","sources":["../src/test-utils.ts"],"names":[],"mappings":"AAAA,OAAO,OAAO,MAAM,kBAAkB,CAAC;AACvC,OAAO,EAAc,gBAAgB,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAG7E,MAAM,GAAG,GAAG,IAAI,OAAO,CAAC,OAAO,CAAC;IAC9B,MAAM,EAAE,IAAI;CACb,CAAC,CAAC;AACH,MAAM,mBAAmB,GAAG,GAAG,CAAC,OAAO,CAAC,gBAAgB,CAAC,CAAC;AAE1D,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,2BAA2B,CAAC,OAAqB,EAAE,OAAmB;QACpE,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YACrC,OAAO,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,QAAoB,EAAE,EAAE;gBAC7C,IAAI,CAAC,mBAAmB,CAAC,QAAQ,CAAC,EAAE,CAAC;oBACnC,MAAM,IAAI,KAAK,CAAC,gCAAgC,IAAI,CAAC,SAAS,CAAC,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC;gBACtF,CAAC;gBAGD,IAAI,QAAQ,CAAC,GAAG,KAAK,OAAO,CAAC,GAAG,GAAG,WAAW,EAAE,CAAC;oBAC/C,IAAI,QAAQ,CAAC,MAAM,KAAK,aAAa,CAAC,OAAO,EAAE,CAAC;wBAC9C,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAA;oBACxB,CAAC;yBAAM,CAAC;wBACN,MAAM,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;oBAC1C,CAAC;gBACH,CAAC;YACH,CAAC,CAAC,CAAC;YAGH,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QACxB,CAAC,CAAC,CAAC;IACL,CAAC;CAEF,CAAC"}
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "codify-plugin-test",
3
+ "version": "0.0.0",
4
+ "description": "",
5
+ "main": "dist/index.js",
6
+ "typings": "dist/index.d.ts",
7
+ "type": "module",
8
+ "keywords": [],
9
+ "author": "",
10
+ "license": "ISC",
11
+ "scripts": {
12
+ "test": "echo \"Error: no test specified\" && exit 1",
13
+ "start": "tsx ./src/index.ts"
14
+ },
15
+ "dependencies": {
16
+ "ajv": "^8.12.0",
17
+ "ajv-formats": "^3.0.1",
18
+ "codify-schemas": "1.0.38"
19
+ },
20
+ "devDependencies": {
21
+ "@oclif/prettier-config": "^0.2.1",
22
+ "@types/debug": "^4.1.12",
23
+ "@types/node": "^18",
24
+ "eslint": "^8.51.0",
25
+ "eslint-config-oclif": "^5",
26
+ "eslint-config-oclif-typescript": "^3",
27
+ "eslint-config-prettier": "^9.0.0",
28
+ "tsx": "^4.7.3",
29
+ "typescript": "^5",
30
+ "vitest": "^1.4.0",
31
+ "codify-plugin-lib": "../codify-plugin-lib"
32
+ },
33
+ "engines": {
34
+ "node": ">=18.0.0"
35
+ }
36
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './plugin-tester.js'
2
+ export * from './test-utils.js'
@@ -0,0 +1,200 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import {
3
+ ApplyRequestData, InitializeResponseData, IpcMessageSchema,
4
+ MessageCmd,
5
+ PlanRequestData,
6
+ PlanResponseData, ResourceConfig, ResourceOperation,
7
+ SpawnStatus,
8
+ SudoRequestData, SudoRequestDataSchema, ValidateRequestData, ValidateResponseData
9
+ } from 'codify-schemas';
10
+ import { ChildProcess, SpawnOptions, fork, spawn } from 'node:child_process';
11
+
12
+ import { CodifyTestUtils } from './test-utils.js';
13
+ import path from 'node:path';
14
+
15
+ const ajv = new Ajv2020.default({
16
+ strict: true
17
+ });
18
+ const ipcMessageValidator = ajv.compile(IpcMessageSchema);
19
+ const sudoRequestValidator = ajv.compile(SudoRequestDataSchema);
20
+
21
+ export class PluginTester {
22
+ childProcess: ChildProcess
23
+
24
+ /**
25
+ * PluginTester is a helper class to integration test plugins. It launches plugins via fork() just like CodifyCLI does.
26
+ *
27
+ * @param pluginPath A fully qualified path
28
+ */
29
+ constructor(pluginPath: string) {
30
+ if (!path.isAbsolute(pluginPath)) {
31
+ throw new Error('A fully qualified path must be supplied to PluginTester');
32
+ }
33
+
34
+ this.childProcess = fork(
35
+ pluginPath,
36
+ [],
37
+ {
38
+ // Use default true to test plugins in secure mode (un-able to request sudo directly)
39
+ detached: true,
40
+ env: { ...process.env },
41
+ execArgv: ['--import', 'tsx/esm'],
42
+ },
43
+ )
44
+
45
+ this.handleSudoRequests(this.childProcess);
46
+ }
47
+
48
+ async test(configs: ResourceConfig[]): Promise<void> {
49
+ const initializeResult = await this.initialize();
50
+
51
+ const unsupportedConfigs = configs.filter((c) =>
52
+ !initializeResult.resourceDefinitions.some((rd) => rd.type === c.type)
53
+ )
54
+ if (unsupportedConfigs.length > 0) {
55
+ throw new Error(`The plugin does not support the following configs supplied:\n ${JSON.stringify(unsupportedConfigs, null, 2)}\n Initialize result: ${JSON.stringify(initializeResult)}`)
56
+ }
57
+
58
+ const validate = await this.validate({ configs });
59
+
60
+ const invalidConfigs = validate.validationResults.filter((v) => !v.isValid)
61
+ if (invalidConfigs.length > 0) {
62
+ throw new Error(`The following configs did not validate:\n ${JSON.stringify(invalidConfigs, null, 2)}`)
63
+ }
64
+
65
+ const plans = [];
66
+ for (const config of configs) {
67
+ plans.push(await this.plan(config));
68
+ }
69
+
70
+ for (const plan of plans) {
71
+ await this.apply({
72
+ planId: plan.planId
73
+ });
74
+ }
75
+
76
+ // Check that all applys were successful by re-planning
77
+ const validationPlans = [];
78
+ for (const config of configs) {
79
+ validationPlans.push(await this.plan(config));
80
+ }
81
+
82
+ const unsuccessfulPlans = validationPlans.filter((p) => p.operation !== ResourceOperation.NOOP);
83
+ if (unsuccessfulPlans.length > 0) {
84
+ throw new Error(`The following applies were not successful.\n ${JSON.stringify(unsuccessfulPlans, null, 2)}`)
85
+ }
86
+ }
87
+
88
+ async initialize(): Promise<InitializeResponseData> {
89
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
90
+ cmd: 'initialize',
91
+ data: {},
92
+ });
93
+ }
94
+
95
+ async validate(data: ValidateRequestData): Promise<ValidateResponseData> {
96
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
97
+ cmd: 'validate',
98
+ data,
99
+ });
100
+ }
101
+
102
+ async plan(data: PlanRequestData): Promise<PlanResponseData> {
103
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
104
+ cmd: 'plan',
105
+ data,
106
+ });
107
+ }
108
+
109
+ async apply(data: ApplyRequestData): Promise<void> {
110
+ return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
111
+ cmd: 'apply',
112
+ data,
113
+ });
114
+ }
115
+
116
+ private handleSudoRequests(process: ChildProcess) {
117
+ // Listen for incoming sudo incoming sudo requests
118
+ process.on('message', async (message) => {
119
+ if (!ipcMessageValidator(message)) {
120
+ throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
121
+ }
122
+
123
+ if (message.cmd === MessageCmd.SUDO_REQUEST) {
124
+ const { data } = message;
125
+ if (!sudoRequestValidator(data)) {
126
+ throw new Error(`Invalid sudo request from plugin. ${JSON.stringify(sudoRequestValidator.errors, null, 2)}`);
127
+ }
128
+
129
+ const { command, options } = data as unknown as SudoRequestData;
130
+
131
+ console.log(`Running command with sudo: 'sudo ${command}'`)
132
+ const result = await sudoSpawn(command, options);
133
+
134
+ process.send({
135
+ cmd: MessageCmd.SUDO_REQUEST + '_Response',
136
+ data: result,
137
+ })
138
+ }
139
+ })
140
+ }
141
+ }
142
+
143
+
144
+ type CodifySpawnOptions = {
145
+ cwd?: string;
146
+ throws?: boolean,
147
+ } & Omit<SpawnOptions, 'detached' | 'shell' | 'stdio'>
148
+
149
+ /**
150
+ *
151
+ * @param cmd Command to run. Ex: `rm -rf`
152
+ * @param opts Options for spawn
153
+ * @param secureMode Secure mode for sudo
154
+ * @param pluginName Optional plugin name so that stdout and stderr can be piped
155
+ *
156
+ * @see promiseSpawn
157
+ * @see spawn
158
+ *
159
+ * @returns SpawnResult { status: SUCCESS | ERROR; data: string }
160
+ */
161
+ async function sudoSpawn(
162
+ cmd: string,
163
+ opts: CodifySpawnOptions,
164
+ ): Promise<{ data: string, status: SpawnStatus }> {
165
+ return new Promise((resolve) => {
166
+ const output: string[] = [];
167
+
168
+ const _cmd = `sudo ${cmd}`;
169
+
170
+ // Source start up shells to emulate a users environment vs. a non-interactive non-login shell script
171
+ // Ignore all stdin
172
+ const _process = spawn(`source ~/.zshrc; ${_cmd}`, [], {
173
+ ...opts,
174
+ shell: 'zsh',
175
+ stdio: ['ignore', 'pipe', 'pipe'],
176
+ });
177
+
178
+ const { stderr, stdout } = _process
179
+ stdout.setEncoding('utf8');
180
+ stderr.setEncoding('utf8');
181
+
182
+ stdout.on('data', (data) => {
183
+ output.push(data.toString());
184
+ })
185
+
186
+ stderr.on('data', (data) => {
187
+ output.push(data.toString());
188
+ })
189
+
190
+ stdout.pipe(process.stdout);
191
+ stderr.pipe(process.stderr);
192
+
193
+ _process.on('close', (code) => {
194
+ resolve({
195
+ data: output.join(''),
196
+ status: code === 0 ? SpawnStatus.SUCCESS : SpawnStatus.ERROR,
197
+ })
198
+ })
199
+ })
200
+ }
@@ -0,0 +1,79 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { ChildProcess } from 'node:child_process';
3
+ import { Readable } from 'stream';
4
+ import { CodifyTestUtils } from './test-utils.js';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { MessageStatus } from 'codify-schemas';
7
+
8
+ describe('Test Utils tests', async () => {
9
+
10
+ const mockChildProcess = () => {
11
+ const process = new ChildProcess();
12
+ process.stdout = new EventEmitter() as Readable;
13
+ process.stderr = new EventEmitter() as Readable
14
+ process.send = () => true;
15
+
16
+ return process;
17
+ }
18
+
19
+ it('Sends the message that was passed in', async () => {
20
+ const process = mockChildProcess();
21
+ const sendMock = vi.spyOn(process, 'send');
22
+
23
+ CodifyTestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data' })
24
+
25
+ expect(sendMock.mock.calls.length).to.eq(1);
26
+ expect(sendMock.mock.calls[0][0]).to.deep.eq({ cmd: 'message', data: 'data' });
27
+ })
28
+
29
+ it('Send a message and receives a response from a plugin (success)', async () => {
30
+ const process = mockChildProcess();
31
+
32
+ const result = await Promise.all([
33
+ (async () => {
34
+ await sleep(30);
35
+ // Note that the response must end in _Response. In accordance to the message schema rules.
36
+ process.emit('message', { cmd: 'message_Response', status: MessageStatus.SUCCESS, data: 'data' })
37
+ })(),
38
+ CodifyTestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data' }),
39
+ ]);
40
+
41
+ expect(result[1]).to.eq('data')
42
+ });
43
+
44
+ it('Send a message and can handle errors', async () => {
45
+ const process = mockChildProcess();
46
+
47
+ expect(async () => Promise.all([
48
+ (async () => {
49
+ await sleep(30);
50
+ // Note that the response must end in _Response. In accordance to the message schema rules.
51
+ process.emit('message', { cmd: 'message_Response', status: MessageStatus.ERROR, data: 'error message' })
52
+ })(),
53
+ CodifyTestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data' }),
54
+ ])).rejects.toThrowError(new Error('error message'))
55
+ });
56
+
57
+ it('Ignores other IPC messages', async () => {
58
+ const process = mockChildProcess();
59
+
60
+ const result = await Promise.all([
61
+ (async () => {
62
+ await sleep(30);
63
+ process.emit('message', { cmd: 'randomMessage1', status: MessageStatus.SUCCESS, data: 'message1' })
64
+ process.emit('message', { cmd: 'randomMessage2', status: MessageStatus.SUCCESS, data: 'message2' })
65
+
66
+
67
+ process.emit('message', { cmd: 'message_Response', status: MessageStatus.SUCCESS, data: 'data' })
68
+ })(),
69
+ CodifyTestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data' }),
70
+ ]);
71
+
72
+ // Only the final _Response message should be returned.
73
+ expect(result[1]).to.eq('data')
74
+ });
75
+ });
76
+
77
+ async function sleep(ms: number) {
78
+ return new Promise((resolve, reject) => setTimeout(resolve, ms))
79
+ }
@@ -0,0 +1,33 @@
1
+ import Ajv2020 from 'ajv/dist/2020.js';
2
+ import { IpcMessage, IpcMessageSchema, MessageStatus } from 'codify-schemas';
3
+ import { ChildProcess } from 'node:child_process';
4
+
5
+ const ajv = new Ajv2020.default({
6
+ strict: true
7
+ });
8
+ const ipcMessageValidator = ajv.compile(IpcMessageSchema);
9
+
10
+ export const CodifyTestUtils = {
11
+ sendMessageAndAwaitResponse(process: ChildProcess, message: IpcMessage): Promise<any> {
12
+ return new Promise((resolve, reject) => {
13
+ process.on('message', (response: IpcMessage) => {
14
+ if (!ipcMessageValidator(response)) {
15
+ throw new Error(`Invalid message from plugin. ${JSON.stringify(message, null, 2)}`);
16
+ }
17
+
18
+ // Wait for the message response. Other messages such as sudoRequest may be sent before the response returns
19
+ if (response.cmd === message.cmd + '_Response') {
20
+ if (response.status === MessageStatus.SUCCESS) {
21
+ resolve(response.data)
22
+ } else {
23
+ reject(new Error(String(response.data)))
24
+ }
25
+ }
26
+ });
27
+
28
+ // Send message last to ensure listeners are all registered
29
+ process.send(message);
30
+ });
31
+ },
32
+
33
+ };
@@ -0,0 +1,112 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { PluginTester } from '../src/plugin-tester.js';
3
+ import path from 'node:path';
4
+ import { ResourceOperation } from 'codify-schemas/src/types/index.js';
5
+ import { ParameterOperation } from 'codify-schemas';
6
+
7
+ describe('Plugin tester integration tests', () => {
8
+ it('Can instantiate a plugin', async () => {
9
+ const plugin = new PluginTester(path.join(__dirname, './test-plugin.ts'));
10
+
11
+ expect(plugin.childProcess.pid).to.not.be.undefined;
12
+ expect(plugin.childProcess.stdout).to.not.be.undefined;
13
+ expect(plugin.childProcess.stderr).to.not.be.undefined;
14
+ expect(plugin.childProcess.channel).to.not.be.undefined;
15
+
16
+ await plugin.initialize({});
17
+ })
18
+
19
+ it('Can validate a config', async () => {
20
+ const plugin = new PluginTester(path.join(__dirname, './test-plugin.ts'));
21
+
22
+ const result = await plugin.validate({
23
+ configs: [{
24
+ type: 'test',
25
+ propA: 'a',
26
+ propB: 2,
27
+ propC: 'c',
28
+ }]
29
+ })
30
+
31
+ expect(result.validationResults).toMatchObject([{
32
+ isValid: true,
33
+ }])
34
+ })
35
+
36
+ it('Can generate a plan', async () => {
37
+ const plugin = new PluginTester(path.join(__dirname, './test-plugin.ts'));
38
+
39
+ const result = await plugin.plan({
40
+ type: 'test',
41
+ propA: 'a',
42
+ propB: 10,
43
+ propC: 'c',
44
+ })
45
+
46
+ expect(result).toMatchObject({
47
+ planId: expect.any(String),
48
+ operation: ResourceOperation.NOOP,
49
+ resourceType: 'test',
50
+ })
51
+ })
52
+
53
+ it('Can generate a plan', async () => {
54
+ const plugin = new PluginTester(path.join(__dirname, './test-plugin.ts'));
55
+
56
+ const result = await plugin.plan({
57
+ type: 'test',
58
+ propA: 'a',
59
+ propB: 10,
60
+ propC: 'c',
61
+ })
62
+
63
+ expect(result).toMatchObject({
64
+ planId: expect.any(String),
65
+ operation: ResourceOperation.NOOP,
66
+ resourceType: 'test',
67
+ })
68
+ })
69
+
70
+ it('Can apply a plan', async () => {
71
+ const plugin = new PluginTester(path.join(__dirname, './test-plugin.ts'));
72
+
73
+ const plan = await plugin.plan({
74
+ type: 'test',
75
+ propA: 'a',
76
+ propB: 10,
77
+ propC: 'c',
78
+ })
79
+
80
+ // No expect needed here. This passes if it doesn't throw.
81
+ await plugin.apply({ planId: plan.planId })
82
+ })
83
+
84
+ it('Handles errors that are thrown', async () => {
85
+ const plugin = new PluginTester(path.join(__dirname, './test-plugin.ts'));
86
+
87
+ expect(async () => plugin.plan({
88
+ type: 'test',
89
+ propA: 'a',
90
+ propB: 10,
91
+ propC: 'c',
92
+ propD: 'any'
93
+ })).rejects.toThrowError(new Error('Prop D is included'));
94
+ })
95
+
96
+ it('Has helpers that can test a resource', async () => {
97
+ const plugin = new PluginTester(path.join(__dirname, './test-plugin.ts'));
98
+
99
+ // No expect needed here. This passes if it doesn't throw.
100
+ await plugin.test([{
101
+ type: 'test',
102
+ propA: 'a',
103
+ propB: 10,
104
+ propC: 'c',
105
+ }, {
106
+ type: 'test',
107
+ propA: 'a',
108
+ propB: 10,
109
+ propC: 'c',
110
+ }]);
111
+ })
112
+ })
@@ -0,0 +1,49 @@
1
+ import { Plan, Plugin, Resource, ValidationResult, runPlugin } from 'codify-plugin-lib';
2
+ import { StringIndexedObject } from 'codify-schemas';
3
+
4
+ export interface TestConfig extends StringIndexedObject{
5
+ propA: string;
6
+ propB: number;
7
+ propC: string;
8
+ }
9
+
10
+ export class TestResource extends Resource<TestConfig> {
11
+ constructor() {
12
+ super({
13
+ type: 'test'
14
+ });
15
+ }
16
+
17
+ async applyCreate(plan: Plan<TestConfig>): Promise<void> {}
18
+
19
+ async applyDestroy(plan: Plan<TestConfig>): Promise<void> {}
20
+
21
+ async refresh(keys: Map<string, unknown>): Promise<Partial<TestConfig> | null> {
22
+ if (keys.has('propD')) {
23
+ throw new Error('Prop D is included');
24
+ }
25
+
26
+ return {
27
+ propA: 'a',
28
+ propB: 10,
29
+ propC: 'c',
30
+ };
31
+ }
32
+
33
+ async validateResource(config: unknown): Promise<ValidationResult> {
34
+ return {
35
+ isValid: true
36
+ }
37
+ }
38
+ }
39
+
40
+ function buildPlugin(): Plugin {
41
+ const resourceMap = new Map();
42
+
43
+ const testResource = new TestResource();
44
+ resourceMap.set(testResource.typeId, testResource);
45
+
46
+ return new Plugin('test', resourceMap);
47
+ }
48
+
49
+ runPlugin(buildPlugin());
package/tsconfig.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "NodeNext",
4
+ "moduleResolution": "NodeNext",
5
+ "sourceMap": true,
6
+ "esModuleInterop": true,
7
+ "resolveJsonModule": true,
8
+ "alwaysStrict": true,
9
+ "noImplicitAny": true,
10
+ "removeComments": true,
11
+ "strictNullChecks": true,
12
+ "declaration": true,
13
+ "emitDecoratorMetadata": true,
14
+ "experimentalDecorators": true,
15
+ "rootDir": "src",
16
+ "outDir": "./dist"
17
+ },
18
+ "exclude": [
19
+ "node_modules",
20
+ "src/**/*.test.ts"
21
+ ],
22
+ "include": [
23
+ "src/**/*.ts"
24
+ ]
25
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "tsconfig.json",
3
+ "compilerOptions": {
4
+ "strictNullChecks": false
5
+ },
6
+ "include": [
7
+ "src/**/*.test.ts",
8
+ "test/**/*.test.ts"
9
+ ]
10
+ }