Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/runtime global function mocking #250

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0e07019
Added function to bast test suite to store global mocked functions
chrisdp Jan 11, 2024
77d06a4
Moved some logic to the utils
chrisdp Jan 11, 2024
9f75924
Added logic to inject code that adds global mock checks at the start …
chrisdp Jan 11, 2024
90edc04
Fixed a reversed check
chrisdp Jan 15, 2024
2c4feef
Removed new api in favor of expanding stubcall and updated injection …
chrisdp Jan 15, 2024
9f6111e
Updated some of the global function detection logic
chrisdp Jan 15, 2024
fadbf2c
Unit tests, fixes, sample test project update
chrisdp Jan 15, 2024
fc6ce1d
removed console logs
chrisdp Jan 15, 2024
8b3fee5
Transpile the file if we had to touch modify it
chrisdp Jan 15, 2024
3307955
Fixed global mocks and stubs not clearing
chrisdp Jan 15, 2024
a155a29
updated some of the checks around transforming stubcall functions
chrisdp Jan 16, 2024
a342165
Made some code reusable and fixed some imports
chrisdp Jan 16, 2024
02c6f4e
Added back the disablemocking logic and removed unused code
chrisdp Jan 16, 2024
02c64df
Fixed bad ast related to noEarlyExit
chrisdp Jan 16, 2024
86656dd
Updated bsc in tests app
chrisdp Jan 16, 2024
db71048
added tests for global stubcall on device
chrisdp Jan 16, 2024
4c3d753
Fixed some device tests
chrisdp Jan 16, 2024
b4024ee
Fixed more on device tests
chrisdp Jan 16, 2024
67ec05a
More test fixes as the result of moving to ast editor
chrisdp Jan 16, 2024
bcfd24d
Fixed some indenting
chrisdp Jan 16, 2024
9f4eabd
Fixed node test xml files being added after modifying assertions lead…
chrisdp Jan 17, 2024
3acbf16
Updated the modify stub detection logic for globals
chrisdp Jan 17, 2024
1be78f6
more tests for global stub call modifications
chrisdp Jan 17, 2024
3164492
Fixed some race conditons and more global function detection refinments
chrisdp Jan 17, 2024
4132da2
Fixed some issues picking the wrong scope and make sure stubcall work…
chrisdp Jan 19, 2024
f435bba
Moved global stub clearing to clearStubs()
chrisdp Jan 20, 2024
a9e1450
Merge branch 'master' into feature/runtime-global-function-mocking
chrisdp Jan 21, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to add the component file to the program before we start looking for stub and expect calls to be modified now that we use the file scopes to reliably know if a global is in scope for this suite.

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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed some undefined ast being injected resulting in this annotation causing down stream crashes at transpile. Required to be able to run the test app on device,

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