From 69c42048c2ee429e3ce117b12cadd51e386111b1 Mon Sep 17 00:00:00 2001 From: ReubenFrankel <60552974+ReubenFrankel@users.noreply.github.com> Date: Wed, 27 Dec 2023 06:51:08 +0000 Subject: [PATCH] feat: Filter configurations available for tests on launch by pattern (#1638) --------- Co-authored-by: sheche --- package.json | 10 ++ package.nls.json | 1 + package.nls.zh.json | 2 + src/controller/testController.ts | 2 +- src/runConfigs.ts | 6 ++ src/utils/configUtils.ts | 180 ++++++++++++++++++++++++++++++- test/suite/configUtils.test.ts | 101 +++++++++++++++++ 7 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 test/suite/configUtils.test.ts diff --git a/package.json b/package.json index 5e148643..6ca7dadf 100644 --- a/package.json +++ b/package.json @@ -320,6 +320,11 @@ "default": [] } } + }, + "when": { + "type": "string", + "markdownDescription": "%configuration.java.test.config.when.description%", + "default": "" } }, "description": "%configuration.java.test.config.description%", @@ -458,6 +463,11 @@ "default": [] } } + }, + "when": { + "type": "string", + "markdownDescription": "%configuration.java.test.config.when.description%", + "default": "" } } }, diff --git a/package.nls.json b/package.nls.json index 1ff7dec7..c2bd8c25 100644 --- a/package.nls.json +++ b/package.nls.json @@ -31,6 +31,7 @@ "configuration.java.test.config.testKind.description": "Specify the targeting test framework for this test configuration. Supported values are `junit`, `testng`.", "configuration.java.test.config.filters.description": "Specify the test filters.", "configuration.java.test.config.filters.tags.description": "Specify the tags to be included or excluded. \n\nTags having `!` as the prefix will be **excluded**. \n\nNote: This setting **only** takes effect when `testKind` is set to `junit`.", + "configuration.java.test.config.when.description": "Specify the when clause for matching tests by to determine if the configuration should be run with.\n\nNote: `testItem =~ //` is the only supported clause currently, where `testItem` is the fully-qualified name of a test class or method. For example:\n- `testItem =~ /^com\\.company\\.package\\.test/` - a package with the name \"com.company.package.test\".\n- `testItem =~ /(?<=\\.)Test/` - a class with a name containing \"Test\".\n\nWhen launching a test that satisfies a single configuration's when clause, it will be run with that configuration. If multiple configurations are satisfied, the user will be prompted to pick which configuration to run with.\n\nWhen launching multiple tests (e.g. for a class or package), a configuration's when clause must be satisfied for **all** tests to be considered.\n\nConfigurations that do not define a when clause will match all tests.", "configuration.java.test.config.javaExec.description": "The path to java executable to use. For example: `C:\\Program Files\\jdk\\bin\\java.exe`. If unset project JDK's java executable is used.", "contributes.viewsWelcome.inLightWeightMode": "No test cases are listed because the Java Language Server is currently running in [LightWeight Mode](https://aka.ms/vscode-java-lightweight). To show test cases, click on the button to switch to Standard Mode.\n[Switch to Standard Mode](command:java.server.mode.switch?%5B%22Standard%22,true%5D)", "contributes.viewsWelcome.enableTests": "Click below button to configure a test framework for your project.\n[Enable Java Tests](command:_java.test.enableTests)" diff --git a/package.nls.zh.json b/package.nls.zh.json index bfacb4db..2f5c153b 100644 --- a/package.nls.zh.json +++ b/package.nls.zh.json @@ -31,6 +31,8 @@ "configuration.java.test.config.testKind.description": "指定该测试配置项的目标测试框架。可选的值有 `junit`,`testng`。", "configuration.java.test.config.filters.description": "测试过滤配置项。", "configuration.java.test.config.filters.tags.description": "指定要包含或排除的标记。\n\n带有`!`前缀的标记将会被**排除**。 \n\n注意:该选项**仅**会在 `testKind` 设置为 `junit` 时生效。", + "configuration.java.test.config.when.description": "指定测试配置项的启用条件。\n\n注:目前仅支持`testItem =~ /<正则表达式>/`,其中,`testItem` 是测试类或方法的完全限定名。例如:\n- `testItem =~ /^com\\.company\\.package\\.test/` - 匹配包名包含`com.company.package.test`的测试。\n- `testItem =~ /(?<=\\.)Test/` - 匹配类名包含`Test`的测试。\n\n如果在执行测试时,仅有一个配置满足匹配条件,该配置将会被启用。如果多个配置项均满足,用户需要选择其中一个。\n\n当一次执行多个测试用例时(例如执行整个测试类或者包),那么配置项的匹配条件必须满足**全部**测试项才会视为匹配通过。\n\n该选项为空时,则视为匹配全部测试项。", + "configuration.java.test.config.javaExec.description": "指定 Java 可执行文件。例如:`C:\\Program Files\\jdk\\bin\\java.exe`。未指定时将使用项目 JDK 执行测试。", "contributes.viewsWelcome.inLightWeightMode": "由于 Java 语言服务正运行在 [LightWeight 模式](https://aka.ms/vscode-java-lightweight)下,因此测试用例将不会展示在该视图中。如果您需要展示测试用例,可以点击下方按钮将 Java 语言服务切换至 Standard 模式。\n[切换至 Standard 模式](command:java.server.mode.switch?%5B%22Standard%22,true%5D)", "contributes.viewsWelcome.enableTests": "点击下方按钮为你的项目添加一个测试框架\n[启用 Java 测试](command:_java.test.enableTests)" } diff --git a/src/controller/testController.ts b/src/controller/testController.ts index 492f64cf..8c12ae69 100644 --- a/src/controller/testController.ts +++ b/src/controller/testController.ts @@ -203,7 +203,7 @@ export const runTests: (request: TestRunRequest, option: IRunOption) => any = in window.showErrorMessage(`Failed to get workspace folder from test item: ${items[0].label}.`); continue; } - const config: IExecutionConfig | undefined = await loadRunConfig(workspaceFolder); + const config: IExecutionConfig | undefined = await loadRunConfig(items, workspaceFolder); if (!config) { continue; } diff --git a/src/runConfigs.ts b/src/runConfigs.ts index a63be37a..62e7820c 100644 --- a/src/runConfigs.ts +++ b/src/runConfigs.ts @@ -102,6 +102,12 @@ export interface IExecutionConfig { */ tags?: string[] } + + /** + * The when clause for matching tests by to determine if the configuration should be run with. + * @since 0.41.0 + */ + when?: string } export function getBuiltinConfig(): IExecutionConfig { diff --git a/src/utils/configUtils.ts b/src/utils/configUtils.ts index 545be82d..7f9f6f70 100644 --- a/src/utils/configUtils.ts +++ b/src/utils/configUtils.ts @@ -3,13 +3,14 @@ import * as crypto from 'crypto'; import * as _ from 'lodash'; -import { ConfigurationTarget, QuickPickItem, Uri, window, workspace, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; +import { ConfigurationTarget, QuickPickItem, TestItem, Uri, window, workspace, WorkspaceConfiguration, WorkspaceFolder } from 'vscode'; import { sendInfo } from 'vscode-extension-telemetry-wrapper'; import { Configurations, Dialog } from '../constants'; +import { dataCache } from '../controller/testItemDataCache'; import { extensionContext } from '../extension'; import { getBuiltinConfig, IExecutionConfig } from '../runConfigs'; -export async function loadRunConfig(workspaceFolder: WorkspaceFolder): Promise { +export async function loadRunConfig(testItems: TestItem[], workspaceFolder: WorkspaceFolder): Promise { const configSetting: IExecutionConfig[] | IExecutionConfig = workspace.getConfiguration(undefined, workspaceFolder.uri).get(Configurations.CONFIG_SETTING_KEY, {}); const configItems: IExecutionConfig[] = []; if (!_.isEmpty(configSetting)) { @@ -40,7 +41,37 @@ export async function loadRunConfig(workspaceFolder: WorkspaceFolder): Promise { + const whenClause: string | undefined = config.when?.trim(); + + if (whenClause) { + const context: WhenClauseEvaluationContext = new WhenClauseEvaluationContext(whenClause); + + try { + return checkTestItems(testItems, context); + } catch (e) { + // do something with the error + } + } + + return true; + + }); +} + +function checkTestItems(testItems: TestItem[], context: WhenClauseEvaluationContext): boolean { + return testItems.every((testItem: TestItem) => { + const fullName: string | undefined = dataCache.get(testItem)?.fullName; + + context.addContextKey('testItem', fullName); + return context.evaluate(); + }); } async function selectQuickPick(configs: IExecutionConfig[], workspaceFolder: WorkspaceFolder): Promise { @@ -103,3 +134,146 @@ async function askPreferenceForConfig(configs: IExecutionConfig[], selectedConfi export function randomSequence(): string { return crypto.randomBytes(3).toString('hex'); } + +type ApplyOperator = (value1: unknown, value2?: unknown) => boolean + +interface Token { + stringValue: string + getValue: () => unknown +} + +export class WhenClauseEvaluationContext { + + private static readonly OPERATORS: Record = { + // logical + '!': (value: unknown) => !value, + '&&': (value1: unknown, value2: unknown) => !!(value1 && value2), + '||': (value1: unknown, value2: unknown) => !!(value1 || value2), + + // equality + '==': (value1: unknown, value2: unknown) => value1 === value2, + '===': (value1: unknown, value2: unknown) => value1 === value2, + '!=': (value1: unknown, value2: unknown) => value1 !== value2, + '!==': (value1: unknown, value2: unknown) => value1 !== value2, + + // comparison + '>': (value1: number, value2: number) => value1 > value2, + '>=': (value1: number, value2: number) => value1 >= value2, + '<': (value1: number, value2: number) => value1 < value2, + '<=': (value1: number, value2: number) => value1 <= value2, + + // match + '=~': (value: string, pattern: RegExp) => pattern.test(value), + } + + private readonly context: Record = {}; + + public constructor(readonly clause: string) {} + + private tokenize(): Token[] { + const operatorKeys: string[] = Object.keys(WhenClauseEvaluationContext.OPERATORS).sort((a: string, b: string) => b.length - a.length); + const operatorPattern: RegExp = new RegExp(`(${operatorKeys.map(_.escapeRegExp).join('|')})`); + + const tokens: string[] = this.clause.split(operatorPattern) + .flatMap((token: string) => token.trim().split(/([()])/)) + .filter(Boolean); + + return tokens.map((token: string) => ({ + stringValue: token, + getValue: () => this.parse(token), + })); + } + + private parse(token: string) { + const quotedStringMatch: RegExpMatchArray | null = token.match(/['"](.*)['"]/); + if (quotedStringMatch) + return quotedStringMatch[1]; + + const regexMatch: RegExpMatchArray | null = token.match(/\/(?.*)\/(?[ismu]*)/) + if (regexMatch?.groups) { + const { pattern, flags } = regexMatch.groups; + return new RegExp(pattern, flags); + } + + const number: number = Number(token); + if (!isNaN(number)) + return number; + + const booleanMatch: RegExpMatchArray | null = token.match(/^(?:true|false)$/); + if (booleanMatch) + return booleanMatch[0] === 'true'; + + if (token === typeof undefined) + return; + + if (!(token in this.context)) + throw new SyntaxError(`Context key not found in evaluation context: ${token}`); + + return this.context[token]; + } + + private evaluateTokens(tokens: Token[], start?: number, end?: number) { + start ||= 0; + end ||= tokens.length; + + const currentTokens: Token[] = tokens.slice(start, end); + + while (currentTokens.length > 1) { + const stringTokens: string[] = currentTokens.map((token: Token) => token.stringValue); + + const parenthesesStart: number = stringTokens.lastIndexOf('('); + const parenthesesEnd: number = (() => { + const relativeEnd: number = stringTokens.slice(parenthesesStart).indexOf(')'); + return relativeEnd >= 0 ? parenthesesStart + relativeEnd : -1; + })(); + + if (parenthesesEnd < parenthesesStart) + throw new SyntaxError('Mismatched parentheses in expression'); + + if (parenthesesEnd > parenthesesStart) { + const resultToken: Token = this.evaluateTokens(currentTokens, parenthesesStart + 1, parenthesesEnd); + currentTokens.splice(parenthesesStart, parenthesesEnd - parenthesesStart + 1, resultToken); + + continue; + } + + for (const [operator, applyOperator] of Object.entries(WhenClauseEvaluationContext.OPERATORS)) { + const operatorIndex: number = currentTokens.findIndex((token: Token) => token.stringValue === operator); + + if (operatorIndex === -1) + continue; + + const leftOperand: Token = currentTokens[operatorIndex - 1]; + const rightOperand: Token = currentTokens[operatorIndex + 1]; + + const value: boolean = applyOperator.length === 1 + ? applyOperator(rightOperand.getValue()) + : applyOperator(leftOperand.getValue(), rightOperand.getValue()); + + const operationStart: number = operatorIndex - (applyOperator.length - 1); + const operationLength: number = applyOperator.length + 1; + + currentTokens.splice(operationStart, operationLength, { + stringValue: value.toString(), + getValue: () => value, + }); + + break; + } + } + + return currentTokens[0]; + } + + addContextKey(key: string, value: unknown): void { + this.context[key] = value; + } + + evaluate(): boolean { + const tokens: Token[] = this.tokenize(); + const result: Token = this.evaluateTokens(tokens); + + return !!result.getValue(); + } + +} diff --git a/test/suite/configUtils.test.ts b/test/suite/configUtils.test.ts new file mode 100644 index 00000000..c3cca4a4 --- /dev/null +++ b/test/suite/configUtils.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. + +'use strict'; + +import * as assert from 'assert'; +import { WhenClauseEvaluationContext } from '../../src/utils/configUtils'; + +suite('ConfigUtils Tests', () => { + + [ + { clause: 'true', expectedResult: true }, + { clause: 'false', expectedResult: false }, + { clause: '!false', expectedResult: true }, + { clause: '!true', expectedResult: false }, + { clause: 'false && false', expectedResult: false }, + { clause: 'false && true', expectedResult: false }, + { clause: 'true && false', expectedResult: false }, + { clause: 'true && true', expectedResult: true }, + { clause: 'false || false', expectedResult: false }, + { clause: 'false || true', expectedResult: true }, + { clause: 'true || false', expectedResult: true }, + { clause: 'true || true', expectedResult: true }, + { clause: 'false == false', expectedResult: true }, + { clause: 'false == true', expectedResult: false }, + { clause: 'true == false', expectedResult: false }, + { clause: 'true == true', expectedResult: true }, + { clause: 'false === false', expectedResult: true }, + { clause: 'false === true', expectedResult: false }, + { clause: 'true === false', expectedResult: false }, + { clause: 'true === true', expectedResult: true }, + { clause: 'false != false', expectedResult: false }, + { clause: 'false != true', expectedResult: true }, + { clause: 'true != false', expectedResult: true }, + { clause: 'true != true', expectedResult: false }, + { clause: 'false !== false', expectedResult: false }, + { clause: 'false !== true', expectedResult: true }, + { clause: 'true !== false', expectedResult: true }, + { clause: 'true !== true', expectedResult: false }, + { clause: '0 > 0', expectedResult: false }, + { clause: '0 > 1', expectedResult: false }, + { clause: '1 > 0', expectedResult: true }, + { clause: '1 > 1', expectedResult: false }, + { clause: '0 >= 0', expectedResult: true }, + { clause: '0 >= 1', expectedResult: false }, + { clause: '1 >= 0', expectedResult: true }, + { clause: '1 >= 1', expectedResult: true }, + { clause: '0 < 0', expectedResult: false }, + { clause: '0 < 1', expectedResult: true }, + { clause: '1 < 0', expectedResult: false }, + { clause: '1 < 1', expectedResult: false }, + { clause: '0 <= 0', expectedResult: true }, + { clause: '0 <= 1', expectedResult: true }, + { clause: '1 <= 0', expectedResult: false }, + { clause: '1 <= 1', expectedResult: true }, + { clause: '"foo" =~ /foo/', expectedResult: true }, + { clause: '\'foo\' =~ /foo/', expectedResult: true }, + { clause: '"foo" =~ /bar/', expectedResult: false }, + { clause: '"foo" =~ /^foo$/', expectedResult: true }, + { clause: '"foo" =~ /\\w+/', expectedResult: true }, + { clause: '"foo" =~ //', expectedResult: true }, + { clause: '"foo" =~ /FOO/', expectedResult: false }, + { clause: '"foo" =~ /FOO/i', expectedResult: true }, + { clause: 'false && true && true || !false', expectedResult: true }, + { clause: 'false && true && (true || !false)', expectedResult: false }, + { clause: '(false && true) && (true || !false)', expectedResult: false }, + { clause: 'false && (true && (true || !false))', expectedResult: false }, + ].forEach(({ clause, expectedResult }) => test(`Evaluate when clause - basic: ${clause}`, () => { + const context = new WhenClauseEvaluationContext(clause); + const result = context.evaluate(); + + assert.equal(result, expectedResult); + })); + + [ + { clause: 'test == undefined', expectedResult: false }, + { clause: 'test == "foo"', expectedResult: true }, + { clause: 'test == "bar"', expectedResult: false }, + { clause: 'test != "bar"', expectedResult: true }, + { clause: 'test =~ /foo/', expectedResult: true }, + { clause: 'test =~ /bar/', expectedResult: false }, + ].forEach(({ clause, expectedResult }) => test(`Evaluate when clause - context key: ${clause}`, () => { + const context = new WhenClauseEvaluationContext(clause); + context.addContextKey('test', 'foo'); + + const result = context.evaluate(); + + assert.equal(result, expectedResult); + })); + + [ + 'truefalse', + 'falsetrue', + 'test', + 'test == true', + ].forEach(clause => test(`Evaluate when clause - missing context key: ${clause}`, () => { + const context = new WhenClauseEvaluationContext(clause); + assert.throws(() => context.evaluate(), SyntaxError); + })); + +});