Skip to content

Commit ca74dcc

Browse files
committed
Add testing-utils for sending IPC message and PluginTester
1 parent 48c798c commit ca74dcc

7 files changed

Lines changed: 486 additions & 5 deletions

File tree

package.json

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
"name": "codify-plugin-test",
33
"version": "0.0.0",
44
"description": "",
5-
"main": "index.js",
5+
"main": "dist/index.js",
6+
"typings": "dist/index.d.ts",
7+
"type": "module",
8+
"keywords": [],
9+
"author": "",
10+
"license": "ISC",
611
"scripts": {
712
"test": "echo \"Error: no test specified\" && exit 1",
813
"start": "tsx ./src/index.ts"
@@ -22,9 +27,10 @@
2227
"eslint-config-prettier": "^9.0.0",
2328
"tsx": "^4.7.3",
2429
"typescript": "^5",
25-
"vitest": "^1.4.0"
30+
"vitest": "^1.4.0",
31+
"codify-plugin-lib": "../codify-plugin-lib"
2632
},
27-
"keywords": [],
28-
"author": "",
29-
"license": "ISC"
33+
"engines": {
34+
"node": ">=18.0.0"
35+
}
3036
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './plugin-tester.js'
2+
export * from './test-utils.js'

src/plugin-tester.ts

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

src/test-utils.test.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

src/test-utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 commit comments

Comments
 (0)