• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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