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:
parent
dc96991fca
commit
d99548b3a0
@ -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 { Action, CustomActions } from './customAction';
|
||||||
|
|
||||||
import { createTempSubdirectory, runCommandAndWriteOutput } from '../util/commonUtil';
|
import { CommandResult } from '../util/commonUtil';
|
||||||
import { logger } from '../util/logger';
|
import { logger } from '../util/logger';
|
||||||
|
|
||||||
|
|
||||||
export interface ChatAction {
|
|
||||||
name: string;
|
|
||||||
type: string[];
|
|
||||||
|
|
||||||
|
|
||||||
|
// extend Action
|
||||||
|
export class CommandRunAction implements Action {
|
||||||
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
type: string[];
|
||||||
action: 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 {
|
export default class ActionManager {
|
||||||
private static instance: ActionManager;
|
private static instance: ActionManager;
|
||||||
private actions: ChatAction[] = [];
|
private actions: Action[] = [];
|
||||||
|
|
||||||
private constructor() { }
|
private constructor() { }
|
||||||
|
|
||||||
@ -27,23 +49,106 @@ export default class ActionManager {
|
|||||||
if (!ActionManager.instance) {
|
if (!ActionManager.instance) {
|
||||||
ActionManager.instance = new ActionManager();
|
ActionManager.instance = new ActionManager();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ActionManager.instance.registerAction(new CommandRunAction());
|
||||||
return ActionManager.instance;
|
return ActionManager.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
public registerAction(action: ChatAction): void {
|
public registerAction(action: Action): void {
|
||||||
this.actions.push(action);
|
this.actions.push(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getActionList(): ChatAction[] {
|
public getActionList(): Action[] {
|
||||||
return this.actions;
|
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());
|
const action = this.actions.find(action => action.name.trim() === actionName.trim());
|
||||||
if (action) {
|
if (!action) {
|
||||||
logger.channel()?.info(`Applying action: ${actionName}`);
|
logger.channel()?.info(`Action not found: ${actionName}`);
|
||||||
await action.handler(codeBlock);
|
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 {
|
public loadCustomActions(workflowsDir: string): void {
|
||||||
@ -51,40 +156,7 @@ export default class ActionManager {
|
|||||||
customActionsInstance.parseActions(workflowsDir);
|
customActionsInstance.parseActions(workflowsDir);
|
||||||
|
|
||||||
for (const customAction of customActionsInstance.getActions()) {
|
for (const customAction of customActionsInstance.getActions()) {
|
||||||
const chatAction: ChatAction = {
|
const chatAction: Action = customAction;
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
this.registerAction(chatAction);
|
this.registerAction(chatAction);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { logger } from '../util/logger';
|
import { logger } from '../util/logger';
|
||||||
|
import { CommandResult, createTempSubdirectory, runCommandAndWriteOutput, runCommandStringAndWriteOutput } from '../util/commonUtil';
|
||||||
|
import { UiUtilWrapper } from '../util/uiUtil';
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
name: string;
|
name: string;
|
||||||
@ -8,6 +10,9 @@ export interface Action {
|
|||||||
type: string[];
|
type: string[];
|
||||||
action: string;
|
action: string;
|
||||||
handler: string[];
|
handler: string[];
|
||||||
|
args: { "name": string, "description": string, "type": string, "as"?: string, "from": string }[];
|
||||||
|
|
||||||
|
handlerAction: (args: { [key: string]: string }) => Promise<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CustomActions {
|
export class CustomActions {
|
||||||
@ -24,6 +29,44 @@ export class CustomActions {
|
|||||||
return CustomActions.instance;
|
return CustomActions.instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.';
|
||||||
|
|
||||||
|
return instruction;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
public parseActions(workflowsDir: string): void {
|
||||||
this.actions = [];
|
this.actions = [];
|
||||||
|
|
||||||
@ -48,7 +91,58 @@ export class CustomActions {
|
|||||||
description: settings.description,
|
description: settings.description,
|
||||||
type: settings.type,
|
type: settings.type,
|
||||||
action: settings.action,
|
action: settings.action,
|
||||||
handler: settings.handler.map((handler: string) => handler.replace('${CurDir}', path.join(actionDir, actionSubDir)))
|
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);
|
this.actions.push(action);
|
||||||
}
|
}
|
||||||
@ -57,7 +151,7 @@ export class CustomActions {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Show error message
|
// Show error message
|
||||||
logger.channel()?.error(`Failed to parse actions due to error: ${error}`);
|
logger.channel()?.error(`Failed to parse actions: ${error}`);
|
||||||
logger.channel()?.show();
|
logger.channel()?.show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -65,4 +159,23 @@ export class CustomActions {
|
|||||||
public getActions(): Action[] {
|
public getActions(): Action[] {
|
||||||
return this.actions;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
69
test/action/actionManager.test.ts
Normal file
69
test/action/actionManager.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
30
test/action/customAction.test.ts
Normal file
30
test/action/customAction.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
Loading…
x
Reference in New Issue
Block a user