From 66d99cbf04b8733ce01b77089b99154784f8e23b Mon Sep 17 00:00:00 2001 From: lucono Date: Tue, 23 Aug 2022 14:10:31 -0400 Subject: [PATCH 1/5] Fixes and updates for v0.7.4 --- CHANGELOG.md | 21 +- docs/documentation.md | 3 +- package-lock.json | 18 +- package.json | 113 +++++- src/adapter.ts | 32 +- src/core/config/config-helper.ts | 5 +- src/core/config/config-setting.ts | 4 +- src/core/config/config-store.ts | 2 + src/core/config/extension-config.ts | 7 +- src/core/config/layered-config-store.ts | 16 +- src/core/config/project-specific-config.ts | 5 +- .../config/simple-mutable-config-store.ts | 34 +- .../workspace-folder-config-resolver.ts | 6 + src/core/default-test-manager.ts | 2 +- src/core/main-factory.ts | 16 +- src/core/parser/ast/ast-test-file-parser.ts | 29 +- src/core/test-locator.ts | 2 +- src/core/vscode/preferences/preferences.ts | 2 +- src/frameworks/angular/angular-factory.ts | 2 + .../angular/angular-test-server-executor.ts | 13 +- src/frameworks/angular/angular-util.ts | 47 ++- .../karma/config/karma-config-loader.ts | 138 +++++++ .../karma/config/karma-configurator.ts | 125 ------ src/frameworks/karma/config/karma.conf.ts | 22 +- .../karma/karma-environment-variable.ts | 1 + src/frameworks/karma/karma-factory.ts | 2 + .../reporter/karma-test-explorer-reporter.ts | 2 +- .../karma/runner/default-test-builder.ts | 16 +- .../karma-command-line-test-run-executor.ts | 2 +- .../runner/karma-test-event-processor.ts | 10 +- ...karma-command-line-test-server-executor.ts | 2 +- src/main.ts | 23 +- src/project-factory.ts | 116 ++++-- src/util/filesystem/file-handler.ts | 12 + .../simple-file-handler.ts} | 36 +- src/util/logging/console-log-appender.ts | 21 + src/util/logging/logger-adapter.ts | 6 +- src/util/process/simple-process.ts | 43 +- src/util/utils.ts | 23 +- test/core/config/extension-config.test.ts | 35 +- .../core/config/laytered-config-store.test.ts | 259 ++++++++++++ .../parser/ast/ast-test-file-parser.test.ts | 370 ++++++++++-------- .../regexp/regexp-test-file-parser.test.ts | 280 ++++++------- test/core/test-locator.test.ts | 2 +- test/frameworks/angular/angular-utils.test.ts | 144 ++++--- ...or.test.ts => karma-config-loader.test.ts} | 0 test/project-factory.test.ts | 55 +++ .../command-line-process-handler.test.ts | 140 ------- test/util/process/simple-process.test.ts | 159 ++++++++ 49 files changed, 1564 insertions(+), 859 deletions(-) create mode 100644 src/core/config/workspace-folder-config-resolver.ts create mode 100644 src/frameworks/karma/config/karma-config-loader.ts delete mode 100644 src/frameworks/karma/config/karma-configurator.ts create mode 100644 src/util/filesystem/file-handler.ts rename src/util/{file-handler.ts => filesystem/simple-file-handler.ts} (77%) create mode 100644 src/util/logging/console-log-appender.ts create mode 100644 test/core/config/laytered-config-store.test.ts rename test/frameworks/karma/config/{karma-configurator.test.ts => karma-config-loader.test.ts} (100%) create mode 100644 test/project-factory.test.ts delete mode 100644 test/util/process/command-line-process-handler.test.ts create mode 100644 test/util/process/simple-process.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cdeda4..3b680ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format of this changelog is loosely based on [Keep a Changelog](https://keep
Releases + - [0.7.4 - Aug 27, 2022](#074---aug-27-2022) - [0.7.3 - Jul 29, 2022](#073---jul-29-2022) - [0.7.2 - Jul 27, 2022](#072---jul-27-2022) - [0.7.1 - Jul 24, 2022](#071---jul-24-2022) @@ -22,6 +23,24 @@ The format of this changelog is loosely based on [Keep a Changelog](https://keep
+--- +## [0.7.4] - Aug 27, 2022 + +### Added + +- Experimental new `karmaTestExplorer.enabledParserPlugins` extension setting for specifying enabled parser plugins for adding support for various specific language syntaxes when parsing test files. (Addresses [this issue](https://github.com/lucono/karma-test-explorer/issues/46)) + +### Changed + +- The `karmaTestExplorer.projects` extension setting has been renamed to `karmaTestExplorer.projectWorkspaces` to avoid confusion with Angular workspace projects +- The `projectRootPath` property of the object format for specifying projects has been renamed to `rootPath` to better align with the new naming of its parent `karmaTestExplorer.projectWorkspaces` setting +- Improved error logging for scenarios where Karma fails to start or quits unexpectedly + +### Fixed + +- Fixed an [issue](https://github.com/lucono/karma-test-explorer/issues/47) where breakpoints are not hit when debugging projects that are not located directly in the VS Code workspace root +- Addressed an [issue](https://github.com/lucono/karma-test-explorer/issues/45) to add support for a Karma configuration scenario that works with Karma but not with the extension + --- ## [0.7.3] - Jul 29, 2022 @@ -34,7 +53,7 @@ The format of this changelog is loosely based on [Keep a Changelog](https://keep ### Fixed -- Fixed an [issue](https://github.com/lucono/karma-test-explorer/issues/37) introduced in `v.0.7.0` that prevented new Angular projects that don't specify a default project in `angular.json` from loading +- Fixed an [issue](https://github.com/lucono/karma-test-explorer/issues/37) introduced in `v0.7.0` that prevented new Angular projects that don't specify a default project in `angular.json` from loading --- ## [0.7.1] - Jul 24, 2022 diff --git a/docs/documentation.md b/docs/documentation.md index 23abd59..85bb1a5 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -148,7 +148,7 @@ Though Karma Test Explorer comes with many configuration options that make it fl Setting | Description -----------------------------------------------|--------------------------------------------------------------- `karmaTestExplorer.enableExtension` | Explicitly enables or disables Karma Test Explorer when its default project inspection to automatically enable or disable itself does not yield the desired decision -`karmaTestExplorer.projects` | This is a list, each entry of which is either a string of the relative path (relative to the workspace root folder) to a project for testing, or an object having a `projectRootPath` property with the value of that path. The object format also accepts the following optional properties which can be used to provide the corresponding settings specifically for that project - `projectType`, `karmaConfFilePath`, `testFramework`, `testFiles`, `excludeFiles`, `testsBasePath`. Like other Karma Test Explorer settings, when not explicitly configured, most settings will be auto-detected where possible with reasonable values for each project +`karmaTestExplorer.projectWorkspaces` | Experimental and subject to change in future releases! This is a list, each entry of which is either a string of the relative path (relative to the VS Code workspace root folder) to a project workspace for testing, or an object having a `rootPath` property with the value of that path. The object format also accepts the following optional properties which can be used to provide the corresponding settings specifically for that project workspace - `projectType`, `karmaConfFilePath`, `testFramework`, `testFiles`, `excludeFiles`, `testsBasePath`. Like other Karma Test Explorer settings, when not explicitly configured, most settings will be auto-detected where possible with reasonable values for each project workspace `karmaTestExplorer.projectType` | The type of the project. This will be auto-detected if not specified. Specify the right project type if not correctly auto-detected `karmaTestExplorer.testFramework` | The test framework used by the project. This will be auto-detected if not specified. Specify the right test framework if not correctly auto-detected `karmaTestExplorer.karmaConfFilePath` | The path where the `karma.conf.js` file is located (relative to the project root path) @@ -171,6 +171,7 @@ Setting | Description `karmaTestExplorer.angularProcessCommand` | The command or path to an executable to use for launching or running Angular tests. This is useful for using a custom script or different command other than the default `karmaTestExplorer.testTriggerMethod` | Experimental. Specifies how test runs are triggered by default, either through the Karma CLI or Http interface. You will usually not need to use this setting unless working around specific issues `karmaTestExplorer.testParsingMethod` | Specifies how tests are parsed by default, either using regular expression matching or an abstract syntax tree. You will usually not need to use this setting unless working around specific issues +`karmaTestExplorer.enabledParserPlugins` | Experimental and subject to change in future releases! Specifies the parser plugins to enable for parsing test files `karmaTestExplorer.failOnStandardError` | Treats any Karma, Angular, or other testing stderr output as a failure. This can sometimes be useful for uncovering testing issues `karmaTestExplorer.testsBasePath` | The base folder containing the test files (relative to the project root path for Karma projects, or the project `root` path specified in `angular.json` for Angular workspace projects). If not specified, defaults to the longest common path of the tests discovered in the project `karmaTestExplorer.testFiles` | The path glob patterns identifying the test files (relative to the project root path) diff --git a/package-lock.json b/package-lock.json index 8e929d7..b9f0111 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "karma-test-explorer", - "version": "0.7.3", + "version": "0.7.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "karma-test-explorer", - "version": "0.7.3", + "version": "0.7.4", "license": "MIT", "dependencies": { "@babel/parser": "^7.18.8", @@ -60,7 +60,7 @@ "ts-jest": "^27.1.3", "type-fest": "^2.17.0", "typescript": "^4.6.2", - "vsce": "^2.10.0" + "vsce": "^2.10.2" }, "engines": { "vscode": "^1.63.0" @@ -7452,9 +7452,9 @@ } }, "node_modules/vsce": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/vsce/-/vsce-2.10.0.tgz", - "integrity": "sha512-b+wB3XMapEi368g64klSM6uylllZdNutseqbNY+tUoHYSy6g2NwnlWuAGKDQTYc0IqfDUjUFRQBpPgA89Q+Fyw==", + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/vsce/-/vsce-2.10.2.tgz", + "integrity": "sha512-DZdD3B7rfANNefBpyzE7g1IQkEWuJ/0KoCrimMreOYW6XKfoZSMouFNBh26Cpk9kNZsjZqxTc1/ckLouDZIw/Q==", "dev": true, "dependencies": { "azure-devops-node-api": "^11.0.1", @@ -13436,9 +13436,9 @@ "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, "vsce": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/vsce/-/vsce-2.10.0.tgz", - "integrity": "sha512-b+wB3XMapEi368g64klSM6uylllZdNutseqbNY+tUoHYSy6g2NwnlWuAGKDQTYc0IqfDUjUFRQBpPgA89Q+Fyw==", + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/vsce/-/vsce-2.10.2.tgz", + "integrity": "sha512-DZdD3B7rfANNefBpyzE7g1IQkEWuJ/0KoCrimMreOYW6XKfoZSMouFNBh26Cpk9kNZsjZqxTc1/ckLouDZIw/Q==", "dev": true, "requires": { "azure-devops-node-api": "^11.0.1", diff --git a/package.json b/package.json index 4f299f0..5d8a1c4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "icon": "docs/img/extension-icon-128.png", "author": "Lucas Ononiwu", "publisher": "lucono", - "version": "0.7.3", + "version": "0.7.4", "license": "MIT", "homepage": "https://github.com/lucono/karma-test-explorer", "repository": { @@ -96,7 +96,7 @@ "ts-jest": "^27.1.3", "type-fest": "^2.17.0", "typescript": "^4.6.2", - "vsce": "^2.10.0", + "vsce": "^2.10.2", "simple-get": "^3.1.1" }, "engines": { @@ -131,8 +131,8 @@ "default": null }, "karmaTestExplorer.projectRootPath": { - "markdownDescription": "The path to the folder containing the project for testing (relative to the workspace root folder). Defaults to the workspace root folder if not specified", - "markdownDeprecationMessage": "This is deprecated. Use `karmaTestExplorer.projects` instead", + "markdownDescription": "The path to the folder containing the project for testing (relative to the VS Code workspace root folder). Defaults to the workspace root folder if not specified", + "markdownDeprecationMessage": "This is deprecated. Use `karmaTestExplorer.projectWorkspaces` instead", "type": "string", "scope": "resource" }, @@ -176,6 +176,58 @@ "Parse test files with regular expression matching" ] }, + "karmaTestExplorer.enabledParserPlugins": { + "markdownDescription": "Experimental and subject to change in future releases! Specifies the parser plugins to enable for parsing test files", + "type": "array", + "scope": "resource", + "default": [], + "items": { + "type": "string", + "uniqueItems": true, + "enum": [ + "asyncDoExpressions", + "asyncGenerators", + "bigInt", + "classPrivateMethods", + "classPrivateProperties", + "classProperties", + "classStaticBlock", + "decimal", + "decorators", + "decorators-legacy", + "decoratorAutoAccessors", + "destructuringPrivate", + "doExpressions", + "dynamicImport", + "estree", + "exportDefaultFrom", + "flow", + "flowComments", + "functionBind", + "functionSent", + "importMeta", + "jsx", + "logicalAssignment", + "importAssertions", + "moduleBlocks", + "moduleStringNames", + "nullishCoalescingOperator", + "numericSeparator", + "objectRestSpread", + "optionalCatchBinding", + "optionalChaining", + "partialApplication", + "pipelineOperator", + "placeholders", + "privateIn", + "regexpUnicodeSets", + "throwExpressions", + "topLevelAwait", + "typescript", + "v8intrinsic" + ] + } + }, "karmaTestExplorer.nonHeadlessModeEnabled": { "markdownDescription": "Enables non-headless testing so that the browser UI is displayed when running tests. Has no effect when running in a container, or when the default value of the `customLauncher` or `browser` config settings are overridden", "type": "boolean", @@ -226,7 +278,7 @@ "default": 9976 }, "karmaTestExplorer.karmaConfFilePath": { - "markdownDescription": "The path where the `karma.conf.js` file is located (relative to the project root path)", + "markdownDescription": "The path where the `karma.conf.js` file is located (relative to the project workspace root path)", "type": "string", "scope": "resource", "default": "karma.conf.js" @@ -246,16 +298,17 @@ ] }, "karmaTestExplorer.testsBasePath": { - "markdownDescription": "The base folder containing the test files (relative to the project root path for Karma projects, or the project `root` path specified in `angular.json` for Angular workspace projects). If not specified, defaults to the longest common path of the tests discovered in the project", + "markdownDescription": "The base folder containing the test files (relative to the project workspace root path for Karma projects, or the project `root` path specified in `angular.json` for Angular workspace projects). If not specified, defaults to the longest common path of the tests discovered in the project", "type": "string", "scope": "resource", "default": null }, "karmaTestExplorer.testFiles": { - "markdownDescription": "The path glob patterns identifying the test files (relative to the project root path)", + "markdownDescription": "The path glob patterns identifying the test files (relative to the project workspace root path)", "type": "array", "items": { - "type": "string" + "type": "string", + "uniqueItems": true }, "scope": "resource", "minItems": 1, @@ -265,10 +318,11 @@ ] }, "karmaTestExplorer.excludeFiles": { - "markdownDescription": "The path glob patterns identifying files to be excluded from `testFiles` (relative to the project root path). The `node_modules` folder is always excluded", + "markdownDescription": "The path glob patterns identifying files to be excluded from `testFiles` (relative to the project workspace root path). The `node_modules` folder is always excluded", "type": "array", "items": { - "type": "string" + "type": "string", + "uniqueItems": true }, "scope": "resource", "default": [] @@ -277,7 +331,8 @@ "markdownDescription": "A list of files which when modified will trigger a Karma reload", "type": "array", "items": { - "type": "string" + "type": "string", + "uniqueItems": true }, "scope": "resource", "default": [] @@ -508,11 +563,20 @@ "default": "" }, "karmaTestExplorer.projects": { - "markdownDescription": "This is a list, each entry of which is either a string of the relative path (relative to the workspace root folder) to a project for testing, or an object having a `projectRootPath` property with the value of that path. The object format also accepts the following optional properties which can be used to provide the corresponding settings specifically for that project - `projectType`, `karmaConfFilePath`, `testFramework`, `testFiles`, `excludeFiles`, `testsBasePath`. Like other Karma Test Explorer settings, when not explicitly configured, most settings will be auto-detected where possible with reasonable values for each project", + "markdownDescription": "This setting has been renamed to `karmaTestExplorer.projectWorkspaces` to avoid confusion with Angular workspace projects. Please update your setting to use the new name.", + "deprecationMessage": "Rename this setting to `karmaTestExplorer.projectWorkspaces`", + "markdownDeprecationMessage": "Rename this setting to `karmaTestExplorer.projectWorkspaces`", + "type": "array", + "scope": "resource", + "default": null + }, + "karmaTestExplorer.projectWorkspaces": { + "markdownDescription": "Experimental and subject to change in future releases! This is a list, each entry of which is either a string of the relative path (relative to the VS Code workspace root folder) to a project workspace for testing, or an object having a `rootPath` property with the value of that path. The object format also accepts the following optional properties which can be used to provide the corresponding settings specifically for that project workspace - `projectType`, `karmaConfFilePath`, `testFramework`, `testFiles`, `excludeFiles`, `testsBasePath`. Like other Karma Test Explorer settings, when not explicitly configured, most settings will be auto-detected where possible with reasonable values for each project workspace", "type": "array", "scope": "resource", "default": [], "items": { + "uniqueItems": true, "oneOf": [ { "type": "string" @@ -520,11 +584,18 @@ { "type": "object", "required": [ - "projectRootPath" + "rootPath" ], "properties": { "projectRootPath": { - "markdownDescription": "The path to the folder containing the project for testing (relative to the workspace root folder)", + "markdownDescription": "This setting has been renamed to `rootPath`. Please update your setting to use the new name.", + "deprecationMessage": "Rename this setting to `rootPath`", + "markdownDeprecationMessage": "Rename this setting to `rootPath`", + "type": "string", + "scope": "resource" + }, + "rootPath": { + "markdownDescription": "The path to the folder containing the project worspace for testing (relative to the VS Code workspace root folder)", "type": "string", "scope": "resource" }, @@ -542,7 +613,7 @@ ] }, "karmaConfFilePath": { - "markdownDescription": "The path where the `karma.conf.js` file is located (relative to the project root path)", + "markdownDescription": "The path where the `karma.conf.js` file is located (relative to the project workspace root path)", "type": "string", "scope": "resource" }, @@ -562,24 +633,26 @@ ] }, "testFiles": { - "markdownDescription": "The path glob patterns identifying the test files (relative to the project root path)", + "markdownDescription": "The path glob patterns identifying the test files (relative to the project workspace root path)", "type": "array", "items": { - "type": "string" + "type": "string", + "uniqueItems": true }, "scope": "resource", "minItems": 1 }, "excludeFiles": { - "markdownDescription": "The path glob patterns identifying files to be excluded from `testFiles` (relative to the project root path). The `node_modules` folder is always excluded", + "markdownDescription": "The path glob patterns identifying files to be excluded from `testFiles` (relative to the project workspace root path). The `node_modules` folder is always excluded", "type": "array", "items": { - "type": "string" + "type": "string", + "uniqueItems": true }, "scope": "resource" }, "testsBasePath": { - "markdownDescription": "The base folder containing the test files (relative to the project root path for Karma projects, or the project `root` path specified in `angular.json` for Angular workspace projects). If not specified, defaults to the longest common path of the tests discovered in the project", + "markdownDescription": "The base folder containing the test files (relative to the project workspace root path for Karma projects, or the project `root` path specified in `angular.json` for Angular workspace projects). If not specified, defaults to the longest common path of the tests discovered in the project", "type": "string", "scope": "resource" } diff --git a/src/adapter.ts b/src/adapter.ts index cd0c44f..dd649ab 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -11,7 +11,7 @@ import { } from 'vscode-test-adapter-api'; import { EXTENSION_CONFIG_PREFIX, EXTENSION_OUTPUT_CHANNEL_NAME, KARMA_SERVER_OUTPUT_CHANNEL_NAME } from './constants'; import { TestLoadEvent, TestResultEvent, TestRunEvent } from './core/base/test-events'; -import { GeneralConfigSetting, ProjectConfigSetting } from './core/config/config-setting'; +import { GeneralConfigSetting, InternalConfigSetting, ProjectConfigSetting } from './core/config/config-setting'; import { ConfigStore } from './core/config/config-store'; import { ExtensionConfig } from './core/config/extension-config'; import { Debugger } from './core/debugger'; @@ -25,18 +25,23 @@ import { OutputChannelLog } from './core/vscode/output-channel-log'; import { KarmaLogLevel } from './frameworks/karma/karma-log-level'; import { Disposable } from './util/disposable/disposable'; import { Disposer } from './util/disposable/disposer'; +import { FileHandler } from './util/filesystem/file-handler'; +import { SimpleFileHandler } from './util/filesystem/simple-file-handler'; import { LogLevel } from './util/logging/log-level'; +import { Logger } from './util/logging/logger'; import { SimpleLogger } from './util/logging/simple-logger'; import { PortAcquisitionClient } from './util/port/port-acquisition-client'; import { PortAcquisitionManager } from './util/port/port-acquisition-manager'; import { getJsonCircularReferenceReplacer } from './util/utils'; export class Adapter implements TestAdapter, Disposable { + private readonly logLevel: LogLevel; private readonly outputChannelLog: OutputChannelLog; private readonly testServerLog: OutputChannelLog; - private readonly logger: SimpleLogger; + private readonly logger: Logger; private readonly config: ExtensionConfig; private readonly portAcquisitionClient: PortAcquisitionClient; + private readonly fileHandler: FileHandler; private readonly debugger: Debugger; private readonly projectCommands: Commands; private readonly notificationHandler: NotificationHandler; @@ -56,21 +61,23 @@ export class Adapter implements TestAdapter, Disposable { portAcquisitionManager: PortAcquisitionManager, projectStatusDisplay: StatusDisplay ) { + this.logLevel = configStore.get(GeneralConfigSetting.LogLevel); this.outputChannelLog = new OutputChannelLog(`${EXTENSION_OUTPUT_CHANNEL_NAME} (${this.projectNamespace})`); this.disposables.push(this.outputChannelLog); + this.logger = this.createLogger(Adapter.name); + + this.fileHandler = new SimpleFileHandler(this.createLogger(SimpleFileHandler.name), { + cwd: configStore.get(InternalConfigSetting.ProjectPath) + }); + this.config = new ExtensionConfig( configStore, this.workspaceFolder.uri.path, - new SimpleLogger( - this.outputChannelLog, - ExtensionConfig.name, - configStore.get(GeneralConfigSetting.LogLevel) - ) + this.fileHandler, + this.createLogger(ExtensionConfig.name) ); - this.logger = new SimpleLogger(this.outputChannelLog, Adapter.name, this.config.logLevel); - this.logger.debug(() => 'Creating server output channel'); const serverOutputChannelName = `${KARMA_SERVER_OUTPUT_CHANNEL_NAME} (${this.projectNamespace})`; this.testServerLog = new OutputChannelLog(serverOutputChannelName, { @@ -114,11 +121,13 @@ export class Adapter implements TestAdapter, Disposable { this.testRunEmitter = new EventEmitter(); this.retireEmitter = new EventEmitter(); this.disposables.push(this.testLoadEmitter, this.testRunEmitter, this.retireEmitter); + + this.logger.debug(() => 'Creating initial test explorer'); this.karmaTestExplorer = this.createTestExplorer(); } private createTestExplorer(): KarmaTestExplorer { - this.logger.debug(() => 'Creating new test explorer'); + this.logger.debug(() => 'Assembling new test explorer'); const testExplorerDisposables: Disposable[] = []; this.logger.debug( @@ -135,6 +144,7 @@ export class Adapter implements TestAdapter, Disposable { this.config, this.debugger, this.portAcquisitionClient, + this.fileHandler, this.projectCommands, this.notificationHandler, this.testLoadEmitter, @@ -187,7 +197,7 @@ export class Adapter implements TestAdapter, Disposable { } private createLogger(loggerName: string): SimpleLogger { - return new SimpleLogger(this.logger, loggerName); + return new SimpleLogger(this.outputChannelLog, loggerName, this.logLevel); } private async reset(): Promise { diff --git a/src/core/config/config-helper.ts b/src/core/config/config-helper.ts index 91c3d8d..2432be9 100644 --- a/src/core/config/config-helper.ts +++ b/src/core/config/config-helper.ts @@ -1,5 +1,4 @@ import { parse as parseDotEnvContent } from 'dotenv'; -import { readFileSync } from 'fs'; import isDocker from 'is-docker'; import { CustomLauncher } from 'karma'; import { resolve } from 'path'; @@ -10,6 +9,7 @@ import { KARMA_BROWSER_CONTAINER_HEADLESS_FLAGS, KARMA_BROWSER_CONTAINER_NO_SANDBOX_FLAG } from '../../constants'; +import { FileHandler } from '../../util/filesystem/file-handler'; import { Logger } from '../../util/logging/logger'; import { asNonBlankStringOrUndefined, expandEnvironment, normalizePath, transformProperties } from '../../util/utils'; import { GeneralConfigSetting, ProjectConfigSetting } from './config-setting'; @@ -136,6 +136,7 @@ export const getTestsBasePath = ( export const getCombinedEnvironment = ( projectRootPath: string, config: ConfigStore, + fileHandler: FileHandler, logger: Logger ): Record => { const envMap: Record = config.get(GeneralConfigSetting.Env) ?? {}; @@ -149,7 +150,7 @@ export const getCombinedEnvironment = ( logger.info(() => `Reading environment from file: ${envFile}`); try { - const envFileContent: Buffer = readFileSync(envFile!); + const envFileContent = fileHandler.readFileSync(envFile); if (!envFileContent) { throw new Error(`Failed to read configured environment file: ${envFile}`); diff --git a/src/core/config/config-setting.ts b/src/core/config/config-setting.ts index 6b55df2..8525426 100644 --- a/src/core/config/config-setting.ts +++ b/src/core/config/config-setting.ts @@ -12,7 +12,8 @@ export enum InternalConfigSetting { export enum ExternalConfigSetting { EnableExtension = 'enableExtension', ProjectType = 'projectType', - Projects = 'projects', + ProjectWorkspaces = 'projectWorkspaces', + RootPath = 'rootPath', ProjectRootPath = 'projectRootPath', // FIXME: Deprecated - remove KarmaConfFilePath = 'karmaConfFilePath' } @@ -23,6 +24,7 @@ export enum GeneralConfigSetting { AngularProcessCommand = 'angularProcessCommand', TestTriggerMethod = 'testTriggerMethod', TestParsingMethod = 'testParsingMethod', + EnabledParserPlugins = 'enabledParserPlugins', Browser = 'browser', CustomLauncher = 'customLauncher', NonHeadlessModeEnabled = 'nonHeadlessModeEnabled', diff --git a/src/core/config/config-store.ts b/src/core/config/config-store.ts index e0c8109..5ed65fd 100644 --- a/src/core/config/config-store.ts +++ b/src/core/config/config-store.ts @@ -6,4 +6,6 @@ export interface ConfigStore { export interface MutableConfigStore extends ConfigStore { set(key: K, value: any): void; + delete(key: K): void; + clear(): void; } diff --git a/src/core/config/extension-config.ts b/src/core/config/extension-config.ts index 65e24a5..772464f 100644 --- a/src/core/config/extension-config.ts +++ b/src/core/config/extension-config.ts @@ -1,3 +1,4 @@ +import { ParserPlugin } from '@babel/parser'; import { CustomLauncher } from 'karma'; import { resolve } from 'path'; import { DebugConfiguration } from 'vscode'; @@ -5,6 +6,7 @@ import { ALWAYS_EXCLUDED_TEST_FILE_GLOBS } from '../../constants'; import { KarmaLogLevel } from '../../frameworks/karma/karma-log-level'; import { Disposable } from '../../util/disposable/disposable'; import { Disposer } from '../../util/disposable/disposer'; +import { FileHandler } from '../../util/filesystem/file-handler'; import { LogLevel } from '../../util/logging/log-level'; import { Logger } from '../../util/logging/logger'; import { asNonBlankStringOrUndefined, normalizePath, toSingleUniqueArray } from '../../util/utils'; @@ -80,10 +82,12 @@ export class ExtensionConfig implements Disposable { public readonly showOnlyFocusedTests: boolean; public readonly showTestDefinitionTypeIndicators: boolean; public readonly showUnmappedTests: boolean; + public readonly enabledParserPlugins: readonly ParserPlugin[]; public constructor( configStore: ConfigStore, workspacePath: string, + fileHandler: FileHandler, private readonly logger: Logger ) { const normalizedWorkspacePath = normalizePath(workspacePath); @@ -104,6 +108,7 @@ export class ExtensionConfig implements Disposable { ); this.testTriggerMethod = configStore.get(GeneralConfigSetting.TestTriggerMethod); this.testParsingMethod = configStore.get(GeneralConfigSetting.TestParsingMethod); + this.enabledParserPlugins = configStore.get(GeneralConfigSetting.EnabledParserPlugins); this.failOnStandardError = !!configStore.get(GeneralConfigSetting.FailOnStandardError); this.testsBasePath = getTestsBasePath(this.projectPath, configStore); this.defaultSocketConnectionPort = configStore.get(GeneralConfigSetting.DefaultSocketConnectionPort)!; @@ -115,7 +120,7 @@ export class ExtensionConfig implements Disposable { this.karmaReadyTimeout = configStore.get(GeneralConfigSetting.KarmaReadyTimeout)!; this.testGrouping = configStore.get(GeneralConfigSetting.TestGrouping)!; this.flattenSingleChildFolders = !!configStore.get(GeneralConfigSetting.FlattenSingleChildFolders); - this.environment = getCombinedEnvironment(this.projectPath, configStore, logger); + this.environment = getCombinedEnvironment(this.projectPath, configStore, fileHandler, logger); this.testFramework = configStore.get(GeneralConfigSetting.TestFramework); this.reloadOnKarmaConfigChange = !!configStore.get(GeneralConfigSetting.ReloadOnKarmaConfigChange); this.customLauncher = getCustomLauncher(configStore); diff --git a/src/core/config/layered-config-store.ts b/src/core/config/layered-config-store.ts index bfa298a..6f37cfc 100644 --- a/src/core/config/layered-config-store.ts +++ b/src/core/config/layered-config-store.ts @@ -1,18 +1,28 @@ import { ConfigStore } from './config-store'; +export interface LayeredConfigStoreOptions { + valuesConsideredAbsent?: any[]; +} + export class LayeredConfigStore implements ConfigStore { private readonly layeredConfigs: ConfigStore[]; + private readonly valuesConsideredAbsent: any[]; - public constructor(...layeredConfigs: (ConfigStore | undefined)[]) { + public constructor(layeredConfigs: (ConfigStore | undefined)[], options: LayeredConfigStoreOptions = {}) { this.layeredConfigs = layeredConfigs.filter(store => store !== undefined).reverse() as ConfigStore[]; + this.valuesConsideredAbsent = options.valuesConsideredAbsent ?? []; } public get(key: K): T { - return this.layeredConfigs.find(config => config.has(key))?.get(key) as T; + return this.layeredConfigs + .find(config => config.has(key) && !this.valuesConsideredAbsent.includes(config.get(key))) + ?.get(key) as T; } public has(key: K): boolean { - return this.layeredConfigs.some(config => config.has(key)); + return this.layeredConfigs.some( + config => config.has(key) && !this.valuesConsideredAbsent.includes(config.get(key)) + ); } public inspect(key: K): { defaultValue?: T | undefined } | undefined { diff --git a/src/core/config/project-specific-config.ts b/src/core/config/project-specific-config.ts index 15a3b18..f4bf670 100644 --- a/src/core/config/project-specific-config.ts +++ b/src/core/config/project-specific-config.ts @@ -2,6 +2,7 @@ import { ExternalConfigSetting, GeneralConfigSetting } from './config-setting'; export type ProjectSpecificConfigSetting = | ExternalConfigSetting.ProjectRootPath + | ExternalConfigSetting.RootPath | ExternalConfigSetting.ProjectType | ExternalConfigSetting.KarmaConfFilePath | GeneralConfigSetting.TestFramework @@ -9,6 +10,6 @@ export type ProjectSpecificConfigSetting = | GeneralConfigSetting.ExcludeFiles | GeneralConfigSetting.TestsBasePath; -export type ProjectSpecificConfig = { [key in ExternalConfigSetting.ProjectRootPath]: string } & { - [key in Exclude]?: unknown; +export type ProjectSpecificConfig = { [key in ExternalConfigSetting.RootPath]: string } & { + [key in Exclude]?: unknown; }; diff --git a/src/core/config/simple-mutable-config-store.ts b/src/core/config/simple-mutable-config-store.ts index 46cc496..558eef3 100644 --- a/src/core/config/simple-mutable-config-store.ts +++ b/src/core/config/simple-mutable-config-store.ts @@ -1,34 +1,42 @@ -import { ConfigStore } from './config-store'; +import { MutableConfigStore } from './config-store'; -export class SimpleMutableConfigStore implements ConfigStore { - private configOverrides: Map = new Map(); +export class SimpleMutableConfigStore implements MutableConfigStore { + private configEntries: Map = new Map(); private readonly configPrefix: string; - public constructor(configPrefix?: string, configEntries?: Partial>) { + public constructor(configPrefix?: string, initialEntries?: Partial>) { this.configPrefix = configPrefix ? `${configPrefix}.` : ''; - if (configEntries) { - this.setAll(configEntries); + if (initialEntries) { + this.setMultiple(initialEntries); } } - public set(key: K, value: any): void { - this.configOverrides.set(this.toPrefixedKey(key), value); - } - - public setAll(overrides: Partial>): void { + private setMultiple(overrides: Partial>): void { for (const key in overrides) { const value = overrides[key]; this.set(key, value); } } + public set(key: K, value: any): void { + this.configEntries.set(this.toPrefixedKey(key), value); + } + public get(key: K): T { - return this.configOverrides.get(this.toPrefixedKey(key)) as T; + return this.configEntries.get(this.toPrefixedKey(key)) as T; } public has(key: K): boolean { - return this.configOverrides.has(this.toPrefixedKey(key)); + return this.configEntries.has(this.toPrefixedKey(key)); + } + + public delete(key: K): void { + this.configEntries.delete(this.toPrefixedKey(key)); + } + + public clear(): void { + [...this.configEntries.keys()].forEach(key => this.configEntries.delete(key)); } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/core/config/workspace-folder-config-resolver.ts b/src/core/config/workspace-folder-config-resolver.ts new file mode 100644 index 0000000..116ae4d --- /dev/null +++ b/src/core/config/workspace-folder-config-resolver.ts @@ -0,0 +1,6 @@ +import { WorkspaceFolder } from 'vscode'; +import { ConfigStore } from './config-store'; + +export interface WorkspaceFolderConfigResolver { + resolveConfig(workspaceFolder: WorkspaceFolder): ConfigStore; +} diff --git a/src/core/default-test-manager.ts b/src/core/default-test-manager.ts index 26aa1dd..d5f5e72 100644 --- a/src/core/default-test-manager.ts +++ b/src/core/default-test-manager.ts @@ -229,7 +229,7 @@ export class DefaultTestManager implements TestManager { return this.currentServerStartInfo; } catch (error: any) { this.logger.error(() => `${error}`); - await this.stop(); + this.stop(); throw error; } finally { this.actionIsRunning = testActionWasRunning; diff --git a/src/core/main-factory.ts b/src/core/main-factory.ts index a3200f0..9375a0a 100644 --- a/src/core/main-factory.ts +++ b/src/core/main-factory.ts @@ -20,7 +20,7 @@ import { KarmaServerProcessLog } from '../frameworks/karma/server/karma-server-p import { MochaTestFrameworkBdd, MochaTestFrameworkTdd } from '../frameworks/mocha/mocha-test-framework'; import { Disposable } from '../util/disposable/disposable'; import { Disposer } from '../util/disposable/disposer'; -import { FileHandler } from '../util/file-handler'; +import { FileHandler } from '../util/filesystem/file-handler'; import { LogAppender } from '../util/logging/log-appender'; import { SimpleLogger } from '../util/logging/simple-logger'; import { PortAcquisitionClient } from '../util/port/port-acquisition-client'; @@ -61,7 +61,6 @@ export class MainFactory { private readonly disposables: Disposable[] = []; private readonly testFramework: TestFramework; private readonly processHandler: ProcessHandler; - private readonly fileHandler: FileHandler; private readonly testLocator: TestLocator; private readonly testHelper: TestHelper; private readonly testStore: TestStore; @@ -73,6 +72,7 @@ export class MainFactory { private readonly config: ExtensionConfig, private readonly testDebugger: Debugger, private readonly portAcquisitionClient: PortAcquisitionClient, + private readonly fileHandler: FileHandler, private readonly projectCommands: Commands, private readonly notificationHandler: NotificationHandler, private readonly testLoadEventEmitter: EventEmitter, @@ -84,9 +84,6 @@ export class MainFactory { ) { this.disposables.push(logger); - this.fileHandler = new FileHandler(this.createLogger(FileHandler.name), { - cwd: this.config.projectPath - }); const karmaConfigPath = this.config.projectKarmaConfigFilePath; const configuredTestFramework: TestFramework | undefined = @@ -326,7 +323,8 @@ export class MainFactory { const testFileParser: AstTestFileParser = new AstTestFileParser( sourceNodeProcessors, - this.createLogger(AstTestFileParser.name) + this.createLogger(AstTestFileParser.name), + { enabledParserPlugins: this.config.enabledParserPlugins } ); const testDefinitionProvider = new AstTestDefinitionProvider( @@ -343,6 +341,7 @@ export class MainFactory { projectKarmaConfigFilePath: this.config.projectKarmaConfigFilePath, autoWatchEnabled: watchModeEnabled, autoWatchBatchDelay: this.config.autoWatchBatchDelay, + logLevel: this.config.logLevel, karmaLogLevel: this.config.karmaLogLevel, karmaReporterLogLevel: this.config.karmaReporterLogLevel, customLauncher: this.config.customLauncher, @@ -372,6 +371,7 @@ export class MainFactory { projectKarmaConfigFilePath: this.config.projectKarmaConfigFilePath, autoWatchEnabled: watchModeEnabled, autoWatchBatchDelay: this.config.autoWatchBatchDelay, + logLevel: this.config.logLevel, karmaLogLevel: this.config.karmaLogLevel, karmaReporterLogLevel: this.config.karmaReporterLogLevel, customLauncher: this.config.customLauncher, @@ -417,8 +417,8 @@ export class MainFactory { suiteTestResultProcessor, this.testLocator, testResolver, - this.fileHandler, this.testHelper, + this.config.projectPath, this.createLogger(KarmaTestEventProcessor.name) ); @@ -433,8 +433,8 @@ export class MainFactory { suiteTestResultProcessor, this.testLocator, testResolver, - this.fileHandler, this.testHelper, + this.config.projectPath, this.createLogger(`${KarmaAutoWatchTestEventProcessor.name}::${KarmaTestEventProcessor.name}`) ); diff --git a/src/core/parser/ast/ast-test-file-parser.ts b/src/core/parser/ast/ast-test-file-parser.ts index 9d08868..4ea407c 100644 --- a/src/core/parser/ast/ast-test-file-parser.ts +++ b/src/core/parser/ast/ast-test-file-parser.ts @@ -1,5 +1,5 @@ import { Node } from '@babel/core'; -import { parse, ParserOptions } from '@babel/parser'; +import { parse, ParserOptions, ParserPlugin } from '@babel/parser'; import { Disposable } from '../../../util/disposable/disposable'; import { Disposer } from '../../../util/disposable/disposer'; import { Logger } from '../../../util/logging/logger'; @@ -10,7 +10,7 @@ import { TestFileParser } from '../test-file-parser'; import { DescribedTestDefinition, DescribedTestDefinitionInfo } from './described-test-definition'; import { ProcessedSourceNode, SourceNodeProcessor } from './source-node-processor'; -const PARSER_OPTIONS: ParserOptions = { +const DEFAULT_PARSER_OPTIONS: ParserOptions = { errorRecovery: true, allowAwaitOutsideFunction: true, allowImportExportEverywhere: true, @@ -23,15 +23,32 @@ const PARSER_OPTIONS: ParserOptions = { sourceType: 'unambiguous', strictMode: false, tokens: false, - startLine: 0, - plugins: ['typescript', 'jsx'] + startLine: 0 }; +const DEFAULT_PARSER_PLUGINS: ParserPlugin[] = ['typescript', 'jsx', ['decorators', { decoratorsBeforeExport: false }]]; + +export interface AstTestFileParserOptions { + readonly enabledParserPlugins?: readonly ParserPlugin[]; +} + export class AstTestFileParser implements TestFileParser { private readonly disposables: Disposable[] = []; private readonly nodeProcessors: SourceNodeProcessor[]; + private readonly parserOptions: ParserOptions; + + public constructor( + nodeProcessors: SourceNodeProcessor[], + private readonly logger: Logger, + options: AstTestFileParserOptions = {} + ) { + const enabledParserPlugins = options.enabledParserPlugins?.length + ? options.enabledParserPlugins + : DEFAULT_PARSER_PLUGINS; + + const uniqueParserPlugins = new Set(enabledParserPlugins); - public constructor(nodeProcessors: SourceNodeProcessor[], private readonly logger: Logger) { + this.parserOptions = { ...DEFAULT_PARSER_OPTIONS, plugins: [...uniqueParserPlugins] }; this.disposables.push(logger); this.nodeProcessors = [...nodeProcessors]; } @@ -41,7 +58,7 @@ export class AstTestFileParser implements TestFileParser `Parse operation ${parseId}: Parsing file '${filePath}' having content: \n${fileText}`); const startTime = new Date(); - const parsedFile = parse(fileText, PARSER_OPTIONS); + const parsedFile = parse(fileText, this.parserOptions); if (parsedFile.errors.length > 0) { const errorMessages = parsedFile.errors.map(error => `--> ${error.code} - ${error.reasonCode}`); diff --git a/src/core/test-locator.ts b/src/core/test-locator.ts index 9815256..5ce4e17 100644 --- a/src/core/test-locator.ts +++ b/src/core/test-locator.ts @@ -3,7 +3,7 @@ import { isMatch } from 'micromatch'; import { join, resolve } from 'path'; import { Disposable } from '../util/disposable/disposable'; import { Disposer } from '../util/disposable/disposer'; -import { FileHandler } from '../util/file-handler'; +import { FileHandler } from '../util/filesystem/file-handler'; import { DeferredPromise } from '../util/future/deferred-promise'; import { Logger } from '../util/logging/logger'; import { isChildPath, normalizePath } from '../util/utils'; diff --git a/src/core/vscode/preferences/preferences.ts b/src/core/vscode/preferences/preferences.ts index 75f5046..bdd8e9d 100644 --- a/src/core/vscode/preferences/preferences.ts +++ b/src/core/vscode/preferences/preferences.ts @@ -38,7 +38,7 @@ export class Preferences implements Disposable { () => `Updated preference '${pref}' ` + `${setGlobal ? 'globally' : 'in current workspace'} ` + - `from: ${JSON.stringify(previousValue, null, 2)}` + + `from: ${JSON.stringify(previousValue, null, 2)} ` + `to: ${JSON.stringify(value, null, 2)}` ); } diff --git a/src/frameworks/angular/angular-factory.ts b/src/frameworks/angular/angular-factory.ts index 8115036..03b2663 100644 --- a/src/frameworks/angular/angular-factory.ts +++ b/src/frameworks/angular/angular-factory.ts @@ -22,6 +22,7 @@ export type AngularFactoryConfig = Pick< | 'environment' | 'failOnStandardError' | 'allowGlobalPackageFallback' + | 'logLevel' | 'karmaLogLevel' | 'karmaReporterLogLevel' | 'projectPath' @@ -57,6 +58,7 @@ export class AngularFactory implements Partial { [KarmaEnvironmentVariable.AutoWatchBatchDelay]: `${this.config.autoWatchBatchDelay ?? ''}`, [KarmaEnvironmentVariable.Browser]: this.config.browser ?? '', [KarmaEnvironmentVariable.CustomLauncher]: JSON.stringify(this.config.customLauncher), + [KarmaEnvironmentVariable.ExtensionLogLevel]: `${this.config.logLevel}`, [KarmaEnvironmentVariable.KarmaLogLevel]: `${this.config.karmaLogLevel}`, [KarmaEnvironmentVariable.KarmaReporterLogLevel]: `${this.config.karmaReporterLogLevel}` }; diff --git a/src/frameworks/angular/angular-test-server-executor.ts b/src/frameworks/angular/angular-test-server-executor.ts index 4d68ada..dee0502 100644 --- a/src/frameworks/angular/angular-test-server-executor.ts +++ b/src/frameworks/angular/angular-test-server-executor.ts @@ -1,6 +1,6 @@ import { join } from 'path'; import { TestServerExecutor } from '../../api/test-server-executor'; -import { EXTENSION_CONFIG_PREFIX } from '../../constants'; +import { EXTENSION_CONFIG_PREFIX, EXTENSION_NAME } from '../../constants'; import { ExternalConfigSetting } from '../../core/config/config-setting'; import { Disposable } from '../../util/disposable/disposable'; import { Disposer } from '../../util/disposable/disposer'; @@ -80,7 +80,7 @@ export class AngularTestServerExecutor implements TestServerExecutor { `Angular CLI does not seem to be installed. You may ` + `need to install your project dependencies or ` + `specify the right path to your project using the ` + - `${EXTENSION_CONFIG_PREFIX}.${ExternalConfigSetting.Projects} ` + + `${EXTENSION_CONFIG_PREFIX}.${ExternalConfigSetting.ProjectWorkspaces} ` + `setting.` ); } @@ -98,12 +98,21 @@ export class AngularTestServerExecutor implements TestServerExecutor { '--no-watch' ]; + this.logKarmaLaunch(); const angularProcess = this.processHandler.spawn(command, processArguments, runOptions); this.disposables.push(angularProcess); return angularProcess; } + private logKarmaLaunch() { + const launchMessage = + `------------------------------------\n` + + `${EXTENSION_NAME}: Launching Karma\n` + + `------------------------------------\n`; + this.options.serverProcessLog?.output(() => launchMessage); + } + public async dispose() { await Disposer.dispose(this.disposables); } diff --git a/src/frameworks/angular/angular-util.ts b/src/frameworks/angular/angular-util.ts index f66c713..504a436 100644 --- a/src/frameworks/angular/angular-util.ts +++ b/src/frameworks/angular/angular-util.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync } from 'fs'; -import { posix } from 'path'; +import { join } from 'path'; +import { FileHandler } from '../../util/filesystem/file-handler'; import { Logger } from '../../util/logging/logger'; import { normalizePath } from '../../util/utils'; import { AngularProjectInfo } from './angular-project-info'; @@ -7,33 +7,37 @@ import { AngularWorkspaceInfo } from './angular-workspace-info'; export const getAngularWorkspaceInfo = ( angularConfigRootPath: string, + fileHandler: FileHandler, logger: Logger ): AngularWorkspaceInfo | undefined => { return ( - getAngularJsonWorkspaceInfo(angularConfigRootPath, logger) ?? - getAngularCliJsonWorkspaceInfo(angularConfigRootPath, logger) + getAngularJsonWorkspaceInfo(angularConfigRootPath, fileHandler, logger) ?? + getAngularCliJsonWorkspaceInfo(angularConfigRootPath, fileHandler, logger) ); }; const getAngularJsonWorkspaceInfo = ( angularConfigRootPath: string, + fileHandler: FileHandler, logger: Logger ): AngularWorkspaceInfo | undefined => { - const angularJsonConfigPath = normalizePath(posix.join(angularConfigRootPath, 'angular.json')); + const angularJsonConfigPath = normalizePath(join(angularConfigRootPath, 'angular.json')); - if (!existsSync(angularJsonConfigPath)) { + if (!fileHandler.existsSync(angularJsonConfigPath)) { logger.debug(() => `Cannot get Angular projects - Angular Json file does not exist: ${angularJsonConfigPath}`); return undefined; } let angularJson: any; + try { - angularJson = JSON.parse(readFileSync(angularJsonConfigPath, 'utf-8')); - } catch (e) { - // do nothing + const angularJsonContent = fileHandler.readFileSync(angularJsonConfigPath, 'utf-8'); + angularJson = angularJsonContent ? JSON.parse(angularJsonContent) : undefined; + } catch (error) { + // No action required } if (!angularJson) { - logger.debug(() => `Cannot get Angular projects - Failed to read Angular Json file: ${angularJsonConfigPath}`); + logger.warn(() => `Cannot get Angular projects - Failed to read Angular Json file: ${angularJsonConfigPath}`); return undefined; } const defaultProjectName: string = angularJson.defaultProject; @@ -46,9 +50,10 @@ const getAngularJsonWorkspaceInfo = ( if (projectConfig.architect.test === undefined || projectConfig.architect.test.options.karmaConfig === undefined) { continue; } - const projectPath = normalizePath(posix.join(angularConfigRootPath, projectConfig.root)); + const projectPath = normalizePath(join(angularConfigRootPath, projectConfig.root)); + const karmaConfigPath = normalizePath( - posix.join(angularConfigRootPath, projectConfig.architect.test.options.karmaConfig) + join(angularConfigRootPath, projectConfig.architect?.test?.options?.karmaConfig) ); const project: AngularProjectInfo = { @@ -77,21 +82,24 @@ const getAngularJsonWorkspaceInfo = ( const getAngularCliJsonWorkspaceInfo = ( angularConfigRootPath: string, + fileHandler: FileHandler, logger: Logger ): AngularWorkspaceInfo | undefined => { - const angularCliJsonConfigPath = normalizePath(posix.join(angularConfigRootPath, '.angular-cli.json')); + const angularCliJsonConfigPath = normalizePath(join(angularConfigRootPath, '.angular-cli.json')); - if (!existsSync(angularCliJsonConfigPath)) { + if (!fileHandler.existsSync(angularCliJsonConfigPath)) { logger.debug( () => `Cannot get Angular projects - Angular CLI Json file does not exist: ${angularCliJsonConfigPath}` ); return undefined; } let angularCliJson: any; + try { - angularCliJson = JSON.parse(readFileSync(angularCliJsonConfigPath, 'utf-8')); - } catch (e) { - // do nothing + const angularCliJsonContent = fileHandler.readFileSync(angularCliJsonConfigPath, 'utf-8'); + angularCliJson = angularCliJsonContent ? JSON.parse(angularCliJsonContent) : undefined; + } catch (error) { + // No action required } if (!angularCliJson) { @@ -104,10 +112,11 @@ const getAngularCliJsonWorkspaceInfo = ( const projects: AngularProjectInfo[] = []; let defaultProject: AngularProjectInfo | undefined; + const karmaConfigPath = normalizePath(join(angularConfigRootPath, angularCliJson.test?.karma?.config)); + for (const app of angularCliJson.apps) { const projectName: string = app.name || angularCliJson.project.name; - const projectPath = normalizePath(posix.join(angularConfigRootPath, app.root)); - const karmaConfigPath = normalizePath(posix.join(angularConfigRootPath, angularCliJson.test.karma.config)); + const projectPath = normalizePath(join(angularConfigRootPath, app.root)); const project: AngularProjectInfo = { name: projectName, diff --git a/src/frameworks/karma/config/karma-config-loader.ts b/src/frameworks/karma/config/karma-config-loader.ts new file mode 100644 index 0000000..c20c250 --- /dev/null +++ b/src/frameworks/karma/config/karma-config-loader.ts @@ -0,0 +1,138 @@ +import { Config as KarmaConfig, ConfigOptions as KarmaConfigOptions, CustomLauncher, InlinePluginDef } from 'karma'; +import { dirname, resolve } from 'path'; +import { + CHROME_BROWSER_DEBUGGING_PORT_FLAG, + KARMA_BROWSER_CAPTURE_MIN_TIMEOUT, + KARMA_CUSTOM_LAUNCHER_BROWSER_NAME +} from '../../../constants'; +import { Logger } from '../../../util/logging/logger'; +import { KarmaEnvironmentVariable } from '../karma-environment-variable'; +import { KarmaLogLevel } from '../karma-log-level'; +import { KarmaTestExplorerReporter } from '../reporter/karma-test-explorer-reporter'; + +export class KarmaConfigLoader { + public constructor(private readonly logger: Logger) {} + + public loadConfig(config: KarmaConfig, originalConfigPath: string) { + this.loadOriginalConfig(config, originalConfigPath); + this.applyConfigOverrides(config, originalConfigPath); + this.addReporter(config); + this.setBasePath(config, originalConfigPath); + this.disableSingleRun(config); + } + + private applyConfigOverrides(config: KarmaConfig, originalConfigPath: string) { + // -- Karma Port and LogLevel settings -- + const karmaLogLevel = process.env[KarmaEnvironmentVariable.KarmaLogLevel] ?? KarmaLogLevel.INFO; + const karmaPort = parseInt(process.env[KarmaEnvironmentVariable.KarmaPort]!, 10); + + // -- Autowatch settings -- + const autoWatchEnabled = + (process.env[KarmaEnvironmentVariable.AutoWatchEnabled] ?? 'false').toLowerCase() === 'true'; + + const configuredAutoWatchBatchDelay = parseInt(process.env[KarmaEnvironmentVariable.AutoWatchBatchDelay]!, 10); + const autoWatchBatchDelay = !autoWatchEnabled + ? 0 + : !Number.isNaN(configuredAutoWatchBatchDelay) + ? configuredAutoWatchBatchDelay + : undefined; + + // -- Browser and Custom Launcher settings -- + const requestedBrowser = process.env[KarmaEnvironmentVariable.Browser]; + + let browser: string; + let customLauncher: CustomLauncher | undefined; + + if (requestedBrowser) { + this.logger.debug(() => `Using requested karma browser: ${requestedBrowser}`); + browser = requestedBrowser; + customLauncher = undefined; + } else { + const debugPortString = process.env[KarmaEnvironmentVariable.DebugPort]; + const debugPort: number | undefined = debugPortString ? parseInt(debugPortString, 10) : undefined; + + const customLauncherString = process.env[KarmaEnvironmentVariable.CustomLauncher]!; + const customLaucherObject = customLauncherString ? JSON.parse(customLauncherString) : {}; + this.addCustomLauncherDebugPort(customLaucherObject, debugPort); + + this.logger.debug(() => `Using custom launcher: ${JSON.stringify(customLaucherObject, null, 2)}`); + browser = KARMA_CUSTOM_LAUNCHER_BROWSER_NAME; + customLauncher = customLaucherObject; + } + + // -- Update Karma config -- + config.port = karmaPort; + config.logLevel = (config as any)[`LOG_${karmaLogLevel.toUpperCase()}`]; + config.singleRun = false; + config.autoWatch = autoWatchEnabled; + config.autoWatchBatchDelay = autoWatchBatchDelay ?? config.autoWatchBatchDelay; + config.restartOnFileChange = false; + config.browsers = [browser]; + config.customLaunchers = customLauncher ? { [browser]: customLauncher } : config.customLaunchers; + config.browserNoActivityTimeout = 1000 * 60 * 60 * 24; + config.browserDisconnectTimeout = Math.max(config.browserDisconnectTimeout || 0, 30_000); + config.pingTimeout = 1000 * 60 * 60 * 24; + config.captureTimeout = Math.max(config.captureTimeout || 0, KARMA_BROWSER_CAPTURE_MIN_TIMEOUT); + config.browserSocketTimeout = 30_000; + config.processKillTimeout = 2000; + config.retryLimit = Math.max(config.retryLimit || 0, 3); + (config.client ??= {}).clearContext = false; + (config.exclude ??= []).push(originalConfigPath); + } + + private loadOriginalConfig(config: KarmaConfig, originalKarmaConfigPath: string) { + let originalKarmaConfigModule = require(originalKarmaConfigPath); // eslint-disable-line @typescript-eslint/no-var-requires + + // https://github.com/karma-runner/karma/blob/v1.7.0/lib/config.js#L364 + if (typeof originalKarmaConfigModule === 'object' && typeof originalKarmaConfigModule.default !== 'undefined') { + originalKarmaConfigModule = originalKarmaConfigModule.default; + } + originalKarmaConfigModule(config); + } + + private addReporter(config: KarmaConfig) { + const reporterName = KarmaTestExplorerReporter.name; + const karmaPlugin: InlinePluginDef = { [`reporter:${reporterName}`]: ['type', KarmaTestExplorerReporter] }; + + const plugins = Array.isArray(config.plugins) ? config.plugins : ['karma-*']; + plugins.push(karmaPlugin); + config.plugins = plugins; + + const reporters = Array.isArray(config.reporters) ? config.reporters : []; + reporters.splice(0, reporters.length, reporterName); + config.reporters = reporters; + } + + private setBasePath(config: KarmaConfig, originalConfigPath: string) { + if (!config.basePath) { + if (originalConfigPath) { + config.basePath = resolve(dirname(originalConfigPath)); + } else { + config.basePath = process.cwd(); + } + } + } + + private disableSingleRun(config: KarmaConfig) { + const originalConfigSet = config.set; + + if (typeof originalConfigSet !== 'function') { + return; + } + config.set = (newConfig?: KarmaConfigOptions) => { + if (newConfig) { + newConfig.singleRun = newConfig.singleRun === true ? false : newConfig.singleRun; + originalConfigSet.apply(config, [newConfig]); + } + }; + } + + private addCustomLauncherDebugPort(customLaucher: CustomLauncher, debugPort: number | undefined) { + if (!customLaucher || debugPort === undefined) { + return; + } + customLaucher.flags = customLaucher.flags?.map(flag => + flag.startsWith(CHROME_BROWSER_DEBUGGING_PORT_FLAG) ? `${CHROME_BROWSER_DEBUGGING_PORT_FLAG}=${debugPort}` : flag + ); + } +} diff --git a/src/frameworks/karma/config/karma-configurator.ts b/src/frameworks/karma/config/karma-configurator.ts deleted file mode 100644 index ac4577f..0000000 --- a/src/frameworks/karma/config/karma-configurator.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Config as KarmaConfig, ConfigOptions as KarmaConfigOptions, CustomLauncher, InlinePluginDef } from 'karma'; -import { dirname, resolve } from 'path'; -import { - CHROME_BROWSER_DEBUGGING_PORT_FLAG, - KARMA_BROWSER_CAPTURE_MIN_TIMEOUT, - KARMA_CUSTOM_LAUNCHER_BROWSER_NAME -} from '../../../constants'; -import { KarmaEnvironmentVariable } from '../karma-environment-variable'; -import { KarmaLogLevel } from '../karma-log-level'; -import { KarmaTestExplorerReporter } from '../reporter/karma-test-explorer-reporter'; - -export class KarmaConfigurator { - private readonly karmaPort: number; - private readonly autoWatchEnabled: boolean; - private readonly autoWatchBatchDelay: number | undefined; - private readonly browser: string; - private readonly customLauncher?: CustomLauncher; - private readonly karmaLogLevel: KarmaLogLevel; - - public constructor() { - this.karmaLogLevel = process.env[KarmaEnvironmentVariable.KarmaLogLevel] ?? KarmaLogLevel.INFO; - - this.autoWatchEnabled = - (process.env[KarmaEnvironmentVariable.AutoWatchEnabled] ?? 'false').toLowerCase() === 'true'; - - this.karmaPort = parseInt(process.env[KarmaEnvironmentVariable.KarmaPort]!, 10); - - const debugPortString = process.env[KarmaEnvironmentVariable.DebugPort]; - const debugPort: number | undefined = debugPortString ? parseInt(debugPortString, 10) : undefined; - - const autoWatchBatchDelay = parseInt(process.env[KarmaEnvironmentVariable.AutoWatchBatchDelay]!, 10); - this.autoWatchBatchDelay = !this.autoWatchEnabled - ? 0 - : !Number.isNaN(autoWatchBatchDelay) - ? autoWatchBatchDelay - : undefined; - - const requestedBrowser = process.env[KarmaEnvironmentVariable.Browser]; - const customLauncherString = process.env[KarmaEnvironmentVariable.CustomLauncher]!; - - if (requestedBrowser) { - this.browser = requestedBrowser; - this.customLauncher = undefined; - } else { - this.browser = KARMA_CUSTOM_LAUNCHER_BROWSER_NAME; - const customLaucher = customLauncherString ? JSON.parse(customLauncherString) : {}; - this.addCustomLauncherDebugPort(customLaucher, debugPort); - this.customLauncher = customLaucher; - } - } - - public applyConfigOverrides(config: KarmaConfig) { - config.port = this.karmaPort; - config.logLevel = (config as any)[`LOG_${this.karmaLogLevel.toUpperCase()}`]; - config.singleRun = false; - config.autoWatch = this.autoWatchEnabled; - config.autoWatchBatchDelay = this.autoWatchBatchDelay ?? config.autoWatchBatchDelay; - config.restartOnFileChange = false; - config.browsers = [this.browser]; - config.customLaunchers = this.customLauncher ? { [this.browser]: this.customLauncher } : config.customLaunchers; - config.browserNoActivityTimeout = 1000 * 60 * 60 * 24; - config.browserDisconnectTimeout = Math.max(config.browserDisconnectTimeout || 0, 30_000); - config.pingTimeout = 1000 * 60 * 60 * 24; - config.captureTimeout = Math.max(config.captureTimeout || 0, KARMA_BROWSER_CAPTURE_MIN_TIMEOUT); - config.browserSocketTimeout = 30_000; - config.processKillTimeout = 2000; - config.retryLimit = Math.max(config.retryLimit || 0, 3); - (config.client ??= {}).clearContext = false; - } - - public loadOriginalKarmaConfig(config: KarmaConfig, originalKarmaConfigPath: string) { - let originalKarmaConfigModule = require(originalKarmaConfigPath); // eslint-disable-line @typescript-eslint/no-var-requires - - // https://github.com/karma-runner/karma/blob/v1.7.0/lib/config.js#L364 - if (typeof originalKarmaConfigModule === 'object' && typeof originalKarmaConfigModule.default !== 'undefined') { - originalKarmaConfigModule = originalKarmaConfigModule.default; - } - originalKarmaConfigModule(config); - } - - public addOriginalKarmaConfigToExcludes(config: KarmaConfig, originalConfigPath: string) { - (config.exclude ??= []).push(originalConfigPath); - } - - public setBasePath(config: KarmaConfig, originalConfigPath: string) { - if (!config.basePath) { - if (originalConfigPath) { - config.basePath = resolve(dirname(originalConfigPath)); - } else { - config.basePath = process.cwd(); - } - } - } - - public disableSingleRun(config: KarmaConfig) { - const originalConfigSet = config.set; - - if (typeof originalConfigSet !== 'function') { - return; - } - config.set = (newConfig?: KarmaConfigOptions) => { - if (newConfig) { - newConfig.singleRun = newConfig.singleRun === true ? false : newConfig.singleRun; - originalConfigSet.apply(config, [newConfig]); - } - }; - } - - public addReporter(config: KarmaConfig) { - const reporterName = KarmaTestExplorerReporter.name; - const karmaPlugin: InlinePluginDef = { [`reporter:${reporterName}`]: ['type', KarmaTestExplorerReporter] }; - - (config.plugins ??= ['karma-*']).push(karmaPlugin); - (config.reporters ??= []).splice(0, config.reporters.length, reporterName); - } - - private addCustomLauncherDebugPort(customLaucher: CustomLauncher, debugPort: number | undefined) { - if (!customLaucher || debugPort === undefined) { - return; - } - customLaucher.flags = customLaucher.flags?.map(flag => - flag.startsWith(CHROME_BROWSER_DEBUGGING_PORT_FLAG) ? `${CHROME_BROWSER_DEBUGGING_PORT_FLAG}=${debugPort}` : flag - ); - } -} diff --git a/src/frameworks/karma/config/karma.conf.ts b/src/frameworks/karma/config/karma.conf.ts index a7e816e..fea07c1 100644 --- a/src/frameworks/karma/config/karma.conf.ts +++ b/src/frameworks/karma/config/karma.conf.ts @@ -1,15 +1,17 @@ import { Config as KarmaConfig } from 'karma'; +import { ConsoleLogAppender } from '../../../util/logging/console-log-appender'; +import { LogLevel } from '../../../util/logging/log-level'; +import { Logger } from '../../../util/logging/logger'; +import { SimpleLogger } from '../../../util/logging/simple-logger'; import { KarmaEnvironmentVariable } from '../karma-environment-variable'; -import { KarmaConfigurator } from './karma-configurator'; - -const originalConfigPath = process.env[KarmaEnvironmentVariable.ProjectKarmaConfigPath] as string; -const karmaConfigurator = new KarmaConfigurator(); +import { KarmaConfigLoader } from './karma-config-loader'; module.exports = (config: KarmaConfig) => { - karmaConfigurator.loadOriginalKarmaConfig(config, originalConfigPath); - karmaConfigurator.applyConfigOverrides(config); - karmaConfigurator.addOriginalKarmaConfigToExcludes(config, originalConfigPath); - karmaConfigurator.addReporter(config); - karmaConfigurator.setBasePath(config, originalConfigPath); - karmaConfigurator.disableSingleRun(config); + const logLevel = process.env[KarmaEnvironmentVariable.ExtensionLogLevel] || LogLevel.INFO; + const logger: Logger = new SimpleLogger(new ConsoleLogAppender(), KarmaConfigLoader.name, logLevel); + + const originalConfigPath = process.env[KarmaEnvironmentVariable.ProjectKarmaConfigPath] as string; + const karmaConfigProcessor = new KarmaConfigLoader(logger); + + karmaConfigProcessor.loadConfig(config, originalConfigPath); }; diff --git a/src/frameworks/karma/karma-environment-variable.ts b/src/frameworks/karma/karma-environment-variable.ts index 9651b70..c7152e5 100644 --- a/src/frameworks/karma/karma-environment-variable.ts +++ b/src/frameworks/karma/karma-environment-variable.ts @@ -7,6 +7,7 @@ export enum KarmaEnvironmentVariable { AutoWatchBatchDelay = 'KarmaTestExplorer_autoWatchBatchDelay', Browser = 'KarmaTestExplorer_browser', CustomLauncher = 'KarmaTestExplorer_customLauncher', + ExtensionLogLevel = 'KarmaTestExplorer_extensionLogLevel', KarmaLogLevel = 'KarmaTestExplorer_karmaLogLevel', KarmaReporterLogLevel = 'KarmaTestExplorer_karmaReporterLogLevel' } diff --git a/src/frameworks/karma/karma-factory.ts b/src/frameworks/karma/karma-factory.ts index be91245..ab64da6 100644 --- a/src/frameworks/karma/karma-factory.ts +++ b/src/frameworks/karma/karma-factory.ts @@ -35,6 +35,7 @@ export type KarmaFactoryConfig = Pick< | 'environment' | 'failOnStandardError' | 'allowGlobalPackageFallback' + | 'logLevel' | 'karmaLogLevel' | 'karmaReporterLogLevel' | 'karmaProcessCommand' @@ -135,6 +136,7 @@ export class KarmaFactory implements TestFactory, Disposable { [KarmaEnvironmentVariable.AutoWatchBatchDelay]: `${this.config.autoWatchBatchDelay ?? ''}`, [KarmaEnvironmentVariable.Browser]: this.config.browser ?? '', [KarmaEnvironmentVariable.CustomLauncher]: JSON.stringify(this.config.customLauncher), + [KarmaEnvironmentVariable.ExtensionLogLevel]: `${this.config.logLevel}`, [KarmaEnvironmentVariable.KarmaLogLevel]: `${this.config.karmaLogLevel}`, [KarmaEnvironmentVariable.KarmaReporterLogLevel]: `${this.config.karmaReporterLogLevel}` }; diff --git a/src/frameworks/karma/reporter/karma-test-explorer-reporter.ts b/src/frameworks/karma/reporter/karma-test-explorer-reporter.ts index 5b0079f..a0f11d9 100644 --- a/src/frameworks/karma/reporter/karma-test-explorer-reporter.ts +++ b/src/frameworks/karma/reporter/karma-test-explorer-reporter.ts @@ -32,7 +32,7 @@ export function KarmaTestExplorerReporter( const logger: Logger = LoggerAdapter.fromBasicLog( karmaLogger.create(`reporter:${KarmaTestExplorerReporter.name}`), logLevel, - { bypassUnderlyingTraceMethod: true } + { patchTraceLogger: true } ); // --- Setup worker to communicate with extension --- diff --git a/src/frameworks/karma/runner/default-test-builder.ts b/src/frameworks/karma/runner/default-test-builder.ts index 72ec3c1..c9aa60e 100644 --- a/src/frameworks/karma/runner/default-test-builder.ts +++ b/src/frameworks/karma/runner/default-test-builder.ts @@ -101,7 +101,7 @@ export class DefaultTestBuilder implements TestBuilder { const existingDuplicateTest = singleDefinitionTestsByNormalizedId.get(normalizedSpecId); if (existingDuplicateTest) { - loadProblemMessage = + loadProblemMessage = // FIXME: Adjust message when duplicate reported tests are due to parameterized tests without parameterized descriptions (💡 use lightbulb icon and also add lightbulb tip to gutter position) `"${spec.fullName}" \n\n` + `--- \n\n` + `Duplicate instances of the above test were reported in your project ` + @@ -131,7 +131,13 @@ export class DefaultTestBuilder implements TestBuilder { let duplicateSpecCounter = 0; const duplicateSpecFiles = matchingTestDefinitions - .sort((loc1, loc2) => (loc1.file === testDefinition?.file ? -1 : loc2.file === testDefinition?.file ? 1 : 0)) + .sort((loc1, loc2) => + this.isSameTestDefinition(loc1, testDefinition) + ? -1 + : this.isSameTestDefinition(loc2, testDefinition) + ? 1 + : 0 + ) .map(location => `${++duplicateSpecCounter}. ${location.file}:${location.line + 1}`) .join('\n'); @@ -158,6 +164,12 @@ export class DefaultTestBuilder implements TestBuilder { return builtTests; } + private isSameTestDefinition(definition1?: TestDefinition, definition2?: TestDefinition): boolean { + return !definition1 || !definition2 + ? false + : definition1.file === definition2.file && definition1.line === definition2.line; + } + private buildTest( rootContainer: TestSuiteInfo, focusContext: TestsFocusContext, diff --git a/src/frameworks/karma/runner/karma-command-line-test-run-executor.ts b/src/frameworks/karma/runner/karma-command-line-test-run-executor.ts index 2d05187..13d5eeb 100644 --- a/src/frameworks/karma/runner/karma-command-line-test-run-executor.ts +++ b/src/frameworks/karma/runner/karma-command-line-test-run-executor.ts @@ -53,7 +53,7 @@ export class KarmaCommandLineTestRunExecutor implements TestRunExecutor { `Karma does not seem to be installed. You may ` + `need to install your project dependencies or ` + `specify the right path to your project using the ` + - `${EXTENSION_CONFIG_PREFIX}.${ExternalConfigSetting.Projects} ` + + `${EXTENSION_CONFIG_PREFIX}.${ExternalConfigSetting.ProjectWorkspaces} ` + `setting.` ); } diff --git a/src/frameworks/karma/runner/karma-test-event-processor.ts b/src/frameworks/karma/runner/karma-test-event-processor.ts index cd1c0a9..cf2b903 100644 --- a/src/frameworks/karma/runner/karma-test-event-processor.ts +++ b/src/frameworks/karma/runner/karma-test-event-processor.ts @@ -1,3 +1,4 @@ +import { relative } from 'path'; import { EventEmitter } from 'vscode'; import { RetireEvent, TestDecoration, TestEvent, TestInfo } from 'vscode-test-adapter-api'; import { TestDefinition } from '../../../core/base/test-definition'; @@ -12,10 +13,9 @@ import { StoredTestResolver } from '../../../core/test-store'; import { TestSuiteFolderGroupingOptions, TestSuiteOrganizer } from '../../../core/util/test-suite-organizer'; import { Disposable } from '../../../util/disposable/disposable'; import { Disposer } from '../../../util/disposable/disposer'; -import { FileHandler } from '../../../util/file-handler'; import { DeferredPromise } from '../../../util/future/deferred-promise'; import { Logger } from '../../../util/logging/logger'; -import { escapeForRegExp } from '../../../util/utils'; +import { escapeForRegExp, normalizePath } from '../../../util/utils'; import { TestCapture } from './karma-test-listener'; import { SpecCompleteResponse } from './spec-complete-response'; import { SuiteAggregateTestResultProcessor } from './suite-aggregate-test-result-processor'; @@ -62,8 +62,8 @@ export class KarmaTestEventProcessor { private readonly suiteTestResultEmitter: SuiteAggregateTestResultProcessor, private readonly testLocator: TestLocator, private readonly testResolver: StoredTestResolver, - private readonly fileHandler: FileHandler, private readonly testHelper: TestHelper, + private readonly basePath: string, private readonly logger: Logger ) { this.disposables.push(logger, testResultEventEmitter); @@ -382,7 +382,7 @@ export class KarmaTestEventProcessor { const decodedFailureMessages = testResult.failureMessages.map(message => decodeURIComponent(message)); const matchingTestDefinitionsForFailure = candidateTestDefinitions.filter(candidateTestDefinition => { - const relativeTestFilePath = this.fileHandler.getFileRelativePath(candidateTestDefinition.file); + const relativeTestFilePath = normalizePath(relative(this.basePath, candidateTestDefinition.file)); return decodedFailureMessages.some(failureMessage => failureMessage.includes(relativeTestFilePath)); }); @@ -408,7 +408,7 @@ export class KarmaTestEventProcessor { try { const decorations = decodedFailureMessages.map((failureMessage): TestDecoration => { - const relativeTestFilePath = this.fileHandler.getFileRelativePath(testDefinition.file); + const relativeTestFilePath = normalizePath(relative(this.basePath, testDefinition.file)); const errorLineAndColumnCollection = failureMessage .substring(failureMessage.indexOf(relativeTestFilePath)) diff --git a/src/frameworks/karma/server/karma-command-line-test-server-executor.ts b/src/frameworks/karma/server/karma-command-line-test-server-executor.ts index 461d5c6..3825235 100644 --- a/src/frameworks/karma/server/karma-command-line-test-server-executor.ts +++ b/src/frameworks/karma/server/karma-command-line-test-server-executor.ts @@ -77,7 +77,7 @@ export class KarmaCommandLineTestServerExecutor implements TestServerExecutor { `Karma does not seem to be installed. You may ` + `need to install your project dependencies or ` + `specify the right path to your project using the ` + - `${EXTENSION_CONFIG_PREFIX}.${ExternalConfigSetting.Projects} ` + + `${EXTENSION_CONFIG_PREFIX}.${ExternalConfigSetting.ProjectWorkspaces} ` + `setting.` ); } diff --git a/src/main.ts b/src/main.ts index 5bea783..a972824 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,6 +16,7 @@ import { Adapter } from './adapter'; import { EXTENSION_CONFIG_PREFIX, EXTENSION_NAME, EXTENSION_OUTPUT_CHANNEL_NAME } from './constants'; import { ConfigChangeManager } from './core/config/config-change-manager'; import { ExternalConfigSetting, GeneralConfigSetting, WorkspaceConfigSetting } from './core/config/config-setting'; +import { WorkspaceFolderConfigResolver } from './core/config/workspace-folder-config-resolver'; import { ExtensionCommands } from './core/vscode/commands/extension-commands'; import { MultiStatusDisplay } from './core/vscode/notifications/multi-status-display'; import { OutputChannelLog } from './core/vscode/output-channel-log'; @@ -23,6 +24,8 @@ import { Preferences } from './core/vscode/preferences/preferences'; import { ProjectFactory } from './project-factory'; import { Disposable } from './util/disposable/disposable'; import { Disposer } from './util/disposable/disposer'; +import { FileHandler } from './util/filesystem/file-handler'; +import { SimpleFileHandler } from './util/filesystem/simple-file-handler'; import { LogLevel } from './util/logging/log-level'; import { Logger } from './util/logging/logger'; import { SimpleLogger } from './util/logging/simple-logger'; @@ -37,6 +40,8 @@ interface SharedAdapterComponents { } const MAIN_LOG_LEVEL = LogLevel.TRACE; +const ALLOW_PROJECT_SELECTION_CONTEXT_KEY = `${EXTENSION_CONFIG_PREFIX}.allowProjectSelection`; + const disposables: Disposable[] = []; const allWorkspaceProjects: Set = new Set(); @@ -65,8 +70,22 @@ export const activate = async (extensionContext: ExtensionContext) => { // --- Create Global Components --- + logger.info(() => `Creating file handler`); + const fileHandler: FileHandler = new SimpleFileHandler(new SimpleLogger(logger, SimpleFileHandler.name)); + + logger.info(() => `Creating workspace configuration resolver`); + const workspaceConfigResolver: WorkspaceFolderConfigResolver = { + resolveConfig(workspaceFolder) { + return workspace.getConfiguration(EXTENSION_CONFIG_PREFIX, workspaceFolder); + } + }; + logger.info(() => `Creating project factory`); - const projectFactory = new ProjectFactory(new SimpleLogger(logger, ProjectFactory.name)); + const projectFactory = new ProjectFactory( + fileHandler, + workspaceConfigResolver, + new SimpleLogger(logger, ProjectFactory.name) + ); disposables.push(projectFactory); logger.info(() => `Creating config change manager`); @@ -359,7 +378,7 @@ const updateMultiProjectContext = (logger: Logger) => { const multiProjectEnabled = allWorkspaceProjects.size > 1; logger.info(() => `Setting multi-project context to: ${multiProjectEnabled}`); - commands.executeCommand('setContext', `${EXTENSION_CONFIG_PREFIX}.allowProjectSelection`, multiProjectEnabled); + commands.executeCommand('setContext', ALLOW_PROJECT_SELECTION_CONTEXT_KEY, multiProjectEnabled); }; export const deactivate = async () => { diff --git a/src/project-factory.ts b/src/project-factory.ts index f288722..f962a6e 100644 --- a/src/project-factory.ts +++ b/src/project-factory.ts @@ -1,7 +1,6 @@ -import { existsSync } from 'fs'; -import { basename, posix, relative, resolve } from 'path'; +import { basename, join, relative, resolve } from 'path'; import type { PackageJson } from 'type-fest'; -import { workspace, WorkspaceFolder } from 'vscode'; +import { WorkspaceFolder } from 'vscode'; import { EXTENSION_CONFIG_PREFIX, EXTENSION_NAME } from './constants'; import { ProjectType } from './core/base/project-type'; import { @@ -15,18 +14,26 @@ import { ConfigStore } from './core/config/config-store'; import { LayeredConfigStore } from './core/config/layered-config-store'; import { ProjectSpecificConfig, ProjectSpecificConfigSetting } from './core/config/project-specific-config'; import { SimpleConfigStore } from './core/config/simple-config-store'; +import { WorkspaceFolderConfigResolver } from './core/config/workspace-folder-config-resolver'; import { getAngularWorkspaceInfo } from './frameworks/angular/angular-util'; import { AngularWorkspaceInfo } from './frameworks/angular/angular-workspace-info'; import { Disposable } from './util/disposable/disposable'; import { Disposer } from './util/disposable/disposer'; +import { FileHandler } from './util/filesystem/file-handler'; import { Logger } from './util/logging/logger'; import { asNonBlankStringOrUndefined, getPackageJsonAtPath, isChildPath, normalizePath } from './util/utils'; import { WorkspaceProject } from './workspace'; +const CONFIG_VALUES_CONSIDERED_ABSENT = [undefined, null]; + export class ProjectFactory implements Disposable { private readonly disposables: Disposable[] = []; - public constructor(private readonly logger: Logger) { + public constructor( + private readonly fileHandler: FileHandler, + private readonly workspaceConfigResolver: WorkspaceFolderConfigResolver, + private readonly logger: Logger + ) { this.disposables.push(logger); } @@ -46,7 +53,7 @@ export class ProjectFactory implements Disposable { return []; } const workspaceFolderPath = normalizePath(workspaceFolder.uri.fsPath); - const workspaceConfig = workspace.getConfiguration(EXTENSION_CONFIG_PREFIX, workspaceFolder); + const workspaceConfig = this.workspaceConfigResolver.resolveConfig(workspaceFolder); const shouldEnableTestingForWorkspaceFolder = this.shouldEnableTestingForWorkspaceFolder( workspaceFolderPath, @@ -68,7 +75,7 @@ export class ProjectFactory implements Disposable { ); let configuredProjects: (string | ProjectSpecificConfig)[] = - workspaceConfig.get(ExternalConfigSetting.Projects) ?? []; + workspaceConfig.get(ExternalConfigSetting.ProjectWorkspaces) ?? []; if (configuredProjects.length === 0) { const deprecatedProjectRootPath = asNonBlankStringOrUndefined( @@ -77,22 +84,37 @@ export class ProjectFactory implements Disposable { configuredProjects = deprecatedProjectRootPath ? [deprecatedProjectRootPath] : ['']; } + const mappedProjectPaths = new Set(); + const workspaceFolderProjects = configuredProjects .map(configuredProject => { const projectSpecificSettings = typeof configuredProject === 'string' - ? { [ExternalConfigSetting.ProjectRootPath]: configuredProject } + ? { [ExternalConfigSetting.RootPath]: configuredProject } : configuredProject; - const projectSpecificConfig: ConfigStore = new SimpleConfigStore( - projectSpecificSettings, - EXTENSION_CONFIG_PREFIX - ); - - const projectFolderConfig: ConfigStore = new LayeredConfigStore( + const projectRootPath = projectSpecificSettings.rootPath ?? projectSpecificSettings.projectRootPath; + + if (mappedProjectPaths.has(projectRootPath)) { + this.logger.warn( + () => + `Ignoring duplicate project entry for path '${projectRootPath}' - ` + + `${JSON.stringify(configuredProject, null, 2)}` + ); + return []; + } + mappedProjectPaths.add(projectRootPath); + + const projectSpecificConfig: ConfigStore = new SimpleConfigStore({ + ...projectSpecificSettings, + rootPath: projectRootPath, + projectRootPath: undefined + }); + + const projectFolderConfig: ConfigStore = new LayeredConfigStore([ workspaceConfig, projectSpecificConfig - ); + ]); return this.createProjectsForConfiguredProjectFolder(projectFolderConfig, workspaceFolder); }) @@ -106,7 +128,7 @@ export class ProjectFactory implements Disposable { workspaceFolder: WorkspaceFolder // FIXME: Can this be reduced to workspace folder path string? ): WorkspaceProject[] { const workspaceFolderPath = normalizePath(workspaceFolder.uri.fsPath); - const relativeProjectRootPath = projectFolderConfig.get(ExternalConfigSetting.ProjectRootPath); + const relativeProjectRootPath = projectFolderConfig.get(ExternalConfigSetting.RootPath); const absoluteProjectRootPath = normalizePath(resolve(workspaceFolderPath, relativeProjectRootPath)); const isProjectPathInWorkspaceFolder = isChildPath(workspaceFolderPath, absoluteProjectRootPath, true); @@ -120,7 +142,7 @@ export class ProjectFactory implements Disposable { return []; } - if (!existsSync(absoluteProjectRootPath)) { + if (!this.fileHandler.existsSync(absoluteProjectRootPath)) { this.logger.debug( () => `Excluding project root path '${relativeProjectRootPath}' ` + @@ -135,6 +157,7 @@ export class ProjectFactory implements Disposable { const angularWorkspace: AngularWorkspaceInfo | undefined = getAngularWorkspaceInfo( absoluteProjectRootPath, + this.fileHandler, this.logger ); @@ -172,20 +195,21 @@ export class ProjectFactory implements Disposable { const angularProjectPath = normalizePath(resolve(absoluteProjectRootPath, angularChildProjectInfo.rootPath)); const karmaConfigPath = normalizePath(resolve(angularProjectPath, angularChildProjectInfo.karmaConfigPath)); - const projectInternalSettings = new SimpleConfigStore( - { - [InternalConfigSetting.ProjectType]: ProjectType.Angular, - [InternalConfigSetting.ProjectName]: angularChildProjectInfo.name, - [InternalConfigSetting.ProjectPath]: angularProjectPath, - [InternalConfigSetting.ProjectInstallRootPath]: absoluteProjectRootPath, - [InternalConfigSetting.ProjectKarmaConfigFilePath]: karmaConfigPath - }, - EXTENSION_CONFIG_PREFIX - ); + const projectDefaultSettings = new SimpleConfigStore({ + [GeneralConfigSetting.WebRoot]: absoluteProjectRootPath + }); + + const projectInternalSettings = new SimpleConfigStore({ + [InternalConfigSetting.ProjectType]: ProjectType.Angular, + [InternalConfigSetting.ProjectName]: angularChildProjectInfo.name, + [InternalConfigSetting.ProjectPath]: angularProjectPath, + [InternalConfigSetting.ProjectInstallRootPath]: absoluteProjectRootPath, + [InternalConfigSetting.ProjectKarmaConfigFilePath]: karmaConfigPath + }); const angularChildProjectConfig: ConfigStore = new LayeredConfigStore( - projectFolderConfig, - projectInternalSettings + [projectDefaultSettings, projectFolderConfig, projectInternalSettings], + { valuesConsideredAbsent: CONFIG_VALUES_CONSIDERED_ABSENT } ); const project: WorkspaceProject = { @@ -193,7 +217,7 @@ export class ProjectFactory implements Disposable { longName: `${projectRootPathFolderName}: ${angularChildProjectInfo.name}`, namespace: `${absoluteProjectRootPath}:${angularChildProjectInfo.name}`, type: ProjectType.Angular, - workspaceFolder: workspaceFolder, // FIXME: Exclude? Could only workspace folder path be enough? + workspaceFolder: workspaceFolder, // FIXME: Exclude? Could only workspace folder path be used? workspaceFolderPath: workspaceFolderPath, projectPath: angularProjectPath, topLevelProjectPath: absoluteProjectRootPath, @@ -209,20 +233,21 @@ export class ProjectFactory implements Disposable { const karmaConfigPath = normalizePath( resolve(absoluteProjectRootPath, projectFolderConfig.get(ExternalConfigSetting.KarmaConfFilePath)!) ); - const projectInternalSettings = new SimpleConfigStore( - { - [InternalConfigSetting.ProjectType]: ProjectType.Karma, - [InternalConfigSetting.ProjectName]: projectRootPathFolderName, - [InternalConfigSetting.ProjectPath]: absoluteProjectRootPath, - [InternalConfigSetting.ProjectInstallRootPath]: absoluteProjectRootPath, - [InternalConfigSetting.ProjectKarmaConfigFilePath]: karmaConfigPath - }, - EXTENSION_CONFIG_PREFIX - ); + const projectDefaultSettings = new SimpleConfigStore({ + [GeneralConfigSetting.WebRoot]: absoluteProjectRootPath + }); + + const projectInternalSettings = new SimpleConfigStore({ + [InternalConfigSetting.ProjectType]: ProjectType.Karma, + [InternalConfigSetting.ProjectName]: projectRootPathFolderName, + [InternalConfigSetting.ProjectPath]: absoluteProjectRootPath, + [InternalConfigSetting.ProjectInstallRootPath]: absoluteProjectRootPath, + [InternalConfigSetting.ProjectKarmaConfigFilePath]: karmaConfigPath + }); const projectConfig: ConfigStore = new LayeredConfigStore( - projectFolderConfig, - projectInternalSettings + [projectDefaultSettings, projectFolderConfig, projectInternalSettings], + { valuesConsideredAbsent: CONFIG_VALUES_CONSIDERED_ABSENT } ); const project: WorkspaceProject = { @@ -288,8 +313,13 @@ export class ProjectFactory implements Disposable { this.logger.debug(() => `Workspace folder '${workspaceFolderPath}' has no ${EXTENSION_NAME} settings configured`); } - const workspacePackageJsonFilePath = posix.join(workspaceFolderPath, 'package.json'); - const packageJson: PackageJson | undefined = getPackageJsonAtPath(workspacePackageJsonFilePath, this.logger); + const workspacePackageJsonFilePath = normalizePath(join(workspaceFolderPath, 'package.json')); + + const packageJson: PackageJson | undefined = getPackageJsonAtPath( + workspacePackageJsonFilePath, + this.fileHandler, + this.logger + ); if (!packageJson) { this.logger.debug( diff --git a/src/util/filesystem/file-handler.ts b/src/util/filesystem/file-handler.ts new file mode 100644 index 0000000..3a2f27d --- /dev/null +++ b/src/util/filesystem/file-handler.ts @@ -0,0 +1,12 @@ +import globby from 'globby'; +import { Disposable } from '../disposable/disposable'; + +export interface FileHandler extends Disposable { + existsSync(filePath: string): boolean; + + readFile(filePath: string, encoding?: BufferEncoding): Promise; + + readFileSync(filePath: string, encoding?: BufferEncoding): string | undefined; + + resolveFileGlobs(filePatterns: string[], globOptions?: globby.GlobbyOptions): Promise; +} diff --git a/src/util/file-handler.ts b/src/util/filesystem/simple-file-handler.ts similarity index 77% rename from src/util/file-handler.ts rename to src/util/filesystem/simple-file-handler.ts index a9396aa..93d2934 100644 --- a/src/util/file-handler.ts +++ b/src/util/filesystem/simple-file-handler.ts @@ -1,12 +1,12 @@ -import { readFile, readFileSync } from 'fs'; +import { existsSync, readFile, readFileSync } from 'fs'; import globby from 'globby'; -import { relative } from 'path'; -import { DEFAULT_FILE_ENCODING } from '../constants'; -import { Disposable } from './disposable/disposable'; -import { Disposer } from './disposable/disposer'; -import { DeferredPromise } from './future/deferred-promise'; -import { Logger } from './logging/logger'; -import { normalizePath } from './utils'; +import { DEFAULT_FILE_ENCODING } from '../../constants'; +import { Disposable } from '../disposable/disposable'; +import { Disposer } from '../disposable/disposer'; +import { DeferredPromise } from '../future/deferred-promise'; +import { Logger } from '../logging/logger'; +import { normalizePath } from '../utils'; +import { FileHandler } from './file-handler'; const DEFAULT_GLOB_OPTIONS: globby.GlobbyOptions = { unique: true, @@ -16,29 +16,31 @@ const DEFAULT_GLOB_OPTIONS: globby.GlobbyOptions = { gitignore: true }; -export interface FileHandlerOptions extends globby.GlobbyOptions { +export interface SimpleFileHandlerOptions extends globby.GlobbyOptions { cwd?: string; fileEncoding?: BufferEncoding; } -export class FileHandler implements Disposable { - private readonly fileHandlerOptions: FileHandlerOptions; +export class SimpleFileHandler implements FileHandler { + private readonly fileHandlerOptions: SimpleFileHandlerOptions; private readonly fileEncoding: BufferEncoding; - private readonly cwd: string; private readonly disposables: Disposable[] = []; - public constructor(private readonly logger: Logger, fileHandlerOptions: FileHandlerOptions = {}) { + public constructor(private readonly logger: Logger, fileHandlerOptions: SimpleFileHandlerOptions = {}) { this.disposables.push(logger); this.fileEncoding = fileHandlerOptions.fileEncoding ?? DEFAULT_FILE_ENCODING; - this.cwd = fileHandlerOptions.cwd ?? process.cwd(); this.fileHandlerOptions = { ...fileHandlerOptions, fileEncoding: this.fileEncoding, - cwd: this.cwd + cwd: fileHandlerOptions.cwd ?? process.cwd() }; } + public existsSync(filePath: string): boolean { + return existsSync(filePath); + } + public readFileSync(filePath: string, encoding?: BufferEncoding): string | undefined { this.logger.debug(() => `Reading file synchronously: ${filePath}`); let fileContents: string | undefined; @@ -88,10 +90,6 @@ export class FileHandler implements Disposable { } } - public getFileRelativePath(fileAbsolutePath: string) { - return normalizePath(relative(this.cwd, fileAbsolutePath)); - } - public async dispose() { await Disposer.dispose(this.disposables); } diff --git a/src/util/logging/console-log-appender.ts b/src/util/logging/console-log-appender.ts new file mode 100644 index 0000000..db49f90 --- /dev/null +++ b/src/util/logging/console-log-appender.ts @@ -0,0 +1,21 @@ +import { LogAppender } from './log-appender'; + +export interface ConsoleLog { + log(content: string): void; +} + +export class ConsoleLogAppender implements LogAppender { + private readonly consoleLog: ConsoleLog; + + public constructor(consoleLog?: ConsoleLog) { + this.consoleLog = consoleLog ?? console; + } + + public append(content: string): void { + this.consoleLog.log(content); + } + + public dispose() { + // Nothing to do + } +} diff --git a/src/util/logging/logger-adapter.ts b/src/util/logging/logger-adapter.ts index d9fd7e5..c987e6a 100644 --- a/src/util/logging/logger-adapter.ts +++ b/src/util/logging/logger-adapter.ts @@ -3,7 +3,7 @@ import { LogLevel, LogLevels } from './log-level'; import { Logger } from './logger'; export interface LoggerAdapterOptions { - bypassUnderlyingTraceMethod?: boolean; + patchTraceLogger?: boolean; } export class LoggerAdapter implements Logger { @@ -14,7 +14,7 @@ export class LoggerAdapter implements Logger { private readonly logLevel: LogLevel, options?: LoggerAdapterOptions ) { - this.options = { bypassUnderlyingTraceMethod: false, ...options }; + this.options = { patchTraceLogger: false, ...options }; } public static fromBasicLog(log: BasicLog, logLevel: LogLevel, options?: LoggerAdapterOptions): LoggerAdapter { @@ -49,7 +49,7 @@ export class LoggerAdapter implements Logger { if (!this.isLevelEnabled(LogLevel.TRACE)) { return; } - if (!this.options.bypassUnderlyingTraceMethod && typeof this.logger.trace === 'function') { + if (!this.options.patchTraceLogger && typeof this.logger.trace === 'function') { this.logger.trace(msgSource()); } else { this.logger.debug(`[TRACE]: ${msgSource()}`); diff --git a/src/util/process/simple-process.ts b/src/util/process/simple-process.ts index 5f737f7..9b79829 100644 --- a/src/util/process/simple-process.ts +++ b/src/util/process/simple-process.ts @@ -4,6 +4,7 @@ import treeKill from 'tree-kill'; import { Disposable } from '../disposable/disposable'; import { Disposer } from '../disposable/disposer'; import { DeferredExecution } from '../future/deferred-execution'; +import { DeferredPromise } from '../future/deferred-promise'; import { Execution } from '../future/execution'; import { Logger } from '../logging/logger'; import { generateRandomId } from '../utils'; @@ -92,7 +93,15 @@ export class SimpleProcess implements Process { if (childProcessLog) { childProcess.stdout?.on('data', (data: unknown) => childProcessLog.output(() => `${data}`)); - childProcess.stderr?.on('data', (data: unknown) => childProcessLog.error(() => `${data}`)); + + childProcess.stderr?.on('data', (data: unknown) => { + childProcessLog.error(() => `${data}`); + const trimmedError = `${data}`.trim(); + + if (trimmedError) { + this.logger.error(() => `Error log from process: ${trimmedError}`); + } + }); } if (runOptions.failOnStandardError) { @@ -106,7 +115,6 @@ export class SimpleProcess implements Process { this.logger.error( () => `Process ${this.uid} - Error from child process: '${error}' - for command: ${commandWithArgs}` ); - this.updateProcessRunning(false); if (this.processCurrentlyStopping) { this.logger.debug(() => 'Process is currently stopping - ending process execution'); @@ -115,6 +123,7 @@ export class SimpleProcess implements Process { this.logger.debug(() => 'Process is not currently stopping - Failing process execution'); deferredProcessExecution.fail(error); } + this.updateProcessRunning(false); }); childProcess.on('exit', (exitCode, signal) => { @@ -123,11 +132,17 @@ export class SimpleProcess implements Process { `Process ${this.uid} - PID ${processPid} exited with code '${exitCode}' ` + `and signal '${signal}' for command: ${commandWithArgs}` ); + const effectiveExitCode = exitCode ?? 0; + + if (effectiveExitCode === 0 || this.processCurrentlyStopping) { + deferredProcessExecution.end(); + } else { + deferredProcessExecution.fail(`Process exited with non-zero status code ${effectiveExitCode}`); + } this.updateProcessRunning(false); - deferredProcessExecution.end(); }); - process.stdin.on('close', async (exitCode: number, signal: string) => { + process.stdin.on('close', async (exitCode: number | null, signal: string) => { // Stop child process tree when main parent process stdio streams are closed this.logger.debug( () => @@ -166,28 +181,28 @@ export class SimpleProcess implements Process { const runningProcess = this.childProcess; this.logger.debug(() => `Process ${this.uid} - Killing process tree of PID: ${runningProcess.pid}`); - const futureProcessTermination = new RichPromise((resolve, reject) => { - const processPid = runningProcess.pid; + const deferredProcessTermination = new DeferredPromise(); + const futureProcessTermination = deferredProcessTermination.promise(); + this.processCurrentlyStopping = futureProcessTermination; - if (!processPid) { - resolve(); - return; - } + const processPid = runningProcess.pid; + if (!processPid) { + deferredProcessTermination.fulfill(); + } else { treeKill(processPid, signal, error => { if (error) { this.logger.error( () => `Process ${this.uid} - Failed to terminate process tree for PID '${processPid}': ${error}` ); - reject(error); + deferredProcessTermination.reject(error); } else { this.logger.debug(() => `Process ${this.uid} - Successfully killed process tree for PID: ${processPid}`); - resolve(); + deferredProcessTermination.fulfill(); } }); - }); + } - this.processCurrentlyStopping = futureProcessTermination; return futureProcessTermination; } diff --git a/src/util/utils.ts b/src/util/utils.ts index 45f2ee7..8ebc304 100644 --- a/src/util/utils.ts +++ b/src/util/utils.ts @@ -1,9 +1,8 @@ import dotenvExpand from 'dotenv-expand'; -import { existsSync } from 'fs'; -import { dirname, isAbsolute, posix, win32 } from 'path'; +import { dirname, isAbsolute, join, posix, win32 } from 'path'; import type { PackageJson } from 'type-fest'; import { sync as which } from 'which'; -import { GeneralConfigSetting } from '../core/config/config-setting'; +import { FileHandler } from './filesystem/file-handler'; import { Logger } from './logging/logger'; export const getPropertyWithValue = (object: Record, propValue: T): string | undefined => { @@ -179,14 +178,16 @@ export const expandEnvironment = ( return expandedEnvironment; }; -export const getPackageJsonAtPath = (absolutePath: string, logger: Logger): PackageJson | undefined => { - const normalizedPath = normalizePath(absolutePath); - - const packageJsonFilePath = normalizedPath.endsWith(`${posix.sep}package.json`) - ? normalizedPath - : posix.join(normalizedPath, 'package.json'); +export const getPackageJsonAtPath = ( + absolutePath: string, + fileHandler: FileHandler, + logger: Logger +): PackageJson | undefined => { + const packageJsonFilePath = absolutePath.endsWith(`package.json`) + ? normalizePath(absolutePath) + : normalizePath(join(absolutePath, 'package.json')); - if (!existsSync(packageJsonFilePath)) { + if (!fileHandler.existsSync(packageJsonFilePath)) { logger.debug(() => `No package.json file at '${packageJsonFilePath}'`); return undefined; } else { @@ -227,7 +228,7 @@ export const getPackageInstallPathForProjectRoot = ( () => `Rejected resolved '${moduleName}' module located at '${moduleInstallPath}' ` + `which is outside of project at '${projectRootPath}'` + - `and '${GeneralConfigSetting.AllowGlobalPackageFallback}' is not enabled` + `and global package fallback is not enabled` ); return; } diff --git a/test/core/config/extension-config.test.ts b/test/core/config/extension-config.test.ts index 6a4334f..2943fab 100644 --- a/test/core/config/extension-config.test.ts +++ b/test/core/config/extension-config.test.ts @@ -4,17 +4,20 @@ import { DebugConfiguration } from 'vscode'; import { GeneralConfigSetting } from '../../../src/core/config/config-setting'; import { ConfigStore } from '../../../src/core/config/config-store'; import { ExtensionConfig } from '../../../src/core/config/extension-config'; +import { FileHandler } from '../../../src/util/filesystem/file-handler'; import { Logger } from '../../../src/util/logging/logger'; import { asExtensionConfigWithUnixStylePaths as withUnixPaths } from '../../test-util'; describe('ExtensionConfig', () => { let mockLogger: MockProxy; + let mockFileHandler: MockProxy; let mockConfigValues: Map; let mockConfigDefaults: Map; let mockConfigStore: ConfigStore; beforeEach(() => { mockLogger = mock(); + mockFileHandler = mock(); mockConfigValues = new Map(); mockConfigDefaults = new Map(); @@ -69,7 +72,7 @@ describe('ExtensionConfig', () => { customLauncherConfig.flags = ['--remote-debugging-port=1234']; const extensionConfig = withUnixPaths( - new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger) + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) ); expect(extensionConfig.defaultDebugPort).toEqual(1234); }); @@ -78,7 +81,7 @@ describe('ExtensionConfig', () => { customLauncherConfig.flags = []; const extensionConfig = withUnixPaths( - new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger) + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) ); expect(extensionConfig.defaultDebugPort).toEqual(9222); }); @@ -94,7 +97,7 @@ describe('ExtensionConfig', () => { customLauncherConfig.flags = ['--remote-debugging-port=1234']; const extensionConfig = withUnixPaths( - new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger) + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) ); expect(extensionConfig.defaultDebugPort).not.toBeDefined(); }); @@ -103,7 +106,7 @@ describe('ExtensionConfig', () => { customLauncherConfig.flags = []; const extensionConfig = withUnixPaths( - new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger) + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) ); expect(extensionConfig.defaultDebugPort).not.toBeDefined(); }); @@ -119,14 +122,18 @@ describe('ExtensionConfig', () => { it('does not set a default debug port if the `remote-debugging-port` launcher flag is present', () => { customLauncherConfig.flags = ['--remote-debugging-port=1234']; - const extensionConfig = withUnixPaths(new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger)); + const extensionConfig = withUnixPaths( + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) + ); expect(extensionConfig.defaultDebugPort).not.toBeDefined(); }); it('does not default to port 9222 if the `remote-debugging-port` launcher flag is not present', () => { customLauncherConfig.flags = []; - const extensionConfig = withUnixPaths(new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger)); + const extensionConfig = withUnixPaths( + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) + ); expect(extensionConfig.defaultDebugPort).not.toBeDefined(); }); @@ -155,7 +162,7 @@ describe('ExtensionConfig', () => { customLauncherConfig.flags = ['some-random-flag']; const extensionConfig = withUnixPaths( - new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger) + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) ); expect(extensionConfig.customLauncher).toEqual( expect.objectContaining({ flags: ['some-random-flag', '--no-sandbox'] }) @@ -166,7 +173,7 @@ describe('ExtensionConfig', () => { customLauncherConfig.flags = ['--no-sandbox', 'random-other-flag']; const extensionConfig = withUnixPaths( - new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger) + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) ); expect(extensionConfig.customLauncher).toEqual( expect.objectContaining({ flags: ['--no-sandbox', 'random-other-flag'] }) @@ -181,7 +188,7 @@ describe('ExtensionConfig', () => { it('does not add the `--no-sandbox` flag when', () => { const extensionConfig = withUnixPaths( - new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger) + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) ); expect(extensionConfig.customLauncher).toEqual( expect.not.objectContaining({ flags: expect.arrayContaining(['--no-sandbox']) }) @@ -192,7 +199,7 @@ describe('ExtensionConfig', () => { customLauncherConfig.flags = ['--random-flag-one', 'randomFlagTwo', '-f3']; const extensionConfig = withUnixPaths( - new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger) + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) ); expect(extensionConfig.customLauncher).toEqual( expect.objectContaining({ flags: ['--random-flag-one', 'randomFlagTwo', '-f3'] }) @@ -212,14 +219,18 @@ describe('ExtensionConfig', () => { it('includes the node_modules folder if it was not included in the configured exclusion list', () => { configuredExcludeFiles = ['fake/exclusion/glob']; - const extensionConfig = withUnixPaths(new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger)); + const extensionConfig = withUnixPaths( + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) + ); expect(extensionConfig.excludeFiles).toEqual(expect.arrayContaining(['**/node_modules/**/*'])); }); it('retains the node_modules folder if it was included in the configured exclusion list', () => { configuredExcludeFiles = ['fake/exclusion/glob/1', '**/node_modules/**/*', 'fake/exclusion/glob/2']; - const extensionConfig = withUnixPaths(new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockLogger)); + const extensionConfig = withUnixPaths( + new ExtensionConfig(mockConfigStore, '/fake/workspace/path', mockFileHandler, mockLogger) + ); expect(extensionConfig.excludeFiles).toEqual(expect.arrayContaining(['**/node_modules/**/*'])); }); diff --git a/test/core/config/laytered-config-store.test.ts b/test/core/config/laytered-config-store.test.ts new file mode 100644 index 0000000..e5b2d07 --- /dev/null +++ b/test/core/config/laytered-config-store.test.ts @@ -0,0 +1,259 @@ +import { MutableConfigStore } from '../../../src/core/config/config-store'; +import { LayeredConfigStore } from '../../../src/core/config/layered-config-store'; +import { SimpleMutableConfigStore } from '../../../src/core/config/simple-mutable-config-store'; + +describe('LayeredConfigStore', () => { + let layeredConfig: LayeredConfigStore; + + describe('having only one underlying config store', () => { + let underlyingConfigStore: MutableConfigStore; + + beforeEach(() => { + underlyingConfigStore = new SimpleMutableConfigStore(); + layeredConfig = new LayeredConfigStore([underlyingConfigStore]); + }); + + it('returns false when the `has` method is called with a key that is not in the underyling config', () => { + expect(layeredConfig.has('random_unadded_absent_key')).toBe(false); + }); + + it('returns true when the `has` method is called with a key that maps to a non-undefined value in the underyling config', () => { + const itemKey = 'random_key'; + const itemValue = 'value for random_key'; + + underlyingConfigStore.set(itemKey, itemValue); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns true when the `has` method is called with a key that maps to the `undefined` value in the underyling config', () => { + const itemKey = 'random_key'; + + underlyingConfigStore.set(itemKey, undefined); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns true when the `has` method is called with a key that maps to the `null` value in the underyling config', () => { + const itemKey = 'random_key'; + + underlyingConfigStore.set(itemKey, null); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns undefined when the `get` method is called with a key that is not in the underyling config', () => { + expect(layeredConfig.get('random_unadded_absent_key')).toBeUndefined(); + }); + + it('returns the corresponding value when the `get` method is called with the key with which the value is mapped in the underyling config', () => { + const itemKey = 'random_key'; + const itemValue = 'value for random_key'; + + underlyingConfigStore.set(itemKey, itemValue); + expect(layeredConfig.get(itemKey)).toEqual(itemValue); + }); + + describe('when the option is set to treat `undefined` values as absent', () => { + beforeEach(() => { + layeredConfig = new LayeredConfigStore([underlyingConfigStore], { valuesConsideredAbsent: [undefined] }); + }); + + it('returns false when the `has` method is called with a key that maps to the `undefined` value in the underyling config', () => { + const itemKey = 'random_key'; + + underlyingConfigStore.set(itemKey, undefined); + expect(layeredConfig.has(itemKey)).toBe(false); + }); + }); + }); + + describe('having more than one underlying config store', () => { + let overridingLayerConfig: MutableConfigStore; + let baseLayerConfig: MutableConfigStore; + + beforeEach(() => { + overridingLayerConfig = new SimpleMutableConfigStore(); + baseLayerConfig = new SimpleMutableConfigStore(); + layeredConfig = new LayeredConfigStore([baseLayerConfig, overridingLayerConfig]); + }); + + it('returns undefined when the `get` method is called with a key that is mapped to undefined in an overriding config layer and a non-undefined value in a lower config layer', () => { + const itemKey = 'random_key'; + baseLayerConfig.set(itemKey, 'random non-undefined value'); + overridingLayerConfig.set(itemKey, undefined); + + expect(layeredConfig.get(itemKey)).toBeUndefined(); + }); + + it('returns the mapped value from the uppermost overriding config layer when the `get` method is called with a key that is mapped in multiple underyling configs', () => { + const itemKey = 'random_key'; + const baseItemValue = 'base value for random_key'; + const overridingItemValue = 'overriding value for random_key'; + + baseLayerConfig.set(itemKey, baseItemValue); + overridingLayerConfig.set(itemKey, overridingItemValue); + + expect(layeredConfig.get(itemKey)).toEqual(overridingItemValue); + }); + + describe('when the option is set to treat `undefined` values as absent', () => { + beforeEach(() => { + layeredConfig = new LayeredConfigStore([baseLayerConfig, overridingLayerConfig], { + valuesConsideredAbsent: [undefined] + }); + }); + + it('returns the first non-undefined mapped value from the uppermost overriding config layer when the `get` method is called with a key that is mapped in multiple underyling configs', () => { + const itemKey = 'random_key'; + const baseItemValue = 'overridden base value'; + + baseLayerConfig.set(itemKey, baseItemValue); + overridingLayerConfig.set(itemKey, undefined); + + expect(layeredConfig.get(itemKey)).toEqual(baseItemValue); + }); + }); + + describe('when the option is set to treat `null` values as absent', () => { + beforeEach(() => { + layeredConfig = new LayeredConfigStore([baseLayerConfig, overridingLayerConfig], { + valuesConsideredAbsent: [null] + }); + }); + + it('returns the first non-null mapped value from the uppermost overriding config layer when the `get` method is called with a key that is mapped in multiple underyling configs', () => { + const itemKey = 'random_key'; + const baseItemValue = 'overridden base value'; + + baseLayerConfig.set(itemKey, baseItemValue); + overridingLayerConfig.set(itemKey, null); + + expect(layeredConfig.get(itemKey)).toEqual(baseItemValue); + }); + }); + + describe('when the option is set to treat empty string values as absent', () => { + beforeEach(() => { + layeredConfig = new LayeredConfigStore([baseLayerConfig, overridingLayerConfig], { + valuesConsideredAbsent: [''] + }); + }); + + it('returns the first non-empty-string mapped value from the uppermost overriding config layer when the `get` method is called with a key that is mapped in multiple underyling configs', () => { + const itemKey = 'random_key'; + const baseItemValue = 'overridden base value'; + + baseLayerConfig.set(itemKey, baseItemValue); + overridingLayerConfig.set(itemKey, ''); + + expect(layeredConfig.get(itemKey)).toEqual(baseItemValue); + }); + }); + + describe('when the option is set to treat a specified object as absent', () => { + const objectTreatedAsAbsent = { randomKey: 'randomValue' }; + + beforeEach(() => { + layeredConfig = new LayeredConfigStore([baseLayerConfig, overridingLayerConfig], { + valuesConsideredAbsent: [objectTreatedAsAbsent] + }); + }); + + it('returns the first mapped value not set for treatment as absent, from the uppermost overriding config layer when the `get` method is called with a key that is mapped in multiple underyling configs', () => { + const itemKey = 'random_key'; + const baseItemValue = 'overridden base value'; + + baseLayerConfig.set(itemKey, baseItemValue); + overridingLayerConfig.set(itemKey, objectTreatedAsAbsent); + + expect(layeredConfig.get(itemKey)).toEqual(baseItemValue); + }); + }); + + describe('when the base config layers are empty', () => { + beforeEach(() => { + baseLayerConfig.clear(); + }); + + it('returns false when the `has` method is called with a key that is not in the overriding layer config', () => { + expect(layeredConfig.has('random_unadded_absent_key')).toBe(false); + }); + + it('returns true when the `has` method is called with a key that maps to a non-undefined value in the overriding layer config', () => { + const itemKey = 'random_key'; + const itemValue = 'value for random_key'; + + overridingLayerConfig.set(itemKey, itemValue); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns true when the `has` method is called with a key that maps to the `undefined` value in the overriding layer config', () => { + const itemKey = 'random_key'; + + overridingLayerConfig.set(itemKey, undefined); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns true when the `has` method is called with a key that maps to the `null` value in the overriding layer config', () => { + const itemKey = 'random_key'; + + overridingLayerConfig.set(itemKey, null); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns undefined when the `get` method is called with a key that is not in the overriding layer config', () => { + expect(layeredConfig.get('random_unadded_absent_key')).toBeUndefined(); + }); + + it('returns the corresponding value when the `get` method is called with the key with which the value is mapped in the overriding layer config', () => { + const itemKey = 'random_key'; + const itemValue = 'value for random_key'; + + overridingLayerConfig.set(itemKey, itemValue); + expect(layeredConfig.get(itemKey)).toEqual(itemValue); + }); + }); + + describe('when the overriding config layers are empty', () => { + beforeEach(() => { + overridingLayerConfig.clear(); + }); + + it('returns false when the `has` method is called with a key that is not in the base layer config', () => { + expect(layeredConfig.has('random_unadded_absent_key')).toBe(false); + }); + + it('returns true when the `has` method is called with a key that maps to a non-undefined value in the base layer config', () => { + const itemKey = 'random_key'; + const itemValue = 'value for random_key'; + + baseLayerConfig.set(itemKey, itemValue); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns true when the `has` method is called with a key that maps to the `undefined` value in the base layer config', () => { + const itemKey = 'random_key'; + + baseLayerConfig.set(itemKey, undefined); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns true when the `has` method is called with a key that maps to the `null` value in the base layer config', () => { + const itemKey = 'random_key'; + + baseLayerConfig.set(itemKey, null); + expect(layeredConfig.has(itemKey)).toBe(true); + }); + + it('returns undefined when the `get` method is called with a key that is not in the base layer config', () => { + expect(layeredConfig.get('random_unadded_absent_key')).toBeUndefined(); + }); + + it('returns the corresponding value when the `get` method is called with the key with which the value is mapped in the base layer config', () => { + const itemKey = 'random_key'; + const itemValue = 'value for random_key'; + + baseLayerConfig.set(itemKey, itemValue); + expect(layeredConfig.get(itemKey)).toEqual(itemValue); + }); + }); + }); +}); diff --git a/test/core/parser/ast/ast-test-file-parser.test.ts b/test/core/parser/ast/ast-test-file-parser.test.ts index 3ae8e96..688b988 100644 --- a/test/core/parser/ast/ast-test-file-parser.test.ts +++ b/test/core/parser/ast/ast-test-file-parser.test.ts @@ -41,9 +41,10 @@ describe('AstTestFileParser', () => { testInterfaceData.forEach(({ testInterfaceName, testInterface, _ }) => { describe(`using the ${testInterfaceName} test interface`, () => { let testParser!: TestFileParser; + let testAndSuiteNodeProcessor!: TestAndSuiteNodeProcessor; beforeEach(() => { - const testAndSuiteNodeProcessor = new TestAndSuiteNodeProcessor( + testAndSuiteNodeProcessor = new TestAndSuiteNodeProcessor( testInterface, new TestDescriptionNodeProcessor(mockLogger), mockLogger @@ -53,20 +54,20 @@ describe('AstTestFileParser', () => { it('correctly parses single-line comments', () => { const fileText = ` - // single-line comment - ${_.describe}('test suite 1', () => { // single-line comment - ${_.it}('test 1', () => { - const msg = 'hello'; + ${_.describe}('test suite 1', () => { // single-line comment - }); + ${_.it}('test 1', () => { + const msg = 'hello'; + // single-line comment + }); + // single-line comment + ${_.it}('test 2', () => { + const msg = 'world!'; + }); + }) // single-line comment - ${_.it}('test 2', () => { - const msg = 'world!'; - }); - }) - // single-line comment - `; + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -118,22 +119,22 @@ describe('AstTestFileParser', () => { it('correctly parses multi-line comments', () => { const fileText = ` - /* multi-line comment with comment opening - and closing on same lines as text */ - ${_.describe}('test suite 1', () => { - /* - multi-line comment - multi-line comment - */ - ${_.it}('test 1', () => { - const msg = 'hello'; - }); - /* multi-line comment on single line */ - ${_.it}('test 2', () => { - const msg = 'world!'; - }); - }) - `; + /* multi-line comment with comment opening + and closing on same lines as text */ + ${_.describe}('test suite 1', () => { + /* + multi-line comment + multi-line comment + */ + ${_.it}('test 1', () => { + const msg = 'hello'; + }); + /* multi-line comment on single line */ + ${_.it}('test 2', () => { + const msg = 'world!'; + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -185,19 +186,19 @@ describe('AstTestFileParser', () => { it('correctly parses commented out tests', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - /* - ${_.it}('commented out test 1') { - test contents - } - */ - ${_.it}('test 1', () => { - // ${_.it}('commented out test 2') { - // test contents - // } - }); - }) - `; + ${_.describe}('test suite 1', () => { + /* + ${_.it}('commented out test 1') { + test contents + } + */ + ${_.it}('test 1', () => { + // ${_.it}('commented out test 2') { + // test contents + // } + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -228,21 +229,21 @@ describe('AstTestFileParser', () => { it('correctly parses a combination of various kinds of comments in the same file', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { - // single-line comments + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { + // single-line comments + /* + multi-line comment + multi-line comment + */ + }); /* - multi-line comment - multi-line comment + ${_.it}('commented out test 1') { + // test contents + }); */ - }); - /* - ${_.it}('commented out test 1') { - // test contents - }); - */ - }) - `; + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -273,12 +274,12 @@ describe('AstTestFileParser', () => { it('correctly parses arrow function tests', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -309,12 +310,12 @@ describe('AstTestFileParser', () => { it('correctly parses non-arrow function tests', () => { const fileText = ` - ${_.describe}('test suite 1', function() { - ${_.it}('test 1', function() { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', function() { + ${_.it}('test 1', function() { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -345,12 +346,12 @@ describe('AstTestFileParser', () => { it('correctly parses async arrow function tests', () => { const fileText = ` - ${_.describe}('test suite 1', async () => { - ${_.it}('test 1', async () => { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', async () => { + ${_.it}('test 1', async () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -381,12 +382,12 @@ describe('AstTestFileParser', () => { it('correctly parses async non-arrow function tests', () => { const fileText = ` - ${_.describe}('test suite 1', async function() { - ${_.it}('test 1', async function() { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', async function() { + ${_.it}('test 1', async function() { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -417,12 +418,51 @@ describe('AstTestFileParser', () => { it('correctly parses test content having typescript type annotations', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { - const with_type_annotation: string = 'hi'; - }); - }) - `; + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { + const with_type_annotation: string = 'hi'; + }); + }) + `; + const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); + + expect(testSuiteFileInfo).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + suite: expect.arrayContaining([ + expect.objectContaining({ + type: TestType.Suite, + description: 'test suite 1', + line: 1, + state: TestDefinitionState.Default, + disabled: false, + file: fakeTestFilePath + }) + ]), + test: expect.objectContaining({ + type: TestType.Test, + description: 'test 1', + line: 2, + state: TestDefinitionState.Default, + disabled: false, + file: fakeTestFilePath + }) + }) + ]) + ); + }); + + it('correctly parses test content having decorators', () => { + const fileText = ` + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { + @fakeDecorator + class FakeClass { + // class contents + } + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -453,14 +493,14 @@ describe('AstTestFileParser', () => { it('correctly parses tests with description on following line', () => { const fileText = ` - ${_.describe}( - 'test suite 1', () => { - ${_.it}( - 'test 1', () => { - // test contents - }); - }) - `; + ${_.describe}( + 'test suite 1', () => { + ${_.it}( + 'test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -491,12 +531,12 @@ describe('AstTestFileParser', () => { it('correctly parses test description containing curly brace', () => { const fileText = ` - ${_.describe}('test { suite 1', () => { - ${_.it}('test } 1', () => { - // test contents - }); - }) - `; + ${_.describe}('test { suite 1', () => { + ${_.it}('test } 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -527,12 +567,12 @@ describe('AstTestFileParser', () => { it('correctly parses test description containing parentheses', () => { const fileText = ` - ${_.describe}('test ( suite 1', () => { - ${_.it}('test ) 1', () => { - // test contents - }); - }) - `; + ${_.describe}('test ( suite 1', () => { + ${_.it}('test ) 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -563,10 +603,10 @@ describe('AstTestFileParser', () => { it('correctly parses single-line test format', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { /* test contents */ }); - }) - `; + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { /* test contents */ }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -599,9 +639,9 @@ describe('AstTestFileParser', () => { const fileText = // First line begins below `${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { /* test contents */ }); - }) - `; + ${_.it}('test 1', () => { /* test contents */ }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -632,16 +672,16 @@ describe('AstTestFileParser', () => { it('correctly parses nested test suites with no identical test descriptions', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.describe}('test suite 2', () => { - ${_.describe}('test suite 3', () => { - ${_.it}('test 1', () => { - // test contents + ${_.describe}('test suite 1', () => { + ${_.describe}('test suite 2', () => { + ${_.describe}('test suite 3', () => { + ${_.it}('test 1', () => { + // test contents + }); }); }); }); - }); - `; + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); @@ -689,23 +729,23 @@ describe('AstTestFileParser', () => { it('correctly parses nested test suites with one or more identical test descriptions', () => { const fileText = ` - ${_.describe}('test suite 1', function () { - ${_.describe}('test suite 1-1', function () { - ${_.describe}('identical inner suite', function () { - ${_.it}('identical inner test', function () { - // test contents + ${_.describe}('test suite 1', function () { + ${_.describe}('test suite 1-1', function () { + ${_.describe}('identical inner suite', function () { + ${_.it}('identical inner test', function () { + // test contents + }) }) }) - }) - ${_.describe}('test suite 1-2', function () { - ${_.describe}('identical inner suite', function () { - ${_.it}('identical inner test', function () { - // test contents + ${_.describe}('test suite 1-2', function () { + ${_.describe}('identical inner suite', function () { + ${_.it}('identical inner test', function () { + // test contents + }) }) }) }) - }) - `; + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); @@ -790,17 +830,17 @@ describe('AstTestFileParser', () => { it('correctly parses file with multiple top level suites', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { - // test contents - }) - }); - ${_.describe}('test suite 2', () => { - ${_.it}('test 2', () => { - // test contents - }) - }); - `; + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { + // test contents + }) + }); + ${_.describe}('test suite 2', () => { + ${_.it}('test 2', () => { + // test contents + }) + }); + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); @@ -853,12 +893,12 @@ describe('AstTestFileParser', () => { it('correctly parses focused suites', () => { const fileText = ` - ${_.fdescribe}('test suite 1', () => { - ${_.it}('test 1', () => { - // test contents - }); - }) - `; + ${_.fdescribe}('test suite 1', () => { + ${_.it}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -889,12 +929,12 @@ describe('AstTestFileParser', () => { it('correctly parses focused tests', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.fit}('test 1', () => { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', () => { + ${_.fit}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -925,12 +965,12 @@ describe('AstTestFileParser', () => { it('correctly parses disabled suites', () => { const fileText = ` - ${_.xdescribe}('test suite 1', () => { - ${_.it}('test 1', () => { - // test contents - }); - }) - `; + ${_.xdescribe}('test suite 1', () => { + ${_.it}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( @@ -961,12 +1001,12 @@ describe('AstTestFileParser', () => { it('correctly parses disabled tests', () => { const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.xit}('test 1', () => { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', () => { + ${_.xit}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo).toEqual( diff --git a/test/core/parser/regexp/regexp-test-file-parser.test.ts b/test/core/parser/regexp/regexp-test-file-parser.test.ts index c558f25..abdb9a6 100644 --- a/test/core/parser/regexp/regexp-test-file-parser.test.ts +++ b/test/core/parser/regexp/regexp-test-file-parser.test.ts @@ -39,20 +39,20 @@ describe('RegexpTestFileParser', () => { it('correctly parses single-line comments', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - // single-line comment - ${_.describe}('test suite 1', () => { // single-line comment - ${_.it}('test 1', () => { - const msg = 'hello'; + ${_.describe}('test suite 1', () => { // single-line comment - }); + ${_.it}('test 1', () => { + const msg = 'hello'; + // single-line comment + }); + // single-line comment + ${_.it}('test 2', () => { + const msg = 'world!'; + }); + }) // single-line comment - ${_.it}('test 2', () => { - const msg = 'world!'; - }); - }) - // single-line comment - `; + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -72,22 +72,22 @@ describe('RegexpTestFileParser', () => { it('correctly parses multi-line comments', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - /* multi-line comment with comment opening - and closing on same lines as text */ - ${_.describe}('test suite 1', () => { - /* - multi-line comment - multi-line comment - */ - ${_.it}('test 1', () => { - const msg = 'hello'; - }); - /* multi-line comment on single line */ - ${_.it}('test 2', () => { - const msg = 'world!'; - }); - }) - `; + /* multi-line comment with comment opening + and closing on same lines as text */ + ${_.describe}('test suite 1', () => { + /* + multi-line comment + multi-line comment + */ + ${_.it}('test 1', () => { + const msg = 'hello'; + }); + /* multi-line comment on single line */ + ${_.it}('test 2', () => { + const msg = 'world!'; + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -107,19 +107,19 @@ describe('RegexpTestFileParser', () => { it('correctly parses commented out tests', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', () => { - /* - ${_.it}('commented out test 1') { - test contents - } - */ - ${_.it}('test 1', () => { - // ${_.it}('commented out test 2') { - // test contents - // } - }); - }) - `; + ${_.describe}('test suite 1', () => { + /* + ${_.it}('commented out test 1') { + test contents + } + */ + ${_.it}('test 1', () => { + // ${_.it}('commented out test 2') { + // test contents + // } + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -138,21 +138,21 @@ describe('RegexpTestFileParser', () => { it('correctly parses a combination of various kinds of comments in the same file', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { - // single-line comments + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { + // single-line comments + /* + multi-line comment + multi-line comment + */ + }); /* - multi-line comment - multi-line comment + ${_.it}('commented out test 1') { + // test contents + }); */ - }); - /* - ${_.it}('commented out test 1') { - // test contents - }); - */ - }) - `; + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -171,12 +171,12 @@ describe('RegexpTestFileParser', () => { it('correctly parses non-arrow function tests', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', function() { - ${_.it}('test 1', function() { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', function() { + ${_.it}('test 1', function() { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -195,14 +195,14 @@ describe('RegexpTestFileParser', () => { it('correctly parses tests with description on following line', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}( - 'test suite 1', function() { - ${_.it}( - 'test 1', function() { - // test contents - }); - }) - `; + ${_.describe}( + 'test suite 1', function() { + ${_.it}( + 'test 1', function() { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -226,12 +226,12 @@ describe('RegexpTestFileParser', () => { it('correctly parses test description containing curly brace', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test { suite 1', function() { - ${_.it}('test } 1', function() { - // test contents - }); - }) - `; + ${_.describe}('test { suite 1', function() { + ${_.it}('test } 1', function() { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -250,12 +250,12 @@ describe('RegexpTestFileParser', () => { it('correctly parses test description containing parentheses', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test ( suite 1', function() { - ${_.it}('test ) 1', function() { - // test contents - }); - }) - `; + ${_.describe}('test ( suite 1', function() { + ${_.it}('test ) 1', function() { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -274,10 +274,10 @@ describe('RegexpTestFileParser', () => { it('correctly parses single-line test format', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { /* test contents */ }); - }) - `; + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { /* test contents */ }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -298,9 +298,9 @@ describe('RegexpTestFileParser', () => { const fileText = // First line begins below `${_.describe}('test suite 1', () => { - ${_.it}('test 1', () => { /* test contents */ }); - }) - `; + ${_.it}('test 1', () => { /* test contents */ }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -319,16 +319,16 @@ describe('RegexpTestFileParser', () => { it('correctly parses nested test suites with no identical test descriptions', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.describe}('test suite 2', () => { - ${_.describe}('test suite 3', () => { - ${_.it}('test 1', () => { - // test contents + ${_.describe}('test suite 1', () => { + ${_.describe}('test suite 2', () => { + ${_.describe}('test suite 3', () => { + ${_.it}('test 1', () => { + // test contents + }); }); }); }); - }); - `; + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); @@ -360,23 +360,23 @@ describe('RegexpTestFileParser', () => { it('correctly parses nested test suites with one or more identical test descriptions', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', function () { - ${_.describe}('test suite 1-1', function () { - ${_.describe}('identical inner suite', function () { - ${_.it}('identical inner test', function () { - // test contents + ${_.describe}('test suite 1', function () { + ${_.describe}('test suite 1-1', function () { + ${_.describe}('identical inner suite', function () { + ${_.it}('identical inner test', function () { + // test contents + }) }) }) - }) - ${_.describe}('test suite 1-2', function () { - ${_.describe}('identical inner suite', function () { - ${_.it}('identical inner test', function () { - // test contents + ${_.describe}('test suite 1-2', function () { + ${_.describe}('identical inner suite', function () { + ${_.it}('identical inner test', function () { + // test contents + }) }) }) }) - }) - `; + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); @@ -431,17 +431,17 @@ describe('RegexpTestFileParser', () => { it('correctly parses file with multiple top level suites', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', { - ${_.it}('test 1', () => { - // test contents - }) - }); - ${_.describe}('test suite 2', { - ${_.it}('test 2', () => { - // test contents - }) - }); - `; + ${_.describe}('test suite 1', { + ${_.it}('test 1', () => { + // test contents + }) + }); + ${_.describe}('test suite 2', { + ${_.it}('test 2', () => { + // test contents + }) + }); + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); @@ -468,12 +468,12 @@ describe('RegexpTestFileParser', () => { it('correctly parses focused suites', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.fdescribe}('test suite 1', () => { - ${_.it}('test 1', () => { - // test contents - }); - }) - `; + ${_.fdescribe}('test suite 1', () => { + ${_.it}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -492,12 +492,12 @@ describe('RegexpTestFileParser', () => { it('correctly parses focused tests', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.fit}('test 1', () => { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', () => { + ${_.fit}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -516,12 +516,12 @@ describe('RegexpTestFileParser', () => { it('correctly parses disabled suites', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.xdescribe}('test suite 1', () => { - ${_.it}('test 1', () => { - // test contents - }); - }) - `; + ${_.xdescribe}('test suite 1', () => { + ${_.it}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ @@ -540,12 +540,12 @@ describe('RegexpTestFileParser', () => { it('correctly parses disabled tests', () => { const testParser = new RegexpTestFileParser(testInterface, mockLogger); const fileText = ` - ${_.describe}('test suite 1', () => { - ${_.xit}('test 1', () => { - // test contents - }); - }) - `; + ${_.describe}('test suite 1', () => { + ${_.xit}('test 1', () => { + // test contents + }); + }) + `; const testSuiteFileInfo = testParser.parseFileText(fileText, fakeTestFilePath); expect(testSuiteFileInfo[TestNodeType.Suite]).toEqual([ diff --git a/test/core/test-locator.test.ts b/test/core/test-locator.test.ts index 8e8138f..d001ac3 100644 --- a/test/core/test-locator.test.ts +++ b/test/core/test-locator.test.ts @@ -1,7 +1,7 @@ import { mock, MockProxy } from 'jest-mock-extended'; import { TestDefinitionProvider } from '../../src/core/base/test-definition-provider'; import { TestLocator } from '../../src/core/test-locator'; -import { FileHandler } from '../../src/util/file-handler'; +import { FileHandler } from '../../src/util/filesystem/file-handler'; import { Logger } from '../../src/util/logging/logger'; import { withUnixStyleSeparator } from '../test-util'; diff --git a/test/frameworks/angular/angular-utils.test.ts b/test/frameworks/angular/angular-utils.test.ts index 307d6ed..f2c4beb 100644 --- a/test/frameworks/angular/angular-utils.test.ts +++ b/test/frameworks/angular/angular-utils.test.ts @@ -1,54 +1,58 @@ +import { mock, MockProxy } from 'jest-mock-extended'; import { getAngularWorkspaceInfo } from '../../../src/frameworks/angular/angular-util'; +import { FileHandler } from '../../../src/util/filesystem/file-handler'; +import { Logger } from '../../../src/util/logging/logger'; import { normalizePath } from '../../../src/util/utils'; -jest.mock('fs'); -const fs = require('fs'); // require necessary for jest to work +describe('Angular Utils', () => { + let mockFileHandler: MockProxy; + let mockLogger: MockProxy; -const logger = { - debug: jest.fn(), - error: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - trace: jest.fn(), - dispose: jest.fn() -}; + beforeEach(() => { + mockFileHandler = mock(); + mockLogger = mock(); + }); + + describe('getAngularWorkspaceInfo', () => { + const { platform, angularConfigRootPath } = + process.platform === 'win32' + ? { platform: 'Windows', angularConfigRootPath: 'C:\\angular-config-root-path' } + : { platform: 'Unix', angularConfigRootPath: '/angular-config-root-path' }; -describe('angular-utils.getAngularWorkspaceInfo', () => { - const angularConfigRootPaths = [ - ['Unix', '/angular-config-root-path'], - ['Windows', 'C:\\angular-config-root-path'] - ]; + it(`should check for an 'angular.json' config file in the specified config root path (${platform})`, () => { + mockFileHandler.existsSync.mockReturnValue(false); + const expectedAngularConfigFile = normalizePath(`${angularConfigRootPath}/angular.json`); + getAngularWorkspaceInfo(angularConfigRootPath, mockFileHandler, mockLogger); - beforeEach(jest.clearAllMocks); + expect(mockFileHandler.existsSync).toHaveBeenCalledWith(expectedAngularConfigFile); + }); - angularConfigRootPaths.forEach(item => { - const [platform, angularConfigRootPath] = item; + it(`should check for an '.angular-cli.json' config file in the specified config root path (${platform})`, () => { + mockFileHandler.existsSync.mockReturnValue(false); + const expectedAngularCliConfigFile = normalizePath(`${angularConfigRootPath}/.angular-cli.json`); + getAngularWorkspaceInfo(angularConfigRootPath, mockFileHandler, mockLogger); + + expect(mockFileHandler.existsSync).toHaveBeenCalledWith(expectedAngularCliConfigFile); + }); - it(`should return undefined if no angular definition exists (${platform})`, () => { - fs.existsSync.mockReturnValue(false); - const result = getAngularWorkspaceInfo(angularConfigRootPath, logger); + it(`should return undefined when neither angular config exists in the config root path (${platform})`, () => { + mockFileHandler.existsSync.mockReturnValue(false); + const result = getAngularWorkspaceInfo(angularConfigRootPath, mockFileHandler, mockLogger); expect(result).toBeUndefined(); - expect(fs.existsSync).toHaveBeenCalledWith(normalizePath(`${angularConfigRootPath}/angular.json`)); - expect(fs.existsSync).toHaveBeenCalledWith(normalizePath(`${angularConfigRootPath}/.angular-cli.json`)); }); - it(`should return undefined if the angular definitions are invalid (${platform})`, () => { - fs.existsSync.mockReturnValue(true); - fs.readFileSync.mockImplementation(() => 'invalid json'); - const result = getAngularWorkspaceInfo(angularConfigRootPath, logger); + it(`should return undefined when the angular config is invalid (${platform})`, () => { + mockFileHandler.existsSync.mockReturnValue(true); + mockFileHandler.readFileSync.mockReturnValue('invalid'); + const result = getAngularWorkspaceInfo(angularConfigRootPath, mockFileHandler, mockLogger); expect(result).toBeUndefined(); - expect(fs.existsSync).toHaveBeenCalledWith(normalizePath(`${angularConfigRootPath}/angular.json`)); - expect(fs.existsSync).toHaveBeenCalledWith(normalizePath(`${angularConfigRootPath}/.angular-cli.json`)); - expect(fs.readFileSync).toHaveBeenCalledWith(normalizePath(`${angularConfigRootPath}/angular.json`), 'utf-8'); - expect(fs.readFileSync).toHaveBeenCalledWith( - normalizePath(`${angularConfigRootPath}/.angular-cli.json`), - 'utf-8' - ); }); - it('should return a valid configuration from angular.json', () => { - fs.existsSync.mockImplementation((name: any) => (name as string).endsWith('angular.json')); - fs.readFileSync.mockImplementation(() => + it(`should successfully get project info from a valid 'angular.json' config file in the config root path (${platform})`, () => { + const angularConfigFile = normalizePath(`${angularConfigRootPath}/angular.json`); + mockFileHandler.existsSync.calledWith(angularConfigFile).mockReturnValue(true); + + mockFileHandler.readFileSync.calledWith(angularConfigFile).mockReturnValue( JSON.stringify({ // defaultProject: 'default-project', // deprecated! projects: { @@ -79,25 +83,32 @@ describe('angular-utils.getAngularWorkspaceInfo', () => { } }) ); - const result = getAngularWorkspaceInfo(angularConfigRootPath, logger); - expect(result).toBeDefined(); - expect(result!.defaultProject).toEqual(result!.projects[0]); - expect(result!.projects.length).toBe(2); - expect(result!.projects[0].name).toBe('project-1'); - expect(result!.projects[0].rootPath).toBe(normalizePath(angularConfigRootPath + '/project-1')); - expect(result!.projects[0].karmaConfigPath).toBe( - normalizePath(angularConfigRootPath + '/project-1/karma.conf.js') + + const workspaceInfoResult = getAngularWorkspaceInfo(angularConfigRootPath, mockFileHandler, mockLogger); + + expect(workspaceInfoResult).toBeDefined(); + expect(workspaceInfoResult!.projects).toHaveLength(2); + + expect(workspaceInfoResult!.projects[0].name).toEqual('project-1'); + expect(workspaceInfoResult!.projects[0].rootPath).toEqual(normalizePath(`${angularConfigRootPath}/project-1`)); + expect(workspaceInfoResult!.projects[0].karmaConfigPath).toEqual( + normalizePath(`${angularConfigRootPath}/project-1/karma.conf.js`) ); - expect(result!.projects[1].name).toBe('project-2'); - expect(result!.projects[1].rootPath).toBe(normalizePath(angularConfigRootPath + '/project-2')); - expect(result!.projects[1].karmaConfigPath).toBe( - normalizePath(angularConfigRootPath + '/project-2/karma.conf.js') + + expect(workspaceInfoResult!.projects[1].name).toEqual('project-2'); + expect(workspaceInfoResult!.projects[1].rootPath).toEqual(normalizePath(`${angularConfigRootPath}/project-2`)); + expect(workspaceInfoResult!.projects[1].karmaConfigPath).toEqual( + normalizePath(`${angularConfigRootPath}/project-2/karma.conf.js`) ); + + expect(workspaceInfoResult!.defaultProject).toEqual(workspaceInfoResult!.projects[0]); }); - it('should return a valid configuration from .angular-cli.json', () => { - fs.existsSync.mockImplementation((name: any) => (name as string).endsWith('.angular-cli.json')); - fs.readFileSync.mockImplementation(() => + it(`should successfully get project info from a valid '.angular-cli.json' config file in the config root path (${platform})`, () => { + const angularCliConfigFile = normalizePath(`${angularConfigRootPath}/.angular-cli.json`); + mockFileHandler.existsSync.calledWith(angularCliConfigFile).mockReturnValue(true); + + mockFileHandler.readFileSync.calledWith(angularCliConfigFile).mockReturnValue( JSON.stringify({ project: { name: 'my-app' @@ -118,16 +129,25 @@ describe('angular-utils.getAngularWorkspaceInfo', () => { } }) ); - const result = getAngularWorkspaceInfo(angularConfigRootPath, logger); - expect(result).toBeDefined(); - expect(result!.defaultProject).toEqual(result!.projects[0]); - expect(result!.projects.length).toBe(2); - expect(result!.projects[0].name).toBe('my-app'); - expect(result!.projects[0].rootPath).toBe(normalizePath(angularConfigRootPath + '/src-1')); - expect(result!.projects[0].karmaConfigPath).toBe(normalizePath(angularConfigRootPath + '/karma.conf.js')); - expect(result!.projects[1].name).toBe('my-app-2'); - expect(result!.projects[1].rootPath).toBe(normalizePath(angularConfigRootPath + '/src-2')); - expect(result!.projects[1].karmaConfigPath).toBe(normalizePath(angularConfigRootPath + '/karma.conf.js')); + + const workspaceInfoResult = getAngularWorkspaceInfo(angularConfigRootPath, mockFileHandler, mockLogger); + + expect(workspaceInfoResult).toBeDefined(); + expect(workspaceInfoResult!.projects).toHaveLength(2); + + expect(workspaceInfoResult!.projects[0].name).toEqual('my-app'); + expect(workspaceInfoResult!.projects[0].rootPath).toEqual(normalizePath(`${angularConfigRootPath}/src-1`)); + expect(workspaceInfoResult!.projects[0].karmaConfigPath).toEqual( + normalizePath(`${angularConfigRootPath}/karma.conf.js`) + ); + + expect(workspaceInfoResult!.projects[1].name).toEqual('my-app-2'); + expect(workspaceInfoResult!.projects[1].rootPath).toEqual(normalizePath(`${angularConfigRootPath}/src-2`)); + expect(workspaceInfoResult!.projects[1].karmaConfigPath).toEqual( + normalizePath(`${angularConfigRootPath}/karma.conf.js`) + ); + + expect(workspaceInfoResult!.defaultProject).toEqual(workspaceInfoResult!.projects[0]); }); }); }); diff --git a/test/frameworks/karma/config/karma-configurator.test.ts b/test/frameworks/karma/config/karma-config-loader.test.ts similarity index 100% rename from test/frameworks/karma/config/karma-configurator.test.ts rename to test/frameworks/karma/config/karma-config-loader.test.ts diff --git a/test/project-factory.test.ts b/test/project-factory.test.ts new file mode 100644 index 0000000..b19c3f9 --- /dev/null +++ b/test/project-factory.test.ts @@ -0,0 +1,55 @@ +import { mock, MockProxy } from 'jest-mock-extended'; +import { Uri, WorkspaceFolder } from 'vscode'; +import { ExternalConfigSetting } from '../src/core/config/config-setting'; +import { SimpleMutableConfigStore } from '../src/core/config/simple-mutable-config-store'; +import { WorkspaceFolderConfigResolver } from '../src/core/config/workspace-folder-config-resolver'; +import { ProjectFactory } from '../src/project-factory'; +import { FileHandler } from '../src/util/filesystem/file-handler'; +import { Logger } from '../src/util/logging/logger'; +import { Writeable } from './test-util'; + +describe('Project Factory', () => { + let configStore: SimpleMutableConfigStore; + let mockWorkspaceFolderConfigResolver: MockProxy; + let mockFileHandler: MockProxy; + let projectFactory: ProjectFactory; + + beforeEach(() => { + configStore = new SimpleMutableConfigStore(undefined, { [ExternalConfigSetting.EnableExtension]: true }); + mockWorkspaceFolderConfigResolver = mock(); + mockWorkspaceFolderConfigResolver.resolveConfig.mockImplementation(() => configStore); + mockFileHandler = mock(); + projectFactory = new ProjectFactory(mockFileHandler, mockWorkspaceFolderConfigResolver, mock()); + }); + + describe('createProjectsForWorkspaceFolders method', () => { + let workspaceFolder: WorkspaceFolder; + + beforeEach(() => { + workspaceFolder = mock(); + }); + + describe('using a non-file scheme uri workspace', () => { + beforeEach(() => { + (workspaceFolder.uri as Writeable).scheme = 'not-file-scheme'; + }); + + it('does not create a project for the workspace', () => { + const projects = projectFactory.createProjectsForWorkspaceFolders(workspaceFolder); + expect(projects).toEqual([]); + }); + }); + + describe('using a file scheme uri workspace', () => { + beforeEach(() => { + (workspaceFolder.uri as Writeable).scheme = 'file'; + (workspaceFolder.uri as Writeable).fsPath = '/fake/workspace/path'; + }); + + it('creates a project for the workspace', () => { + const projects = projectFactory.createProjectsForWorkspaceFolders(workspaceFolder); + expect(projects).toEqual([]); + }); + }); + }); +}); diff --git a/test/util/process/command-line-process-handler.test.ts b/test/util/process/command-line-process-handler.test.ts deleted file mode 100644 index fce00fb..0000000 --- a/test/util/process/command-line-process-handler.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { ChildProcess, spawn } from 'child_process'; -import { mock } from 'jest-mock-extended'; -import treeKill from 'tree-kill'; -import { Logger } from '../../../src/util/logging/logger'; -import { Process } from '../../../src/util/process/process'; -import { SimpleProcess } from '../../../src/util/process/simple-process'; -import { Writeable } from '../../test-util'; - -jest.mock('child_process'); -jest.mock('tree-kill'); - -const mockSpawn = spawn as jest.MockedFunction; -const mockTreeKill = treeKill as jest.MockedFunction; - -describe('CommandLineProcessHandler', () => { - let mockLogger: Logger; - let commandLineHandler: Process; - - beforeEach(() => { - mockSpawn.mockClear(); - mockTreeKill.mockClear(); - mockLogger = mock(); - }); - - describe('when instantiated using a given command and arguments', () => { - let mockChildProcess: Writeable; - let processCommand: string; - let processArgs: string[]; - let listeners: Map any>; - - beforeEach(() => { - listeners = new Map(); - processCommand = 'randomCommandName'; - processArgs = ['arg1', 'arg2']; - - mockChildProcess = { - pid: 1000, - on: jest.fn((event: any, handler: (...args: any[]) => void) => { - listeners.set(event, handler); - return mockChildProcess; - }) - } as any; - - mockSpawn.mockReturnValue(mockChildProcess); - mockTreeKill.mockImplementation((pid, signal, callback?: (error?: Error) => void) => callback?.()); - }); - - it('a process is spawned using the specified command and arguments', () => { - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - expect(mockSpawn).toHaveBeenCalledTimes(1); - expect(mockSpawn).toHaveBeenCalledWith(processCommand, processArgs, expect.objectContaining({})); - }); - - it('the execution for the process is started', () => { - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - expect(commandLineHandler.execution().isStarted()).toBe(true); - }); - - it('an error handler is registered', () => { - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - expect(mockChildProcess.on).toHaveBeenCalledWith('error', expect.anything()); - }); - - it('an exit handler is registered', () => { - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - expect(mockChildProcess.on).toHaveBeenCalledWith('exit', expect.anything()); - }); - - it('the PID for the process is killed with SIGTERM when the stop method is subsequently called', () => { - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - commandLineHandler.stop(); - expect(mockTreeKill).toHaveBeenCalledTimes(1); - expect(mockTreeKill).toHaveBeenCalledWith(mockChildProcess.pid, 'SIGTERM', expect.anything()); - }); - - it('the execution for the process is ended if an exit event is emitted for the process', () => { - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - const registeredExitListener = listeners.get('exit'); - registeredExitListener?.(); - - expect(commandLineHandler.execution().isEnded()).toBe(true); - }); - - it('the process execution indicates successful start if the child process has a pid', async () => { - (mockChildProcess as Partial>).pid = 1000; - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - - await expect(commandLineHandler.execution().started()).resolves.not.toThrow(); - }); - - it('the process execution indicates failure to start if the child process does not have a pid when process errors', async () => { - (mockChildProcess as Partial>).pid = undefined; - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - commandLineHandler.execution().started().suppressUnhandledRejections(); - - const errorReason = 'random failure reason'; - const registeredErrorListener = listeners.get('error'); - registeredErrorListener?.(errorReason); - - await expect(commandLineHandler.execution().started()).rejects.toBe(errorReason); - }); - - it('the process execution indicates failure after start if the child process has a pid when process errors', async () => { - (mockChildProcess as Partial>).pid = 1000; - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - commandLineHandler.execution().ended().suppressUnhandledRejections(); - - const errorReason = 'random failure reason'; - const registeredErrorListener = listeners.get('error'); - registeredErrorListener?.(errorReason); - - await expect(commandLineHandler.execution().started()).resolves.not.toThrow(); - await expect(commandLineHandler.execution().ended()).rejects.toBe(errorReason); - }); - - it('the `windowsHide` spawn option is set to true for the spawned child process when not provided', () => { - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger); - - expect(mockSpawn).toHaveBeenCalledTimes(1); - expect(mockSpawn).toHaveBeenCalledWith( - processCommand, - processArgs, - expect.objectContaining({ windowsHide: true }) - ); - }); - - it('the `windowsHide` spawn option is set to false for the spawned child process when explicitly false in spawn options', () => { - commandLineHandler = new SimpleProcess(processCommand, processArgs, mockLogger, { - windowsHide: false - }); - - expect(mockSpawn).toHaveBeenCalledTimes(1); - expect(mockSpawn).toHaveBeenCalledWith( - processCommand, - processArgs, - expect.objectContaining({ windowsHide: false }) - ); - }); - }); -}); diff --git a/test/util/process/simple-process.test.ts b/test/util/process/simple-process.test.ts new file mode 100644 index 0000000..d2c15ff --- /dev/null +++ b/test/util/process/simple-process.test.ts @@ -0,0 +1,159 @@ +import { ChildProcess, spawn } from 'child_process'; +import { mock } from 'jest-mock-extended'; +import treeKill from 'tree-kill'; +import { Logger } from '../../../src/util/logging/logger'; +import { Process } from '../../../src/util/process/process'; +import { SimpleProcess } from '../../../src/util/process/simple-process'; +import { Writeable } from '../../test-util'; + +jest.mock('child_process'); +jest.mock('tree-kill'); + +const mockSpawn = spawn as jest.MockedFunction; +const mockTreeKill = treeKill as jest.MockedFunction; + +describe('SimpleProcess', () => { + let mockLogger: Logger; + let simpleProcess: Process; + + beforeEach(() => { + mockSpawn.mockClear(); + mockTreeKill.mockClear(); + mockLogger = mock(); + }); + + describe('when instantiated using a given command and arguments', () => { + let mockChildProcess: Writeable; + let processCommand: string; + let processArgs: string[]; + let listeners: Map any>; + + beforeEach(() => { + listeners = new Map(); + processCommand = 'randomCommandName'; + processArgs = ['arg1', 'arg2']; + + mockChildProcess = { + pid: 1000, + on: jest.fn((event: any, handler: (...args: any[]) => void) => { + listeners.set(event, handler); + return mockChildProcess; + }) + } as any; + + mockSpawn.mockReturnValue(mockChildProcess); + mockTreeKill.mockImplementation((pid, signal, callback?: (error?: Error) => void) => callback?.()); + }); + + it('spawns a process using the specified command and arguments', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn).toHaveBeenCalledWith(processCommand, processArgs, expect.objectContaining({})); + }); + + it('starts the execution for the process', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + expect(simpleProcess.execution().isStarted()).toBe(true); + }); + + it('registers an error handler', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + expect(mockChildProcess.on).toHaveBeenCalledWith('error', expect.anything()); + }); + + it('registers an exit handler', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + expect(mockChildProcess.on).toHaveBeenCalledWith('exit', expect.anything()); + }); + + it('kills the PID for the process with SIGTERM when the stop method is called', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + simpleProcess.stop(); + expect(mockTreeKill).toHaveBeenCalledTimes(1); + expect(mockTreeKill).toHaveBeenCalledWith(mockChildProcess.pid, 'SIGTERM', expect.anything()); + }); + + it('kills the PID for the process with SIGKILL when the kill method is called', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + simpleProcess.kill(); + expect(mockTreeKill).toHaveBeenCalledTimes(1); + expect(mockTreeKill).toHaveBeenCalledWith(mockChildProcess.pid, 'SIGKILL', expect.anything()); + }); + + it('has an end-succeeded execution state if the process exits with a zero exit code', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + const registeredExitListener = listeners.get('exit'); + registeredExitListener?.(0); + + expect(simpleProcess.execution().isEnded()).toBe(true); + }); + + it('has an end-failed execution state if the process exits with a non-zero exit code', async () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + simpleProcess.execution().ended().suppressUnhandledRejections(); + + const randomNonZeroExitCode = 5; + const failureReason = `Process exited with non-zero status code ${randomNonZeroExitCode}`; + const registeredExitListener = listeners.get('exit'); + registeredExitListener?.(randomNonZeroExitCode); + + await expect(simpleProcess.execution().ended()).rejects.toBe(failureReason); + }); + + it('has a start-succeeded execution state if the child process has a pid', async () => { + (mockChildProcess as Partial>).pid = 1000; + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + + await expect(simpleProcess.execution().started()).resolves.not.toThrow(); + }); + + it('has a start-failed execution state if the child process does not have a pid when the process errors', async () => { + (mockChildProcess as Partial>).pid = undefined; + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + simpleProcess.execution().started().suppressUnhandledRejections(); + + const errorReason = 'random failure reason'; + const registeredErrorListener = listeners.get('error'); + registeredErrorListener?.(errorReason); + + await expect(simpleProcess.execution().started()).rejects.toBe(errorReason); + }); + + it('has an end-failed execution state if the child process has a pid when the process errors', async () => { + (mockChildProcess as Partial>).pid = 1000; + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + simpleProcess.execution().ended().suppressUnhandledRejections(); + + const errorReason = 'random failure reason'; + const registeredErrorListener = listeners.get('error'); + registeredErrorListener?.(errorReason); + + await expect(simpleProcess.execution().started()).resolves.not.toThrow(); + await expect(simpleProcess.execution().ended()).rejects.toBe(errorReason); + }); + + it('has the `windowsHide` spawn option for the spawned child process as `true` when not provided', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn).toHaveBeenCalledWith( + processCommand, + processArgs, + expect.objectContaining({ windowsHide: true }) + ); + }); + + it('has the `windowsHide` spawn option for the spawned child process as `false` when explicitly set to `false` in spawn options', () => { + simpleProcess = new SimpleProcess(processCommand, processArgs, mockLogger, { + windowsHide: false + }); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + expect(mockSpawn).toHaveBeenCalledWith( + processCommand, + processArgs, + expect.objectContaining({ windowsHide: false }) + ); + }); + }); +}); From bb33c8de665ab1d583cf6ce62e4c37393cb316d3 Mon Sep 17 00:00:00 2001 From: lucono Date: Thu, 25 Aug 2022 10:12:33 -0400 Subject: [PATCH 2/5] Maintain support for newly deprecated 'projects' setting --- src/core/config/config-setting.ts | 1 + src/project-factory.ts | 25 +++++++++++++------- test/project-factory.test.ts | 39 ++++++++++++++++++++++++------- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/core/config/config-setting.ts b/src/core/config/config-setting.ts index 8525426..6c2f00f 100644 --- a/src/core/config/config-setting.ts +++ b/src/core/config/config-setting.ts @@ -13,6 +13,7 @@ export enum ExternalConfigSetting { EnableExtension = 'enableExtension', ProjectType = 'projectType', ProjectWorkspaces = 'projectWorkspaces', + Projects = 'projects', // FIXME: Deprecated - remove RootPath = 'rootPath', ProjectRootPath = 'projectRootPath', // FIXME: Deprecated - remove KarmaConfFilePath = 'karmaConfFilePath' diff --git a/src/project-factory.ts b/src/project-factory.ts index f962a6e..daa8f45 100644 --- a/src/project-factory.ts +++ b/src/project-factory.ts @@ -74,15 +74,24 @@ export class ProjectFactory implements Disposable { `One or more inclusion conditions were satisfied: ${workspaceFolderPath}` ); - let configuredProjects: (string | ProjectSpecificConfig)[] = + const configuredProjectWorkspaces: (string | ProjectSpecificConfig)[] = workspaceConfig.get(ExternalConfigSetting.ProjectWorkspaces) ?? []; - if (configuredProjects.length === 0) { - const deprecatedProjectRootPath = asNonBlankStringOrUndefined( - workspaceConfig.get(ExternalConfigSetting.ProjectRootPath) - ); - configuredProjects = deprecatedProjectRootPath ? [deprecatedProjectRootPath] : ['']; - } + const configuredDeprecatedProjects: (string | ProjectSpecificConfig)[] = + workspaceConfig.get(ExternalConfigSetting.Projects) ?? []; + + const configuredDeprecatedProjectRootPath = asNonBlankStringOrUndefined( + workspaceConfig.get(ExternalConfigSetting.ProjectRootPath) + ); + + const configuredProjects = + configuredProjectWorkspaces.length > 0 + ? configuredProjectWorkspaces + : configuredDeprecatedProjects.length > 0 + ? configuredDeprecatedProjects + : configuredDeprecatedProjectRootPath + ? [configuredDeprecatedProjectRootPath] + : ['']; const mappedProjectPaths = new Set(); @@ -170,7 +179,7 @@ export class ProjectFactory implements Disposable { ); } - const isAngularProject = angularWorkspace && projectType !== ProjectType.Karma; + const isAngularProject = angularWorkspace !== undefined && projectType !== ProjectType.Karma; this.logger.info( () => diff --git a/test/project-factory.test.ts b/test/project-factory.test.ts index b19c3f9..b22d853 100644 --- a/test/project-factory.test.ts +++ b/test/project-factory.test.ts @@ -1,11 +1,13 @@ import { mock, MockProxy } from 'jest-mock-extended'; import { Uri, WorkspaceFolder } from 'vscode'; +import { ProjectType } from '../src/core/base/project-type'; import { ExternalConfigSetting } from '../src/core/config/config-setting'; import { SimpleMutableConfigStore } from '../src/core/config/simple-mutable-config-store'; import { WorkspaceFolderConfigResolver } from '../src/core/config/workspace-folder-config-resolver'; import { ProjectFactory } from '../src/project-factory'; import { FileHandler } from '../src/util/filesystem/file-handler'; import { Logger } from '../src/util/logging/logger'; +import { WorkspaceProject } from '../src/workspace'; import { Writeable } from './test-util'; describe('Project Factory', () => { @@ -16,6 +18,7 @@ describe('Project Factory', () => { beforeEach(() => { configStore = new SimpleMutableConfigStore(undefined, { [ExternalConfigSetting.EnableExtension]: true }); + mockWorkspaceFolderConfigResolver = mock(); mockWorkspaceFolderConfigResolver.resolveConfig.mockImplementation(() => configStore); mockFileHandler = mock(); @@ -23,32 +26,52 @@ describe('Project Factory', () => { }); describe('createProjectsForWorkspaceFolders method', () => { - let workspaceFolder: WorkspaceFolder; + let mockWorkspaceFolder: WorkspaceFolder; beforeEach(() => { - workspaceFolder = mock(); + configStore.set(ExternalConfigSetting.EnableExtension, true); + configStore.set(ExternalConfigSetting.KarmaConfFilePath, ''); + + mockWorkspaceFolder = mock(); + (mockWorkspaceFolder.uri as Writeable).fsPath = '/fake/workspace/project'; + mockFileHandler.existsSync.calledWith('/fake/workspace/project').mockReturnValue(true); }); describe('using a non-file scheme uri workspace', () => { beforeEach(() => { - (workspaceFolder.uri as Writeable).scheme = 'not-file-scheme'; + (mockWorkspaceFolder.uri as Writeable).scheme = 'not-file-scheme'; }); it('does not create a project for the workspace', () => { - const projects = projectFactory.createProjectsForWorkspaceFolders(workspaceFolder); + const projects = projectFactory.createProjectsForWorkspaceFolders(mockWorkspaceFolder); expect(projects).toEqual([]); }); }); describe('using a file scheme uri workspace', () => { beforeEach(() => { - (workspaceFolder.uri as Writeable).scheme = 'file'; - (workspaceFolder.uri as Writeable).fsPath = '/fake/workspace/path'; + (mockWorkspaceFolder.uri as Writeable).scheme = 'file'; }); it('creates a project for the workspace', () => { - const projects = projectFactory.createProjectsForWorkspaceFolders(workspaceFolder); - expect(projects).toEqual([]); + const projects = projectFactory.createProjectsForWorkspaceFolders(mockWorkspaceFolder); + + expect(projects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: ProjectType.Karma, + shortName: 'project', + longName: 'project', + namespace: '/fake/workspace/project', + workspaceFolder: mockWorkspaceFolder, + workspaceFolderPath: '/fake/workspace/project', + shortProjectPath: '', + topLevelProjectPath: '/fake/workspace/project', + projectPath: '/fake/workspace/project', + isPrimary: true + }) + ]) + ); }); }); }); From 5d806630e5cbeddf21a6f93fa713bb74ea42cc89 Mon Sep 17 00:00:00 2001 From: lucono Date: Thu, 25 Aug 2022 12:18:54 -0400 Subject: [PATCH 3/5] Fix tests --- test/project-factory.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/project-factory.test.ts b/test/project-factory.test.ts index b22d853..b6726d0 100644 --- a/test/project-factory.test.ts +++ b/test/project-factory.test.ts @@ -27,14 +27,18 @@ describe('Project Factory', () => { describe('createProjectsForWorkspaceFolders method', () => { let mockWorkspaceFolder: WorkspaceFolder; + let mockWorkspaceFolderFsPath: string; beforeEach(() => { configStore.set(ExternalConfigSetting.EnableExtension, true); configStore.set(ExternalConfigSetting.KarmaConfFilePath, ''); + mockWorkspaceFolderFsPath = + process.platform === 'win32' ? 'C:/fake/workspace/project' : '/fake/workspace/project'; + mockWorkspaceFolder = mock(); - (mockWorkspaceFolder.uri as Writeable).fsPath = '/fake/workspace/project'; - mockFileHandler.existsSync.calledWith('/fake/workspace/project').mockReturnValue(true); + (mockWorkspaceFolder.uri as Writeable).fsPath = mockWorkspaceFolderFsPath; + mockFileHandler.existsSync.calledWith(mockWorkspaceFolderFsPath).mockReturnValue(true); }); describe('using a non-file scheme uri workspace', () => { @@ -62,12 +66,12 @@ describe('Project Factory', () => { type: ProjectType.Karma, shortName: 'project', longName: 'project', - namespace: '/fake/workspace/project', + namespace: mockWorkspaceFolderFsPath, workspaceFolder: mockWorkspaceFolder, - workspaceFolderPath: '/fake/workspace/project', + workspaceFolderPath: mockWorkspaceFolderFsPath, shortProjectPath: '', - topLevelProjectPath: '/fake/workspace/project', - projectPath: '/fake/workspace/project', + topLevelProjectPath: mockWorkspaceFolderFsPath, + projectPath: mockWorkspaceFolderFsPath, isPrimary: true }) ]) From e7d109faed8aa1b424788aafd158d33d34d5a013 Mon Sep 17 00:00:00 2001 From: lucono Date: Thu, 25 Aug 2022 17:52:04 -0400 Subject: [PATCH 4/5] Fix support for angle bracket Typescript cast syntax --- src/core/parser/ast/ast-test-file-parser.ts | 35 ++++++--- .../parser/ast/ast-test-file-parser.test.ts | 78 +++++++++++++++++++ 2 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/core/parser/ast/ast-test-file-parser.ts b/src/core/parser/ast/ast-test-file-parser.ts index 4ea407c..9e9f72c 100644 --- a/src/core/parser/ast/ast-test-file-parser.ts +++ b/src/core/parser/ast/ast-test-file-parser.ts @@ -26,8 +26,6 @@ const DEFAULT_PARSER_OPTIONS: ParserOptions = { startLine: 0 }; -const DEFAULT_PARSER_PLUGINS: ParserPlugin[] = ['typescript', 'jsx', ['decorators', { decoratorsBeforeExport: false }]]; - export interface AstTestFileParserOptions { readonly enabledParserPlugins?: readonly ParserPlugin[]; } @@ -35,20 +33,12 @@ export interface AstTestFileParserOptions { export class AstTestFileParser implements TestFileParser { private readonly disposables: Disposable[] = []; private readonly nodeProcessors: SourceNodeProcessor[]; - private readonly parserOptions: ParserOptions; public constructor( nodeProcessors: SourceNodeProcessor[], private readonly logger: Logger, - options: AstTestFileParserOptions = {} + private readonly options: AstTestFileParserOptions = {} ) { - const enabledParserPlugins = options.enabledParserPlugins?.length - ? options.enabledParserPlugins - : DEFAULT_PARSER_PLUGINS; - - const uniqueParserPlugins = new Set(enabledParserPlugins); - - this.parserOptions = { ...DEFAULT_PARSER_OPTIONS, plugins: [...uniqueParserPlugins] }; this.disposables.push(logger); this.nodeProcessors = [...nodeProcessors]; } @@ -57,8 +47,14 @@ export class AstTestFileParser implements TestFileParser `Parse operation ${parseId}: Parsing file '${filePath}' having content: \n${fileText}`); + const enabledParserPlugins = this.options.enabledParserPlugins?.length + ? this.options.enabledParserPlugins + : this.getParserPluginsForFile(filePath); + + const parserOptions = { ...DEFAULT_PARSER_OPTIONS, plugins: [...enabledParserPlugins] }; + const startTime = new Date(); - const parsedFile = parse(fileText, this.parserOptions); + const parsedFile = parse(fileText, parserOptions); if (parsedFile.errors.length > 0) { const errorMessages = parsedFile.errors.map(error => `--> ${error.code} - ${error.reasonCode}`); @@ -160,6 +156,21 @@ export class AstTestFileParser implements TestFileParser { ); }); + it('correctly parses non-jsx TypeScript `.ts` test file content having typescript angle bracket type casts', () => { + const fileText = ` + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { + const with_angle_bracket_type_cast = 5; + const obj_prop_with_angle_bracket_type_cast = { + prop1: 'hi', + prop2: '5' + }; + }); + }) + `; + const testFileName = '/fake/test/file/path.ts'; + const testSuiteFileInfo = testParser.parseFileText(fileText, testFileName); + + expect(testSuiteFileInfo).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + suite: expect.arrayContaining([ + expect.objectContaining({ + type: TestType.Suite, + description: 'test suite 1', + line: 1, + state: TestDefinitionState.Default, + disabled: false, + file: testFileName + }) + ]), + test: expect.objectContaining({ + type: TestType.Test, + description: 'test 1', + line: 2, + state: TestDefinitionState.Default, + disabled: false, + file: testFileName + }) + }) + ]) + ); + }); + + it('correctly parses jsx TypeScript `.tsx` test file content having typescript `as` type casts', () => { + const fileText = ` + ${_.describe}('test suite 1', () => { + ${_.it}('test 1', () => { + const with_angle_bracket_type_cast = 5 as string; + }); + }) + `; + const testFileName = '/fake/test/file/path.tsx'; + const testSuiteFileInfo = testParser.parseFileText(fileText, testFileName); + + expect(testSuiteFileInfo).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + suite: expect.arrayContaining([ + expect.objectContaining({ + type: TestType.Suite, + description: 'test suite 1', + line: 1, + state: TestDefinitionState.Default, + disabled: false, + file: testFileName + }) + ]), + test: expect.objectContaining({ + type: TestType.Test, + description: 'test 1', + line: 2, + state: TestDefinitionState.Default, + disabled: false, + file: testFileName + }) + }) + ]) + ); + }); + it('correctly parses test content having decorators', () => { const fileText = ` ${_.describe}('test suite 1', () => { From ba5156b79a97691f7ea2922691d5c687440cf055 Mon Sep 17 00:00:00 2001 From: lucono Date: Fri, 26 Aug 2022 23:09:05 -0400 Subject: [PATCH 5/5] Use sensible options for user-specified parser plugins --- CHANGELOG.md | 4 ++- docs/documentation.md | 2 +- package.json | 2 +- src/core/parser/ast/ast-test-file-parser.ts | 29 ++++++++++----------- 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b680ad..878332a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,10 +28,11 @@ The format of this changelog is loosely based on [Keep a Changelog](https://keep ### Added -- Experimental new `karmaTestExplorer.enabledParserPlugins` extension setting for specifying enabled parser plugins for adding support for various specific language syntaxes when parsing test files. (Addresses [this issue](https://github.com/lucono/karma-test-explorer/issues/46)) +- Experimental new `karmaTestExplorer.enabledParserPlugins` extension setting for specifying exact set of parser plugins to enable for supporting the specific language syntaxes present in user's test files ### Changed +- Parser plugins enabled by default now include support for decorators syntax in test sources, which addresses [this issue](https://github.com/lucono/karma-test-explorer/issues/46) - The `karmaTestExplorer.projects` extension setting has been renamed to `karmaTestExplorer.projectWorkspaces` to avoid confusion with Angular workspace projects - The `projectRootPath` property of the object format for specifying projects has been renamed to `rootPath` to better align with the new naming of its parent `karmaTestExplorer.projectWorkspaces` setting - Improved error logging for scenarios where Karma fails to start or quits unexpectedly @@ -39,6 +40,7 @@ The format of this changelog is loosely based on [Keep a Changelog](https://keep ### Fixed - Fixed an [issue](https://github.com/lucono/karma-test-explorer/issues/47) where breakpoints are not hit when debugging projects that are not located directly in the VS Code workspace root +- Fixed an [issue](https://github.com/lucono/karma-test-explorer/issues/49) where tests are unmapped when Typescript angle bracket cast syntax is used in non-JSX test files - Addressed an [issue](https://github.com/lucono/karma-test-explorer/issues/45) to add support for a Karma configuration scenario that works with Karma but not with the extension --- diff --git a/docs/documentation.md b/docs/documentation.md index 85bb1a5..6e5ba21 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -171,7 +171,7 @@ Setting | Description `karmaTestExplorer.angularProcessCommand` | The command or path to an executable to use for launching or running Angular tests. This is useful for using a custom script or different command other than the default `karmaTestExplorer.testTriggerMethod` | Experimental. Specifies how test runs are triggered by default, either through the Karma CLI or Http interface. You will usually not need to use this setting unless working around specific issues `karmaTestExplorer.testParsingMethod` | Specifies how tests are parsed by default, either using regular expression matching or an abstract syntax tree. You will usually not need to use this setting unless working around specific issues -`karmaTestExplorer.enabledParserPlugins` | Experimental and subject to change in future releases! Specifies the parser plugins to enable for parsing test files +`karmaTestExplorer.enabledParserPlugins` | Experimental and subject to change in future releases! Specifies the exact set of Babel parser plugins to enable for parsing test files. Useful for enabling full support for various language syntaxes present in the test files `karmaTestExplorer.failOnStandardError` | Treats any Karma, Angular, or other testing stderr output as a failure. This can sometimes be useful for uncovering testing issues `karmaTestExplorer.testsBasePath` | The base folder containing the test files (relative to the project root path for Karma projects, or the project `root` path specified in `angular.json` for Angular workspace projects). If not specified, defaults to the longest common path of the tests discovered in the project `karmaTestExplorer.testFiles` | The path glob patterns identifying the test files (relative to the project root path) diff --git a/package.json b/package.json index 5d8a1c4..387b63a 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ ] }, "karmaTestExplorer.enabledParserPlugins": { - "markdownDescription": "Experimental and subject to change in future releases! Specifies the parser plugins to enable for parsing test files", + "markdownDescription": "Experimental and subject to change in future releases! Specifies the exact set of Babel parser plugins to enable for parsing test files. Useful for enabling full support for various language syntaxes present in the test files", "type": "array", "scope": "resource", "default": [], diff --git a/src/core/parser/ast/ast-test-file-parser.ts b/src/core/parser/ast/ast-test-file-parser.ts index 9e9f72c..f937195 100644 --- a/src/core/parser/ast/ast-test-file-parser.ts +++ b/src/core/parser/ast/ast-test-file-parser.ts @@ -1,5 +1,5 @@ import { Node } from '@babel/core'; -import { parse, ParserOptions, ParserPlugin } from '@babel/parser'; +import { parse, ParserOptions, ParserPlugin, ParserPluginWithOptions } from '@babel/parser'; import { Disposable } from '../../../util/disposable/disposable'; import { Disposer } from '../../../util/disposable/disposer'; import { Logger } from '../../../util/logging/logger'; @@ -26,6 +26,11 @@ const DEFAULT_PARSER_OPTIONS: ParserOptions = { startLine: 0 }; +const PLUGINS_WITH_OPTIONS: Map = new Map([ + ['typescript', ['typescript', { disallowAmbiguousJSXLike: false }]], + ['decorators', ['decorators', { decoratorsBeforeExport: false }]] +]); + export interface AstTestFileParserOptions { readonly enabledParserPlugins?: readonly ParserPlugin[]; } @@ -47,10 +52,7 @@ export class AstTestFileParser implements TestFileParser `Parse operation ${parseId}: Parsing file '${filePath}' having content: \n${fileText}`); - const enabledParserPlugins = this.options.enabledParserPlugins?.length - ? this.options.enabledParserPlugins - : this.getParserPluginsForFile(filePath); - + const enabledParserPlugins = this.getParserPluginsForFile(filePath); const parserOptions = { ...DEFAULT_PARSER_OPTIONS, plugins: [...enabledParserPlugins] }; const startTime = new Date(); @@ -158,17 +160,14 @@ export class AstTestFileParser implements TestFileParser (pluginName === 'jsx' ? isJsxFile : pluginName === 'typescript' ? isTypeScriptFile : true)) + .map(pluginName => PLUGINS_WITH_OPTIONS.get(pluginName) ?? pluginName); + + return pluginsWithOptions; } public async dispose() {