Gradle: Basic Kotlin NodeJS tests runner

#KT-30531 Fixed
#KT-30528 Fixed
This commit is contained in:
Sergey Rostov
2019-02-20 15:14:05 +03:00
parent b6e675f934
commit d467e4209a
54 changed files with 3968 additions and 64 deletions
@@ -0,0 +1,45 @@
# test directories
__tests__
test
tests
powered-test
# asset directories
docs
doc
website
images
assets
# examples
example
examples
# code coverage directories
coverage
.nyc_output
# build scripts
Makefile
Gulpfile.js
Gruntfile.js
# configs
appveyor.yml
circle.yml
codeship-services.yml
codeship-steps.yml
wercker.yml
.tern-project
.gitattributes
.editorconfig
.*ignore
.eslintrc
.jshintrc
.flowconfig
.documentup.json
.yarn-metadata.json
.travis.yml
# misc
*.md
@@ -0,0 +1,6 @@
Kotlin/JS TeamCity tests results reporter.
### Usage
`yarn add @kotlin/js-tests-teamcity --dev`
`yarn run kotlin-js-tests`
@@ -0,0 +1,49 @@
import com.moowork.gradle.node.yarn.YarnTask
plugins {
id("base")
id("com.moowork.node") version "1.2.0"
}
node {
version = "11.9.0"
download = true
nodeModulesDir = projectDir
}
tasks {
"yarn" {
outputs.upToDateWhen {
projectDir.resolve("node_modules").isDirectory
}
}
create<YarnTask>("yarnBuild") {
group = "build"
dependsOn("yarn")
setWorkingDir(projectDir)
args = listOf("build")
inputs.dir(projectDir.resolve("src"))
outputs.file(projectDir.resolve("lib/kotlin-js-test.js"))
}
create<Delete>("cleanYarn") {
group = "build"
delete = setOf(
projectDir.resolve("node_modules"),
projectDir.resolve("lib"),
projectDir.resolve(".rpt2_cache")
)
}
getByName("clean").dependsOn("cleanYarn")
}
artifacts {
add("archives", projectDir.resolve("lib/kotlin-js-test.js")) {
builtBy("yarnBuild")
}
}
+65
View File
@@ -0,0 +1,65 @@
import {TeamCityMessagesFlow} from "./src/TeamCityMessagesFlow";
import {directRunner, KotlinTestRunner} from "./src/KotlinTestRunner";
import {IgnoredTestSuitesReporting, runWithTeamCityReporter} from "./src/KotlinTestTeamCityReporter";
import {CliArgsParser} from "./src/CliArgsParser";
import {configureFiltering} from "./src/CliFiltertingConfiguration";
import {hrTimer} from "./src/Timer";
const kotlin_test = require('kotlin-test');
const parser = new CliArgsParser({
version: VERSION,
bin: BIN,
description: DESCRIPTION,
usage: "[-t --tests] [-e --exclude] <module_name1>, <module_name2>, ..",
args: {
include: {
keys: ['--tests', '--include'],
help: "Tests to include. Example: MySuite.test1,MySuite.MySubSuite.*,*unix*,!*windows*",
default: "*"
},
exclude: {
keys: ['--exclude'],
help: "Tests to exclude. Example: MySuite.test1,MySuite.MySubSuite.*,*unix*"
},
ignoredTestSuites: {
keys: ['--ignoredTestSuites'],
help: "How to deal with ignored test suites",
single: true,
values: [
IgnoredTestSuitesReporting.skip,
IgnoredTestSuitesReporting.reportAsIgnoredTest,
IgnoredTestSuitesReporting.reportAllInnerTestsAsIgnored
],
valuesHelp: [
"don't report ignored test suites",
"useful to speedup large ignored test suites",
"will cause visiting all inner tests",
],
default: IgnoredTestSuitesReporting.reportAllInnerTestsAsIgnored
}
},
freeArgsTitle: "module_name"
});
const processArgs = process.argv.slice(2);
const untypedArgs = parser.parse(processArgs);
const args = {
onIgnoredTestSuites: (untypedArgs.ignoredTestSuites
|| IgnoredTestSuitesReporting.reportAllInnerTestsAsIgnored) as IgnoredTestSuitesReporting,
include: untypedArgs.include as string[],
exclude: untypedArgs.exclude as string[],
};
const teamCity = new TeamCityMessagesFlow(null, (payload) => console.log(payload));
let runner: KotlinTestRunner = directRunner;
runner = runWithTeamCityReporter(runner, args.onIgnoredTestSuites, teamCity, hrTimer);
runner = configureFiltering(runner, args.include, args.exclude);
kotlin_test.setAdapter(runner);
untypedArgs.free.forEach((arg: string) => {
require(arg);
});
@@ -0,0 +1,4 @@
declare const DEBUG: boolean;
declare const VERSION: string;
declare const BIN: string;
declare const DESCRIPTION: string;
@@ -0,0 +1,30 @@
{
"name": "@kotlin/js-tests-teamcity",
"version": "0.0.1",
"description": "Simple Kotlin/JS tests runner with TeamCity reporter",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"bin": {
"kotlin-js-tests": "lib/kotlin-js-test.js"
},
"files": [
"lib/**/*"
],
"scripts": {
"build": "rimraf lib/* && rollup -c rollup.config.js"
},
"dependencies": {
"@types/node": "^10.12.21"
},
"devDependencies": {
"copyfiles": "^2.1.0",
"rimraf": "^2.6.3",
"rollup": "^1.1.2",
"rollup-plugin-commonjs": "^9.2.0",
"rollup-plugin-node-resolve": "^4.0.0",
"rollup-plugin-sourcemaps": "^0.4.2",
"rollup-plugin-typescript2": "^0.19.2",
"rollup-plugin-uglify": "^6.0.2",
"typescript": "^3.3.1"
}
}
@@ -0,0 +1,59 @@
import typescript from 'rollup-plugin-typescript2';
import {uglify} from "rollup-plugin-uglify";
import nodeResolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
const pckg = require('./package.json');
export default [
{
input: './cli.ts',
output: {
file: 'lib/kotlin-js-test.js',
format: 'cjs',
banner: '#!/usr/bin/env node'
},
plugins: [
nodeResolve({
jsnext: true,
main: true
}),
commonjs(),
typescript({
tsconfig: "tsconfig.json"
}),
uglify({
sourcemap: true,
compress: {
// hoist_funs: true,
// hoist_vars: true,
toplevel: true,
unsafe: true,
dead_code: true,
global_defs: {
DEBUG: false,
VERSION: pckg.version,
BIN: Object.keys(pckg.bin)[0],
DESCRIPTION: pckg.description
}
},
mangle: {
properties: {
keep_quoted: true,
reserved: [
"argv", "hrtime",
"kotlin_test", "kotlin", "setAdapter", "setAssertHook_4duqou$",
"suite", "test",
"stack"
]
},
toplevel: true,
},
// output: {
// beautify: true
// }
}),
// sourceMaps()
]
}
]
@@ -0,0 +1,115 @@
import {println, startsWith} from "./utils";
export type CliDescription = {
version: string,
bin: string,
description: string,
usage: string,
args: {
[k: string]: CliArgDescription,
},
freeArgsTitle: string
}
export type CliArgValues = {
[k: string]: string[] | string,
free: string[]
}
export type CliArgDescription = {
keys: string[],
help: string,
values?: string[],
valuesHelp?: string[],
default?: string,
single?: true
}
export class CliArgsParser {
constructor(private description: CliDescription) {
}
printUsage() {
const description = this.description;
println(`${description.bin} v${description.version} - ${description.description}`);
println();
println(`Usage: ${description.bin} ${description.usage}`);
println();
for (let key in description.args) {
const data = description.args[key];
println(' ' + data.keys.join(', '));
const indent = ' ';
println(`${indent}${data.help}`);
if (data.values && data.valuesHelp) {
println(`${indent}Possible values:`);
for (let i = 0; i < data.values.length; i++) {
const value = data.values[i];
const help = data.valuesHelp[i];
println(`${indent} - "${value}": ${help}`)
}
}
if (data.default) println(`${indent}By default: ${data.default}`);
println('')
}
}
badArgsExit(message: string) {
println(message);
println();
this.printUsage();
process.exit(1)
}
parse(args: string[]): CliArgValues {
const description = this.description;
const result: CliArgValues = {
free: []
};
for (let key in description.args) {
if (!description.args[key].single) {
result[key] = [];
}
}
// process all arguments from left to right
args: while (args.length != 0) {
const arg = args.shift() as string;
if (startsWith(arg, '--')) {
for (let argName in description.args) {
const argDescription = description.args[argName];
if (argDescription.keys.indexOf(arg) != -1) {
if (args.length == 0) {
this.badArgsExit("Missed value after option " + arg);
}
const value = args.shift() as string;
if (argDescription.values && argDescription.values.indexOf(value) == -1) {
this.badArgsExit("Unsupported value for option " + arg);
}
if (argDescription.single) {
result[argName] = value;
} else {
(result[argName] as string[]).push(value);
}
continue args;
}
}
this.badArgsExit("Unknown option: " + arg);
} else {
result.free.push(arg)
}
}
if (result.free.length == 0) {
this.badArgsExit(`At least one ${description.freeArgsTitle} should be provided`)
}
return result
}
}
@@ -0,0 +1,52 @@
import {KotlinTestRunner} from "./KotlinTestRunner";
import {
allTest,
CompositeTestFilter,
KotlinTestsFilter,
newKotlinTestsFilter,
runWithFilter
} from "./KotlinTestsFilter";
import {flatMap, println, pushIfNotNull} from "./utils";
export function configureFiltering(
runner: KotlinTestRunner,
includeWildcards: string[],
excludeWildcards: string[]
): KotlinTestRunner {
const include: KotlinTestsFilter[] = [];
const exclude: KotlinTestsFilter[] = [];
function collectWildcards(
value: string[],
positive: KotlinTestsFilter[],
negative: KotlinTestsFilter[]
) {
flatMap(value, (t: string) => t.split(','))
.map(t => {
if (t.length && t[0] == '!') {
pushIfNotNull(negative, newKotlinTestsFilter(t.substring(1)))
} else {
pushIfNotNull(positive, newKotlinTestsFilter(t))
}
})
}
collectWildcards(includeWildcards, include, exclude);
collectWildcards(excludeWildcards, exclude, include);
if (include.length == 0 && exclude.length == 0) {
return runner
} else {
if (include.length == 0) {
include.push(allTest)
}
const filter = new CompositeTestFilter(include, exclude);
if (DEBUG) {
println(filter.toString());
}
return runWithFilter(runner, filter)
}
}
@@ -0,0 +1,14 @@
export interface KotlinTestRunner {
suite(name: string, isIgnored: boolean, fn: () => void): void
test(name: string, isIgnored: boolean, fn: () => void): void
}
export const directRunner: KotlinTestRunner = {
suite(name: string, isIgnored: boolean, fn: () => void): void {
if (!isIgnored) fn()
},
test(name: string, isIgnored: boolean, fn: () => void): void {
if (!isIgnored) fn()
}
};
@@ -0,0 +1,104 @@
import {KotlinTestRunner} from "./KotlinTestRunner";
import {TeamCityMessageData, TeamCityMessagesFlow} from "./TeamCityMessagesFlow";
import {Timer} from "./Timer";
const kotlin_test = require('kotlin-test');
// don't use enum as it is not minified by uglify
export type IgnoredTestSuitesReporting
= "skip" | "reportAsIgnoredTest" | "reportAllInnerTestsAsIgnored"
export const IgnoredTestSuitesReporting: { [key: string]: IgnoredTestSuitesReporting } = {
skip: "skip",
reportAsIgnoredTest: "reportAsIgnoredTest",
reportAllInnerTestsAsIgnored: "reportAllInnerTestsAsIgnored"
};
// to reduce minified code size
function withName(name: string, data?: TeamCityMessageData): TeamCityMessageData {
data = data || {};
data["name"] = name;
return data
}
export function runWithTeamCityReporter(
runner: KotlinTestRunner,
ignoredTestSuites: IgnoredTestSuitesReporting,
teamCity: TeamCityMessagesFlow,
timer: Timer<any> | null
): KotlinTestRunner {
let inIgnoredSuite = false;
let currentAssertionResult: { expected: any, actual: any } | null = null;
kotlin_test.kotlin.test.setAssertHook_4duqou$(function (assertionResult: { expected: any, actual: any }) {
currentAssertionResult = assertionResult;
});
return {
suite: function (name: string, isIgnored: boolean, fn: () => void) {
if (isIgnored) {
if (ignoredTestSuites == IgnoredTestSuitesReporting.skip) return;
else if (ignoredTestSuites == IgnoredTestSuitesReporting.reportAsIgnoredTest) {
teamCity.sendMessage('testIgnored', withName(name, {"suite": true}));
return
}
}
teamCity.sendMessage('testSuiteStarted', withName(name));
// noinspection UnnecessaryLocalVariableJS
const alreadyInIgnoredSuite = inIgnoredSuite;
if (!alreadyInIgnoredSuite && isIgnored) {
inIgnoredSuite = true;
}
try {
if (isIgnored && ignoredTestSuites == IgnoredTestSuitesReporting.reportAllInnerTestsAsIgnored) {
fn();
} else {
runner.suite(name, isIgnored, fn)
}
} finally {
if (isIgnored && !alreadyInIgnoredSuite) {
inIgnoredSuite = false;
}
const data: TeamCityMessageData = withName(name);
// extension only for Gradle
if (isIgnored) data["ignored"] = true;
teamCity.sendMessage('testSuiteFinished', data);
}
},
test: function (name: string, isIgnored: boolean, fn: () => void) {
if (inIgnoredSuite || isIgnored) {
teamCity.sendMessage('testIgnored', withName(name));
} else {
const startTime = timer ? timer.start() : null;
teamCity.sendMessage('testStarted', withName(name));
try {
runner.test(name, isIgnored, fn);
} catch (e) {
const data: TeamCityMessageData = withName(name, {
"message": e.message,
"details": e.stack
});
if (currentAssertionResult) {
data["type"] = 'comparisonFailure';
data["expected"] = currentAssertionResult.expected;
data["actual"] = currentAssertionResult.actual;
}
teamCity.sendMessage('testFailed', data);
} finally {
currentAssertionResult = null;
const data: TeamCityMessageData = withName(name);
if (startTime) {
data["duration"] = timer!!.end(startTime); // ns to ms
}
teamCity.sendMessage('testFinished', data);
}
}
}
}
}
@@ -0,0 +1,168 @@
import {escapeRegExp, startsWith, trim} from "./utils";
import {KotlinTestRunner} from "./KotlinTestRunner";
export interface KotlinTestsFilter {
mayContainTestsFromSuite(fqn: string): boolean;
containsTest(fqn: string): boolean;
}
export function runWithFilter(
runner: KotlinTestRunner,
filter: KotlinTestsFilter,
): KotlinTestRunner {
let path: string[] = [];
function pathString() {
// skip root
return path.slice(1).join('.')
}
return {
suite: function (name: string, isIgnored: boolean, fn: () => void) {
path.push(name);
try {
if (path.length > 1 && !filter.mayContainTestsFromSuite(pathString())) return;
runner.suite(name, isIgnored, fn);
} finally {
path.pop()
}
},
test: function (name: string, isIgnored: boolean, fn: () => void) {
path.push(name);
try {
if (!filter.containsTest(pathString())) return;
runner.test(name, isIgnored, fn);
} finally {
path.pop()
}
}
};
}
export function newKotlinTestsFilter(wildcard: string | null): KotlinTestsFilter | null {
if (wildcard == null) return null;
wildcard = trim(wildcard);
wildcard = wildcard.replace(/\*+/, '*'); // ** => *
if (wildcard.length == 0) return null;
else if (wildcard == '*') return allTest;
else if (wildcard.indexOf('*') == -1) return new ExactFilter(wildcard);
else if (startsWith(wildcard, '*')) return new RegExpKotlinTestsFilter(wildcard);
else {
// optimize for cases like "Something*", "Something*a*b" and so on.
// by adding explicit prefix matcher to not visit unneeded suites
// (RegExpKotlinTestsFilter doesn't support suites matching)
const [prefix, rest] = wildcard.split('*', 2);
return new StartsWithFilter(prefix, rest ? new RegExpKotlinTestsFilter(wildcard) : null)
}
}
export const allTest = new class implements KotlinTestsFilter {
mayContainTestsFromSuite(fqn: string): boolean {
return true;
}
containsTest(fqn: string): boolean {
return true;
}
};
export class StartsWithFilter implements KotlinTestsFilter {
constructor(
public readonly prefix: string,
public readonly filter: RegExpKotlinTestsFilter | null
) {
}
isPrefixMatched(fqn: string): boolean {
return startsWith(fqn + ".", this.prefix);
}
mayContainTestsFromSuite(fqn: string): boolean {
return this.isPrefixMatched(fqn);
}
containsAllTestsFromSuite(fqn: string): boolean {
return this.filter == null && this.isPrefixMatched(fqn);
}
containsTest(fqn: string): boolean {
return startsWith(fqn, this.prefix)
&& (this.filter == null || this.filter.containsTest(fqn));
}
}
export class ExactFilter implements KotlinTestsFilter {
constructor(public fqn: string) {
}
mayContainTestsFromSuite(fqn: string): boolean {
return startsWith(fqn, this.fqn);
}
containsTest(fqn: string): boolean {
return fqn === this.fqn;
}
}
export class RegExpKotlinTestsFilter implements KotlinTestsFilter {
public readonly regexp: RegExp;
constructor(wildcard: string) {
this.regexp = RegExp(wildcard
.split('*')
.map(it => escapeRegExp(it))
.join('.*'));
}
mayContainTestsFromSuite(fqn: string): boolean {
return true
}
containsTest(fqn: string): boolean {
return this.regexp!.test(fqn)
}
toString(): string {
return this.regexp.toString()
}
}
export class CompositeTestFilter implements KotlinTestsFilter {
private readonly excludePrefix: StartsWithFilter[] = [];
constructor(
public include: KotlinTestsFilter[],
public exclude: KotlinTestsFilter[]
) {
this.exclude.forEach(it => {
if (it instanceof StartsWithFilter && it.filter == null)
this.excludePrefix.push(it)
})
}
mayContainTestsFromSuite(fqn: string): boolean {
for (const excl of this.excludePrefix) {
if (excl.containsAllTestsFromSuite(fqn)) return false
}
for (const incl of this.include) {
if (incl.mayContainTestsFromSuite(fqn)) return true
}
return false;
}
containsTest(fqn: string): boolean {
for (const excl of this.exclude) {
if (excl.containsTest(fqn)) return false
}
for (const incl of this.include) {
if (incl.containsTest(fqn)) return true
}
return false
}
}
@@ -0,0 +1,32 @@
import {
allTest,
CompositeTestFilter,
ExactFilter,
RegExpKotlinTestsFilter,
StartsWithFilter
} from "./KotlinTestsFilter";
declare const DEBUG: boolean;
if (DEBUG) {
allTest.toString = function () {
return "(ALL)"
};
StartsWithFilter.prototype.toString = function (): string {
return "(STARTS WITH " + this.prefix + (this.filter ? (" AND " + this.filter) : "") + ")"
};
RegExpKotlinTestsFilter.prototype.toString = function (): string {
return "(REGEXP " + this.regexp + ")"
};
ExactFilter.prototype.toString = function (): string {
return "(EXACT " + this.fqn + ")"
};
CompositeTestFilter.prototype.toString = function (): string {
return this.include.map(it => it.toString()).join(" OR ")
+ "\n NOT (" + this.exclude.map(it => it.toString()).join(" OR ") + ")"
};
}
@@ -0,0 +1,23 @@
import {dateTimeWithoutTimeZone, newFlowId, tcEscape} from "./utils"
export type TeamCityMessageData = { [key: string]: any }
export class TeamCityMessagesFlow {
public readonly id: number;
constructor(id: number | null, private readonly send: (payload: string) => void) {
this.id = id || newFlowId()
}
sendMessage(type: string, args: TeamCityMessageData) {
args['flowId'] = this.id;
args['timestamp'] = dateTimeWithoutTimeZone();
const serializedArgs = Object
.keys(args)
.map((key) => `${key}='${tcEscape(args[key])}'`)
.join(' ');
this.send(`##teamcity[${type} ${serializedArgs}]`)
}
}
@@ -0,0 +1,16 @@
export interface Timer<T> {
start(): T
end(start: T): number
}
export const hrTimer: Timer<[number, number]> = {
start(): [number, number] {
return process.hrtime();
},
end(start: [number, number]): number {
const elapsedHr = process.hrtime(start);
return elapsedHr[0] + (elapsedHr[1] / 1e6); // ns to ms
}
};
@@ -0,0 +1,96 @@
/**
* from teamcity-service-messages
* Copyright (c) 2013 Aaron Forsander
*
* Escape string for TeamCity output.
* @see https://confluence.jetbrains.com/display/TCD65/Build+Script+Interaction+with+TeamCity#BuildScriptInteractionwithTeamCity-servMsgsServiceMessages
*/
export function tcEscape(str: string): string {
if (!str) {
return '';
}
return str
.toString()
.replace(/\|/g, '||')
.replace(/\n/g, '|n')
.replace(/\r/g, '|r')
.replace(/\[/g, '|[')
.replace(/\]/g, '|]')
.replace(/\u0085/g, '|x') // next line
.replace(/\u2028/g, '|l') // line separator
.replace(/\u2029/g, '|p') // paragraph separator
.replace(/'/g, '|\'');
}
/**
* From teamcity-service-messages.
* Copyright 2013 Aaron Forsander
*/
export function newFlowId(): number {
return Math.floor(Math.random() * (1e10 - 1e6 + 1)) + 1e6;
}
/**
* From teamcity-service-messages.
* Copyright 2013 Aaron Forsander
*/
export function dateTimeWithoutTimeZone(): string {
// TeamCity not fully support ISO 8601 (see TW-36173) so we need to cut off 'Z' at the end.
return new Date().toISOString().slice(0, -1);
}
/**
* From lodash.
* Copyright JS Foundation and other contributors <https://js.foundation/>
*/
export function startsWith(string: string, target: string) {
return string.slice(0, target.length) == target;
}
/**
* From lodash.
* Copyright JS Foundation and other contributors <https://js.foundation/>
*/
export function trim(str: string): string {
return str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, '');
}
/**
* Used to match `RegExp`
* [syntax characters](http://ecma-international.org/ecma-262/7.0/#sec-patterns).
*/
const reRegExpChar = /[\\^$.*+?()[\]{}|]/g,
reHasRegExpChar = RegExp(reRegExpChar.source);
/**
* Escapes the `RegExp` special characters "^", "$", "\", ".", "*", "+",
* "?", "(", ")", "[", "]", "{", "}", and "|" in `string`.
*
* From lodash.
* Copyright JS Foundation and other contributors <https://js.foundation/>
*/
export function escapeRegExp(string: string) {
return (string && reHasRegExpChar.test(string))
? string.replace(reRegExpChar, '\\$&')
: string;
}
export function pushIfNotNull<T>(list: T[], value: T) {
if (value !== null) list.push(value)
}
export function flatMap<T>(arr: T[], f: (item: T) => T[]): T[] {
const result: T[] = [];
arr.forEach(item => {
f(item).forEach(x => {
result.push(x)
})
});
return result;
}
export function println(message ?: string) {
console.log(message)
}
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es5",
"module": "ESNext",
// "declaration": true,
// "outDir": "./lib",
"strict": true,
// "sourceMap": true,
"allowSyntheticDefaultImports": true,
"inlineSourceMap": true
},
"exclude": [
"**/*Debug.ts"
]
}
File diff suppressed because it is too large Load Diff