diff --git a/.commitlintrc.yml b/.commitlintrc.yml deleted file mode 100644 index 72bde9ed7..000000000 --- a/.commitlintrc.yml +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - ---- -extends: - - "@commitlint/config-conventional" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5625b6e0d..5ae4828bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -187,9 +187,10 @@ jobs: MARKDOWN_CONFIG_FILE: ../../.markdownlint.json SAVE_SUPER_LINTER_SUMMARY: true SPELL_CODESPELL_CONFIG_FILE: .codespellrc - # Disable linters that conflict with other linters. + # Disable linters that conflict with other linters or are not enforced. VALIDATE_BIOME_FORMAT: false VALIDATE_BIOME_LINT: false + VALIDATE_GIT_COMMITLINT: false VALIDATE_JSON: false VALIDATE_PYTHON_BLACK: false VALIDATE_TYPESCRIPT_ES: false diff --git a/biome.json b/biome.json deleted file mode 100644 index 9dd2dd1dc..000000000 --- a/biome.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/latest/schema.json", - "files": { - "includes": ["src/**/*.ts"] - }, - "linter": { - "rules": { - "recommended": true, - "complexity": { - "noUselessSwitchCase": "off" - } - } - } -} diff --git a/eslint.config.mjs b/eslint.config.mjs index 7f99d455f..97bda8a3a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -30,6 +30,7 @@ export default tseslint.config( }, rules: { "@typescript-eslint/consistent-type-exports": "error", + "@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/default-param-last": "error", "@typescript-eslint/explicit-function-return-type": "error", "@typescript-eslint/explicit-member-accessibility": "error", diff --git a/package.json b/package.json index da032b8d8..0a638605e 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "main": "dist/index.mjs", "type": "module", "scripts": { - "build:initialization:debug": "npm ci && node scripts/fs-helpers.mjs cp src debug", - "build:initialization:release": "npm ci && node scripts/fs-helpers.mjs cp src release", + "build:initialization:debug": "npm ci && node scripts/fs-helpers.mjs rm debug && node scripts/fs-helpers.mjs cp src debug", + "build:initialization:release": "npm ci && node scripts/fs-helpers.mjs rm release && node scripts/fs-helpers.mjs cp src release", "build:debug": "npm run build:initialization:debug && cd debug/task && tsc --sourceMap", "build:release": "npm run build:initialization:release && npm run build:release:compile && npm run build:release:clean && npm run build:release:package", "build:release:compile": "cd release/task && tsc && ncc build index.js --out . --minify", @@ -28,7 +28,7 @@ "deploy:upload": "tfx build tasks upload --task-path release/task --no-prompt", "lint": "eslint --fix **/*.ts", "test": "npm run build:debug && cd debug/task && c8 --reporter=text --reporter=text-summary mocha tests/**/*.spec.js", - "test:fast": "node scripts/fs-helpers.mjs cp src debug && cd debug/task && tsc --sourceMap && c8 --reporter=text --reporter=text-summary mocha tests/**/*.spec.js", + "test:fast": "node scripts/fs-helpers.mjs rm debug && node scripts/fs-helpers.mjs cp src debug && cd debug/task && tsc --sourceMap && c8 --reporter=text --reporter=text-summary mocha tests/**/*.spec.js", "update:dependencies": "npm update", "update:versions": "ncu -u" }, diff --git a/src/task/src/repos/reposInvokerInterface.d.ts b/src/task/src/repos/reposInvokerInterface.d.ts index 6dd8cb136..210fa0ee1 100644 --- a/src/task/src/repos/reposInvokerInterface.d.ts +++ b/src/task/src/repos/reposInvokerInterface.d.ts @@ -3,9 +3,9 @@ * Licensed under the MIT License. */ -import CommentData from "./interfaces/commentData.js"; -import { CommentThreadStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js"; -import PullRequestDetailsInterface from "./interfaces/pullRequestDetailsInterface.js"; +import type CommentData from "./interfaces/commentData.js"; +import type { CommentThreadStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import type PullRequestDetailsInterface from "./interfaces/pullRequestDetailsInterface.js"; /** * An interface for invoking repository functionality with any underlying repository store. diff --git a/src/task/src/runners/runnerInvokerInterface.d.ts b/src/task/src/runners/runnerInvokerInterface.d.ts index bd6e7a96b..57be0d57e 100644 --- a/src/task/src/runners/runnerInvokerInterface.d.ts +++ b/src/task/src/runners/runnerInvokerInterface.d.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. */ -import { EndpointAuthorization } from "./endpointAuthorization.js"; -import ExecOutput from "./execOutput.js"; +import type { EndpointAuthorization } from "./endpointAuthorization.js"; +import type ExecOutput from "./execOutput.js"; /** * An interface for invoking runner functionality with any underlying runner. diff --git a/src/task/tests/git/gitInvoker.spec.ts b/src/task/tests/git/gitInvoker.spec.ts index d02f5b846..985375965 100644 --- a/src/task/tests/git/gitInvoker.spec.ts +++ b/src/task/tests/git/gitInvoker.spec.ts @@ -10,15 +10,16 @@ import GitInvoker from "../../src/git/gitInvoker.js"; import Logger from "../../src/utilities/logger.js"; import RunnerInvoker from "../../src/runners/runnerInvoker.js"; import assert from "node:assert/strict"; +import { stubEnv } from "../testUtilities/stubEnv.js"; describe("gitInvoker.ts", (): void => { let logger: Logger; let runnerInvoker: RunnerInvoker; beforeEach((): void => { - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; - process.env.SYSTEM_PULLREQUEST_TARGETBRANCH = "refs/heads/develop"; - process.env.SYSTEM_PULLREQUEST_PULLREQUESTID = "12345"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); + stubEnv(["SYSTEM_PULLREQUEST_TARGETBRANCH", "refs/heads/develop"]); + stubEnv(["SYSTEM_PULLREQUEST_PULLREQUESTID", "12345"]); logger = mock(Logger); @@ -60,17 +61,11 @@ describe("gitInvoker.ts", (): void => { ); }); - afterEach((): void => { - delete process.env.BUILD_REPOSITORY_PROVIDER; - delete process.env.SYSTEM_PULLREQUEST_TARGETBRANCH; - delete process.env.SYSTEM_PULLREQUEST_PULLREQUESTID; - }); - describe("pullRequestId", (): void => { it("should return the correct output when the GitHub runner is being used", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull/12345/merge"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull/12345/merge"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -81,19 +76,12 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, 12345); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); it("should return the correct output when the GitHub runner is being used and it is called multiple times", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull/12345/merge"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull/12345/merge"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -106,18 +94,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result1, 12345); assert.equal(result2, 12345); - verify(logger.logDebug("* GitInvoker.pullRequestId")).twice(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); it("should throw an error when the GitHub runner is being used and GITHUB_REF is undefined", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -134,18 +115,12 @@ describe("gitInvoker.ts", (): void => { ), ); verify(logger.logWarning("'GITHUB_REF' is undefined.")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); it("should throw an error when the GitHub runner is being used and GITHUB_REF is in the incorrect format", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -166,19 +141,12 @@ describe("gitInvoker.ts", (): void => { "'GITHUB_REF' is in an incorrect format 'refs/pull'.", ), ).once(); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); it("should return the correct output when the Azure Pipelines runner is being used", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull/12345/merge"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull/12345/merge"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -189,18 +157,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, 12345); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); it("should throw an error when the Azure Pipelines runner is being used and BUILD_REPOSITORY_PROVIDER is undefined", (): void => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -219,16 +180,11 @@ describe("gitInvoker.ts", (): void => { verify( logger.logWarning("'BUILD_REPOSITORY_PROVIDER' is undefined."), ).once(); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); }); it("should throw an error when the Azure Pipelines runner is being used and SYSTEM_PULLREQUEST_PULLREQUESTID is undefined", (): void => { // Arrange - delete process.env.SYSTEM_PULLREQUEST_PULLREQUESTID; + stubEnv(["SYSTEM_PULLREQUEST_PULLREQUESTID", undefined]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -247,16 +203,11 @@ describe("gitInvoker.ts", (): void => { verify( logger.logWarning("'SYSTEM_PULLREQUEST_PULLREQUESTID' is undefined."), ).once(); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); }); it("should throw an error when the Azure Pipelines runner is being used and SYSTEM_PULLREQUEST_PULLREQUESTID is not numeric", (): void => { // Arrange - process.env.SYSTEM_PULLREQUEST_PULLREQUESTID = "abc"; + stubEnv(["SYSTEM_PULLREQUEST_PULLREQUESTID", "abc"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -277,11 +228,6 @@ describe("gitInvoker.ts", (): void => { "'SYSTEM_PULLREQUEST_PULLREQUESTID' is not numeric 'abc'.", ), ).once(); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); }); { @@ -290,7 +236,7 @@ describe("gitInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should throw an error when the Azure Pipelines runner is being used and the PR is on '${buildRepositoryProvider}' and SYSTEM_PULLREQUEST_PULLREQUESTNUMBER is undefined`, (): void => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -311,22 +257,14 @@ describe("gitInvoker.ts", (): void => { "'SYSTEM_PULLREQUEST_PULLREQUESTNUMBER' is undefined.", ), ).once(); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw an error when the ID cannot be parsed as an integer", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull/PullRequestID/merge"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull/PullRequestID/merge"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -347,13 +285,6 @@ describe("gitInvoker.ts", (): void => { "Pull request ID 'PullRequestID' from 'GITHUB_REF' is not numeric.", ), ).once(); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); { @@ -362,8 +293,8 @@ describe("gitInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should throw an error when the Azure Pipelines runner is being used and the PR is on '${buildRepositoryProvider}' and SYSTEM_PULLREQUEST_PULLREQUESTNUMBER is not numeric`, (): void => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; - process.env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER = "abc"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); + stubEnv(["SYSTEM_PULLREQUEST_PULLREQUESTNUMBER", "abc"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -384,15 +315,6 @@ describe("gitInvoker.ts", (): void => { "'SYSTEM_PULLREQUEST_PULLREQUESTNUMBER' is not numeric 'abc'.", ), ).once(); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; - delete process.env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER; }); }); } @@ -428,8 +350,6 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* GitInvoker.isGitRepo()")).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); }); }); } @@ -459,16 +379,14 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, false); - verify(logger.logDebug("* GitInvoker.isGitRepo()")).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); }); }); describe("isPullRequestIdAvailable()", (): void => { it("should return true when the GitHub runner is being used", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull/12345/merge"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull/12345/merge"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -479,18 +397,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* GitInvoker.isPullRequestIdAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); it("should return false when the GitHub runner is being used and GITHUB_REF is undefined", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -502,18 +413,12 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, false); verify(logger.logWarning("'GITHUB_REF' is undefined.")).once(); - verify(logger.logDebug("* GitInvoker.isPullRequestIdAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); it("should return false when the GitHub runner is being used and GITHUB_REF is in the incorrect format", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -529,19 +434,12 @@ describe("gitInvoker.ts", (): void => { "'GITHUB_REF' is in an incorrect format 'refs/pull'.", ), ).once(); - verify(logger.logDebug("* GitInvoker.isPullRequestIdAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); it("should return true when the Azure Pipelines runner is being used", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull/12345/merge"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull/12345/merge"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -552,18 +450,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* GitInvoker.isPullRequestIdAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); it("should throw an error when the Azure Pipelines runner is being used and BUILD_REPOSITORY_PROVIDER is undefined", (): void => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -577,16 +468,11 @@ describe("gitInvoker.ts", (): void => { verify( logger.logWarning("'BUILD_REPOSITORY_PROVIDER' is undefined."), ).once(); - verify(logger.logDebug("* GitInvoker.isPullRequestIdAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); }); it("should throw an error when the Azure Pipelines runner is being used and SYSTEM_PULLREQUEST_PULLREQUESTID is undefined", (): void => { // Arrange - delete process.env.SYSTEM_PULLREQUEST_PULLREQUESTID; + stubEnv(["SYSTEM_PULLREQUEST_PULLREQUESTID", undefined]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -600,11 +486,6 @@ describe("gitInvoker.ts", (): void => { verify( logger.logWarning("'SYSTEM_PULLREQUEST_PULLREQUESTID' is undefined."), ).once(); - verify(logger.logDebug("* GitInvoker.isPullRequestIdAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); }); { @@ -613,7 +494,7 @@ describe("gitInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should throw an error when the Azure Pipelines runner is being used and the PR is on '${buildRepositoryProvider}' and SYSTEM_PULLREQUEST_PULLREQUESTNUMBER is undefined`, (): void => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -629,24 +510,14 @@ describe("gitInvoker.ts", (): void => { "'SYSTEM_PULLREQUEST_PULLREQUESTNUMBER' is undefined.", ), ).once(); - verify( - logger.logDebug("* GitInvoker.isPullRequestIdAvailable()"), - ).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw an error when the ID cannot be parsed as an integer", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_REF = "refs/pull/PullRequestID/merge"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_REF", "refs/pull/PullRequestID/merge"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -657,20 +528,13 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, false); - verify(logger.logDebug("* GitInvoker.isPullRequestIdAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_REF; }); }); describe("isGitHistoryAvailable()", (): void => { it("should return true when the Git history is available", async (): Promise => { // Arrange - delete process.env.GITHUB_ACTION; + stubEnv(["GITHUB_ACTION", undefined]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -681,19 +545,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* GitInvoker.isGitHistoryAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); }); it("should return true when the Git history is available and the method is called after retrieving the pull request ID", async (): Promise => { // Arrange - delete process.env.GITHUB_ACTION; + stubEnv(["GITHUB_ACTION", undefined]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -706,22 +562,13 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result1, 12345); assert.equal(result2, true); - verify(logger.logDebug("* GitInvoker.pullRequestId")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.isGitHistoryAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).twice(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); }); it("should return true when the Git history is available and the PR is using the GitHub runner", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_BASE_REF = "develop"; - process.env.GITHUB_REF = "refs/pull/12345/merge"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_BASE_REF", "develop"]); + stubEnv(["GITHUB_REF", "refs/pull/12345/merge"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -732,22 +579,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* GitInvoker.isGitHistoryAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdForGitHub")).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_BASE_REF; - delete process.env.GITHUB_REF; }); it("should throw an error when the PR is using the GitHub runner and GITHUB_BASE_REF is undefined", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -762,12 +598,6 @@ describe("gitInvoker.ts", (): void => { func, "'GITHUB_BASE_REF', accessed within 'GitInvoker.targetBranch', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* GitInvoker.isGitHistoryAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); { @@ -776,10 +606,14 @@ describe("gitInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should return true when the Git history is available and the PR is on '${buildRepositoryProvider}'`, async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; - process.env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER = - process.env.SYSTEM_PULLREQUEST_PULLREQUESTID; - delete process.env.SYSTEM_PULLREQUEST_PULLREQUESTID; + stubEnv( + ["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider], + ["SYSTEM_PULLREQUEST_PULLREQUESTID", undefined], + [ + "SYSTEM_PULLREQUEST_PULLREQUESTNUMBER", + process.env.SYSTEM_PULLREQUEST_PULLREQUESTID, + ], + ); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -790,19 +624,6 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, true); - verify( - logger.logDebug("* GitInvoker.isGitHistoryAvailable()"), - ).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); - - // Finalization - delete process.env.SYSTEM_PULLREQUEST_PULLREQUESTNUMBER; }); }); } @@ -837,14 +658,6 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, false); - verify(logger.logDebug("* GitInvoker.isGitHistoryAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); }); it("should return true when the Git history is available and the method is called twice", async (): Promise => { @@ -861,19 +674,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result1, true); assert.equal(result2, true); - verify(logger.logDebug("* GitInvoker.isGitHistoryAvailable()")).twice(); - verify(logger.logDebug("* GitInvoker.initialize()")).twice(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).twice(); }); it("should throw an error when SYSTEM_PULLREQUEST_TARGETBRANCH is undefined", async (): Promise => { // Arrange - delete process.env.SYSTEM_PULLREQUEST_TARGETBRANCH; + stubEnv(["SYSTEM_PULLREQUEST_TARGETBRANCH", undefined]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -888,14 +693,11 @@ describe("gitInvoker.ts", (): void => { func, "'SYSTEM_PULLREQUEST_TARGETBRANCH', accessed within 'GitInvoker.targetBranch', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* GitInvoker.isGitHistoryAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); }); it("should throw an error when the target branch contains whitespace", async (): Promise => { // Arrange - process.env.SYSTEM_PULLREQUEST_TARGETBRANCH = "refs/heads/main branch"; + stubEnv(["SYSTEM_PULLREQUEST_TARGETBRANCH", "refs/heads/main branch"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -910,9 +712,6 @@ describe("gitInvoker.ts", (): void => { func, "Target branch 'main branch' contains whitespace or control characters, which is not allowed in command-line arguments.", ); - verify(logger.logDebug("* GitInvoker.isGitHistoryAvailable()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); }); }); @@ -929,19 +728,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, "1\t2\tFile.txt"); - verify(logger.logDebug("* GitInvoker.getDiffSummary()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); }); it("should return the correct output when no error occurs and the target branch is in the GitHub format", async (): Promise => { // Arrange - process.env.SYSTEM_PULLREQUEST_TARGETBRANCH = "develop"; + stubEnv(["SYSTEM_PULLREQUEST_TARGETBRANCH", "develop"]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -952,14 +743,6 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, "1\t2\tFile.txt"); - verify(logger.logDebug("* GitInvoker.getDiffSummary()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); }); it("should return the correct output when no error occurs and the method is called twice", async (): Promise => { @@ -975,19 +758,11 @@ describe("gitInvoker.ts", (): void => { // Assert assert.equal(result, "1\t2\tFile.txt"); - verify(logger.logDebug("* GitInvoker.getDiffSummary()")).twice(); - verify(logger.logDebug("* GitInvoker.initialize()")).twice(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).twice(); }); it("should throw an error when SYSTEM_PULLREQUEST_TARGETBRANCH is undefined", async (): Promise => { // Arrange - delete process.env.SYSTEM_PULLREQUEST_TARGETBRANCH; + stubEnv(["SYSTEM_PULLREQUEST_TARGETBRANCH", undefined]); const gitInvoker: GitInvoker = new GitInvoker( instance(logger), instance(runnerInvoker), @@ -1002,9 +777,6 @@ describe("gitInvoker.ts", (): void => { func, "'SYSTEM_PULLREQUEST_TARGETBRANCH', accessed within 'GitInvoker.targetBranch', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* GitInvoker.getDiffSummary()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); }); it("should throw an error when Git invocation fails", async (): Promise => { @@ -1038,14 +810,6 @@ describe("gitInvoker.ts", (): void => { // Assert await AssertExtensions.toThrowAsync(func, "Failure"); - verify(logger.logDebug("* GitInvoker.getDiffSummary()")).once(); - verify(logger.logDebug("* GitInvoker.initialize()")).once(); - verify(logger.logDebug("* GitInvoker.targetBranch")).once(); - verify(logger.logDebug("* GitInvoker.pullRequestIdInternal")).once(); - verify( - logger.logDebug("* GitInvoker.pullRequestIdForAzurePipelines"), - ).once(); - verify(logger.logDebug("* GitInvoker.invokeGit()")).once(); }); }); }); diff --git a/src/task/tests/git/octokitGitDiffParser.spec.ts b/src/task/tests/git/octokitGitDiffParser.spec.ts index e66c0cbe2..753588f5b 100644 --- a/src/task/tests/git/octokitGitDiffParser.spec.ts +++ b/src/task/tests/git/octokitGitDiffParser.spec.ts @@ -120,16 +120,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, lineNumber); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.processDiffs()"), - ).once(); }); }, ); @@ -175,14 +165,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, 11); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); }); it("should return the correct line number when considering a renamed file with no changes", async (): Promise => { @@ -219,14 +201,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, null); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); }); it("should return the correct line number when considering an added file", async (): Promise => { @@ -267,14 +241,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, 1); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); }); it("should return null when considering a deleted file", async (): Promise => { @@ -314,14 +280,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, null); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); verify( logger.logDebug( "Skipping file type 'DeletedFile' while performing diff parsing.", @@ -363,14 +321,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, null); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); verify( logger.logDebug( "Skipping 'AddedFile' 'file.png' while performing diff parsing.", @@ -411,14 +361,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, null); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); verify( logger.logDebug( "Skipping 'ChangedFile' 'file.png' while performing diff parsing.", @@ -462,14 +404,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, null); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); verify( logger.logDebug( "Skipping 'RenamedFile' 'file.png' while performing diff parsing.", @@ -523,14 +457,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result1, 11); assert.equal(result2, 11); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).twice(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).twice(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); }); it("should return null when an unknown file is specified", async (): Promise => { @@ -570,14 +496,6 @@ describe("octokitGitDiffParser.ts", (): void => { // Assert assert.equal(result, null); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLine()"), - ).once(); - verify( - logger.logDebug("* OctokitGitDiffParser.getFirstChangedLines()"), - ).once(); - verify(logger.logDebug("* OctokitGitDiffParser.getDiffs()")).once(); - verify(logger.logDebug("* OctokitGitDiffParser.processDiffs()")).once(); }); }); }); diff --git a/src/task/tests/jsonTypes/taskJsonInterface.d.ts b/src/task/tests/jsonTypes/taskJsonInterface.d.ts index 63a3e7bb2..6329119be 100644 --- a/src/task/tests/jsonTypes/taskJsonInterface.d.ts +++ b/src/task/tests/jsonTypes/taskJsonInterface.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import ResourcesJson from "../jsonTypes/resourcesJson.js"; +import type ResourcesJson from "../jsonTypes/resourcesJson.js"; /** * An interface defining the format of Azure Pipelines task JSON files. diff --git a/src/task/tests/metrics/codeMetrics.defaults.spec.ts b/src/task/tests/metrics/codeMetrics.defaults.spec.ts new file mode 100644 index 000000000..ba68a3f23 --- /dev/null +++ b/src/task/tests/metrics/codeMetrics.defaults.spec.ts @@ -0,0 +1,500 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { createCodeMetricsMocks, createSut } from "./codeMetricsTestSetup.js"; +import type CodeMetrics from "../../src/metrics/codeMetrics.js"; +import CodeMetricsData from "../../src/metrics/codeMetricsData.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { when } from "ts-mockito"; + +describe("codeMetrics.ts", (): void => { + let gitInvoker: GitInvoker; + let inputs: Inputs; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, inputs, logger, runnerInvoker } = createCodeMetricsMocks()); + }); + + { + interface TestCaseType { + gitResponse: string; + metrics: CodeMetricsData; + sizeIndicator: string; + testCoverageIndicator: boolean; + } + + const testCases: TestCaseType[] = [ + { + gitResponse: "0\t0\tfile.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfile.ts", + metrics: new CodeMetricsData(1, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + gitResponse: "1\t0\tfile.ts\n1\t0\ttest.ts", + metrics: new CodeMetricsData(1, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "199\t0\tfile.ts", + metrics: new CodeMetricsData(199, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + gitResponse: "199\t0\tfile.ts\n198\t0\ttest.ts", + metrics: new CodeMetricsData(199, 198, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + gitResponse: "199\t0\tfile.ts\n199\t0\ttest.ts", + metrics: new CodeMetricsData(199, 199, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "200\t0\tfile.ts", + metrics: new CodeMetricsData(200, 0, 0), + sizeIndicator: "S", + testCoverageIndicator: false, + }, + { + gitResponse: "200\t0\tfile.ts\n199\t0\ttest.ts", + metrics: new CodeMetricsData(200, 199, 0), + sizeIndicator: "S", + testCoverageIndicator: false, + }, + { + gitResponse: "200\t0\tfile.ts\n200\t0\ttest.ts", + metrics: new CodeMetricsData(200, 200, 0), + sizeIndicator: "S", + testCoverageIndicator: true, + }, + { + gitResponse: "399\t0\tfile.ts", + metrics: new CodeMetricsData(399, 0, 0), + sizeIndicator: "S", + testCoverageIndicator: false, + }, + { + gitResponse: "399\t0\tfile.ts\n398\t0\ttest.ts", + metrics: new CodeMetricsData(399, 398, 0), + sizeIndicator: "S", + testCoverageIndicator: false, + }, + { + gitResponse: "399\t0\tfile.ts\n399\t0\ttest.ts", + metrics: new CodeMetricsData(399, 399, 0), + sizeIndicator: "S", + testCoverageIndicator: true, + }, + { + gitResponse: "400\t0\tfile.ts", + metrics: new CodeMetricsData(400, 0, 0), + sizeIndicator: "M", + testCoverageIndicator: false, + }, + { + gitResponse: "400\t0\tfile.ts\n399\t0\ttest.ts", + metrics: new CodeMetricsData(400, 399, 0), + sizeIndicator: "M", + testCoverageIndicator: false, + }, + { + gitResponse: "400\t0\tfile.ts\n400\t0\ttest.ts", + metrics: new CodeMetricsData(400, 400, 0), + sizeIndicator: "M", + testCoverageIndicator: true, + }, + { + gitResponse: "799\t0\tfile.ts", + metrics: new CodeMetricsData(799, 0, 0), + sizeIndicator: "M", + testCoverageIndicator: false, + }, + { + gitResponse: "799\t0\tfile.ts\n798\t0\ttest.ts", + metrics: new CodeMetricsData(799, 798, 0), + sizeIndicator: "M", + testCoverageIndicator: false, + }, + { + gitResponse: "799\t0\tfile.ts\n799\t0\ttest.ts", + metrics: new CodeMetricsData(799, 799, 0), + sizeIndicator: "M", + testCoverageIndicator: true, + }, + { + gitResponse: "800\t0\tfile.ts", + metrics: new CodeMetricsData(800, 0, 0), + sizeIndicator: "L", + testCoverageIndicator: false, + }, + { + gitResponse: "800\t0\tfile.ts\n799\t0\ttest.ts", + metrics: new CodeMetricsData(800, 799, 0), + sizeIndicator: "L", + testCoverageIndicator: false, + }, + { + gitResponse: "800\t0\tfile.ts\n800\t0\ttest.ts", + metrics: new CodeMetricsData(800, 800, 0), + sizeIndicator: "L", + testCoverageIndicator: true, + }, + { + gitResponse: "1599\t0\tfile.ts", + metrics: new CodeMetricsData(1599, 0, 0), + sizeIndicator: "L", + testCoverageIndicator: false, + }, + { + gitResponse: "1599\t0\tfile.ts\n1598\t0\ttest.ts", + metrics: new CodeMetricsData(1599, 1598, 0), + sizeIndicator: "L", + testCoverageIndicator: false, + }, + { + gitResponse: "1599\t0\tfile.ts\n1599\t0\ttest.ts", + metrics: new CodeMetricsData(1599, 1599, 0), + sizeIndicator: "L", + testCoverageIndicator: true, + }, + { + gitResponse: "1600\t0\tfile.ts", + metrics: new CodeMetricsData(1600, 0, 0), + sizeIndicator: "XL", + testCoverageIndicator: false, + }, + { + gitResponse: "1600\t0\tfile.ts\n1599\t0\ttest.ts", + metrics: new CodeMetricsData(1600, 1599, 0), + sizeIndicator: "XL", + testCoverageIndicator: false, + }, + { + gitResponse: "1600\t0\tfile.ts\n1600\t0\ttest.ts", + metrics: new CodeMetricsData(1600, 1600, 0), + sizeIndicator: "XL", + testCoverageIndicator: true, + }, + { + gitResponse: "3199\t0\tfile.ts", + metrics: new CodeMetricsData(3199, 0, 0), + sizeIndicator: "XL", + testCoverageIndicator: false, + }, + { + gitResponse: "3199\t0\tfile.ts\n3198\t0\ttest.ts", + metrics: new CodeMetricsData(3199, 3198, 0), + sizeIndicator: "XL", + testCoverageIndicator: false, + }, + { + gitResponse: "3199\t0\tfile.ts\n3199\t0\ttest.ts", + metrics: new CodeMetricsData(3199, 3199, 0), + sizeIndicator: "XL", + testCoverageIndicator: true, + }, + { + gitResponse: "3200\t0\tfile.ts", + metrics: new CodeMetricsData(3200, 0, 0), + sizeIndicator: "2XL", + testCoverageIndicator: false, + }, + { + gitResponse: "3200\t0\tfile.ts\n3199\t0\ttest.ts", + metrics: new CodeMetricsData(3200, 3199, 0), + sizeIndicator: "2XL", + testCoverageIndicator: false, + }, + { + gitResponse: "3200\t0\tfile.ts\n3200\t0\ttest.ts", + metrics: new CodeMetricsData(3200, 3200, 0), + sizeIndicator: "2XL", + testCoverageIndicator: true, + }, + { + gitResponse: "6399\t0\tfile.ts", + metrics: new CodeMetricsData(6399, 0, 0), + sizeIndicator: "2XL", + testCoverageIndicator: false, + }, + { + gitResponse: "6399\t0\tfile.ts\n6398\t0\ttest.ts", + metrics: new CodeMetricsData(6399, 6398, 0), + sizeIndicator: "2XL", + testCoverageIndicator: false, + }, + { + gitResponse: "6399\t0\tfile.ts\n6399\t0\ttest.ts", + metrics: new CodeMetricsData(6399, 6399, 0), + sizeIndicator: "2XL", + testCoverageIndicator: true, + }, + { + gitResponse: "6400\t0\tfile.ts", + metrics: new CodeMetricsData(6400, 0, 0), + sizeIndicator: "3XL", + testCoverageIndicator: false, + }, + { + gitResponse: "6400\t0\tfile.ts\n6399\t0\ttest.ts", + metrics: new CodeMetricsData(6400, 6399, 0), + sizeIndicator: "3XL", + testCoverageIndicator: false, + }, + { + gitResponse: "6400\t0\tfile.ts\n6400\t0\ttest.ts", + metrics: new CodeMetricsData(6400, 6400, 0), + sizeIndicator: "3XL", + testCoverageIndicator: true, + }, + { + gitResponse: "819200\t0\tfile.ts", + metrics: new CodeMetricsData(819200, 0, 0), + sizeIndicator: "10XL", + testCoverageIndicator: false, + }, + { + gitResponse: "819200\t0\tfile.ts\n819199\t0\ttest.ts", + metrics: new CodeMetricsData(819200, 819199, 0), + sizeIndicator: "10XL", + testCoverageIndicator: false, + }, + { + gitResponse: "819200\t0\tfile.ts\n819200\t0\ttest.ts", + metrics: new CodeMetricsData(819200, 819200, 0), + sizeIndicator: "10XL", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfile.TS", + metrics: new CodeMetricsData(1, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + gitResponse: "0\t1\tfile.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfile.ignored", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfile", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfile.ts.ignored", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfile.ignored.ts", + metrics: new CodeMetricsData(1, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + gitResponse: "1\t0\ttest.ignored", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\ttasb.cc => test.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tt{a => e}s{b => t}.t{c => s}", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tt{a => est.ts}", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\t{a => test.ts}", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfolder/test.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfolder/Test.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfolder/TEST.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfolder/DuplicateStorage.ts", + metrics: new CodeMetricsData(1, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + gitResponse: "1\t0\tfolder/file.spec.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfolder/file.Spec.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfolder.spec.ts/file.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\ttest/file.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\ttests/file.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\ttests/file.spec.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\ttests/file.SPEC.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t0\tfolder/tests/file.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t1\tfa/b => folder/test.ts", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "1\t1\tf{a => older}/{b => test.ts}", + metrics: new CodeMetricsData(0, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "0\t0\tfile.ts\n", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "-\t-\tfile.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + gitResponse: "0\t0\tfile.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + ]; + + testCases.forEach( + ({ + gitResponse, + metrics, + sizeIndicator, + testCoverageIndicator, + }: TestCaseType): void => { + it(`with default inputs and git diff '${gitResponse.replace(/\n/gu, "\\n").replace(/\r/gu, "\\r")}', returns '${sizeIndicator}' size and '${String(testCoverageIndicator)}' test coverage`, async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve(gitResponse); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), []); + assert.deepEqual( + await codeMetrics.getDeletedFilesNotRequiringReview(), + [], + ); + assert.equal(await codeMetrics.getSize(), sizeIndicator); + assert.equal( + await codeMetrics.getSizeIndicator(), + `${sizeIndicator}${testCoverageIndicator ? "✔" : "⚠️"}`, + ); + assert.deepEqual(await codeMetrics.getMetrics(), metrics); + assert.equal( + await codeMetrics.isSmall(), + sizeIndicator === "XS" || sizeIndicator === "S", + ); + assert.equal( + await codeMetrics.isSufficientlyTested(), + testCoverageIndicator, + ); + }); + }, + ); + } +}); diff --git a/src/task/tests/metrics/codeMetrics.edgeCases.spec.ts b/src/task/tests/metrics/codeMetrics.edgeCases.spec.ts new file mode 100644 index 000000000..f2d20fec7 --- /dev/null +++ b/src/task/tests/metrics/codeMetrics.edgeCases.spec.ts @@ -0,0 +1,75 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { createCodeMetricsMocks, createSut } from "./codeMetricsTestSetup.js"; +import type CodeMetrics from "../../src/metrics/codeMetrics.js"; +import CodeMetricsData from "../../src/metrics/codeMetricsData.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { when } from "ts-mockito"; + +describe("codeMetrics.ts", (): void => { + let gitInvoker: GitInvoker; + let inputs: Inputs; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, inputs, logger, runnerInvoker } = createCodeMetricsMocks()); + }); + + it("should return the expected result with test coverage disabled", async (): Promise => { + // Arrange + when(inputs.testFactor).thenReturn(null); + when(gitInvoker.getDiffSummary()).thenResolve("1\t0\tfile.ts"); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), []); + assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); + assert.equal(await codeMetrics.getSize(), "XS"); + assert.equal(await codeMetrics.getSizeIndicator(), "XS"); + assert.deepEqual( + await codeMetrics.getMetrics(), + new CodeMetricsData(1, 0, 0), + ); + assert.equal(await codeMetrics.isSmall(), true); + assert.equal(await codeMetrics.isSufficientlyTested(), null); + }); + + it("with a size multiplier exceeding 1000, returns a size without thousands separators", async (): Promise => { + // ARRANGE + when(inputs.baseSize).thenReturn(1); + when(inputs.growthRate).thenReturn(1.001); + when(inputs.testFactor).thenReturn(1.0); + when(inputs.fileMatchingPatterns).thenReturn(["**/*"]); + when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); + when(gitInvoker.getDiffSummary()).thenResolve("3\t0\tfile.ts"); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // ACT + const size: string = await codeMetrics.getSize(); + + // ASSERT + assert.match(size, /^\d+XL$/u); + const multiplier: number = Number.parseInt(size.replace("XL", ""), 10); + assert.ok(multiplier >= 1000); + }); +}); diff --git a/src/task/tests/metrics/codeMetrics.fileMatching.spec.ts b/src/task/tests/metrics/codeMetrics.fileMatching.spec.ts new file mode 100644 index 000000000..2929e2364 --- /dev/null +++ b/src/task/tests/metrics/codeMetrics.fileMatching.spec.ts @@ -0,0 +1,250 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { createCodeMetricsMocks, createSut } from "./codeMetricsTestSetup.js"; +import type CodeMetrics from "../../src/metrics/codeMetrics.js"; +import CodeMetricsData from "../../src/metrics/codeMetricsData.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { when } from "ts-mockito"; + +describe("codeMetrics.ts", (): void => { + let gitInvoker: GitInvoker; + let inputs: Inputs; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, inputs, logger, runnerInvoker } = createCodeMetricsMocks()); + }); + + { + interface TestCaseType { + gitResponse: string; + } + + const testCases: TestCaseType[] = [ + { + gitResponse: + "2\t2\tfile.ts\n1\t1\tignored1.ts\n1\t1\tacceptance.ts\n1\t1\tignored2.ts", + }, + { + gitResponse: + "1\t1\tfile1.ts\n1\t1\tignored1.ts\n1\t1\tignored2.ts\n1\t1\tacceptance.ts\n1\t1\tfile2.ts", + }, + { + gitResponse: + "1\t1\tfile1.ts\n1\t1\tignored1.ts\n1\t1\tfile2.ts\n1\t1\tacceptance.ts\n1\t1\tignored2.ts", + }, + ]; + + testCases.forEach(({ gitResponse }: TestCaseType): void => { + it(`with multiple ignore patterns and git diff '${gitResponse.replace(/\n/gu, "\\n").replace(/\r/gu, "\\r")}' ignores the appropriate files`, async (): Promise => { + // Arrange + when(inputs.baseSize).thenReturn(100); + when(inputs.growthRate).thenReturn(1.5); + when(inputs.testFactor).thenReturn(2.0); + when(inputs.fileMatchingPatterns).thenReturn([ + "**/*", + "!**/ignored1.ts", + "!**/ignored2.ts", + ]); + when(inputs.testMatchingPatterns).thenReturn(["**/acceptance.ts"]); + when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); + when(gitInvoker.getDiffSummary()).thenResolve(gitResponse); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), [ + "ignored1.ts", + "ignored2.ts", + ]); + assert.deepEqual( + await codeMetrics.getDeletedFilesNotRequiringReview(), + [], + ); + assert.equal(await codeMetrics.getSize(), "XS"); + assert.equal(await codeMetrics.getSizeIndicator(), "XS⚠️"); + assert.deepEqual( + await codeMetrics.getMetrics(), + new CodeMetricsData(2, 1, 2), + ); + assert.equal(await codeMetrics.isSmall(), true); + assert.equal(await codeMetrics.isSufficientlyTested(), false); + }); + }); + } + + it("with custom include pattern, includes the relevant files", async (): Promise => { + // Arrange + when(inputs.baseSize).thenReturn(100); + when(inputs.growthRate).thenReturn(1.5); + when(inputs.testFactor).thenReturn(2.0); + when(inputs.fileMatchingPatterns).thenReturn(["src/*.ts", "__test__/*.ts"]); + when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); + when(gitInvoker.getDiffSummary()).thenResolve( + "1\t1\tfile.ts\n1\t1\tsrc/file.ts\n1\t1\t__test__/file.test.ts", + ); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), [ + "file.ts", + ]); + assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); + assert.equal(await codeMetrics.getSize(), "XS"); + assert.equal(await codeMetrics.getSizeIndicator(), "XS⚠️"); + assert.deepEqual( + await codeMetrics.getMetrics(), + new CodeMetricsData(1, 1, 1), + ); + assert.equal(await codeMetrics.isSmall(), true); + assert.equal(await codeMetrics.isSufficientlyTested(), false); + }); + + it("with only negative patterns, treats all files as non-matching", async (): Promise => { + // Arrange + when(inputs.baseSize).thenReturn(100); + when(inputs.growthRate).thenReturn(1.5); + when(inputs.testFactor).thenReturn(2.0); + when(inputs.fileMatchingPatterns).thenReturn(["!**/ignored.ts"]); + when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); + when(gitInvoker.getDiffSummary()).thenResolve( + "1\t1\tfile.ts\n1\t1\tignored.ts", + ); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), [ + "file.ts", + "ignored.ts", + ]); + assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); + assert.equal(await codeMetrics.getSize(), "XS"); + assert.equal(await codeMetrics.getSizeIndicator(), "XS✔"); + assert.deepEqual( + await codeMetrics.getMetrics(), + new CodeMetricsData(0, 0, 2), + ); + assert.equal(await codeMetrics.isSmall(), true); + assert.equal(await codeMetrics.isSufficientlyTested(), true); + }); + + it("with double exclusion ignore patterns ignores the appropriate files", async (): Promise => { + // Arrange + when(inputs.baseSize).thenReturn(100); + when(inputs.growthRate).thenReturn(1.5); + when(inputs.testFactor).thenReturn(2.0); + when(inputs.fileMatchingPatterns).thenReturn([ + "**/*", + "!**/ignored*.ts", + "!!**/ignored2.ts", + ]); + when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); + when(gitInvoker.getDiffSummary()).thenResolve( + "1\t1\tfile.ts\n1\t1\tignored1.ts\n1\t1\tignored2.ts\n1\t1\tignored3.ts", + ); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), [ + "ignored1.ts", + "ignored3.ts", + ]); + assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); + assert.equal(await codeMetrics.getSize(), "XS"); + assert.equal(await codeMetrics.getSizeIndicator(), "XS⚠️"); + assert.deepEqual( + await codeMetrics.getMetrics(), + new CodeMetricsData(2, 0, 2), + ); + assert.equal(await codeMetrics.isSmall(), true); + assert.equal(await codeMetrics.isSufficientlyTested(), false); + }); + + it("with all files matching test files, returns the appropriate results", async (): Promise => { + // Arrange + when(inputs.testMatchingPatterns).thenReturn(["**", "*/**"]); + when(gitInvoker.getDiffSummary()).thenResolve( + "1\t0\tfile.ts\n1\t0\ttest.ts\n1\t0\tfolder/file.ts", + ); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), []); + assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); + assert.equal(await codeMetrics.getSize(), "XS"); + assert.equal(await codeMetrics.getSizeIndicator(), "XS✔"); + assert.deepEqual( + await codeMetrics.getMetrics(), + new CodeMetricsData(0, 3, 0), + ); + assert.equal(await codeMetrics.isSmall(), true); + assert.equal(await codeMetrics.isSufficientlyTested(), true); + }); + + it("matches dotfiles by extracting extension from basename", async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve("1\t0\t.env"); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), []); + assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); + assert.equal(await codeMetrics.getSize(), "XS"); + assert.equal(await codeMetrics.getSizeIndicator(), "XS⚠️"); + assert.deepEqual( + await codeMetrics.getMetrics(), + new CodeMetricsData(1, 0, 0), + ); + assert.equal(await codeMetrics.isSmall(), true); + assert.equal(await codeMetrics.isSufficientlyTested(), false); + }); +}); diff --git a/src/task/tests/metrics/codeMetrics.getDeletedFilesNotRequiringReview.spec.ts b/src/task/tests/metrics/codeMetrics.getDeletedFilesNotRequiringReview.spec.ts new file mode 100644 index 000000000..a966b1a31 --- /dev/null +++ b/src/task/tests/metrics/codeMetrics.getDeletedFilesNotRequiringReview.spec.ts @@ -0,0 +1,108 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import { createCodeMetricsMocks, createSut } from "./codeMetricsTestSetup.js"; +import type CodeMetrics from "../../src/metrics/codeMetrics.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { when } from "ts-mockito"; + +describe("codeMetrics.ts", (): void => { + let gitInvoker: GitInvoker; + let inputs: Inputs; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, inputs, logger, runnerInvoker } = createCodeMetricsMocks()); + }); + + describe("getDeletedFilesNotRequiringReview()", (): void => { + it("should return an empty array when the Git diff summary '' is empty", async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve(""); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Act + const result: string[] = + await codeMetrics.getDeletedFilesNotRequiringReview(); + + // Assert + assert.deepEqual(result, []); + }); + + it("should throw when the file name in the Git diff summary '0' cannot be parsed", async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve("0"); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + codeMetrics.getDeletedFilesNotRequiringReview(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "The number of elements '1' in '0' in input '0' did not match the expected 3.", + ); + }); + + it("should throw when the lines added in the Git diff summary cannot be converted", async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve("A\t0\tfile.ts"); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + codeMetrics.getDeletedFilesNotRequiringReview(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "Could not parse added lines 'A' from line 'A\t0\tfile.ts'.", + ); + }); + + it("should throw when the lines deleted in the Git diff summary cannot be converted", async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve("0\tA\tfile.ts"); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + codeMetrics.getDeletedFilesNotRequiringReview(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "Could not parse deleted lines 'A' from line '0\tA\tfile.ts'.", + ); + }); + }); +}); diff --git a/src/task/tests/metrics/codeMetrics.getFilesNotRequiringReview.spec.ts b/src/task/tests/metrics/codeMetrics.getFilesNotRequiringReview.spec.ts new file mode 100644 index 000000000..c806b6088 --- /dev/null +++ b/src/task/tests/metrics/codeMetrics.getFilesNotRequiringReview.spec.ts @@ -0,0 +1,150 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import { createCodeMetricsMocks, createSut } from "./codeMetricsTestSetup.js"; +import type CodeMetrics from "../../src/metrics/codeMetrics.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { when } from "ts-mockito"; + +describe("codeMetrics.ts", (): void => { + let gitInvoker: GitInvoker; + let inputs: Inputs; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, inputs, logger, runnerInvoker } = createCodeMetricsMocks()); + }); + + describe("getFilesNotRequiringReview()", (): void => { + { + const testCases: string[] = ["", " ", "\t", "\n", "\t\n"]; + + testCases.forEach((gitDiffSummary: string): void => { + it(`should return an empty array when the Git diff summary '${gitDiffSummary}' is empty`, async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve(gitDiffSummary); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Act + const result: string[] = + await codeMetrics.getFilesNotRequiringReview(); + + // Assert + assert.deepEqual(result, []); + }); + }); + } + + { + interface TestCaseType { + elements: number; + summary: string; + } + + const testCases: TestCaseType[] = [ + { + elements: 1, + summary: "0", + }, + { + elements: 1, + summary: "0\t", + }, + { + elements: 2, + summary: "0\t0", + }, + { + elements: 2, + summary: "0\t0\t", + }, + { + elements: 2, + summary: "0\tfile.ts", + }, + { + elements: 2, + summary: "0\tfile.ts\t", + }, + ]; + + testCases.forEach(({ elements, summary }: TestCaseType): void => { + it(`should throw when the file name in the Git diff summary '${summary}' cannot be parsed`, async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve(summary); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + codeMetrics.getFilesNotRequiringReview(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `The number of elements '${String(elements)}' in '${summary.trim()}' in input '${summary.trim()}' did not match the expected 3.`, + ); + }); + }); + } + + it("should throw when the lines added in the Git diff summary cannot be converted", async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve("A\t0\tfile.ts"); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + codeMetrics.getFilesNotRequiringReview(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "Could not parse added lines 'A' from line 'A\t0\tfile.ts'.", + ); + }); + + it("should throw when the lines deleted in the Git diff summary cannot be converted", async (): Promise => { + // Arrange + when(gitInvoker.getDiffSummary()).thenResolve("0\tA\tfile.ts"); + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + codeMetrics.getFilesNotRequiringReview(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "Could not parse deleted lines 'A' from line '0\tA\tfile.ts'.", + ); + }); + }); +}); diff --git a/src/task/tests/metrics/codeMetrics.nonDefaults.spec.ts b/src/task/tests/metrics/codeMetrics.nonDefaults.spec.ts new file mode 100644 index 000000000..d02e49eb7 --- /dev/null +++ b/src/task/tests/metrics/codeMetrics.nonDefaults.spec.ts @@ -0,0 +1,568 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { createCodeMetricsMocks, createSut } from "./codeMetricsTestSetup.js"; +import type CodeMetrics from "../../src/metrics/codeMetrics.js"; +import CodeMetricsData from "../../src/metrics/codeMetricsData.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { when } from "ts-mockito"; + +describe("codeMetrics.ts", (): void => { + let gitInvoker: GitInvoker; + let inputs: Inputs; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, inputs, logger, runnerInvoker } = createCodeMetricsMocks()); + }); + + { + interface TestCaseType { + deletedFilesNotRequiringReview: string[]; + filesNotRequiringReview: string[]; + gitResponse: string; + metrics: CodeMetricsData; + sizeIndicator: string; + testCoverageIndicator: boolean; + } + + const testCases: TestCaseType[] = [ + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "0\t0\tfile.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "1\t0\tfile.ts", + metrics: new CodeMetricsData(1, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "1\t0\tfile.ts\n1\t0\ttest.ts", + metrics: new CodeMetricsData(1, 1, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "1\t0\tfile.ts\n2\t0\ttest.ts", + metrics: new CodeMetricsData(1, 2, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "99\t0\tfile.ts", + metrics: new CodeMetricsData(99, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "99\t0\tfile.ts\n197\t0\ttest.ts", + metrics: new CodeMetricsData(99, 197, 0), + sizeIndicator: "XS", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "99\t0\tfile.ts\n198\t0\ttest.ts", + metrics: new CodeMetricsData(99, 198, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "100\t0\tfile.ts", + metrics: new CodeMetricsData(100, 0, 0), + sizeIndicator: "S", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "100\t0\tfile.ts\n199\t0\ttest.ts", + metrics: new CodeMetricsData(100, 199, 0), + sizeIndicator: "S", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "100\t0\tfile.ts\n200\t0\ttest.ts", + metrics: new CodeMetricsData(100, 200, 0), + sizeIndicator: "S", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "149\t0\tfile.ts", + metrics: new CodeMetricsData(149, 0, 0), + sizeIndicator: "S", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "149\t0\tfile.ts\n297\t0\ttest.ts", + metrics: new CodeMetricsData(149, 297, 0), + sizeIndicator: "S", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "149\t0\tfile.ts\n298\t0\ttest.ts", + metrics: new CodeMetricsData(149, 298, 0), + sizeIndicator: "S", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "150\t0\tfile.ts", + metrics: new CodeMetricsData(150, 0, 0), + sizeIndicator: "M", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "150\t0\tfile.ts\n299\t0\ttest.ts", + metrics: new CodeMetricsData(150, 299, 0), + sizeIndicator: "M", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "150\t0\tfile.ts\n300\t0\ttest.ts", + metrics: new CodeMetricsData(150, 300, 0), + sizeIndicator: "M", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "224\t0\tfile.ts", + metrics: new CodeMetricsData(224, 0, 0), + sizeIndicator: "M", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "224\t0\tfile.ts\n447\t0\ttest.ts", + metrics: new CodeMetricsData(224, 447, 0), + sizeIndicator: "M", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "224\t0\tfile.ts\n448\t0\ttest.ts", + metrics: new CodeMetricsData(224, 448, 0), + sizeIndicator: "M", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "225\t0\tfile.ts", + metrics: new CodeMetricsData(225, 0, 0), + sizeIndicator: "L", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "225\t0\tfile.ts\n449\t0\ttest.ts", + metrics: new CodeMetricsData(225, 449, 0), + sizeIndicator: "L", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "225\t0\tfile.ts\n450\t0\ttest.ts", + metrics: new CodeMetricsData(225, 450, 0), + sizeIndicator: "L", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "337\t0\tfile.ts", + metrics: new CodeMetricsData(337, 0, 0), + sizeIndicator: "L", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "337\t0\tfile.ts\n673\t0\ttest.ts", + metrics: new CodeMetricsData(337, 673, 0), + sizeIndicator: "L", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "337\t0\tfile.ts\n674\t0\ttest.ts", + metrics: new CodeMetricsData(337, 674, 0), + sizeIndicator: "L", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "338\t0\tfile.ts", + metrics: new CodeMetricsData(338, 0, 0), + sizeIndicator: "XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "338\t0\tfile.ts\n675\t0\ttest.ts", + metrics: new CodeMetricsData(338, 675, 0), + sizeIndicator: "XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "338\t0\tfile.ts\n676\t0\ttest.ts", + metrics: new CodeMetricsData(338, 676, 0), + sizeIndicator: "XL", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "506\t0\tfile.ts", + metrics: new CodeMetricsData(506, 0, 0), + sizeIndicator: "XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "506\t0\tfile.ts\n1011\t0\ttest.ts", + metrics: new CodeMetricsData(506, 1011, 0), + sizeIndicator: "XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "506\t0\tfile.ts\n1012\t0\ttest.ts", + metrics: new CodeMetricsData(506, 1012, 0), + sizeIndicator: "XL", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "507\t0\tfile.ts", + metrics: new CodeMetricsData(507, 0, 0), + sizeIndicator: "2XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "507\t0\tfile.ts\n1013\t0\ttest.ts", + metrics: new CodeMetricsData(507, 1013, 0), + sizeIndicator: "2XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "507\t0\tfile.ts\n1014\t0\ttest.ts", + metrics: new CodeMetricsData(507, 1014, 0), + sizeIndicator: "2XL", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "759\t0\tfile.ts", + metrics: new CodeMetricsData(759, 0, 0), + sizeIndicator: "2XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "759\t0\tfile.ts\n1517\t0\ttest.ts", + metrics: new CodeMetricsData(759, 1517, 0), + sizeIndicator: "2XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "759\t0\tfile.ts\n1518\t0\ttest.ts", + metrics: new CodeMetricsData(759, 1518, 0), + sizeIndicator: "2XL", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "760\t0\tfile.ts", + metrics: new CodeMetricsData(760, 0, 0), + sizeIndicator: "3XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "760\t0\tfile.ts\n1519\t0\ttest.ts", + metrics: new CodeMetricsData(760, 1519, 0), + sizeIndicator: "3XL", + testCoverageIndicator: false, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "760\t0\tfile.ts\n1520\t0\ttest.ts", + metrics: new CodeMetricsData(760, 1520, 0), + sizeIndicator: "3XL", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "1\t0\tfile.cs", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "1\t0\ttest.cs", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "1\t0\tfile.tst", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "1\t0\tfile.tts", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: [], + gitResponse: "1\t0\tfilets", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["ignored.ts"], + gitResponse: "1\t0\tignored.ts", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["ignored.cs"], + gitResponse: "1\t0\tignored.cs", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["folder/ignored.ts"], + gitResponse: "1\t0\tfolder/ignored.ts", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["folder/ignored.cs"], + gitResponse: "1\t0\tfolder/ignored.cs", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["ignored.ts"], + gitResponse: "0\t0\tignored.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["ignored.cs"], + gitResponse: "0\t0\tignored.cs", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["folder/ignored.ts"], + gitResponse: "0\t0\tfolder/ignored.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["folder/ignored.cs"], + gitResponse: "0\t0\tfolder/ignored.cs", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: [], + filesNotRequiringReview: ["ignored.ts", "folder/ignored.ts"], + gitResponse: "1\t0\tignored.ts\n0\t0\tfolder/ignored.ts", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: ["ignored.ts"], + filesNotRequiringReview: [], + gitResponse: "0\t1\tignored.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: ["ignored.cs"], + filesNotRequiringReview: [], + gitResponse: "0\t1\tignored.cs", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: ["folder/ignored.ts"], + filesNotRequiringReview: [], + gitResponse: "0\t1\tfolder/ignored.ts", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: ["folder/ignored.cs"], + filesNotRequiringReview: [], + gitResponse: "0\t1\tfolder/ignored.cs", + metrics: new CodeMetricsData(0, 0, 0), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + { + deletedFilesNotRequiringReview: ["folder/ignored.ts"], + filesNotRequiringReview: ["ignored.ts"], + gitResponse: "1\t0\tignored.ts\n0\t1\tfolder/ignored.ts", + metrics: new CodeMetricsData(0, 0, 1), + sizeIndicator: "XS", + testCoverageIndicator: true, + }, + ]; + + testCases.forEach( + ({ + deletedFilesNotRequiringReview, + filesNotRequiringReview, + gitResponse, + metrics, + sizeIndicator, + testCoverageIndicator, + }: TestCaseType): void => { + it(`with non-default inputs and git diff '${gitResponse.replace(/\n/gu, "\\n")}', returns '${sizeIndicator}' size and '${String(testCoverageIndicator)}' test coverage`, async (): Promise => { + // Arrange + when(inputs.baseSize).thenReturn(100); + when(inputs.growthRate).thenReturn(1.5); + when(inputs.testFactor).thenReturn(2.0); + when(inputs.fileMatchingPatterns).thenReturn([ + "**/*", + "other.ts", + "!**/ignored.*", + ]); + when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); + when(gitInvoker.getDiffSummary()).thenResolve(gitResponse); + + // Act + const codeMetrics: CodeMetrics = createSut( + gitInvoker, + inputs, + logger, + runnerInvoker, + ); + + // Assert + assert.deepEqual( + await codeMetrics.getFilesNotRequiringReview(), + filesNotRequiringReview, + ); + assert.deepEqual( + await codeMetrics.getDeletedFilesNotRequiringReview(), + deletedFilesNotRequiringReview, + ); + assert.equal(await codeMetrics.getSize(), sizeIndicator); + assert.equal( + await codeMetrics.getSizeIndicator(), + `${sizeIndicator}${testCoverageIndicator ? "✔" : "⚠️"}`, + ); + assert.deepEqual(await codeMetrics.getMetrics(), metrics); + assert.equal( + await codeMetrics.isSmall(), + sizeIndicator === "XS" || sizeIndicator === "S", + ); + assert.equal( + await codeMetrics.isSufficientlyTested(), + testCoverageIndicator, + ); + }); + }, + ); + } +}); diff --git a/src/task/tests/metrics/codeMetrics.spec.ts b/src/task/tests/metrics/codeMetrics.spec.ts deleted file mode 100644 index db5b1cdc3..000000000 --- a/src/task/tests/metrics/codeMetrics.spec.ts +++ /dev/null @@ -1,2112 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. - * Licensed under the MIT License. - */ - -import * as AssertExtensions from "../testUtilities/assertExtensions.js"; -import * as InputsDefault from "../../src/metrics/inputsDefault.js"; -import { anyString, instance, mock, verify, when } from "ts-mockito"; -import CodeMetrics from "../../src/metrics/codeMetrics.js"; -import CodeMetricsData from "../../src/metrics/codeMetricsData.js"; -import GitInvoker from "../../src/git/gitInvoker.js"; -import Inputs from "../../src/metrics/inputs.js"; -import Logger from "../../src/utilities/logger.js"; -import RunnerInvoker from "../../src/runners/runnerInvoker.js"; -import assert from "node:assert/strict"; - -describe("codeMetrics.ts", (): void => { - let gitInvoker: GitInvoker; - let inputs: Inputs; - let logger: Logger; - let runnerInvoker: RunnerInvoker; - - beforeEach((): void => { - gitInvoker = mock(GitInvoker); - - inputs = mock(Inputs); - when(inputs.baseSize).thenReturn(InputsDefault.baseSize); - when(inputs.growthRate).thenReturn(InputsDefault.growthRate); - when(inputs.testFactor).thenReturn(InputsDefault.testFactor); - when(inputs.fileMatchingPatterns).thenReturn( - InputsDefault.fileMatchingPatterns, - ); - when(inputs.testMatchingPatterns).thenReturn( - InputsDefault.testMatchingPatterns, - ); - when(inputs.codeFileExtensions).thenReturn( - new Set(InputsDefault.codeFileExtensions), - ); - - logger = mock(Logger); - - runnerInvoker = mock(RunnerInvoker); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeXS")).thenReturn("XS"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeS")).thenReturn("S"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeM")).thenReturn("M"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeL")).thenReturn("L"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeXL")).thenReturn("XL"); - when( - runnerInvoker.loc("metrics.codeMetrics.titleTestsSufficient"), - ).thenReturn("✔"); - when( - runnerInvoker.loc("metrics.codeMetrics.titleTestsInsufficient"), - ).thenReturn("⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "XS", - "✔", - ), - ).thenReturn("XS✔"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "XS", - "⚠️", - ), - ).thenReturn("XS⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "S", - "✔", - ), - ).thenReturn("S✔"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "S", - "⚠️", - ), - ).thenReturn("S⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "M", - "✔", - ), - ).thenReturn("M✔"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "M", - "⚠️", - ), - ).thenReturn("M⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "L", - "✔", - ), - ).thenReturn("L✔"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "L", - "⚠️", - ), - ).thenReturn("L⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "XL", - "✔", - ), - ).thenReturn("XL✔"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "XL", - "⚠️", - ), - ).thenReturn("XL⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "2XL", - "✔", - ), - ).thenReturn("2XL✔"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "2XL", - "⚠️", - ), - ).thenReturn("2XL⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "3XL", - "✔", - ), - ).thenReturn("3XL✔"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "3XL", - "⚠️", - ), - ).thenReturn("3XL⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "10XL", - "✔", - ), - ).thenReturn("10XL✔"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "10XL", - "⚠️", - ), - ).thenReturn("10XL⚠️"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "XS", - "", - ), - ).thenReturn("XS"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "S", - "", - ), - ).thenReturn("S"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "M", - "", - ), - ).thenReturn("M"); - }); - - { - interface TestCaseType { - gitResponse: string; - globChecks: number; - metrics: CodeMetricsData; - sizeIndicator: string; - testCoverageIndicator: boolean; - } - - const testCases: TestCaseType[] = [ - { - gitResponse: "0\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(1, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - gitResponse: "1\t0\tfile.ts\n1\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(1, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "199\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(199, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - gitResponse: "199\t0\tfile.ts\n198\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(199, 198, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - gitResponse: "199\t0\tfile.ts\n199\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(199, 199, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "200\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(200, 0, 0), - sizeIndicator: "S", - testCoverageIndicator: false, - }, - { - gitResponse: "200\t0\tfile.ts\n199\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(200, 199, 0), - sizeIndicator: "S", - testCoverageIndicator: false, - }, - { - gitResponse: "200\t0\tfile.ts\n200\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(200, 200, 0), - sizeIndicator: "S", - testCoverageIndicator: true, - }, - { - gitResponse: "399\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(399, 0, 0), - sizeIndicator: "S", - testCoverageIndicator: false, - }, - { - gitResponse: "399\t0\tfile.ts\n398\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(399, 398, 0), - sizeIndicator: "S", - testCoverageIndicator: false, - }, - { - gitResponse: "399\t0\tfile.ts\n399\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(399, 399, 0), - sizeIndicator: "S", - testCoverageIndicator: true, - }, - { - gitResponse: "400\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(400, 0, 0), - sizeIndicator: "M", - testCoverageIndicator: false, - }, - { - gitResponse: "400\t0\tfile.ts\n399\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(400, 399, 0), - sizeIndicator: "M", - testCoverageIndicator: false, - }, - { - gitResponse: "400\t0\tfile.ts\n400\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(400, 400, 0), - sizeIndicator: "M", - testCoverageIndicator: true, - }, - { - gitResponse: "799\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(799, 0, 0), - sizeIndicator: "M", - testCoverageIndicator: false, - }, - { - gitResponse: "799\t0\tfile.ts\n798\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(799, 798, 0), - sizeIndicator: "M", - testCoverageIndicator: false, - }, - { - gitResponse: "799\t0\tfile.ts\n799\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(799, 799, 0), - sizeIndicator: "M", - testCoverageIndicator: true, - }, - { - gitResponse: "800\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(800, 0, 0), - sizeIndicator: "L", - testCoverageIndicator: false, - }, - { - gitResponse: "800\t0\tfile.ts\n799\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(800, 799, 0), - sizeIndicator: "L", - testCoverageIndicator: false, - }, - { - gitResponse: "800\t0\tfile.ts\n800\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(800, 800, 0), - sizeIndicator: "L", - testCoverageIndicator: true, - }, - { - gitResponse: "1599\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(1599, 0, 0), - sizeIndicator: "L", - testCoverageIndicator: false, - }, - { - gitResponse: "1599\t0\tfile.ts\n1598\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(1599, 1598, 0), - sizeIndicator: "L", - testCoverageIndicator: false, - }, - { - gitResponse: "1599\t0\tfile.ts\n1599\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(1599, 1599, 0), - sizeIndicator: "L", - testCoverageIndicator: true, - }, - { - gitResponse: "1600\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(1600, 0, 0), - sizeIndicator: "XL", - testCoverageIndicator: false, - }, - { - gitResponse: "1600\t0\tfile.ts\n1599\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(1600, 1599, 0), - sizeIndicator: "XL", - testCoverageIndicator: false, - }, - { - gitResponse: "1600\t0\tfile.ts\n1600\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(1600, 1600, 0), - sizeIndicator: "XL", - testCoverageIndicator: true, - }, - { - gitResponse: "3199\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(3199, 0, 0), - sizeIndicator: "XL", - testCoverageIndicator: false, - }, - { - gitResponse: "3199\t0\tfile.ts\n3198\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(3199, 3198, 0), - sizeIndicator: "XL", - testCoverageIndicator: false, - }, - { - gitResponse: "3199\t0\tfile.ts\n3199\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(3199, 3199, 0), - sizeIndicator: "XL", - testCoverageIndicator: true, - }, - { - gitResponse: "3200\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(3200, 0, 0), - sizeIndicator: "2XL", - testCoverageIndicator: false, - }, - { - gitResponse: "3200\t0\tfile.ts\n3199\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(3200, 3199, 0), - sizeIndicator: "2XL", - testCoverageIndicator: false, - }, - { - gitResponse: "3200\t0\tfile.ts\n3200\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(3200, 3200, 0), - sizeIndicator: "2XL", - testCoverageIndicator: true, - }, - { - gitResponse: "6399\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(6399, 0, 0), - sizeIndicator: "2XL", - testCoverageIndicator: false, - }, - { - gitResponse: "6399\t0\tfile.ts\n6398\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(6399, 6398, 0), - sizeIndicator: "2XL", - testCoverageIndicator: false, - }, - { - gitResponse: "6399\t0\tfile.ts\n6399\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(6399, 6399, 0), - sizeIndicator: "2XL", - testCoverageIndicator: true, - }, - { - gitResponse: "6400\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(6400, 0, 0), - sizeIndicator: "3XL", - testCoverageIndicator: false, - }, - { - gitResponse: "6400\t0\tfile.ts\n6399\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(6400, 6399, 0), - sizeIndicator: "3XL", - testCoverageIndicator: false, - }, - { - gitResponse: "6400\t0\tfile.ts\n6400\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(6400, 6400, 0), - sizeIndicator: "3XL", - testCoverageIndicator: true, - }, - { - gitResponse: "819200\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(819200, 0, 0), - sizeIndicator: "10XL", - testCoverageIndicator: false, - }, - { - gitResponse: "819200\t0\tfile.ts\n819199\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(819200, 819199, 0), - sizeIndicator: "10XL", - testCoverageIndicator: false, - }, - { - gitResponse: "819200\t0\tfile.ts\n819200\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(819200, 819200, 0), - sizeIndicator: "10XL", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfile.TS", - globChecks: 6, - metrics: new CodeMetricsData(1, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - gitResponse: "0\t1\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfile.ignored", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfile", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfile.ts.ignored", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfile.ignored.ts", - globChecks: 6, - metrics: new CodeMetricsData(1, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - gitResponse: "1\t0\ttest.ignored", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\ttasb.cc => test.ts", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tt{a => e}s{b => t}.t{c => s}", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tt{a => est.ts}", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\t{a => test.ts}", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfolder/test.ts", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfolder/Test.ts", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfolder/TEST.ts", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfolder/DuplicateStorage.ts", - globChecks: 6, - metrics: new CodeMetricsData(1, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - gitResponse: "1\t0\tfolder/file.spec.ts", - globChecks: 5, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfolder/file.Spec.ts", - globChecks: 5, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfolder.spec.ts/file.ts", - globChecks: 6, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\ttest/file.ts", - globChecks: 4, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\ttests/file.ts", - globChecks: 4, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\ttests/file.spec.ts", - globChecks: 4, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\ttests/file.SPEC.ts", - globChecks: 4, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t0\tfolder/tests/file.ts", - globChecks: 4, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t1\tfa/b => folder/test.ts", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "1\t1\tf{a => older}/{b => test.ts}", - globChecks: 3, - metrics: new CodeMetricsData(0, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "0\t0\tfile.ts\n", - globChecks: 6, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "-\t-\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - gitResponse: "0\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - ]; - - testCases.forEach( - ({ - gitResponse, - globChecks, - metrics, - sizeIndicator, - testCoverageIndicator, - }: TestCaseType): void => { - it(`with default inputs and git diff '${gitResponse.replace(/\n/gu, "\\n").replace(/\r/gu, "\\r")}', returns '${sizeIndicator}' size and '${String(testCoverageIndicator)}' test coverage`, async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve(gitResponse); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), []); - assert.deepEqual( - await codeMetrics.getDeletedFilesNotRequiringReview(), - [], - ); - assert.equal(await codeMetrics.getSize(), sizeIndicator); - assert.equal( - await codeMetrics.getSizeIndicator(), - `${sizeIndicator}${testCoverageIndicator ? "✔" : "⚠️"}`, - ); - assert.deepEqual(await codeMetrics.getMetrics(), metrics); - assert.equal( - await codeMetrics.isSmall(), - sizeIndicator === "XS" || sizeIndicator === "S", - ); - assert.equal( - await codeMetrics.isSufficientlyTested(), - testCoverageIndicator, - ); - const derivedCount: number = - (gitResponse.replace(/\r\n/gu, "").match(/\n/gu) ?? []).length + - 1 - - (gitResponse.endsWith("\n") ? 1 : 0); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug( - "* CodeMetrics.getDeletedFilesNotRequiringReview()", - ), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).times(derivedCount); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).times( - globChecks, - ); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).times( - derivedCount, - ); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.createFileMetricsMap()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.initializeSizeIndicator()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - }, - ); - } - - { - interface TestCaseType { - deletedFilesNotRequiringReview: string[]; - filesNotRequiringReview: string[]; - gitResponse: string; - globChecks: number; - metrics: CodeMetricsData; - sizeIndicator: string; - testCoverageIndicator: boolean; - } - - const testCases: TestCaseType[] = [ - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "0\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "1\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(1, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "1\t0\tfile.ts\n1\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(1, 1, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "1\t0\tfile.ts\n2\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(1, 2, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "99\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(99, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "99\t0\tfile.ts\n197\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(99, 197, 0), - sizeIndicator: "XS", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "99\t0\tfile.ts\n198\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(99, 198, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "100\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(100, 0, 0), - sizeIndicator: "S", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "100\t0\tfile.ts\n199\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(100, 199, 0), - sizeIndicator: "S", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "100\t0\tfile.ts\n200\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(100, 200, 0), - sizeIndicator: "S", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "149\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(149, 0, 0), - sizeIndicator: "S", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "149\t0\tfile.ts\n297\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(149, 297, 0), - sizeIndicator: "S", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "149\t0\tfile.ts\n298\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(149, 298, 0), - sizeIndicator: "S", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "150\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(150, 0, 0), - sizeIndicator: "M", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "150\t0\tfile.ts\n299\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(150, 299, 0), - sizeIndicator: "M", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "150\t0\tfile.ts\n300\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(150, 300, 0), - sizeIndicator: "M", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "224\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(224, 0, 0), - sizeIndicator: "M", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "224\t0\tfile.ts\n447\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(224, 447, 0), - sizeIndicator: "M", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "224\t0\tfile.ts\n448\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(224, 448, 0), - sizeIndicator: "M", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "225\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(225, 0, 0), - sizeIndicator: "L", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "225\t0\tfile.ts\n449\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(225, 449, 0), - sizeIndicator: "L", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "225\t0\tfile.ts\n450\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(225, 450, 0), - sizeIndicator: "L", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "337\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(337, 0, 0), - sizeIndicator: "L", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "337\t0\tfile.ts\n673\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(337, 673, 0), - sizeIndicator: "L", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "337\t0\tfile.ts\n674\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(337, 674, 0), - sizeIndicator: "L", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "338\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(338, 0, 0), - sizeIndicator: "XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "338\t0\tfile.ts\n675\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(338, 675, 0), - sizeIndicator: "XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "338\t0\tfile.ts\n676\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(338, 676, 0), - sizeIndicator: "XL", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "506\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(506, 0, 0), - sizeIndicator: "XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "506\t0\tfile.ts\n1011\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(506, 1011, 0), - sizeIndicator: "XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "506\t0\tfile.ts\n1012\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(506, 1012, 0), - sizeIndicator: "XL", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "507\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(507, 0, 0), - sizeIndicator: "2XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "507\t0\tfile.ts\n1013\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(507, 1013, 0), - sizeIndicator: "2XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "507\t0\tfile.ts\n1014\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(507, 1014, 0), - sizeIndicator: "2XL", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "759\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(759, 0, 0), - sizeIndicator: "2XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "759\t0\tfile.ts\n1517\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(759, 1517, 0), - sizeIndicator: "2XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "759\t0\tfile.ts\n1518\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(759, 1518, 0), - sizeIndicator: "2XL", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "760\t0\tfile.ts", - globChecks: 6, - metrics: new CodeMetricsData(760, 0, 0), - sizeIndicator: "3XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "760\t0\tfile.ts\n1519\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(760, 1519, 0), - sizeIndicator: "3XL", - testCoverageIndicator: false, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "760\t0\tfile.ts\n1520\t0\ttest.ts", - globChecks: 9, - metrics: new CodeMetricsData(760, 1520, 0), - sizeIndicator: "3XL", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "1\t0\tfile.cs", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "1\t0\ttest.cs", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "1\t0\tfile.tst", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "1\t0\tfile.tts", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: [], - gitResponse: "1\t0\tfilets", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["ignored.ts"], - gitResponse: "1\t0\tignored.ts", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["ignored.cs"], - gitResponse: "1\t0\tignored.cs", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["folder/ignored.ts"], - gitResponse: "1\t0\tfolder/ignored.ts", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["folder/ignored.cs"], - gitResponse: "1\t0\tfolder/ignored.cs", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["ignored.ts"], - gitResponse: "0\t0\tignored.ts", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["ignored.cs"], - gitResponse: "0\t0\tignored.cs", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["folder/ignored.ts"], - gitResponse: "0\t0\tfolder/ignored.ts", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["folder/ignored.cs"], - gitResponse: "0\t0\tfolder/ignored.cs", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: [], - filesNotRequiringReview: ["ignored.ts", "folder/ignored.ts"], - gitResponse: "1\t0\tignored.ts\n0\t0\tfolder/ignored.ts", - globChecks: 4, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: ["ignored.ts"], - filesNotRequiringReview: [], - gitResponse: "0\t1\tignored.ts", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: ["ignored.cs"], - filesNotRequiringReview: [], - gitResponse: "0\t1\tignored.cs", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: ["folder/ignored.ts"], - filesNotRequiringReview: [], - gitResponse: "0\t1\tfolder/ignored.ts", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: ["folder/ignored.cs"], - filesNotRequiringReview: [], - gitResponse: "0\t1\tfolder/ignored.cs", - globChecks: 2, - metrics: new CodeMetricsData(0, 0, 0), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - { - deletedFilesNotRequiringReview: ["folder/ignored.ts"], - filesNotRequiringReview: ["ignored.ts"], - gitResponse: "1\t0\tignored.ts\n0\t1\tfolder/ignored.ts", - globChecks: 4, - metrics: new CodeMetricsData(0, 0, 1), - sizeIndicator: "XS", - testCoverageIndicator: true, - }, - ]; - - testCases.forEach( - ({ - deletedFilesNotRequiringReview, - filesNotRequiringReview, - gitResponse, - globChecks, - metrics, - sizeIndicator, - testCoverageIndicator, - }: TestCaseType): void => { - it(`with non-default inputs and git diff '${gitResponse.replace(/\n/gu, "\\n")}', returns '${sizeIndicator}' size and '${String(testCoverageIndicator)}' test coverage`, async (): Promise => { - // Arrange - when(inputs.baseSize).thenReturn(100); - when(inputs.growthRate).thenReturn(1.5); - when(inputs.testFactor).thenReturn(2.0); - when(inputs.fileMatchingPatterns).thenReturn([ - "**/*", - "other.ts", - "!**/ignored.*", - ]); - when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); - when(gitInvoker.getDiffSummary()).thenResolve(gitResponse); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual( - await codeMetrics.getFilesNotRequiringReview(), - filesNotRequiringReview, - ); - assert.deepEqual( - await codeMetrics.getDeletedFilesNotRequiringReview(), - deletedFilesNotRequiringReview, - ); - assert.equal(await codeMetrics.getSize(), sizeIndicator); - assert.equal( - await codeMetrics.getSizeIndicator(), - `${sizeIndicator}${testCoverageIndicator ? "✔" : "⚠️"}`, - ); - assert.deepEqual(await codeMetrics.getMetrics(), metrics); - assert.equal( - await codeMetrics.isSmall(), - sizeIndicator === "XS" || sizeIndicator === "S", - ); - assert.equal( - await codeMetrics.isSufficientlyTested(), - testCoverageIndicator, - ); - const derivedCount: number = - (gitResponse.match(/\n/gu) ?? []).length + 1; - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug( - "* CodeMetrics.getDeletedFilesNotRequiringReview()", - ), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).times(derivedCount); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).times( - globChecks, - ); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).times( - derivedCount, - ); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).times( - (gitResponse.match(/\n/gu) ?? []).length + 1, - ); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.createFileMetricsMap()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.initializeSizeIndicator()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - }, - ); - } - - { - interface TestCaseType { - gitResponse: string; - globChecks: number; - } - - const testCases: TestCaseType[] = [ - { - gitResponse: - "2\t2\tfile.ts\n1\t1\tignored1.ts\n1\t1\tacceptance.ts\n1\t1\tignored2.ts", - globChecks: 13, - }, - { - gitResponse: - "1\t1\tfile1.ts\n1\t1\tignored1.ts\n1\t1\tignored2.ts\n1\t1\tacceptance.ts\n1\t1\tfile2.ts", - globChecks: 17, - }, - { - gitResponse: - "1\t1\tfile1.ts\n1\t1\tignored1.ts\n1\t1\tfile2.ts\n1\t1\tacceptance.ts\n1\t1\tignored2.ts", - globChecks: 17, - }, - ]; - - testCases.forEach(({ gitResponse, globChecks }: TestCaseType): void => { - it(`with multiple ignore patterns and git diff '${gitResponse.replace(/\n/gu, "\\n").replace(/\r/gu, "\\r")}' ignores the appropriate files`, async (): Promise => { - // Arrange - when(inputs.baseSize).thenReturn(100); - when(inputs.growthRate).thenReturn(1.5); - when(inputs.testFactor).thenReturn(2.0); - when(inputs.fileMatchingPatterns).thenReturn([ - "**/*", - "!**/ignored1.ts", - "!**/ignored2.ts", - ]); - when(inputs.testMatchingPatterns).thenReturn(["**/acceptance.ts"]); - when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); - when(gitInvoker.getDiffSummary()).thenResolve(gitResponse); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), [ - "ignored1.ts", - "ignored2.ts", - ]); - assert.deepEqual( - await codeMetrics.getDeletedFilesNotRequiringReview(), - [], - ); - assert.equal(await codeMetrics.getSize(), "XS"); - assert.equal(await codeMetrics.getSizeIndicator(), "XS⚠️"); - assert.deepEqual( - await codeMetrics.getMetrics(), - new CodeMetricsData(2, 1, 2), - ); - assert.equal(await codeMetrics.isSmall(), true); - assert.equal(await codeMetrics.isSufficientlyTested(), false); - const derivedCount: number = - (gitResponse.match(/\n/gu) ?? []).length + 1; - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).times(derivedCount); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).times( - globChecks, - ); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).times( - derivedCount, - ); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.initializeSizeIndicator()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - }); - } - - it("with custom include pattern, includes the relevant files", async (): Promise => { - // Arrange - when(inputs.baseSize).thenReturn(100); - when(inputs.growthRate).thenReturn(1.5); - when(inputs.testFactor).thenReturn(2.0); - when(inputs.fileMatchingPatterns).thenReturn(["src/*.ts", "__test__/*.ts"]); - when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); - when(gitInvoker.getDiffSummary()).thenResolve( - "1\t1\tfile.ts\n1\t1\tsrc/file.ts\n1\t1\t__test__/file.test.ts", - ); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), [ - "file.ts", - ]); - assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); - assert.equal(await codeMetrics.getSize(), "XS"); - assert.equal(await codeMetrics.getSizeIndicator(), "XS⚠️"); - assert.deepEqual( - await codeMetrics.getMetrics(), - new CodeMetricsData(1, 1, 1), - ); - assert.equal(await codeMetrics.isSmall(), true); - assert.equal(await codeMetrics.isSufficientlyTested(), false); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).times(3); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).times(10); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).times(3); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initializeSizeIndicator()")).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - - it("with only negative patterns, treats all files as non-matching", async (): Promise => { - // Arrange - when(inputs.baseSize).thenReturn(100); - when(inputs.growthRate).thenReturn(1.5); - when(inputs.testFactor).thenReturn(2.0); - when(inputs.fileMatchingPatterns).thenReturn(["!**/ignored.ts"]); - when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); - when(gitInvoker.getDiffSummary()).thenResolve( - "1\t1\tfile.ts\n1\t1\tignored.ts", - ); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), [ - "file.ts", - "ignored.ts", - ]); - assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); - assert.equal(await codeMetrics.getSize(), "XS"); - assert.equal(await codeMetrics.getSizeIndicator(), "XS✔"); - assert.deepEqual( - await codeMetrics.getMetrics(), - new CodeMetricsData(0, 0, 2), - ); - assert.equal(await codeMetrics.isSmall(), true); - assert.equal(await codeMetrics.isSufficientlyTested(), true); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).times(2); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).never(); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).times(2); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initializeSizeIndicator()")).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - - it("with double exclusion ignore patterns ignores the appropriate files", async (): Promise => { - // Arrange - when(inputs.baseSize).thenReturn(100); - when(inputs.growthRate).thenReturn(1.5); - when(inputs.testFactor).thenReturn(2.0); - when(inputs.fileMatchingPatterns).thenReturn([ - "**/*", - "!**/ignored*.ts", - "!!**/ignored2.ts", - ]); - when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); - when(gitInvoker.getDiffSummary()).thenResolve( - "1\t1\tfile.ts\n1\t1\tignored1.ts\n1\t1\tignored2.ts\n1\t1\tignored3.ts", - ); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), [ - "ignored1.ts", - "ignored3.ts", - ]); - assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); - assert.equal(await codeMetrics.getSize(), "XS"); - assert.equal(await codeMetrics.getSizeIndicator(), "XS⚠️"); - assert.deepEqual( - await codeMetrics.getMetrics(), - new CodeMetricsData(2, 0, 2), - ); - assert.equal(await codeMetrics.isSmall(), true); - assert.equal(await codeMetrics.isSufficientlyTested(), false); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).times(4); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).times(19); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).times(4); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initializeSizeIndicator()")).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - - it("with all files matching test files, returns the appropriate results", async (): Promise => { - // Arrange - when(inputs.testMatchingPatterns).thenReturn(["**", "*/**"]); - when(gitInvoker.getDiffSummary()).thenResolve( - "1\t0\tfile.ts\n1\t0\ttest.ts\n1\t0\tfolder/file.ts", - ); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), []); - assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); - assert.equal(await codeMetrics.getSize(), "XS"); - assert.equal(await codeMetrics.getSizeIndicator(), "XS✔"); - assert.deepEqual( - await codeMetrics.getMetrics(), - new CodeMetricsData(0, 3, 0), - ); - assert.equal(await codeMetrics.isSmall(), true); - assert.equal(await codeMetrics.isSufficientlyTested(), true); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).times(3); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).times(9); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).times(3); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initializeSizeIndicator()")).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - - it("should match dotfiles by extracting extension from basename", async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve("1\t0\t.env"); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), []); - assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); - assert.equal(await codeMetrics.getSize(), "XS"); - assert.equal(await codeMetrics.getSizeIndicator(), "XS⚠️"); - assert.deepEqual( - await codeMetrics.getMetrics(), - new CodeMetricsData(1, 0, 0), - ); - assert.equal(await codeMetrics.isSmall(), true); - assert.equal(await codeMetrics.isSufficientlyTested(), false); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.performInitialization()")).once(); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).times(6); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).once(); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initializeSizeIndicator()")).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - - it("should return the expected result with test coverage disabled", async (): Promise => { - // Arrange - when(inputs.testFactor).thenReturn(null); - when(gitInvoker.getDiffSummary()).thenResolve("1\t0\tfile.ts"); - - // Act - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(await codeMetrics.getFilesNotRequiringReview(), []); - assert.deepEqual(await codeMetrics.getDeletedFilesNotRequiringReview(), []); - assert.equal(await codeMetrics.getSize(), "XS"); - assert.equal(await codeMetrics.getSizeIndicator(), "XS"); - assert.deepEqual( - await codeMetrics.getMetrics(), - new CodeMetricsData(1, 0, 0), - ); - assert.equal(await codeMetrics.isSmall(), true); - assert.equal(await codeMetrics.isSufficientlyTested(), null); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.getSize()")).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).times(7); - verify(logger.logDebug("* CodeMetrics.initializeMetrics()")).once(); - verify( - logger.logDebug("* CodeMetrics.determineIfValidFilePattern()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.performGlobCheck()")).times(6); - verify(logger.logDebug("* CodeMetrics.matchFileExtension()")).once(); - verify(logger.logDebug("* CodeMetrics.constructMetrics()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initializeSizeIndicator()")).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - - describe("getFilesNotRequiringReview()", (): void => { - { - const testCases: string[] = ["", " ", "\t", "\n", "\t\n"]; - - testCases.forEach((gitDiffSummary: string): void => { - it(`should return an empty array when the Git diff summary '${gitDiffSummary}' is empty`, async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve(gitDiffSummary); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Act - const result: string[] = - await codeMetrics.getFilesNotRequiringReview(); - - // Assert - assert.deepEqual(result, []); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify( - logger.logDebug("* CodeMetrics.initializeSizeIndicator()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - }); - } - - { - interface TestCaseType { - elements: number; - summary: string; - } - - const testCases: TestCaseType[] = [ - { - elements: 1, - summary: "0", - }, - { - elements: 1, - summary: "0\t", - }, - { - elements: 2, - summary: "0\t0", - }, - { - elements: 2, - summary: "0\t0\t", - }, - { - elements: 2, - summary: "0\tfile.ts", - }, - { - elements: 2, - summary: "0\tfile.ts\t", - }, - ]; - - testCases.forEach(({ elements, summary }: TestCaseType): void => { - it(`should throw when the file name in the Git diff summary '${summary}' cannot be parsed`, async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve(summary); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - codeMetrics.getFilesNotRequiringReview(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `The number of elements '${String(elements)}' in '${summary.trim()}' in input '${summary.trim()}' did not match the expected 3.`, - ); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).once(); - verify( - logger.logDebug("* CodeMetrics.createFileMetricsMap()"), - ).once(); - }); - }); - } - - it("should throw when the lines added in the Git diff summary cannot be converted", async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve("A\t0\tfile.ts"); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - codeMetrics.getFilesNotRequiringReview(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "Could not parse added lines 'A' from line 'A\t0\tfile.ts'.", - ); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - }); - - it("should throw when the lines deleted in the Git diff summary cannot be converted", async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve("0\tA\tfile.ts"); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - codeMetrics.getFilesNotRequiringReview(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "Could not parse deleted lines 'A' from line '0\tA\tfile.ts'.", - ); - verify( - logger.logDebug("* CodeMetrics.getFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - }); - }); - - describe("getDeletedFilesNotRequiringReview()", (): void => { - it("should return an empty array when the Git diff summary '' is empty", async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve(""); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Act - const result: string[] = - await codeMetrics.getDeletedFilesNotRequiringReview(); - - // Assert - assert.deepEqual(result, []); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).once(); - verify( - logger.logDebug("* CodeMetrics.initializeIsSufficientlyTested()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initializeSizeIndicator()")).once(); - verify(logger.logDebug("* CodeMetrics.calculateSize()")).once(); - }); - - it("should throw when the file name in the Git diff summary '0' cannot be parsed", async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve("0"); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - codeMetrics.getDeletedFilesNotRequiringReview(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "The number of elements '1' in '0' in input '0' did not match the expected 3.", - ); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - }); - - it("should throw when the lines added in the Git diff summary cannot be converted", async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve("A\t0\tfile.ts"); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - codeMetrics.getDeletedFilesNotRequiringReview(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "Could not parse added lines 'A' from line 'A\t0\tfile.ts'.", - ); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - }); - - it("should throw when the lines deleted in the Git diff summary cannot be converted", async (): Promise => { - // Arrange - when(gitInvoker.getDiffSummary()).thenResolve("0\tA\tfile.ts"); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - codeMetrics.getDeletedFilesNotRequiringReview(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "Could not parse deleted lines 'A' from line '0\tA\tfile.ts'.", - ); - verify( - logger.logDebug("* CodeMetrics.getDeletedFilesNotRequiringReview()"), - ).once(); - verify(logger.logDebug("* CodeMetrics.initialize()")).once(); - verify(logger.logDebug("* CodeMetrics.createFileMetricsMap()")).once(); - }); - }); - - it("with a size multiplier exceeding 1000, returns a size without thousands separators", async (): Promise => { - // ARRANGE - when(inputs.baseSize).thenReturn(1); - when(inputs.growthRate).thenReturn(1.001); - when(inputs.testFactor).thenReturn(1.0); - when(inputs.fileMatchingPatterns).thenReturn(["**/*"]); - when(inputs.codeFileExtensions).thenReturn(new Set(["ts"])); - when(gitInvoker.getDiffSummary()).thenResolve("3\t0\tfile.ts"); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - anyString() as string, - anyString() as string, - ), - ).thenReturn(""); - const codeMetrics: CodeMetrics = new CodeMetrics( - instance(gitInvoker), - instance(inputs), - instance(logger), - instance(runnerInvoker), - ); - - // ACT - const size: string = await codeMetrics.getSize(); - - // ASSERT - assert.match(size, /^\d+XL$/u); - const multiplier: number = Number.parseInt(size.replace("XL", ""), 10); - assert.ok(multiplier >= 1000); - }); -}); diff --git a/src/task/tests/metrics/codeMetricsCalculator.spec.ts b/src/task/tests/metrics/codeMetricsCalculator.spec.ts index e3929cdb4..d5e89f777 100644 --- a/src/task/tests/metrics/codeMetricsCalculator.spec.ts +++ b/src/task/tests/metrics/codeMetricsCalculator.spec.ts @@ -14,6 +14,8 @@ import PullRequestCommentsData from "../../src/pullRequests/pullRequestCommentsD import ReposInvoker from "../../src/repos/reposInvoker.js"; import RunnerInvoker from "../../src/runners/runnerInvoker.js"; import assert from "node:assert/strict"; +import { stubEnv } from "../testUtilities/stubEnv.js"; +import { stubLocalization } from "../testUtilities/stubLocalization.js"; describe("codeMetricsCalculator.ts", (): void => { let gitInvoker: GitInvoker; @@ -41,49 +43,7 @@ describe("codeMetricsCalculator.ts", (): void => { pullRequestComments = mock(PullRequestComments); runnerInvoker = mock(RunnerInvoker); - when( - runnerInvoker.loc("metrics.codeMetricsCalculator.noGitRepoAzureDevOps"), - ).thenReturn( - "No Git repo present. Remove 'checkout: none' (YAML) or disable 'Don't sync sources' under the build process phase settings (classic).", - ); - when( - runnerInvoker.loc("metrics.codeMetricsCalculator.noGitRepoGitHub"), - ).thenReturn( - "No Git repo present. Run the 'actions/checkout' action prior to PR Metrics.", - ); - when( - runnerInvoker.loc( - "metrics.codeMetricsCalculator.noGitHistoryAzureDevOps", - ), - ).thenReturn( - "Could not access sufficient Git history. Set 'fetchDepth: 0' as a parameter to the 'checkout' task (YAML) or disable 'Shallow fetch' under the build process phase settings (classic).", - ); - when( - runnerInvoker.loc("metrics.codeMetricsCalculator.noGitHistoryGitHub"), - ).thenReturn( - "Could not access sufficient Git history. Add 'fetch-depth: 0' as a parameter to the 'actions/checkout' action.", - ); - when( - runnerInvoker.loc( - "metrics.codeMetricsCalculator.noPullRequestIdAzureDevOps", - ), - ).thenReturn("Could not determine the Pull Request ID."); - when( - runnerInvoker.loc("metrics.codeMetricsCalculator.noPullRequestIdGitHub"), - ).thenReturn( - "Could not determine the Pull Request ID. Ensure 'pull_request' is the pipeline trigger.", - ); - when( - runnerInvoker.loc("metrics.codeMetricsCalculator.noPullRequest"), - ).thenReturn("The build is not running against a pull request."); - when( - runnerInvoker.loc( - "metrics.codeMetricsCalculator.unsupportedProvider", - "Other", - ), - ).thenReturn( - "The build is running against a pull request from 'Other', which is not a supported provider.", - ); + stubLocalization(runnerInvoker); }); describe("shouldSkipWithUnsupportedProvider", (): void => { @@ -104,7 +64,6 @@ describe("codeMetricsCalculator.ts", (): void => { // Assert assert.equal(result, null); - verify(logger.logDebug("* CodeMetricsCalculator.shouldSkip")).once(); }); it("should return the appropriate message when not a supported provider", (): void => { @@ -125,7 +84,6 @@ describe("codeMetricsCalculator.ts", (): void => { // Assert assert.equal(result, "The build is not running against a pull request."); - verify(logger.logDebug("* CodeMetricsCalculator.shouldSkip")).once(); }); it("should return null when the task should not be skipped", (): void => { @@ -149,7 +107,6 @@ describe("codeMetricsCalculator.ts", (): void => { result, "The build is running against a pull request from 'Other', which is not a supported provider.", ); - verify(logger.logDebug("* CodeMetricsCalculator.shouldSkip")).once(); }); }); @@ -171,7 +128,6 @@ describe("codeMetricsCalculator.ts", (): void => { // Assert assert.equal(result, null); - verify(logger.logDebug("* CodeMetricsCalculator.shouldStop()")).once(); }); it("should return the appropriate message when no access token is available", async (): Promise => { @@ -194,7 +150,6 @@ describe("codeMetricsCalculator.ts", (): void => { // Assert assert.equal(result, "No Access Token"); - verify(logger.logDebug("* CodeMetricsCalculator.shouldStop()")).once(); }); it("should return the appropriate message when not called from a Git repo on Azure DevOps", async (): Promise => { @@ -218,12 +173,11 @@ describe("codeMetricsCalculator.ts", (): void => { result, "No Git repo present. Remove 'checkout: none' (YAML) or disable 'Don't sync sources' under the build process phase settings (classic).", ); - verify(logger.logDebug("* CodeMetricsCalculator.shouldStop()")).once(); }); it("should return the appropriate message when not called from a Git repo on GitHub", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); when(gitInvoker.isGitRepo()).thenResolve(false); const codeMetricsCalculator: CodeMetricsCalculator = new CodeMetricsCalculator( @@ -243,10 +197,6 @@ describe("codeMetricsCalculator.ts", (): void => { result, "No Git repo present. Run the 'actions/checkout' action prior to PR Metrics.", ); - verify(logger.logDebug("* CodeMetricsCalculator.shouldStop()")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); it("should return the appropriate message when the pull request ID is not available on Azure DevOps", async (): Promise => { @@ -267,12 +217,11 @@ describe("codeMetricsCalculator.ts", (): void => { // Assert assert.equal(result, "Could not determine the Pull Request ID."); - verify(logger.logDebug("* CodeMetricsCalculator.shouldStop()")).once(); }); it("should return the appropriate message when the pull request ID is not available on GitHub", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); when(gitInvoker.isPullRequestIdAvailable()).thenReturn(false); const codeMetricsCalculator: CodeMetricsCalculator = new CodeMetricsCalculator( @@ -292,10 +241,6 @@ describe("codeMetricsCalculator.ts", (): void => { result, "Could not determine the Pull Request ID. Ensure 'pull_request' is the pipeline trigger.", ); - verify(logger.logDebug("* CodeMetricsCalculator.shouldStop()")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); it("should return the appropriate message when the Git history is unavailable on Azure DevOps", async (): Promise => { @@ -319,12 +264,11 @@ describe("codeMetricsCalculator.ts", (): void => { result, "Could not access sufficient Git history. Set 'fetchDepth: 0' as a parameter to the 'checkout' task (YAML) or disable 'Shallow fetch' under the build process phase settings (classic).", ); - verify(logger.logDebug("* CodeMetricsCalculator.shouldStop()")).once(); }); it("should return the appropriate message when the Git history is unavailable on GitHub", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); when(gitInvoker.isGitHistoryAvailable()).thenResolve(false); const codeMetricsCalculator: CodeMetricsCalculator = new CodeMetricsCalculator( @@ -344,10 +288,6 @@ describe("codeMetricsCalculator.ts", (): void => { result, "Could not access sufficient Git history. Add 'fetch-depth: 0' as a parameter to the 'actions/checkout' action.", ); - verify(logger.logDebug("* CodeMetricsCalculator.shouldStop()")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -376,7 +316,6 @@ describe("codeMetricsCalculator.ts", (): void => { await codeMetricsCalculator.updateDetails(); // Assert - verify(logger.logDebug("* CodeMetricsCalculator.updateDetails()")).once(); verify(pullRequest.getUpdatedTitle("Title")).once(); verify(pullRequest.getUpdatedDescription("Description")).once(); verify( @@ -406,7 +345,6 @@ describe("codeMetricsCalculator.ts", (): void => { await codeMetricsCalculator.updateDetails(); // Assert - verify(logger.logDebug("* CodeMetricsCalculator.updateDetails()")).once(); verify(pullRequest.getUpdatedTitle("Title")).once(); verify(pullRequest.getUpdatedDescription(null)).once(); verify( @@ -438,9 +376,6 @@ describe("codeMetricsCalculator.ts", (): void => { await codeMetricsCalculator.updateComments(); // Assert - verify( - logger.logDebug("* CodeMetricsCalculator.updateComments()"), - ).once(); }); it("should perform the expected actions when the metrics comment is to be updated", async (): Promise => { @@ -469,12 +404,6 @@ describe("codeMetricsCalculator.ts", (): void => { await codeMetricsCalculator.updateComments(); // Assert - verify( - logger.logDebug("* CodeMetricsCalculator.updateComments()"), - ).once(); - verify( - logger.logDebug("* CodeMetricsCalculator.updateMetricsComment()"), - ).once(); verify( reposInvoker.updateComment( 1, @@ -509,12 +438,6 @@ describe("codeMetricsCalculator.ts", (): void => { await codeMetricsCalculator.updateComments(); // Assert - verify( - logger.logDebug("* CodeMetricsCalculator.updateComments()"), - ).once(); - verify( - logger.logDebug("* CodeMetricsCalculator.updateMetricsComment()"), - ).once(); verify( reposInvoker.createComment( "Description", @@ -583,14 +506,6 @@ describe("codeMetricsCalculator.ts", (): void => { await codeMetricsCalculator.updateComments(); // Assert - verify( - logger.logDebug("* CodeMetricsCalculator.updateComments()"), - ).once(); - verify( - logger.logDebug( - "* CodeMetricsCalculator.updateNoReviewRequiredComment()", - ), - ).times(file1Comments + file2Comments); verify( reposInvoker.createComment( "No Review Required", @@ -640,14 +555,6 @@ describe("codeMetricsCalculator.ts", (): void => { await codeMetricsCalculator.updateComments(); // Assert - verify( - logger.logDebug("* CodeMetricsCalculator.updateComments()"), - ).once(); - verify( - logger.logDebug( - "* CodeMetricsCalculator.updateNoReviewRequiredComment()", - ), - ).times(file1Comments + file2Comments); verify( reposInvoker.createComment( "No Review Required", @@ -691,9 +598,6 @@ describe("codeMetricsCalculator.ts", (): void => { await codeMetricsCalculator.updateComments(); // Assert - verify( - logger.logDebug("* CodeMetricsCalculator.updateComments()"), - ).once(); verify(reposInvoker.deleteCommentThread(1)).once(); verify(reposInvoker.deleteCommentThread(2)).once(); }); diff --git a/src/task/tests/metrics/codeMetricsTestSetup.ts b/src/task/tests/metrics/codeMetricsTestSetup.ts new file mode 100644 index 000000000..deda70186 --- /dev/null +++ b/src/task/tests/metrics/codeMetricsTestSetup.ts @@ -0,0 +1,72 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { instance, mock, when } from "ts-mockito"; +import CodeMetrics from "../../src/metrics/codeMetrics.js"; +import GitInvoker from "../../src/git/gitInvoker.js"; +import Inputs from "../../src/metrics/inputs.js"; +import Logger from "../../src/utilities/logger.js"; +import RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { stubLocalization } from "../testUtilities/stubLocalization.js"; + +export interface CodeMetricsMocks { + gitInvoker: GitInvoker; + inputs: Inputs; + logger: Logger; + runnerInvoker: RunnerInvoker; +} + +/** + * Creates the mocks required by `codeMetrics.ts` tests, pre-wired with the + * default `Inputs` values so that `CodeMetrics` construction succeeds. + * Individual tests can override any stub after calling this helper. + * @returns The paired mocks. + */ +export const createCodeMetricsMocks = (): CodeMetricsMocks => { + const gitInvoker: GitInvoker = mock(GitInvoker); + + const inputs: Inputs = mock(Inputs); + when(inputs.baseSize).thenReturn(InputsDefault.baseSize); + when(inputs.growthRate).thenReturn(InputsDefault.growthRate); + when(inputs.testFactor).thenReturn(InputsDefault.testFactor); + when(inputs.fileMatchingPatterns).thenReturn( + InputsDefault.fileMatchingPatterns, + ); + when(inputs.testMatchingPatterns).thenReturn( + InputsDefault.testMatchingPatterns, + ); + when(inputs.codeFileExtensions).thenReturn( + new Set(InputsDefault.codeFileExtensions), + ); + + const logger: Logger = mock(Logger); + + const runnerInvoker: RunnerInvoker = mock(RunnerInvoker); + stubLocalization(runnerInvoker); + + return { gitInvoker, inputs, logger, runnerInvoker }; +}; + +/** + * Constructs a `CodeMetrics` instance from the supplied mocks. + * @param gitInvoker The mocked git invoker. + * @param inputs The mocked inputs. + * @param logger The mocked logger. + * @param runnerInvoker The mocked runner invoker. + * @returns The constructed `CodeMetrics` instance. + */ +export const createSut = ( + gitInvoker: GitInvoker, + inputs: Inputs, + logger: Logger, + runnerInvoker: RunnerInvoker, +): CodeMetrics => + new CodeMetrics( + instance(gitInvoker), + instance(inputs), + instance(logger), + instance(runnerInvoker), + ); diff --git a/src/task/tests/metrics/inputs.allInputs.spec.ts b/src/task/tests/metrics/inputs.allInputs.spec.ts new file mode 100644 index 000000000..356e7f4ac --- /dev/null +++ b/src/task/tests/metrics/inputs.allInputs.spec.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { + adjustingAlwaysCloseComment, + adjustingBaseSizeResource, + adjustingCodeFileExtensionsResource, + adjustingFileMatchingPatternsResource, + adjustingGrowthRateResource, + adjustingTestFactorResource, + adjustingTestMatchingPatternsResource, + createInputsMocks, + createSut, + settingAlwaysCloseComment, + settingBaseSizeResource, + settingCodeFileExtensionsResource, + settingFileMatchingPatternsResource, + settingGrowthRateResource, + settingTestFactorResource, + settingTestMatchingPatternsResource, +} from "./inputsTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; + +describe("inputs.ts", (): void => { + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ logger, runnerInvoker } = createInputsMocks()); + }); + + describe("initialize()", (): void => { + describe("all inputs", (): void => { + it("should set all default values when nothing is specified", (): void => { + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.baseSize, InputsDefault.baseSize); + assert.equal(inputs.growthRate, InputsDefault.growthRate); + assert.equal(inputs.testFactor, InputsDefault.testFactor); + assert.equal( + inputs.alwaysCloseComment, + InputsDefault.alwaysCloseComment, + ); + assert.deepEqual( + inputs.fileMatchingPatterns, + InputsDefault.fileMatchingPatterns, + ); + assert.deepEqual( + inputs.testMatchingPatterns, + InputsDefault.testMatchingPatterns, + ); + assert.deepEqual( + inputs.codeFileExtensions, + new Set(InputsDefault.codeFileExtensions), + ); + verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); + verify(logger.logInfo(adjustingBaseSizeResource)).once(); + verify(logger.logInfo(adjustingGrowthRateResource)).once(); + verify(logger.logInfo(adjustingTestFactorResource)).once(); + verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); + verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); + verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); + }); + + it("should set all input values when all are specified", (): void => { + // Arrange + when(runnerInvoker.getInput(deepEqual(["Base", "Size"]))).thenReturn( + "5.0", + ); + when(runnerInvoker.getInput(deepEqual(["Growth", "Rate"]))).thenReturn( + "4.4", + ); + when(runnerInvoker.getInput(deepEqual(["Test", "Factor"]))).thenReturn( + "2.7", + ); + when( + runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), + ).thenReturn("true"); + when( + runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), + ).thenReturn("aa\nbb"); + when( + runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), + ).thenReturn("cc\ndd"); + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn("js\nts"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.baseSize, 5.0); + assert.equal(inputs.growthRate, 4.4); + assert.equal(inputs.testFactor, 2.7); + assert.deepEqual(inputs.alwaysCloseComment, true); + assert.deepEqual(inputs.fileMatchingPatterns, ["aa", "bb"]); + assert.deepEqual(inputs.testMatchingPatterns, ["cc", "dd"]); + assert.deepEqual( + inputs.codeFileExtensions, + new Set(["js", "ts"]), + ); + verify(logger.logInfo(settingAlwaysCloseComment)).once(); + verify( + logger.logInfo(settingBaseSizeResource((5).toLocaleString())), + ).once(); + verify( + logger.logInfo(settingGrowthRateResource((4.4).toLocaleString())), + ).once(); + verify( + logger.logInfo(settingTestFactorResource((2.7).toLocaleString())), + ).once(); + verify( + logger.logInfo( + settingFileMatchingPatternsResource(JSON.stringify(["aa", "bb"])), + ), + ).once(); + verify( + logger.logInfo( + settingTestMatchingPatternsResource(JSON.stringify(["cc", "dd"])), + ), + ).once(); + verify( + logger.logInfo( + settingCodeFileExtensionsResource(JSON.stringify(["js", "ts"])), + ), + ).once(); + }); + }); + }); +}); diff --git a/src/task/tests/metrics/inputs.alwaysCloseComment.spec.ts b/src/task/tests/metrics/inputs.alwaysCloseComment.spec.ts new file mode 100644 index 000000000..365d6a762 --- /dev/null +++ b/src/task/tests/metrics/inputs.alwaysCloseComment.spec.ts @@ -0,0 +1,84 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { + adjustingAlwaysCloseComment, + createInputsMocks, + createSut, + settingAlwaysCloseComment, +} from "./inputsTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; + +describe("inputs.ts", (): void => { + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ logger, runnerInvoker } = createInputsMocks()); + }); + + describe("initialize()", (): void => { + describe("alwaysCloseComment", (): void => { + { + const testCases: (string | null)[] = [ + null, + "", + " ", + "abc", + "false", + "False", + "FALSE", + "fALSE", + "null", + "undefined", + ]; + + testCases.forEach((alwaysCloseComment: string | null): void => { + it(`should set the default when the input is '${String(alwaysCloseComment)}'`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), + ).thenReturn(alwaysCloseComment); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal( + inputs.alwaysCloseComment, + InputsDefault.alwaysCloseComment, + ); + verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); + }); + }); + } + + { + const testCases: string[] = ["true", "True", "TRUE", "tRUE"]; + + testCases.forEach((alwaysCloseComment: string): void => { + it(`should set to true when the input is '${alwaysCloseComment}'`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), + ).thenReturn(alwaysCloseComment); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.alwaysCloseComment, true); + verify(logger.logInfo(settingAlwaysCloseComment)).once(); + }); + }); + } + }); + }); +}); diff --git a/src/task/tests/metrics/inputs.baseSize.spec.ts b/src/task/tests/metrics/inputs.baseSize.spec.ts new file mode 100644 index 000000000..24dab836d --- /dev/null +++ b/src/task/tests/metrics/inputs.baseSize.spec.ts @@ -0,0 +1,94 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { + adjustingBaseSizeResource, + createInputsMocks, + createSut, + settingBaseSizeResource, +} from "./inputsTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { decimalRadix } from "../../src/utilities/constants.js"; +import { invalidNumericStrings } from "../testUtilities/fixtures/invalidInputs.js"; + +describe("inputs.ts", (): void => { + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ logger, runnerInvoker } = createInputsMocks()); + }); + + describe("initialize()", (): void => { + describe("baseSize", (): void => { + invalidNumericStrings.forEach((baseSize: string | null): void => { + it(`should set the default when the input '${String(baseSize)}' is invalid`, (): void => { + // Arrange + when(runnerInvoker.getInput(deepEqual(["Base", "Size"]))).thenReturn( + baseSize, + ); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.baseSize, InputsDefault.baseSize); + verify(logger.logInfo(adjustingBaseSizeResource)).once(); + }); + }); + + { + const testCases: string[] = ["0", "-1", "-1000", "-5"]; + + testCases.forEach((baseSize: string): void => { + it(`should set the default when the input '${baseSize}' is less than or equal to 0`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Base", "Size"])), + ).thenReturn(baseSize); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.baseSize, InputsDefault.baseSize); + verify(logger.logInfo(adjustingBaseSizeResource)).once(); + }); + }); + } + + { + const testCases: string[] = ["1", "5", "1000", "5.5"]; + + testCases.forEach((baseSize: string): void => { + it(`should set the converted value when the input '${baseSize}' is greater than 0`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Base", "Size"])), + ).thenReturn(baseSize); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.baseSize, parseInt(baseSize, decimalRadix)); + verify( + logger.logInfo( + settingBaseSizeResource( + parseInt(baseSize, decimalRadix).toLocaleString(), + ), + ), + ).once(); + }); + }); + } + }); + }); +}); diff --git a/src/task/tests/metrics/inputs.codeFileExtensions.spec.ts b/src/task/tests/metrics/inputs.codeFileExtensions.spec.ts new file mode 100644 index 000000000..bd9039ebf --- /dev/null +++ b/src/task/tests/metrics/inputs.codeFileExtensions.spec.ts @@ -0,0 +1,170 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { + adjustingCodeFileExtensionsResource, + createInputsMocks, + createSut, + settingCodeFileExtensionsResource, +} from "./inputsTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { invalidPatternStrings } from "../testUtilities/fixtures/invalidInputs.js"; + +describe("inputs.ts", (): void => { + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ logger, runnerInvoker } = createInputsMocks()); + }); + + describe("initialize()", (): void => { + describe("codeFileExtensions", (): void => { + invalidPatternStrings.forEach( + (codeFileExtensions: string | null): void => { + it(`should set the default when the input '${String(codeFileExtensions?.replace(/\n/gu, "\\n"))}' is invalid`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn(codeFileExtensions); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual( + inputs.codeFileExtensions, + new Set(InputsDefault.codeFileExtensions), + ); + verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); + }); + }, + ); + + { + const testCases: string[] = [ + "ada\njs\nts\nbb\ntxt", + "abc\ndef\nhij", + "ts", + ]; + + testCases.forEach((codeFileExtensions: string): void => { + it(`should split '${codeFileExtensions.replace(/\n/gu, "\\n")}' at the newline character`, (): void => { + // Arrange + const expectedResult: Set = new Set( + codeFileExtensions.split("\n"), + ); + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn(codeFileExtensions); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.codeFileExtensions, expectedResult); + verify( + logger.logInfo( + settingCodeFileExtensionsResource( + JSON.stringify([...expectedResult]), + ), + ), + ).once(); + }); + }); + } + + it("should handle repeated insertion of identical items", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn("ada\nada"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.codeFileExtensions, new Set(["ada"])); + verify( + logger.logInfo( + settingCodeFileExtensionsResource(JSON.stringify(["ada"])), + ), + ).once(); + }); + + it("should convert extensions to lower case", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn("ADA\ncS\nTxT"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual( + inputs.codeFileExtensions, + new Set(["ada", "cs", "txt"]), + ); + verify( + logger.logInfo( + settingCodeFileExtensionsResource( + JSON.stringify(["ada", "cs", "txt"]), + ), + ), + ).once(); + }); + + it("should remove . and * from extension names", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn("*.ada\n.txt"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual( + inputs.codeFileExtensions, + new Set(["ada", "txt"]), + ); + verify( + logger.logInfo( + settingCodeFileExtensionsResource(JSON.stringify(["ada", "txt"])), + ), + ).once(); + }); + + it("should remove trailing new lines", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn("ada\ncs\ntxt\n"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual( + inputs.codeFileExtensions, + new Set(["ada", "cs", "txt"]), + ); + verify( + logger.logInfo( + settingCodeFileExtensionsResource( + JSON.stringify(["ada", "cs", "txt"]), + ), + ), + ).once(); + }); + }); + }); +}); diff --git a/src/task/tests/metrics/inputs.fileMatchingPatterns.spec.ts b/src/task/tests/metrics/inputs.fileMatchingPatterns.spec.ts new file mode 100644 index 000000000..83809814f --- /dev/null +++ b/src/task/tests/metrics/inputs.fileMatchingPatterns.spec.ts @@ -0,0 +1,216 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { + adjustingFileMatchingPatternsResource, + createInputsMocks, + createSut, + settingFileMatchingPatternsResource, +} from "./inputsTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { invalidPatternStrings } from "../testUtilities/fixtures/invalidInputs.js"; +import { maxPatternCount } from "../../src/utilities/constants.js"; + +describe("inputs.ts", (): void => { + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ logger, runnerInvoker } = createInputsMocks()); + }); + + describe("initialize()", (): void => { + describe("fileMatchingPatterns", (): void => { + invalidPatternStrings.forEach( + (fileMatchingPatterns: string | null): void => { + it(`should set the default when the input '${String(fileMatchingPatterns?.replace(/\n/gu, "\\n"))}' is invalid`, (): void => { + // Arrange + when( + runnerInvoker.getInput( + deepEqual(["File", "Matching", "Patterns"]), + ), + ).thenReturn(fileMatchingPatterns); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual( + inputs.fileMatchingPatterns, + InputsDefault.fileMatchingPatterns, + ); + verify( + logger.logInfo(adjustingFileMatchingPatternsResource), + ).once(); + }); + }, + ); + + { + const testCases: string[] = [ + "abc", + "abc def hik", + "*.ada *.js *ts *.bb *txt", + ]; + + testCases.forEach((fileMatchingPatterns: string): void => { + it(`should not split '${fileMatchingPatterns}'`, (): void => { + // Arrange + when( + runnerInvoker.getInput( + deepEqual(["File", "Matching", "Patterns"]), + ), + ).thenReturn(fileMatchingPatterns); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.fileMatchingPatterns, [ + fileMatchingPatterns, + ]); + verify( + logger.logInfo(adjustingFileMatchingPatternsResource), + ).never(); + verify( + logger.logInfo( + settingFileMatchingPatternsResource( + JSON.stringify([fileMatchingPatterns]), + ), + ), + ).once(); + }); + }); + } + + { + const testCases: string[] = [ + "*.ada\n*.js\n*.ts\n*.bb\n*.txt", + "abc\ndef\nhij", + ]; + + testCases.forEach((fileMatchingPatterns: string): void => { + it(`should split '${fileMatchingPatterns.replace(/\n/gu, "\\n")}' at the newline character`, (): void => { + // Arrange + const expectedOutput: string[] = fileMatchingPatterns.split("\n"); + when( + runnerInvoker.getInput( + deepEqual(["File", "Matching", "Patterns"]), + ), + ).thenReturn(fileMatchingPatterns); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.fileMatchingPatterns, expectedOutput); + verify( + logger.logInfo(adjustingFileMatchingPatternsResource), + ).never(); + verify( + logger.logInfo( + settingFileMatchingPatternsResource( + JSON.stringify(expectedOutput), + ), + ), + ).once(); + }); + }); + } + + it("should replace all '\\' with '/'", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), + ).thenReturn("folder1\\file.js\nfolder2\\*.js"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.fileMatchingPatterns, [ + "folder1/file.js", + "folder2/*.js", + ]); + verify( + logger.logInfo( + settingFileMatchingPatternsResource( + JSON.stringify(["folder1/file.js", "folder2/*.js"]), + ), + ), + ).once(); + }); + + it("should remove trailing new lines", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), + ).thenReturn("file.js\n"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.fileMatchingPatterns, ["file.js"]); + verify( + logger.logInfo( + settingFileMatchingPatternsResource(JSON.stringify(["file.js"])), + ), + ).once(); + }); + + it("should trim whitespace and filter empty lines", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), + ).thenReturn(" pattern1 \n\n pattern2 \n \npattern3"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.fileMatchingPatterns, [ + "pattern1", + "pattern2", + "pattern3", + ]); + }); + + it("should truncate patterns exceeding the maximum count", (): void => { + // Arrange + const excessCount: number = maxPatternCount + 50; + const patterns: string[] = Array.from( + { length: excessCount }, + (_value: string, index: number) => `pattern${String(index)}`, + ); + const maxPatternCountString: string = maxPatternCount.toLocaleString(); + when( + runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), + ).thenReturn(patterns.join("\n")); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.fileMatchingPatterns.length, maxPatternCount); + assert.equal(inputs.fileMatchingPatterns[0], "pattern0"); + assert.equal( + inputs.fileMatchingPatterns[maxPatternCount - 1], + `pattern${String(maxPatternCount - 1)}`, + ); + verify( + logger.logWarning( + `The matching pattern count '${excessCount.toLocaleString()}' exceeds the maximum '${maxPatternCountString}'. Using only the first '${maxPatternCountString}'.`, + ), + ).once(); + }); + }); + }); +}); diff --git a/src/task/tests/metrics/inputs.growthRate.spec.ts b/src/task/tests/metrics/inputs.growthRate.spec.ts new file mode 100644 index 000000000..a10519d1d --- /dev/null +++ b/src/task/tests/metrics/inputs.growthRate.spec.ts @@ -0,0 +1,117 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { + adjustingGrowthRateResource, + createInputsMocks, + createSut, + settingGrowthRateResource, +} from "./inputsTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { invalidNumericStrings } from "../testUtilities/fixtures/invalidInputs.js"; + +describe("inputs.ts", (): void => { + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ logger, runnerInvoker } = createInputsMocks()); + }); + + describe("initialize()", (): void => { + describe("growthRate", (): void => { + { + const testCases: (string | null)[] = [ + ...invalidNumericStrings, + "Infinity", + ]; + + testCases.forEach((growthRate: string | null): void => { + it(`should set the default when the input '${String(growthRate)}' is invalid`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Growth", "Rate"])), + ).thenReturn(growthRate); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.growthRate, InputsDefault.growthRate); + verify(logger.logInfo(adjustingGrowthRateResource)).once(); + }); + }); + } + + { + const testCases: string[] = [ + "0", + "0.5", + "1", + "-2", + "-1.2", + "-5", + "0.9999999999", + ]; + + testCases.forEach((growthRate: string): void => { + it(`should set the default when the input '${growthRate}' is less than or equal to 1.0`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Growth", "Rate"])), + ).thenReturn(growthRate); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.growthRate, InputsDefault.growthRate); + verify(logger.logInfo(adjustingGrowthRateResource)).once(); + }); + }); + } + + { + const testCases: string[] = [ + "5", + "2.0", + "1000", + "1.001", + "1.2", + "1.0000000001", + "1.09", + "7", + ]; + + testCases.forEach((growthRate: string): void => { + it(`should set the converted value when the input '${growthRate}' is greater than 1.0`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Growth", "Rate"])), + ).thenReturn(growthRate); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.growthRate, parseFloat(growthRate)); + verify( + logger.logInfo( + settingGrowthRateResource( + parseFloat(growthRate).toLocaleString(), + ), + ), + ).once(); + }); + }); + } + }); + }); +}); diff --git a/src/task/tests/metrics/inputs.property.spec.ts b/src/task/tests/metrics/inputs.property.spec.ts index b7969b39a..958d084d2 100644 --- a/src/task/tests/metrics/inputs.property.spec.ts +++ b/src/task/tests/metrics/inputs.property.spec.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. */ +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; import * as fc from "fast-check"; import { deepEqual, instance, mock, when } from "ts-mockito"; import Inputs from "../../src/metrics/inputs.js"; @@ -10,45 +11,297 @@ import Logger from "../../src/utilities/logger.js"; import RunnerInvoker from "../../src/runners/runnerInvoker.js"; import { anyString } from "../testUtilities/mockito.js"; import assert from "node:assert/strict"; +import { maxPatternCount } from "../../src/utilities/constants.js"; const numRuns = 10; +interface InputOverrides { + readonly alwaysCloseComment?: string; + readonly baseSize?: string; + readonly codeFileExtensions?: string; + readonly fileMatchingPatterns?: string; + readonly growthRate?: string; + readonly testFactor?: string; + readonly testMatchingPatterns?: string; +} + +const createInputs = (overrides: InputOverrides = {}): Inputs => { + const logger: Logger = mock(Logger); + const runnerInvoker: RunnerInvoker = mock(RunnerInvoker); + when(runnerInvoker.loc(anyString())).thenReturn(""); + when(runnerInvoker.loc(anyString(), anyString())).thenReturn(""); + when(runnerInvoker.getInput(deepEqual(["Base", "Size"]))).thenReturn( + overrides.baseSize ?? null, + ); + when(runnerInvoker.getInput(deepEqual(["Growth", "Rate"]))).thenReturn( + overrides.growthRate ?? null, + ); + when(runnerInvoker.getInput(deepEqual(["Test", "Factor"]))).thenReturn( + overrides.testFactor ?? null, + ); + when( + runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), + ).thenReturn(overrides.alwaysCloseComment ?? null); + when( + runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), + ).thenReturn(overrides.fileMatchingPatterns ?? null); + when( + runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), + ).thenReturn(overrides.testMatchingPatterns ?? null); + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn(overrides.codeFileExtensions ?? null); + return new Inputs(instance(logger), instance(runnerInvoker)); +}; + describe("inputs.ts", (): void => { describe("Property-Based Tests", (): void => { - describe("codeFileExtensions", (): void => { - const createInputs = (codeFileExtensions: string): Inputs => { - const logger: Logger = mock(Logger); - const runnerInvoker: RunnerInvoker = mock(RunnerInvoker); - when(runnerInvoker.loc(anyString())).thenReturn(""); - when(runnerInvoker.loc(anyString(), anyString())).thenReturn(""); - when(runnerInvoker.getInput(deepEqual(["Base", "Size"]))).thenReturn( - null, - ); - when(runnerInvoker.getInput(deepEqual(["Growth", "Rate"]))).thenReturn( - null, - ); - when(runnerInvoker.getInput(deepEqual(["Test", "Factor"]))).thenReturn( - null, - ); - when( - runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), - ).thenReturn(null); - when( - runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), - ).thenReturn(null); - when( - runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), - ).thenReturn(null); - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn(codeFileExtensions); - return new Inputs(instance(logger), instance(runnerInvoker)); - }; + describe("baseSize", (): void => { + it("should use the parsed value for any positive integer string", (): void => { + fc.assert( + fc.property( + fc.integer({ max: 1_000_000, min: 1 }), + (value: number) => { + const inputs: Inputs = createInputs({ + baseSize: String(value), + }); + assert.equal(inputs.baseSize, value); + }, + ), + { numRuns }, + ); + }); + it("should use the default for any non-positive integer string", (): void => { + fc.assert( + fc.property( + fc.integer({ max: 0, min: -1_000_000 }), + (value: number) => { + const inputs: Inputs = createInputs({ + baseSize: String(value), + }); + assert.equal(inputs.baseSize, InputsDefault.baseSize); + }, + ), + { numRuns }, + ); + }); + + it("should use the default for any non-numeric string", (): void => { + fc.assert( + fc.property( + fc.stringMatching(/^[A-Za-z!?@#]{1,10}$/u), + (text: string) => { + const inputs: Inputs = createInputs({ baseSize: text }); + assert.equal(inputs.baseSize, InputsDefault.baseSize); + }, + ), + { numRuns }, + ); + }); + }); + + describe("growthRate", (): void => { + it("should use the parsed value for any number greater than 1.0", (): void => { + fc.assert( + fc.property( + fc.double({ + max: 1_000_000, + min: 1.01, + noDefaultInfinity: true, + noNaN: true, + }), + (value: number) => { + const inputs: Inputs = createInputs({ + growthRate: String(value), + }); + assert.equal(inputs.growthRate, parseFloat(String(value))); + }, + ), + { numRuns }, + ); + }); + + it("should use the default for any number less than or equal to 1.0", (): void => { + fc.assert( + fc.property( + fc.double({ + max: 1.0, + min: -1_000_000, + noDefaultInfinity: true, + noNaN: true, + }), + (value: number) => { + const inputs: Inputs = createInputs({ + growthRate: String(value), + }); + assert.equal(inputs.growthRate, InputsDefault.growthRate); + }, + ), + { numRuns }, + ); + }); + }); + + describe("testFactor", (): void => { + it("should use the parsed value for any positive number", (): void => { + fc.assert( + fc.property( + fc.double({ + max: 1_000_000, + min: 0.001, + noDefaultInfinity: true, + noNaN: true, + }), + (value: number) => { + const inputs: Inputs = createInputs({ + testFactor: String(value), + }); + assert.equal(inputs.testFactor, parseFloat(String(value))); + }, + ), + { numRuns }, + ); + }); + + it("should use the default for any negative number", (): void => { + fc.assert( + fc.property( + fc.double({ + max: -0.001, + min: -1_000_000, + noDefaultInfinity: true, + noNaN: true, + }), + (value: number) => { + const inputs: Inputs = createInputs({ + testFactor: String(value), + }); + assert.equal(inputs.testFactor, InputsDefault.testFactor); + }, + ), + { numRuns }, + ); + }); + }); + + describe("fileMatchingPatterns", (): void => { + it("should wrap any non-empty single-line pattern in a one-element array", (): void => { + fc.assert( + fc.property( + fc.stringMatching(/^[a-z]{1,20}$/u), + (pattern: string) => { + const inputs: Inputs = createInputs({ + fileMatchingPatterns: pattern, + }); + assert.deepEqual(inputs.fileMatchingPatterns, [pattern]); + }, + ), + { numRuns }, + ); + }); + + it("should split any newline-separated string into its component patterns", (): void => { + fc.assert( + fc.property( + fc.array(fc.stringMatching(/^[a-z]{1,20}$/u), { + maxLength: 10, + minLength: 2, + }), + (parts: string[]) => { + const inputs: Inputs = createInputs({ + fileMatchingPatterns: parts.join("\n"), + }); + assert.deepEqual(inputs.fileMatchingPatterns, parts); + }, + ), + { numRuns }, + ); + }); + + it("should replace backslashes with forward slashes", (): void => { + fc.assert( + fc.property( + fc.stringMatching(/^[a-z]{1,10}$/u), + fc.stringMatching(/^[a-z]{1,10}$/u), + (head: string, tail: string) => { + const inputs: Inputs = createInputs({ + fileMatchingPatterns: `${head}\\${tail}`, + }); + assert.deepEqual(inputs.fileMatchingPatterns, [ + `${head}/${tail}`, + ]); + }, + ), + { numRuns }, + ); + }); + + it("should cap the pattern count at the configured maximum", (): void => { + fc.assert( + fc.property( + fc.integer({ + max: maxPatternCount + 100, + min: maxPatternCount + 1, + }), + (count: number) => { + const input: string = Array.from( + { length: count }, + (_value: unknown, index: number) => `pattern${String(index)}`, + ).join("\n"); + const inputs: Inputs = createInputs({ + fileMatchingPatterns: input, + }); + assert.equal(inputs.fileMatchingPatterns.length, maxPatternCount); + }, + ), + { numRuns }, + ); + }); + }); + + describe("testMatchingPatterns", (): void => { + it("should wrap any non-empty single-line pattern in a one-element array", (): void => { + fc.assert( + fc.property( + fc.stringMatching(/^[a-z]{1,20}$/u), + (pattern: string) => { + const inputs: Inputs = createInputs({ + testMatchingPatterns: pattern, + }); + assert.deepEqual(inputs.testMatchingPatterns, [pattern]); + }, + ), + { numRuns }, + ); + }); + + it("should split any newline-separated string into its component patterns", (): void => { + fc.assert( + fc.property( + fc.array(fc.stringMatching(/^[a-z]{1,20}$/u), { + maxLength: 10, + minLength: 2, + }), + (parts: string[]) => { + const inputs: Inputs = createInputs({ + testMatchingPatterns: parts.join("\n"), + }); + assert.deepEqual(inputs.testMatchingPatterns, parts); + }, + ), + { numRuns }, + ); + }); + }); + + describe("codeFileExtensions", (): void => { it("should normalize extensions with wildcard prefix '*.ext' to 'ext'", (): void => { fc.assert( fc.property(fc.stringMatching(/^[a-z]{1,10}$/u), (ext: string) => { - const inputs: Inputs = createInputs(`*.${ext}`); + const inputs: Inputs = createInputs({ + codeFileExtensions: `*.${ext}`, + }); const result: Set = inputs.codeFileExtensions; assert.ok(result.has(ext)); assert.equal(result.size, 1); @@ -60,7 +313,9 @@ describe("inputs.ts", (): void => { it("should normalize extensions with dot prefix '.ext' to 'ext'", (): void => { fc.assert( fc.property(fc.stringMatching(/^[a-z]{1,10}$/u), (ext: string) => { - const inputs: Inputs = createInputs(`.${ext}`); + const inputs: Inputs = createInputs({ + codeFileExtensions: `.${ext}`, + }); const result: Set = inputs.codeFileExtensions; assert.ok(result.has(ext)); assert.equal(result.size, 1); @@ -72,7 +327,7 @@ describe("inputs.ts", (): void => { it("should accept extensions without any prefix", (): void => { fc.assert( fc.property(fc.stringMatching(/^[a-z]{1,10}$/u), (ext: string) => { - const inputs: Inputs = createInputs(ext); + const inputs: Inputs = createInputs({ codeFileExtensions: ext }); const result: Set = inputs.codeFileExtensions; assert.ok(result.has(ext)); assert.equal(result.size, 1); @@ -86,7 +341,8 @@ describe("inputs.ts", (): void => { fc.property(fc.stringMatching(/^[a-z]{1,10}$/u), (ext: string) => { const formats: string[] = [`*.${ext}`, `.${ext}`, ext]; const results: Set[] = formats.map( - (format: string) => createInputs(format).codeFileExtensions, + (format: string) => + createInputs({ codeFileExtensions: format }).codeFileExtensions, ); const [first, second, third] = results; assert.deepEqual(first, second); @@ -99,7 +355,7 @@ describe("inputs.ts", (): void => { it("should convert uppercase extensions to lowercase", (): void => { fc.assert( fc.property(fc.stringMatching(/^[A-Z]{1,10}$/u), (ext: string) => { - const inputs: Inputs = createInputs(ext); + const inputs: Inputs = createInputs({ codeFileExtensions: ext }); const result: Set = inputs.codeFileExtensions; assert.ok(result.has(ext.toLowerCase())); assert.ok(!result.has(ext)); @@ -118,7 +374,9 @@ describe("inputs.ts", (): void => { (extensions: string[]) => { fc.pre(new Set(extensions).size === extensions.length); const input: string = extensions.join("\n"); - const inputs: Inputs = createInputs(input); + const inputs: Inputs = createInputs({ + codeFileExtensions: input, + }); const result: Set = inputs.codeFileExtensions; assert.equal(result.size, extensions.length); for (const ext of extensions) { @@ -134,7 +392,7 @@ describe("inputs.ts", (): void => { fc.assert( fc.property(fc.stringMatching(/^[a-z]{1,10}$/u), (ext: string) => { const input = `*.${ext}\n.${ext}\n${ext}`; - const inputs: Inputs = createInputs(input); + const inputs: Inputs = createInputs({ codeFileExtensions: input }); const result: Set = inputs.codeFileExtensions; assert.equal(result.size, 1); assert.ok(result.has(ext)); @@ -146,7 +404,7 @@ describe("inputs.ts", (): void => { it("should handle mixed case extensions consistently", (): void => { fc.assert( fc.property(fc.stringMatching(/^[a-zA-Z]{1,10}$/u), (ext: string) => { - const inputs: Inputs = createInputs(ext); + const inputs: Inputs = createInputs({ codeFileExtensions: ext }); const result: Set = inputs.codeFileExtensions; assert.ok(result.has(ext.toLowerCase())); assert.equal(result.size, 1); diff --git a/src/task/tests/metrics/inputs.spec.ts b/src/task/tests/metrics/inputs.spec.ts deleted file mode 100644 index f98d9f09b..000000000 --- a/src/task/tests/metrics/inputs.spec.ts +++ /dev/null @@ -1,2175 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. - * Licensed under the MIT License. - */ - -import * as InputsDefault from "../../src/metrics/inputsDefault.js"; -import { deepEqual, instance, mock, verify, when } from "ts-mockito"; -import Inputs from "../../src/metrics/inputs.js"; -import Logger from "../../src/utilities/logger.js"; -import RunnerInvoker from "../../src/runners/runnerInvoker.js"; -import { anyString } from "../testUtilities/mockito.js"; -import assert from "node:assert/strict"; -import { decimalRadix } from "../../src/utilities/constants.js"; - -describe("inputs.ts", (): void => { - const adjustingAlwaysCloseComment = - "Adjusting the always-close-comment mode input to 'false'."; - const adjustingBaseSizeResource = `Adjusting the base size input to '${String(InputsDefault.baseSize)}'.`; - const adjustingGrowthRateResource = `Adjusting the growth rate input to '${String(InputsDefault.growthRate)}'.`; - const adjustingTestFactorResource = `Adjusting the test factor input to '${String(InputsDefault.testFactor)}'.`; - const adjustingFileMatchingPatternsResource = `Adjusting the file matching patterns input to '${JSON.stringify(InputsDefault.fileMatchingPatterns)}'.`; - const adjustingTestMatchingPatternsResource = `Adjusting the test matching patterns input to '${JSON.stringify(InputsDefault.testMatchingPatterns)}'.`; - const adjustingCodeFileExtensionsResource = `Adjusting the code file extensions input to '${JSON.stringify(InputsDefault.codeFileExtensions)}'.`; - const disablingTestFactorResource = "Disabling the test factor validation."; - const settingAlwaysCloseComment = - "Setting the always-close-comment mode input to 'true'."; - const settingBaseSizeResource = "Setting the base size input to 'VALUE'."; - const settingGrowthRateResource = "Setting the growth rate input to 'VALUE'."; - const settingTestFactorResource = "Setting the test factor input to 'VALUE'."; - const settingFileMatchingPatternsResource = - "Setting the file matching patterns input to 'VALUE'."; - const settingTestMatchingPatternsResource = - "Setting the test matching patterns input to 'VALUE'."; - const settingCodeFileExtensionsResource = - "Setting the code file extensions input to 'VALUE'."; - - let logger: Logger; - let runnerInvoker: RunnerInvoker; - - beforeEach((): void => { - logger = mock(Logger); - - runnerInvoker = mock(RunnerInvoker); - when(runnerInvoker.getInput(deepEqual(["Base", "Size"]))).thenReturn(""); - when(runnerInvoker.getInput(deepEqual(["Growth", "Rate"]))).thenReturn(""); - when(runnerInvoker.getInput(deepEqual(["Test", "Factor"]))).thenReturn(""); - when( - runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), - ).thenReturn(""); - when( - runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), - ).thenReturn(""); - when( - runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), - ).thenReturn(""); - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn(""); - when( - runnerInvoker.loc("metrics.inputs.adjustingAlwaysCloseComment"), - ).thenReturn(adjustingAlwaysCloseComment); - when( - runnerInvoker.loc( - "metrics.inputs.adjustingBaseSize", - InputsDefault.baseSize.toLocaleString(), - ), - ).thenReturn(adjustingBaseSizeResource); - when( - runnerInvoker.loc( - "metrics.inputs.adjustingGrowthRate", - InputsDefault.growthRate.toLocaleString(), - ), - ).thenReturn(adjustingGrowthRateResource); - when( - runnerInvoker.loc( - "metrics.inputs.adjustingTestFactor", - InputsDefault.testFactor.toLocaleString(), - ), - ).thenReturn(adjustingTestFactorResource); - when( - runnerInvoker.loc( - "metrics.inputs.adjustingFileMatchingPatterns", - JSON.stringify(InputsDefault.fileMatchingPatterns), - ), - ).thenReturn(adjustingFileMatchingPatternsResource); - when( - runnerInvoker.loc( - "metrics.inputs.adjustingTestMatchingPatterns", - JSON.stringify(InputsDefault.testMatchingPatterns), - ), - ).thenReturn(adjustingTestMatchingPatternsResource); - when( - runnerInvoker.loc( - "metrics.inputs.adjustingCodeFileExtensions", - JSON.stringify(InputsDefault.codeFileExtensions), - ), - ).thenReturn(adjustingCodeFileExtensionsResource); - when(runnerInvoker.loc("metrics.inputs.disablingTestFactor")).thenReturn( - disablingTestFactorResource, - ); - when( - runnerInvoker.loc("metrics.inputs.settingAlwaysCloseComment"), - ).thenReturn(settingAlwaysCloseComment); - when( - runnerInvoker.loc("metrics.inputs.settingBaseSize", anyString()), - ).thenReturn(settingBaseSizeResource); - when( - runnerInvoker.loc("metrics.inputs.settingGrowthRate", anyString()), - ).thenReturn(settingGrowthRateResource); - when( - runnerInvoker.loc("metrics.inputs.settingTestFactor", anyString()), - ).thenReturn(settingTestFactorResource); - when( - runnerInvoker.loc( - "metrics.inputs.settingFileMatchingPatterns", - anyString(), - ), - ).thenReturn(settingFileMatchingPatternsResource); - when( - runnerInvoker.loc( - "metrics.inputs.settingTestMatchingPatterns", - anyString(), - ), - ).thenReturn(settingTestMatchingPatternsResource); - when( - runnerInvoker.loc( - "metrics.inputs.settingCodeFileExtensions", - anyString(), - ), - ).thenReturn(settingCodeFileExtensionsResource); - }); - - describe("initialize()", (): void => { - describe("all inputs", (): void => { - it("should set all default values when nothing is specified", (): void => { - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.baseSize, InputsDefault.baseSize); - assert.equal(inputs.growthRate, InputsDefault.growthRate); - assert.equal(inputs.testFactor, InputsDefault.testFactor); - assert.equal( - inputs.alwaysCloseComment, - InputsDefault.alwaysCloseComment, - ); - assert.deepEqual( - inputs.fileMatchingPatterns, - InputsDefault.fileMatchingPatterns, - ); - assert.deepEqual( - inputs.testMatchingPatterns, - InputsDefault.testMatchingPatterns, - ); - assert.deepEqual( - inputs.codeFileExtensions, - new Set(InputsDefault.codeFileExtensions), - ); - verify(logger.logDebug("* Inputs.initialize()")).times(7); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.baseSize")).once(); - verify(logger.logDebug("* Inputs.growthRate")).once(); - verify(logger.logDebug("* Inputs.testFactor")).once(); - verify(logger.logDebug("* Inputs.alwaysCloseComment")).once(); - verify(logger.logDebug("* Inputs.fileMatchingPatterns")).once(); - verify(logger.logDebug("* Inputs.testMatchingPatterns")).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - - it("should set all input values when all are specified", (): void => { - // Arrange - when(runnerInvoker.getInput(deepEqual(["Base", "Size"]))).thenReturn( - "5.0", - ); - when(runnerInvoker.getInput(deepEqual(["Growth", "Rate"]))).thenReturn( - "4.4", - ); - when(runnerInvoker.getInput(deepEqual(["Test", "Factor"]))).thenReturn( - "2.7", - ); - when( - runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), - ).thenReturn("true"); - when( - runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), - ).thenReturn("aa\nbb"); - when( - runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), - ).thenReturn("cc\ndd"); - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn("js\nts"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.baseSize, 5.0); - assert.equal(inputs.growthRate, 4.4); - assert.equal(inputs.testFactor, 2.7); - assert.deepEqual(inputs.alwaysCloseComment, true); - assert.deepEqual(inputs.fileMatchingPatterns, ["aa", "bb"]); - assert.deepEqual(inputs.testMatchingPatterns, ["cc", "dd"]); - assert.deepEqual( - inputs.codeFileExtensions, - new Set(["js", "ts"]), - ); - verify(logger.logDebug("* Inputs.initialize()")).times(7); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.baseSize")).once(); - verify(logger.logDebug("* Inputs.growthRate")).once(); - verify(logger.logDebug("* Inputs.testFactor")).once(); - verify(logger.logDebug("* Inputs.alwaysCloseComment")).once(); - verify(logger.logDebug("* Inputs.fileMatchingPatterns")).once(); - verify(logger.logDebug("* Inputs.testMatchingPatterns")).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).never(); - verify(logger.logInfo(adjustingBaseSizeResource)).never(); - verify(logger.logInfo(adjustingGrowthRateResource)).never(); - verify(logger.logInfo(adjustingTestFactorResource)).never(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).never(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).never(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).once(); - verify(logger.logInfo(settingBaseSizeResource)).once(); - verify(logger.logInfo(settingGrowthRateResource)).once(); - verify(logger.logInfo(settingTestFactorResource)).once(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).once(); - }); - }); - - describe("baseSize", (): void => { - { - const testCases: (string | null)[] = [ - null, - "", - " ", - "abc", - "===", - "!2", - "null", - "undefined", - ]; - - testCases.forEach((baseSize: string | null): void => { - it(`should set the default when the input '${String(baseSize)}' is invalid`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Base", "Size"])), - ).thenReturn(baseSize); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.baseSize, InputsDefault.baseSize); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.baseSize")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = ["0", "-1", "-1000", "-5"]; - - testCases.forEach((baseSize: string): void => { - it(`should set the default when the input '${baseSize}' is less than or equal to 0`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Base", "Size"])), - ).thenReturn(baseSize); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.baseSize, InputsDefault.baseSize); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.baseSize")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = ["1", "5", "1000", "5.5"]; - - testCases.forEach((baseSize: string): void => { - it(`should set the converted value when the input '${baseSize}' is greater than 0`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Base", "Size"])), - ).thenReturn(baseSize); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.baseSize, parseInt(baseSize, decimalRadix)); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.baseSize")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).never(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).once(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - }); - - describe("growthRate", (): void => { - { - const testCases: (string | null)[] = [ - null, - "", - " ", - "abc", - "===", - "!2", - "null", - "undefined", - "Infinity", - ]; - - testCases.forEach((growthRate: string | null): void => { - it(`should set the default when the input '${String(growthRate)}' is invalid`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Growth", "Rate"])), - ).thenReturn(growthRate); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.growthRate, InputsDefault.growthRate); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.growthRate")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "0", - "0.5", - "1", - "-2", - "-1.2", - "-5", - "0.9999999999", - ]; - - testCases.forEach((growthRate: string): void => { - it(`should set the default when the input '${growthRate}' is less than or equal to 1.0`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Growth", "Rate"])), - ).thenReturn(growthRate); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.growthRate, InputsDefault.growthRate); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.growthRate")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "5", - "2.0", - "1000", - "1.001", - "1.2", - "1.0000000001", - "1.09", - "7", - ]; - - testCases.forEach((growthRate: string): void => { - it(`should set the converted value when the input '${growthRate}' is greater than 1.0`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Growth", "Rate"])), - ).thenReturn(growthRate); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.growthRate, parseFloat(growthRate)); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.growthRate")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).never(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).once(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - }); - - describe("testFactor", (): void => { - { - const testCases: (string | null)[] = [ - null, - "", - " ", - "abc", - "===", - "!2", - "null", - "undefined", - "Infinity", - ]; - - testCases.forEach((testFactor: string | null): void => { - it(`should set the default when the input '${String(testFactor)}' is invalid`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Test", "Factor"])), - ).thenReturn(testFactor); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.testFactor, InputsDefault.testFactor); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testFactor")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "-0.0000009", - "-2", - "-1.2", - "-5", - "-0.9999999999", - ]; - - testCases.forEach((testFactor: string): void => { - it(`should set the default when the input '${testFactor}' is less than 0.0`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Test", "Factor"])), - ).thenReturn(testFactor); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.testFactor, InputsDefault.testFactor); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testFactor")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "5", - "2.0", - "1000", - "1.001", - "1.2", - "0.000000000000009", - "0.09", - "7", - ]; - - testCases.forEach((testFactor: string): void => { - it(`should set the converted value when the input '${testFactor}' is greater than 0.0`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Test", "Factor"])), - ).thenReturn(testFactor); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.testFactor, parseFloat(testFactor)); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testFactor")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).never(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).once(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = ["0", "0.0"]; - - testCases.forEach((testFactor: string): void => { - it(`should set null when the input '${testFactor}' is equal to 0.0`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Test", "Factor"])), - ).thenReturn(testFactor); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.testFactor, null); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testFactor")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).never(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).once(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - }); - - describe("alwaysCloseComment", (): void => { - { - const testCases: (string | null)[] = [ - null, - "", - " ", - "abc", - "false", - "False", - "FALSE", - "fALSE", - "null", - "undefined", - ]; - - testCases.forEach((alwaysCloseComment: string | null): void => { - it(`should set the default when the input is '${String(alwaysCloseComment)}'`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), - ).thenReturn(alwaysCloseComment); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal( - inputs.alwaysCloseComment, - InputsDefault.alwaysCloseComment, - ); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.alwaysCloseComment")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = ["true", "True", "TRUE", "tRUE"]; - - testCases.forEach((alwaysCloseComment: string): void => { - it(`should set to true when the input is '${alwaysCloseComment}'`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), - ).thenReturn(alwaysCloseComment); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.alwaysCloseComment, true); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.alwaysCloseComment")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).never(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).once(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - }); - - describe("fileMatchingPatterns", (): void => { - { - const testCases: (string | null)[] = [null, "", " ", " ", "\n"]; - - testCases.forEach((fileMatchingPatterns: string | null): void => { - it(`should set the default when the input '${String(fileMatchingPatterns?.replace(/\n/gu, "\\n"))}' is invalid`, (): void => { - // Arrange - when( - runnerInvoker.getInput( - deepEqual(["File", "Matching", "Patterns"]), - ), - ).thenReturn(fileMatchingPatterns); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual( - inputs.fileMatchingPatterns, - InputsDefault.fileMatchingPatterns, - ); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.fileMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "abc", - "abc def hik", - "*.ada *.js *ts *.bb *txt", - ]; - - testCases.forEach((fileMatchingPatterns: string): void => { - it(`should not split '${fileMatchingPatterns}'`, (): void => { - // Arrange - when( - runnerInvoker.getInput( - deepEqual(["File", "Matching", "Patterns"]), - ), - ).thenReturn(fileMatchingPatterns); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.fileMatchingPatterns, [ - fileMatchingPatterns, - ]); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.fileMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).never(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "*.ada\n*.js\n*.ts\n*.bb\n*.txt", - "abc\ndef\nhij", - ]; - - testCases.forEach((fileMatchingPatterns: string): void => { - it(`should split '${fileMatchingPatterns.replace(/\n/gu, "\\n")}' at the newline character`, (): void => { - // Arrange - const expectedOutput: string[] = fileMatchingPatterns.split("\n"); - when( - runnerInvoker.getInput( - deepEqual(["File", "Matching", "Patterns"]), - ), - ).thenReturn(fileMatchingPatterns); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.fileMatchingPatterns, expectedOutput); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.fileMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).never(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - it("should replace all '\\' with '/'", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), - ).thenReturn("folder1\\file.js\nfolder2\\*.js"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.fileMatchingPatterns, [ - "folder1/file.js", - "folder2/*.js", - ]); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.fileMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - - it("should remove trailing new lines", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), - ).thenReturn("file.js\n"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.fileMatchingPatterns, ["file.js"]); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.fileMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - - it("should trim whitespace and filter empty lines", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), - ).thenReturn(" pattern1 \n\n pattern2 \n \npattern3"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.fileMatchingPatterns, [ - "pattern1", - "pattern2", - "pattern3", - ]); - }); - - it("should truncate patterns exceeding the maximum count", (): void => { - // Arrange - const patterns: string[] = Array.from( - { length: 250 }, - (_value: string, index: number) => `pattern${String(index)}`, - ); - when( - runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), - ).thenReturn(patterns.join("\n")); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.equal(inputs.fileMatchingPatterns.length, 200); - assert.equal(inputs.fileMatchingPatterns[0], "pattern0"); - assert.equal(inputs.fileMatchingPatterns[199], "pattern199"); - verify( - logger.logWarning( - "The matching pattern count '250' exceeds the maximum '200'. Using only the first '200'.", - ), - ).once(); - }); - }); - - describe("testMatchingPatterns", (): void => { - { - const testCases: (string | null)[] = [null, "", " ", " ", "\n"]; - - testCases.forEach((testMatchingPatterns: string | null): void => { - it(`should set the default when the input '${String(testMatchingPatterns?.replace(/\n/gu, "\\n"))}' is invalid`, (): void => { - // Arrange - when( - runnerInvoker.getInput( - deepEqual(["Test", "Matching", "Patterns"]), - ), - ).thenReturn(testMatchingPatterns); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual( - inputs.testMatchingPatterns, - InputsDefault.testMatchingPatterns, - ); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "abc", - "abc def hik", - "*.ada *.js *ts *.bb *txt", - ]; - - testCases.forEach((testMatchingPatterns: string): void => { - it(`should not split '${testMatchingPatterns}'`, (): void => { - // Arrange - when( - runnerInvoker.getInput( - deepEqual(["Test", "Matching", "Patterns"]), - ), - ).thenReturn(testMatchingPatterns); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.testMatchingPatterns, [ - testMatchingPatterns, - ]); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).never(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "*.ada\n*.js\n*.ts\n*.bb\n*.txt", - "abc\ndef\nhij", - ]; - - testCases.forEach((testMatchingPatterns: string): void => { - it(`should split '${testMatchingPatterns.replace(/\n/gu, "\\n")}' at the newline character`, (): void => { - // Arrange - const expectedOutput: string[] = testMatchingPatterns.split("\n"); - when( - runnerInvoker.getInput( - deepEqual(["Test", "Matching", "Patterns"]), - ), - ).thenReturn(testMatchingPatterns); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.testMatchingPatterns, expectedOutput); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).never(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - it("should replace all '\\' with '/'", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), - ).thenReturn("folder1\\file.js\nfolder2\\*.js"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.testMatchingPatterns, [ - "folder1/file.js", - "folder2/*.js", - ]); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - - it("should remove trailing new lines", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), - ).thenReturn("file.js\n"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.testMatchingPatterns, ["file.js"]); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.testMatchingPatterns")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - - describe("codeFileExtensions", (): void => { - { - const testCases: (string | null)[] = [null, "", " ", " ", "\n"]; - - testCases.forEach((codeFileExtensions: string | null): void => { - it(`should set the default when the input '${String(codeFileExtensions?.replace(/\n/gu, "\\n"))}' is invalid`, (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn(codeFileExtensions); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual( - inputs.codeFileExtensions, - new Set(InputsDefault.codeFileExtensions), - ); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).once(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).never(); - }); - }); - } - - { - const testCases: string[] = [ - "ada\njs\nts\nbb\ntxt", - "abc\ndef\nhij", - "ts", - ]; - - testCases.forEach((codeFileExtensions: string): void => { - it(`should split '${codeFileExtensions.replace(/\n/gu, "\\n")}' at the newline character`, (): void => { - // Arrange - const expectedResult: Set = new Set( - codeFileExtensions.split("\n"), - ); - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn(codeFileExtensions); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.codeFileExtensions, expectedResult); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify( - logger.logInfo(adjustingFileMatchingPatternsResource), - ).once(); - verify( - logger.logInfo(adjustingTestMatchingPatternsResource), - ).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).never(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).once(); - }); - }); - } - - it("should handle repeated insertion of identical items", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn("ada\nada"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual(inputs.codeFileExtensions, new Set(["ada"])); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).never(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).once(); - }); - - it("should convert extensions to lower case", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn("ADA\ncS\nTxT"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual( - inputs.codeFileExtensions, - new Set(["ada", "cs", "txt"]), - ); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).never(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).once(); - }); - - it("should remove . and * from extension names", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn("*.ada\n.txt"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual( - inputs.codeFileExtensions, - new Set(["ada", "txt"]), - ); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).never(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).once(); - }); - - it("should convert extensions to lower case", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn("ADA\ncS\nTxT"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual( - inputs.codeFileExtensions, - new Set(["ada", "cs", "txt"]), - ); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).never(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).once(); - }); - - it("should remove trailing new lines", (): void => { - // Arrange - when( - runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), - ).thenReturn("ada\ncs\ntxt\n"); - - // Act - const inputs: Inputs = new Inputs( - instance(logger), - instance(runnerInvoker), - ); - - // Assert - assert.deepEqual( - inputs.codeFileExtensions, - new Set(["ada", "cs", "txt"]), - ); - verify(logger.logDebug("* Inputs.initialize()")).once(); - verify(logger.logDebug("* Inputs.initializeBaseSize()")).once(); - verify(logger.logDebug("* Inputs.initializeGrowthRate()")).once(); - verify(logger.logDebug("* Inputs.initializeTestFactor()")).once(); - verify( - logger.logDebug("* Inputs.initializeAlwaysCloseComment()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeFileMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeTestMatchingPatterns()"), - ).once(); - verify( - logger.logDebug("* Inputs.initializeMatchingPatterns()"), - ).twice(); - verify( - logger.logDebug("* Inputs.initializeCodeFileExtensions()"), - ).once(); - verify(logger.logDebug("* Inputs.codeFileExtensions")).once(); - verify(logger.logInfo(adjustingAlwaysCloseComment)).once(); - verify(logger.logInfo(adjustingBaseSizeResource)).once(); - verify(logger.logInfo(adjustingGrowthRateResource)).once(); - verify(logger.logInfo(adjustingTestFactorResource)).once(); - verify(logger.logInfo(adjustingFileMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingTestMatchingPatternsResource)).once(); - verify(logger.logInfo(adjustingCodeFileExtensionsResource)).never(); - verify(logger.logInfo(disablingTestFactorResource)).never(); - verify(logger.logInfo(settingAlwaysCloseComment)).never(); - verify(logger.logInfo(settingBaseSizeResource)).never(); - verify(logger.logInfo(settingGrowthRateResource)).never(); - verify(logger.logInfo(settingTestFactorResource)).never(); - verify(logger.logInfo(settingFileMatchingPatternsResource)).never(); - verify(logger.logInfo(settingTestMatchingPatternsResource)).never(); - verify(logger.logInfo(settingCodeFileExtensionsResource)).once(); - }); - }); - }); -}); diff --git a/src/task/tests/metrics/inputs.testFactor.spec.ts b/src/task/tests/metrics/inputs.testFactor.spec.ts new file mode 100644 index 000000000..025a7772f --- /dev/null +++ b/src/task/tests/metrics/inputs.testFactor.spec.ts @@ -0,0 +1,136 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { + adjustingTestFactorResource, + createInputsMocks, + createSut, + disablingTestFactorResource, + settingTestFactorResource, +} from "./inputsTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { invalidNumericStrings } from "../testUtilities/fixtures/invalidInputs.js"; + +describe("inputs.ts", (): void => { + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ logger, runnerInvoker } = createInputsMocks()); + }); + + describe("initialize()", (): void => { + describe("testFactor", (): void => { + { + const testCases: (string | null)[] = [ + ...invalidNumericStrings, + "Infinity", + ]; + + testCases.forEach((testFactor: string | null): void => { + it(`should set the default when the input '${String(testFactor)}' is invalid`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Test", "Factor"])), + ).thenReturn(testFactor); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.testFactor, InputsDefault.testFactor); + verify(logger.logInfo(adjustingTestFactorResource)).once(); + }); + }); + } + + { + const testCases: string[] = [ + "-0.0000009", + "-2", + "-1.2", + "-5", + "-0.9999999999", + ]; + + testCases.forEach((testFactor: string): void => { + it(`should set the default when the input '${testFactor}' is less than 0.0`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Test", "Factor"])), + ).thenReturn(testFactor); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.testFactor, InputsDefault.testFactor); + verify(logger.logInfo(adjustingTestFactorResource)).once(); + }); + }); + } + + { + const testCases: string[] = [ + "5", + "2.0", + "1000", + "1.001", + "1.2", + "0.000000000000009", + "0.09", + "7", + ]; + + testCases.forEach((testFactor: string): void => { + it(`should set the converted value when the input '${testFactor}' is greater than 0.0`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Test", "Factor"])), + ).thenReturn(testFactor); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.testFactor, parseFloat(testFactor)); + verify( + logger.logInfo( + settingTestFactorResource( + parseFloat(testFactor).toLocaleString(), + ), + ), + ).once(); + }); + }); + } + + { + const testCases: string[] = ["0", "0.0"]; + + testCases.forEach((testFactor: string): void => { + it(`should set null when the input '${testFactor}' is equal to 0.0`, (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Test", "Factor"])), + ).thenReturn(testFactor); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.equal(inputs.testFactor, null); + verify(logger.logInfo(disablingTestFactorResource)).once(); + }); + }); + } + }); + }); +}); diff --git a/src/task/tests/metrics/inputs.testMatchingPatterns.spec.ts b/src/task/tests/metrics/inputs.testMatchingPatterns.spec.ts new file mode 100644 index 000000000..610088968 --- /dev/null +++ b/src/task/tests/metrics/inputs.testMatchingPatterns.spec.ts @@ -0,0 +1,169 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { + adjustingTestMatchingPatternsResource, + createInputsMocks, + createSut, + settingTestMatchingPatternsResource, +} from "./inputsTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type Inputs from "../../src/metrics/inputs.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { invalidPatternStrings } from "../testUtilities/fixtures/invalidInputs.js"; + +describe("inputs.ts", (): void => { + let logger: Logger; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ logger, runnerInvoker } = createInputsMocks()); + }); + + describe("initialize()", (): void => { + describe("testMatchingPatterns", (): void => { + invalidPatternStrings.forEach( + (testMatchingPatterns: string | null): void => { + it(`should set the default when the input '${String(testMatchingPatterns?.replace(/\n/gu, "\\n"))}' is invalid`, (): void => { + // Arrange + when( + runnerInvoker.getInput( + deepEqual(["Test", "Matching", "Patterns"]), + ), + ).thenReturn(testMatchingPatterns); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual( + inputs.testMatchingPatterns, + InputsDefault.testMatchingPatterns, + ); + verify( + logger.logInfo(adjustingTestMatchingPatternsResource), + ).once(); + }); + }, + ); + + { + const testCases: string[] = [ + "abc", + "abc def hik", + "*.ada *.js *ts *.bb *txt", + ]; + + testCases.forEach((testMatchingPatterns: string): void => { + it(`should not split '${testMatchingPatterns}'`, (): void => { + // Arrange + when( + runnerInvoker.getInput( + deepEqual(["Test", "Matching", "Patterns"]), + ), + ).thenReturn(testMatchingPatterns); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.testMatchingPatterns, [ + testMatchingPatterns, + ]); + verify( + logger.logInfo(adjustingTestMatchingPatternsResource), + ).never(); + verify( + logger.logInfo( + settingTestMatchingPatternsResource( + JSON.stringify([testMatchingPatterns]), + ), + ), + ).once(); + }); + }); + } + + { + const testCases: string[] = [ + "*.ada\n*.js\n*.ts\n*.bb\n*.txt", + "abc\ndef\nhij", + ]; + + testCases.forEach((testMatchingPatterns: string): void => { + it(`should split '${testMatchingPatterns.replace(/\n/gu, "\\n")}' at the newline character`, (): void => { + // Arrange + const expectedOutput: string[] = testMatchingPatterns.split("\n"); + when( + runnerInvoker.getInput( + deepEqual(["Test", "Matching", "Patterns"]), + ), + ).thenReturn(testMatchingPatterns); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.testMatchingPatterns, expectedOutput); + verify( + logger.logInfo(adjustingTestMatchingPatternsResource), + ).never(); + verify( + logger.logInfo( + settingTestMatchingPatternsResource( + JSON.stringify(expectedOutput), + ), + ), + ).once(); + }); + }); + } + + it("should replace all '\\' with '/'", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), + ).thenReturn("folder1\\file.js\nfolder2\\*.js"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.testMatchingPatterns, [ + "folder1/file.js", + "folder2/*.js", + ]); + verify( + logger.logInfo( + settingTestMatchingPatternsResource( + JSON.stringify(["folder1/file.js", "folder2/*.js"]), + ), + ), + ).once(); + }); + + it("should remove trailing new lines", (): void => { + // Arrange + when( + runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), + ).thenReturn("file.js\n"); + + // Act + const inputs: Inputs = createSut(logger, runnerInvoker); + + // Assert + assert.deepEqual(inputs.testMatchingPatterns, ["file.js"]); + verify( + logger.logInfo( + settingTestMatchingPatternsResource(JSON.stringify(["file.js"])), + ), + ).once(); + }); + }); + }); +}); diff --git a/src/task/tests/metrics/inputsTestSetup.ts b/src/task/tests/metrics/inputsTestSetup.ts new file mode 100644 index 000000000..884d241b3 --- /dev/null +++ b/src/task/tests/metrics/inputsTestSetup.ts @@ -0,0 +1,108 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as InputsDefault from "../../src/metrics/inputsDefault.js"; +import { deepEqual, instance, mock, when } from "ts-mockito"; +import { + localize, + stubLocalization, +} from "../testUtilities/stubLocalization.js"; +import Inputs from "../../src/metrics/inputs.js"; +import Logger from "../../src/utilities/logger.js"; +import RunnerInvoker from "../../src/runners/runnerInvoker.js"; + +export const adjustingAlwaysCloseComment = localize( + "metrics.inputs.adjustingAlwaysCloseComment", +); +export const adjustingBaseSizeResource = localize( + "metrics.inputs.adjustingBaseSize", + InputsDefault.baseSize.toLocaleString(), +); +export const adjustingGrowthRateResource = localize( + "metrics.inputs.adjustingGrowthRate", + InputsDefault.growthRate.toLocaleString(), +); +export const adjustingTestFactorResource = localize( + "metrics.inputs.adjustingTestFactor", + InputsDefault.testFactor.toLocaleString(), +); +export const adjustingFileMatchingPatternsResource = localize( + "metrics.inputs.adjustingFileMatchingPatterns", + JSON.stringify(InputsDefault.fileMatchingPatterns), +); +export const adjustingTestMatchingPatternsResource = localize( + "metrics.inputs.adjustingTestMatchingPatterns", + JSON.stringify(InputsDefault.testMatchingPatterns), +); +export const adjustingCodeFileExtensionsResource = localize( + "metrics.inputs.adjustingCodeFileExtensions", + JSON.stringify(InputsDefault.codeFileExtensions), +); +export const disablingTestFactorResource = localize( + "metrics.inputs.disablingTestFactor", +); +export const settingAlwaysCloseComment = localize( + "metrics.inputs.settingAlwaysCloseComment", +); + +export const settingBaseSizeResource = (value: string): string => + localize("metrics.inputs.settingBaseSize", value); +export const settingGrowthRateResource = (value: string): string => + localize("metrics.inputs.settingGrowthRate", value); +export const settingTestFactorResource = (value: string): string => + localize("metrics.inputs.settingTestFactor", value); +export const settingFileMatchingPatternsResource = (value: string): string => + localize("metrics.inputs.settingFileMatchingPatterns", value); +export const settingTestMatchingPatternsResource = (value: string): string => + localize("metrics.inputs.settingTestMatchingPatterns", value); +export const settingCodeFileExtensionsResource = (value: string): string => + localize("metrics.inputs.settingCodeFileExtensions", value); + +export interface InputsMocks { + logger: Logger; + runnerInvoker: RunnerInvoker; +} + +/** + * Creates mocked `Logger` and `RunnerInvoker` instances pre-configured with the + * default stubs required for `Inputs.initialize()` to run without throwing. + * Tests can override individual stubs as needed. + * @returns The paired mocks. + */ +export const createInputsMocks = (): InputsMocks => { + const logger: Logger = mock(Logger); + const runnerInvoker: RunnerInvoker = mock(RunnerInvoker); + + when(runnerInvoker.getInput(deepEqual(["Base", "Size"]))).thenReturn(""); + when(runnerInvoker.getInput(deepEqual(["Growth", "Rate"]))).thenReturn(""); + when(runnerInvoker.getInput(deepEqual(["Test", "Factor"]))).thenReturn(""); + when( + runnerInvoker.getInput(deepEqual(["Always", "Close", "Comment"])), + ).thenReturn(""); + when( + runnerInvoker.getInput(deepEqual(["File", "Matching", "Patterns"])), + ).thenReturn(""); + when( + runnerInvoker.getInput(deepEqual(["Test", "Matching", "Patterns"])), + ).thenReturn(""); + when( + runnerInvoker.getInput(deepEqual(["Code", "File", "Extensions"])), + ).thenReturn(""); + stubLocalization(runnerInvoker); + + return { logger, runnerInvoker }; +}; + +/** + * Constructs an `Inputs` instance from the supplied mocks. Tests use this in + * place of inline `new Inputs(instance(...), ...)` calls. + * @param logger The mocked logger. + * @param runnerInvoker The mocked runner invoker. + * @returns The constructed `Inputs` instance. + */ +export const createSut = ( + logger: Logger, + runnerInvoker: RunnerInvoker, +): Inputs => new Inputs(instance(logger), instance(runnerInvoker)); diff --git a/src/task/tests/pullRequestMetrics.spec.ts b/src/task/tests/pullRequestMetrics.spec.ts index 617e3f3ca..ec8712ba3 100644 --- a/src/task/tests/pullRequestMetrics.spec.ts +++ b/src/task/tests/pullRequestMetrics.spec.ts @@ -4,6 +4,10 @@ */ import { instance, mock, verify, when } from "ts-mockito"; +import { + localize, + stubLocalization, +} from "./testUtilities/stubLocalization.js"; import CodeMetricsCalculator from "../src/metrics/codeMetricsCalculator.js"; import Logger from "../src/utilities/logger.js"; import PullRequestMetrics from "../src/pullRequestMetrics.js"; @@ -19,9 +23,7 @@ describe("pullRequestMetrics.ts", (): void => { logger = mock(Logger); runnerInvoker = mock(RunnerInvoker); - when(runnerInvoker.loc("pullRequestMetrics.succeeded")).thenReturn( - "PR Metrics succeeded", - ); + stubLocalization(runnerInvoker); }); describe("run()", (): void => { @@ -74,7 +76,11 @@ describe("pullRequestMetrics.ts", (): void => { verify(runnerInvoker.locInitialize("Folder")).once(); verify(codeMetricsCalculator.updateDetails()).once(); verify(codeMetricsCalculator.updateComments()).once(); - verify(runnerInvoker.setStatusSucceeded("PR Metrics succeeded")).once(); + verify( + runnerInvoker.setStatusSucceeded( + localize("pullRequestMetrics.succeeded"), + ), + ).once(); }); it("should catch and log errors", async (): Promise => { diff --git a/src/task/tests/pullRequests/pullRequest.spec.ts b/src/task/tests/pullRequests/pullRequest.spec.ts index 4f5bf691a..75391e07e 100644 --- a/src/task/tests/pullRequests/pullRequest.spec.ts +++ b/src/task/tests/pullRequests/pullRequest.spec.ts @@ -3,12 +3,14 @@ * Licensed under the MIT License. */ -import { instance, mock, verify, when } from "ts-mockito"; +import { instance, mock, when } from "ts-mockito"; import CodeMetrics from "../../src/metrics/codeMetrics.js"; import Logger from "../../src/utilities/logger.js"; import PullRequest from "../../src/pullRequests/pullRequest.js"; import RunnerInvoker from "../../src/runners/runnerInvoker.js"; import assert from "node:assert/strict"; +import { stubEnv } from "../testUtilities/stubEnv.js"; +import { stubLocalization } from "../testUtilities/stubLocalization.js"; describe("pullRequest.ts", (): void => { let codeMetrics: CodeMetrics; @@ -22,99 +24,14 @@ describe("pullRequest.ts", (): void => { logger = mock(Logger); runnerInvoker = mock(RunnerInvoker); - when( - runnerInvoker.loc( - "metrics.codeMetrics.titleSizeIndicatorFormat", - "(XS|S|M|L|\\d*XL)", - "(✔|⚠️)?", - ), - ).thenReturn("(XS|S|M|L|\\d*XL)(✔|⚠️)?"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeL")).thenReturn("L"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeM")).thenReturn("M"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeS")).thenReturn("S"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeXL")).thenReturn("XL"); - when(runnerInvoker.loc("metrics.codeMetrics.titleSizeXS")).thenReturn("XS"); - when( - runnerInvoker.loc("metrics.codeMetrics.titleTestsInsufficient"), - ).thenReturn("⚠️"); - when( - runnerInvoker.loc("metrics.codeMetrics.titleTestsSufficient"), - ).thenReturn("✔"); - when( - runnerInvoker.loc("pullRequests.pullRequest.addDescription"), - ).thenReturn("❌ **Add a description.**"); - when( - runnerInvoker.loc("pullRequests.pullRequest.titleFormat", "S✔", ""), - ).thenReturn("S✔ ◾ "); - when( - runnerInvoker.loc("pullRequests.pullRequest.titleFormat", "PREFIX", ""), - ).thenReturn("PREFIX ◾ "); - when( - runnerInvoker.loc("pullRequests.pullRequest.titleFormat", "S✔", "Title"), - ).thenReturn("S✔ ◾ Title"); - when( - runnerInvoker.loc( - "pullRequests.pullRequest.titleFormat", - "PREFIX", - "Title", - ), - ).thenReturn("PREFIX ◾ Title"); - when( - runnerInvoker.loc( - "pullRequests.pullRequest.titleFormat", - "S✔", - "PREFIX ◾ Title", - ), - ).thenReturn("S✔ ◾ PREFIX ◾ Title"); - when( - runnerInvoker.loc( - "pullRequests.pullRequest.titleFormat", - "S✔", - "PREFIX✔ ◾ Title", - ), - ).thenReturn("S✔ ◾ PREFIX✔ ◾ Title"); - when( - runnerInvoker.loc( - "pullRequests.pullRequest.titleFormat", - "S✔", - "PREFIX⚠️ ◾ Title", - ), - ).thenReturn("S✔ ◾ PREFIX⚠️ ◾ Title"); - when( - runnerInvoker.loc( - "pullRequests.pullRequest.titleFormat", - "S✔", - "PS ◾ Title", - ), - ).thenReturn("S✔ ◾ PS ◾ Title"); - when( - runnerInvoker.loc( - "pullRequests.pullRequest.titleFormat", - "S✔", - "PS✔ ◾ Title", - ), - ).thenReturn("S✔ ◾ PS✔ ◾ Title"); - when( - runnerInvoker.loc( - "pullRequests.pullRequest.titleFormat", - "S✔", - "PS⚠️ ◾ Title", - ), - ).thenReturn("S✔ ◾ PS⚠️ ◾ Title"); - when( - runnerInvoker.loc( - "pullRequests.pullRequest.titleFormat", - "(XS|S|M|L|\\d*XL)(✔|⚠️)?", - "(?.*)", - ), - ).thenReturn("(XS|S|M|L|\\d*XL)(✔|⚠️)? ◾ (?.*)"); + stubLocalization(runnerInvoker); }); describe("isPullRequest", (): void => { it("should return true when the GitHub runner is being used and GITHUB_BASE_REF is defined", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_BASE_REF = "develop"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_BASE_REF", "develop"]); const pullRequest: PullRequest = new PullRequest( instance(codeMetrics), instance(logger), @@ -126,17 +43,12 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* PullRequest.isPullRequest")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_BASE_REF; }); it("should return false when the GitHub runner is being used and GITHUB_BASE_REF is the empty string", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_BASE_REF = ""; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_BASE_REF", ""]); const pullRequest: PullRequest = new PullRequest( instance(codeMetrics), instance(logger), @@ -148,16 +60,11 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, false); - verify(logger.logDebug("* PullRequest.isPullRequest")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_BASE_REF; }); it("should return true when the Azure Pipelines runner is being used and SYSTEM_PULLREQUEST_PULLREQUESTID is defined", (): void => { // Arrange - process.env.SYSTEM_PULLREQUEST_PULLREQUESTID = "refs/heads/develop"; + stubEnv(["SYSTEM_PULLREQUEST_PULLREQUESTID", "refs/heads/develop"]); const pullRequest: PullRequest = new PullRequest( instance(codeMetrics), instance(logger), @@ -169,15 +76,11 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* PullRequest.isPullRequest")).once(); - - // Finalization - delete process.env.SYSTEM_PULLREQUEST_PULLREQUESTID; }); it("should return false when the Azure Pipelines runner is being used and SYSTEM_PULLREQUEST_PULLREQUESTID is not defined", (): void => { // Arrange - delete process.env.SYSTEM_PULLREQUEST_TARGETBRANCH; + stubEnv(["SYSTEM_PULLREQUEST_PULLREQUESTID", undefined]); const pullRequest: PullRequest = new PullRequest( instance(codeMetrics), instance(logger), @@ -189,14 +92,13 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, false); - verify(logger.logDebug("* PullRequest.isPullRequest")).once(); }); }); describe("isSupportedProvider", (): void => { it("should return true when the GitHub runner is being used", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const pullRequest: PullRequest = new PullRequest( instance(codeMetrics), instance(logger), @@ -208,15 +110,11 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* PullRequest.isSupportedProvider")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); it("should throw an error when the Azure Pipelines runner is being used and BUILD_REPOSITORY_PROVIDER is undefined", (): void => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const pullRequest: PullRequest = new PullRequest( instance(codeMetrics), instance(logger), @@ -234,7 +132,6 @@ describe("pullRequest.ts", (): void => { "'BUILD_REPOSITORY_PROVIDER', accessed within 'PullRequest.isSupportedProvider', is invalid, null, or undefined 'undefined'.", ), ); - verify(logger.logDebug("* PullRequest.isSupportedProvider")).once(); }); { @@ -243,7 +140,7 @@ describe("pullRequest.ts", (): void => { testCases.forEach((provider: string): void => { it(`should return true when the Azure Pipelines runner is being used and BUILD_REPOSITORY_PROVIDER is set to '${provider}'`, (): void => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = provider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", provider]); const pullRequest: PullRequest = new PullRequest( instance(codeMetrics), instance(logger), @@ -255,17 +152,13 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, true); - verify(logger.logDebug("* PullRequest.isSupportedProvider")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should return the provider when the Azure Pipelines runner is being used and BUILD_REPOSITORY_PROVIDER is not set to TfsGit or GitHub", (): void => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "Other"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "Other"]); const pullRequest: PullRequest = new PullRequest( instance(codeMetrics), instance(logger), @@ -277,10 +170,6 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, "Other"); - verify(logger.logDebug("* PullRequest.isSupportedProvider")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); @@ -299,7 +188,6 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, null); - verify(logger.logDebug("* PullRequest.getUpdatedDescription()")).once(); }); { @@ -320,9 +208,6 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, "❌ **Add a description.**"); - verify( - logger.logDebug("* PullRequest.getUpdatedDescription()"), - ).once(); }); }); } @@ -343,7 +228,6 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, null); - verify(logger.logDebug("* PullRequest.getUpdatedTitle()")).once(); }); { @@ -372,7 +256,6 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, `S✔ ◾ ${currentTitle}`); - verify(logger.logDebug("* PullRequest.getUpdatedTitle()")).once(); }); }); } @@ -418,7 +301,6 @@ describe("pullRequest.ts", (): void => { // Assert assert.equal(result, "PREFIX ◾ Title"); - verify(logger.logDebug("* PullRequest.getUpdatedTitle()")).once(); }); }); } diff --git a/src/task/tests/pullRequests/pullRequestComments.spec.ts b/src/task/tests/pullRequests/pullRequestComments.spec.ts index 488477b61..c9fbdab35 100644 --- a/src/task/tests/pullRequests/pullRequestComments.spec.ts +++ b/src/task/tests/pullRequests/pullRequestComments.spec.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. */ -import { instance, mock, verify, when } from "ts-mockito"; +import { instance, mock, when } from "ts-mockito"; import CodeMetrics from "../../src/metrics/codeMetrics.js"; import CodeMetricsData from "../../src/metrics/codeMetricsData.js"; import CommentData from "../../src/repos/interfaces/commentData.js"; @@ -18,6 +18,7 @@ import type PullRequestCommentsData from "../../src/pullRequests/pullRequestComm import ReposInvoker from "../../src/repos/reposInvoker.js"; import RunnerInvoker from "../../src/runners/runnerInvoker.js"; import assert from "node:assert/strict"; +import { stubLocalization } from "../testUtilities/stubLocalization.js"; describe("pullRequestComments.ts", (): void => { let complexGitPullRequestComments: CommentData; @@ -66,76 +67,7 @@ describe("pullRequestComments.ts", (): void => { logger = mock(Logger); runnerInvoker = mock(RunnerInvoker); - when( - runnerInvoker.loc("pullRequests.pullRequestComments.commentFooter"), - ).thenReturn( - "[Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs!](https://aka.ms/PRMetrics/Comment)", - ); - when( - runnerInvoker.loc("pullRequests.pullRequestComments.commentTitle"), - ).thenReturn("# PR Metrics"); - when( - runnerInvoker.loc( - "pullRequests.pullRequestComments.largePullRequestComment", - (400).toLocaleString(), - ), - ).thenReturn( - `❌ **Try to keep pull requests smaller than ${(400).toLocaleString()} lines of new product code by following the [Single Responsibility Principle (SRP)](https://aka.ms/PRMetrics/SRP).**`, - ); - when( - runnerInvoker.loc( - "pullRequests.pullRequestComments.largePullRequestComment", - (2000).toLocaleString(), - ), - ).thenReturn( - `❌ **Try to keep pull requests smaller than ${(2000).toLocaleString()} lines of new product code by following the [Single Responsibility Principle (SRP)](https://aka.ms/PRMetrics/SRP).**`, - ); - when( - runnerInvoker.loc( - "pullRequests.pullRequestComments.largePullRequestComment", - (2000000).toLocaleString(), - ), - ).thenReturn( - `❌ **Try to keep pull requests smaller than ${(2000000).toLocaleString()} lines of new product code by following the [Single Responsibility Principle (SRP)](https://aka.ms/PRMetrics/SRP).**`, - ); - when( - runnerInvoker.loc( - "pullRequests.pullRequestComments.noReviewRequiredComment", - ), - ).thenReturn("❗ **This file doesn't require review.**"); - when( - runnerInvoker.loc( - "pullRequests.pullRequestComments.smallPullRequestComment", - ), - ).thenReturn("✔ **Thanks for keeping your pull request small.**"); - when( - runnerInvoker.loc("pullRequests.pullRequestComments.tableIgnoredCode"), - ).thenReturn("Ignored Code"); - when( - runnerInvoker.loc("pullRequests.pullRequestComments.tableLines"), - ).thenReturn("Lines"); - when( - runnerInvoker.loc("pullRequests.pullRequestComments.tableProductCode"), - ).thenReturn("Product Code"); - when( - runnerInvoker.loc("pullRequests.pullRequestComments.tableSubtotal"), - ).thenReturn("Subtotal"); - when( - runnerInvoker.loc("pullRequests.pullRequestComments.tableTestCode"), - ).thenReturn("Test Code"); - when( - runnerInvoker.loc("pullRequests.pullRequestComments.tableTotal"), - ).thenReturn("Total"); - when( - runnerInvoker.loc( - "pullRequests.pullRequestComments.testsInsufficientComment", - ), - ).thenReturn("⚠️ **Consider adding additional tests.**"); - when( - runnerInvoker.loc( - "pullRequests.pullRequestComments.testsSufficientComment", - ), - ).thenReturn("✔ **Thanks for adding tests.**"); + stubLocalization(runnerInvoker); }); describe("noReviewRequiredComment", (): void => { @@ -154,9 +86,6 @@ describe("pullRequestComments.ts", (): void => { // Assert assert.equal(result, "❗ **This file doesn't require review.**"); - verify( - logger.logDebug("* PullRequestComments.noReviewRequiredComment"), - ).once(); }); }); @@ -184,7 +113,6 @@ describe("pullRequestComments.ts", (): void => { assert.deepEqual(result.filesNotRequiringReview, []); assert.deepEqual(result.deletedFilesNotRequiringReview, []); assert.deepEqual(result.commentThreadsRequiringDeletion, []); - verify(logger.logDebug("* PullRequestComments.getCommentData()")).once(); }); { @@ -225,12 +153,6 @@ describe("pullRequestComments.ts", (): void => { assert.deepEqual(result.filesNotRequiringReview, []); assert.deepEqual(result.deletedFilesNotRequiringReview, []); assert.deepEqual(result.commentThreadsRequiringDeletion, []); - verify( - logger.logDebug("* PullRequestComments.getCommentData()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.getMetricsCommentData()"), - ).atLeast(1); }); }); } @@ -315,14 +237,6 @@ describe("pullRequestComments.ts", (): void => { ); assert.deepEqual(result.deletedFilesNotRequiringReview, []); assert.deepEqual(result.commentThreadsRequiringDeletion, []); - verify( - logger.logDebug("* PullRequestComments.getCommentData()"), - ).once(); - verify( - logger.logDebug( - "* PullRequestComments.getFilesRequiringCommentUpdates()", - ), - ).atLeast(1); }); }, ); @@ -411,14 +325,6 @@ describe("pullRequestComments.ts", (): void => { deletedFilesNotRequiringReview, ); assert.deepEqual(result.commentThreadsRequiringDeletion, []); - verify( - logger.logDebug("* PullRequestComments.getCommentData()"), - ).once(); - verify( - logger.logDebug( - "* PullRequestComments.getFilesRequiringCommentUpdates()", - ), - ).atLeast(1); }); }, ); @@ -456,15 +362,6 @@ describe("pullRequestComments.ts", (): void => { assert.deepEqual(result.filesNotRequiringReview, ["folder/file1.ts"]); assert.deepEqual(result.deletedFilesNotRequiringReview, []); assert.deepEqual(result.commentThreadsRequiringDeletion, []); - verify(logger.logDebug("* PullRequestComments.getCommentData()")).once(); - verify( - logger.logDebug("* PullRequestComments.getMetricsCommentData()"), - ).once(); - verify( - logger.logDebug( - "* PullRequestComments.getFilesRequiringCommentUpdates()", - ), - ).twice(); }); it("should return the expected result when all comment types are present in deleted files not requiring review", async (): Promise => { @@ -501,15 +398,6 @@ describe("pullRequestComments.ts", (): void => { "folder/file1.ts", ]); assert.deepEqual(result.commentThreadsRequiringDeletion, []); - verify(logger.logDebug("* PullRequestComments.getCommentData()")).once(); - verify( - logger.logDebug("* PullRequestComments.getMetricsCommentData()"), - ).once(); - verify( - logger.logDebug( - "* PullRequestComments.getFilesRequiringCommentUpdates()", - ), - ).twice(); }); it("should return the expected result when all comment types are present in both modified and deleted files not requiring review", async (): Promise => { @@ -547,15 +435,6 @@ describe("pullRequestComments.ts", (): void => { assert.deepEqual(result.filesNotRequiringReview, ["folder/file1.ts"]); assert.deepEqual(result.deletedFilesNotRequiringReview, ["file3.ts"]); assert.deepEqual(result.commentThreadsRequiringDeletion, []); - verify(logger.logDebug("* PullRequestComments.getCommentData()")).once(); - verify( - logger.logDebug("* PullRequestComments.getMetricsCommentData()"), - ).once(); - verify( - logger.logDebug( - "* PullRequestComments.getFilesRequiringCommentUpdates()", - ), - ).twice(); }); it("should return the expected result when all comment types are present in both modified and deleted files not requiring review and comments need to be deleted", async (): Promise => { @@ -592,15 +471,6 @@ describe("pullRequestComments.ts", (): void => { assert.deepEqual(result.filesNotRequiringReview, ["folder/file1.ts"]); assert.deepEqual(result.deletedFilesNotRequiringReview, ["file3.ts"]); assert.deepEqual(result.commentThreadsRequiringDeletion, [40]); - verify(logger.logDebug("* PullRequestComments.getCommentData()")).once(); - verify( - logger.logDebug("* PullRequestComments.getMetricsCommentData()"), - ).once(); - verify( - logger.logDebug( - "* PullRequestComments.getFilesRequiringCommentUpdates()", - ), - ).twice(); }); it("should continue when no comment content is present", async (): Promise => { @@ -635,10 +505,6 @@ describe("pullRequestComments.ts", (): void => { assert.equal(result.metricsCommentContent, null); assert.deepEqual(result.filesNotRequiringReview, ["file.ts"]); assert.deepEqual(result.commentThreadsRequiringDeletion, []); - verify(logger.logDebug("* PullRequestComments.getCommentData()")).once(); - verify( - logger.logDebug("* PullRequestComments.getMetricsCommentData()"), - ).once(); }); }); @@ -686,18 +552,6 @@ describe("pullRequestComments.ts", (): void => { "\n" + "[Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs!](https://aka.ms/PRMetrics/Comment)", ); - verify( - logger.logDebug("* PullRequestComments.getMetricsComment()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentSizeStatus()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentTestStatus()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentMetrics()"), - ).times(5); }); }); } @@ -739,18 +593,6 @@ describe("pullRequestComments.ts", (): void => { "\n" + "[Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs!](https://aka.ms/PRMetrics/Comment)", ); - verify( - logger.logDebug("* PullRequestComments.getMetricsComment()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentSizeStatus()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentTestStatus()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentMetrics()"), - ).times(5); }); }); } @@ -785,18 +627,6 @@ describe("pullRequestComments.ts", (): void => { "\n" + "[Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs!](https://aka.ms/PRMetrics/Comment)", ); - verify( - logger.logDebug("* PullRequestComments.getMetricsComment()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentSizeStatus()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentTestStatus()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentMetrics()"), - ).times(5); }); it("should return the expected result when the pull request does not require a specific level of test coverage", async (): Promise => { @@ -828,18 +658,6 @@ describe("pullRequestComments.ts", (): void => { "\n" + "[Metrics computed by PR Metrics. Add it to your Azure DevOps and GitHub PRs!](https://aka.ms/PRMetrics/Comment)", ); - verify( - logger.logDebug("* PullRequestComments.getMetricsComment()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentSizeStatus()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentTestStatus()"), - ).once(); - verify( - logger.logDebug("* PullRequestComments.addCommentMetrics()"), - ).times(5); }); }); @@ -861,9 +679,6 @@ describe("pullRequestComments.ts", (): void => { // Assert assert.equal(result, CommentThreadStatus.Closed); - verify( - logger.logDebug("* PullRequestComments.getMetricsCommentStatus()"), - ).once(); }); { @@ -891,9 +706,6 @@ describe("pullRequestComments.ts", (): void => { // Assert assert.equal(result, CommentThreadStatus.Closed); - verify( - logger.logDebug("* PullRequestComments.getMetricsCommentStatus()"), - ).once(); }); }); } @@ -946,11 +758,6 @@ describe("pullRequestComments.ts", (): void => { // Assert assert.equal(result, CommentThreadStatus.Active); - verify( - logger.logDebug( - "* PullRequestComments.getMetricsCommentStatus()", - ), - ).once(); }); }, ); diff --git a/src/task/tests/repos/azureReposInvoker.createComment.spec.ts b/src/task/tests/repos/azureReposInvoker.createComment.spec.ts new file mode 100644 index 000000000..a56a121f5 --- /dev/null +++ b/src/task/tests/repos/azureReposInvoker.createComment.spec.ts @@ -0,0 +1,319 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import { + CommentThreadStatus, + type GitPullRequestCommentThread, +} from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import { + createAzureReposInvokerMocks, + createSut, +} from "./azureReposInvokerTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; +import type AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; +import ErrorWithStatus from "../wrappers/errorWithStatus.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type { IGitApi } from "azure-devops-node-api/GitApi.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { StatusCodes } from "http-status-codes"; +import type TokenManager from "../../src/repos/tokenManager.js"; +import { any } from "../testUtilities/mockito.js"; +import assert from "node:assert/strict"; + +describe("azureReposInvoker.ts", (): void => { + let gitApi: IGitApi; + let azureDevOpsApiWrapper: AzureDevOpsApiWrapper; + let gitInvoker: GitInvoker; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + let tokenManager: TokenManager; + + beforeEach((): void => { + ({ + gitApi, + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + } = createAzureReposInvokerMocks()); + }); + + describe("createComment()", (): void => { + { + const testCases: StatusCodes[] = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.FORBIDDEN, + StatusCodes.NOT_FOUND, + ]; + + testCases.forEach((statusCode: StatusCodes): void => { + it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { + // Arrange + const error: ErrorWithStatus = new ErrorWithStatus("Test"); + error.statusCode = statusCode; + when(gitApi.createThread(any(), "RepoID", 10, "Project")).thenThrow( + error, + ); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.createComment( + "Comment Content", + "file.ts", + CommentThreadStatus.Active, + ); + + // Assert + const expectedMessage: string = + statusCode === StatusCodes.NOT_FOUND + ? "The resource could not be found. Verify the repository and pull request exist." + : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read & write' and 'Pull Request Threads' > 'Read & write'."; + const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( + func, + expectedMessage, + ); + assert.equal(result.internalMessage, "Test"); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.createThread(any(), "RepoID", 10, "Project")).once(); + }); + }); + } + + it("should call the API for no file", async (): Promise => { + // Arrange + const expectedComment: GitPullRequestCommentThread = { + comments: [{ content: "Comment Content" }], + status: CommentThreadStatus.Active, + }; + when( + gitApi.createThread( + deepEqual(expectedComment), + "RepoID", + 10, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.createComment( + "Comment Content", + null, + CommentThreadStatus.Active, + ); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.createThread( + deepEqual(expectedComment), + "RepoID", + 10, + "Project", + ), + ).once(); + }); + + it("should call the API for no file when called multiple times", async (): Promise => { + // Arrange + const expectedComment: GitPullRequestCommentThread = { + comments: [{ content: "Comment Content" }], + status: CommentThreadStatus.Active, + }; + when( + gitApi.createThread( + deepEqual(expectedComment), + "RepoID", + 10, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.createComment( + "Comment Content", + null, + CommentThreadStatus.Active, + ); + await azureReposInvoker.createComment( + "Comment Content", + null, + CommentThreadStatus.Active, + ); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.createThread( + deepEqual(expectedComment), + "RepoID", + 10, + "Project", + ), + ).twice(); + }); + + it("should call the API for a file", async (): Promise => { + // Arrange + const expectedComment: GitPullRequestCommentThread = { + comments: [{ content: "Comment Content" }], + status: CommentThreadStatus.Active, + threadContext: { + filePath: "/file.ts", + rightFileEnd: { + line: 1, + offset: 2, + }, + rightFileStart: { + line: 1, + offset: 1, + }, + }, + }; + when( + gitApi.createThread( + deepEqual(expectedComment), + "RepoID", + 10, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.createComment( + "Comment Content", + "file.ts", + CommentThreadStatus.Active, + ); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.createThread( + deepEqual(expectedComment), + "RepoID", + 10, + "Project", + ), + ).once(); + }); + + it("should call the API for a deleted file", async (): Promise => { + // Arrange + const expectedComment: GitPullRequestCommentThread = { + comments: [{ content: "Comment Content" }], + status: CommentThreadStatus.Active, + threadContext: { + filePath: "/file.ts", + leftFileEnd: { + line: 1, + offset: 2, + }, + leftFileStart: { + line: 1, + offset: 1, + }, + }, + }; + when( + gitApi.createThread( + deepEqual(expectedComment), + "RepoID", + 10, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.createComment( + "Comment Content", + "file.ts", + CommentThreadStatus.Active, + true, + ); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.createThread( + deepEqual(expectedComment), + "RepoID", + 10, + "Project", + ), + ).once(); + }); + }); +}); diff --git a/src/task/tests/repos/azureReposInvoker.deleteCommentThread.spec.ts b/src/task/tests/repos/azureReposInvoker.deleteCommentThread.spec.ts new file mode 100644 index 000000000..5883dfefa --- /dev/null +++ b/src/task/tests/repos/azureReposInvoker.deleteCommentThread.spec.ts @@ -0,0 +1,149 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import { any, anyNumber } from "../testUtilities/mockito.js"; +import { + createAzureReposInvokerMocks, + createSut, +} from "./azureReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; +import type AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; +import ErrorWithStatus from "../wrappers/errorWithStatus.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type { IGitApi } from "azure-devops-node-api/GitApi.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { StatusCodes } from "http-status-codes"; +import type TokenManager from "../../src/repos/tokenManager.js"; +import assert from "node:assert/strict"; + +describe("azureReposInvoker.ts", (): void => { + let gitApi: IGitApi; + let azureDevOpsApiWrapper: AzureDevOpsApiWrapper; + let gitInvoker: GitInvoker; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + let tokenManager: TokenManager; + + beforeEach((): void => { + ({ + gitApi, + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + } = createAzureReposInvokerMocks()); + }); + + describe("deleteCommentThread()", (): void => { + { + const testCases: StatusCodes[] = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.FORBIDDEN, + StatusCodes.NOT_FOUND, + ]; + + testCases.forEach((statusCode: StatusCodes): void => { + it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { + // Arrange + const error: ErrorWithStatus = new ErrorWithStatus("Test"); + error.statusCode = statusCode; + when(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).thenThrow( + error, + ); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.deleteCommentThread(20); + + // Assert + const expectedMessage: string = + statusCode === StatusCodes.NOT_FOUND + ? "The resource could not be found. Verify the repository and pull request exist." + : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read & write' and 'Pull Request Threads' > 'Read & write'."; + const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( + func, + expectedMessage, + ); + assert.equal(result.internalMessage, "Test"); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).once(); + }); + }); + } + + it("should call the API for a single comment", async (): Promise => { + // Arrange + when(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).thenResolve(); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.deleteCommentThread(20); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).once(); + }); + + it("should call the API when called multiple times", async (): Promise => { + // Arrange + when( + gitApi.deleteComment("RepoID", 10, anyNumber(), 1, "Project"), + ).thenResolve(); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.deleteCommentThread(20); + await azureReposInvoker.deleteCommentThread(30); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).once(); + verify(gitApi.deleteComment("RepoID", 10, 30, 1, "Project")).once(); + }); + }); +}); diff --git a/src/task/tests/repos/azureReposInvoker.getComments.spec.ts b/src/task/tests/repos/azureReposInvoker.getComments.spec.ts new file mode 100644 index 000000000..da48aa140 --- /dev/null +++ b/src/task/tests/repos/azureReposInvoker.getComments.spec.ts @@ -0,0 +1,454 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import { + CommentThreadStatus, + type GitPullRequestCommentThread, +} from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import { + createAzureReposInvokerMocks, + createSut, +} from "./azureReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; +import type AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; +import type CommentData from "../../src/repos/interfaces/commentData.js"; +import ErrorWithStatus from "../wrappers/errorWithStatus.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type { IGitApi } from "azure-devops-node-api/GitApi.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { StatusCodes } from "http-status-codes"; +import type TokenManager from "../../src/repos/tokenManager.js"; +import { any } from "../testUtilities/mockito.js"; +import assert from "node:assert/strict"; + +describe("azureReposInvoker.ts", (): void => { + let gitApi: IGitApi; + let azureDevOpsApiWrapper: AzureDevOpsApiWrapper; + let gitInvoker: GitInvoker; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + let tokenManager: TokenManager; + + beforeEach((): void => { + ({ + gitApi, + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + } = createAzureReposInvokerMocks()); + }); + + describe("getComments()", (): void => { + { + const testCases: StatusCodes[] = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.FORBIDDEN, + StatusCodes.NOT_FOUND, + ]; + + testCases.forEach((statusCode: StatusCodes): void => { + it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { + // Arrange + const error: ErrorWithStatus = new ErrorWithStatus("Test"); + error.statusCode = statusCode; + when(gitApi.getThreads("RepoID", 10, "Project")).thenThrow(error); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.getComments(); + + // Assert + const expectedMessage: string = + statusCode === StatusCodes.NOT_FOUND + ? "The resource could not be found. Verify the repository and pull request exist." + : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read & write' and 'Pull Request Threads' > 'Read & write'."; + const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( + func, + expectedMessage, + ); + assert.equal(result.internalMessage, "Test"); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).once(); + }); + }); + } + + it("should return the result when called with a pull request comment whose thread context is undefined", async (): Promise => { + // Arrange + when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ + { comments: [{ content: "Content" }], id: 1, status: 1 }, + ]); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: CommentData = await azureReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 1); + assert.equal(result.pullRequestComments[0]?.id, 1); + assert.equal(result.pullRequestComments[0].content, "Content"); + assert.equal( + result.pullRequestComments[0].status, + CommentThreadStatus.Active, + ); + assert.equal(result.fileComments.length, 0); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).once(); + }); + + it("should return the result when called with a pull request comment whose thread context is null", async (): Promise => { + // Arrange + when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ + { + comments: [{ content: "Content" }], + id: 1, + status: 1, + threadContext: null as unknown as undefined, + }, + ]); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: CommentData = await azureReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 1); + assert.equal(result.pullRequestComments[0]?.id, 1); + assert.equal(result.pullRequestComments[0].content, "Content"); + assert.equal( + result.pullRequestComments[0].status, + CommentThreadStatus.Active, + ); + assert.equal(result.fileComments.length, 0); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).once(); + }); + + it("should return the result when called with a file comment", async (): Promise => { + // Arrange + when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ + { + comments: [{ content: "Content" }], + id: 1, + status: 1, + threadContext: { filePath: "/file.ts" }, + }, + ]); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: CommentData = await azureReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 0); + assert.equal(result.fileComments.length, 1); + assert.equal(result.fileComments[0]?.id, 1); + assert.equal(result.fileComments[0].content, "Content"); + assert.equal(result.fileComments[0].status, CommentThreadStatus.Active); + assert.equal(result.fileComments[0].fileName, "file.ts"); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).once(); + }); + + it("should return the result when called with both a pull request and file comment", async (): Promise => { + // Arrange + when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ + { comments: [{ content: "PR Content" }], id: 1, status: 1 }, + { + comments: [{ content: "File Content" }], + id: 2, + status: 1, + threadContext: { filePath: "/file.ts" }, + }, + ]); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: CommentData = await azureReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 1); + assert.equal(result.pullRequestComments[0]?.id, 1); + assert.equal(result.pullRequestComments[0].content, "PR Content"); + assert.equal( + result.pullRequestComments[0].status, + CommentThreadStatus.Active, + ); + assert.equal(result.fileComments.length, 1); + assert.equal(result.fileComments[0]?.id, 2); + assert.equal(result.fileComments[0].content, "File Content"); + assert.equal(result.fileComments[0].status, CommentThreadStatus.Active); + assert.equal(result.fileComments[0].fileName, "file.ts"); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).once(); + }); + + it("should return the result when called multiple times", async (): Promise => { + // Arrange + when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ + { comments: [{ content: "Content" }], id: 1, status: 1 }, + ]); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.getComments(); + const result: CommentData = await azureReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 1); + assert.equal(result.pullRequestComments[0]?.id, 1); + assert.equal(result.pullRequestComments[0].content, "Content"); + assert.equal( + result.pullRequestComments[0].status, + CommentThreadStatus.Active, + ); + assert.equal(result.fileComments.length, 0); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).twice(); + }); + + it("should throw when provided with a payload with no ID", async (): Promise => { + // Arrange + when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ + { comments: [{ content: "Content" }], status: 1 }, + ]); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.getComments(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "'commentThread[0].id', accessed within 'AzureReposInvoker.convertPullRequestComments()', is invalid, null, or undefined 'undefined'.", + ); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).once(); + }); + + it("should continue if the payload has no status", async (): Promise => { + // Arrange + const getThreadsResult: GitPullRequestCommentThread[] = [ + { comments: [{ content: "PR Content" }], id: 1 }, + { + comments: [{ content: "File Content" }], + id: 2, + threadContext: { filePath: "/file.ts" }, + }, + ]; + when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve( + getThreadsResult, + ); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: CommentData = await azureReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 1); + assert.equal(result.pullRequestComments[0]?.id, 1); + assert.equal(result.pullRequestComments[0].content, "PR Content"); + assert.equal( + result.pullRequestComments[0].status, + CommentThreadStatus.Unknown, + ); + assert.equal(result.fileComments.length, 1); + assert.equal(result.fileComments[0]?.id, 2); + assert.equal(result.fileComments[0].content, "File Content"); + assert.equal(result.fileComments[0].status, CommentThreadStatus.Unknown); + assert.equal(result.fileComments[0].fileName, "file.ts"); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).once(); + }); + + { + const testCases: GitPullRequestCommentThread[] = [ + { id: 1, status: 1 }, + { comments: [], id: 1, status: 1 }, + { comments: [{}], id: 1, status: 1 }, + { comments: [{ content: "" }], id: 1, status: 1 }, + { + comments: [{ content: "Content" }], + id: 1, + status: 1, + threadContext: {}, + }, + { + comments: [{ content: "Content" }], + id: 1, + status: 1, + threadContext: { filePath: "" }, + }, + { + comments: [{ content: "Content" }], + id: 1, + status: 1, + threadContext: { filePath: "/" }, + }, + ]; + + testCases.forEach((commentThread: GitPullRequestCommentThread): void => { + it(`should skip the comment with the malformed payload '${JSON.stringify(commentThread)}'`, async (): Promise => { + // Arrange + const getThreadsResult: GitPullRequestCommentThread[] = [ + commentThread, + { comments: [{ content: "PR Content" }], id: 2, status: 1 }, + { + comments: [{ content: "File Content" }], + id: 3, + status: 1, + threadContext: { filePath: "/file.ts" }, + }, + ]; + when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve( + getThreadsResult, + ); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: CommentData = await azureReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 1); + assert.equal(result.pullRequestComments[0]?.id, 2); + assert.equal(result.pullRequestComments[0].content, "PR Content"); + assert.equal( + result.pullRequestComments[0].status, + CommentThreadStatus.Active, + ); + assert.equal(result.fileComments.length, 1); + assert.equal(result.fileComments[0]?.id, 3); + assert.equal(result.fileComments[0].content, "File Content"); + assert.equal( + result.fileComments[0].status, + CommentThreadStatus.Active, + ); + assert.equal(result.fileComments[0].fileName, "file.ts"); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getThreads("RepoID", 10, "Project")).once(); + }); + }); + } + }); +}); diff --git a/src/task/tests/repos/azureReposInvoker.getTitleAndDescription.spec.ts b/src/task/tests/repos/azureReposInvoker.getTitleAndDescription.spec.ts new file mode 100644 index 000000000..d82e0bd1a --- /dev/null +++ b/src/task/tests/repos/azureReposInvoker.getTitleAndDescription.spec.ts @@ -0,0 +1,353 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import { + createAzureReposInvokerMocks, + createSut, +} from "./azureReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; +import type AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; +import ErrorWithStatus from "../wrappers/errorWithStatus.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type { IGitApi } from "azure-devops-node-api/GitApi.js"; +import type Logger from "../../src/utilities/logger.js"; +import type PullRequestDetailsInterface from "../../src/repos/interfaces/pullRequestDetailsInterface.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { StatusCodes } from "http-status-codes"; +import type TokenManager from "../../src/repos/tokenManager.js"; +import { any } from "../testUtilities/mockito.js"; +import assert from "node:assert/strict"; +import { stubEnv } from "../testUtilities/stubEnv.js"; + +describe("azureReposInvoker.ts", (): void => { + let gitApi: IGitApi; + let azureDevOpsApiWrapper: AzureDevOpsApiWrapper; + let gitInvoker: GitInvoker; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + let tokenManager: TokenManager; + + beforeEach((): void => { + ({ + gitApi, + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + } = createAzureReposInvokerMocks()); + }); + + describe("getTitleAndDescription()", (): void => { + { + const testCases: (string | undefined)[] = [undefined, ""]; + + testCases.forEach((variable: string | undefined): void => { + it(`should throw when SYSTEM_TEAMPROJECT is set to the invalid value '${String(variable)}'`, async (): Promise => { + // Arrange + if (typeof variable === "undefined") { + stubEnv(["SYSTEM_TEAMPROJECT", undefined]); + } else { + stubEnv(["SYSTEM_TEAMPROJECT", variable]); + } + + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `'SYSTEM_TEAMPROJECT', accessed within 'AzureReposInvoker.getGitApi()', is invalid, null, or undefined '${String(variable)}'.`, + ); + }); + }); + } + + { + const testCases: (string | undefined)[] = [undefined, ""]; + + testCases.forEach((variable: string | undefined): void => { + it(`should throw when BUILD_REPOSITORY_ID is set to the invalid value '${String(variable)}'`, async (): Promise => { + // Arrange + if (typeof variable === "undefined") { + stubEnv(["BUILD_REPOSITORY_ID", undefined]); + } else { + stubEnv(["BUILD_REPOSITORY_ID", variable]); + } + + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `'BUILD_REPOSITORY_ID', accessed within 'AzureReposInvoker.getGitApi()', is invalid, null, or undefined '${String(variable)}'.`, + ); + }); + }); + } + + { + const testCases: (string | undefined)[] = [undefined, ""]; + + testCases.forEach((variable: string | undefined): void => { + it(`should throw when PR_METRICS_ACCESS_TOKEN is set to the invalid value '${String(variable)}'`, async (): Promise => { + // Arrange + if (typeof variable === "undefined") { + stubEnv(["PR_METRICS_ACCESS_TOKEN", undefined]); + } else { + stubEnv(["PR_METRICS_ACCESS_TOKEN", variable]); + } + + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `'PR_METRICS_ACCESS_TOKEN', accessed within 'AzureReposInvoker.getGitApi()', is invalid, null, or undefined '${String(variable)}'.`, + ); + }); + }); + } + + { + const testCases: (string | undefined)[] = [undefined, ""]; + + testCases.forEach((variable: string | undefined): void => { + it(`should throw when SYSTEM_TEAMFOUNDATIONCOLLECTIONURI is set to the invalid value '${String(variable)}'`, async (): Promise => { + // Arrange + if (typeof variable === "undefined") { + stubEnv(["SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", undefined]); + } else { + stubEnv(["SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", variable]); + } + + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `'SYSTEM_TEAMFOUNDATIONCOLLECTIONURI', accessed within 'AzureReposInvoker.getGitApi()', is invalid, null, or undefined '${String(variable)}'.`, + ); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + }); + }); + } + + { + const testCases: StatusCodes[] = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.FORBIDDEN, + StatusCodes.NOT_FOUND, + ]; + + testCases.forEach((statusCode: StatusCodes): void => { + it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { + // Arrange + const error: ErrorWithStatus = new ErrorWithStatus("Test"); + error.statusCode = statusCode; + when(gitApi.getPullRequestById(10, "Project")).thenThrow(error); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.getTitleAndDescription(); + + // Assert + const expectedMessage: string = + statusCode === StatusCodes.NOT_FOUND + ? "The resource could not be found. Verify the repository and pull request exist." + : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read & write' and 'Pull Request Threads' > 'Read & write'."; + const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( + func, + expectedMessage, + ); + assert.equal(result.internalMessage, "Test"); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getPullRequestById(10, "Project")).once(); + }); + }); + } + + it("should return the title and description when available", async (): Promise => { + // Arrange + when(gitApi.getPullRequestById(10, "Project")).thenResolve({ + description: "Description", + title: "Title", + }); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: PullRequestDetailsInterface = + await azureReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, "Description"); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getPullRequestById(10, "Project")).once(); + }); + + it("should return the title and description when available and called multiple times", async (): Promise => { + // Arrange + when(gitApi.getPullRequestById(10, "Project")).thenResolve({ + description: "Description", + title: "Title", + }); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.getTitleAndDescription(); + const result: PullRequestDetailsInterface = + await azureReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, "Description"); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getPullRequestById(10, "Project")).twice(); + }); + + it("should return the title when the description is unavailable", async (): Promise => { + // Arrange + when(gitApi.getPullRequestById(10, "Project")).thenResolve({ + title: "Title", + }); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: PullRequestDetailsInterface = + await azureReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, null); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getPullRequestById(10, "Project")).once(); + }); + + it("should throw when the title is unavailable", async (): Promise => { + // Arrange + when(gitApi.getPullRequestById(10, "Project")).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "'title', accessed within 'AzureReposInvoker.getTitleAndDescription()', is invalid, null, or undefined 'undefined'.", + ); + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify(gitApi.getPullRequestById(10, "Project")).once(); + }); + }); +}); diff --git a/src/task/tests/repos/azureReposInvoker.isAccessTokenAvailable.spec.ts b/src/task/tests/repos/azureReposInvoker.isAccessTokenAvailable.spec.ts new file mode 100644 index 000000000..1c24fc173 --- /dev/null +++ b/src/task/tests/repos/azureReposInvoker.isAccessTokenAvailable.spec.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { + createAzureReposInvokerMocks, + createSut, +} from "./azureReposInvokerTestSetup.js"; +import type AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; +import type AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import type TokenManager from "../../src/repos/tokenManager.js"; +import assert from "node:assert/strict"; +import { localize } from "../testUtilities/stubLocalization.js"; +import { stubEnv } from "../testUtilities/stubEnv.js"; +import { when } from "ts-mockito"; + +describe("azureReposInvoker.ts", (): void => { + let azureDevOpsApiWrapper: AzureDevOpsApiWrapper; + let gitInvoker: GitInvoker; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + let tokenManager: TokenManager; + + beforeEach((): void => { + ({ + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + } = createAzureReposInvokerMocks()); + }); + + describe("isAccessTokenAvailable()", (): void => { + it("should return null when the token exists", async (): Promise => { + // Arrange + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: string | null = + await azureReposInvoker.isAccessTokenAvailable(); + + // Assert + assert.equal(result, null); + }); + + it("should return a string when the token manager fails", async (): Promise => { + // Arrange + stubEnv(["PR_METRICS_ACCESS_TOKEN", undefined]); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + when(tokenManager.getToken()).thenResolve("Failure"); + + // Act + const result: string | null = + await azureReposInvoker.isAccessTokenAvailable(); + + // Assert + assert.equal(result, "Failure"); + }); + + it("should return a string when the token does not exist", async (): Promise => { + // Arrange + stubEnv(["PR_METRICS_ACCESS_TOKEN", undefined]); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const result: string | null = + await azureReposInvoker.isAccessTokenAvailable(); + + // Assert + assert.equal( + result, + localize("repos.azureReposInvoker.noAzureReposAccessToken"), + ); + }); + }); +}); diff --git a/src/task/tests/repos/azureReposInvoker.setTitleAndDescription.spec.ts b/src/task/tests/repos/azureReposInvoker.setTitleAndDescription.spec.ts new file mode 100644 index 000000000..ec992d3ab --- /dev/null +++ b/src/task/tests/repos/azureReposInvoker.setTitleAndDescription.spec.ts @@ -0,0 +1,295 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import { + createAzureReposInvokerMocks, + createSut, +} from "./azureReposInvokerTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; +import type AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; +import ErrorWithStatus from "../wrappers/errorWithStatus.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type { GitPullRequest } from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import type { IGitApi } from "azure-devops-node-api/GitApi.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { StatusCodes } from "http-status-codes"; +import type TokenManager from "../../src/repos/tokenManager.js"; +import { any } from "../testUtilities/mockito.js"; +import assert from "node:assert/strict"; + +describe("azureReposInvoker.ts", (): void => { + let gitApi: IGitApi; + let azureDevOpsApiWrapper: AzureDevOpsApiWrapper; + let gitInvoker: GitInvoker; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + let tokenManager: TokenManager; + + beforeEach((): void => { + ({ + gitApi, + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + } = createAzureReposInvokerMocks()); + }); + + describe("setTitleAndDescription()", (): void => { + { + const testCases: StatusCodes[] = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.FORBIDDEN, + StatusCodes.NOT_FOUND, + ]; + + testCases.forEach((statusCode: StatusCodes): void => { + it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { + // Arrange + const error: ErrorWithStatus = new ErrorWithStatus("Test"); + error.statusCode = statusCode; + when( + gitApi.updatePullRequest(any(), "RepoID", 10, "Project"), + ).thenThrow(error); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.setTitleAndDescription("Title", "Description"); + + // Assert + const expectedMessage: string = + statusCode === StatusCodes.NOT_FOUND + ? "The resource could not be found. Verify the repository and pull request exist." + : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read & write' and 'Pull Request Threads' > 'Read & write'."; + const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( + func, + expectedMessage, + ); + assert.equal(result.internalMessage, "Test"); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updatePullRequest(any(), "RepoID", 10, "Project"), + ).once(); + }); + }); + } + + it("should not call the API when the title and description are null", async (): Promise => { + // Arrange + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.setTitleAndDescription(null, null); + + // Assert + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).never(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).never(); + verify(gitApi.updatePullRequest(any(), "RepoID", 10, "Project")).never(); + }); + + it("should call the API when the title is valid", async (): Promise => { + // Arrange + const expectedDetails: GitPullRequest = { + title: "Title", + }; + when( + gitApi.updatePullRequest( + deepEqual(expectedDetails), + "RepoID", + 10, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.setTitleAndDescription("Title", null); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updatePullRequest( + deepEqual(expectedDetails), + "RepoID", + 10, + "Project", + ), + ).once(); + }); + + it("should call the API when the description is valid", async (): Promise => { + // Arrange + const expectedDetails: GitPullRequest = { + description: "Description", + }; + when( + gitApi.updatePullRequest( + deepEqual(expectedDetails), + "RepoID", + 10, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.setTitleAndDescription(null, "Description"); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updatePullRequest( + deepEqual(expectedDetails), + "RepoID", + 10, + "Project", + ), + ).once(); + }); + + it("should call the API when both the title and description are valid", async (): Promise => { + // Arrange + const expectedDetails: GitPullRequest = { + description: "Description", + title: "Title", + }; + when( + gitApi.updatePullRequest( + deepEqual(expectedDetails), + "RepoID", + 10, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.setTitleAndDescription("Title", "Description"); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updatePullRequest( + deepEqual(expectedDetails), + "RepoID", + 10, + "Project", + ), + ).once(); + }); + + it("should call the API when both the title and description are valid and called multiple times", async (): Promise => { + // Arrange + const expectedDetails: GitPullRequest = { + description: "Description", + title: "Title", + }; + when( + gitApi.updatePullRequest( + deepEqual(expectedDetails), + "RepoID", + 10, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.setTitleAndDescription("Title", "Description"); + await azureReposInvoker.setTitleAndDescription("Title", "Description"); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updatePullRequest( + deepEqual(expectedDetails), + "RepoID", + 10, + "Project", + ), + ).twice(); + }); + }); +}); diff --git a/src/task/tests/repos/azureReposInvoker.spec.ts b/src/task/tests/repos/azureReposInvoker.spec.ts deleted file mode 100644 index ddd0afd0b..000000000 --- a/src/task/tests/repos/azureReposInvoker.spec.ts +++ /dev/null @@ -1,2042 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. - * Licensed under the MIT License. - */ - -import * as AssertExtensions from "../testUtilities/assertExtensions.js"; -import { - type Comment, - CommentThreadStatus, - type GitPullRequest, - type GitPullRequestCommentThread, -} from "azure-devops-node-api/interfaces/GitInterfaces.js"; -import { any, anyNumber } from "../testUtilities/mockito.js"; -import { deepEqual, instance, mock, verify, when } from "ts-mockito"; -import AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; -import AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; -import type CommentData from "../../src/repos/interfaces/commentData.js"; -import ErrorWithStatus from "../wrappers/errorWithStatus.js"; -import GitInvoker from "../../src/git/gitInvoker.js"; -import type { IGitApi } from "azure-devops-node-api/GitApi.js"; -import type { IRequestHandler } from "azure-devops-node-api/interfaces/common/VsoBaseInterfaces.js"; -import Logger from "../../src/utilities/logger.js"; -import type PullRequestDetailsInterface from "../../src/repos/interfaces/pullRequestDetailsInterface.js"; -import RunnerInvoker from "../../src/runners/runnerInvoker.js"; -import { StatusCodes } from "http-status-codes"; -import TokenManager from "../../src/repos/tokenManager.js"; -import { WebApi } from "azure-devops-node-api"; -import assert from "node:assert/strict"; -import { resolvableInstance } from "../testUtilities/resolvableInstance.js"; - -describe("azureReposInvoker.ts", (): void => { - let gitApi: IGitApi; - let azureDevOpsApiWrapper: AzureDevOpsApiWrapper; - let gitInvoker: GitInvoker; - let logger: Logger; - let runnerInvoker: RunnerInvoker; - let tokenManager: TokenManager; - - beforeEach((): void => { - process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI = - "https://dev.azure.com/organization"; - process.env.SYSTEM_TEAMPROJECT = "Project"; - process.env.BUILD_REPOSITORY_ID = "RepoID"; - process.env.PR_METRICS_ACCESS_TOKEN = "PAT"; - - gitApi = mock(); - const requestHandler: IRequestHandler = mock(); - const webApi: WebApi = mock(WebApi); - when(webApi.getGitApi()).thenResolve(resolvableInstance(gitApi)); - - azureDevOpsApiWrapper = mock(AzureDevOpsApiWrapper); - when(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).thenReturn( - instance(requestHandler), - ); - when( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - deepEqual(instance(requestHandler)), - ), - ).thenReturn(instance(webApi)); - - gitInvoker = mock(GitInvoker); - when(gitInvoker.pullRequestId).thenReturn(10); - - logger = mock(Logger); - - runnerInvoker = mock(RunnerInvoker); - when( - runnerInvoker.loc( - "repos.azureReposInvoker.insufficientAzureReposAccessTokenPermissions", - ), - ).thenReturn( - "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read' and 'Pull Request Threads' > 'Read & write'.", - ); - when( - runnerInvoker.loc("repos.azureReposInvoker.noAzureReposAccessToken"), - ).thenReturn( - "Could not access the Workload Identity Federation or Personal Access Token (PAT). Add the 'WorkloadIdentityFederation' input or 'PR_Metrics_Access_Token' as a secret environment variable.", - ); - when( - runnerInvoker.loc("repos.baseReposInvoker.resourceNotFound"), - ).thenReturn( - "The resource could not be found. Verify the repository and pull request exist.", - ); - - tokenManager = mock(TokenManager); - }); - - after(() => { - delete process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI; - delete process.env.SYSTEM_TEAMPROJECT; - delete process.env.BUILD_REPOSITORY_ID; - delete process.env.PR_METRICS_ACCESS_TOKEN; - }); - - describe("isAccessTokenAvailable()", (): void => { - it("should return null when the token exists", async (): Promise => { - // Arrange - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: string | null = - await azureReposInvoker.isAccessTokenAvailable(); - - // Assert - assert.equal(result, null); - verify( - logger.logDebug("* AzureReposInvoker.isAccessTokenAvailable()"), - ).once(); - }); - - it("should return a string when the token manager fails", async (): Promise => { - // Arrange - delete process.env.PR_METRICS_ACCESS_TOKEN; - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - when(tokenManager.getToken()).thenResolve("Failure"); - - // Act - const result: string | null = - await azureReposInvoker.isAccessTokenAvailable(); - - // Assert - assert.equal(result, "Failure"); - verify( - logger.logDebug("* AzureReposInvoker.isAccessTokenAvailable()"), - ).once(); - }); - - it("should return a string when the token does not exist", async (): Promise => { - // Arrange - delete process.env.PR_METRICS_ACCESS_TOKEN; - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: string | null = - await azureReposInvoker.isAccessTokenAvailable(); - - // Assert - assert.equal( - result, - "Could not access the Workload Identity Federation or Personal Access Token (PAT). Add the 'WorkloadIdentityFederation' input or 'PR_Metrics_Access_Token' as a secret environment variable.", - ); - verify( - logger.logDebug("* AzureReposInvoker.isAccessTokenAvailable()"), - ).once(); - }); - }); - - describe("getTitleAndDescription()", (): void => { - { - const testCases: (string | undefined)[] = [undefined, ""]; - - testCases.forEach((variable: string | undefined): void => { - it(`should throw when SYSTEM_TEAMPROJECT is set to the invalid value '${String(variable)}'`, async (): Promise => { - // Arrange - if (typeof variable === "undefined") { - delete process.env.SYSTEM_TEAMPROJECT; - } else { - process.env.SYSTEM_TEAMPROJECT = variable; - } - - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `'SYSTEM_TEAMPROJECT', accessed within 'AzureReposInvoker.getGitApi()', is invalid, null, or undefined '${String(variable)}'.`, - ); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - { - const testCases: (string | undefined)[] = [undefined, ""]; - - testCases.forEach((variable: string | undefined): void => { - it(`should throw when BUILD_REPOSITORY_ID is set to the invalid value '${String(variable)}'`, async (): Promise => { - // Arrange - if (typeof variable === "undefined") { - delete process.env.BUILD_REPOSITORY_ID; - } else { - process.env.BUILD_REPOSITORY_ID = variable; - } - - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `'BUILD_REPOSITORY_ID', accessed within 'AzureReposInvoker.getGitApi()', is invalid, null, or undefined '${String(variable)}'.`, - ); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - { - const testCases: (string | undefined)[] = [undefined, ""]; - - testCases.forEach((variable: string | undefined): void => { - it(`should throw when PR_METRICS_ACCESS_TOKEN is set to the invalid value '${String(variable)}'`, async (): Promise => { - // Arrange - if (typeof variable === "undefined") { - delete process.env.PR_METRICS_ACCESS_TOKEN; - } else { - process.env.PR_METRICS_ACCESS_TOKEN = variable; - } - - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `'PR_METRICS_ACCESS_TOKEN', accessed within 'AzureReposInvoker.getGitApi()', is invalid, null, or undefined '${String(variable)}'.`, - ); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - { - const testCases: (string | undefined)[] = [undefined, ""]; - - testCases.forEach((variable: string | undefined): void => { - it(`should throw when SYSTEM_TEAMFOUNDATIONCOLLECTIONURI is set to the invalid value '${String(variable)}'`, async (): Promise => { - // Arrange - if (typeof variable === "undefined") { - delete process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI; - } else { - process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI = variable; - } - - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `'SYSTEM_TEAMFOUNDATIONCOLLECTIONURI', accessed within 'AzureReposInvoker.getGitApi()', is invalid, null, or undefined '${String(variable)}'.`, - ); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - }); - }); - } - - { - const testCases: StatusCodes[] = [ - StatusCodes.UNAUTHORIZED, - StatusCodes.FORBIDDEN, - StatusCodes.NOT_FOUND, - ]; - - testCases.forEach((statusCode: StatusCodes): void => { - it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { - // Arrange - const error: ErrorWithStatus = new ErrorWithStatus("Test"); - error.statusCode = statusCode; - when(gitApi.getPullRequestById(10, "Project")).thenThrow(error); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.getTitleAndDescription(); - - // Assert - const expectedMessage: string = - statusCode === StatusCodes.NOT_FOUND - ? "The resource could not be found. Verify the repository and pull request exist." - : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read' and 'Pull Request Threads' > 'Read & write'."; - const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( - func, - expectedMessage, - ); - assert.equal(result.internalMessage, "Test"); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getPullRequestById(10, "Project")).once(); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - it("should return the title and description when available", async (): Promise => { - // Arrange - when(gitApi.getPullRequestById(10, "Project")).thenResolve({ - description: "Description", - title: "Title", - }); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: PullRequestDetailsInterface = - await azureReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, "Description"); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getPullRequestById(10, "Project")).once(); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify( - logger.logDebug('{"description":"Description","title":"Title"}'), - ).once(); - }); - - it("should return the title and description when available and called multiple times", async (): Promise => { - // Arrange - when(gitApi.getPullRequestById(10, "Project")).thenResolve({ - description: "Description", - title: "Title", - }); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.getTitleAndDescription(); - const result: PullRequestDetailsInterface = - await azureReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, "Description"); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getPullRequestById(10, "Project")).twice(); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).twice(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).twice(); - verify( - logger.logDebug('{"description":"Description","title":"Title"}'), - ).twice(); - }); - - it("should return the title when the description is unavailable", async (): Promise => { - // Arrange - when(gitApi.getPullRequestById(10, "Project")).thenResolve({ - title: "Title", - }); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: PullRequestDetailsInterface = - await azureReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, null); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getPullRequestById(10, "Project")).once(); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug('{"title":"Title"}')).once(); - }); - - it("should throw when the title is unavailable", async (): Promise => { - // Arrange - when(gitApi.getPullRequestById(10, "Project")).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "'title', accessed within 'AzureReposInvoker.getTitleAndDescription()', is invalid, null, or undefined 'undefined'.", - ); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getPullRequestById(10, "Project")).once(); - verify( - logger.logDebug("* AzureReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - }); - - describe("getComments()", (): void => { - { - const testCases: StatusCodes[] = [ - StatusCodes.UNAUTHORIZED, - StatusCodes.FORBIDDEN, - StatusCodes.NOT_FOUND, - ]; - - testCases.forEach((statusCode: StatusCodes): void => { - it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { - // Arrange - const error: ErrorWithStatus = new ErrorWithStatus("Test"); - error.statusCode = statusCode; - when(gitApi.getThreads("RepoID", 10, "Project")).thenThrow(error); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.getComments(); - - // Assert - const expectedMessage: string = - statusCode === StatusCodes.NOT_FOUND - ? "The resource could not be found. Verify the repository and pull request exist." - : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read' and 'Pull Request Threads' > 'Read & write'."; - const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( - func, - expectedMessage, - ); - assert.equal(result.internalMessage, "Test"); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - it("should return the result when called with a pull request comment whose thread context is undefined", async (): Promise => { - // Arrange - when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ - { comments: [{ content: "Content" }], id: 1, status: 1 }, - ]); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: CommentData = await azureReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 1); - assert.equal(result.pullRequestComments[0]?.id, 1); - assert.equal(result.pullRequestComments[0].content, "Content"); - assert.equal( - result.pullRequestComments[0].status, - CommentThreadStatus.Active, - ); - assert.equal(result.fileComments.length, 0); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify( - logger.logDebug( - '[{"comments":[{"content":"Content"}],"id":1,"status":1}]', - ), - ).once(); - }); - - it("should return the result when called with a pull request comment whose thread context is null", async (): Promise => { - // Arrange - when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ - { - comments: [{ content: "Content" }], - id: 1, - status: 1, - threadContext: null as unknown as undefined, - }, - ]); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: CommentData = await azureReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 1); - assert.equal(result.pullRequestComments[0]?.id, 1); - assert.equal(result.pullRequestComments[0].content, "Content"); - assert.equal( - result.pullRequestComments[0].status, - CommentThreadStatus.Active, - ); - assert.equal(result.fileComments.length, 0); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify( - logger.logDebug( - '[{"comments":[{"content":"Content"}],"id":1,"status":1,"threadContext":null}]', - ), - ).once(); - }); - - it("should return the result when called with a file comment", async (): Promise => { - // Arrange - when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ - { - comments: [{ content: "Content" }], - id: 1, - status: 1, - threadContext: { filePath: "/file.ts" }, - }, - ]); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: CommentData = await azureReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 0); - assert.equal(result.fileComments.length, 1); - assert.equal(result.fileComments[0]?.id, 1); - assert.equal(result.fileComments[0].content, "Content"); - assert.equal(result.fileComments[0].status, CommentThreadStatus.Active); - assert.equal(result.fileComments[0].fileName, "file.ts"); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify( - logger.logDebug( - '[{"comments":[{"content":"Content"}],"id":1,"status":1,"threadContext":{"filePath":"/file.ts"}}]', - ), - ).once(); - }); - - it("should return the result when called with both a pull request and file comment", async (): Promise => { - // Arrange - when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ - { comments: [{ content: "PR Content" }], id: 1, status: 1 }, - { - comments: [{ content: "File Content" }], - id: 2, - status: 1, - threadContext: { filePath: "/file.ts" }, - }, - ]); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: CommentData = await azureReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 1); - assert.equal(result.pullRequestComments[0]?.id, 1); - assert.equal(result.pullRequestComments[0].content, "PR Content"); - assert.equal( - result.pullRequestComments[0].status, - CommentThreadStatus.Active, - ); - assert.equal(result.fileComments.length, 1); - assert.equal(result.fileComments[0]?.id, 2); - assert.equal(result.fileComments[0].content, "File Content"); - assert.equal(result.fileComments[0].status, CommentThreadStatus.Active); - assert.equal(result.fileComments[0].fileName, "file.ts"); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify( - logger.logDebug( - '[{"comments":[{"content":"PR Content"}],"id":1,"status":1},{"comments":[{"content":"File Content"}],"id":2,"status":1,"threadContext":{"filePath":"/file.ts"}}]', - ), - ).once(); - }); - - it("should return the result when called multiple times", async (): Promise => { - // Arrange - when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ - { comments: [{ content: "Content" }], id: 1, status: 1 }, - ]); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.getComments(); - const result: CommentData = await azureReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 1); - assert.equal(result.pullRequestComments[0]?.id, 1); - assert.equal(result.pullRequestComments[0].content, "Content"); - assert.equal( - result.pullRequestComments[0].status, - CommentThreadStatus.Active, - ); - assert.equal(result.fileComments.length, 0); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).twice(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).twice(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).twice(); - verify( - logger.logDebug( - '[{"comments":[{"content":"Content"}],"id":1,"status":1}]', - ), - ).twice(); - }); - - it("should throw when provided with a payload with no ID", async (): Promise => { - // Arrange - when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve([ - { comments: [{ content: "Content" }], status: 1 }, - ]); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.getComments(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "'commentThread[0].id', accessed within 'AzureReposInvoker.convertPullRequestComments()', is invalid, null, or undefined 'undefined'.", - ); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify( - logger.logDebug('[{"comments":[{"content":"Content"}],"status":1}]'), - ).once(); - }); - - it("should continue if the payload has no status", async (): Promise => { - // Arrange - const getThreadsResult: GitPullRequestCommentThread[] = [ - { comments: [{ content: "PR Content" }], id: 1 }, - { - comments: [{ content: "File Content" }], - id: 2, - threadContext: { filePath: "/file.ts" }, - }, - ]; - when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve( - getThreadsResult, - ); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: CommentData = await azureReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 1); - assert.equal(result.pullRequestComments[0]?.id, 1); - assert.equal(result.pullRequestComments[0].content, "PR Content"); - assert.equal( - result.pullRequestComments[0].status, - CommentThreadStatus.Unknown, - ); - assert.equal(result.fileComments.length, 1); - assert.equal(result.fileComments[0]?.id, 2); - assert.equal(result.fileComments[0].content, "File Content"); - assert.equal(result.fileComments[0].status, CommentThreadStatus.Unknown); - assert.equal(result.fileComments[0].fileName, "file.ts"); - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug(JSON.stringify(getThreadsResult))).once(); - }); - - { - const testCases: GitPullRequestCommentThread[] = [ - { id: 1, status: 1 }, - { comments: [], id: 1, status: 1 }, - { comments: [{}], id: 1, status: 1 }, - { comments: [{ content: "" }], id: 1, status: 1 }, - { - comments: [{ content: "Content" }], - id: 1, - status: 1, - threadContext: {}, - }, - { - comments: [{ content: "Content" }], - id: 1, - status: 1, - threadContext: { filePath: "" }, - }, - { - comments: [{ content: "Content" }], - id: 1, - status: 1, - threadContext: { filePath: "/" }, - }, - ]; - - testCases.forEach((commentThread: GitPullRequestCommentThread): void => { - it(`should skip the comment with the malformed payload '${JSON.stringify(commentThread)}'`, async (): Promise => { - // Arrange - const getThreadsResult: GitPullRequestCommentThread[] = [ - commentThread, - { comments: [{ content: "PR Content" }], id: 2, status: 1 }, - { - comments: [{ content: "File Content" }], - id: 3, - status: 1, - threadContext: { filePath: "/file.ts" }, - }, - ]; - when(gitApi.getThreads("RepoID", 10, "Project")).thenResolve( - getThreadsResult, - ); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const result: CommentData = await azureReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 1); - assert.equal(result.pullRequestComments[0]?.id, 2); - assert.equal(result.pullRequestComments[0].content, "PR Content"); - assert.equal( - result.pullRequestComments[0].status, - CommentThreadStatus.Active, - ); - assert.equal(result.fileComments.length, 1); - assert.equal(result.fileComments[0]?.id, 3); - assert.equal(result.fileComments[0].content, "File Content"); - assert.equal( - result.fileComments[0].status, - CommentThreadStatus.Active, - ); - assert.equal(result.fileComments[0].fileName, "file.ts"); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.getThreads("RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.getComments()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug(JSON.stringify(getThreadsResult))).once(); - }); - }); - } - }); - - describe("setTitleAndDescription()", (): void => { - { - const testCases: StatusCodes[] = [ - StatusCodes.UNAUTHORIZED, - StatusCodes.FORBIDDEN, - StatusCodes.NOT_FOUND, - ]; - - testCases.forEach((statusCode: StatusCodes): void => { - it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { - // Arrange - const error: ErrorWithStatus = new ErrorWithStatus("Test"); - error.statusCode = statusCode; - when( - gitApi.updatePullRequest(any(), "RepoID", 10, "Project"), - ).thenThrow(error); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.setTitleAndDescription("Title", "Description"); - - // Assert - const expectedMessage: string = - statusCode === StatusCodes.NOT_FOUND - ? "The resource could not be found. Verify the repository and pull request exist." - : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read' and 'Pull Request Threads' > 'Read & write'."; - const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( - func, - expectedMessage, - ); - assert.equal(result.internalMessage, "Test"); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updatePullRequest(any(), "RepoID", 10, "Project"), - ).once(); - verify( - logger.logDebug("* AzureReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - it("should not call the API when the title and description are null", async (): Promise => { - // Arrange - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.setTitleAndDescription(null, null); - - // Assert - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).never(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).never(); - verify(gitApi.updatePullRequest(any(), "RepoID", 10, "Project")).never(); - verify( - logger.logDebug("* AzureReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).never(); - }); - - it("should call the API when the title is valid", async (): Promise => { - // Arrange - const expectedDetails: GitPullRequest = { - title: "Title", - }; - when( - gitApi.updatePullRequest( - deepEqual(expectedDetails), - "RepoID", - 10, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.setTitleAndDescription("Title", null); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updatePullRequest( - deepEqual(expectedDetails), - "RepoID", - 10, - "Project", - ), - ).once(); - verify( - logger.logDebug("* AzureReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - - it("should call the API when the description is valid", async (): Promise => { - // Arrange - const expectedDetails: GitPullRequest = { - description: "Description", - }; - when( - gitApi.updatePullRequest( - deepEqual(expectedDetails), - "RepoID", - 10, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.setTitleAndDescription(null, "Description"); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updatePullRequest( - deepEqual(expectedDetails), - "RepoID", - 10, - "Project", - ), - ).once(); - verify( - logger.logDebug("* AzureReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - - it("should call the API when both the title and description are valid", async (): Promise => { - // Arrange - const expectedDetails: GitPullRequest = { - description: "Description", - title: "Title", - }; - when( - gitApi.updatePullRequest( - deepEqual(expectedDetails), - "RepoID", - 10, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.setTitleAndDescription("Title", "Description"); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updatePullRequest( - deepEqual(expectedDetails), - "RepoID", - 10, - "Project", - ), - ).once(); - verify( - logger.logDebug("* AzureReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - - it("should call the API when both the title and description are valid and called multiple times", async (): Promise => { - // Arrange - const expectedDetails: GitPullRequest = { - description: "Description", - title: "Title", - }; - when( - gitApi.updatePullRequest( - deepEqual(expectedDetails), - "RepoID", - 10, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.setTitleAndDescription("Title", "Description"); - await azureReposInvoker.setTitleAndDescription("Title", "Description"); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updatePullRequest( - deepEqual(expectedDetails), - "RepoID", - 10, - "Project", - ), - ).twice(); - verify( - logger.logDebug("* AzureReposInvoker.setTitleAndDescription()"), - ).twice(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).twice(); - verify(logger.logDebug("{}")).twice(); - }); - }); - - describe("createComment()", (): void => { - { - const testCases: StatusCodes[] = [ - StatusCodes.UNAUTHORIZED, - StatusCodes.FORBIDDEN, - StatusCodes.NOT_FOUND, - ]; - - testCases.forEach((statusCode: StatusCodes): void => { - it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { - // Arrange - const error: ErrorWithStatus = new ErrorWithStatus("Test"); - error.statusCode = statusCode; - when(gitApi.createThread(any(), "RepoID", 10, "Project")).thenThrow( - error, - ); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.createComment( - "Comment Content", - "file.ts", - CommentThreadStatus.Active, - ); - - // Assert - const expectedMessage: string = - statusCode === StatusCodes.NOT_FOUND - ? "The resource could not be found. Verify the repository and pull request exist." - : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read' and 'Pull Request Threads' > 'Read & write'."; - const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( - func, - expectedMessage, - ); - assert.equal(result.internalMessage, "Test"); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.createThread(any(), "RepoID", 10, "Project")).once(); - verify(logger.logDebug("* AzureReposInvoker.createComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - it("should call the API for no file", async (): Promise => { - // Arrange - const expectedComment: GitPullRequestCommentThread = { - comments: [{ content: "Comment Content" }], - status: CommentThreadStatus.Active, - }; - when( - gitApi.createThread( - deepEqual(expectedComment), - "RepoID", - 10, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.createComment( - "Comment Content", - null, - CommentThreadStatus.Active, - ); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.createThread( - deepEqual(expectedComment), - "RepoID", - 10, - "Project", - ), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.createComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - - it("should call the API for no file when called multiple times", async (): Promise => { - // Arrange - const expectedComment: GitPullRequestCommentThread = { - comments: [{ content: "Comment Content" }], - status: CommentThreadStatus.Active, - }; - when( - gitApi.createThread( - deepEqual(expectedComment), - "RepoID", - 10, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.createComment( - "Comment Content", - null, - CommentThreadStatus.Active, - ); - await azureReposInvoker.createComment( - "Comment Content", - null, - CommentThreadStatus.Active, - ); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.createThread( - deepEqual(expectedComment), - "RepoID", - 10, - "Project", - ), - ).twice(); - verify(logger.logDebug("* AzureReposInvoker.createComment()")).twice(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).twice(); - verify(logger.logDebug("{}")).twice(); - }); - - it("should call the API for a file", async (): Promise => { - // Arrange - const expectedComment: GitPullRequestCommentThread = { - comments: [{ content: "Comment Content" }], - status: CommentThreadStatus.Active, - threadContext: { - filePath: "/file.ts", - rightFileEnd: { - line: 1, - offset: 2, - }, - rightFileStart: { - line: 1, - offset: 1, - }, - }, - }; - when( - gitApi.createThread( - deepEqual(expectedComment), - "RepoID", - 10, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.createComment( - "Comment Content", - "file.ts", - CommentThreadStatus.Active, - ); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.createThread( - deepEqual(expectedComment), - "RepoID", - 10, - "Project", - ), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.createComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - - it("should call the API for a deleted file", async (): Promise => { - // Arrange - const expectedComment: GitPullRequestCommentThread = { - comments: [{ content: "Comment Content" }], - status: CommentThreadStatus.Active, - threadContext: { - filePath: "/file.ts", - leftFileEnd: { - line: 1, - offset: 2, - }, - leftFileStart: { - line: 1, - offset: 1, - }, - }, - }; - when( - gitApi.createThread( - deepEqual(expectedComment), - "RepoID", - 10, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.createComment( - "Comment Content", - "file.ts", - CommentThreadStatus.Active, - true, - ); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.createThread( - deepEqual(expectedComment), - "RepoID", - 10, - "Project", - ), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.createComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - }); - - describe("updateComment()", (): void => { - { - const testCases: StatusCodes[] = [ - StatusCodes.UNAUTHORIZED, - StatusCodes.FORBIDDEN, - StatusCodes.NOT_FOUND, - ]; - - testCases.forEach((statusCode: StatusCodes): void => { - it(`should throw when the access token has insufficient access for the updateComment API and the API call returns status code '${String(statusCode)}'`, async (): Promise => { - // Arrange - const error: ErrorWithStatus = new ErrorWithStatus("Test"); - error.statusCode = statusCode; - when( - gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), - ).thenThrow(error); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.updateComment( - 20, - "Content", - CommentThreadStatus.Active, - ); - - // Assert - const expectedMessage: string = - statusCode === StatusCodes.NOT_FOUND - ? "The resource could not be found. Verify the repository and pull request exist." - : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read' and 'Pull Request Threads' > 'Read & write'."; - const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( - func, - expectedMessage, - ); - assert.equal(result.internalMessage, "Test"); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - { - const testCases: StatusCodes[] = [ - StatusCodes.UNAUTHORIZED, - StatusCodes.FORBIDDEN, - StatusCodes.NOT_FOUND, - ]; - - testCases.forEach((status: StatusCodes): void => { - it(`should throw when the access token has insufficient access for the updateComment API and the API call returns status '${String(status)}'`, async (): Promise => { - // Arrange - const error: ErrorWithStatus = new ErrorWithStatus("Test"); - error.status = status; - when( - gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), - ).thenResolve({}); - when( - gitApi.updateThread(any(), "RepoID", 10, 20, "Project"), - ).thenThrow(error); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.updateComment( - 20, - "Content", - CommentThreadStatus.Active, - ); - - // Assert - const expectedMessage: string = - status === StatusCodes.NOT_FOUND - ? "The resource could not be found. Verify the repository and pull request exist." - : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read' and 'Pull Request Threads' > 'Read & write'."; - const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( - func, - expectedMessage, - ); - assert.equal(result.internalMessage, "Test"); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), - ).once(); - verify( - gitApi.updateThread(any(), "RepoID", 10, 20, "Project"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - }); - } - - it("should call the APIs when both the comment content and the thread status are updated", async (): Promise => { - // Arrange - const expectedComment: Comment = { - content: "Content", - }; - when( - gitApi.updateComment( - deepEqual(expectedComment), - "RepoID", - 10, - 20, - 1, - "Project", - ), - ).thenResolve({}); - const expectedCommentThread: GitPullRequestCommentThread = { - status: CommentThreadStatus.Active, - }; - when( - gitApi.updateThread( - deepEqual(expectedCommentThread), - "RepoID", - 10, - 20, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.updateComment( - 20, - "Content", - CommentThreadStatus.Active, - ); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updateComment( - deepEqual(expectedComment), - "RepoID", - 10, - 20, - 1, - "Project", - ), - ).once(); - verify( - gitApi.updateThread( - deepEqual(expectedCommentThread), - "RepoID", - 10, - 20, - "Project", - ), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).twice(); - }); - - it("should call the API when the comment content is updated", async (): Promise => { - // Arrange - const expectedComment: Comment = { - content: "Content", - }; - when( - gitApi.updateComment( - deepEqual(expectedComment), - "RepoID", - 10, - 20, - 1, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.updateComment(20, "Content", null); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updateComment( - deepEqual(expectedComment), - "RepoID", - 10, - 20, - 1, - "Project", - ), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - - it("should call the API when the thread status is updated", async (): Promise => { - // Arrange - const expectedComment: GitPullRequestCommentThread = { - status: CommentThreadStatus.Active, - }; - when( - gitApi.updateThread( - deepEqual(expectedComment), - "RepoID", - 10, - 20, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.updateComment( - 20, - null, - CommentThreadStatus.Active, - ); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updateThread( - deepEqual(expectedComment), - "RepoID", - 10, - 20, - "Project", - ), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - verify(logger.logDebug("{}")).once(); - }); - - it("should call no APIs when neither the comment content nor the thread status are updated", async (): Promise => { - // Arrange - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.updateComment(20, null, null); - - // Assert - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).never(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).never(); - verify( - gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), - ).never(); - verify(gitApi.updateThread(any(), "RepoID", 10, 20, "Project")).never(); - verify(logger.logDebug("* AzureReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).never(); - }); - - it("should call the API when called multiple times", async (): Promise => { - // Arrange - const expectedComment: Comment = { - content: "Content", - }; - when( - gitApi.updateComment( - deepEqual(expectedComment), - "RepoID", - 10, - 20, - 1, - "Project", - ), - ).thenResolve({}); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.updateComment(20, "Content", null); - await azureReposInvoker.updateComment(20, "Content", null); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify( - gitApi.updateComment( - deepEqual(expectedComment), - "RepoID", - 10, - 20, - 1, - "Project", - ), - ).twice(); - verify(logger.logDebug("* AzureReposInvoker.updateComment()")).twice(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).twice(); - verify(logger.logDebug("{}")).twice(); - }); - }); - - describe("deleteCommentThread()", (): void => { - { - const testCases: StatusCodes[] = [ - StatusCodes.UNAUTHORIZED, - StatusCodes.FORBIDDEN, - StatusCodes.NOT_FOUND, - ]; - - testCases.forEach((statusCode: StatusCodes): void => { - it(`should throw when the access token has insufficient access and the API call returns status code '${String(statusCode)}'`, async (): Promise => { - // Arrange - const error: ErrorWithStatus = new ErrorWithStatus("Test"); - error.statusCode = statusCode; - when(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).thenThrow( - error, - ); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - const func: () => Promise = async () => - azureReposInvoker.deleteCommentThread(20); - - // Assert - const expectedMessage: string = - statusCode === StatusCodes.NOT_FOUND - ? "The resource could not be found. Verify the repository and pull request exist." - : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read' and 'Pull Request Threads' > 'Read & write'."; - const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( - func, - expectedMessage, - ); - assert.equal(result.internalMessage, "Test"); - verify( - azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), - ).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).once(); - verify( - logger.logDebug("* AzureReposInvoker.deleteCommentThread()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - }); - } - - it("should call the API for a single comment", async (): Promise => { - // Arrange - when(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).thenResolve(); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.deleteCommentThread(20); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).once(); - verify( - logger.logDebug("* AzureReposInvoker.deleteCommentThread()"), - ).once(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).once(); - }); - - it("should call the API when called multiple times", async (): Promise => { - // Arrange - when( - gitApi.deleteComment("RepoID", 10, anyNumber(), 1, "Project"), - ).thenResolve(); - const azureReposInvoker: AzureReposInvoker = new AzureReposInvoker( - instance(azureDevOpsApiWrapper), - instance(gitInvoker), - instance(logger), - instance(runnerInvoker), - instance(tokenManager), - ); - - // Act - await azureReposInvoker.deleteCommentThread(20); - await azureReposInvoker.deleteCommentThread(30); - - // Assert - verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); - verify( - azureDevOpsApiWrapper.getWebApiInstance( - "https://dev.azure.com/organization", - any(), - ), - ).once(); - verify(gitApi.deleteComment("RepoID", 10, 20, 1, "Project")).once(); - verify(gitApi.deleteComment("RepoID", 10, 30, 1, "Project")).once(); - verify( - logger.logDebug("* AzureReposInvoker.deleteCommentThread()"), - ).twice(); - verify(logger.logDebug("* AzureReposInvoker.getGitApi()")).twice(); - }); - }); -}); diff --git a/src/task/tests/repos/azureReposInvoker.updateComment.spec.ts b/src/task/tests/repos/azureReposInvoker.updateComment.spec.ts new file mode 100644 index 000000000..5a49716e8 --- /dev/null +++ b/src/task/tests/repos/azureReposInvoker.updateComment.spec.ts @@ -0,0 +1,410 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import { + type Comment, + CommentThreadStatus, + type GitPullRequestCommentThread, +} from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import { + createAzureReposInvokerMocks, + createSut, +} from "./azureReposInvokerTestSetup.js"; +import { deepEqual, verify, when } from "ts-mockito"; +import type AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; +import type AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; +import ErrorWithStatus from "../wrappers/errorWithStatus.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type { IGitApi } from "azure-devops-node-api/GitApi.js"; +import type Logger from "../../src/utilities/logger.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { StatusCodes } from "http-status-codes"; +import type TokenManager from "../../src/repos/tokenManager.js"; +import { any } from "../testUtilities/mockito.js"; +import assert from "node:assert/strict"; + +describe("azureReposInvoker.ts", (): void => { + let gitApi: IGitApi; + let azureDevOpsApiWrapper: AzureDevOpsApiWrapper; + let gitInvoker: GitInvoker; + let logger: Logger; + let runnerInvoker: RunnerInvoker; + let tokenManager: TokenManager; + + beforeEach((): void => { + ({ + gitApi, + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + } = createAzureReposInvokerMocks()); + }); + + describe("updateComment()", (): void => { + { + const testCases: StatusCodes[] = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.FORBIDDEN, + StatusCodes.NOT_FOUND, + ]; + + testCases.forEach((statusCode: StatusCodes): void => { + it(`should throw when the access token has insufficient access for the updateComment API and the API call returns status code '${String(statusCode)}'`, async (): Promise => { + // Arrange + const error: ErrorWithStatus = new ErrorWithStatus("Test"); + error.statusCode = statusCode; + when( + gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), + ).thenThrow(error); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.updateComment( + 20, + "Content", + CommentThreadStatus.Active, + ); + + // Assert + const expectedMessage: string = + statusCode === StatusCodes.NOT_FOUND + ? "The resource could not be found. Verify the repository and pull request exist." + : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read & write' and 'Pull Request Threads' > 'Read & write'."; + const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( + func, + expectedMessage, + ); + assert.equal(result.internalMessage, "Test"); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), + ).once(); + }); + }); + } + + { + const testCases: StatusCodes[] = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.FORBIDDEN, + StatusCodes.NOT_FOUND, + ]; + + testCases.forEach((status: StatusCodes): void => { + it(`should throw when the access token has insufficient access for the updateComment API and the API call returns status '${String(status)}'`, async (): Promise => { + // Arrange + const error: ErrorWithStatus = new ErrorWithStatus("Test"); + error.status = status; + when( + gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), + ).thenResolve({}); + when( + gitApi.updateThread(any(), "RepoID", 10, 20, "Project"), + ).thenThrow(error); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + const func: () => Promise = async () => + azureReposInvoker.updateComment( + 20, + "Content", + CommentThreadStatus.Active, + ); + + // Assert + const expectedMessage: string = + status === StatusCodes.NOT_FOUND + ? "The resource could not be found. Verify the repository and pull request exist." + : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has access to 'Code' > 'Read & write' and 'Pull Request Threads' > 'Read & write'."; + const result: ErrorWithStatus = await AssertExtensions.toThrowAsync( + func, + expectedMessage, + ); + assert.equal(result.internalMessage, "Test"); + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), + ).once(); + verify( + gitApi.updateThread(any(), "RepoID", 10, 20, "Project"), + ).once(); + }); + }); + } + + it("should call the APIs when both the comment content and the thread status are updated", async (): Promise => { + // Arrange + const expectedComment: Comment = { + content: "Content", + }; + when( + gitApi.updateComment( + deepEqual(expectedComment), + "RepoID", + 10, + 20, + 1, + "Project", + ), + ).thenResolve({}); + const expectedCommentThread: GitPullRequestCommentThread = { + status: CommentThreadStatus.Active, + }; + when( + gitApi.updateThread( + deepEqual(expectedCommentThread), + "RepoID", + 10, + 20, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.updateComment( + 20, + "Content", + CommentThreadStatus.Active, + ); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updateComment( + deepEqual(expectedComment), + "RepoID", + 10, + 20, + 1, + "Project", + ), + ).once(); + verify( + gitApi.updateThread( + deepEqual(expectedCommentThread), + "RepoID", + 10, + 20, + "Project", + ), + ).once(); + }); + + it("should call the API when the comment content is updated", async (): Promise => { + // Arrange + const expectedComment: Comment = { + content: "Content", + }; + when( + gitApi.updateComment( + deepEqual(expectedComment), + "RepoID", + 10, + 20, + 1, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.updateComment(20, "Content", null); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updateComment( + deepEqual(expectedComment), + "RepoID", + 10, + 20, + 1, + "Project", + ), + ).once(); + }); + + it("should call the API when the thread status is updated", async (): Promise => { + // Arrange + const expectedComment: GitPullRequestCommentThread = { + status: CommentThreadStatus.Active, + }; + when( + gitApi.updateThread( + deepEqual(expectedComment), + "RepoID", + 10, + 20, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.updateComment( + 20, + null, + CommentThreadStatus.Active, + ); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updateThread( + deepEqual(expectedComment), + "RepoID", + 10, + 20, + "Project", + ), + ).once(); + }); + + it("should call no APIs when neither the comment content nor the thread status are updated", async (): Promise => { + // Arrange + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.updateComment(20, null, null); + + // Assert + verify( + azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT"), + ).never(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).never(); + verify( + gitApi.updateComment(any(), "RepoID", 10, 20, 1, "Project"), + ).never(); + verify(gitApi.updateThread(any(), "RepoID", 10, 20, "Project")).never(); + }); + + it("should call the API when called multiple times", async (): Promise => { + // Arrange + const expectedComment: Comment = { + content: "Content", + }; + when( + gitApi.updateComment( + deepEqual(expectedComment), + "RepoID", + 10, + 20, + 1, + "Project", + ), + ).thenResolve({}); + const azureReposInvoker: AzureReposInvoker = createSut( + azureDevOpsApiWrapper, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + ); + + // Act + await azureReposInvoker.updateComment(20, "Content", null); + await azureReposInvoker.updateComment(20, "Content", null); + + // Assert + verify(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).once(); + verify( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + any(), + ), + ).once(); + verify( + gitApi.updateComment( + deepEqual(expectedComment), + "RepoID", + 10, + 20, + 1, + "Project", + ), + ).twice(); + }); + }); +}); diff --git a/src/task/tests/repos/azureReposInvokerTestSetup.ts b/src/task/tests/repos/azureReposInvokerTestSetup.ts new file mode 100644 index 000000000..13d346fee --- /dev/null +++ b/src/task/tests/repos/azureReposInvokerTestSetup.ts @@ -0,0 +1,107 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { deepEqual, instance, mock, when } from "ts-mockito"; +import AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; +import AzureReposInvoker from "../../src/repos/azureReposInvoker.js"; +import GitInvoker from "../../src/git/gitInvoker.js"; +import type { IGitApi } from "azure-devops-node-api/GitApi.js"; +import type { IRequestHandler } from "azure-devops-node-api/interfaces/common/VsoBaseInterfaces.js"; +import Logger from "../../src/utilities/logger.js"; +import RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import TokenManager from "../../src/repos/tokenManager.js"; +import { WebApi } from "azure-devops-node-api"; +import { resolvableInstance } from "../testUtilities/resolvableInstance.js"; +import { stubEnv } from "../testUtilities/stubEnv.js"; +import { stubLocalization } from "../testUtilities/stubLocalization.js"; + +export interface AzureReposInvokerMocks { + gitApi: IGitApi; + azureDevOpsApiWrapper: AzureDevOpsApiWrapper; + gitInvoker: GitInvoker; + logger: Logger; + runnerInvoker: RunnerInvoker; + tokenManager: TokenManager; +} + +/** + * Creates the mocks and environment variable stubs required by + * `azureReposInvoker.ts` tests. Individual tests can override any stub after + * calling this helper. + * @returns The paired mocks. + */ +export const createAzureReposInvokerMocks = (): AzureReposInvokerMocks => { + stubEnv( + ["BUILD_REPOSITORY_ID", "RepoID"], + ["PR_METRICS_ACCESS_TOKEN", "PAT"], + [ + "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", + "https://dev.azure.com/organization", + ], + ["SYSTEM_TEAMPROJECT", "Project"], + ); + + const gitApi: IGitApi = mock(); + const requestHandler: IRequestHandler = mock(); + const webApi: WebApi = mock(WebApi); + when(webApi.getGitApi()).thenResolve(resolvableInstance(gitApi)); + + const azureDevOpsApiWrapper: AzureDevOpsApiWrapper = mock( + AzureDevOpsApiWrapper, + ); + when(azureDevOpsApiWrapper.getPersonalAccessTokenHandler("PAT")).thenReturn( + instance(requestHandler), + ); + when( + azureDevOpsApiWrapper.getWebApiInstance( + "https://dev.azure.com/organization", + deepEqual(instance(requestHandler)), + ), + ).thenReturn(instance(webApi)); + + const pullRequestId = 10; + const gitInvoker: GitInvoker = mock(GitInvoker); + when(gitInvoker.pullRequestId).thenReturn(pullRequestId); + + const logger: Logger = mock(Logger); + + const runnerInvoker: RunnerInvoker = mock(RunnerInvoker); + stubLocalization(runnerInvoker); + + const tokenManager: TokenManager = mock(TokenManager); + + return { + azureDevOpsApiWrapper, + gitApi, + gitInvoker, + logger, + runnerInvoker, + tokenManager, + }; +}; + +/** + * Constructs an `AzureReposInvoker` instance from the supplied mocks. + * @param azureDevOpsApiWrapper The mocked Azure DevOps API wrapper. + * @param gitInvoker The mocked git invoker. + * @param logger The mocked logger. + * @param runnerInvoker The mocked runner invoker. + * @param tokenManager The mocked token manager. + * @returns The constructed `AzureReposInvoker` instance. + */ +export const createSut = ( + azureDevOpsApiWrapper: AzureDevOpsApiWrapper, + gitInvoker: GitInvoker, + logger: Logger, + runnerInvoker: RunnerInvoker, + tokenManager: TokenManager, +): AzureReposInvoker => + new AzureReposInvoker( + instance(azureDevOpsApiWrapper), + instance(gitInvoker), + instance(logger), + instance(runnerInvoker), + instance(tokenManager), + ); diff --git a/src/task/tests/repos/gitHubReposInvoker.createComment.spec.ts b/src/task/tests/repos/gitHubReposInvoker.createComment.spec.ts new file mode 100644 index 000000000..6d67561c7 --- /dev/null +++ b/src/task/tests/repos/gitHubReposInvoker.createComment.spec.ts @@ -0,0 +1,489 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import * as GitHubReposInvokerConstants from "./gitHubReposInvokerConstants.js"; +import { any, anyNumber, anyString } from "../testUtilities/mockito.js"; +import { + createGitHubReposInvokerMocks, + createSut, + expectedUserAgent, +} from "./gitHubReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import HttpError from "../testUtilities/httpError.js"; +import type Logger from "../../src/utilities/logger.js"; +import type { OctokitOptions } from "@octokit/core"; +import type OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; +import type { RequestError } from "@octokit/request-error"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { StatusCodes } from "http-status-codes"; +import assert from "node:assert/strict"; +import { createRequestError } from "../testUtilities/createRequestError.js"; + +describe("gitHubReposInvoker.ts", (): void => { + let gitInvoker: GitInvoker; + let logger: Logger; + let octokitWrapper: OctokitWrapper; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, logger, octokitWrapper, runnerInvoker } = + createGitHubReposInvokerMocks()); + }); + + describe("createComment()", (): void => { + it("should succeed when a file name is specified", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.createComment("Content", "file.ts"); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), + ).once(); + verify( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).once(); + }); + + it("should throw when the commit list is empty", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + when( + octokitWrapper.listCommits( + anyString(), + anyString(), + anyNumber(), + anyNumber(), + ), + ).thenResolve({ + data: [], + headers: {}, + status: StatusCodes.OK, + url: "", + }); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.createComment("Content", "file.ts"); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "'result.data[-1].sha', accessed within 'GitHubReposInvoker.getCommitId()', is invalid, null, or undefined 'undefined'.", + ); + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), + ).once(); + }); + + it("should succeed when there are multiple pages of commits", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + when( + octokitWrapper.listCommits(anyString(), anyString(), anyNumber(), 1), + ).thenResolve({ + data: [], + headers: { + link: '; rel="next", ; rel="last"', + }, + status: StatusCodes.OK, + url: "", + }); + when( + octokitWrapper.listCommits(anyString(), anyString(), anyNumber(), 24), + ).thenResolve(GitHubReposInvokerConstants.listCommitsResponse); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.createComment("Content", "file.ts"); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), + ).once(); + verify( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).once(); + }); + + it("should throw when the link header does not match the expected format", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + when( + octokitWrapper.listCommits(anyString(), anyString(), anyNumber(), 1), + ).thenResolve({ + data: [], + headers: { + link: "non-matching", + }, + status: StatusCodes.OK, + url: "", + }); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.createComment("Content", "file.ts"); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "The regular expression did not match 'non-matching'.", + ); + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), + ).once(); + }); + + it("should succeed when a file name is specified and the method is called twice", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.createComment("Content", "file.ts"); + await gitHubReposInvoker.createComment("Content", "file.ts"); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), + ).once(); + verify( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).twice(); + }); + + it("should succeed when createReviewComment() returns null", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + when( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).thenResolve(null); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.createComment("Content", "file.ts"); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), + ).once(); + verify( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).once(); + }); + + { + const testCases: string[] = [ + "file.ts is too big", + "file.ts diff is too large", + ]; + + testCases.forEach((message: string): void => { + it(`should succeed when a HTTP 422 error occurs due to: '${message}'`, async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const errorMessage = `Validation Failed: {"resource":"PullRequestReviewComment","code":"custom","field":"pull_request_review_thread.diff_entry","message":"${message}"}`; + const error: RequestError = createRequestError( + StatusCodes.UNPROCESSABLE_ENTITY, + errorMessage, + ); + when( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).thenCall((): void => { + throw error; + }); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.createComment("Content", "file.ts"); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), + ).once(); + verify( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).once(); + verify( + logger.logInfo( + "GitHub createReviewComment() threw a 422 error related to a large diff. Ignoring as this is expected.", + ), + ).once(); + verify(logger.logErrorObject(error)).once(); + }); + }); + } + + { + const testCases: HttpError[] = [ + new HttpError( + StatusCodes.BAD_REQUEST, + 'Validation Failed: {"resource":"PullRequestReviewComment","code":"custom","field":"pull_request_review_thread.diff_entry","message":"file.ts is too big"}', + ), + new HttpError(StatusCodes.UNPROCESSABLE_ENTITY, "Unprocessable Entity"), + ]; + + testCases.forEach((error: HttpError): void => { + it("should throw when an error occurs that is not a HTTP 422 or is not due to having a too large path diff", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + when( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).thenCall((): void => { + throw error; + }); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.createComment("Content", "file.ts"); + + // Assert + await AssertExtensions.toThrowAsync(func, error.message); + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), + ).once(); + verify( + octokitWrapper.createReviewComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + "file.ts", + "sha54321", + ), + ).once(); + }); + }); + } + + it("should succeed when no file name is specified", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.createComment("Content", null); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.createIssueComment( + "microsoft", + "PR-Metrics", + 12345, + "Content", + ), + ).once(); + }); + }); +}); diff --git a/src/task/tests/repos/gitHubReposInvoker.deleteCommentThread.spec.ts b/src/task/tests/repos/gitHubReposInvoker.deleteCommentThread.spec.ts new file mode 100644 index 000000000..643eb12a6 --- /dev/null +++ b/src/task/tests/repos/gitHubReposInvoker.deleteCommentThread.spec.ts @@ -0,0 +1,63 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { + createGitHubReposInvokerMocks, + createSut, + expectedUserAgent, +} from "./gitHubReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Logger from "../../src/utilities/logger.js"; +import type { OctokitOptions } from "@octokit/core"; +import type OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { any } from "../testUtilities/mockito.js"; +import assert from "node:assert/strict"; + +describe("gitHubReposInvoker.ts", (): void => { + let gitInvoker: GitInvoker; + let logger: Logger; + let octokitWrapper: OctokitWrapper; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, logger, octokitWrapper, runnerInvoker } = + createGitHubReposInvokerMocks()); + }); + + describe("deleteCommentThread()", (): void => { + it("should succeed", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.deleteCommentThread(54321); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.deleteReviewComment("microsoft", "PR-Metrics", 54321), + ).once(); + }); + }); +}); diff --git a/src/task/tests/repos/gitHubReposInvoker.getComments.spec.ts b/src/task/tests/repos/gitHubReposInvoker.getComments.spec.ts new file mode 100644 index 000000000..7477031a3 --- /dev/null +++ b/src/task/tests/repos/gitHubReposInvoker.getComments.spec.ts @@ -0,0 +1,236 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as GitHubReposInvokerConstants from "./gitHubReposInvokerConstants.js"; +import { any, anyNumber, anyString } from "../testUtilities/mockito.js"; +import { + createGitHubReposInvokerMocks, + createSut, + expectedUserAgent, +} from "./gitHubReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type CommentData from "../../src/repos/interfaces/commentData.js"; +import { CommentThreadStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js"; +import type GetIssueCommentsResponse from "../../src/wrappers/octokitInterfaces/getIssueCommentsResponse.js"; +import type GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Logger from "../../src/utilities/logger.js"; +import type { OctokitOptions } from "@octokit/core"; +import type OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; + +describe("gitHubReposInvoker.ts", (): void => { + let gitInvoker: GitInvoker; + let logger: Logger; + let octokitWrapper: OctokitWrapper; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, logger, octokitWrapper, runnerInvoker } = + createGitHubReposInvokerMocks()); + }); + + describe("getComments()", (): void => { + it("should return the result when called with a pull request comment", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const response: GetIssueCommentsResponse = structuredClone( + GitHubReposInvokerConstants.getIssueCommentsResponse, + ); + if (typeof response.data[0] === "undefined") { + throw new Error("response.data[0] is undefined"); + } + + response.data[0].body = "PR Content"; + when( + octokitWrapper.getIssueComments(anyString(), anyString(), anyNumber()), + ).thenResolve(response); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: CommentData = await gitHubReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 1); + assert.equal(result.pullRequestComments[0]?.id, 1); + assert.equal(result.pullRequestComments[0].content, "PR Content"); + assert.equal( + result.pullRequestComments[0].status, + CommentThreadStatus.Unknown, + ); + assert.equal(result.fileComments.length, 0); + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.getIssueComments("microsoft", "PR-Metrics", 12345), + ).once(); + verify( + octokitWrapper.getReviewComments("microsoft", "PR-Metrics", 12345), + ).once(); + }); + + it("should return the result when called with a file comment", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + when( + octokitWrapper.getReviewComments(anyString(), anyString(), anyNumber()), + ).thenResolve(GitHubReposInvokerConstants.getReviewCommentsResponse); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: CommentData = await gitHubReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 0); + assert.equal(result.fileComments.length, 1); + assert.equal(result.fileComments[0]?.id, 2); + assert.equal(result.fileComments[0].content, "File Content"); + assert.equal(result.fileComments[0].status, CommentThreadStatus.Unknown); + assert.equal(result.fileComments[0].fileName, "file.ts"); + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.getIssueComments("microsoft", "PR-Metrics", 12345), + ).once(); + verify( + octokitWrapper.getReviewComments("microsoft", "PR-Metrics", 12345), + ).once(); + }); + + it("should return the result when called with both a pull request and file comment", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const response: GetIssueCommentsResponse = structuredClone( + GitHubReposInvokerConstants.getIssueCommentsResponse, + ); + if (typeof response.data[0] === "undefined") { + throw new Error("response.data[0] is undefined"); + } + + response.data[0].body = "PR Content"; + when( + octokitWrapper.getIssueComments(anyString(), anyString(), anyNumber()), + ).thenResolve(response); + when( + octokitWrapper.getReviewComments(anyString(), anyString(), anyNumber()), + ).thenResolve(GitHubReposInvokerConstants.getReviewCommentsResponse); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: CommentData = await gitHubReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 1); + assert.equal(result.pullRequestComments[0]?.id, 1); + assert.equal(result.pullRequestComments[0].content, "PR Content"); + assert.equal( + result.pullRequestComments[0].status, + CommentThreadStatus.Unknown, + ); + assert.equal(result.fileComments.length, 1); + assert.equal(result.fileComments[0]?.id, 2); + assert.equal(result.fileComments[0].content, "File Content"); + assert.equal(result.fileComments[0].status, CommentThreadStatus.Unknown); + assert.equal(result.fileComments[0].fileName, "file.ts"); + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.getIssueComments("microsoft", "PR-Metrics", 12345), + ).once(); + verify( + octokitWrapper.getReviewComments("microsoft", "PR-Metrics", 12345), + ).once(); + }); + + it("should skip pull request comments with no body", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const response: GetIssueCommentsResponse = structuredClone( + GitHubReposInvokerConstants.getIssueCommentsResponse, + ); + if (typeof response.data[0] === "undefined") { + throw new Error("response.data[0] is undefined"); + } + + response.data[0].body = undefined; + when( + octokitWrapper.getIssueComments(anyString(), anyString(), anyNumber()), + ).thenResolve(response); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: CommentData = await gitHubReposInvoker.getComments(); + + // Assert + assert.equal(result.pullRequestComments.length, 0); + assert.equal(result.fileComments.length, 0); + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.getIssueComments("microsoft", "PR-Metrics", 12345), + ).once(); + verify( + octokitWrapper.getReviewComments("microsoft", "PR-Metrics", 12345), + ).once(); + }); + }); +}); diff --git a/src/task/tests/repos/gitHubReposInvoker.getTitleAndDescription.spec.ts b/src/task/tests/repos/gitHubReposInvoker.getTitleAndDescription.spec.ts new file mode 100644 index 000000000..dd2f2d26d --- /dev/null +++ b/src/task/tests/repos/gitHubReposInvoker.getTitleAndDescription.spec.ts @@ -0,0 +1,573 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as AssertExtensions from "../testUtilities/assertExtensions.js"; +import * as GitHubReposInvokerConstants from "./gitHubReposInvokerConstants.js"; +import { any, anyNumber, anyString } from "../testUtilities/mockito.js"; +import { + createGitHubReposInvokerMocks, + createSut, + expectedUserAgent, +} from "./gitHubReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type ErrorWithStatusInterface from "../../src/repos/interfaces/errorWithStatusInterface.js"; +import type GetPullResponse from "../../src/wrappers/octokitInterfaces/getPullResponse.js"; +import type GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Logger from "../../src/utilities/logger.js"; +import type OctokitLogObjectInterface from "../wrappers/octokitLogObjectInterface.js"; +import type { OctokitOptions } from "@octokit/core"; +import type OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; +import type PullRequestDetailsInterface from "../../src/repos/interfaces/pullRequestDetailsInterface.js"; +import type { RequestError } from "@octokit/request-error"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { StatusCodes } from "http-status-codes"; +import assert from "node:assert/strict"; +import { createRequestError } from "../testUtilities/createRequestError.js"; +import { stubEnv } from "../testUtilities/stubEnv.js"; + +describe("gitHubReposInvoker.ts", (): void => { + let gitInvoker: GitInvoker; + let logger: Logger; + let octokitWrapper: OctokitWrapper; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, logger, octokitWrapper, runnerInvoker } = + createGitHubReposInvokerMocks()); + }); + + describe("getTitleAndDescription()", (): void => { + { + const testCases: (string | undefined)[] = [undefined, ""]; + + testCases.forEach((variable: string | undefined): void => { + it(`should throw when SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI is set to the invalid value '${String(variable)}' and the task is running on Azure Pipelines`, async (): Promise => { + // Arrange + if (typeof variable === "undefined") { + stubEnv(["SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", undefined]); + } else { + stubEnv(["SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", variable]); + } + + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `'SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI', accessed within 'GitHubReposInvoker.initializeForAzureDevOps()', is invalid, null, or undefined '${String(variable)}'.`, + ); + }); + }); + } + + it("should throw when SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI is set to an invalid URL and the task is running on Azure Pipelines", async (): Promise => { + // Arrange + stubEnv([ + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", + "https://github.com/microsoft", + ]); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI 'https://github.com/microsoft' is in an unexpected format.", + ); + }); + + it("should throw when SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI is set to a non-parseable URL and the task is running on Azure Pipelines", async (): Promise => { + // Arrange + stubEnv(["SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", "not-a-valid-url"]); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI 'not-a-valid-url' is in an unexpected format.", + ); + }); + + { + const testCases: (string | undefined)[] = [undefined, ""]; + + testCases.forEach((variable: string | undefined): void => { + it(`should throw when GITHUB_API_URL is set to the invalid value '${String(variable)}' and the task is running on GitHub`, async (): Promise => { + // Arrange + stubEnv(["PR_METRICS_ACCESS_TOKEN", undefined]); + stubEnv(["PR_METRICS_ACCESS_TOKEN", "PAT"]); + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + if (typeof variable === "undefined") { + stubEnv(["GITHUB_API_URL", undefined]); + } else { + stubEnv(["GITHUB_API_URL", variable]); + } + + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `'GITHUB_API_URL', accessed within 'GitHubReposInvoker.initializeForGitHub()', is invalid, null, or undefined '${String(variable)}'.`, + ); + }); + }); + } + + { + const testCases: (string | undefined)[] = [undefined, ""]; + + testCases.forEach((variable: string | undefined): void => { + it(`should throw when GITHUB_REPOSITORY_OWNER is set to the invalid value '${String(variable)}' and the task is running on GitHub`, async (): Promise => { + // Arrange + stubEnv(["PR_METRICS_ACCESS_TOKEN", undefined]); + stubEnv(["PR_METRICS_ACCESS_TOKEN", "PAT"]); + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_API_URL", "https://api.github.com"]); + if (typeof variable === "undefined") { + stubEnv(["GITHUB_REPOSITORY_OWNER", undefined]); + } else { + stubEnv(["GITHUB_REPOSITORY_OWNER", variable]); + } + + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `'GITHUB_REPOSITORY_OWNER', accessed within 'GitHubReposInvoker.initializeForGitHub()', is invalid, null, or undefined '${String(variable)}'.`, + ); + }); + }); + } + + { + const testCases: (string | undefined)[] = [undefined, ""]; + + testCases.forEach((variable: string | undefined): void => { + it(`should throw when GITHUB_REPOSITORY is set to the invalid value '${String(variable)}' and the task is running on GitHub`, async (): Promise => { + // Arrange + stubEnv(["PR_METRICS_ACCESS_TOKEN", undefined]); + stubEnv(["PR_METRICS_ACCESS_TOKEN", "PAT"]); + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_API_URL", "https://api.github.com"]); + stubEnv(["GITHUB_REPOSITORY_OWNER", "microsoft"]); + if (typeof variable === "undefined") { + stubEnv(["GITHUB_REPOSITORY", undefined]); + } else { + stubEnv(["GITHUB_REPOSITORY", variable]); + } + + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + `'GITHUB_REPOSITORY', accessed within 'GitHubReposInvoker.initializeForGitHub()', is invalid, null, or undefined '${String(variable)}'.`, + ); + }); + }); + } + + it("should throw when GITHUB_REPOSITORY is in an incorrect format and the task is running on GitHub", async (): Promise => { + // Arrange + stubEnv(["PR_METRICS_ACCESS_TOKEN", undefined]); + stubEnv(["PR_METRICS_ACCESS_TOKEN", "PAT"]); + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_API_URL", "https://api.github.com"]); + stubEnv(["GITHUB_REPOSITORY_OWNER", "microsoft"]); + stubEnv(["GITHUB_REPOSITORY", "microsoft"]); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync( + func, + "GITHUB_REPOSITORY 'microsoft' is in an unexpected format.", + ); + }); + + it("should succeed when the inputs are valid and the task is running on Azure Pipelines", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: PullRequestDetailsInterface = + await gitHubReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, "Description"); + verify(octokitWrapper.initialize(any())).once(); + verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); + }); + + it("should succeed when the inputs are valid and the task is running on GitHub", async (): Promise => { + // Arrange + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + stubEnv(["GITHUB_API_URL", "https://api.github.com"]); + stubEnv(["GITHUB_REPOSITORY_OWNER", "microsoft"]); + stubEnv(["GITHUB_REPOSITORY", "microsoft/PR-Metrics"]); + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: PullRequestDetailsInterface = + await gitHubReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, "Description"); + verify(octokitWrapper.initialize(any())).once(); + verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); + }); + + it("should succeed when the inputs are valid and the URL ends with '.git'", async (): Promise => { + // Arrange + stubEnv([ + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", + "https://github.com/microsoft/PR-Metrics.git", + ]); + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: PullRequestDetailsInterface = + await gitHubReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, "Description"); + verify(octokitWrapper.initialize(any())).once(); + verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); + }); + + it("should succeed when the inputs are valid and GitHub Enterprise is in use", async (): Promise => { + // Arrange + stubEnv([ + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", + "https://organization.githubenterprise.com/microsoft/PR-Metrics", + ]); + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.equal( + options.baseUrl, + "https://organization.githubenterprise.com/api/v3", + ); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: PullRequestDetailsInterface = + await gitHubReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, "Description"); + verify(octokitWrapper.initialize(any())).once(); + verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); + }); + + it("should succeed when called twice with the inputs valid", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.getTitleAndDescription(); + const result: PullRequestDetailsInterface = + await gitHubReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, "Description"); + verify(octokitWrapper.initialize(any())).once(); + verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).twice(); + }); + + it("should succeed when the description is null", async (): Promise => { + // Arrange + const currentMockPullResponse: GetPullResponse = + GitHubReposInvokerConstants.getPullResponse; + currentMockPullResponse.data.body = null; + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + when( + octokitWrapper.getPull(anyString(), anyString(), anyNumber()), + ).thenResolve(currentMockPullResponse); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: PullRequestDetailsInterface = + await gitHubReposInvoker.getTitleAndDescription(); + + // Assert + assert.equal(result.title, "Title"); + assert.equal(result.description, null); + verify(octokitWrapper.initialize(any())).once(); + verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); + }); + + { + const testCases: StatusCodes[] = [ + StatusCodes.UNAUTHORIZED, + StatusCodes.FORBIDDEN, + StatusCodes.NOT_FOUND, + ]; + + testCases.forEach((status: StatusCodes): void => { + it(`should throw when the PAT has insufficient access and the API call returns status '${String(status)}'`, async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const error: RequestError = createRequestError(status, "Test"); + when( + octokitWrapper.getPull(anyString(), anyString(), anyNumber()), + ).thenThrow(error); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + const expectedMessage: string = + status === StatusCodes.NOT_FOUND + ? "The resource could not be found. Verify the repository and pull request exist." + : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has Read and Write access to pull requests (or access to 'repos' if using a Classic PAT)."; + const result: ErrorWithStatusInterface = + await AssertExtensions.toThrowAsync(func, expectedMessage); + assert.equal(result.internalMessage, "Test"); + verify(octokitWrapper.initialize(any())).once(); + }); + }); + } + + it("should throw an error when an error occurs", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + when( + octokitWrapper.getPull(anyString(), anyString(), anyNumber()), + ).thenThrow(Error("Error")); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const func: () => Promise = async () => + gitHubReposInvoker.getTitleAndDescription(); + + // Assert + await AssertExtensions.toThrowAsync(func, "Error"); + verify(octokitWrapper.initialize(any())).once(); + }); + + it("should initialize log object correctly", async (): Promise => { + // Arrange + let logObject: OctokitLogObjectInterface | undefined; + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + logObject = options.log; + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + await gitHubReposInvoker.getTitleAndDescription(); + + // Act + logObject?.debug("Debug Message"); + logObject?.info("Info Message"); + logObject?.warn("Warning Message"); + logObject?.error("Error Message"); + + // Assert + verify(logger.logDebug("Octokit – Debug Message")).once(); + verify(logger.logInfo("Octokit – Info Message")).once(); + verify(logger.logWarning("Octokit – Warning Message")).once(); + verify(logger.logError("Octokit – Error Message")).once(); + }); + }); +}); diff --git a/src/task/tests/repos/gitHubReposInvoker.isAccessTokenAvailable.spec.ts b/src/task/tests/repos/gitHubReposInvoker.isAccessTokenAvailable.spec.ts new file mode 100644 index 000000000..16f132e21 --- /dev/null +++ b/src/task/tests/repos/gitHubReposInvoker.isAccessTokenAvailable.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { + createGitHubReposInvokerMocks, + createSut, +} from "./gitHubReposInvokerTestSetup.js"; +import type GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Logger from "../../src/utilities/logger.js"; +import type OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import assert from "node:assert/strict"; +import { localize } from "../testUtilities/stubLocalization.js"; +import { stubEnv } from "../testUtilities/stubEnv.js"; + +describe("gitHubReposInvoker.ts", (): void => { + let gitInvoker: GitInvoker; + let logger: Logger; + let octokitWrapper: OctokitWrapper; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, logger, octokitWrapper, runnerInvoker } = + createGitHubReposInvokerMocks()); + }); + + describe("isAccessTokenAvailable()", (): void => { + it("should return null when the token exists on Azure DevOps", async (): Promise => { + // Arrange + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: string | null = + await gitHubReposInvoker.isAccessTokenAvailable(); + + // Assert + assert.equal(result, null); + }); + + it("should return null when the token exists on GitHub", async (): Promise => { + // Arrange + stubEnv(["PR_METRICS_ACCESS_TOKEN", "PAT"]); + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: string | null = + await gitHubReposInvoker.isAccessTokenAvailable(); + + // Assert + assert.equal(result, null); + }); + + it("should return a string when the token does not exist", async (): Promise => { + // Arrange + stubEnv(["PR_METRICS_ACCESS_TOKEN", undefined]); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + const result: string | null = + await gitHubReposInvoker.isAccessTokenAvailable(); + + // Assert + assert.equal( + result, + localize("repos.gitHubReposInvoker.noGitHubAccessToken"), + ); + }); + }); +}); diff --git a/src/task/tests/repos/gitHubReposInvoker.setTitleAndDescription.spec.ts b/src/task/tests/repos/gitHubReposInvoker.setTitleAndDescription.spec.ts new file mode 100644 index 000000000..331d5b5f6 --- /dev/null +++ b/src/task/tests/repos/gitHubReposInvoker.setTitleAndDescription.spec.ts @@ -0,0 +1,160 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { + createGitHubReposInvokerMocks, + createSut, + expectedUserAgent, +} from "./gitHubReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Logger from "../../src/utilities/logger.js"; +import type { OctokitOptions } from "@octokit/core"; +import type OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { any } from "../testUtilities/mockito.js"; +import assert from "node:assert/strict"; + +describe("gitHubReposInvoker.ts", (): void => { + let gitInvoker: GitInvoker; + let logger: Logger; + let octokitWrapper: OctokitWrapper; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, logger, octokitWrapper, runnerInvoker } = + createGitHubReposInvokerMocks()); + }); + + describe("setTitleAndDescription()", (): void => { + it("should succeed when the title and description are both null", async (): Promise => { + // Arrange + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.setTitleAndDescription(null, null); + + // Assert + verify(octokitWrapper.initialize(any())).never(); + verify( + octokitWrapper.updatePull(any(), any(), any(), any(), any()), + ).never(); + }); + + it("should succeed when the title and description are both set", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.setTitleAndDescription("Title", "Description"); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.updatePull( + "microsoft", + "PR-Metrics", + 12345, + "Title", + "Description", + ), + ).once(); + }); + + it("should succeed when the title is set", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.setTitleAndDescription("Title", null); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.updatePull( + "microsoft", + "PR-Metrics", + 12345, + "Title", + null, + ), + ).once(); + }); + + it("should succeed when the description is set", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.setTitleAndDescription(null, "Description"); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.updatePull( + "microsoft", + "PR-Metrics", + 12345, + null, + "Description", + ), + ).once(); + }); + }); +}); diff --git a/src/task/tests/repos/gitHubReposInvoker.spec.ts b/src/task/tests/repos/gitHubReposInvoker.spec.ts deleted file mode 100644 index 6ab091025..000000000 --- a/src/task/tests/repos/gitHubReposInvoker.spec.ts +++ /dev/null @@ -1,1875 +0,0 @@ -/* - * Copyright (c) Microsoft Corporation. - * Licensed under the MIT License. - */ - -import * as AssertExtensions from "../testUtilities/assertExtensions.js"; -import * as GitHubReposInvokerConstants from "./gitHubReposInvokerConstants.js"; -import { any, anyNumber, anyString } from "../testUtilities/mockito.js"; -import { instance, mock, verify, when } from "ts-mockito"; -import type CommentData from "../../src/repos/interfaces/commentData.js"; -import { CommentThreadStatus } from "azure-devops-node-api/interfaces/GitInterfaces.js"; -import type ErrorWithStatusInterface from "../../src/repos/interfaces/errorWithStatusInterface.js"; -import type GetIssueCommentsResponse from "../../src/wrappers/octokitInterfaces/getIssueCommentsResponse.js"; -import type GetPullResponse from "../../src/wrappers/octokitInterfaces/getPullResponse.js"; -import GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; -import GitInvoker from "../../src/git/gitInvoker.js"; -import HttpError from "../testUtilities/httpError.js"; -import Logger from "../../src/utilities/logger.js"; -import type OctokitLogObjectInterface from "../wrappers/octokitLogObjectInterface.js"; -import type { OctokitOptions } from "@octokit/core"; -import OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; -import type PullRequestDetailsInterface from "../../src/repos/interfaces/pullRequestDetailsInterface.js"; -import type { RequestError } from "@octokit/request-error"; -import RunnerInvoker from "../../src/runners/runnerInvoker.js"; -import { StatusCodes } from "http-status-codes"; -import assert from "node:assert/strict"; -import { createRequestError } from "../testUtilities/createRequestError.js"; -import { userAgent } from "../../src/utilities/constants.js"; - -describe("gitHubReposInvoker.ts", (): void => { - let gitInvoker: GitInvoker; - let logger: Logger; - let octokitWrapper: OctokitWrapper; - let runnerInvoker: RunnerInvoker; - - beforeEach((): void => { - process.env.PR_METRICS_ACCESS_TOKEN = "PAT"; - process.env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI = - "https://github.com/microsoft/PR-Metrics"; - - gitInvoker = mock(GitInvoker); - when(gitInvoker.pullRequestId).thenReturn(12345); - - logger = mock(Logger); - - octokitWrapper = mock(OctokitWrapper); - when( - octokitWrapper.getPull(anyString(), anyString(), anyNumber()), - ).thenResolve(GitHubReposInvokerConstants.getPullResponse); - when( - octokitWrapper.updatePull( - anyString(), - anyString(), - anyNumber(), - anyString(), - anyString(), - ), - ).thenResolve(GitHubReposInvokerConstants.getPullResponse); - when( - octokitWrapper.listCommits( - anyString(), - anyString(), - anyNumber(), - anyNumber(), - ), - ).thenResolve(GitHubReposInvokerConstants.listCommitsResponse); - - runnerInvoker = mock(RunnerInvoker); - when( - runnerInvoker.loc( - "repos.gitHubReposInvoker.insufficientGitHubAccessTokenPermissions", - ), - ).thenReturn( - "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has Read and Write access to pull requests (or access to 'repos' if using a Classic PAT).", - ); - when( - runnerInvoker.loc("repos.gitHubReposInvoker.noGitHubAccessToken"), - ).thenReturn( - "Could not access the Personal Access Token (PAT). Add 'PR_Metrics_Access_Token' as a secret environment variable with Read and Write access to Pull Requests (or access to 'repos' if using a Classic PAT, or write access to 'pull-requests' and 'statuses' if specified within the workflow YAML).", - ); - when( - runnerInvoker.loc("repos.baseReposInvoker.resourceNotFound"), - ).thenReturn( - "The resource could not be found. Verify the repository and pull request exist.", - ); - }); - - afterEach((): void => { - delete process.env.PR_METRICS_ACCESS_TOKEN; - delete process.env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI; - }); - - describe("isAccessTokenAvailable()", (): void => { - it("should return null when the token exists on Azure DevOps", async (): Promise => { - // Arrange - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: string | null = - await gitHubReposInvoker.isAccessTokenAvailable(); - - // Assert - assert.equal(result, null); - verify( - logger.logDebug("* GitHubReposInvoker.isAccessTokenAvailable()"), - ).once(); - }); - - it("should return null when the token exists on GitHub", async (): Promise => { - // Arrange - process.env.PR_METRICS_ACCESS_TOKEN = "PAT"; - process.env.GITHUB_ACTION = "PR-Metrics"; - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: string | null = - await gitHubReposInvoker.isAccessTokenAvailable(); - - // Assert - assert.equal(result, null); - verify( - logger.logDebug("* GitHubReposInvoker.isAccessTokenAvailable()"), - ).once(); - - // Finalization - delete process.env.PR_METRICS_ACCESS_TOKEN; - delete process.env.GITHUB_ACTION; - }); - - it("should return a string when the token does not exist", async (): Promise => { - // Arrange - delete process.env.PR_METRICS_ACCESS_TOKEN; - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: string | null = - await gitHubReposInvoker.isAccessTokenAvailable(); - - // Assert - assert.equal( - result, - "Could not access the Personal Access Token (PAT). Add 'PR_Metrics_Access_Token' as a secret environment variable with Read and Write access to Pull Requests (or access to 'repos' if using a Classic PAT, or write access to 'pull-requests' and 'statuses' if specified within the workflow YAML).", - ); - verify( - logger.logDebug("* GitHubReposInvoker.isAccessTokenAvailable()"), - ).once(); - }); - }); - - describe("getTitleAndDescription()", (): void => { - { - const testCases: (string | undefined)[] = [undefined, ""]; - - testCases.forEach((variable: string | undefined): void => { - it(`should throw when SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI is set to the invalid value '${String(variable)}' and the task is running on Azure Pipelines`, async (): Promise => { - // Arrange - if (typeof variable === "undefined") { - delete process.env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI; - } else { - process.env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI = variable; - } - - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `'SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI', accessed within 'GitHubReposInvoker.initializeForAzureDevOps()', is invalid, null, or undefined '${String(variable)}'.`, - ); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - }); - }); - } - - it("should throw when SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI is set to a non-parseable URL and the task is running on Azure Pipelines", async (): Promise => { - // Arrange - process.env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI = "not-a-valid-url"; - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI 'not-a-valid-url' is in an unexpected format.", - ); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - }); - - it("should throw when SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI is set to a URL with insufficient path segments and the task is running on Azure Pipelines", async (): Promise => { - // Arrange - process.env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI = - "https://github.com/microsoft"; - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI 'https://github.com/microsoft' is in an unexpected format.", - ); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - }); - - { - const testCases: (string | undefined)[] = [undefined, ""]; - - testCases.forEach((variable: string | undefined): void => { - it(`should throw when GITHUB_API_URL is set to the invalid value '${String(variable)}' and the task is running on GitHub`, async (): Promise => { - // Arrange - delete process.env.PR_METRICS_ACCESS_TOKEN; - process.env.PR_METRICS_ACCESS_TOKEN = "PAT"; - process.env.GITHUB_ACTION = "PR-Metrics"; - if (typeof variable === "undefined") { - delete process.env.GITHUB_API_URL; - } else { - process.env.GITHUB_API_URL = variable; - } - - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `'GITHUB_API_URL', accessed within 'GitHubReposInvoker.initializeForGitHub()', is invalid, null, or undefined '${String(variable)}'.`, - ); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForGitHub()"), - ).once(); - - // Finalization - delete process.env.PR_METRICS_ACCESS_TOKEN; - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_API_URL; - }); - }); - } - - { - const testCases: (string | undefined)[] = [undefined, ""]; - - testCases.forEach((variable: string | undefined): void => { - it(`should throw when GITHUB_REPOSITORY_OWNER is set to the invalid value '${String(variable)}' and the task is running on GitHub`, async (): Promise => { - // Arrange - delete process.env.PR_METRICS_ACCESS_TOKEN; - process.env.PR_METRICS_ACCESS_TOKEN = "PAT"; - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_API_URL = "https://api.github.com"; - if (typeof variable === "undefined") { - delete process.env.GITHUB_REPOSITORY_OWNER; - } else { - process.env.GITHUB_REPOSITORY_OWNER = variable; - } - - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `'GITHUB_REPOSITORY_OWNER', accessed within 'GitHubReposInvoker.initializeForGitHub()', is invalid, null, or undefined '${String(variable)}'.`, - ); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForGitHub()"), - ).once(); - - // Finalization - delete process.env.PR_METRICS_ACCESS_TOKEN; - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_API_URL; - delete process.env.GITHUB_REPOSITORY_OWNER; - }); - }); - } - - { - const testCases: (string | undefined)[] = [undefined, ""]; - - testCases.forEach((variable: string | undefined): void => { - it(`should throw when GITHUB_REPOSITORY is set to the invalid value '${String(variable)}' and the task is running on GitHub`, async (): Promise => { - // Arrange - delete process.env.PR_METRICS_ACCESS_TOKEN; - process.env.PR_METRICS_ACCESS_TOKEN = "PAT"; - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_API_URL = "https://api.github.com"; - process.env.GITHUB_REPOSITORY_OWNER = "microsoft"; - if (typeof variable === "undefined") { - delete process.env.GITHUB_REPOSITORY; - } else { - process.env.GITHUB_REPOSITORY = variable; - } - - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - `'GITHUB_REPOSITORY', accessed within 'GitHubReposInvoker.initializeForGitHub()', is invalid, null, or undefined '${String(variable)}'.`, - ); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForGitHub()"), - ).once(); - - // Finalization - delete process.env.PR_METRICS_ACCESS_TOKEN; - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_API_URL; - delete process.env.GITHUB_REPOSITORY_OWNER; - delete process.env.GITHUB_REPOSITORY; - }); - }); - } - - it("should throw when GITHUB_REPOSITORY is in an incorrect format and the task is running on GitHub", async (): Promise => { - // Arrange - delete process.env.PR_METRICS_ACCESS_TOKEN; - process.env.PR_METRICS_ACCESS_TOKEN = "PAT"; - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_API_URL = "https://api.github.com"; - process.env.GITHUB_REPOSITORY_OWNER = "microsoft"; - process.env.GITHUB_REPOSITORY = "microsoft"; - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "GITHUB_REPOSITORY 'microsoft' is in an unexpected format.", - ); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForGitHub()"), - ).once(); - - // Finalization - delete process.env.PR_METRICS_ACCESS_TOKEN; - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_API_URL; - delete process.env.GITHUB_REPOSITORY_OWNER; - delete process.env.GITHUB_REPOSITORY; - }); - - it("should succeed when the inputs are valid and the task is running on Azure Pipelines", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: PullRequestDetailsInterface = - await gitHubReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, "Description"); - verify(octokitWrapper.initialize(any())).once(); - verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug( - JSON.stringify(GitHubReposInvokerConstants.getPullResponse), - ), - ).once(); - }); - - it("should succeed when the inputs are valid and the task is running on GitHub", async (): Promise => { - // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; - process.env.GITHUB_API_URL = "https://api.github.com"; - process.env.GITHUB_REPOSITORY_OWNER = "microsoft"; - process.env.GITHUB_REPOSITORY = "microsoft/PR-Metrics"; - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: PullRequestDetailsInterface = - await gitHubReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, "Description"); - verify(octokitWrapper.initialize(any())).once(); - verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForGitHub()"), - ).once(); - verify( - logger.logDebug( - JSON.stringify(GitHubReposInvokerConstants.getPullResponse), - ), - ).once(); - - // Finalization - delete process.env.GITHUB_ACTION; - delete process.env.GITHUB_API_URL; - delete process.env.GITHUB_REPOSITORY_OWNER; - delete process.env.GITHUB_REPOSITORY; - }); - - it("should succeed when the inputs are valid and the URL ends with '.git'", async (): Promise => { - // Arrange - process.env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI = - "https://github.com/microsoft/PR-Metrics.git"; - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: PullRequestDetailsInterface = - await gitHubReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, "Description"); - verify(octokitWrapper.initialize(any())).once(); - verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug( - JSON.stringify(GitHubReposInvokerConstants.getPullResponse), - ), - ).once(); - }); - - it("should succeed when the inputs are valid and GitHub Enterprise is in use", async (): Promise => { - // Arrange - process.env.SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI = - "https://organization.githubenterprise.com/microsoft/PR-Metrics"; - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.equal( - options.baseUrl, - "https://organization.githubenterprise.com/api/v3", - ); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: PullRequestDetailsInterface = - await gitHubReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, "Description"); - verify(octokitWrapper.initialize(any())).once(); - verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug( - "Using Base URL 'https://organization.githubenterprise.com/api/v3'.", - ), - ).once(); - verify( - logger.logDebug( - JSON.stringify(GitHubReposInvokerConstants.getPullResponse), - ), - ).once(); - }); - - it("should succeed when called twice with the inputs valid", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.getTitleAndDescription(); - const result: PullRequestDetailsInterface = - await gitHubReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, "Description"); - verify(octokitWrapper.initialize(any())).once(); - verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).twice(); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).twice(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).twice(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug( - JSON.stringify(GitHubReposInvokerConstants.getPullResponse), - ), - ).twice(); - }); - - it("should succeed when the description is null", async (): Promise => { - // Arrange - const currentMockPullResponse: GetPullResponse = - GitHubReposInvokerConstants.getPullResponse; - currentMockPullResponse.data.body = null; - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - when( - octokitWrapper.getPull(anyString(), anyString(), anyNumber()), - ).thenResolve(currentMockPullResponse); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: PullRequestDetailsInterface = - await gitHubReposInvoker.getTitleAndDescription(); - - // Assert - assert.equal(result.title, "Title"); - assert.equal(result.description, null); - verify(octokitWrapper.initialize(any())).once(); - verify(octokitWrapper.getPull("microsoft", "PR-Metrics", 12345)).once(); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug(JSON.stringify(currentMockPullResponse))).once(); - }); - - { - const testCases: StatusCodes[] = [ - StatusCodes.UNAUTHORIZED, - StatusCodes.FORBIDDEN, - StatusCodes.NOT_FOUND, - ]; - - testCases.forEach((status: StatusCodes): void => { - it(`should throw when the PAT has insufficient access and the API call returns status '${String(status)}'`, async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const error: RequestError = createRequestError(status, "Test"); - when( - octokitWrapper.getPull(anyString(), anyString(), anyNumber()), - ).thenThrow(error); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - const expectedMessage: string = - status === StatusCodes.NOT_FOUND - ? "The resource could not be found. Verify the repository and pull request exist." - : "Could not access the resources. Ensure the 'PR_Metrics_Access_Token' secret environment variable has Read and Write access to pull requests (or access to 'repos' if using a Classic PAT)."; - const result: ErrorWithStatusInterface = - await AssertExtensions.toThrowAsync(func, expectedMessage); - assert.equal(result.internalMessage, "Test"); - verify(octokitWrapper.initialize(any())).once(); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - }); - }); - } - - it("should throw an error when an error occurs", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - when( - octokitWrapper.getPull(anyString(), anyString(), anyNumber()), - ).thenThrow(Error("Error")); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.getTitleAndDescription(); - - // Assert - await AssertExtensions.toThrowAsync(func, "Error"); - verify(octokitWrapper.initialize(any())).once(); - verify( - logger.logDebug("* GitHubReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - }); - - it("should initialize log object correctly", async (): Promise => { - // Arrange - let logObject: OctokitLogObjectInterface | undefined; - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - logObject = options.log; - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - await gitHubReposInvoker.getTitleAndDescription(); - - // Act - logObject?.debug("Debug Message"); - logObject?.info("Info Message"); - logObject?.warn("Warning Message"); - logObject?.error("Error Message"); - - // Assert - verify(logger.logDebug("Octokit – Debug Message")).once(); - verify(logger.logInfo("Octokit – Info Message")).once(); - verify(logger.logWarning("Octokit – Warning Message")).once(); - verify(logger.logError("Octokit – Error Message")).once(); - }); - }); - - describe("getComments()", (): void => { - it("should return the result when called with a pull request comment", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const response: GetIssueCommentsResponse = - GitHubReposInvokerConstants.getIssueCommentsResponse; - if (typeof response.data[0] === "undefined") { - throw new Error("response.data[0] is undefined"); - } - - response.data[0].body = "PR Content"; - when( - octokitWrapper.getIssueComments(anyString(), anyString(), anyNumber()), - ).thenResolve(response); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: CommentData = await gitHubReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 1); - assert.equal(result.pullRequestComments[0]?.id, 1); - assert.equal(result.pullRequestComments[0].content, "PR Content"); - assert.equal( - result.pullRequestComments[0].status, - CommentThreadStatus.Unknown, - ); - assert.equal(result.fileComments.length, 0); - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.getIssueComments("microsoft", "PR-Metrics", 12345), - ).once(); - verify( - octokitWrapper.getReviewComments("microsoft", "PR-Metrics", 12345), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getComments()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.convertPullRequestComments()"), - ).once(); - verify(logger.logDebug(JSON.stringify(response))).once(); - }); - - it("should return the result when called with a file comment", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - when( - octokitWrapper.getReviewComments(anyString(), anyString(), anyNumber()), - ).thenResolve(GitHubReposInvokerConstants.getReviewCommentsResponse); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: CommentData = await gitHubReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 0); - assert.equal(result.fileComments.length, 1); - assert.equal(result.fileComments[0]?.id, 2); - assert.equal(result.fileComments[0].content, "File Content"); - assert.equal(result.fileComments[0].status, CommentThreadStatus.Unknown); - assert.equal(result.fileComments[0].fileName, "file.ts"); - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.getIssueComments("microsoft", "PR-Metrics", 12345), - ).once(); - verify( - octokitWrapper.getReviewComments("microsoft", "PR-Metrics", 12345), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getComments()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.convertPullRequestComments()"), - ).once(); - verify( - logger.logDebug( - JSON.stringify(GitHubReposInvokerConstants.getReviewCommentsResponse), - ), - ).once(); - }); - - it("should return the result when called with both a pull request and file comment", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const response: GetIssueCommentsResponse = - GitHubReposInvokerConstants.getIssueCommentsResponse; - if (typeof response.data[0] === "undefined") { - throw new Error("response.data[0] is undefined"); - } - - response.data[0].body = "PR Content"; - when( - octokitWrapper.getIssueComments(anyString(), anyString(), anyNumber()), - ).thenResolve(response); - when( - octokitWrapper.getReviewComments(anyString(), anyString(), anyNumber()), - ).thenResolve(GitHubReposInvokerConstants.getReviewCommentsResponse); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: CommentData = await gitHubReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 1); - assert.equal(result.pullRequestComments[0]?.id, 1); - assert.equal(result.pullRequestComments[0].content, "PR Content"); - assert.equal( - result.pullRequestComments[0].status, - CommentThreadStatus.Unknown, - ); - assert.equal(result.fileComments.length, 1); - assert.equal(result.fileComments[0]?.id, 2); - assert.equal(result.fileComments[0].content, "File Content"); - assert.equal(result.fileComments[0].status, CommentThreadStatus.Unknown); - assert.equal(result.fileComments[0].fileName, "file.ts"); - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.getIssueComments("microsoft", "PR-Metrics", 12345), - ).once(); - verify( - octokitWrapper.getReviewComments("microsoft", "PR-Metrics", 12345), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getComments()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.convertPullRequestComments()"), - ).once(); - verify(logger.logDebug(JSON.stringify(response))).once(); - verify( - logger.logDebug( - JSON.stringify(GitHubReposInvokerConstants.getReviewCommentsResponse), - ), - ).once(); - }); - - it("should skip pull request comments with no body", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const response: GetIssueCommentsResponse = - GitHubReposInvokerConstants.getIssueCommentsResponse; - if (typeof response.data[0] === "undefined") { - throw new Error("response.data[0] is undefined"); - } - - response.data[0].body = undefined; - when( - octokitWrapper.getIssueComments(anyString(), anyString(), anyNumber()), - ).thenResolve(response); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const result: CommentData = await gitHubReposInvoker.getComments(); - - // Assert - assert.equal(result.pullRequestComments.length, 0); - assert.equal(result.fileComments.length, 0); - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.getIssueComments("microsoft", "PR-Metrics", 12345), - ).once(); - verify( - octokitWrapper.getReviewComments("microsoft", "PR-Metrics", 12345), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getComments()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.convertPullRequestComments()"), - ).once(); - verify(logger.logDebug(JSON.stringify(response))).once(); - }); - }); - - describe("setTitleAndDescription()", (): void => { - it("should succeed when the title and description are both null", async (): Promise => { - // Arrange - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.setTitleAndDescription(null, null); - - // Assert - verify( - logger.logDebug("* GitHubReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).never(); - }); - - it("should succeed when the title and description are both set", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.setTitleAndDescription("Title", "Description"); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.updatePull( - "microsoft", - "PR-Metrics", - 12345, - "Title", - "Description", - ), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify( - logger.logDebug( - JSON.stringify(GitHubReposInvokerConstants.getPullResponse), - ), - ).once(); - }); - - it("should succeed when the title is set", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.setTitleAndDescription("Title", null); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.updatePull( - "microsoft", - "PR-Metrics", - 12345, - "Title", - null, - ), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("null")).once(); - }); - - it("should succeed when the description is set", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.setTitleAndDescription(null, "Description"); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.updatePull( - "microsoft", - "PR-Metrics", - 12345, - null, - "Description", - ), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("null")).once(); - }); - }); - - describe("createComment()", (): void => { - it("should succeed when a file name is specified", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.createComment("Content", "file.ts"); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), - ).once(); - verify( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.createComment()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getCommitId()")).once(); - verify(logger.logDebug("null")).once(); - }); - - it("should throw when the commit list is empty", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - when( - octokitWrapper.listCommits( - anyString(), - anyString(), - anyNumber(), - anyNumber(), - ), - ).thenResolve({ - data: [], - headers: {}, - status: StatusCodes.OK, - url: "", - }); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.createComment("Content", "file.ts"); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "'result.data[-1].sha', accessed within 'GitHubReposInvoker.getCommitId()', is invalid, null, or undefined 'undefined'.", - ); - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.createComment()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getCommitId()")).once(); - }); - - it("should succeed when there are multiple pages of commits", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - when( - octokitWrapper.listCommits(anyString(), anyString(), anyNumber(), 1), - ).thenResolve({ - data: [], - headers: { - link: '; rel="next", ; rel="last"', - }, - status: StatusCodes.OK, - url: "", - }); - when( - octokitWrapper.listCommits(anyString(), anyString(), anyNumber(), 24), - ).thenResolve(GitHubReposInvokerConstants.listCommitsResponse); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.createComment("Content", "file.ts"); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), - ).once(); - verify( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.createComment()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getCommitId()")).once(); - verify(logger.logDebug("null")).once(); - }); - - it("should throw when the link header does not match the expected format", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - when( - octokitWrapper.listCommits(anyString(), anyString(), anyNumber(), 1), - ).thenResolve({ - data: [], - headers: { - link: "non-matching", - }, - status: StatusCodes.OK, - url: "", - }); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.createComment("Content", "file.ts"); - - // Assert - await AssertExtensions.toThrowAsync( - func, - "The regular expression did not match 'non-matching'.", - ); - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.createComment()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getCommitId()")).once(); - }); - - it("should succeed when a file name is specified and the method is called twice", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.createComment("Content", "file.ts"); - await gitHubReposInvoker.createComment("Content", "file.ts"); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), - ).once(); - verify( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).twice(); - verify(logger.logDebug("* GitHubReposInvoker.createComment()")).twice(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).twice(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getCommitId()")).once(); - verify(logger.logDebug("null")).twice(); - }); - - it("should succeed when createReviewComment() returns undefined", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - when( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).thenResolve(null); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.createComment("Content", "file.ts"); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), - ).once(); - verify( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.createComment()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getCommitId()")).once(); - verify(logger.logDebug("null")).once(); - }); - - { - const testCases: string[] = [ - "file.ts is too big", - "file.ts diff is too large", - ]; - - testCases.forEach((message: string): void => { - it(`should succeed when a HTTP 422 error occurs due to: '${message}'`, async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const errorMessage = `Validation Failed: {"resource":"PullRequestReviewComment","code":"custom","field":"pull_request_review_thread.diff_entry","message":"${message}"}`; - const error: RequestError = createRequestError( - StatusCodes.UNPROCESSABLE_ENTITY, - errorMessage, - ); - when( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).thenCall((): void => { - throw error; - }); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.createComment("Content", "file.ts"); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), - ).once(); - verify( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.createComment()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getCommitId()")).once(); - verify( - logger.logInfo( - "GitHub createReviewComment() threw a 422 error related to a large diff. Ignoring as this is expected.", - ), - ).once(); - verify(logger.logErrorObject(error)).once(); - }); - }); - } - - { - const testCases: HttpError[] = [ - new HttpError( - StatusCodes.BAD_REQUEST, - 'Validation Failed: {"resource":"PullRequestReviewComment","code":"custom","field":"pull_request_review_thread.diff_entry","message":"file.ts is too big"}', - ), - new HttpError(StatusCodes.UNPROCESSABLE_ENTITY, "Unprocessable Entity"), - ]; - - testCases.forEach((error: HttpError): void => { - it("should throw when an error occurs that is not a HTTP 422 or is not due to having a too large path diff", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - when( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).thenCall((): void => { - throw error; - }); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - const func: () => Promise = async () => - gitHubReposInvoker.createComment("Content", "file.ts"); - - // Assert - await AssertExtensions.toThrowAsync(func, error.message); - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.listCommits("microsoft", "PR-Metrics", 12345, 1), - ).once(); - verify( - octokitWrapper.createReviewComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - "file.ts", - "sha54321", - ), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.createComment()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.getCommitId()")).once(); - }); - }); - } - - it("should succeed when no file name is specified", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.createComment("Content", null); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.createIssueComment( - "microsoft", - "PR-Metrics", - 12345, - "Content", - ), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.createComment()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("null")).once(); - }); - }); - - describe("updateComment()", (): void => { - it("should succeed when the content is null", async (): Promise => { - // Arrange - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.updateComment(54321, null); - - // Assert - verify(logger.logDebug("* GitHubReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).never(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).never(); - }); - - it("should succeed when the content is set", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.updateComment(54321, "Content"); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.updateIssueComment( - "microsoft", - "PR-Metrics", - 12345, - 54321, - "Content", - ), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("null")).once(); - }); - }); - - describe("deleteCommentThread()", (): void => { - it("should succeed", async (): Promise => { - // Arrange - when(octokitWrapper.initialize(any())).thenCall( - (options: OctokitOptions): void => { - assert.equal(options.auth, "PAT"); - assert.equal(options.userAgent, userAgent); - assert.notEqual(options.log, null); - assert.notEqual(options.log?.debug, null); - assert.notEqual(options.log?.info, null); - assert.notEqual(options.log?.warn, null); - assert.notEqual(options.log?.error, null); - }, - ); - const gitHubReposInvoker: GitHubReposInvoker = new GitHubReposInvoker( - instance(gitInvoker), - instance(logger), - instance(octokitWrapper), - instance(runnerInvoker), - ); - - // Act - await gitHubReposInvoker.deleteCommentThread(54321); - - // Assert - verify(octokitWrapper.initialize(any())).once(); - verify( - octokitWrapper.deleteReviewComment("microsoft", "PR-Metrics", 54321), - ).once(); - verify( - logger.logDebug("* GitHubReposInvoker.deleteCommentThread()"), - ).once(); - verify(logger.logDebug("* GitHubReposInvoker.initialize()")).once(); - verify( - logger.logDebug("* GitHubReposInvoker.initializeForAzureDevOps()"), - ).once(); - verify(logger.logDebug("null")).once(); - }); - }); -}); diff --git a/src/task/tests/repos/gitHubReposInvoker.updateComment.spec.ts b/src/task/tests/repos/gitHubReposInvoker.updateComment.spec.ts new file mode 100644 index 000000000..e2a48dc52 --- /dev/null +++ b/src/task/tests/repos/gitHubReposInvoker.updateComment.spec.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import { + createGitHubReposInvokerMocks, + createSut, + expectedUserAgent, +} from "./gitHubReposInvokerTestSetup.js"; +import { verify, when } from "ts-mockito"; +import type GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; +import type GitInvoker from "../../src/git/gitInvoker.js"; +import type Logger from "../../src/utilities/logger.js"; +import type { OctokitOptions } from "@octokit/core"; +import type OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { any } from "../testUtilities/mockito.js"; +import assert from "node:assert/strict"; + +describe("gitHubReposInvoker.ts", (): void => { + let gitInvoker: GitInvoker; + let logger: Logger; + let octokitWrapper: OctokitWrapper; + let runnerInvoker: RunnerInvoker; + + beforeEach((): void => { + ({ gitInvoker, logger, octokitWrapper, runnerInvoker } = + createGitHubReposInvokerMocks()); + }); + + describe("updateComment()", (): void => { + it("should succeed when the content is null", async (): Promise => { + // Arrange + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.updateComment(54321, null); + + // Assert + verify(octokitWrapper.initialize(any())).never(); + verify( + octokitWrapper.updateIssueComment(any(), any(), any(), any(), any()), + ).never(); + }); + + it("should succeed when the content is set", async (): Promise => { + // Arrange + when(octokitWrapper.initialize(any())).thenCall( + (options: OctokitOptions): void => { + assert.equal(options.auth, "PAT"); + assert.equal(options.userAgent, expectedUserAgent); + assert.notEqual(options.log, null); + assert.notEqual(options.log?.debug, null); + assert.notEqual(options.log?.info, null); + assert.notEqual(options.log?.warn, null); + assert.notEqual(options.log?.error, null); + }, + ); + const gitHubReposInvoker: GitHubReposInvoker = createSut( + gitInvoker, + logger, + octokitWrapper, + runnerInvoker, + ); + + // Act + await gitHubReposInvoker.updateComment(54321, "Content"); + + // Assert + verify(octokitWrapper.initialize(any())).once(); + verify( + octokitWrapper.updateIssueComment( + "microsoft", + "PR-Metrics", + 12345, + 54321, + "Content", + ), + ).once(); + }); + }); +}); diff --git a/src/task/tests/repos/gitHubReposInvokerTestSetup.ts b/src/task/tests/repos/gitHubReposInvokerTestSetup.ts new file mode 100644 index 000000000..099315939 --- /dev/null +++ b/src/task/tests/repos/gitHubReposInvokerTestSetup.ts @@ -0,0 +1,95 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as GitHubReposInvokerConstants from "./gitHubReposInvokerConstants.js"; +import { anyNumber, anyString } from "../testUtilities/mockito.js"; +import { instance, mock, when } from "ts-mockito"; +import GitHubReposInvoker from "../../src/repos/gitHubReposInvoker.js"; +import GitInvoker from "../../src/git/gitInvoker.js"; +import Logger from "../../src/utilities/logger.js"; +import OctokitWrapper from "../../src/wrappers/octokitWrapper.js"; +import RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { stubEnv } from "../testUtilities/stubEnv.js"; +import { stubLocalization } from "../testUtilities/stubLocalization.js"; +import { userAgent } from "../../src/utilities/constants.js"; + +export const expectedUserAgent = userAgent; + +export interface GitHubReposInvokerMocks { + gitInvoker: GitInvoker; + logger: Logger; + octokitWrapper: OctokitWrapper; + runnerInvoker: RunnerInvoker; +} + +/** + * Creates the mocks and environment variable stubs required by + * `gitHubReposInvoker.ts` tests. Individual tests can override any stub + * after calling this helper. + * @returns The paired mocks. + */ +export const createGitHubReposInvokerMocks = (): GitHubReposInvokerMocks => { + stubEnv( + ["PR_METRICS_ACCESS_TOKEN", "PAT"], + [ + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", + "https://github.com/microsoft/PR-Metrics", + ], + ); + + const pullRequestId = 12345; + const gitInvoker: GitInvoker = mock(GitInvoker); + when(gitInvoker.pullRequestId).thenReturn(pullRequestId); + + const logger: Logger = mock(Logger); + + const octokitWrapper: OctokitWrapper = mock(OctokitWrapper); + when( + octokitWrapper.getPull(anyString(), anyString(), anyNumber()), + ).thenResolve(GitHubReposInvokerConstants.getPullResponse); + when( + octokitWrapper.updatePull( + anyString(), + anyString(), + anyNumber(), + anyString(), + anyString(), + ), + ).thenResolve(GitHubReposInvokerConstants.getPullResponse); + when( + octokitWrapper.listCommits( + anyString(), + anyString(), + anyNumber(), + anyNumber(), + ), + ).thenResolve(GitHubReposInvokerConstants.listCommitsResponse); + + const runnerInvoker: RunnerInvoker = mock(RunnerInvoker); + stubLocalization(runnerInvoker); + + return { gitInvoker, logger, octokitWrapper, runnerInvoker }; +}; + +/** + * Constructs a `GitHubReposInvoker` instance from the supplied mocks. + * @param gitInvoker The mocked git invoker. + * @param logger The mocked logger. + * @param octokitWrapper The mocked octokit wrapper. + * @param runnerInvoker The mocked runner invoker. + * @returns The constructed `GitHubReposInvoker` instance. + */ +export const createSut = ( + gitInvoker: GitInvoker, + logger: Logger, + octokitWrapper: OctokitWrapper, + runnerInvoker: RunnerInvoker, +): GitHubReposInvoker => + new GitHubReposInvoker( + instance(gitInvoker), + instance(logger), + instance(octokitWrapper), + instance(runnerInvoker), + ); diff --git a/src/task/tests/repos/reposInvoker.spec.ts b/src/task/tests/repos/reposInvoker.spec.ts index d06bda47d..fc4378e62 100644 --- a/src/task/tests/repos/reposInvoker.spec.ts +++ b/src/task/tests/repos/reposInvoker.spec.ts @@ -13,6 +13,7 @@ import Logger from "../../src/utilities/logger.js"; import type PullRequestDetailsInterface from "../../src/repos/interfaces/pullRequestDetailsInterface.js"; import ReposInvoker from "../../src/repos/reposInvoker.js"; import assert from "node:assert/strict"; +import { stubEnv } from "../testUtilities/stubEnv.js"; describe("reposInvoker.ts", (): void => { let azureReposInvoker: AzureReposInvoker; @@ -28,7 +29,7 @@ describe("reposInvoker.ts", (): void => { describe("isAccessTokenAvailable()", (): void => { it("should invoke Azure Repos when called from an appropriate repo", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -41,17 +42,12 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.isAccessTokenAvailable()).once(); verify(gitHubReposInvoker.isAccessTokenAvailable()).never(); - verify(logger.logDebug("* ReposInvoker.isAccessTokenAvailable()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); it("should invoke Azure Repos when called from an appropriate repo twice", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -67,20 +63,13 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.isAccessTokenAvailable()).twice(); verify(gitHubReposInvoker.isAccessTokenAvailable()).never(); - verify( - logger.logDebug("* ReposInvoker.isAccessTokenAvailable()"), - ).twice(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).twice(); assert.equal(result1, null); assert.equal(result2, null); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); it("should invoke GitHub when called from a GitHub runner", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -93,12 +82,7 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.isAccessTokenAvailable()).never(); verify(gitHubReposInvoker.isAccessTokenAvailable()).once(); - verify(logger.logDebug("* ReposInvoker.isAccessTokenAvailable()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.GITHUB_ACTION; }); { @@ -107,7 +91,7 @@ describe("reposInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should invoke GitHub when called from a repo on '${buildRepositoryProvider}'`, async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -121,21 +105,14 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.isAccessTokenAvailable()).never(); verify(gitHubReposInvoker.isAccessTokenAvailable()).once(); - verify( - logger.logDebug("* ReposInvoker.isAccessTokenAvailable()"), - ).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw when the repo type is not set", async (): Promise => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -153,13 +130,11 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.isAccessTokenAvailable()).never(); verify(gitHubReposInvoker.isAccessTokenAvailable()).never(); - verify(logger.logDebug("* ReposInvoker.isAccessTokenAvailable()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); }); it("should throw when the repo type is set to an invalid value", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "Other"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "Other"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -177,18 +152,13 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.isAccessTokenAvailable()).never(); verify(gitHubReposInvoker.isAccessTokenAvailable()).never(); - verify(logger.logDebug("* ReposInvoker.isAccessTokenAvailable()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); describe("getTitleAndDescription()", (): void => { it("should invoke Azure Repos when called from an appropriate repo", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -202,17 +172,12 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.getTitleAndDescription()).once(); verify(gitHubReposInvoker.getTitleAndDescription()).never(); - verify(logger.logDebug("* ReposInvoker.getTitleAndDescription()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); it("should invoke GitHub when called from a GitHub runner", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -226,12 +191,7 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.getTitleAndDescription()).never(); verify(gitHubReposInvoker.getTitleAndDescription()).once(); - verify(logger.logDebug("* ReposInvoker.getTitleAndDescription()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.GITHUB_ACTION; }); { @@ -240,7 +200,7 @@ describe("reposInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should invoke GitHub when called from a repo on '${buildRepositoryProvider}'`, async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -254,21 +214,14 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.getTitleAndDescription()).never(); verify(gitHubReposInvoker.getTitleAndDescription()).once(); - verify( - logger.logDebug("* ReposInvoker.getTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw when the repo type is not set", async (): Promise => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -286,13 +239,11 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.getTitleAndDescription()).never(); verify(gitHubReposInvoker.getTitleAndDescription()).never(); - verify(logger.logDebug("* ReposInvoker.getTitleAndDescription()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); }); it("should throw when the repo type is set to an invalid value", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "Other"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "Other"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -310,18 +261,13 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.getTitleAndDescription()).never(); verify(gitHubReposInvoker.getTitleAndDescription()).never(); - verify(logger.logDebug("* ReposInvoker.getTitleAndDescription()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); describe("getComments()", (): void => { it("should invoke Azure Repos when called from an appropriate repo", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -334,17 +280,12 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.getComments()).once(); verify(gitHubReposInvoker.getComments()).never(); - verify(logger.logDebug("* ReposInvoker.getComments()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); it("should invoke GitHub when called from a GitHub runner", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -357,12 +298,7 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.getComments()).never(); verify(gitHubReposInvoker.getComments()).once(); - verify(logger.logDebug("* ReposInvoker.getComments()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.GITHUB_ACTION; }); { @@ -371,7 +307,7 @@ describe("reposInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should invoke GitHub when called from a repo on '${buildRepositoryProvider}'`, async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -384,19 +320,14 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.getComments()).never(); verify(gitHubReposInvoker.getComments()).once(); - verify(logger.logDebug("* ReposInvoker.getComments()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); assert.equal(result, null); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw when the repo type is not set", async (): Promise => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -414,13 +345,11 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.getComments()).never(); verify(gitHubReposInvoker.getComments()).never(); - verify(logger.logDebug("* ReposInvoker.getComments()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); }); it("should throw when the repo type is set to an invalid value", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "Other"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "Other"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -438,18 +367,13 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.getComments()).never(); verify(gitHubReposInvoker.getComments()).never(); - verify(logger.logDebug("* ReposInvoker.getComments()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); describe("setTitleAndDescription()", (): void => { it("should invoke Azure Repos when called from an appropriate repo", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -462,16 +386,11 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.setTitleAndDescription(null, null)).once(); verify(gitHubReposInvoker.setTitleAndDescription(null, null)).never(); - verify(logger.logDebug("* ReposInvoker.setTitleAndDescription()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); it("should invoke GitHub when called from a GitHub runner", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -484,11 +403,6 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.setTitleAndDescription(null, null)).never(); verify(gitHubReposInvoker.setTitleAndDescription(null, null)).once(); - verify(logger.logDebug("* ReposInvoker.setTitleAndDescription()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); { @@ -497,7 +411,7 @@ describe("reposInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should invoke GitHub when called from a repo on '${buildRepositoryProvider}'`, async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -510,20 +424,13 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.setTitleAndDescription(null, null)).never(); verify(gitHubReposInvoker.setTitleAndDescription(null, null)).once(); - verify( - logger.logDebug("* ReposInvoker.setTitleAndDescription()"), - ).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw when the repo type is not set", async (): Promise => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -541,13 +448,11 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.setTitleAndDescription(null, null)).never(); verify(gitHubReposInvoker.setTitleAndDescription(null, null)).never(); - verify(logger.logDebug("* ReposInvoker.setTitleAndDescription()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); }); it("should throw when the repo type is set to an invalid value", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "Other"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "Other"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -565,18 +470,13 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.setTitleAndDescription(null, null)).never(); verify(gitHubReposInvoker.setTitleAndDescription(null, null)).never(); - verify(logger.logDebug("* ReposInvoker.setTitleAndDescription()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); describe("createComment()", (): void => { it("should invoke Azure Repos when called from an appropriate repo", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -609,16 +509,11 @@ describe("reposInvoker.ts", (): void => { false, ), ).never(); - verify(logger.logDebug("* ReposInvoker.createComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); it("should invoke GitHub when called from a GitHub runner", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -651,11 +546,6 @@ describe("reposInvoker.ts", (): void => { false, ), ).once(); - verify(logger.logDebug("* ReposInvoker.createComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); { @@ -664,7 +554,7 @@ describe("reposInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should invoke GitHub when called from a repo on '${buildRepositoryProvider}'`, async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -697,18 +587,13 @@ describe("reposInvoker.ts", (): void => { false, ), ).once(); - verify(logger.logDebug("* ReposInvoker.createComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw when the repo type is not set", async (): Promise => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -741,13 +626,11 @@ describe("reposInvoker.ts", (): void => { false, ), ).never(); - verify(logger.logDebug("* ReposInvoker.createComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); }); it("should throw when the repo type is set to an invalid value", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "Other"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "Other"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -780,18 +663,13 @@ describe("reposInvoker.ts", (): void => { false, ), ).never(); - verify(logger.logDebug("* ReposInvoker.createComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); describe("updateComment()", (): void => { it("should invoke Azure Repos when called from an appropriate repo", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -805,16 +683,11 @@ describe("reposInvoker.ts", (): void => { verify(azureReposInvoker.updateComment(0, null, null)).once(); // @ts-expect-error -- Interface is called with additional parameters not present in implementation. verify(gitHubReposInvoker.updateComment(0, null, null)).never(); - verify(logger.logDebug("* ReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); it("should invoke GitHub when called from a GitHub runner", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -828,11 +701,6 @@ describe("reposInvoker.ts", (): void => { verify(azureReposInvoker.updateComment(0, null, null)).never(); // @ts-expect-error -- Interface is called with additional parameters not present in implementation. verify(gitHubReposInvoker.updateComment(0, null, null)).once(); - verify(logger.logDebug("* ReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); { @@ -841,7 +709,7 @@ describe("reposInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should invoke GitHub when called from a repo on '${buildRepositoryProvider}'`, async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -855,18 +723,13 @@ describe("reposInvoker.ts", (): void => { verify(azureReposInvoker.updateComment(0, null, null)).never(); // @ts-expect-error -- Interface is called with additional parameters not present in implementation. verify(gitHubReposInvoker.updateComment(0, null, null)).once(); - verify(logger.logDebug("* ReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw when the repo type is not set", async (): Promise => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -885,13 +748,11 @@ describe("reposInvoker.ts", (): void => { verify(azureReposInvoker.updateComment(0, null, null)).never(); // @ts-expect-error -- Interface is called with additional parameters not present in implementation. verify(gitHubReposInvoker.updateComment(0, null, null)).never(); - verify(logger.logDebug("* ReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); }); it("should throw when the repo type is set to an invalid value", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "Other"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "Other"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -910,18 +771,13 @@ describe("reposInvoker.ts", (): void => { verify(azureReposInvoker.updateComment(0, null, null)).never(); // @ts-expect-error -- Interface is called with additional parameters not present in implementation. verify(gitHubReposInvoker.updateComment(0, null, null)).never(); - verify(logger.logDebug("* ReposInvoker.updateComment()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); describe("deleteCommentThread()", (): void => { it("should invoke Azure Repos when called from an appropriate repo", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "TfsGit"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "TfsGit"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -934,16 +790,11 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.deleteCommentThread(20)).once(); verify(gitHubReposInvoker.deleteCommentThread(20)).never(); - verify(logger.logDebug("* ReposInvoker.deleteCommentThread()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); it("should invoke GitHub when called from a GitHub runner", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -956,11 +807,6 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.deleteCommentThread(20)).never(); verify(gitHubReposInvoker.deleteCommentThread(20)).once(); - verify(logger.logDebug("* ReposInvoker.deleteCommentThread()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); { @@ -969,7 +815,7 @@ describe("reposInvoker.ts", (): void => { testCases.forEach((buildRepositoryProvider: string): void => { it(`should invoke GitHub when called from a repo on '${buildRepositoryProvider}'`, async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = buildRepositoryProvider; + stubEnv(["BUILD_REPOSITORY_PROVIDER", buildRepositoryProvider]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -982,20 +828,13 @@ describe("reposInvoker.ts", (): void => { // Assert verify(azureReposInvoker.deleteCommentThread(20)).never(); verify(gitHubReposInvoker.deleteCommentThread(20)).once(); - verify( - logger.logDebug("* ReposInvoker.deleteCommentThread()"), - ).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); } it("should throw when the repo type is not set", async (): Promise => { // Arrange - delete process.env.BUILD_REPOSITORY_PROVIDER; + stubEnv(["BUILD_REPOSITORY_PROVIDER", undefined]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -1013,13 +852,11 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.deleteCommentThread(20)).never(); verify(gitHubReposInvoker.deleteCommentThread(20)).never(); - verify(logger.logDebug("* ReposInvoker.deleteCommentThread()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); }); it("should throw when the repo type is set to an invalid value", async (): Promise => { // Arrange - process.env.BUILD_REPOSITORY_PROVIDER = "Other"; + stubEnv(["BUILD_REPOSITORY_PROVIDER", "Other"]); const reposInvoker: ReposInvoker = new ReposInvoker( instance(azureReposInvoker), instance(gitHubReposInvoker), @@ -1037,11 +874,6 @@ describe("reposInvoker.ts", (): void => { ); verify(azureReposInvoker.deleteCommentThread(20)).never(); verify(gitHubReposInvoker.deleteCommentThread(20)).never(); - verify(logger.logDebug("* ReposInvoker.deleteCommentThread()")).once(); - verify(logger.logDebug("* ReposInvoker.getReposInvoker()")).once(); - - // Finalization - delete process.env.BUILD_REPOSITORY_PROVIDER; }); }); }); diff --git a/src/task/tests/repos/tokenManager.spec.ts b/src/task/tests/repos/tokenManager.spec.ts index 553ec326b..31bd5154e 100644 --- a/src/task/tests/repos/tokenManager.spec.ts +++ b/src/task/tests/repos/tokenManager.spec.ts @@ -5,6 +5,10 @@ import * as AssertExtensions from "../testUtilities/assertExtensions.js"; import { deepEqual, instance, mock, verify, when } from "ts-mockito"; +import { + localize, + stubLocalization, +} from "../testUtilities/stubLocalization.js"; import AzureDevOpsApiWrapper from "../../src/wrappers/azureDevOpsApiWrapper.js"; import type { EndpointAuthorization } from "azure-pipelines-task-lib"; import type { IRequestHandler } from "azure-devops-node-api/interfaces/common/VsoBaseInterfaces.js"; @@ -15,6 +19,7 @@ import TokenManager from "../../src/repos/tokenManager.js"; import { WebApi } from "azure-devops-node-api"; import assert from "node:assert/strict"; import { resolvableInstance } from "../testUtilities/resolvableInstance.js"; +import { stubEnv } from "../testUtilities/stubEnv.js"; describe("tokenManager.ts", (): void => { let taskApi: ITaskApi; @@ -27,11 +32,14 @@ describe("tokenManager.ts", (): void => { const tenantId = "98765432-abcd-ef01-2345-678901234567"; beforeEach((): void => { - process.env.SYSTEM_COLLECTIONURI = "https://dev.azure.com/organization"; - process.env.SYSTEM_TEAMPROJECTID = "TeamProjectId"; - process.env.SYSTEM_HOSTTYPE = "HostType"; - process.env.SYSTEM_PLANID = "PlanId"; - process.env.SYSTEM_JOBID = "JobId"; + stubEnv( + ["PR_METRICS_ACCESS_TOKEN", undefined], + ["SYSTEM_COLLECTIONURI", "https://dev.azure.com/organization"], + ["SYSTEM_HOSTTYPE", "HostType"], + ["SYSTEM_JOBID", "JobId"], + ["SYSTEM_PLANID", "PlanId"], + ["SYSTEM_TEAMPROJECTID", "TeamProjectId"], + ); taskApi = mock(); const requestHandler: IRequestHandler = mock(); @@ -64,6 +72,7 @@ describe("tokenManager.ts", (): void => { logger = mock(Logger); runnerInvoker = mock(RunnerInvoker); + stubLocalization(runnerInvoker); when( runnerInvoker.getInput(deepEqual(["Workload", "Identity", "Federation"])), ).thenReturn("Id"); @@ -129,14 +138,6 @@ describe("tokenManager.ts", (): void => { }); }); - after(() => { - delete process.env.SYSTEM_COLLECTIONURI; - delete process.env.SYSTEM_TEAMPROJECTID; - delete process.env.SYSTEM_HOSTTYPE; - delete process.env.SYSTEM_PLANID; - delete process.env.SYSTEM_JOBID; - }); - describe("getToken()", (): void => { it("returns null when no workload identity federation is specified", async (): Promise => { // Arrange @@ -156,12 +157,6 @@ describe("tokenManager.ts", (): void => { // Assert assert.equal(result, null); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify( - logger.logDebug( - "No workload identity federation specified. Using Personal Access Token (PAT) for authentication.", - ), - ).once(); }); it("returns a string indicating that the authorization scheme is invalid", async (): Promise => { @@ -174,27 +169,19 @@ describe("tokenManager.ts", (): void => { when(runnerInvoker.getEndpointAuthorizationScheme("Id")).thenReturn( "Other", ); - when( - runnerInvoker.loc( - "repos.tokenManager.incorrectAuthorizationScheme", - "WorkloadIdentityFederation", - "Other", - ), - ).thenReturn( - "Authorization scheme of workload identity federation 'Id' must be 'WorkloadIdentityFederation' instead of 'Other'.", - ); // Act const result: string | null = await tokenManager.getToken(); // Assert - assert.equal(result, null); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify( - logger.logDebug( - "Using workload identity federation 'Id' for authentication.", + assert.equal( + result, + localize( + "repos.tokenManager.incorrectAuthorizationScheme", + "Id", + "Other", ), - ).once(); + ); }); it("throws an error when the service principal ID is null", async (): Promise => { @@ -220,8 +207,6 @@ describe("tokenManager.ts", (): void => { func, "'servicePrincipalId', accessed within 'TokenManager.getAccessToken()', is invalid, null, or undefined 'null'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); }); it("throws an error when the tenant ID is null", async (): Promise => { @@ -244,8 +229,6 @@ describe("tokenManager.ts", (): void => { func, "'tenantId', accessed within 'TokenManager.getAccessToken()', is invalid, null, or undefined 'null'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); }); it("throws an error when the service principal ID is not a valid GUID", async (): Promise => { @@ -271,8 +254,6 @@ describe("tokenManager.ts", (): void => { func, "'servicePrincipalId', accessed within 'TokenManager.getAccessToken()', is not a valid GUID 'NotAGuid'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); }); it("throws an error when the tenant ID is not a valid GUID", async (): Promise => { @@ -295,8 +276,6 @@ describe("tokenManager.ts", (): void => { func, "'tenantId', accessed within 'TokenManager.getAccessToken()', is not a valid GUID 'NotAGuid'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); }); { @@ -332,14 +311,6 @@ describe("tokenManager.ts", (): void => { func, `Could not acquire authorization token from workload identity federation as the scheme was '${endpointAuthorization?.scheme ?? ""}'.`, ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify( - logger.logDebug("* TokenManager.getFederatedToken()"), - ).once(); - verify( - logger.logDebug("* TokenManager.getSystemAccessToken()"), - ).once(); }); }, ); @@ -371,20 +342,11 @@ describe("tokenManager.ts", (): void => { func, "'endpointAuthorization.parameters.AccessToken', accessed within 'TokenManager.getSystemAccessToken()', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); }); it("throws an error when the collection URI is undefined", async (): Promise => { // Arrange - delete process.env.SYSTEM_COLLECTIONURI; + stubEnv(["SYSTEM_COLLECTIONURI", undefined]); const tokenManager: TokenManager = new TokenManager( instance(azureDevOpsApiWrapper), instance(logger), @@ -400,20 +362,11 @@ describe("tokenManager.ts", (): void => { func, "'SYSTEM_COLLECTIONURI', accessed within 'TokenManager.getFederatedToken()', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); }); it("throws an error when the team project URI is undefined", async (): Promise => { // Arrange - delete process.env.SYSTEM_TEAMPROJECTID; + stubEnv(["SYSTEM_TEAMPROJECTID", undefined]); const tokenManager: TokenManager = new TokenManager( instance(azureDevOpsApiWrapper), instance(logger), @@ -429,20 +382,11 @@ describe("tokenManager.ts", (): void => { func, "'SYSTEM_TEAMPROJECTID', accessed within 'TokenManager.getFederatedToken()', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); }); it("throws an error when the host type is undefined", async (): Promise => { // Arrange - delete process.env.SYSTEM_HOSTTYPE; + stubEnv(["SYSTEM_HOSTTYPE", undefined]); const tokenManager: TokenManager = new TokenManager( instance(azureDevOpsApiWrapper), instance(logger), @@ -458,20 +402,11 @@ describe("tokenManager.ts", (): void => { func, "'SYSTEM_HOSTTYPE', accessed within 'TokenManager.getFederatedToken()', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); }); it("throws an error when the plan ID is undefined", async (): Promise => { // Arrange - delete process.env.SYSTEM_PLANID; + stubEnv(["SYSTEM_PLANID", undefined]); const tokenManager: TokenManager = new TokenManager( instance(azureDevOpsApiWrapper), instance(logger), @@ -487,20 +422,11 @@ describe("tokenManager.ts", (): void => { func, "'SYSTEM_PLANID', accessed within 'TokenManager.getFederatedToken()', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); }); it("throws an error when the job ID is undefined", async (): Promise => { // Arrange - delete process.env.SYSTEM_JOBID; + stubEnv(["SYSTEM_JOBID", undefined]); const tokenManager: TokenManager = new TokenManager( instance(azureDevOpsApiWrapper), instance(logger), @@ -516,15 +442,6 @@ describe("tokenManager.ts", (): void => { func, "'SYSTEM_JOBID', accessed within 'TokenManager.getFederatedToken()', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); }); it("throws an error when the OIDC token is undefined", async (): Promise => { @@ -554,15 +471,6 @@ describe("tokenManager.ts", (): void => { func, "'response.oidcToken', accessed within 'TokenManager.getFederatedToken()', is invalid, null, or undefined 'undefined'.", ); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); }); it("throws an error when Azure sign in fails", async (): Promise => { @@ -599,15 +507,6 @@ describe("tokenManager.ts", (): void => { // Assert await AssertExtensions.toThrowAsync(func, "Error Message"); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); verify(runnerInvoker.setSecret("OidcToken")).once(); }); @@ -644,15 +543,6 @@ describe("tokenManager.ts", (): void => { // Assert await AssertExtensions.toThrowAsync(func, "Error Message"); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); verify(runnerInvoker.setSecret("OidcToken")).once(); }); @@ -670,15 +560,6 @@ describe("tokenManager.ts", (): void => { // Assert assert.equal(result, null); assert.equal(process.env.PR_METRICS_ACCESS_TOKEN, "AccessToken"); - verify(logger.logDebug("* TokenManager.getToken()")).once(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); verify(runnerInvoker.setSecret("OidcToken")).once(); verify(runnerInvoker.setSecret("AccessToken")).once(); }); @@ -699,15 +580,6 @@ describe("tokenManager.ts", (): void => { assert.equal(result1, null); assert.equal(result2, null); assert.equal(process.env.PR_METRICS_ACCESS_TOKEN, "AccessToken"); - verify(logger.logDebug("* TokenManager.getToken()")).twice(); - verify(logger.logDebug("* TokenManager.getAccessToken()")).once(); - verify(logger.logDebug("* TokenManager.getFederatedToken()")).once(); - verify(logger.logDebug("* TokenManager.getSystemAccessToken()")).once(); - verify( - logger.logDebug( - "Acquired authorization token from workload identity federation.", - ), - ).once(); verify(runnerInvoker.setSecret("OidcToken")).once(); verify(runnerInvoker.setSecret("AccessToken")).once(); }); diff --git a/src/task/tests/runners/runnerInvoker.spec.ts b/src/task/tests/runners/runnerInvoker.spec.ts index ba6d30758..505b3424b 100644 --- a/src/task/tests/runners/runnerInvoker.spec.ts +++ b/src/task/tests/runners/runnerInvoker.spec.ts @@ -10,6 +10,7 @@ import type ExecOutput from "../../src/runners/execOutput.js"; import GitHubRunnerInvoker from "../../src/runners/gitHubRunnerInvoker.js"; import RunnerInvoker from "../../src/runners/runnerInvoker.js"; import assert from "node:assert/strict"; +import { stubEnv } from "../testUtilities/stubEnv.js"; describe("runnerInvoker.ts", (): void => { let azurePipelinesRunnerInvoker: AzurePipelinesRunnerInvoker; @@ -31,16 +32,13 @@ describe("runnerInvoker.ts", (): void => { it("should return true when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); // Act const result: boolean = RunnerInvoker.isGitHub; // Assert assert.equal(result, true); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -86,7 +84,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -119,14 +117,11 @@ describe("runnerInvoker.ts", (): void => { verify( gitHubRunnerInvoker.exec("TOOL", deepEqual(["Argument1", "Argument2"])), ).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); it("should call the underlying method each time when running on GitHub", async (): Promise => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -166,9 +161,6 @@ describe("runnerInvoker.ts", (): void => { verify( gitHubRunnerInvoker.exec("TOOL", deepEqual(["Argument1", "Argument2"])), ).twice(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -198,7 +190,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -218,9 +210,6 @@ describe("runnerInvoker.ts", (): void => { verify( gitHubRunnerInvoker.getInput(deepEqual(["Test", "Suffix"])), ).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -254,7 +243,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -281,9 +270,6 @@ describe("runnerInvoker.ts", (): void => { ).never(); // @ts-expect-error -- Interface is called with additional parameters not present in implementation. verify(gitHubRunnerInvoker.getEndpointAuthorization("id")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -313,7 +299,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -334,9 +320,6 @@ describe("runnerInvoker.ts", (): void => { ).never(); // @ts-expect-error -- Interface is called with additional parameters not present in implementation. verify(gitHubRunnerInvoker.getEndpointAuthorizationScheme("id")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -374,7 +357,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -400,9 +383,6 @@ describe("runnerInvoker.ts", (): void => { // @ts-expect-error -- Interface is called with additional parameters not present in implementation. gitHubRunnerInvoker.getEndpointAuthorizationParameter("id", "key"), ).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -424,7 +404,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -436,9 +416,6 @@ describe("runnerInvoker.ts", (): void => { // Assert verify(azurePipelinesRunnerInvoker.locInitialize("TEST")).never(); verify(gitHubRunnerInvoker.locInitialize("TEST")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); it("should throw when locInitialize is called twice", (): void => { @@ -502,7 +479,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -517,9 +494,6 @@ describe("runnerInvoker.ts", (): void => { assert.equal(result, "VALUE"); verify(azurePipelinesRunnerInvoker.loc("TEST")).never(); verify(gitHubRunnerInvoker.loc("TEST")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -541,7 +515,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -553,9 +527,6 @@ describe("runnerInvoker.ts", (): void => { // Assert verify(azurePipelinesRunnerInvoker.logDebug("TEST")).never(); verify(gitHubRunnerInvoker.logDebug("TEST")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -577,7 +548,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -589,9 +560,6 @@ describe("runnerInvoker.ts", (): void => { // Assert verify(azurePipelinesRunnerInvoker.logError("TEST")).never(); verify(gitHubRunnerInvoker.logError("TEST")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -613,7 +581,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -625,9 +593,6 @@ describe("runnerInvoker.ts", (): void => { // Assert verify(azurePipelinesRunnerInvoker.logWarning("TEST")).never(); verify(gitHubRunnerInvoker.logWarning("TEST")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -649,7 +614,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -661,9 +626,6 @@ describe("runnerInvoker.ts", (): void => { // Assert verify(azurePipelinesRunnerInvoker.setStatusFailed("TEST")).never(); verify(gitHubRunnerInvoker.setStatusFailed("TEST")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -685,7 +647,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -697,9 +659,6 @@ describe("runnerInvoker.ts", (): void => { // Assert verify(azurePipelinesRunnerInvoker.setStatusSkipped("TEST")).never(); verify(gitHubRunnerInvoker.setStatusSkipped("TEST")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -721,7 +680,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -733,9 +692,6 @@ describe("runnerInvoker.ts", (): void => { // Assert verify(azurePipelinesRunnerInvoker.setStatusSucceeded("TEST")).never(); verify(gitHubRunnerInvoker.setStatusSucceeded("TEST")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); @@ -757,7 +713,7 @@ describe("runnerInvoker.ts", (): void => { it("should call the underlying method when running on GitHub", (): void => { // Arrange - process.env.GITHUB_ACTION = "PR-Metrics"; + stubEnv(["GITHUB_ACTION", "PR-Metrics"]); const runnerInvoker: RunnerInvoker = new RunnerInvoker( instance(azurePipelinesRunnerInvoker), instance(gitHubRunnerInvoker), @@ -769,9 +725,6 @@ describe("runnerInvoker.ts", (): void => { // Assert verify(azurePipelinesRunnerInvoker.setSecret("id")).never(); verify(gitHubRunnerInvoker.setSecret("id")).once(); - - // Finalization - delete process.env.GITHUB_ACTION; }); }); }); diff --git a/src/task/tests/testUtilities/fixtures/invalidInputs.ts b/src/task/tests/testUtilities/fixtures/invalidInputs.ts new file mode 100644 index 000000000..6d5430d40 --- /dev/null +++ b/src/task/tests/testUtilities/fixtures/invalidInputs.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +/** + * Strings that should fail to parse as a positive numeric value. Shared by + * tests of the numeric `Inputs` fields (base size, growth rate, test + * factor) to cover the "fall back to the default" branch. Tests that also + * want to cover the `Infinity` literal can spread this list and append + * `"Infinity"`. + */ +export const invalidNumericStrings: readonly (string | null)[] = [ + null, + "", + " ", + "abc", + "===", + "!2", + "null", + "undefined", +]; + +/** + * Strings that reduce to an empty value after whitespace handling. Shared + * by tests of the pattern-style `Inputs` fields (file matching patterns, + * test matching patterns, code file extensions) to cover the "fall back + * to the default" branch. + */ +export const invalidPatternStrings: readonly (string | null)[] = [ + null, + "", + " ", + " ", + "\n", +]; diff --git a/src/task/tests/testUtilities/stubEnv.ts b/src/task/tests/testUtilities/stubEnv.ts new file mode 100644 index 000000000..79fdc6a06 --- /dev/null +++ b/src/task/tests/testUtilities/stubEnv.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +interface PendingChange { + key: string; + original: string | undefined; +} + +const pending: PendingChange[] = []; + +/** + * Environment variables that tests expect to be unset unless explicitly + * stubbed. Clearing them at suite start prevents values inherited from the + * host environment (notably GitHub Actions' auto-populated `GITHUB_*` vars) + * from being restored mid-suite and leaking into unrelated tests. + */ +const managedEnvVars: readonly string[] = [ + "BUILD_REPOSITORY_ID", + "BUILD_REPOSITORY_PROVIDER", + "GITHUB_ACTION", + "GITHUB_API_URL", + "GITHUB_BASE_REF", + "GITHUB_REF", + "GITHUB_REPOSITORY", + "GITHUB_REPOSITORY_OWNER", + "PR_METRICS_ACCESS_TOKEN", + "SYSTEM_COLLECTIONURI", + "SYSTEM_HOSTTYPE", + "SYSTEM_JOBID", + "SYSTEM_PLANID", + "SYSTEM_PULLREQUEST_PULLREQUESTID", + "SYSTEM_PULLREQUEST_PULLREQUESTNUMBER", + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", + "SYSTEM_PULLREQUEST_TARGETBRANCH", + "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", + "SYSTEM_TEAMPROJECT", + "SYSTEM_TEAMPROJECTID", +]; + +const unset = (key: string): void => { + Reflect.deleteProperty(process.env, key); +}; + +before((): void => { + for (const key of managedEnvVars) { + unset(key); + } +}); + +/** + * Sets one or more environment variables for the duration of the current test. + * Any previous value is captured and restored automatically by a global + * `afterEach` hook once the test completes. + * + * Pass `undefined` as a value to temporarily unset a variable. The helper + * composes correctly with `process.env` assignments from `beforeEach` blocks, + * restoring the state that existed before the `stubEnv` call. + * + * Tuple arguments are used so that POSIX-style uppercase environment variable + * names can be expressed without triggering the camelCase naming convention + * applied to object literal properties. + * @param entries One or more `[name, value]` tuples. + */ +export const stubEnv = ( + ...entries: (readonly [string, string | undefined])[] +): void => { + for (const [key, value] of entries) { + pending.push({ key, original: process.env[key] }); + if (typeof value === "undefined") { + unset(key); + } else { + process.env[key] = value; + } + } +}; + +afterEach((): void => { + while (pending.length > 0) { + const entry: PendingChange | undefined = pending.pop(); + if (typeof entry === "undefined") { + break; + } + + if (typeof entry.original === "undefined") { + unset(entry.key); + } else { + process.env[entry.key] = entry.original; + } + } +}); diff --git a/src/task/tests/testUtilities/stubLocalization.ts b/src/task/tests/testUtilities/stubLocalization.ts new file mode 100644 index 000000000..d3e56029b --- /dev/null +++ b/src/task/tests/testUtilities/stubLocalization.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT License. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as util from "node:util"; +import type ResourcesJsonInterface from "../../src/jsonTypes/resourcesJsonInterface.js"; +import type RunnerInvoker from "../../src/runners/runnerInvoker.js"; +import { anyString } from "./mockito.js"; +import { when } from "ts-mockito"; + +const resourcePrefix = "loc.messages."; + +let cachedResources: Map | null = null; + +const loadResources = (): Map => { + if (cachedResources !== null) { + return cachedResources; + } + + const resourcesPath: string = path.join( + import.meta.dirname, + "..", + "..", + "Strings", + "resources.resjson", + "en-US", + "resources.resjson", + ); + const raw: string = fs.readFileSync(resourcesPath, "utf8"); + const json: ResourcesJsonInterface = JSON.parse( + raw, + ) as ResourcesJsonInterface; + + const map: Map = new Map(); + for (const [key, value] of Object.entries(json)) { + if (key.startsWith(resourcePrefix)) { + map.set(key.substring(resourcePrefix.length), value); + } + } + cachedResources = map; + return map; +}; + +/** + * Resolves a localization key against the real `resources.resjson` file and + * applies parameter substitution via `util.format`. Tests use this to compute + * expected `logger` assertions without duplicating English text from the + * resource file. + * @param key The localization key (without the `loc.messages.` prefix). + * @param params The values to substitute into the template. + * @returns The formatted string. + */ +export const localize = (key: string, ...params: string[]): string => { + const template: string | undefined = loadResources().get(key); + if (typeof template === "undefined") { + throw new Error(`Unknown localization key: '${key}'.`); + } + + return params.length > 0 ? util.format(template, ...params) : template; +}; + +/** + * Wires the `loc()` method on a mocked `RunnerInvoker` to return values read + * from the real `resources.resjson` file, with parameter substitution via + * `util.format`. + * @param runnerInvoker The mocked runner invoker. + */ +export const stubLocalization = (runnerInvoker: RunnerInvoker): void => { + when(runnerInvoker.loc(anyString())).thenCall(localize); + when(runnerInvoker.loc(anyString(), anyString())).thenCall(localize); + when(runnerInvoker.loc(anyString(), anyString(), anyString())).thenCall( + localize, + ); + when( + runnerInvoker.loc(anyString(), anyString(), anyString(), anyString()), + ).thenCall(localize); +}; diff --git a/src/task/tests/utilities/validator.spec.ts b/src/task/tests/utilities/validator.spec.ts index 0c86c8b72..9ef241ece 100644 --- a/src/task/tests/utilities/validator.spec.ts +++ b/src/task/tests/utilities/validator.spec.ts @@ -5,6 +5,7 @@ import * as Validator from "../../src/utilities/validator.js"; import assert from "node:assert/strict"; +import { stubEnv } from "../testUtilities/stubEnv.js"; describe("validator.ts", (): void => { describe("validateString()", (): void => { @@ -52,11 +53,7 @@ describe("validator.ts", (): void => { testCases.forEach((value: string | undefined): void => { it(`should throw an error when passed invalid string value '${String(value)}'`, (): void => { // Arrange - if (typeof value === "undefined") { - delete process.env.TEST_VARIABLE; - } else { - process.env.TEST_VARIABLE = value; - } + stubEnv(["TEST_VARIABLE", value]); // Act const func: () => void = () => @@ -72,16 +69,13 @@ describe("validator.ts", (): void => { `'TEST_VARIABLE', accessed within 'string test method name', is invalid, null, or undefined '${String(value)}'.`, ), ); - - // Finalization - delete process.env.TEST_VARIABLE; }); }); } it("should not throw an error when passed a valid string value", (): void => { // Arrange - process.env.TEST_VARIABLE = "value"; + stubEnv(["TEST_VARIABLE", "value"]); // Act const result: string = Validator.validateVariable( @@ -91,9 +85,6 @@ describe("validator.ts", (): void => { // Assert assert.equal(result, "value"); - - // Finalization - delete process.env.TEST_VARIABLE; }); });