Skip to content

Commit

Permalink
Merge branch 'main' into cs/1es
Browse files Browse the repository at this point in the history
  • Loading branch information
jdneo authored Jan 2, 2024
2 parents c9ad144 + 69c4204 commit f5842aa
Show file tree
Hide file tree
Showing 7 changed files with 298 additions and 4 deletions.
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,11 @@
"default": []
}
}
},
"when": {
"type": "string",
"markdownDescription": "%configuration.java.test.config.when.description%",
"default": ""
}
},
"description": "%configuration.java.test.config.description%",
Expand Down Expand Up @@ -458,6 +463,11 @@
"default": []
}
}
},
"when": {
"type": "string",
"markdownDescription": "%configuration.java.test.config.when.description%",
"default": ""
}
}
},
Expand Down
1 change: 1 addition & 0 deletions package.nls.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 =~ /<regular-expression>/` 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)"
Expand Down
2 changes: 2 additions & 0 deletions package.nls.zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
2 changes: 1 addition & 1 deletion src/controller/testController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions src/runConfigs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
180 changes: 177 additions & 3 deletions src/utils/configUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<IExecutionConfig | undefined> {
export async function loadRunConfig(testItems: TestItem[], workspaceFolder: WorkspaceFolder): Promise<IExecutionConfig | undefined> {
const configSetting: IExecutionConfig[] | IExecutionConfig = workspace.getConfiguration(undefined, workspaceFolder.uri).get<IExecutionConfig[] | IExecutionConfig>(Configurations.CONFIG_SETTING_KEY, {});
const configItems: IExecutionConfig[] = [];
if (!_.isEmpty(configSetting)) {
Expand Down Expand Up @@ -40,7 +41,37 @@ export async function loadRunConfig(workspaceFolder: WorkspaceFolder): Promise<I
return defaultConfigs[0];
}
}
return await selectQuickPick(configItems, workspaceFolder);

const candidateConfigItems: IExecutionConfig[] = filterCandidateConfigItems(configItems, testItems);
return await selectQuickPick(candidateConfigItems, workspaceFolder);
}

function filterCandidateConfigItems(configItems: IExecutionConfig[], testItems: TestItem[]): IExecutionConfig[] {
return configItems.filter((config: IExecutionConfig) => {
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<IExecutionConfig | undefined> {
Expand Down Expand Up @@ -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<string, ApplyOperator> = {
// 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<string, unknown> = {};

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(/\/(?<pattern>.*)\/(?<flags>[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();
}

}
101 changes: 101 additions & 0 deletions test/suite/configUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}));

});

0 comments on commit f5842aa

Please sign in to comment.