Add unit tests for actions with valid and empty args

- Add a test case for applying an action with valid args.
- Add a test case for applying an action with empty args.
This commit is contained in:
bobo.yang 2023-07-24 00:11:56 +08:00
parent dc96991fca
commit d99548b3a0
4 changed files with 381 additions and 97 deletions

View File

@ -1,25 +1,47 @@
import * as fs from 'fs';
import * as path from 'path';
import { UiUtilWrapper } from '../util/uiUtil';
import { Action, CustomActions } from './customAction';
import { createTempSubdirectory, runCommandAndWriteOutput } from '../util/commonUtil';
import { CommandResult } from '../util/commonUtil';
import { logger } from '../util/logger';
export interface ChatAction {
name: string;
type: string[];
// extend Action
export class CommandRunAction implements Action {
name: string;
description: string;
type: string[];
action: string;
handler: (codeBlock: { [key: string]: string }) => Promise<void>;
}
handler: string[];
args: { "name": string, "description": string, "type": string, "as"?: string, "from": string }[];
constructor() {
this.name = 'command_run';
this.description = 'run command';
this.type = ['command'];
this.action = 'command_run';
this.handler = [];
this.args = [
{"name": "content", "description": "command json to run", "type": "string", "from": "content.content"},
];
}
async handlerAction(args: {[key: string]: any}): Promise<CommandResult> {
try {
const commandData = JSON.parse(args.content);
const result = await ActionManager.getInstance().applyCommandAction(commandData.command, commandData.args);
return result;
} catch (error) {
logger.channel()?.error('Failed to parse code file content: ' + error);
logger.channel()?.show();
return {exitCode: -1, stdout: '', stderr: `Failed to parse code file content: ${error}`};
}
}
};
export default class ActionManager {
private static instance: ActionManager;
private actions: ChatAction[] = [];
private actions: Action[] = [];
private constructor() { }
@ -27,23 +49,106 @@ export default class ActionManager {
if (!ActionManager.instance) {
ActionManager.instance = new ActionManager();
}
ActionManager.instance.registerAction(new CommandRunAction());
return ActionManager.instance;
}
public registerAction(action: ChatAction): void {
public registerAction(action: Action): void {
this.actions.push(action);
}
public getActionList(): ChatAction[] {
public getActionList(): Action[] {
return this.actions;
}
public async applyAction(actionName: string, codeBlock: { [key: string]: string }): Promise<void> {
public async applyAction(actionName: string, content: { "command": string, content: string, fileName: string }): Promise<CommandResult> {
const action = this.actions.find(action => action.name.trim() === actionName.trim());
if (action) {
logger.channel()?.info(`Applying action: ${actionName}`);
await action.handler(codeBlock);
if (!action) {
logger.channel()?.info(`Action not found: ${actionName}`);
return {exitCode: -1, stdout: '', stderr: `${actionName} not found in action list: ${this.actions.map(action => action.name)}`};
}
logger.channel()?.info(`Apply action: ${actionName}`);
// action.args define what args should be passed to handler
// for example:
// action.args = [
// {"name": "arg1", "description": "arg1 description", "type": "string", "from": "content.fileName"},
// {"name": "arg2", "description": "arg2 description", "type": "string", "from": "content.content.v1"}]
// then:
// arg1 = content.fileName
// arg2 = content.content.v1
// before use content.content.v1, we should parse content.content first as json
if (action.args === undefined || action.args.length === 0) {
// every action should have args, if not, then it is invalid
logger.channel()?.error(`Action ${actionName} has no args`);
logger.channel()?.show();
return {exitCode: -1, stdout: '', stderr: `Action ${actionName} has no args`};
}
// construct args for handler
let args: {[key: string]: any} = {};
// check whether action.args has x.x.x like from value
let hasDotDotFrom = false;
for (const arg of action.args) {
if (arg.from !== undefined) {
// if arg.from has two or more dot, then it is x.x.x
if (arg.from.split('.').length >= 3) {
hasDotDotFrom = true;
break;
}
}
}
// if hasDotDotFrom is true, then parse content as json
if (hasDotDotFrom) {
try {
content.content = JSON.parse(content.content);
} catch (error) {
logger.channel()?.info(`Parse content as json failed: ${error}`);
return {exitCode: -1, stdout: '', stderr: `Parse content as json failed: ${error}`};
}
}
for (const arg of action.args) {
let argValue = '';
if (arg.from !== undefined) {
// visit arg.from, it is string
let argFromValue: any = content;
const argFrom = arg.from.split('.');
// first item of argFrom is content, so skip it
for (const argFromItem of argFrom.slice(1)) {
argFromValue = argFromValue[argFromItem];
}
// if argFromValue is undefined, then it is invalid
if (argFromValue === undefined) {
logger.channel()?.error(`Action ${actionName} arg ${arg.name} from ${arg.from} is undefined`);
logger.channel()?.show();
return {exitCode: -1, stdout: '', stderr: `Action ${actionName} arg ${arg.name} from ${arg.from} is undefined`};
}
argValue = argFromValue;
}
args[arg.name] = argValue;
}
return await action.handlerAction(args);
}
public async applyCommandAction(command: string, args: {[key: string]: any}) : Promise<CommandResult> {
const action = this.actions.find(action => action.name.trim() === command.trim());
if (!action) {
logger.channel()?.info(`Action not found: ${command}`);
return {exitCode: -1, stdout: '', stderr: `${command} not found in action list: ${this.actions.map(action => action.name)}`};
}
logger.channel()?.info(`Apply command action: ${command}`);
return await action.handlerAction(args);
}
public loadCustomActions(workflowsDir: string): void {
@ -51,40 +156,7 @@ export default class ActionManager {
customActionsInstance.parseActions(workflowsDir);
for (const customAction of customActionsInstance.getActions()) {
const chatAction: ChatAction = {
name: customAction.name,
type: customAction.type,
description: customAction.description,
action: customAction.action,
handler: async (codeBlock: { [key: string]: string }) => {
// Implement the handler logic for the custom action
const tempDir = await createTempSubdirectory('devchat/context');
const tempFile = path.join(tempDir, 'apply.json');
const contextMap = {
'codeBlock': codeBlock,
'workspaceDir': UiUtilWrapper.workspaceFoldersFirstPath(),
'activeFile': UiUtilWrapper.activeFilePath(),
'selectRang': UiUtilWrapper.selectRange(),
'secectText': UiUtilWrapper.selectText(),
};
// Save contextMap to temp file
await UiUtilWrapper.writeFile(tempFile, JSON.stringify(contextMap));
// replace ${contextFile} with tempFile for arg in handler
const handlerArgs = customAction.handler.map(arg => arg.replace('${contextFile}', tempFile));
// run handler
const result = await runCommandAndWriteOutput(handlerArgs[0], handlerArgs.slice(1), undefined);
logger.channel()?.info(`Applied action: ${customAction.name}`);
logger.channel()?.info(` exit code:`, result.exitCode)
logger.channel()?.info(` stdout:`, result.stdout);
logger.channel()?.info(` stderr:`, result.stderr);
// remove temp file
fs.unlinkSync(tempFile);
},
};
const chatAction: Action = customAction;
this.registerAction(chatAction);
}
}

View File

@ -1,63 +1,157 @@
import fs from 'fs';
import path from 'path';
import { logger } from '../util/logger';
import { CommandResult, createTempSubdirectory, runCommandAndWriteOutput, runCommandStringAndWriteOutput } from '../util/commonUtil';
import { UiUtilWrapper } from '../util/uiUtil';
export interface Action {
name: string;
description: string;
type: string[];
action: string;
handler: string[];
name: string;
description: string;
type: string[];
action: string;
handler: string[];
args: { "name": string, "description": string, "type": string, "as"?: string, "from": string }[];
handlerAction: (args: { [key: string]: string }) => Promise<CommandResult>;
}
export class CustomActions {
private static instance: CustomActions | null = null;
private actions: Action[] = [];
private static instance: CustomActions | null = null;
private actions: Action[] = [];
private constructor() {
}
private constructor() {
}
public static getInstance(): CustomActions {
if (!CustomActions.instance) {
CustomActions.instance = new CustomActions();
}
return CustomActions.instance;
}
public static getInstance(): CustomActions {
if (!CustomActions.instance) {
CustomActions.instance = new CustomActions();
}
return CustomActions.instance;
}
public parseActions(workflowsDir: string): void {
this.actions = [];
public actionInstruction(): string {
let instruction = 'As an AI bot, you replay user with "command" block, don\'t replay any other words.\n' +
'"command" block is like this:\n' +
'``` command\n' +
'{\n' +
' "command": "xxx",\n' +
' "args" {\n' +
' "var name": "xxx"\n' +
' }\n' +
'}\n' +
'```\n' +
'You can split task into small sub tasks, after each command I will give you the result of command executed. so, the next command can depend pre command\'s output.\n' +
'\n' +
'Supported commands are:\n';
let index = 1;
for (const action of this.actions) {
instruction += String(index) + ". " + this.getActionInstruction(action.name) + "\n";
index += 1;
}
try {
const extensionDirs = fs.readdirSync(workflowsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
instruction += 'Restriction for output:\n' +
'1. Only reponse "command" block.\n' +
'2. Don\'t include any other text exclude command.\n' +
'3. Only supported extension commands can be used to complete the response.\n' +
'4. When update file, old_content must include at least three lines.';
for (const extensionDir of extensionDirs) {
const actionDir = path.join(workflowsDir, extensionDir, 'action');
if (fs.existsSync(actionDir)) {
const actionSubDirs = fs.readdirSync(actionDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
return instruction;
}
for (const actionSubDir of actionSubDirs) {
const settingsPath = path.join(actionDir, actionSubDir, '_setting_.json');
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
const action: Action = {
name: settings.name,
description: settings.description,
type: settings.type,
action: settings.action,
handler: settings.handler.map((handler: string) => handler.replace('${CurDir}', path.join(actionDir, actionSubDir)))
};
this.actions.push(action);
}
}
}
}
} catch (error) {
// Show error message
logger.channel()?.error(`Failed to parse actions due to error: ${error}`);
public saveActionInstructionFile(tarFile: string): void {
try {
fs.writeFileSync(tarFile, this.actionInstruction());
} catch (error) {
logger.channel()?.error(`Failed to save action instruction file: ${error}`);
logger.channel()?.show();
}
}
public parseActions(workflowsDir: string): void {
this.actions = [];
try {
const extensionDirs = fs.readdirSync(workflowsDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
for (const extensionDir of extensionDirs) {
const actionDir = path.join(workflowsDir, extensionDir, 'action');
if (fs.existsSync(actionDir)) {
const actionSubDirs = fs.readdirSync(actionDir, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => dirent.name);
for (const actionSubDir of actionSubDirs) {
const settingsPath = path.join(actionDir, actionSubDir, '_setting_.json');
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
const action: Action = {
name: settings.name,
description: settings.description,
type: settings.type,
action: settings.action,
args: settings.args,
handler: settings.handler.map((handler: string) => handler.replace('${CurDir}', path.join(actionDir, actionSubDir))),
handlerAction: async (args: { [key: string]: string }) => {
// Implement the handler logic for the custom action
const tempDir = await createTempSubdirectory('devchat/context');
const tempFile = path.join(tempDir, 'apply.json');
const contextMap = {
'codeBlock': args,
'workspaceDir': UiUtilWrapper.workspaceFoldersFirstPath(),
'activeFile': UiUtilWrapper.activeFilePath(),
'selectRang': UiUtilWrapper.selectRange(),
'secectText': UiUtilWrapper.selectText(),
};
// Save contextMap to temp file
await UiUtilWrapper.writeFile(tempFile, JSON.stringify(contextMap));
// replace ${contextFile} with tempFile for arg in handler
let handlerArgs = action.handler.map(arg => arg.replace('${contextFile}', tempFile));
if (args !== undefined) {
// visit args, it is {[key: string]: string}
for (const arg in args) {
let argValue = args[arg];
const argDefine = action.args.find(v => v.name === arg);
if (argDefine !== undefined && argDefine.as !== undefined) {
// save argValue to temp file
const tempFile = path.join(tempDir, argDefine.as);
await UiUtilWrapper.writeFile(tempFile, argValue);
argValue = tempFile;
}
// replace ${arg} with commandObj.args[arg]
handlerArgs = handlerArgs.map(v => { if (v === '${' + arg + '}') { return argValue; } else { return v; } });
}
}
handlerArgs = handlerArgs.flat();
// run handler
let result: CommandResult = { exitCode: -1, stdout: '', stderr: '' };
if (handlerArgs.length === 1) {
result = await runCommandStringAndWriteOutput(handlerArgs[0], undefined);
} else if (handlerArgs.length > 1) {
result = await runCommandAndWriteOutput(handlerArgs[0], handlerArgs.slice(1), undefined);
}
logger.channel()?.info(`Apply action: ${action.name} exit code:`, result.exitCode);
logger.channel()?.info(`stdout:`, result.stdout);
logger.channel()?.info(`stderr:`, result.stderr);
// remove temp file
fs.unlinkSync(tempFile);
return result;
},
};
this.actions.push(action);
}
}
}
}
} catch (error) {
// Show error message
logger.channel()?.error(`Failed to parse actions: ${error}`);
logger.channel()?.show();
}
}
@ -65,4 +159,23 @@ export class CustomActions {
public getActions(): Action[] {
return this.actions;
}
// generate instruction for action
public getActionInstruction(actionName: string): string {
const action = this.actions.find(action => action.name.trim() === actionName.trim());
if (!action) {
return '';
}
let instruction = `${action.name}: ${action.description}\n`;
// if args is not undefined and has values, then visit args
if (action.args !== undefined && action.args.length > 0) {
instruction += `Args:\n`;
for (const arg of action.args) {
instruction += ` name: ${arg.name} type: (${arg.type}) description: ${arg.description}\n`;
}
}
return instruction;
}
}

View File

@ -0,0 +1,69 @@
import { expect } from 'chai';
import 'mocha';
import ActionManager from '../../src/action/actionManager';
import { Action } from '../../src/action/customAction';
describe('ActionManager', () => {
const testAction: Action = {
name: 'testAction',
description: 'Test action for unit testing',
type: ['test'],
action: 'test',
handler: [],
args: [],
handlerAction: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
};
it('should register and retrieve actions', () => {
const actionManager = ActionManager.getInstance();
actionManager.registerAction(testAction);
const actionList = actionManager.getActionList();
expect(actionList).to.contain(testAction);
});
it('should return an error for action with empty args', async () => {
const actionManager = ActionManager.getInstance();
const testActionWithEmptyArgs: Action = {
...testAction,
name: 'testActionWithEmptyArgs',
args: [],
};
actionManager.registerAction(testActionWithEmptyArgs);
const actionName = 'testActionWithEmptyArgs';
const content = {
command: 'test',
content: 'test content',
fileName: 'test.txt',
};
const result = await actionManager.applyAction(actionName, content);
expect(result.exitCode).to.equal(-1);
expect(result.stdout).to.equal('');
expect(result.stderr).to.equal('Action testActionWithEmptyArgs has no args');
});
it('should apply action with valid args correctly', async () => {
const actionManager = ActionManager.getInstance();
const testActionWithArgs: Action = {
...testAction,
name: 'testActionWithArgs',
args: [
{
name: 'arg1',
description: 'arg1 description',
type: 'string',
from: 'content.fileName',
},
],
};
actionManager.registerAction(testActionWithArgs);
const actionName = 'testActionWithArgs';
const content = {
command: 'test',
content: 'test content',
fileName: 'test.txt',
};
const result = await actionManager.applyAction(actionName, content);
expect(result.exitCode).to.equal(0);
expect(result.stdout).to.equal('');
expect(result.stderr).to.equal('');
});
});

View File

@ -0,0 +1,30 @@
import { expect } from 'chai';
import 'mocha';
import { CustomActions } from '../../src/action/customAction';
describe('CustomActions', () => {
const customActions = CustomActions.getInstance();
it('should return an empty action list', () => {
const actions = customActions.getActions();
expect(actions).to.deep.equal([]);
});
it('should return a non-empty action instruction with actions', () => {
// Add a sample action to the customActions instance
customActions.getActions().push({
name: 'sampleAction',
description: 'A sample action for testing',
type: ['test'],
action: 'sample',
handler: [],
args: [],
handlerAction: async (args: { [key: string]: string }) => {
return { exitCode: 0, stdout: '', stderr: '' };
},
});
const instruction = customActions.actionInstruction();
expect(instruction).to.include('sampleAction: A sample action for testing');
});
});