Skip to content

Commit

Permalink
Feature/runtime global function mocking (#259)
Browse files Browse the repository at this point in the history
* Added function to bast test suite to store global mocked functions

* Moved some logic to the utils

* Added logic to inject code that adds global mock checks at the start of function bodys

* Fixed a reversed check

* Removed new api in favor of expanding stubcall and updated injection code

* Updated some of the global function detection logic

* Unit tests, fixes, sample test project update

* removed console logs

* Transpile the file if we had to touch modify it

* Fixed global mocks and stubs not clearing

* updated some of the checks around transforming stubcall functions

* Made some code reusable and fixed some imports

* Added back the disablemocking logic and removed unused code

* Fixed bad ast related to noEarlyExit

* Updated bsc in tests app

* added tests for global stubcall on device

* Fixed some device tests

* Fixed more on device tests

* More test fixes as the result of moving to ast editor

* Fixed some indenting

* Fixed node test xml files being added after modifying assertions leading to crashes

* Updated the modify stub detection logic for globals

* more tests for global stub call modifications

* Fixed some race conditons and more global function detection refinments

* Fixed some issues picking the wrong scope and make sure stubcall worked with async tests

* Moved global stub clearing to clearStubs()
  • Loading branch information
chrisdp authored Jan 22, 2024
1 parent a3d843e commit 1e15f77
Show file tree
Hide file tree
Showing 14 changed files with 1,864 additions and 678 deletions.
442 changes: 269 additions & 173 deletions bsc-plugin/src/lib/rooibos/MockUtil.spec.ts

Large diffs are not rendered by default.

137 changes: 87 additions & 50 deletions bsc-plugin/src/lib/rooibos/MockUtil.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
import type { BrsFile, Editor, ProgramBuilder } from 'brighterscript';
import { Position, isClassStatement } from 'brighterscript';
import type { BrsFile, Editor, NamespaceStatement, ProgramBuilder, Scope } from 'brighterscript';
import { ParseMode, Parser, isClassStatement, isNamespaceStatement } from 'brighterscript';
import * as brighterscript from 'brighterscript';
import type { RooibosConfig } from './RooibosConfig';
import { RawCodeStatement } from './RawCodeStatement';
import { Range } from 'vscode-languageserver-types';
import type { FileFactory } from './FileFactory';
import undent from 'undent';
import type { RooibosSession } from './RooibosSession';
import { diagnosticErrorProcessingFile } from '../utils/Diagnostics';
import type { TestCase } from './TestCase';
import type { TestSuite } from './TestSuite';
import { getAllDottedGetParts } from './Utils';
import { functionRequiresReturnValue, getAllDottedGetParts, getScopeForSuite } from './Utils';

export class MockUtil {

Expand All @@ -26,13 +24,13 @@ export class MockUtil {
session: RooibosSession;

private brsFileAdditions = `
function RBS_SM_#ID#_getMocksByFunctionName()
if m._rMocksByFunctionName = invalid
m._rMocksByFunctionName = {}
end if
return m._rMocksByFunctionName
end function
`;
function RBS_SM_#ID#_getMocksByFunctionName()
if m._rMocksByFunctionName = invalid
m._rMocksByFunctionName = {}
end if
return m._rMocksByFunctionName
end function
`;

private config: RooibosConfig;
private fileId: number;
Expand All @@ -52,8 +50,8 @@ export class MockUtil {
this.processedStatements = new Set<brighterscript.FunctionStatement>();
this.astEditor = astEditor;
// console.log('processing global methods on ', file.pkgPath);
for (let fs of file.parser.references.functionStatements) {
this.enableMockOnFunction(fs);
for (let functionStatement of file.parser.references.functionStatements) {
this.enableMockOnFunction(file, functionStatement);
}

this.filePathMap[this.fileId] = file.pkgPath;
Expand All @@ -62,7 +60,7 @@ export class MockUtil {
}
}

private enableMockOnFunction(functionStatement: brighterscript.FunctionStatement) {
private enableMockOnFunction(file: BrsFile, functionStatement: brighterscript.FunctionStatement) {
if (isClassStatement(functionStatement.parent?.parent)) {
// console.log('skipping class', functionStatement.parent?.parent?.name?.text);
return;
Expand All @@ -79,29 +77,53 @@ export class MockUtil {
return;
}

let isDisabledFoMocking = functionStatement.annotations?.find(x => x.name.toLowerCase() === 'disablemocking');
let parentNamespace = functionStatement.findAncestor<NamespaceStatement>(isNamespaceStatement);
while (parentNamespace && !isDisabledFoMocking) {
if (parentNamespace) {
isDisabledFoMocking = parentNamespace.annotations?.find(x => x.name.toLowerCase() === 'disablemocking');
parentNamespace = parentNamespace.findAncestor<NamespaceStatement>(isNamespaceStatement);
}
}
if (isDisabledFoMocking) {
// The developer has stated that this function is not safe to be mocked
return;
}

// console.log('processing stubbed method', methodName);
// TODO check if the user has actually mocked or stubbed this function, otherwise leave it alone!

for (let param of functionStatement.func.parameters) {
param.asToken = null;
}

const paramNames = functionStatement.func.parameters.map((param) => param.name.text).join(',');
const requiresReturnValue = functionRequiresReturnValue(functionStatement);
const globalAaName = '__stubs_globalAa';
const resultName = '__stubOrMockResult';
const storageName = '__globalStubs';

const returnStatement = ((functionStatement.func.functionType?.kind === brighterscript.TokenKind.Sub && (functionStatement.func.returnTypeToken === undefined || functionStatement.func.returnTypeToken?.kind === brighterscript.TokenKind.Void)) || functionStatement.func.returnTypeToken?.kind === brighterscript.TokenKind.Void) ? 'return' : 'return result';
this.astEditor.addToArray(functionStatement.func.body.statements, 0, new RawCodeStatement(undent`
const template = undent`
${globalAaName} = getGlobalAa()
if RBS_SM_${this.fileId}_getMocksByFunctionName()["${methodName}"] <> invalid
result = RBS_SM_${this.fileId}_getMocksByFunctionName()["${methodName}"].callback(${paramNames})
${returnStatement}
${resultName} = RBS_SM_${this.fileId}_getMocksByFunctionName()["${methodName}"].callback(${paramNames})
return${requiresReturnValue ? ` ${resultName}` : '' }
else if type(${globalAaName}?.${storageName}?.${methodName}).endsWith("Function")
__stubFunction = ${globalAaName}.${storageName}.${methodName}
${resultName} = __stubFunction(${paramNames})
return${requiresReturnValue ? ` ${resultName}` : ''}
end if
`));
`;
const astCodeToInject = Parser.parse(template).ast.statements;
this.astEditor.arrayUnshift(functionStatement.func.body.statements, ...astCodeToInject);

this.processedStatements.add(functionStatement);
file.needsTranspiled = true;
}

addBrsAPIText(file: BrsFile) {
//TODO should use ast editor!
const func = new RawCodeStatement(this.brsFileAdditions.replace(/\#ID\#/g, this.fileId.toString().trim()), file, Range.create(Position.create(1, 1), Position.create(1, 1)));
file.ast.statements.push(func);
const func = Parser.parse(this.brsFileAdditions.replace(/\#ID\#/g, this.fileId.toString().trim())).ast.statements;
this.astEditor.arrayPush(file.ast.statements, ...func);
}


Expand All @@ -125,13 +147,13 @@ export class MockUtil {
let assertRegex = /(?:fail|assert(?:[a-z0-9]*)|expect(?:[a-z0-9]*)|stubCall)/i;
if (dge && assertRegex.test(dge.name.text)) {
if (dge.name.text === 'stubCall') {
this.processGlobalStubbedMethod(callExpression);
this.processGlobalStubbedMethod(callExpression, testSuite);
return expressionStatement;

} else {

if (dge.name.text === 'expectCalled' || dge.name.text === 'expectNotCalled') {
this.processGlobalStubbedMethod(callExpression);
this.processGlobalStubbedMethod(callExpression, testSuite);
}
}
}
Expand All @@ -147,9 +169,10 @@ export class MockUtil {
}
}

private processGlobalStubbedMethod(callExpression: brighterscript.CallExpression) {
private processGlobalStubbedMethod(callExpression: brighterscript.CallExpression, testSuite: TestSuite) {
let isNotCalled = false;
let isStubCall = false;
const scope = getScopeForSuite(testSuite);
const namespaceLookup = this.session.namespaceLookup;
if (brighterscript.isDottedGetExpression(callExpression.callee)) {
const nameText = callExpression.callee.name.text;
Expand All @@ -158,32 +181,46 @@ export class MockUtil {
}
//modify args
let arg0 = callExpression.args[0];
if (brighterscript.isCallExpression(arg0) && brighterscript.isDottedGetExpression(arg0.callee)) {

//is it a namespace?
let dg = arg0.callee;
let nameParts = getAllDottedGetParts(dg);
let name = nameParts.pop();

// console.log('found expect with name', name);
if (name) {
//is a namespace?
if (nameParts[0] && namespaceLookup.has(nameParts[0].toLowerCase())) {
//then this must be a namespace method
let fullPathName = nameParts.join('.').toLowerCase();
let ns = namespaceLookup.get(fullPathName);
if (!ns) {
//TODO this is an error condition!
}
nameParts.push(name);
let functionName = nameParts.join('_').toLowerCase();
this.session.globalStubbedMethods.add(functionName);
}
let arg1 = callExpression.args[1];

if (isStubCall) {
let functionName = this.getGlobalFunctionName(arg0, scope);
if (functionName) {
this.session.globalStubbedMethods.add(functionName.toLowerCase());
return;
}
}

if (brighterscript.isCallExpression(arg0)) {
let functionName = this.getGlobalFunctionName(arg0.callee, scope);
if (functionName) {
this.session.globalStubbedMethods.add(functionName.toLowerCase());
}
} else if (brighterscript.isCallExpression(arg0) && brighterscript.isVariableExpression(arg0.callee)) {
let functionName = arg0.callee.getName(brighterscript.ParseMode.BrightScript).toLowerCase();
this.session.globalStubbedMethods.add(functionName);
}
}


private getGlobalFunctionName(expression: brighterscript.Expression, scope: Scope) {
let result: string;
if (brighterscript.isDottedGetExpression(expression)) {
let nameParts = getAllDottedGetParts(expression);
let functionName = nameParts.join('.');
let callable = scope.getCallableByName(functionName);
if (callable) {
result = callable.getName(ParseMode.BrightScript);
}
} else if (brighterscript.isVariableExpression(expression)) {
let functionName = expression.getName(ParseMode.BrightScript);
if (scope.symbolTable.hasSymbol(functionName)) {
result = functionName;
}

functionName = expression.getName(ParseMode.BrighterScript);
if (scope.getCallableByName(functionName)) {
result = functionName;
}
}

return result;
}
}
17 changes: 4 additions & 13 deletions bsc-plugin/src/lib/rooibos/RooibosSession.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as path from 'path';
import type { BrsFile, BscFile, ClassStatement, FunctionStatement, NamespaceStatement, Program, ProgramBuilder, Scope, Statement } from 'brighterscript';
import type { BrsFile, ClassStatement, FunctionStatement, NamespaceContainer, NamespaceStatement, Program, ProgramBuilder, Scope } from 'brighterscript';
import { isBrsFile, isCallExpression, isVariableExpression, ParseMode, WalkMode } from 'brighterscript';
import type { AstEditor } from 'brighterscript/dist/astUtils/AstEditor';
import type { RooibosConfig } from './RooibosConfig';
Expand All @@ -16,18 +16,6 @@ import type { MockUtil } from './MockUtil';
// eslint-disable-next-line
const pkg = require('../../../package.json');


export interface NamespaceContainer {
file: BscFile;
fullName: string;
nameRange: Range;
lastPartName: string;
statements: Statement[];
classStatements: Record<string, ClassStatement>;
functionStatements: Record<string, FunctionStatement>;
namespaces: Record<string, NamespaceContainer>;
}

export class RooibosSession {

constructor(builder: ProgramBuilder, fileFactory: FileFactory) {
Expand Down Expand Up @@ -58,6 +46,9 @@ export class RooibosSession {
console.log('Efficient global stubbing is enabled');
this.namespaceLookup = this.getNamespaces(program);
for (let testSuite of this.sessionInfo.testSuitesToRun) {
if (testSuite.isNodeTest) {
this.createNodeFile(program, testSuite);
}
mockUtil.gatherGlobalMethodMocks(testSuite);
}

Expand Down
52 changes: 36 additions & 16 deletions bsc-plugin/src/lib/rooibos/TestGroup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AstEditor, CallExpression, DottedGetExpression } from 'brighterscript';
import { ArrayLiteralExpression, createInvalidLiteral, createStringLiteral, createToken, isDottedGetExpression, TokenKind, isFunctionExpression, Parser } from 'brighterscript';
import type { AstEditor, CallExpression, DottedGetExpression, Expression, NamespaceContainer, Scope } from 'brighterscript';
import { ArrayLiteralExpression, createInvalidLiteral, createStringLiteral, createToken, isDottedGetExpression, TokenKind, isFunctionExpression, Parser, ParseMode } from 'brighterscript';
import * as brighterscript from 'brighterscript';
import { BrsTranspileState } from 'brighterscript/dist/parser/BrsTranspileState';
import { diagnosticErrorProcessingFile } from '../utils/Diagnostics';
Expand All @@ -8,7 +8,6 @@ import type { TestCase } from './TestCase';
import type { TestSuite } from './TestSuite';
import { TestBlock } from './TestSuite';
import { getAllDottedGetParts, getRootObjectFromDottedGet, getStringPathFromDottedGet, sanitizeBsJsonString } from './Utils';
import type { NamespaceContainer } from './RooibosSession';

export class TestGroup extends TestBlock {

Expand Down Expand Up @@ -40,14 +39,13 @@ export class TestGroup extends TestBlock {
} else {
this.hasAsyncTests = testCase.isAsync;
}

}

public getTestCases(): TestCase[] {
return [...this.testCases.values()];
}

public modifyAssertions(testCase: TestCase, noEarlyExit: boolean, editor: AstEditor, namespaceLookup: Map<string, NamespaceContainer>) {
public modifyAssertions(testCase: TestCase, noEarlyExit: boolean, editor: AstEditor, namespaceLookup: Map<string, NamespaceContainer>, scope: Scope) {
//for each method
//if assertion
//wrap with if is not fail
Expand All @@ -64,21 +62,22 @@ export class TestGroup extends TestBlock {
let assertRegex = /(?:fail|assert(?:[a-z0-9]*)|expect(?:[a-z0-9]*)|stubCall)/i;
if (dge && assertRegex.test(dge.name.text)) {
if (dge.name.text === 'stubCall') {
this.modifyModernRooibosExpectCallExpression(callExpression, editor, namespaceLookup);
this.modifyModernRooibosExpectCallExpression(callExpression, editor, namespaceLookup, scope);
return expressionStatement;

} else {

if (dge.name.text === 'expectCalled' || dge.name.text === 'expectNotCalled') {
this.modifyModernRooibosExpectCallExpression(callExpression, editor, namespaceLookup);
this.modifyModernRooibosExpectCallExpression(callExpression, editor, namespaceLookup, scope);
}
if (dge.name.text === 'expectCalled' || dge.name.text === 'expectNotCalled') {
this.modifyModernRooibosExpectCallExpression(callExpression, editor, namespaceLookup);
this.modifyModernRooibosExpectCallExpression(callExpression, editor, namespaceLookup, scope);
}
const trailingLine = Parser.parse(`${noEarlyExit ? '' : `if m.currentResult?.isFail = true then m.done() : return ${isSub ? '' : 'invalid'}`}`).ast.statements[0];

editor.arraySplice(owner, key + 1, 0, trailingLine);

if (!noEarlyExit) {
const trailingLine = Parser.parse(`if m.currentResult?.isFail = true then m.done() : return ${isSub ? '' : 'invalid'}`).ast.statements[0];
editor.arraySplice(owner, key + 1, 0, trailingLine);
}
const leadingLine = Parser.parse(`m.currentAssertLineNumber = ${callExpression.range.start.line}`).ast.statements[0];
editor.arraySplice(owner, key, 0, leadingLine);
}
Expand All @@ -89,23 +88,30 @@ export class TestGroup extends TestBlock {
walkMode: brighterscript.WalkMode.visitStatementsRecursive
});
} catch (e) {
// console.log(e);
console.error(e);
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
diagnosticErrorProcessingFile(this.testSuite.file, e.message);
}
}

private modifyModernRooibosExpectCallExpression(callExpression: CallExpression, editor: AstEditor, namespaceLookup: Map<string, NamespaceContainer>) {
private modifyModernRooibosExpectCallExpression(callExpression: CallExpression, editor: AstEditor, namespaceLookup: Map<string, NamespaceContainer>, scope: Scope) {
let isNotCalled = false;
let isStubCall = false;

//modify args
let arg0 = callExpression.args[0];
let arg1 = callExpression.args[1];
if (isDottedGetExpression(callExpression.callee)) {
const nameText = callExpression.callee.name.text;
editor.setProperty(callExpression.callee.name, 'text', `_${nameText}`);
isNotCalled = nameText === 'expectNotCalled';
isStubCall = nameText === 'stubCall';

if (isStubCall && this.shouldNotModifyStubCall(arg0, namespaceLookup, scope)) {
return;
}
editor.setProperty(callExpression.callee.name, 'text', `_${nameText}`);
}
//modify args
let arg0 = callExpression.args[0];

if (brighterscript.isCallExpression(arg0) && isDottedGetExpression(arg0.callee)) {

//is it a namespace?
Expand Down Expand Up @@ -191,6 +197,20 @@ export class TestGroup extends TestBlock {
}
}

private shouldNotModifyStubCall(arg0: Expression, namespaceLookup: Map<string, NamespaceContainer>, scope: Scope) {
if (brighterscript.isDottedGetExpression(arg0)) {
let nameParts = getAllDottedGetParts(arg0);
let functionName = nameParts.join('.');
return scope.getCallableByName(functionName);
} else if (brighterscript.isVariableExpression(arg0)) {
return (
scope.symbolTable.hasSymbol(arg0.getName(ParseMode.BrightScript)) ||
scope.getCallableByName(arg0.getName(ParseMode.BrighterScript))
);
}
return false;
}

public asText(): string {
let testCaseText = [...this.testCases.values()].filter((tc) => tc.isIncluded).map((tc) => tc.asText());

Expand Down
Loading

0 comments on commit 1e15f77

Please sign in to comment.