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 './LoggerImpl';
18Logger.init(new LoggerImpl());
19
20import * as fs from 'node:fs';
21import * as path from 'node:path';
22import * as ts from 'typescript';
23import type { CommandLineOptions } from '../lib/CommandLineOptions';
24import { lint } from '../lib/LinterRunner';
25import { TypeScriptLinter } from '../lib/TypeScriptLinter';
26import type { Autofix } from '../lib/autofixes/Autofixer';
27import { parseCommandLine } from './CommandLineParser';
28import { compileLintOptions } from './Compiler';
29import { getEtsLoaderPath } from './LinterCLI';
30import { ProblemSeverity } from '../lib/ProblemSeverity';
31
32const TEST_DIR = 'test';
33const TAB = '    ';
34
35interface TestNodeInfo {
36  line: number;
37  column: number;
38  endLine: number;
39  endColumn: number;
40  problem: string;
41  autofix?: Autofix[];
42  suggest?: string;
43  rule?: string;
44  severity?: string;
45}
46
47enum Mode {
48  DEFAULT,
49  AUTOFIX
50}
51
52const RESULT_EXT: string[] = [];
53RESULT_EXT[Mode.DEFAULT] = '.json';
54RESULT_EXT[Mode.AUTOFIX] = '.autofix.json';
55const AUTOFIX_SKIP_EXT = '.autofix.skip';
56const ARGS_CONFIG_EXT = '.args.json';
57const DIFF_EXT = '.diff';
58const testExtensionSts = '.sts';
59const testExtensionDSts = '.d.sts';
60
61function runTests(testDirs: string[]): number {
62
63  /*
64   * Set the IDE mode manually to enable storing information
65   * about found bad nodes and also disable the log output.
66   */
67  TypeScriptLinter.ideMode = true;
68  TypeScriptLinter.testMode = true;
69
70  let hasComparisonFailures = false;
71  let passed = 0;
72  let failed = 0;
73  // Get tests from test directory
74  if (!testDirs?.length) {
75    // eslint-disable-next-line no-param-reassign
76    testDirs = [TEST_DIR];
77  }
78  for (const testDir of testDirs) {
79    const testFiles: string[] = fs.readdirSync(testDir).filter((x) => {
80      return (
81        x.trimEnd().endsWith(ts.Extension.Ts) && !x.trimEnd().endsWith(ts.Extension.Dts) ||
82        x.trimEnd().endsWith(ts.Extension.Tsx) ||
83        x.trimEnd().endsWith(ts.Extension.Ets) ||
84        x.trimEnd().endsWith(testExtensionSts) && !x.trimEnd().endsWith(testExtensionDSts)
85      );
86    });
87    Logger.info(`\nProcessing "${testDir}" directory:\n`);
88    // Run each test in Default and Autofix mode:
89    [passed, failed, hasComparisonFailures] = runTestFiles(testFiles, testDir);
90  }
91  Logger.info(`\nSUMMARY: ${passed + failed} total, ${passed} passed or skipped, ${failed} failed.`);
92  Logger.info(failed > 0 ? '\nTEST FAILED' : '\nTEST SUCCESSFUL');
93  process.exit(hasComparisonFailures ? -1 : 0);
94}
95
96function runTestFiles(testFiles: string[], testDir: string): [number, number, boolean] {
97  let hasComparisonFailures = false;
98  let passed = 0;
99  let failed = 0;
100  for (const testFile of testFiles) {
101    let renamed = false;
102    let tsName = testFile;
103    if (testFile.includes(testExtensionSts)) {
104      renamed = true;
105      tsName = testFile.replace(testExtensionSts, ts.Extension.Ts);
106      fs.renameSync(path.join(testDir, testFile), path.join(testDir, tsName));
107    }
108    if (runTest(testDir, tsName, Mode.DEFAULT)) {
109      failed++;
110      hasComparisonFailures = true;
111    } else {
112      passed++;
113    }
114    if (runTest(testDir, tsName, Mode.AUTOFIX)) {
115      failed++;
116      hasComparisonFailures = true;
117    } else {
118      passed++;
119    }
120    if (renamed) {
121      fs.renameSync(path.join(testDir, tsName), path.join(testDir, testFile));
122    }
123  }
124  return [passed, failed, hasComparisonFailures];
125}
126
127function parseArgs(testDir: string, testFile: string, mode: Mode): CommandLineOptions {
128  // Configure test parameters and run linter.
129  const cmdArgs: string[] = [path.join(testDir, testFile)];
130  const argsFileName = path.join(testDir, testFile + ARGS_CONFIG_EXT);
131
132  if (fs.existsSync(argsFileName)) {
133    const data = fs.readFileSync(argsFileName).toString();
134    const args = JSON.parse(data);
135    if (args.testMode !== undefined) {
136      TypeScriptLinter.testMode = args.testMode;
137    }
138    if (args.arkts2 === true) {
139      cmdArgs.push('--arkts-2');
140    }
141  }
142
143  if (mode === Mode.AUTOFIX) {
144    cmdArgs.push('--autofix');
145  }
146
147  return parseCommandLine(cmdArgs);
148}
149
150function compareExpectedAndActual(testDir: string, testFile: string, mode: Mode, resultNodes: TestNodeInfo[]): string {
151  // Read file with expected test result.
152  let expectedResult: { nodes: TestNodeInfo[] };
153  let diff: string = '';
154  const resultExt = RESULT_EXT[mode];
155  const testResultFileName = testFile + resultExt;
156  try {
157    const expectedResultFile = fs.readFileSync(path.join(testDir, testResultFileName)).toString();
158    expectedResult = JSON.parse(expectedResultFile);
159
160    if (!expectedResult?.nodes || expectedResult.nodes.length !== resultNodes.length) {
161      const expectedResultCount = expectedResult?.nodes ? expectedResult.nodes.length : 0;
162      diff = `Expected count: ${expectedResultCount} vs actual count: ${resultNodes.length}`;
163      Logger.info(`${TAB}${diff}`);
164    } else {
165      diff = expectedAndActualMatch(expectedResult.nodes, resultNodes);
166    }
167
168    if (diff) {
169      Logger.info(`${TAB}Test failed. Expected and actual results differ.`);
170    }
171  } catch (error) {
172    Logger.info(`${TAB}Test failed. ` + error);
173  }
174
175  return diff;
176}
177
178function runTest(testDir: string, testFile: string, mode: Mode): boolean {
179  if (mode === Mode.AUTOFIX && fs.existsSync(path.join(testDir, testFile + AUTOFIX_SKIP_EXT))) {
180    Logger.info(`Skipping test ${testFile} (${Mode[mode]} mode)`);
181    return false;
182  }
183  Logger.info(`Running test ${testFile} (${Mode[mode]} mode)`);
184
185  TypeScriptLinter.initGlobals();
186
187  const currentTestMode = TypeScriptLinter.testMode;
188
189  const cmdOptions = parseArgs(testDir, testFile, mode);
190  const lintOptions = compileLintOptions(cmdOptions);
191  lintOptions.compatibleSdkVersion = '12';
192  lintOptions.compatibleSdkVersionStage = 'beta3';
193  const result = lint(lintOptions, getEtsLoaderPath(lintOptions));
194  const fileProblems = result.problemsInfos.get(path.normalize(cmdOptions.inputFiles[0]));
195  if (fileProblems === undefined) {
196    return true;
197  }
198
199  TypeScriptLinter.testMode = currentTestMode;
200
201  // Get list of bad nodes from the current run.
202  const resultNodes: TestNodeInfo[] = fileProblems.map<TestNodeInfo>((x) => {
203    return {
204      line: x.line,
205      column: x.column,
206      endLine: x.endLine,
207      endColumn: x.endColumn,
208      problem: x.problem,
209      autofix: mode === Mode.AUTOFIX ? x.autofix : undefined,
210      suggest: x.suggest,
211      rule: x.rule,
212      severity: ProblemSeverity[x.severity]
213    };
214  });
215
216  // Read file with expected test result.
217  const testResult = compareExpectedAndActual(testDir, testFile, mode, resultNodes);
218
219  // Write file with actual test results.
220  writeActualResultFile(testDir, testFile, mode, resultNodes, testResult);
221
222  return !!testResult;
223}
224
225function expectedAndActualMatch(expectedNodes: TestNodeInfo[], actualNodes: TestNodeInfo[]): string {
226  // Compare expected and actual results.
227  for (let i = 0; i < actualNodes.length; i++) {
228    const actual = actualNodes[i];
229    const expect = expectedNodes[i];
230    if (!locationMatch(expect, actual) || actual.problem !== expect.problem) {
231      return reportDiff(expect, actual);
232    }
233    if (!autofixArraysMatch(expect.autofix, actual.autofix)) {
234      return reportDiff(expect, actual);
235    }
236    if (expect.suggest && actual.suggest !== expect.suggest) {
237      return reportDiff(expect, actual);
238    }
239    if (expect.rule && actual.rule !== expect.rule) {
240      return reportDiff(expect, actual);
241    }
242    if (expect.severity && actual.severity !== expect.severity) {
243      return reportDiff(expect, actual);
244    }
245  }
246
247  return '';
248}
249
250function locationMatch(expected: TestNodeInfo, actual: TestNodeInfo): boolean {
251  return (
252    actual.line === expected.line ||
253    actual.column === expected.column ||
254    !!(expected.endLine && actual.endLine === expected.endLine) ||
255    !!(expected.endColumn && actual.endColumn === expected.endColumn)
256  );
257}
258
259function autofixArraysMatch(expected: Autofix[] | undefined, actual: Autofix[] | undefined): boolean {
260  if (!expected && !actual) {
261    return true;
262  }
263  if (!(expected && actual) || expected.length !== actual.length) {
264    return false;
265  }
266  for (let i = 0; i < actual.length; ++i) {
267    if (
268      actual[i].start !== expected[i].start ||
269      actual[i].end !== expected[i].end ||
270      actual[i].replacementText !== expected[i].replacementText
271    ) {
272      return false;
273    }
274  }
275  return true;
276}
277
278function writeActualResultFile(
279  testDir: string,
280  testFile: string,
281  mode: Mode,
282  resultNodes: TestNodeInfo[],
283  diff: string
284): void {
285  const actualResultsDir = path.join(testDir, 'results');
286  const resultExt = RESULT_EXT[mode];
287  if (!fs.existsSync(actualResultsDir)) {
288    fs.mkdirSync(actualResultsDir);
289  }
290
291  const actualResultJSON = JSON.stringify({ nodes: resultNodes }, null, 4);
292  fs.writeFileSync(path.join(actualResultsDir, testFile + resultExt), actualResultJSON);
293
294  if (diff) {
295    fs.writeFileSync(path.join(actualResultsDir, testFile + resultExt + DIFF_EXT), diff);
296  }
297}
298
299function reportDiff(expected: TestNodeInfo, actual: TestNodeInfo): string {
300  const expectedNode = JSON.stringify({ nodes: [expected] }, null, 4);
301  const actualNode = JSON.stringify({ nodes: [actual] }, null, 4);
302
303  const diff = `Expected:
304${expectedNode}
305Actual:
306${actualNode}`;
307
308  Logger.info(diff);
309  return diff;
310}
311
312runTests(process.argv.slice(2));
313