Skip to content

Commit 6377346

Browse files
committed
feat: Testing improvements
1 parent a9b612d commit 6377346

7 files changed

Lines changed: 205 additions & 34 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codify-plugin-test",
3-
"version": "0.0.53-beta13",
3+
"version": "0.0.53-beta23",
44
"description": "",
55
"main": "dist/index.js",
66
"typings": "dist/index.d.ts",

src/plugin-process.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import * as os from 'node:os';
2121
import path from 'node:path';
2222

2323
import { spawnSafe } from './spawn.js';
24-
import { CodifyTestUtils } from './test-utils.js';
24+
import { TestUtils } from './test-utils.js';
2525

2626
const ajv = new Ajv.default({
2727
strict: true
@@ -64,39 +64,39 @@ export class PluginProcess {
6464
}
6565

6666
async initialize(): Promise<InitializeResponseData> {
67-
return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
67+
return TestUtils.sendMessageAndAwaitResponse(this.childProcess, {
6868
cmd: 'initialize',
6969
data: { verbosityLevel: 3 },
7070
requestId: nanoid(6),
7171
});
7272
}
7373

7474
async validate(data: ValidateRequestData): Promise<ValidateResponseData> {
75-
return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
75+
return TestUtils.sendMessageAndAwaitResponse(this.childProcess, {
7676
cmd: 'validate',
7777
data,
7878
requestId: nanoid(6),
7979
});
8080
}
8181

8282
async plan(data: PlanRequestData): Promise<PlanResponseData> {
83-
return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
83+
return TestUtils.sendMessageAndAwaitResponse(this.childProcess, {
8484
cmd: 'plan',
8585
data,
8686
requestId: nanoid(6),
8787
});
8888
}
8989

9090
async apply(data: ApplyRequestData): Promise<void> {
91-
return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
91+
return TestUtils.sendMessageAndAwaitResponse(this.childProcess, {
9292
cmd: 'apply',
9393
data,
9494
requestId: nanoid(6),
9595
});
9696
}
9797

9898
async import(data: ImportRequestData): Promise<ImportResponseData> {
99-
return CodifyTestUtils.sendMessageAndAwaitResponse(this.childProcess, {
99+
return TestUtils.sendMessageAndAwaitResponse(this.childProcess, {
100100
cmd: 'import',
101101
data,
102102
requestId: nanoid(6),

src/plugin-tester.ts

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class PluginTester {
2626
modifiedConfigs: ResourceConfig[],
2727
validateModify?: (plans: PlanResponseData[]) => Promise<void> | void,
2828
}
29-
}): Promise<void> {
29+
}): Promise<void> {
3030
configs = configs.filter((c) => !c.os || c.os.includes(getPlatformOs()));
3131
const ids = configs
3232
.map((c) => `${c.type}${c.name ? `.${c.name}` : ''}`)
@@ -51,13 +51,15 @@ export class PluginTester {
5151
throw new Error(`The plugin does not support the following configs supplied:\n ${JSON.stringify(unsupportedConfigs, null, 2)}\n Initialize result: ${JSON.stringify(initializeResult)}`)
5252
}
5353

54-
configs = configs.filter((c) => initializeResult.resourceDefinitions.find((rd) => rd.type === c.type)?.operatingSystems?.includes(os.platform() as OS));
54+
// configs = configs.filter((c) => initializeResult.resourceDefinitions.find((rd) => rd.type === c.type)?.operatingSystems?.includes(os.platform() as OS));
5555

5656
console.info(chalk.cyan('Testing validate...'))
57-
const validate = await plugin.validate({ configs: configs.map((c) => {
58-
const { coreParameters, parameters } = splitUserConfig(c)
59-
return { core: coreParameters, parameters };
60-
}) });
57+
const validate = await plugin.validate({
58+
configs: configs.map((c) => {
59+
const { coreParameters, parameters } = splitUserConfig(c)
60+
return { core: coreParameters, parameters };
61+
})
62+
});
6163

6264
const invalidConfigs = validate.resourceValidations.filter((v) => !v.isValid)
6365
if (invalidConfigs.length > 0) {
@@ -68,7 +70,7 @@ export class PluginTester {
6870
const plans = [];
6971
for (const config of configs) {
7072
const { coreParameters, parameters } = splitUserConfig(config);
71-
73+
7274
plans.push(await plugin.plan({
7375
core: coreParameters,
7476
desired: parameters,
@@ -127,7 +129,7 @@ export class PluginTester {
127129
const modifyPlans = [];
128130
for (const config of options.testModify.modifiedConfigs) {
129131
const { coreParameters, parameters } = splitUserConfig(config);
130-
132+
131133
modifyPlans.push(await modifyPlugin.plan({
132134
core: coreParameters,
133135
desired: parameters,
@@ -167,6 +169,59 @@ ${JSON.stringify(modifyPlans, null, 2)}`)
167169
}
168170
}
169171

172+
static async install(pluginPath: string, configs: ResourceConfig[]) {
173+
const plugin = new PluginProcess(pluginPath);
174+
175+
try {
176+
console.info(chalk.cyan('Testing initialization...'))
177+
const initializeResult = await plugin.initialize();
178+
179+
const unsupportedConfigs = configs.filter((c) =>
180+
!initializeResult.resourceDefinitions.some((rd) => rd.type === c.type)
181+
)
182+
if (unsupportedConfigs.length > 0) {
183+
throw new Error(`The plugin does not support the following configs supplied:\n ${JSON.stringify(unsupportedConfigs, null, 2)}\n Initialize result: ${JSON.stringify(initializeResult)}`)
184+
}
185+
186+
// configs = configs.filter((c) => initializeResult.resourceDefinitions.find((rd) => rd.type === c.type)?.operatingSystems?.includes(os.platform() as OS));
187+
188+
console.info(chalk.cyan('Testing validate...'))
189+
const validate = await plugin.validate({
190+
configs: configs.map((c) => {
191+
const { coreParameters, parameters } = splitUserConfig(c)
192+
return { core: coreParameters, parameters };
193+
})
194+
});
195+
196+
const invalidConfigs = validate.resourceValidations.filter((v) => !v.isValid)
197+
if (invalidConfigs.length > 0) {
198+
throw new Error(`The following configs did not validate:\n ${JSON.stringify(invalidConfigs, null, 2)}`)
199+
}
200+
201+
console.info(chalk.cyan('Testing plan...'))
202+
const plans = [];
203+
for (const config of configs) {
204+
const { coreParameters, parameters } = splitUserConfig(config);
205+
206+
plans.push(await plugin.plan({
207+
core: coreParameters,
208+
desired: parameters,
209+
isStateful: false,
210+
state: undefined,
211+
}));
212+
}
213+
214+
console.info(chalk.cyan('Testing apply...'))
215+
for (const plan of plans) {
216+
await plugin.apply({
217+
planId: plan.planId
218+
});
219+
}
220+
} finally {
221+
plugin.kill();
222+
}
223+
}
224+
170225
static async uninstall(pluginPath: string, configs: ResourceConfig[], options?: {
171226
validateDestroy?: (plans: PlanResponseData[]) => Promise<void> | void
172227
}) {
@@ -179,7 +234,7 @@ ${JSON.stringify(modifyPlans, null, 2)}`)
179234
const plans = [];
180235
for (const config of configs) {
181236
const { coreParameters, parameters } = splitUserConfig(config);
182-
237+
183238
plans.push(await destroyPlugin.plan({
184239
core: coreParameters,
185240
isStateful: true,
@@ -213,7 +268,9 @@ ${JSON.stringify(modifyPlans, null, 2)}`)
213268
for (const type of typeSet) {
214269
const sameTypeConfigs = configs.filter((c) => c.type === type);
215270
if (sameTypeConfigs.length > 1) {
216-
sameTypeConfigs.forEach((c, idx) => { c.name = c.name ?? idx.toString() });
271+
sameTypeConfigs.forEach((c, idx) => {
272+
c.name = c.name ?? idx.toString()
273+
});
217274
}
218275

219276
configsWithNames.push(...sameTypeConfigs);

src/spawn.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnRes
2727
throw new Error('Command must not include sudo')
2828
}
2929

30-
process.stdout.write(`Running command: ${options?.requiresRoot ? 'sudo' : ''} ${cmd}` + (options?.cwd ? `(${options?.cwd})` : ''))
30+
console.log(`Running command: ${options?.requiresRoot ? 'sudo' : ''} ${cmd}` + (options?.cwd ? `(${options?.cwd})` : ''))
3131

3232
return new Promise((resolve) => {
3333
const output: string[] = [];
@@ -44,7 +44,7 @@ export function spawnSafe(cmd: string, options?: SpawnOptions): Promise<SpawnRes
4444
}
4545

4646
// Initial terminal dimensions
47-
const initialCols = process.stdout.columns ?? 80;
47+
const initialCols = 10_000; // Set to a large value to prevent wrapping
4848
const initialRows = process.stdout.rows ?? 24;
4949

5050
const command = options?.requiresRoot ? `sudo ${cmd}` : cmd;

src/test-utils.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { EventEmitter } from 'node:events';
22
import { ChildProcess } from 'node:child_process';
33
import { Readable } from 'stream';
4-
import { CodifyTestUtils } from './test-utils.js';
4+
import { TestUtils } from './test-utils.js';
55
import { describe, expect, it, vi } from 'vitest';
66
import { MessageStatus } from 'codify-schemas';
77
import { nanoid } from 'nanoid';
@@ -22,7 +22,7 @@ describe('Test Utils tests', async () => {
2222
const sendMock = vi.spyOn(process, 'send');
2323
const requestId = nanoid(6);
2424

25-
CodifyTestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data', requestId })
25+
TestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data', requestId })
2626

2727
expect(sendMock.mock.calls.length).to.eq(1);
2828
expect(sendMock.mock.calls[0][0]).to.deep.eq({ cmd: 'message', data: 'data', requestId });
@@ -38,7 +38,7 @@ describe('Test Utils tests', async () => {
3838
// Note that the response must end in _Response. In accordance to the message schema rules.
3939
process.emit('message', { cmd: 'message_Response', status: MessageStatus.SUCCESS, data: 'data', requestId })
4040
})(),
41-
CodifyTestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data', requestId }),
41+
TestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data', requestId }),
4242
]);
4343

4444
expect(result[1]).to.eq('data')
@@ -54,7 +54,7 @@ describe('Test Utils tests', async () => {
5454
// Note that the response must end in _Response. In accordance to the message schema rules.
5555
process.emit('message', { cmd: 'message_Response', status: MessageStatus.ERROR, data: 'error message', requestId })
5656
})(),
57-
CodifyTestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data', requestId }),
57+
TestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data', requestId }),
5858
])).rejects.toThrowError(new Error('error message'))
5959
});
6060

@@ -70,7 +70,7 @@ describe('Test Utils tests', async () => {
7070
process.emit('message', { cmd: 'randomMessage2', status: MessageStatus.SUCCESS, data: 'message2', requestId: nanoid(6) })
7171
process.emit('message', { cmd: 'message_Response', status: MessageStatus.SUCCESS, data: 'data', requestId })
7272
})(),
73-
CodifyTestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data', requestId }),
73+
TestUtils.sendMessageAndAwaitResponse(process, { cmd: 'message', data: 'data', requestId }),
7474
]);
7575

7676
// Only the final _Response message should be returned.

src/test-utils.ts

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import Ajv from 'ajv';
2-
import { IpcMessageSchema, IpcMessageV2, MessageStatus } from 'codify-schemas';
2+
import { IpcMessageSchema, IpcMessageV2, MessageStatus, ResourceOs, SpawnStatus } from 'codify-schemas';
33
import { ChildProcess } from 'node:child_process';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
7+
import { PluginTester } from './plugin-tester.js';
8+
import { testSpawn } from './spawn.js';
49

510
const ajv = new Ajv.default({
611
strict: true
712
});
813
const ipcMessageValidator = ajv.compile(IpcMessageSchema);
914

10-
export const CodifyTestUtils = {
15+
export const TestUtils = {
1116
sendMessageAndAwaitResponse(process: ChildProcess, message: IpcMessageV2): Promise<any> {
1217
return new Promise((resolve, reject) => {
1318
process.on('message', (response: IpcMessageV2) => {
@@ -30,4 +35,113 @@ export const CodifyTestUtils = {
3035
});
3136
},
3237

38+
async ensureHomebrewInstalledOnMacOs(pluginPath: string) {
39+
const homebrewQuery = await testSpawn('which brew');
40+
if (homebrewQuery.status !== SpawnStatus.SUCCESS) {
41+
await PluginTester.install(pluginPath, [{ type: 'homebrew', os: [ResourceOs.MACOS] }])
42+
}
43+
},
44+
45+
async ensureXcodeInstalledOnMacOs(pluginPath: string) {
46+
const xcodeQuery = await testSpawn('xcode-select -p');
47+
if (xcodeQuery.status !== SpawnStatus.SUCCESS) {
48+
await PluginTester.install(pluginPath, [{ type: 'xcode-tools', os: [ResourceOs.MACOS] }])
49+
}
50+
},
51+
52+
53+
getShell(): 'bash' | 'zsh' {
54+
const shell = process.env.SHELL || '';
55+
56+
if (shell.includes('bash')) {
57+
return 'bash';
58+
}
59+
60+
if (shell.includes('zsh')) {
61+
return 'zsh';
62+
}
63+
64+
// Default to bash for tests
65+
return 'bash';
66+
},
67+
68+
/**
69+
* Get the primary shell rc file path
70+
*/
71+
getPrimaryShellRc(): string {
72+
const shell = TestUtils.getShell();
73+
const homeDir = os.homedir();
74+
75+
if (shell === 'bash') {
76+
return path.join(homeDir, '.bashrc')
77+
}
78+
79+
if (shell === 'zsh') {
80+
return path.join(homeDir, '.zshrc');
81+
}
82+
83+
throw new Error('Unsupported shell')
84+
},
85+
86+
/**
87+
* Get the source command for the shell rc file
88+
* Usage: execSync(TestUtils.getSourceCommand())
89+
*/
90+
getSourceCommand(): string {
91+
return `source ${TestUtils.getPrimaryShellRc()}`;
92+
},
93+
94+
/**
95+
* Get shell-specific command to run with sourced environment
96+
* Usage: execSync(TestUtils.getShellCommand('which brew'))
97+
*/
98+
getShellCommand(command: string): string {
99+
return `${TestUtils.getSourceCommand()}; ${command}`;
100+
},
101+
102+
/**
103+
* Get shell name for execSync shell option
104+
*/
105+
getShellName(): string {
106+
return TestUtils.getShell();
107+
},
108+
109+
/**
110+
* Get interactive shell command
111+
* Usage: execSync(TestUtils.getInteractiveCommand('my-alias'))
112+
*/
113+
getInteractiveCommand(command: string): string {
114+
const shell = TestUtils.getShell();
115+
116+
return shell === 'bash'
117+
? `bash -i -c "${command}"`
118+
: `zsh -i -c "${command}"`;
119+
},
120+
121+
/**
122+
* Get which command output format based on shell
123+
*/
124+
getAliasWhichCommand(aliasName: string): string {
125+
const shell = TestUtils.getShell();
126+
127+
// zsh outputs: "alias_name: aliased to command"
128+
// bash outputs: "alias alias_name='command'"
129+
return shell === 'bash'
130+
? `${TestUtils.getShellCommand(`alias ${aliasName}`)}`
131+
: `${TestUtils.getShellCommand(`which ${aliasName}`)}`;
132+
},
133+
134+
/**
135+
* Check if running on macOS
136+
*/
137+
isMacOS(): boolean {
138+
return os.platform() === 'darwin';
139+
},
140+
141+
/**
142+
* Check if running on Linux
143+
*/
144+
isLinux(): boolean {
145+
return os.platform() === 'linux';
146+
},
33147
};

0 commit comments

Comments
 (0)