diff --git a/.gitignore b/.gitignore index b57a61e..ba083b8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ /dist /build* -/transform/listFunctions.mjs +/transform/*.mjs /transform/tsconfig.tsbuildinfo /coverage diff --git a/assembly/env.ts b/assembly/env.ts index bae6bcf..f2a2e2a 100644 --- a/assembly/env.ts +++ b/assembly/env.ts @@ -8,6 +8,10 @@ export namespace assertResult { export declare function removeDescription(): void; + @external("__unittest_framework_env","registerTestFunction") + export declare function registerTestFunction(index: u32): void; + + @external("__unittest_framework_env","collectCheckResult") export declare function collectCheckResult( result: bool, diff --git a/assembly/implement.ts b/assembly/implement.ts index 92ab21f..9e21335 100644 --- a/assembly/implement.ts +++ b/assembly/implement.ts @@ -11,6 +11,7 @@ export function describeImpl( } export function testImpl(description: string, testFunction: () => void): void { assertResult.addDescription(description); + assertResult.registerTestFunction(testFunction.index); testFunction(); assertResult.removeDescription(); mockFunctionStatus.clear(); diff --git a/bin/cli.js b/bin/cli.js index 8134565..c529148 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -16,7 +16,8 @@ program .option("--temp ", "test template file folder") .option("--output ", "coverage report output folder") .option("--mode ", "test result output format") - .option("--coverageLimit [error warning...]", "set warn(yellow) and error(red) upper limit in coverage report"); + .option("--coverageLimit [error warning...]", "set warn(yellow) and error(red) upper limit in coverage report") + .option("--testNamePattern ", "run only tests with a name that matches the regex pattern"); program.parse(process.argv); const options = program.opts(); @@ -53,9 +54,11 @@ let outputFolder = options.output || config.output || "coverage"; let errorLimit = options.coverageLimit?.at(0); let warnLimit = options.coverageLimit?.at(1); +let testNamePattern = options.testNamePattern; + validatArgument(includes, excludes); start_unit_test( - { includes, excludes, testcases }, + { includes, excludes, testcases, testNamePattern }, { flags, imports }, { tempFolder, outputFolder, mode, warnLimit, errorLimit } ) diff --git a/src/core/executionRecorder.ts b/src/core/executionRecorder.ts index a55219e..89cc88c 100644 --- a/src/core/executionRecorder.ts +++ b/src/core/executionRecorder.ts @@ -5,6 +5,7 @@ export class ExecutionRecorder implements IAssertResult { total: number = 0; fail: number = 0; failed_info: AssertFailMessage = {}; + registerFunctions: [string, number][] = []; _currentTestDescriptions: string[] = []; _addDescription(description: string): void { @@ -13,6 +14,10 @@ export class ExecutionRecorder implements IAssertResult { _removeDescription(): void { this._currentTestDescriptions.pop(); } + registerTestFunction(fncIndex: number): void { + const testCaseFullName = this._currentTestDescriptions.join(" - "); + this.registerFunctions.push([testCaseFullName, fncIndex]); + } collectCheckResult(result: boolean, codeInfoIndex: number, actualValue: string, expectValue: string): void { this.total++; if (!result) { @@ -33,6 +38,9 @@ export class ExecutionRecorder implements IAssertResult { removeDescription: (): void => { this._removeDescription(); }, + registerTestFunction: (index: number): void => { + this.registerTestFunction(index); + }, collectCheckResult: (result: number, codeInfoIndex: number, actualValue: number, expectValue: number): void => { this.collectCheckResult( result !== 0, diff --git a/src/core/precompile.ts b/src/core/precompile.ts index 1fe290b..c4aea04 100644 --- a/src/core/precompile.ts +++ b/src/core/precompile.ts @@ -9,34 +9,73 @@ import { getIncludeFiles } from "../utils/pathResolver.js"; import { SourceFunctionInfo, UnittestPackage } from "../interface.js"; import { projectRoot } from "../utils/projectRoot.js"; -const sourceFunctions = new Map(); export async function precompile( includes: string[], excludes: string[], testcases: string[] | undefined, - flags: string, - transformFunction = join(projectRoot, "transform", "listFunctions.mjs") + testNamePattern: string | undefined, + flags: string ): Promise { // if specify testcases, use testcases for unittest // otherwise, get testcases(*.test.ts) in includes directory const testCodePaths = testcases ?? getRelatedFiles(includes, excludes, (path: string) => path.endsWith(".test.ts")); - const sourceCodePaths = getRelatedFiles(includes, excludes, (path: string) => !path.endsWith(".test.ts")); + const matchedTestNames: string[] = []; + if (testNamePattern) { + const testNameInfos = new Map(); + const testNameTransformFunction = join(projectRoot, "transform", "listTestNames.mjs"); + for (const testCodePath of testCodePaths) { + await transform(testNameTransformFunction, testCodePath, flags, () => { + testNameInfos.set(testCodePath, testNames); + }); + } + const regexPattern = new RegExp(testNamePattern); + for (const testNames of testNameInfos.values()) { + for (const testName of testNames) { + if (regexPattern.test(testName)) { + matchedTestNames.push(testName); + } + } + } + } + const sourceCodePaths = getRelatedFiles(includes, excludes, (path: string) => !path.endsWith(".test.ts")); + const sourceFunctions = new Map(); + const sourceTransformFunction = join(projectRoot, "transform", "listFunctions.mjs"); // The batchSize = 2 is empirical data after benchmarking const batchSize = 2; for (let i = 0; i < sourceCodePaths.length; i += batchSize) { await Promise.all( - sourceCodePaths.slice(i, i + batchSize).map((sourcePath) => transform(sourcePath, transformFunction, flags)) + sourceCodePaths.slice(i, i + batchSize).map((sourcePath) => + transform(sourceTransformFunction, sourcePath, flags, () => { + sourceFunctions.set(sourcePath, functionInfos); + }) + ) ); } return { testCodePaths, + matchedTestNames, sourceFunctions, }; } +async function transform(transformFunction: string, codePath: string, flags: string, collectCallback: () => void) { + let ascArgv = [codePath, "--noEmit", "--disableWarning", "--transform", transformFunction, "-O0"]; + if (flags) { + const argv = flags.split(" "); + ascArgv = ascArgv.concat(argv); + } + const { error, stderr } = await main(ascArgv); + if (error) { + // eslint-disable-next-line @typescript-eslint/no-base-to-string + console.error(stderr.toString()); + throw error; + } + collectCallback(); +} + // a. include in config // b. exclude in config export function getRelatedFiles(includes: string[], excludes: string[], filter: (path: string) => boolean) { @@ -58,18 +97,3 @@ export function getRelatedFiles(includes: string[], excludes: string[], filter: } return result; } - -async function transform(sourceCodePath: string, transformFunction: string, flags: string) { - let ascArgv = [sourceCodePath, "--noEmit", "--disableWarning", "--transform", transformFunction, "-O0"]; - if (flags) { - const argv = flags.split(" "); - ascArgv = ascArgv.concat(argv); - } - const { error, stderr } = await main(ascArgv); - if (error) { - // eslint-disable-next-line @typescript-eslint/no-base-to-string - console.error(stderr.toString()); - throw error; - } - sourceFunctions.set(sourceCodePath, functionInfos); -} diff --git a/src/index.ts b/src/index.ts index e6458cf..30e9bbf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -55,6 +55,7 @@ export interface FileOption { includes: string[]; excludes: string[]; testcases: string[] | undefined; + testNamePattern: string | undefined; } export interface TestOption { flags: string; @@ -75,7 +76,7 @@ export type OutputMode = "html" | "json" | "table"; export async function start_unit_test(fo: FileOption, to: TestOption, oo: OutputOption): Promise { emptydirSync(oo.outputFolder); emptydirSync(oo.tempFolder); - const unittestPackage = await precompile(fo.includes, fo.excludes, fo.testcases, to.flags); + const unittestPackage = await precompile(fo.includes, fo.excludes, fo.testcases, fo.testNamePattern, to.flags); console.log(chalk.blueBright("code analysis: ") + chalk.bold.greenBright("OK")); const wasmPaths = await compile(unittestPackage.testCodePaths, oo.tempFolder, to.flags); console.log(chalk.blueBright("compile testcases: ") + chalk.bold.greenBright("OK")); diff --git a/src/interface.ts b/src/interface.ts index 705a646..d9e5d43 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -150,6 +150,7 @@ export class CodeCoverage { export interface UnittestPackage { readonly testCodePaths: string[]; + readonly matchedTestNames: string[]; readonly sourceFunctions: Map; } @@ -158,5 +159,10 @@ export interface SourceFunctionInfo { range: [number, number]; } +export interface TestNameInfo { + testName: string; + testFilePath: string; +} + export const OrganizationName = "wasm-ecosystem"; export const Repository = "https://github.com/wasm-ecosystem/assemblyscript-unittest-framework"; diff --git a/src/type/global.d.ts b/src/type/global.d.ts index 36acbbd..c4caa07 100644 --- a/src/type/global.d.ts +++ b/src/type/global.d.ts @@ -3,6 +3,8 @@ import { SourceFunctionInfo } from "../interface.ts"; declare global { // store listFunctions transform results in global let functionInfos: SourceFunctionInfo[]; + // store listTestNames transform results in global + let testNames: string[]; } export {}; diff --git a/tests/ts/test/core/precompile.test.ts b/tests/ts/test/core/precompile.test.ts index bdf2e98..ac5e4e0 100644 --- a/tests/ts/test/core/precompile.test.ts +++ b/tests/ts/test/core/precompile.test.ts @@ -1,10 +1,7 @@ -import { join } from "node:path"; import { precompile } from "../../../../src/core/precompile.js"; -import { projectRoot } from "../../../../src/utils/projectRoot.js"; test("listFunction transform", async () => { - const transformFunction = join(projectRoot, "transform", "listFunctions.mjs"); - const unittestPackages = await precompile(["tests/ts/fixture/transformFunction.ts"], [], [], "", transformFunction); + const unittestPackages = await precompile(["tests/ts/fixture/transformFunction.ts"], [], undefined, undefined, ""); expect(unittestPackages.testCodePaths).toEqual([]); expect(unittestPackages.sourceFunctions).toMatchSnapshot(); }); diff --git a/transform/listTestNames.mts b/transform/listTestNames.mts new file mode 100644 index 0000000..5d95f03 --- /dev/null +++ b/transform/listTestNames.mts @@ -0,0 +1,529 @@ +import { Transform } from "assemblyscript/transform"; +import { + NodeKind, + SourceKind, + Node, + Source, + ParameterNode, + AssertionExpression, + BinaryExpression, + CallExpression, + ClassExpression, + CommaExpression, + ElementAccessExpression, + FunctionExpression, + InstanceOfExpression, + NewExpression, + ParenthesizedExpression, + PropertyAccessExpression, + TernaryExpression, + UnaryPostfixExpression, + UnaryPrefixExpression, + BlockStatement, + DoStatement, + ExpressionStatement, + ForStatement, + ForOfStatement, + IfStatement, + ReturnStatement, + SwitchStatement, + ThrowStatement, + TryStatement, + VariableStatement, + VoidStatement, + WhileStatement, + ClassDeclaration, + EnumDeclaration, + EnumValueDeclaration, + FieldDeclaration, + FunctionDeclaration, + InterfaceDeclaration, + MethodDeclaration, + NamespaceDeclaration, + VariableDeclaration, + SwitchCase, + Program, + IdentifierExpression, + LiteralExpression, + LiteralKind, + StringLiteralExpression, +} from "assemblyscript"; +import assert from "node:assert"; + +class SourceFunctionTransform extends Transform { + testNames: string[] = []; + currentTestDescriptions: string[] = []; + testFileName: string; + + afterInitialize(program: Program) { + // There will be two sources with SourceKind.UserEntry, ~lib/rt/index-incremental.ts should be filtered + const entrySource = program.sources.find( + (source) => source.sourceKind === SourceKind.UserEntry && !source.normalizedPath.startsWith("~lib/") + ); + this.testFileName = entrySource.normalizedPath; + this.visitNode(entrySource); + globalThis.testNames = this.testNames; + } + + visitNode(node: Node) { + // eslint-disable-next-line sonarjs/max-switch-cases + switch (node.kind) { + case NodeKind.Source: { + this.visitSource(node as Source); + break; + } + + // types + case NodeKind.NamedType: + case NodeKind.FunctionType: + case NodeKind.TypeName: + case NodeKind.TypeParameter: { + break; + } + case NodeKind.Parameter: { + this.visitParameterNode(node as ParameterNode); + break; + } + + // Expressions + case NodeKind.Identifier: + case NodeKind.False: + case NodeKind.Literal: + case NodeKind.Null: + case NodeKind.Omitted: + case NodeKind.Super: + case NodeKind.This: + case NodeKind.True: + case NodeKind.Constructor: + case NodeKind.Compiled: { + break; + } + case NodeKind.Assertion: { + this.visitAssertionExpression(node as AssertionExpression); + break; + } + case NodeKind.Binary: { + this.visitBinaryExpression(node as BinaryExpression); + break; + } + case NodeKind.Call: { + this.visitCallExpression(node as CallExpression); + break; + } + case NodeKind.Class: { + this.visitClassExpression(node as ClassExpression); + break; + } + case NodeKind.Comma: { + this.visitCommaExpression(node as CommaExpression); + break; + } + case NodeKind.ElementAccess: { + this.visitElementAccessExpression(node as ElementAccessExpression); + break; + } + case NodeKind.Function: { + this.visitFunctionExpression(node as FunctionExpression); + break; + } + case NodeKind.InstanceOf: { + this.visitInstanceOfExpression(node as InstanceOfExpression); + break; + } + case NodeKind.New: { + this.visitNewExpression(node as NewExpression); + break; + } + case NodeKind.Parenthesized: { + this.visitParenthesizedExpression(node as ParenthesizedExpression); + break; + } + case NodeKind.PropertyAccess: { + this.visitPropertyAccessExpression(node as PropertyAccessExpression); + break; + } + case NodeKind.Ternary: { + this.visitTernaryExpression(node as TernaryExpression); + break; + } + case NodeKind.UnaryPostfix: { + this.visitUnaryPostfixExpression(node as UnaryPostfixExpression); + break; + } + case NodeKind.UnaryPrefix: { + this.visitUnaryPrefixExpression(node as UnaryPrefixExpression); + break; + } + + // statements: + + case NodeKind.Break: + case NodeKind.Empty: + case NodeKind.Export: + case NodeKind.ExportDefault: + case NodeKind.ExportImport: + case NodeKind.Continue: + case NodeKind.Import: + case NodeKind.Module: { + break; + } + case NodeKind.Block: { + this.visitBlockStatement(node as BlockStatement); + break; + } + case NodeKind.Do: { + this.visitDoStatement(node as DoStatement); + break; + } + case NodeKind.Expression: { + this.visitExpressionStatement(node as ExpressionStatement); + break; + } + case NodeKind.For: { + this.visitForStatement(node as ForStatement); + break; + } + case NodeKind.ForOf: { + this.visitForOfStatement(node as ForOfStatement); + break; + } + case NodeKind.If: { + this.visitIfStatement(node as IfStatement); + break; + } + case NodeKind.Return: { + this.visitReturnStatement(node as ReturnStatement); + break; + } + case NodeKind.Switch: { + this.visitSwitchStatement(node as SwitchStatement); + break; + } + case NodeKind.Throw: { + this.visitThrowStatement(node as ThrowStatement); + break; + } + case NodeKind.Try: { + this.visitTryStatement(node as TryStatement); + break; + } + case NodeKind.Variable: { + this.visitVariableStatement(node as VariableStatement); + break; + } + case NodeKind.Void: { + this.visitVoidStatement(node as VoidStatement); + break; + } + case NodeKind.While: { + this.visitWhileStatement(node as WhileStatement); + break; + } + + // declaration statements + case NodeKind.ImportDeclaration: + case NodeKind.TypeDeclaration: { + break; + } + case NodeKind.ClassDeclaration: { + this.visitClassDeclaration(node as ClassDeclaration); + break; + } + case NodeKind.EnumDeclaration: { + this.visitEnumDeclaration(node as EnumDeclaration); + break; + } + case NodeKind.EnumValueDeclaration: { + this.visitEnumValueDeclaration(node as EnumValueDeclaration); + break; + } + case NodeKind.FieldDeclaration: { + this.visitFieldDeclaration(node as FieldDeclaration); + break; + } + case NodeKind.FunctionDeclaration: { + this.visitFunctionDeclaration(node as FunctionDeclaration); + break; + } + case NodeKind.InterfaceDeclaration: { + this.visitInterfaceDeclaration(node as InterfaceDeclaration); + break; + } + case NodeKind.MethodDeclaration: { + this.visitMethodDeclaration(node as MethodDeclaration); + break; + } + case NodeKind.NamespaceDeclaration: { + this.visitNamespaceDeclaration(node as NamespaceDeclaration); + break; + } + case NodeKind.VariableDeclaration: { + this.visitVariableDeclaration(node as VariableDeclaration); + break; + } + + // special + case NodeKind.ExportMember: + case NodeKind.IndexSignature: + case NodeKind.Comment: + case NodeKind.Decorator: { + break; + } + case NodeKind.SwitchCase: { + this.visitSwitchCase(node as SwitchCase); + break; + } + } + } + + visitSource(node: Source) { + for (const statement of node.statements) { + this.visitNode(statement); + } + } + + visitParameterNode(node: ParameterNode) { + if (node.initializer) { + this.visitNode(node.initializer); + } + } + + visitAssertionExpression(node: AssertionExpression) { + this.visitNode(node.expression); + } + + visitBinaryExpression(node: BinaryExpression) { + this.visitNode(node.left); + this.visitNode(node.right); + } + + visitCallExpression(node: CallExpression) { + if (node.expression.kind === NodeKind.Identifier) { + const fncName = (node.expression as IdentifierExpression).text; + if (fncName === "describe" || fncName === "test") { + assert(node.args.length === 2); + assert( + node.args[0].kind === NodeKind.Literal && + (node.args[0] as LiteralExpression).literalKind === LiteralKind.String + ); + const testName = (node.args[0] as StringLiteralExpression).value; + this.currentTestDescriptions.push(testName); + if (fncName === "test") { + this.testNames.push(this.currentTestDescriptions.join("")); + } + this.visitNode(node.expression); + for (const arg of node.args) { + this.visitNode(arg); + } + this.currentTestDescriptions.pop(); + } + } else { + this.visitNode(node.expression); + for (const arg of node.args) { + this.visitNode(arg); + } + } + } + + visitClassExpression(node: ClassExpression) { + this.visitClassDeclaration(node.declaration); + } + + visitCommaExpression(node: CommaExpression) { + for (const expr of node.expressions) { + this.visitNode(expr); + } + } + + visitElementAccessExpression(node: ElementAccessExpression) { + this.visitNode(node.expression); + this.visitNode(node.elementExpression); + } + + visitFunctionExpression(node: FunctionExpression) { + this.visitFunctionDeclaration(node.declaration); + } + + visitInstanceOfExpression(node: InstanceOfExpression) { + this.visitNode(node.expression); + } + + visitNewExpression(node: NewExpression) { + for (const arg of node.args) { + this.visitNode(arg); + } + } + + visitParenthesizedExpression(node: ParenthesizedExpression) { + this.visitNode(node.expression); + } + + visitPropertyAccessExpression(node: PropertyAccessExpression) { + this.visitNode(node.expression); + } + + visitTernaryExpression(node: TernaryExpression) { + this.visitNode(node.condition); + this.visitNode(node.ifThen); + this.visitNode(node.ifElse); + } + + visitUnaryPostfixExpression(node: UnaryPostfixExpression) { + this.visitNode(node.operand); + } + + visitUnaryPrefixExpression(node: UnaryPrefixExpression) { + this.visitNode(node.operand); + } + // eslint-disable-next-line sonarjs/no-identical-functions + visitBlockStatement(node: BlockStatement) { + for (const statement of node.statements) { + this.visitNode(statement); + } + } + + visitDoStatement(node: DoStatement) { + this.visitNode(node.body); + this.visitNode(node.condition); + } + + visitExpressionStatement(node: ExpressionStatement) { + this.visitNode(node.expression); + } + + visitForStatement(node: ForStatement) { + if (node.initializer) { + this.visitNode(node.initializer); + } + if (node.condition) { + this.visitNode(node.condition); + } + if (node.incrementor) { + this.visitNode(node.incrementor); + } + this.visitNode(node.body); + } + + visitForOfStatement(node: ForOfStatement) { + this.visitNode(node.variable); + this.visitNode(node.iterable); + this.visitNode(node.body); + } + + visitIfStatement(node: IfStatement) { + this.visitNode(node.condition); + this.visitNode(node.ifTrue); + if (node.ifFalse) { + this.visitNode(node.ifFalse); + } + } + + visitReturnStatement(node: ReturnStatement) { + if (node.value) { + this.visitNode(node.value); + } + } + + visitSwitchStatement(node: SwitchStatement) { + this.visitNode(node.condition); + for (const switchCase of node.cases) { + this.visitSwitchCase(switchCase); + } + } + + visitThrowStatement(node: ThrowStatement) { + this.visitNode(node.value); + } + + visitTryStatement(node: TryStatement) { + for (const stat of node.bodyStatements) { + this.visitNode(stat); + } + if (node.catchStatements) { + for (const stat of node.catchStatements) { + this.visitNode(stat); + } + } + if (node.finallyStatements) { + for (const stat of node.finallyStatements) { + this.visitNode(stat); + } + } + } + + visitVariableStatement(node: VariableStatement) { + for (const declaration of node.declarations) { + this.visitVariableDeclaration(declaration); + } + } + + visitVoidStatement(node: VoidStatement) { + this.visitNode(node.expression); + } + + visitWhileStatement(node: WhileStatement) { + this.visitNode(node.condition); + this.visitNode(node.body); + } + + visitClassDeclaration(node: ClassDeclaration) { + for (const member of node.members) { + this.visitNode(member); + } + } + + visitEnumDeclaration(node: EnumDeclaration) { + for (const value of node.values) { + this.visitEnumValueDeclaration(value); + } + } + // eslint-disable-next-line sonarjs/no-identical-functions + visitEnumValueDeclaration(node: EnumValueDeclaration) { + if (node.initializer) { + this.visitNode(node.initializer); + } + } + // eslint-disable-next-line sonarjs/no-identical-functions + visitFieldDeclaration(node: FieldDeclaration) { + if (node.initializer) { + this.visitNode(node.initializer); + } + } + + visitFunctionDeclaration(node: FunctionDeclaration) { + if (node.body) { + this.visitNode(node.body); + } + } + + visitInterfaceDeclaration(node: InterfaceDeclaration) { + this.visitClassDeclaration(node); + } + + visitMethodDeclaration(node: MethodDeclaration) { + this.visitFunctionDeclaration(node); + } + // eslint-disable-next-line sonarjs/no-identical-functions + visitNamespaceDeclaration(node: NamespaceDeclaration) { + for (const member of node.members) { + this.visitNode(member); + } + } + // eslint-disable-next-line sonarjs/no-identical-functions + visitVariableDeclaration(node: VariableDeclaration) { + if (node.initializer) { + this.visitNode(node.initializer); + } + } + + visitSwitchCase(node: SwitchCase) { + if (node.label) { + this.visitNode(node.label); + } + for (const stat of node.statements) { + this.visitNode(stat); + } + } +} + +export default SourceFunctionTransform; diff --git a/transform/tsconfig.json b/transform/tsconfig.json index 6bce599..1bfcd67 100644 --- a/transform/tsconfig.json +++ b/transform/tsconfig.json @@ -10,6 +10,6 @@ "assemblyscript/transform": ["../node_modules/assemblyscript/dist/transform"] } }, - "files": ["listFunctions.mts"], + "files": ["listFunctions.mts", "listTestNames.mts"], "exclude": [] }