1/* 2 * Copyright (c) 2022-2024 Huawei Device Co., Ltd. 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16import { Logger } from '../lib/Logger'; 17import { LoggerImpl } from '../cli/LoggerImpl'; 18import * as fs from 'node:fs'; 19import * as path from 'node:path'; 20import * as ts from 'typescript'; 21import type { CommandLineOptions } from '../lib/CommandLineOptions'; 22import { lint } from '../lib/LinterRunner'; 23import { TypeScriptLinter } from '../lib/TypeScriptLinter'; 24import { InteropTypescriptLinter } from '../lib/InteropTypescriptLinter'; 25import type { Autofix } from '../lib/autofixes/Autofixer'; 26import { parseCommandLine as parseLinterCommandLine } from '../cli/CommandLineParser'; 27import { compileLintOptions } from '../cli/Compiler'; 28import { getEtsLoaderPath } from '../cli/LinterCLI'; 29import { ProblemSeverity } from '../lib/ProblemSeverity'; 30import type { ProblemInfo } from '../lib/ProblemInfo'; 31import type { TestArguments } from './TestArgs'; 32import { validateTestArgs } from './TestArgs'; 33import type { LinterOptions } from '../lib/LinterOptions'; 34import { Command } from 'commander'; 35import { rimrafSync } from 'rimraf'; 36import type { TestProblemInfo, TestResult } from './TestResult'; 37import { validateTestResult } from './TestResult'; 38import { globSync } from 'glob'; 39import type { Path } from 'path-scurry'; 40import { PathScurry } from 'path-scurry'; 41import { getCommandLineArguments } from './CommandLineUtil'; 42 43Logger.init(new LoggerImpl()); 44 45const TAB = ' '; 46const RESULTS_DIR = 'results'; 47 48const ARGS_CONFIG_EXT = '.args.json'; 49const DIFF_EXT = '.diff'; 50const TEST_EXTENSION_STS = '.sts'; 51const TEST_EXTENSION_STSX = '.stsx'; 52const TEST_EXTENSION_D_STS = '.d.sts'; 53 54interface TestStatistics { 55 passed: number; 56 failed: number; 57} 58 59enum TestMode { 60 DEFAULT, 61 AUTOFIX, 62 ARKTS2 63} 64 65interface CreateTestConfigurationOptions { 66 runTestFileOpts: RunTestFileOptions; 67 testModeProps: TestModeProperties; 68 testModeArgs?: string; 69 testCommonOpts?: LinterOptions; 70} 71 72interface TestRunnerOptions { 73 linterOptions: LinterOptions; 74 testDirs: string[]; 75 testPattern?: string; 76} 77 78interface RunTestFileOptions { 79 testDir: string; 80 testFile: string; 81 testRunnerOpts: TestRunnerOptions; 82} 83 84interface TestModeProperties { 85 resultFileExt: string; 86 mode: TestMode; 87 modeOpts: LinterOptions /* Options that enable specific mode */; 88} 89 90const DEFAULT_MODE_PROPERTIES: TestModeProperties = { 91 resultFileExt: '.json', 92 mode: TestMode.DEFAULT, 93 modeOpts: {} 94}; 95const AUTOFIX_MODE_PROPERTIES: TestModeProperties = { 96 resultFileExt: '.autofix.json', 97 mode: TestMode.AUTOFIX, 98 modeOpts: { 99 enableAutofix: true 100 } 101}; 102const ARKTS2_MODE_PROPERTIES: TestModeProperties = { 103 resultFileExt: '.arkts2.json', 104 mode: TestMode.ARKTS2, 105 modeOpts: { 106 arkts2: true 107 } 108}; 109 110interface TestConfiguration { 111 testDir: string; 112 testFile: string; 113 testModeProps: TestModeProperties; 114 cmdOptions: CommandLineOptions; 115} 116 117export function getSdkConfigOptions(configFile: string): ts.CompilerOptions { 118 // initial configuration 119 const options = ts.readConfigFile(configFile, ts.sys.readFile).config.compilerOptions; 120 121 const allPath = ['*']; 122 Object.assign(options, { 123 emitNodeModulesFiles: true, 124 importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Preserve, 125 module: ts.ModuleKind.ES2020, 126 moduleResolution: ts.ModuleResolutionKind.NodeJs, 127 noEmit: true, 128 target: ts.ScriptTarget.ES2021, 129 baseUrl: '/', 130 paths: { 131 '*': allPath 132 }, 133 lib: ['lib.es2021.d.ts'], 134 types: [], 135 etsLoaderPath: 'null_sdkPath' 136 }); 137 138 return options; 139} 140 141function parseCommandLine(commandLineArgs: string[]): TestRunnerOptions { 142 const program = new Command(); 143 program. 144 name('testrunner'). 145 description('Test Runner for Linter tests'). 146 configureHelp({ helpWidth: 100 }). 147 version('1.0.0'); 148 program. 149 requiredOption( 150 '-d, --test-dir <test_dir>', 151 'Specifies a directory with test files. Multiple directories can be specified with a comma-separated list' 152 ). 153 option( 154 '-p, --test-pattern <pattern>', 155 'Specifies a glob pattern to filter the list of tests. The path from the pattern is resolved to test directory. ' + 156 'Note: Only tests in specified test directory will be run, and the sub-directories will be ignored.' 157 ). 158 option('--sdk', 'Use SDK check logic'). 159 option('--interop-mode', 'Run interop-mode check'); 160 161 // method parse() eats two first args, so make them dummy 162 const cmdArgs: string[] = ['dummy', 'dummy']; 163 cmdArgs.push(...commandLineArgs); 164 program.parse(cmdArgs); 165 const programOpts = program.opts(); 166 const testRunnerOpts: TestRunnerOptions = { 167 testDirs: processTestDirArg(programOpts.testDir), 168 linterOptions: {} 169 }; 170 if (programOpts.testPattern) { 171 testRunnerOpts.testPattern = programOpts.testPattern; 172 } 173 if (programOpts.interopMode) { 174 testRunnerOpts.linterOptions.interopCheckMode = true; 175 } 176 if (programOpts.sdk) { 177 testRunnerOpts.linterOptions.useRtLogic = false; 178 } 179 return testRunnerOpts; 180} 181 182function processTestDirArg(testDirs: string | undefined): string[] { 183 if (!testDirs) { 184 return []; 185 } 186 return testDirs. 187 split(','). 188 map((val) => { 189 return val.trim(); 190 }). 191 filter((val) => { 192 return val.length > 0; 193 }); 194} 195 196function runTests(): boolean { 197 const testRunnerOpts = parseCommandLine(process.argv.slice(2)); 198 const testStats: TestStatistics = { passed: 0, failed: 0 }; 199 // Get tests from test directory 200 for (const testDir of testRunnerOpts.testDirs) { 201 if (!fs.existsSync(testDir)) { 202 Logger.info(`\nThe "${testDir}" directory doesn't exist.\n`); 203 continue; 204 } 205 Logger.info(`\nProcessing "${testDir}" directory:\n`); 206 rimrafSync(path.join(testDir, RESULTS_DIR)); 207 let testFiles: string[] = testRunnerOpts.testPattern ? 208 collectTestFilesWithGlob(testDir, testRunnerOpts.testPattern) : 209 fs.readdirSync(testDir); 210 testFiles = testFiles.filter((x) => { 211 return ( 212 x.trimEnd().endsWith(ts.Extension.Ts) && !x.trimEnd().endsWith(ts.Extension.Dts) || 213 x.trimEnd().endsWith(ts.Extension.Tsx) || 214 x.trimEnd().endsWith(ts.Extension.Ets) || 215 x.trimEnd().endsWith(TEST_EXTENSION_STS) && !x.trimEnd().endsWith(TEST_EXTENSION_D_STS) || 216 x.trimEnd().endsWith(TEST_EXTENSION_STSX) 217 ); 218 }); 219 runTestFiles(testFiles, testDir, testRunnerOpts, testStats); 220 } 221 const { passed, failed } = testStats; 222 Logger.info(`\nSUMMARY: ${passed + failed} total, ${passed} passed, ${failed} failed.`); 223 Logger.info(failed > 0 ? '\nTEST FAILED' : '\nTEST SUCCESSFUL'); 224 process.exit(failed > 0 ? -1 : 0); 225} 226 227function collectTestFilesWithGlob(testDir: string, pattern: string): string[] { 228 const testDirPath = new PathScurry(testDir).cwd.fullpath(); 229 return globSync(pattern, { 230 cwd: testDir, 231 ignore: { 232 childrenIgnored(p: Path): boolean { 233 return p.fullpath() !== testDirPath; 234 } 235 } 236 }).sort(); 237} 238 239function runTestFiles( 240 testFiles: string[], 241 testDir: string, 242 testRunnerOpts: TestRunnerOptions, 243 testStats: TestStatistics 244): void { 245 for (const testFile of testFiles) { 246 try { 247 let renamed = false; 248 let tsName = testFile; 249 if (testFile.includes(TEST_EXTENSION_STS)) { 250 renamed = true; 251 tsName = testFile.replace(TEST_EXTENSION_STS, ts.Extension.Ts); 252 fs.renameSync(path.join(testDir, testFile), path.join(testDir, tsName)); 253 } 254 runTestFile({ testDir, testFile: tsName, testRunnerOpts }, testStats); 255 if (renamed) { 256 fs.renameSync(path.join(testDir, tsName), path.join(testDir, testFile)); 257 } 258 } catch (error) { 259 Logger.info(`Test ${testFile} failed:\n${TAB}` + (error as Error).message); 260 testStats.failed++; 261 } 262 } 263} 264 265function runTestFile(runTestFileOpts: RunTestFileOptions, testStats: TestStatistics): void { 266 const testConfigs: TestConfiguration[] = []; 267 268 const testArgs = processTestArgsFile(runTestFileOpts.testDir, runTestFileOpts.testFile); 269 if (testArgs) { 270 const testCommonOpts = testArgs.commonArgs ? getLinterOptionsFromCommandLine(testArgs.commonArgs) : undefined; 271 addTestConfiguration(testConfigs, { 272 runTestFileOpts, 273 testModeProps: DEFAULT_MODE_PROPERTIES, 274 testModeArgs: testArgs.mode?.default, 275 testCommonOpts 276 }); 277 addTestConfiguration(testConfigs, { 278 runTestFileOpts, 279 testModeProps: AUTOFIX_MODE_PROPERTIES, 280 testModeArgs: testArgs.mode?.autofix, 281 testCommonOpts 282 }); 283 addTestConfiguration(testConfigs, { 284 runTestFileOpts, 285 testModeProps: ARKTS2_MODE_PROPERTIES, 286 testModeArgs: testArgs.mode?.arkts2, 287 testCommonOpts 288 }); 289 } else { 290 addTestConfiguration(testConfigs, { runTestFileOpts, testModeProps: DEFAULT_MODE_PROPERTIES }); 291 } 292 293 testConfigs.forEach((config: TestConfiguration) => { 294 if (runTest(config)) { 295 ++testStats.passed; 296 } else { 297 ++testStats.failed; 298 } 299 }); 300} 301 302function processTestArgsFile(testDir: string, testFile: string): TestArguments | undefined { 303 const argsFileName = path.join(testDir, testFile + ARGS_CONFIG_EXT); 304 if (fs.existsSync(argsFileName)) { 305 try { 306 const data = fs.readFileSync(argsFileName).toString(); 307 const json = JSON.parse(data); 308 return validateTestArgs(json); 309 } catch (error) { 310 throw new Error(`Failed to process ${argsFileName}: ${(error as Error).message}`); 311 } 312 } 313 return undefined; 314} 315 316function addTestConfiguration( 317 testConfigs: TestConfiguration[], 318 createTestConfigOpts: CreateTestConfigurationOptions 319): void { 320 const { runTestFileOpts, testModeProps, testModeArgs, testCommonOpts } = createTestConfigOpts; 321 const { testDir, testFile, testRunnerOpts } = runTestFileOpts; 322 323 if (testModeArgs === undefined && testModeProps.mode !== TestMode.DEFAULT) { 324 return; 325 } 326 327 /* 328 * Test options are formed in the following order (from lowest to highest priority): 329 * - default test options; 330 * - [test_args_file] --> 'commonArgs'; 331 * - [test_args_file] --> the arguments specified for a mode; 332 * - options specified by TestRunner command-line arguments; 333 * - options that enable specific mode. 334 */ 335 const linterOpts = getDefaultTestOptions(); 336 if (testCommonOpts) { 337 Object.assign(linterOpts, testCommonOpts); 338 } 339 if (testModeArgs) { 340 Object.assign(linterOpts, getLinterOptionsFromCommandLine(testModeArgs)); 341 } 342 Object.assign(linterOpts, testRunnerOpts.linterOptions); 343 Object.assign(linterOpts, testModeProps.modeOpts); 344 345 const cmdOptions: CommandLineOptions = { 346 inputFiles: [path.join(testDir, testFile)], 347 linterOptions: linterOpts 348 }; 349 350 testConfigs.push({ 351 testDir, 352 testFile, 353 testModeProps, 354 cmdOptions 355 }); 356} 357 358function getDefaultTestOptions(): LinterOptions { 359 360 /* 361 * Set the IDE mode manually to enable storing information 362 * about found bad nodes and also disable the log output. 363 */ 364 return { 365 ideMode: true, 366 useRtLogic: true, 367 checkTsAsSource: true /* Currently, tests have '.ts' extension, therefore, enable this flag by default */, 368 compatibleSdkVersion: 12, 369 compatibleSdkVersionStage: 'beta3' 370 }; 371} 372 373function getLinterOptionsFromCommandLine(cmdLine: string): LinterOptions { 374 return parseLinterCommandLine(getCommandLineArguments(cmdLine), { exitOnFail: false, disableErrorOutput: true }). 375 linterOptions; 376} 377 378function runTest(testConfig: TestConfiguration): boolean { 379 Logger.info(`Running test ${testConfig.testFile} (${TestMode[testConfig.testModeProps.mode]} mode)`); 380 381 TypeScriptLinter.initGlobals(); 382 InteropTypescriptLinter.initGlobals(); 383 384 const linterConfig = compileLintOptions(testConfig.cmdOptions); 385 const linterResult = lint(linterConfig, getEtsLoaderPath(linterConfig)); 386 387 // Get actual test results. 388 const fileProblems = linterResult.problemsInfos.get(path.normalize(testConfig.cmdOptions.inputFiles[0])); 389 if (fileProblems === undefined) { 390 return true; 391 } 392 const actualResult = transformProblemInfos(fileProblems, testConfig.testModeProps.mode); 393 394 // Read file with expected test result. 395 const resultDiff = compareExpectedAndActual(testConfig, actualResult); 396 397 // Write file with actual test results. 398 writeActualResultFile(testConfig, actualResult, resultDiff); 399 400 return !resultDiff; 401} 402 403function readTestResultFile(testDir: string, testResultFileName: string): TestProblemInfo[] { 404 const filePath = path.join(testDir, testResultFileName); 405 try { 406 const testResult = fs.readFileSync(filePath).toString(); 407 return validateTestResult(JSON.parse(testResult)).result; 408 } catch (error) { 409 throw new Error(`Failed to process ${filePath}: ${(error as Error).message}`); 410 } 411} 412 413function transformProblemInfos(fileProblems: ProblemInfo[], mode: TestMode): TestProblemInfo[] { 414 return fileProblems.map<TestProblemInfo>((x) => { 415 return { 416 line: x.line, 417 column: x.column, 418 endLine: x.endLine, 419 endColumn: x.endColumn, 420 problem: x.problem, 421 autofix: mode === TestMode.AUTOFIX ? x.autofix : undefined, 422 suggest: x.suggest, 423 rule: x.rule, 424 severity: ProblemSeverity[x.severity] 425 }; 426 }); 427} 428 429function compareExpectedAndActual(testConfig: TestConfiguration, actual: TestProblemInfo[]): string { 430 const { 431 testDir, 432 testFile, 433 cmdOptions: { linterOptions }, 434 testModeProps: { resultFileExt } 435 } = testConfig; 436 437 // Read file with expected test result. 438 let diff: string = ''; 439 const testResultFileName = testFile + resultFileExt; 440 try { 441 let expected = readTestResultFile(testDir, testResultFileName); 442 443 /** 444 * The exclusive field is added to identify whether the use case is exclusive to the RT or SDK 445 * RT means the RT exclusive 446 * SDK means the SDK exclusive 447 * undefined means shared 448 */ 449 expected = expected.filter((x) => { 450 return !x?.exclusive || x.exclusive === (linterOptions.useRtLogic ? 'RT' : 'SDK'); 451 }); 452 453 if (!expected || expected.length !== actual.length) { 454 const expectedResultCount = expected ? expected.length : 0; 455 diff = `Expected count: ${expectedResultCount} vs actual count: ${actual.length}`; 456 Logger.info(`${TAB}${diff}`); 457 } else { 458 diff = expectedAndActualMatch(expected, actual); 459 } 460 461 if (diff) { 462 Logger.info(`${TAB}Test failed. Expected and actual results differ.`); 463 } 464 } catch (error) { 465 // Write error message to diff, as non-empty diff indicates that test has failed. 466 diff = (error as Error).message; 467 Logger.info(`${TAB}Failed to compare expected and actual results. ` + diff); 468 } 469 470 return diff; 471} 472 473function expectedAndActualMatch(expectedNodes: TestProblemInfo[], actualNodes: TestProblemInfo[]): string { 474 // Compare expected and actual results. 475 for (let i = 0; i < actualNodes.length; i++) { 476 const actual = actualNodes[i]; 477 const expect = expectedNodes[i]; 478 if (!locationMatch(expect, actual) || actual.problem !== expect.problem) { 479 return reportDiff(expect, actual); 480 } 481 if (!autofixArraysMatch(expect.autofix, actual.autofix)) { 482 return reportDiff(expect, actual); 483 } 484 if (expect.suggest && actual.suggest !== expect.suggest) { 485 return reportDiff(expect, actual); 486 } 487 if (expect.rule && actual.rule !== expect.rule) { 488 return reportDiff(expect, actual); 489 } 490 if (expect.severity && actual.severity !== expect.severity) { 491 return reportDiff(expect, actual); 492 } 493 } 494 495 return ''; 496} 497 498function locationMatch(expected: TestProblemInfo, actual: TestProblemInfo): boolean { 499 return ( 500 actual.line === expected.line && 501 actual.column === expected.column && 502 (!expected.endLine || actual.endLine === expected.endLine) && 503 (!expected.endColumn || actual.endColumn === expected.endColumn) 504 ); 505} 506 507function autofixArraysMatch(expected: Autofix[] | undefined, actual: Autofix[] | undefined): boolean { 508 if (!expected && !actual) { 509 return true; 510 } 511 if (!(expected && actual) || expected.length !== actual.length) { 512 return false; 513 } 514 for (let i = 0; i < actual.length; ++i) { 515 if ( 516 actual[i].start !== expected[i].start || 517 actual[i].end !== expected[i].end || 518 actual[i].replacementText !== expected[i].replacementText 519 ) { 520 return false; 521 } 522 } 523 return true; 524} 525 526function writeActualResultFile(testConfig: TestConfiguration, actual: TestProblemInfo[], diff: string): void { 527 const { 528 testModeProps: { resultFileExt } 529 } = testConfig; 530 const actualResultsDir = path.join(testConfig.testDir, RESULTS_DIR); 531 if (!fs.existsSync(actualResultsDir)) { 532 fs.mkdirSync(actualResultsDir); 533 } 534 535 const actualTestResult: TestResult = { result: actual }; 536 const actualResultJSON = JSON.stringify(actualTestResult, null, 4); 537 fs.writeFileSync(path.join(actualResultsDir, testConfig.testFile + resultFileExt), actualResultJSON); 538 539 if (diff) { 540 fs.writeFileSync(path.join(actualResultsDir, testConfig.testFile + resultFileExt + DIFF_EXT), diff); 541 } 542} 543 544function reportDiff(expected: TestProblemInfo, actual: TestProblemInfo): string { 545 const expectedNode = JSON.stringify({ nodes: [expected] }, null, 4); 546 const actualNode = JSON.stringify({ nodes: [actual] }, null, 4); 547 548 const diff = `Expected: 549${expectedNode} 550Actual: 551${actualNode}`; 552 553 Logger.info(diff); 554 return diff; 555} 556 557runTests(); 558