diff --git a/doc/common/Typespec-Project-Scripts.md b/doc/common/TypeSpec-Project-Scripts.md similarity index 85% rename from doc/common/Typespec-Project-Scripts.md rename to doc/common/TypeSpec-Project-Scripts.md index d13385227e9..20cb50b12ec 100644 --- a/doc/common/Typespec-Project-Scripts.md +++ b/doc/common/TypeSpec-Project-Scripts.md @@ -6,15 +6,16 @@ repo and use the remote typespec definition in the spec repo. ## One time language repo setup There are 3 things that these two scripts expect are set up in your language repo before they will run correctly. + 1. Make sure your .gitignore is ignoring the TempTypeSpecFiles 2. Create a common emitter-package.json for your language 3. Write the language specific hooks in Language-Settings.ps1 ### TempTypeSpecFiles -You should add a new entry in your .gitignore for your repo so that none of these files are accidentally checked in if [cleanup](#cleanup-anchor) is turned off. +You should add a new entry in your .gitignore for your repo so that none of these files are accidentally checked in if `-SaveInputs` flag is passed in. -``` +```text # .gitignore file TempTypeSpecFiles/ ``` @@ -35,7 +36,11 @@ Example } ``` -Note that cadl compile currently requires the "main" line to be there. +Note that tsp compile currently requires the "main" line to be there. + +### Emitter additionalProperties + +The `-SaveInputs` flag will get forwarded to your emitter as `--option @azure-tools/typespec-csharp.save-inputs=true`. If your emitter or generator creates any temporary files similar to CodeModel.yaml and Configuration.json from autorest then you should honor this flag and not delete those files. If your emitter does not does not have any of these files you can ignore this flag but be sure you have [additionalProperties set to true](https://github.com/Azure/autorest.java/blob/main/typespec-extension/src/emitter.ts#L41) or have added `save-inputs` into your schema. ### Language-Settings.ps1 @@ -92,7 +97,6 @@ This file should live under the project directory for each service and has the f | additionalDirectories | Sometimes a typespec file will use a relative import that might not be under the main directory. In this case a single `directory` will not be enough to pull down all necessary files. To support this you can specify additional directories as a list to sync so that all needed files are synced. | false: default = null | | commit | The commit sha for the version of the typespec files you want to generate off of. This allows us to have idempotence on generation until we opt into pointing at a later version. | true | | repo | The repo this spec lives in. This should be either `Azure/azure-rest-api-specs` or `Azure/azure-rest-api-specs-pr`. Note that pr will work locally but not in CI until we add another change to handle token based auth. | true | -| cleanup | This will remove the TempTypeSpecFiles directory after generation is complete if true otherwise this directory will be left to support local changes to the files to see how different changes would affect the generation. | false: default = true | Example @@ -117,7 +121,7 @@ This script will create a `sparse-spec` folder as a sibling to the root of your As an example if you have your language repo at `D:\git\azure-sdk-for-net` there will be a new directory `D:\git\sparse-spec\Azure.AI.OpenAI` where the sparse spec will live. -This is then copied over to your project directory so that you can make temporary changes if needed. The location will be `./{projectDir}/TempTypeSpecFiles`. This temporary directory will be [cleaned up](#cleanup-anchor) at the end of the generate script if set in the tsp-location.yaml. +This is then copied over to your project directory so that you can make temporary changes if needed. The location will be `./{projectDir}/TempTypeSpecFiles`. This temporary directory will be cleaned up at the end of the generate script unless the -SaveInputs flag is passed into the generate script. ## TypeSpec-Project-Generate.ps1 @@ -127,6 +131,8 @@ This is the second script that should be called and can be found at `./eng/commo ./eng/common/scripts/TypeSpec-Project-Generate.ps1 ./sdk/openai/Azure.AI.OpenAI ``` +This script takes an optional `-SaveInputs` flag which acts similar to the old `save-inputs: true` configuration in autorest. It will not delete the `TempTypeSpecFiles` folder and it will forward this flag to your emitter in case your emitter creates any additional intermediate files. For example in dotnet a cadl.json and configuration.json file will be created as an intermediate step between the emitter and the generator and these will not be cleaned up if this flag is passed in. + The first thing this does is clean up the npm install that might exist in `./{projectDir}/TempTypeSpecFiles`, followed by replacing the package.json with the language static one. Once this is done it will run `npm install` followed by `tsp compile` which is the standard way to generate a typespec project. diff --git a/eng/common/pipelines/templates/steps/docs-metadata-release.yml b/eng/common/pipelines/templates/steps/docs-metadata-release.yml deleted file mode 100644 index 7b6fb183a54..00000000000 --- a/eng/common/pipelines/templates/steps/docs-metadata-release.yml +++ /dev/null @@ -1,119 +0,0 @@ -# intended to be used as part of a release process -parameters: - - name: ArtifactLocation - type: string - default: 'not-specified' - - name: PackageRepository - type: string - default: 'not-specified' - - name: ReleaseSha - type: string - default: 'not-specified' - - name: RepoId - type: string - default: $(Build.Repository.Name) - - name: WorkingDirectory - type: string - default: '' - - name: ScriptDirectory - type: string - default: eng/common/scripts - - name: TargetDocRepoName - type: string - default: '' - - name: TargetDocRepoOwner - type: string - default: '' - - name: PRBranchName - type: string - default: 'main-rdme' - - name: PRLabels - type: string - default: 'auto-merge' - - name: ArtifactName - type: string - default: '' - - name: Language - type: string - default: '' - - name: DocRepoDestinationPath - type: string - default: '' #usually docs-ref-services/ - - name: CIConfigs - type: string - default: '[]' - - name: GHReviewersVariable - type: string - default: '' - - name: GHTeamReviewersVariable - type: string - default: '' # externally set, as eng-common does not have the identity-resolver. Run as pre-step - - name: OnboardingBranch - type: string - default: '' - - name: CloseAfterOpenForTesting - type: boolean - default: false - - name: SkipPackageJson - type: object - default: false - - name: SparseCheckoutPaths - type: object - default: null - -steps: -- pwsh: | - if ($IsWindows) { - REG ADD HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem /f /v LongPathsEnabled /t REG_DWORD /d 1 - git config --system core.longpaths true - } - else { - Write-Host "This script is not executing on Windows, skipping registry modification." - } - displayName: Enable Long Paths if Necessary - -- ${{ if not(parameters.SparseCheckoutPaths) }}: - - pwsh: | - git clone https://github.com/${{ parameters.TargetDocRepoOwner }}/${{ parameters.TargetDocRepoName }} ${{ parameters.WorkingDirectory }}/repo - displayName: Clone Documentation Repository - ignoreLASTEXITCODE: false - -- ${{ if parameters.SparseCheckoutPaths }}: - - template: /eng/common/pipelines/templates/steps/sparse-checkout.yml - parameters: - SkipCheckoutNone: true - Repositories: - - Name: ${{ parameters.TargetDocRepoOwner }}/${{ parameters.TargetDocRepoName }} - WorkingDirectory: ${{ parameters.WorkingDirectory }}/repo - Paths: ${{ parameters.SparseCheckoutPaths }} - -- template: /eng/common/pipelines/templates/steps/set-default-branch.yml - parameters: - WorkingDirectory: ${{ parameters.WorkingDirectory }}/repo -- task: PowerShell@2 - displayName: 'Apply Documentation Updates From Artifact' - inputs: - targetType: filePath - filePath: ${{ parameters.ScriptDirectory }}/update-docs-metadata.ps1 - arguments: > - -ArtifactLocation ${{ parameters.ArtifactLocation }} - -Repository ${{ parameters.PackageRepository }} - -ReleaseSHA ${{ parameters.ReleaseSha }} - -RepoId ${{ parameters.RepoId }} - -WorkDirectory "${{ parameters.WorkingDirectory }}" - -DocRepoLocation "${{ parameters.WorkingDirectory }}/repo" - -Language "${{parameters.Language}}" - -Configs "${{ parameters.CIConfigs }}" - pwsh: true - env: - GH_TOKEN: $(azuresdk-github-pat) - -- template: /eng/common/pipelines/templates/steps/git-push-changes.yml - parameters: - BaseRepoBranch: $(DefaultBranch) - BaseRepoOwner: ${{ parameters.TargetDocRepoOwner }} - CommitMsg: "Update docs metadata and targeting for release of ${{ parameters.ArtifactName }}" - TargetRepoName: ${{ parameters.TargetDocRepoName }} - TargetRepoOwner: ${{ parameters.TargetDocRepoOwner }} - WorkingDirectory: ${{ parameters.WorkingDirectory }}/repo - ScriptDirectory: ${{ parameters.WorkingDirectory }}/${{ parameters.ScriptDirectory }} diff --git a/eng/common/scripts/Test-SampleMetadata.ps1 b/eng/common/scripts/Test-SampleMetadata.ps1 index c20396b0a96..d0a4670113a 100644 --- a/eng/common/scripts/Test-SampleMetadata.ps1 +++ b/eng/common/scripts/Test-SampleMetadata.ps1 @@ -247,6 +247,7 @@ begin { "azure-network-watcher", "azure-notebooks", "azure-notification-hubs", + "azure-openai", "azure-open-datasets", "azure-personalizer", "azure-pipelines", diff --git a/eng/common/scripts/TypeSpec-Project-Generate.ps1 b/eng/common/scripts/TypeSpec-Project-Generate.ps1 index feba00d37ed..c16dad66449 100644 --- a/eng/common/scripts/TypeSpec-Project-Generate.ps1 +++ b/eng/common/scripts/TypeSpec-Project-Generate.ps1 @@ -5,8 +5,8 @@ param ( [Parameter(Position=0)] [ValidateNotNullOrEmpty()] [string] $ProjectDirectory, - [Parameter(Position=1)] - [string] $typespecAdditionalOptions ## addtional typespec emitter options, separated by semicolon if more than one, e.g. option1=value1;option2=value2 + [string] $TypespecAdditionalOptions = $null, ## addtional typespec emitter options, separated by semicolon if more than one, e.g. option1=value1;option2=value2 + [switch] $SaveInputs = $false ## saves the temporary files during execution, default false ) $ErrorActionPreference = "Stop" @@ -45,6 +45,14 @@ function NpmInstallForProject([string]$workingDirectory) { Write-Host("Copying package.json from $replacementPackageJson") Copy-Item -Path $replacementPackageJson -Destination "package.json" -Force + + $useAlphaNpmRegistry = (Get-Content $replacementPackageJson -Raw).Contains("-alpha.") + + if($useAlphaNpmRegistry) { + Write-Host "Package.json contains '-alpha.' in the version, Creating .npmrc using public/azure-sdk-for-js-test-autorest feed." + "registry=https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-js-test-autorest/npm/registry/ `n`nalways-auth=true" | Out-File '.npmrc' + } + npm install --no-lock-file if ($LASTEXITCODE) { exit $LASTEXITCODE } } @@ -80,12 +88,17 @@ try { } } $typespecCompileCommand = "npx tsp compile $mainTypeSpecFile --emit $emitterName$emitterAdditionalOptions" - if ($typespecAdditionalOptions) { - $options = $typespecAdditionalOptions.Split(";"); + if ($TypespecAdditionalOptions) { + $options = $TypespecAdditionalOptions.Split(";"); foreach ($option in $options) { $typespecCompileCommand += " --option $emitterName.$option" } } + + if ($SaveInputs) { + $typespecCompileCommand += " --option $emitterName.save-inputs=true" + } + Write-Host($typespecCompileCommand) Invoke-Expression $typespecCompileCommand @@ -95,7 +108,7 @@ finally { Pop-Location } -$shouldCleanUp = $configuration["cleanup"] ?? $true +$shouldCleanUp = !$SaveInputs if ($shouldCleanUp) { Remove-Item $tempFolder -Recurse -Force } diff --git a/eng/common/scripts/TypeSpec-Project-Process.ps1 b/eng/common/scripts/TypeSpec-Project-Process.ps1 new file mode 100644 index 00000000000..3bff436dca7 --- /dev/null +++ b/eng/common/scripts/TypeSpec-Project-Process.ps1 @@ -0,0 +1,166 @@ +# For details see https://github.com/Azure/azure-sdk-tools/blob/main/doc/common/TypeSpec-Project-Scripts.md + +[CmdletBinding()] +param ( + [Parameter(Position = 0)] + [ValidateNotNullOrEmpty()] + [string] $TypeSpecProjectDirectory, # A directory of `tspconfig.yaml` or a remoteUrl of `tspconfig.yaml` + [Parameter(Position = 1)] + [string] $CommitHash, + [Parameter(Position = 2)] + [string] $RepoUrl +) + +. $PSScriptRoot/common.ps1 +. $PSScriptRoot/Helpers/PSModule-Helpers.ps1 +Install-ModuleIfNotInstalled "powershell-yaml" "0.4.1" | Import-Module + +function CreateUpdate-TspLocation([System.Object]$tspConfig, [string]$TypeSpecProjectDirectory, [string]$CommitHash, [string]$repo, [string]$repoRoot) { + $serviceDir = "" + $additionalDirs = @() + + # Parse tspcofig.yaml to get service-dir, additionalDirectories, and package-dir + if ($tspConfig["parameters"] -and $tspConfig["parameters"]["service-dir"]) { + $serviceDir = $tspConfig["parameters"]["service-dir"]["default"]; + } + else { + Write-Error "Missing service-dir in parameters section of tspconfig.yaml." + exit 1 + } + if ($tspConfig["parameters"]["dependencies"] -and $tspConfig["parameters"]["dependencies"]["additionalDirectories"]) { + $additionalDirs = $tspConfig["parameters"]["dependencies"]["additionalDirectories"]; + } + + $packageDir = Get-PackageDir $tspConfig + + # Create service-dir if not exist + $serviceDir = Join-Path $repoRoot $serviceDir + if (!(Test-Path -Path $serviceDir)) { + New-Item -Path $serviceDir -ItemType Directory | Out-Null + Write-Host "created service folder $serviceDir" + } + + # Create package-dir if not exist + $packageDir = Join-Path $serviceDir $packageDir + if (!(Test-Path -Path $packageDir)) { + New-Item -Path $packageDir -ItemType Directory | Out-Null + Write-Host "created package folder $packageDir" + } + + # Load tsp-location.yaml if exist + $tspLocationYamlPath = Join-Path $packageDir "tsp-location.yaml" + $tspLocationYaml = @{} + if (Test-Path -Path $tspLocationYamlPath) { + $tspLocationYaml = Get-Content -Path $tspLocationYamlPath -Raw | ConvertFrom-Yaml + } + else { + Write-Host "creating tsp-location.yaml in $packageDir" + } + + # Update tsp-location.yaml + $tspLocationYaml["commit"] = $CommitHash + Write-Host "updated tsp-location.yaml commit to $CommitHash" + $tspLocationYaml["repo"] = $repo + Write-Host "updated tsp-location.yaml repo to $repo" + $tspLocationYaml["directory"] = $TypeSpecProjectDirectory + Write-Host "updated tsp-location.yaml directory to $TypeSpecProjectDirectory" + $tspLocationYaml["additionalDirectories"] = $additionalDirs + Write-Host "updated tsp-location.yaml additionalDirectories to $additionalDirs" + $tspLocationYaml |ConvertTo-Yaml | Out-File $tspLocationYamlPath + Write-Host "finished updating tsp-location.yaml in $packageDir" + return $packageDir +} + +function Get-PackageDir([System.Object]$tspConfig) { + $emitterName = "" + if (Test-Path "Function:$GetEmitterNameFn") { + $emitterName = &$GetEmitterNameFn + } + else { + Write-Error "Missing $GetEmitterNameFn function in {$Language} SDK repo script." + exit 1 + } + $packageDir = "" + if ($tspConfig["options"] -and $tspConfig["options"]["$emitterName"] -and $tspConfig["options"]["$emitterName"]["package-dir"]) { + $packageDir = $tspConfig["options"]["$emitterName"]["package-dir"] + } + else { + Write-Error "Missing package-dir in $emitterName options of tspconfig.yaml." + exit 1 + } + return $packageDir +} + +$repoRootPath = (Join-Path $PSScriptRoot .. .. ..) +$repoRootPath = Resolve-Path $repoRootPath +$repoRootPath = $repoRootPath -replace "\\", "/" +$tspConfigPath = Join-Path $repoRootPath 'tspconfig.yaml' +$tmpTspConfigPath = $tspConfigPath +$repo = "" +# remote url scenario +# example url of tspconfig.yaml: https://github.com/Azure/azure-rest-api-specs-pr/blob/724ccc4d7ef7655c0b4d5c5ac4a5513f19bbef35/specification/containerservice/Fleet.Management/tspconfig.yaml +if ($TypeSpecProjectDirectory -match '^https://github.com/(?Azure/azure-rest-api-specs(-pr)?)/blob/(?[0-9a-f]{40})/(?.*)/tspconfig.yaml$') { + try { + $TypeSpecProjectDirectory = $TypeSpecProjectDirectory -replace "https://github.com/(.*)/(tree|blob)", "https://raw.githubusercontent.com/`$1" + Invoke-WebRequest $TypeSpecProjectDirectory -OutFile $tspConfigPath -MaximumRetryCount 3 + } + catch { + Write-Host "Failed to download '$TypeSpecProjectDirectory'" + Write-Error $_.Exception.Message + return + } + $repo = $Matches["repo"] + $TypeSpecProjectDirectory = $Matches["path"] + $CommitHash = $Matches["commit"] + # TODO support the branch name in url then get the commithash from branch name +} else { + # local path scenario + $tspConfigPath = Join-Path $TypeSpecProjectDirectory "tspconfig.yaml" + if (!(Test-Path $tspConfigPath)) { + Write-Error "Failed to find tspconfig.yaml in '$TypeSpecProjectDirectory'" + exit 1 + } + $TypeSpecProjectDirectory = $TypeSpecProjectDirectory.Replace("\", "/") + if ($TypeSpecProjectDirectory -match "^.*/(?specification/.*)$") { + $TypeSpecProjectDirectory = $Matches["path"] + } else { + Write-Error "$TypeSpecProjectDirectory doesn't have 'specification' in path." + exit 1 + } + if (!$CommitHash) { + Write-Error "Parameter of Commithash is not provided in the local path scenario." + exit 1 + } + if (!$RepoUrl) { + Write-Error "Parameter of RepoUrl:$RepoUrl is not provided in the local path scenario." + exit 1 + } + if ($RepoUrl -match "^https://github.com/(?[^/]*/azure-rest-api-specs(-pr)?).*") { + $repo = $Matches["repo"] + } + else { + Write-Error "Parameter 'RepoUrl' has incorrect value:$RepoUrl. It should be similar like 'https://github.com/Azure/azure-rest-api-specs'" + exit 1 + } +} + +$tspConfigYaml = Get-Content $tspConfigPath -Raw | ConvertFrom-Yaml + +# delete the tmporary tspconfig.yaml downloaded from github +if (Test-Path $tmpTspConfigPath) { + Remove-Item $tspConfigPath +} +# call CreateUpdate-TspLocation function +$sdkProjectFolder = CreateUpdate-TspLocation $tspConfigYaml $TypeSpecProjectDirectory $CommitHash $repo $repoRootPath + +# call TypeSpec-Project-Sync.ps1 +$syncScript = Join-Path $PSScriptRoot TypeSpec-Project-Sync.ps1 +& $syncScript $sdkProjectFolder +if ($LASTEXITCODE) { exit $LASTEXITCODE } + +# call TypeSpec-Project-Generate.ps1 +$generateScript = Join-Path $PSScriptRoot TypeSpec-Project-Generate.ps1 +& $generateScript $sdkProjectFolder +if ($LASTEXITCODE) { exit $LASTEXITCODE } + +return $sdkProjectFolder \ No newline at end of file diff --git a/eng/common/scripts/stress-testing/stress-test-deployment-lib.ps1 b/eng/common/scripts/stress-testing/stress-test-deployment-lib.ps1 index f8763de3502..b5a497179d1 100644 --- a/eng/common/scripts/stress-testing/stress-test-deployment-lib.ps1 +++ b/eng/common/scripts/stress-testing/stress-test-deployment-lib.ps1 @@ -76,7 +76,7 @@ function Login([string]$subscription, [string]$clusterGroup, [switch]$skipPushIm if (!$skipPushImages) { $registry = RunOrExitOnFailure az acr list -g $clusterGroup --subscription $subscription -o json $registryName = ($registry | ConvertFrom-Json).name - RunOrExitOnFailure az acr login -n $registryName + RunOrExitOnFailure az acr login -n $registryName --subscription $subscription } } @@ -387,6 +387,21 @@ function CheckDependencies() throw "Please update helm to version >= $MIN_HELM_VERSION (current version: $helmVersionString)`nAdditional information for updating helm version can be found here: https://helm.sh/docs/intro/install/" } + # Ensure docker is running via command and handle command hangs + if (!$skipPushImages) { + $LastErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $job = Start-Job { docker ps; return $LASTEXITCODE } + $result = $job | Wait-Job -Timeout 5 | Receive-Job + + $ErrorActionPreference = $LastErrorActionPreference + $job | Remove-Job -Force + + if (($result -eq $null -and $job.State -ne "Completed") -or ($result | Select -Last 1) -ne 0) { + throw "Docker does not appear to be running. Start/restart docker." + } + } + if ($shouldError) { exit 1 } diff --git a/eng/common/testproxy/publish-proxy-logs.yml b/eng/common/testproxy/publish-proxy-logs.yml index 543527a4437..543186edd35 100644 --- a/eng/common/testproxy/publish-proxy-logs.yml +++ b/eng/common/testproxy/publish-proxy-logs.yml @@ -5,12 +5,14 @@ steps: - pwsh: | Copy-Item -Path "${{ parameters.rootFolder }}/test-proxy.log" -Destination "${{ parameters.rootFolder }}/proxy.log" displayName: Copy Log File + condition: succeededOrFailed() - template: ../pipelines/templates/steps/publish-artifact.yml parameters: - ArtifactName: "$(System.JobName)-proxy-logs" + ArtifactName: "$(System.StageName)-$(System.JobName)-$(System.JobAttempt)-proxy-logs" ArtifactPath: "${{ parameters.rootFolder }}/proxy.log" - pwsh: | Remove-Item -Force ${{ parameters.rootFolder }}/proxy.log displayName: Cleanup Copied Log File + condition: succeededOrFailed() diff --git a/eng/pipelines/githubio-linkcheck.yml b/eng/pipelines/githubio-linkcheck.yml index 39595dbddcb..fc547ebd807 100644 --- a/eng/pipelines/githubio-linkcheck.yml +++ b/eng/pipelines/githubio-linkcheck.yml @@ -90,6 +90,20 @@ jobs: -inputCacheFile "$(cachefile)" -outputCacheFile "$(cachefile)" -devOpsLogging: $true + + - task: PowerShell@2 + displayName: 'tools link check' + condition: succeededOrFailed() + inputs: + pwsh: true + filePath: eng/common/scripts/Verify-Links.ps1 + arguments: > + -urls (Get-ChildItem -Path ./eng/common -Recurse -Include *.md) + -rootUrl "file://$(System.DefaultWorkingDirectory)" + -ignoreLinksFile "./eng/pipelines/githubio-linkcheck-ignore-links.txt" + -inputCacheFile "$(cachefile)" + -outputCacheFile "$(cachefile)" + -devOpsLogging: $true - publish: $(cachefile) artifact: verify-links-cache.txt diff --git a/eng/pipelines/templates/jobs/azuresdkpartnerdrops-to-nugetfeed.yml b/eng/pipelines/templates/jobs/azuresdkpartnerdrops-to-nugetfeed.yml index 845a9986da4..544323f1271 100644 --- a/eng/pipelines/templates/jobs/azuresdkpartnerdrops-to-nugetfeed.yml +++ b/eng/pipelines/templates/jobs/azuresdkpartnerdrops-to-nugetfeed.yml @@ -4,7 +4,7 @@ resources: - repository: azure-sdk-build-tools type: git name: internal/azure-sdk-build-tools - ref: refs/tags/azure-sdk-build-tools_20230425.1 + ref: refs/tags/azure-sdk-build-tools_20230530.2 parameters: - name: BuildToolsRepoPath diff --git a/eng/pipelines/templates/stages/archetype-sdk-tool-dotnet.yml b/eng/pipelines/templates/stages/archetype-sdk-tool-dotnet.yml index 1d5cd7a6c80..efce55801e5 100644 --- a/eng/pipelines/templates/stages/archetype-sdk-tool-dotnet.yml +++ b/eng/pipelines/templates/stages/archetype-sdk-tool-dotnet.yml @@ -3,7 +3,7 @@ resources: - repository: azure-sdk-build-tools type: git name: internal/azure-sdk-build-tools - ref: refs/tags/azure-sdk-build-tools_20230425.1 + ref: refs/tags/azure-sdk-build-tools_20230530.2 parameters: - name: ToolDirectory @@ -91,31 +91,32 @@ stages: artifact: packages condition: succeededOrFailed() - - job: Produce_Executables - - strategy: - matrix: - linux: - imageName: 'ubuntu-22.04' - poolName: 'azsdk-pool-mms-ubuntu-2204-general' - artifactName: 'linux_windows' - mac: - imageName: 'macos-11' - poolName: 'Azure Pipelines' - artifactName: 'mac' - - pool: - name: $(poolName) - vmImage: $(imageName) - - steps: - - template: /eng/pipelines/templates/steps/install-dotnet.yml - - - template: /eng/pipelines/templates/steps/produce-net-standalone-packs.yml - parameters: - StagingDirectory: $(Build.ArtifactStagingDirectory) - BuildMatrix: ${{ parameters.StandaloneExeMatrix }} - TargetDirectory: '${{ coalesce(parameters.PackageDirectory, parameters.ToolDirectory) }}' + - ${{ if ne(length(parameters.StandaloneExeMatrix), 0) }}: + - job: Produce_Executables + + strategy: + matrix: + linux: + imageName: 'ubuntu-22.04' + poolName: 'azsdk-pool-mms-ubuntu-2204-general' + artifactName: 'linux_windows' + mac: + imageName: 'macos-11' + poolName: 'Azure Pipelines' + artifactName: 'mac' + + pool: + name: $(poolName) + vmImage: $(imageName) + + steps: + - template: /eng/pipelines/templates/steps/install-dotnet.yml + + - template: /eng/pipelines/templates/steps/produce-net-standalone-packs.yml + parameters: + StagingDirectory: $(Build.ArtifactStagingDirectory) + BuildMatrix: ${{ parameters.StandaloneExeMatrix }} + TargetDirectory: '${{ coalesce(parameters.PackageDirectory, parameters.ToolDirectory) }}' - job: Test diff --git a/eng/pipelines/templates/steps/apiview-ui-tests.yml b/eng/pipelines/templates/steps/apiview-ui-tests.yml index b1e4642a95e..3e5c5e15cb8 100644 --- a/eng/pipelines/templates/steps/apiview-ui-tests.yml +++ b/eng/pipelines/templates/steps/apiview-ui-tests.yml @@ -53,7 +53,6 @@ steps: AZURE_TENANT_ID: "$(apiview-appconfig-tenant-id)" AZURE_CLIENT_SECRET: "$(apiview-appconfig-client-secret)" APIVIEW_APPROVERS: "azure-sdk" - APIVIEW_SWAGGERMETADATABACKGROUNDTASKDISABLED: "true" - task: Powershell@2 inputs: @@ -66,7 +65,8 @@ steps: displayName: 'Copy from Test Files From Blob' env: AZCOPY_SPA_CLIENT_SECRET: $(apiviewstorageaccess-service-principal-key) - + +# Should remove Selenium Tests once converted to Playwright - task: DotNetCoreCLI@2 displayName: 'Build & Test (UI)' env: @@ -83,4 +83,18 @@ steps: command: test projects: src/dotnet/APIView/**/APIViewUITests.csproj arguments: --logger trx - publishTestResults: false \ No newline at end of file + publishTestResults: false + +- script: | + npx playwright test --project=end-to-end-tests + workingDirectory: $(WebClientProjectDirectory) + displayName: "Run Client-Side End-to-End Tests" + env: + "BASE_URL": "http://localhost:5000" + "FIXTURE_DIR": "$(Build.BinariesDirectory)" + "APIVIEW_API_KEY": "$(azuresdk-apiview-apikey)" + +- task: PublishBuildArtifacts@1 + inputs: + pathtoPublish: '$(Build.SourcesDirectory)\src\dotnet\APIView\APIViewWeb\Client\playwright-report' + artifactName: 'Client-Side Test Reports' \ No newline at end of file diff --git a/src/dotnet/APIView/APIViewIntegrationTests/APIViewIntegrationTests.csproj b/src/dotnet/APIView/APIViewIntegrationTests/APIViewIntegrationTests.csproj index 81afb0d527d..5347d92df68 100644 --- a/src/dotnet/APIView/APIViewIntegrationTests/APIViewIntegrationTests.csproj +++ b/src/dotnet/APIView/APIViewIntegrationTests/APIViewIntegrationTests.csproj @@ -12,7 +12,6 @@ - diff --git a/src/dotnet/APIView/APIViewIntegrationTests/ReviewManagerTests.cs b/src/dotnet/APIView/APIViewIntegrationTests/ReviewManagerTests.cs index 0e3f1d57531..588e8d8876d 100644 --- a/src/dotnet/APIView/APIViewIntegrationTests/ReviewManagerTests.cs +++ b/src/dotnet/APIView/APIViewIntegrationTests/ReviewManagerTests.cs @@ -4,11 +4,6 @@ using System; using APIViewWeb; using APIViewWeb.Repositories; -using Newtonsoft.Json; -using APIViewWeb.Models; -using System.Collections.Generic; -using ApiView; -using FluentAssertions; namespace APIViewIntegrationTests { @@ -95,55 +90,5 @@ public async Task Delete_PullRequest_Review_Throws_Exception() await Assert.ThrowsAsync(async () => await reviewManager.DeleteRevisionAsync(user, review.ReviewId, review.Revisions[0].RevisionId)); } - - [Fact(Skip = "Need Resource to run so won't run on PR piplines plus Only needed once.")] - public async Task UpdateSwaggerReviewsMetaData_Test() - { - string reviewJson = File.ReadAllText(Path.Join(testsBaseFixture.TestDataPath, "account.swagger-cosmos-data.json")); - ReviewModel testReview = JsonConvert.DeserializeObject(reviewJson); - await testsBaseFixture.ReviewRepository.UpsertReviewAsync(testReview); - - DirectoryInfo directoryInfo = new DirectoryInfo(Path.Join(testsBaseFixture.TestDataPath, "testComments")); - - foreach(var file in directoryInfo.GetFiles()) - { - string commentJson = File.ReadAllText(file.FullName); - CommentModel comment = JsonConvert.DeserializeObject(commentJson); - await testsBaseFixture.CommentRepository.UpsertCommentAsync(comment); - } - - foreach (var revision in testReview.Revisions) - { - string codeFileJson = File.ReadAllText(Path.Join(testsBaseFixture.TestDataPath, "codeFiles", revision.Files[0].ReviewFileId)); - CodeFile testCodeFile = JsonConvert.DeserializeObject(codeFileJson); - await testsBaseFixture.BlobCodeFileRepository.UpsertCodeFileAsync(revision.RevisionId, revision.Files[0].ReviewFileId, testCodeFile); - } - - await testsBaseFixture.ReviewManager.UpdateSwaggerReviewsMetaData(); - - ReviewModel updatedReview = await testsBaseFixture.ReviewRepository.GetReviewAsync(testReview.ReviewId); - IEnumerable updatedComments = await testsBaseFixture.CommentRepository.GetCommentsAsync(testReview.ReviewId); - - List expectedCommentIds = new List() { - "-account.swagger-General-consumes", - "-account.swagger-Paths-/", - "-account.swagger-Paths-/collections-0-operationId-Collections_ListCollections-QueryParameters-table-tr-2", - "-account.swagger-Paths-/collections-0-operationId-Collections_ListCollections-Responses-200-table-tr-3" - }; - - foreach (var comment in updatedComments) - { - expectedCommentIds.Should().Contain(comment.ElementId); - } - - int[] expectedLineNumbers = { 1, 2, 3, 7, 8, 9, 10, 11, 20, 26, 860, 1184, 1193}; - var renderedCodeFile = await testsBaseFixture.BlobCodeFileRepository.GetCodeFileAsync(updatedReview.Revisions[1]); - renderedCodeFile.Render(false); - - for (int i = 0; i < renderedCodeFile.RenderResult.CodeLines.Length; i++) - { - Assert.Equal(renderedCodeFile.RenderResult.CodeLines[i].LineNumber, expectedLineNumbers[i]); - } - } } } diff --git a/src/dotnet/APIView/APIViewUnitTests/ReviewManagerTests.cs.cs b/src/dotnet/APIView/APIViewUnitTests/ReviewManagerTests.cs.cs deleted file mode 100644 index 0f93309c564..00000000000 --- a/src/dotnet/APIView/APIViewUnitTests/ReviewManagerTests.cs.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Moq; - -namespace APIViewUnitTests -{ - public class ReviewManagerTests - { - } -} diff --git a/src/dotnet/APIView/APIViewWeb/.gitignore b/src/dotnet/APIView/APIViewWeb/.gitignore index 0715521491b..d7afd149179 100644 --- a/src/dotnet/APIView/APIViewWeb/.gitignore +++ b/src/dotnet/APIView/APIViewWeb/.gitignore @@ -4,4 +4,9 @@ wwwroot/*.css.map # Typescript output wwwroot/*.js -wwwroot/*.js.map \ No newline at end of file +wwwroot/*.js.map + +# PlayWright Tests Results +Client/test-results/ +Client/playwright-report/ +Client/playwright/.cache/ \ No newline at end of file diff --git a/src/dotnet/APIView/APIViewWeb/Client/css/shared/bootstraps-overrides.scss b/src/dotnet/APIView/APIViewWeb/Client/css/shared/bootstraps-overrides.scss index d673e1f525e..5958adbf255 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/css/shared/bootstraps-overrides.scss +++ b/src/dotnet/APIView/APIViewWeb/Client/css/shared/bootstraps-overrides.scss @@ -41,6 +41,14 @@ color: var(--link-color); } +.btn-link { + color: var(--link-color); +} + +.btn-link:hover { + color: var(--link-active); +} + .btn-check:checked + .btn-outline-primary:focus, .btn-check:active + .btn-outline-primary:focus, .btn-outline-primary:active:focus, .btn-outline-primary.active:focus, .btn-outline-primary.dropdown-toggle.show:focus { box-shadow: 0 0 0 0.25rem var(--outer-glow); } diff --git a/src/dotnet/APIView/APIViewWeb/Client/css/shared/codeline.scss b/src/dotnet/APIView/APIViewWeb/Client/css/shared/codeline.scss index 718b29cce67..77a2751f268 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/css/shared/codeline.scss +++ b/src/dotnet/APIView/APIViewWeb/Client/css/shared/codeline.scss @@ -250,10 +250,12 @@ top: 2px; } +.comment-in-section { + visibility: visible !important; +} /* Code rendering classes ----------------------------------------------------------------------*/ - .class { color: var(--class-color) !important; } diff --git a/src/dotnet/APIView/APIViewWeb/Client/package.json b/src/dotnet/APIView/APIViewWeb/Client/package.json index 37fc6b483ef..7c773d4247c 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/package.json +++ b/src/dotnet/APIView/APIViewWeb/Client/package.json @@ -7,17 +7,21 @@ "outputDir": "../wwwroot/", "scripts": { "build": "webpack --config webpack.config.js --mode=production", - "watch": "webpack --watch" + "watch": "webpack --watch", + "test": "BASE_URL=http://localhost:5000 FIXTURE_DIR=C:/Users/chononiw/Downloads playwright test" }, "dependencies": { "@microsoft/signalr": "^7.0.5", "acorn": "^8.0.0", "core-js": "^3.3.2", + "form-data": "^4.0.0", "jquery": "3.6.0", "underscore": "^1.13.4" }, "devDependencies": { + "@playwright/test": "^1.33.0", "@types/jquery": "3.3.31", + "@types/node": "^20.1.3", "@types/split.js": "^1.4.0", "@types/webpack-env": "^1.14.1", "@typescript-eslint/parser": "5.16.0", @@ -25,13 +29,17 @@ "css-loader": "^3.6.0", "eslint": "^8.11.0", "eslint-plugin-vue": "^8.5.0", + "fetch-blob": "^2.1.2", + "form-data-encoder": "^1.9.0", + "formdata-node": "^4.4.1", "inspectpack": "^4.7.1", "mini-css-extract-plugin": "^2.7.2", + "node-fetch": "^2.6.11", "sass": "^1.19.0", "sass-loader": "^8.0.2", "style-loader": "^3.3.1", "ts-loader": "^6.2.0", - "typescript": "^3.6.0", + "typescript": "^5.0.4", "webpack": "^5.70.0", "webpack-cli": "^4.9.2" }, diff --git a/src/dotnet/APIView/APIViewWeb/Client/playwright.config.ts b/src/dotnet/APIView/APIViewWeb/Client/playwright.config.ts new file mode 100644 index 00000000000..1eeaa460bbb --- /dev/null +++ b/src/dotnet/APIView/APIViewWeb/Client/playwright.config.ts @@ -0,0 +1,84 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './tests', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + timeout: 50000, + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + viewport: null, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'unit-tests', + testDir: './tests/unit', + use: { + ...devices['Desktop Chrome'], + ...devices['Desktop Firefox'], + ...devices['Desktop Safari'], + } + }, + { + name: 'end-to-end-tests', + testDir: './tests/end-to-end', + use: { + ...devices['Desktop Chrome'], + ...devices['Desktop Firefox'], + ...devices['Desktop Safari'], + } + } + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/src/dotnet/APIView/APIViewWeb/Client/src/pages/review.module.ts b/src/dotnet/APIView/APIViewWeb/Client/src/pages/review.module.ts new file mode 100644 index 00000000000..2ead32edd80 --- /dev/null +++ b/src/dotnet/APIView/APIViewWeb/Client/src/pages/review.module.ts @@ -0,0 +1,445 @@ +import Split from "split.js"; +import * as hp from "../shared/helpers"; + +/* Hide some of the option switched (checkbox) when not needed */ +export function hideCheckboxesIfNotApplicable() { + if ($(".documentation").length == 0) { + $("#show-documentation-component").hide(); + } + if ($(".hidden-api-toggleable").length == 0) { + $("#show-hidden-api-component").hide(); + } +} + +/* Split left and right review panes using split.js */ +export function splitReviewPageContent() { + const rl = $('#review-left'); + const rr = $('#review-right'); + + if (rl.length && rr.length) { + Split(['#review-left', '#review-right'], { + direction: 'horizontal', + sizes: [17, 83], + elementStyle: (dimension, size, gutterSize) => { + return { + 'flex-basis': `calc(${size}% - ${gutterSize}px` + } + }, + gutterStyle: (dimension, gutterSize) => { + return { + 'flex-basis': `${gutterSize}px` + } + } + }); + } +} + +//------------------------------------------------------------------------------------------------- +// Funtions for managing expanging / collapseing of CodeLine Sections / subSections +//------------------------------------------------------------------------------------------------- + +export enum CodeLineSectionState { shown, hidden } + +/** +* Get the section row that was clicked +* @param { JQuery.ClickEvent } event that triggered the change of state +* @return { JQuery } the row that is being updated +*/ +export function getSectionHeadingRow(event: JQuery.ClickEvent) +{ + return $(event.currentTarget).parents(".code-line").first(); +} + +/** +* Update Icons that indicate if Section is Expanded or Collapsed +* @param { CodeLineSectionState } setTo - the section state after update +* @param { JQuery } caretIcon - the icon (button) that controls the section state +* @param { JQuery } headingRow - the heading of the section +*/ +function updateSectionHeadingIcons(setTo: CodeLineSectionState, caretIcon : JQuery, + headingRow : JQuery) { + if (setTo == CodeLineSectionState.shown) { + caretIcon.removeClass("fa-angle-right"); + caretIcon.addClass("fa-angle-down"); + headingRow.find(".row-fold-elipsis").addClass("d-none"); + } + + if (setTo == CodeLineSectionState.hidden) { + caretIcon.removeClass("fa-angle-down"); + caretIcon.addClass("fa-angle-right"); + headingRow.find(".row-fold-elipsis").removeClass("d-none"); + } +} + +/** +* Expand or Collapse CodeLine Top Level Sections +* @param { JQuery } headingRow - the heading row that controls the state of the section +* @param { any } sectionContent - row or rows whose state (hidden/shown) is managed by the headingRow +* @param { string } caretDirection - indicates the state of the section can end with "right" or "down" +* @param { JQuery } caretIcon - indicates the state of the section > (hidden) v (shown) +*/ +function toggleSectionContent(headingRow : JQuery, sectionContent, caretDirection : string, + caretIcon : JQuery) { + const rowLineNumber = headingRow.find(".line-number>span").text(); + if (caretDirection.endsWith("right")) { + // In case the section passed has already been replaced with more rows + if (sectionContent.length == 1) { + const sectionContentClass = sectionContent[0].className.replace(/\s/g, '.'); + const sectionCommentClass = sectionContentClass.replace("code-line.", "comment-row."); + sectionContent = $(`.${sectionContentClass}`); + sectionContent.push(...$(`.${sectionCommentClass}`)); + } + + $.each(sectionContent, function (index, value) { + let rowClasses = $(value).attr("class"); + if (rowClasses) { + if (rowClasses.match(/comment-row/)) { + // Ensure comment icon is shown on parent row that have comments in its section or subsection + let rowClassList = rowClasses.split(/\s+/); + let sectionClass = rowClassList.find((c) => c.match(/code-line-section-content-[0-9]+/)); + let levelClass = rowClassList.find((c) => c.match(/lvl_[0-9]+_child_[0-9]+/)); + if (sectionClass && levelClass) { + let levelClassParts = levelClass.split("_"); + let level = levelClassParts[1]; + let headingLvl = levelClassParts[3]; + $(`.${sectionClass}`).each(function(idx, el) { + let classList = hp.getElementClassList(el); + let lvlClass = classList.find((c) => c.match(/lvl_[0-9]+_parent_[0-9]+/)); + if (lvlClass && lvlClass.length > 0) { + let lvlClassParts = lvlClass.split("_"); + if (Number(lvlClassParts[1]) == Number(level) && Number(lvlClassParts[3]) == Number(headingLvl) && classList.includes("comment-row")) { + return false; + } + + if (Number(lvlClassParts[1]) <= Number(level) && Number(lvlClassParts[3]) <= Number(headingLvl) && !classList.includes("comment-row")) { + $(el).find(".icon-comments").addClass("comment-in-section"); + } + } + }); + } + } + + if (rowClasses.match(/lvl_1_/)) { + if (rowClasses.match(/comment-row/) && !$("#show-comments-checkbox").prop("checked")) { + hp.toggleCommentIcon($(value).attr("data-line-id")!, true); + return; // Dont show comment row if show comments setting is unchecked + } + $(value).removeClass("d-none"); + $(value).find("svg").attr("height", `${$(value).height()}`); + } + } + }); + + // Update section heading icons to open state + updateSectionHeadingIcons(CodeLineSectionState.shown, caretIcon, headingRow); + + // maintain lineNumbers of shown headings in sessionStorage + let shownSectionHeadingLineNumbers = sessionStorage.getItem("shownSectionHeadingLineNumbers") ?? ""; + shownSectionHeadingLineNumbers = updateCodeLineSectionState(shownSectionHeadingLineNumbers, rowLineNumber, CodeLineSectionState.shown); + sessionStorage.setItem("shownSectionHeadingLineNumbers", shownSectionHeadingLineNumbers); + } + else { + $.each(sectionContent, function (index, value) { + let rowClasses = $(value).attr("class"); + if (rowClasses) { + if (rowClasses.match(/lvl_[0-9]+_parent_/)) { + // Update all heading/parent rows to closed state before hiding it + let caretIcon = $(value).find(".row-fold-caret").children("i"); + let lineNo = $(value).find(".line-number>span").text(); + updateSectionHeadingIcons(CodeLineSectionState.hidden, caretIcon, $(value)); + + // maintain lineNumbers of shown headings in sessionStorage + let shownSubSectionHeadingLineNumbers = sessionStorage.getItem("shownSubSectionHeadingLineNumbers") ?? ""; + shownSubSectionHeadingLineNumbers = updateCodeLineSectionState(shownSubSectionHeadingLineNumbers, lineNo, CodeLineSectionState.hidden); + sessionStorage.setItem("shownSubSectionHeadingLineNumbers", shownSubSectionHeadingLineNumbers) + } + } + $(value).addClass("d-none"); + }); + + // Update section heading icons to closed state + updateSectionHeadingIcons(CodeLineSectionState.hidden, caretIcon, headingRow); + + // maintain lineNumbers of shown headings in sessionStorage + let shownSectionHeadingLineNumbers = sessionStorage.getItem("shownSectionHeadingLineNumbers") ?? ""; + shownSectionHeadingLineNumbers = updateCodeLineSectionState(shownSectionHeadingLineNumbers, rowLineNumber, CodeLineSectionState.hidden); + sessionStorage.setItem("shownSectionHeadingLineNumbers", shownSectionHeadingLineNumbers); + } +} + +/** +* Expand or Collapse CodeLine SubSections +* @param { JQuery } headingRow - the heading row that controls the state of the section +* @param { string } subSectionLevel - the level or depth of the subSection on the section tree +* @param { string } subSectionHeadingPosition - The position of the subSectionHeading i.e position among its siblings +* @param { string } subSectionContentClass - class of the subsection +* @param { string } caretDirection - indicates the state of the section can end with "right" or "down" +* @param { JQuery } caretIcon - indicates the state of the section > (hidden) v (shown) +* @param { string } linenumber - for the heading row +*/ +function toggleSubSectionContent(headingRow : JQuery, subSectionLevel : string, subSectionHeadingPosition : string, + subSectionContentClass : string, caretDirection : string, caretIcon : JQuery, lineNumber) { + var subSectionDescendants = $(`.${subSectionContentClass}`); + + if (caretDirection.endsWith("right")) { + var startShowing = false; + $.each(subSectionDescendants, function (index, value) { + var rowClasses = $(value).attr("class"); + var rowLineNumber = $(value).find(".line-number>span").text(); + if (rowClasses) { + if (rowClasses.match(new RegExp(`lvl_${subSectionLevel}_parent_${subSectionHeadingPosition}`)) && rowLineNumber == lineNumber) { + startShowing = true; + } + + if (startShowing && (rowClasses.match(new RegExp(`lvl_${subSectionLevel}_parent_${Number(subSectionHeadingPosition) + 1}`)) + || rowClasses.match(new RegExp(`lvl_${subSectionLevel}_child_${Number(subSectionHeadingPosition) + 1}`)) + || rowClasses.match(new RegExp(`lvl_${Number(subSectionLevel) - 1}_`)))) { + return false; + } + + + // Show only immediate descendants + if (startShowing) { + if (rowClasses.match(new RegExp(`lvl_${Number(subSectionLevel) + 1}_`))) { + if (rowClasses.match(/comment-row/) && !$("#show-comments-checkbox").prop("checked")) { + hp.toggleCommentIcon($(value).attr("data-line-id")!, true); + return; // Dont show comment row if show comments setting is unchecked + } + + $(value).removeClass("d-none"); + let rowHeight = $(value).height() ?? 0; + $(value).find("svg").attr("height", `${rowHeight}`); + } + } + } + }); + + // Update section heading icons to open state + updateSectionHeadingIcons(CodeLineSectionState.shown, caretIcon, headingRow); + + // maintain lineNumbers of shown headings in session storage + let shownSubSectionHeadingLineNumbers = sessionStorage.getItem("shownSubSectionHeadingLineNumbers") ?? ""; + shownSubSectionHeadingLineNumbers = updateCodeLineSectionState(shownSubSectionHeadingLineNumbers, lineNumber, CodeLineSectionState.shown); + sessionStorage.setItem("shownSubSectionHeadingLineNumbers", shownSubSectionHeadingLineNumbers); + } + else { + var startHiding = false; + $.each(subSectionDescendants, function (index, value) { + var rowClasses = $(value).attr("class"); + var rowLineNumber = $(value).find(".line-number>span").text(); + if (rowClasses) { + if (rowClasses.match(new RegExp(`lvl_${subSectionLevel}_parent_${subSectionHeadingPosition}`)) && rowLineNumber == lineNumber) { + startHiding = true; + } + + if (startHiding && (rowClasses.match(new RegExp(`lvl_${subSectionLevel}_parent_${Number(subSectionHeadingPosition) + 1}`)) + || rowClasses.match(new RegExp(`lvl_${subSectionLevel}_child_${Number(subSectionHeadingPosition) + 1}`)) + || rowClasses.match(new RegExp(`lvl_${Number(subSectionLevel) - 1}_`)))) { + return false; + } + + + if (startHiding) { + let descendantClasses = rowClasses.split(' ').filter(c => c.match(/lvl_[0-9]+_child_.*/))[0]; + if (descendantClasses) { + let descendantLevel = descendantClasses.split('_')[1]; + if (/^\d+$/.test(descendantLevel)) { + if (Number(descendantLevel) > Number(subSectionLevel)) { + $(value).addClass("d-none"); + if (rowClasses.match(/lvl_[0-9]+_parent_.*/)) { + // Update all heading/parent rows to closed state before hiding it + let caretIcon = $(value).find(".row-fold-caret").children("i"); + let lineNo = $(value).find(".line-number>span").text(); + updateSectionHeadingIcons(CodeLineSectionState.hidden, caretIcon, $(value)); + + // maintain lineNumbers of shown headings in sessionStorage + let shownSubSectionHeadingLineNumbers = sessionStorage.getItem("shownSubSectionHeadingLineNumbers") ?? ""; + shownSubSectionHeadingLineNumbers = updateCodeLineSectionState(shownSubSectionHeadingLineNumbers, lineNo, CodeLineSectionState.hidden); + sessionStorage.setItem("shownSubSectionHeadingLineNumbers", shownSubSectionHeadingLineNumbers); + } + } + } + } + } + } + }); + + // Update section heading icons to closed state + updateSectionHeadingIcons(CodeLineSectionState.hidden, caretIcon, headingRow); + + // maintain lineNumbers of shown headings in sessionStorage + let shownSubSectionHeadingLineNumbers = sessionStorage.getItem("shownSubSectionHeadingLineNumbers") ?? ""; + shownSubSectionHeadingLineNumbers = updateCodeLineSectionState(shownSubSectionHeadingLineNumbers, lineNumber, CodeLineSectionState.hidden); + sessionStorage.setItem("shownSubSectionHeadingLineNumbers", shownSubSectionHeadingLineNumbers); + } +} + +/** +* Updates the state of the section and subSections logically under the headingRow +* @param { JQuery } headingRow - the heading row that controls the state of the section +*/ +export function toggleCodeLines(headingRow : JQuery) { + if (headingRow.attr('class')) { + const headingRowClasses = hp.getElementClassList(headingRow); + const caretIcon = headingRow.find(".row-fold-caret").children("i"); + const caretDirection = hp.getElementClassList(caretIcon).filter(c => c.startsWith('fa-angle-'))[0]; + const subSectionHeadingClass = headingRowClasses.filter(c => c.startsWith('code-line-section-heading-'))[0]; + const subSectionContentClass = headingRowClasses.filter(c => c.startsWith('code-line-section-content-'))[0]; + + if (subSectionHeadingClass) { + const sectionKey = subSectionHeadingClass.replace("code-line-section-heading-", "") + const sectionKeyA = headingRowClasses.filter(c => c.startsWith('rev-a-heading-'))[0]?.replace('rev-a-heading-', ''); + const sectionKeyB = headingRowClasses.filter(c => c.startsWith('rev-b-heading-'))[0]?.replace('rev-b-heading-', ''); + + if (/^\d+$/.test(sectionKey)) { + var sectionContent = $(`.code-line-section-content-${sectionKey}`); + if (sectionContent.hasClass("section-loaded")) { + toggleSectionContent(headingRow, sectionContent, caretDirection, caretIcon); + } + else { + let uri = '?handler=codelinesection'; + const uriPath = location.pathname.split('/'); + const reviewId = uriPath[uriPath.length - 1]; + const revisionId = new URLSearchParams(location.search).get("revisionId"); + const diffRevisionId = new URLSearchParams(location.search).get("diffRevisionId"); + const diffOnly = new URLSearchParams(location.search).get("diffOnly"); + uri = uri + '&id=' + reviewId + '§ionKey=' + sectionKey; + if (revisionId) + uri = uri + '&revisionId=' + revisionId; + if (diffRevisionId) + uri = uri + '&diffRevisionId=' + diffRevisionId; + if (diffOnly) + uri = uri + '&diffOnly=' + diffOnly; + if (sectionKeyA) + uri = uri + '§ionKeyA=' + sectionKeyA; + if (sectionKeyB) + uri = uri + '§ionKeyB=' + sectionKeyB; + + const loadingMarkUp = "Loading..."; + const failedToLoadMarkUp = ""; + if (sectionContent.children(".spinner-border").length == 0) { + sectionContent.children("td").after(loadingMarkUp); + } + sectionContent.removeClass("d-none"); + + const request = $.ajax({ url: uri }); + request.done(function (partialViewResult) { + sectionContent.replaceWith(partialViewResult); + toggleSectionContent(headingRow, sectionContent, caretDirection, caretIcon); + addCodeLineToggleEventHandlers(); + }); + request.fail(function () { + if (sectionContent.children(".alert").length == 0) { + sectionContent.children(".spinner-border").replaceWith(failedToLoadMarkUp); + } + }); + return request; + } + } + } + + if (subSectionContentClass) { + const subSectionClass = headingRowClasses.filter(c => c.match(/.*lvl_[0-9]+_parent.*/))[0]; + const lineNumber = headingRow.find(".line-number>span").text(); + if (subSectionClass) { + const subSectionLevel = subSectionClass.split('_')[1]; + const subSectionHeadingPosition = subSectionClass.split('_')[3]; + if (/^\d+$/.test(subSectionLevel) && /^\d+$/.test(subSectionHeadingPosition)) { + toggleSubSectionContent(headingRow, subSectionLevel, subSectionHeadingPosition, subSectionContentClass, caretDirection, caretIcon, lineNumber); + } + } + } + } +} + +/* Add event handler for Expand / Collapse of CodeLine Sections and SubSections */ +export function addCodeLineToggleEventHandlers() { + $('.row-fold-elipsis, .row-fold-caret').on('click', function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + var headingRow = getSectionHeadingRow(event); + toggleCodeLines(headingRow); + }); +} + +/** +* Updates Browser Cookie with the State of the Codeline Section (hidden or shown) +* @param { String } cookieValue +* @param { String } lineNumber +* @param { CodeLineSectionState } state +* @return { string } updatedCookieValue +*/ +export function updateCodeLineSectionState(cookieValue: string, lineNumber: string, state: CodeLineSectionState) { + const expandedSections = cookieValue.split(','); + const updatedCookieValue : string[] = []; + let updateComplete : boolean = false; + expandedSections.forEach((val) => { + if (val) { + if (val !== lineNumber && !isNaN(Number(val))) { + updatedCookieValue.push(val); + } + else { + if (state == CodeLineSectionState.shown && !isNaN(Number(val))) { + updatedCookieValue.push(val); + } + updateComplete = true; + } + } + }); + if (!updateComplete && state == CodeLineSectionState.shown) + updatedCookieValue.push(lineNumber); + + return updatedCookieValue.join(','); +} + +/** +* Read section and subSection state (lineNumbers) from cookies and reload them +*/ +export function loadPreviouslyShownSections() { + const shownSectionHeadingLineNumbers = sessionStorage.getItem("shownSectionHeadingLineNumbers") ?? ""; + + // Load each section whose heading line number is present in the cookie + const elementsWithLineNumbers = Array.from($(".line-number")); + for (const lineNumber of shownSectionHeadingLineNumbers.split(',')) { + const lineNoElement = elementsWithLineNumbers.filter(element => $(element).find('span').text() === lineNumber); + const lineDetailsBtnCell = $(lineNoElement).siblings("td .line-details-button-cell"); + for (const element of Array.from($(lineDetailsBtnCell))) { + const rowCaretCell = Array.from($(element).children(".row-fold-caret")); + if (rowCaretCell.length > 0) + { + rowCaretCell[0].click(); + } + } + } + + const shownSubSectionHeadingLineNumbers = sessionStorage.getItem("shownSubSectionHeadingLineNumbers") ?? ""; + + // Load subSections as the headings become visible on the page + const subSectionHeadingLineNumberQueue = shownSubSectionHeadingLineNumbers.split(','); + const intervalID = setInterval((subSectionHeadingLineNumberQueue) => { + if (subSectionHeadingLineNumberQueue.length > 0) + { + const lineNumber = subSectionHeadingLineNumberQueue.shift(); + const lineNoElement = Array.from($(".line-number")).filter(element => $(element).find('span').text() === lineNumber); + if (lineNoElement.length > 0) { + const lineDetailsBtnCell = $(lineNoElement).siblings("td .line-details-button-cell"); + for (const element of Array.from($(lineDetailsBtnCell))) { + const rowCaretCell = Array.from($(element).children(".row-fold-caret")); + if (rowCaretCell.length > 0) + { + rowCaretCell[0].click(); + } + } + } + else { + subSectionHeadingLineNumberQueue.push(lineNumber!); + } + } + else { + clearInterval(intervalID); + } + }, 1000, subSectionHeadingLineNumberQueue); + + // remove toast + $("#loadPreviouslyShownSectionsToast").remove(); +} \ No newline at end of file diff --git a/src/dotnet/APIView/APIViewWeb/Client/src/pages/review.ts b/src/dotnet/APIView/APIViewWeb/Client/src/pages/review.ts index 9f26df53f60..8b3e4c81b0d 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/src/pages/review.ts +++ b/src/dotnet/APIView/APIViewWeb/Client/src/pages/review.ts @@ -1,293 +1,20 @@ -import Split from "split.js"; -import { updatePageSettings, toggleCommentIcon } from "../shared/helpers"; + import { rightOffCanvasNavToggle } from "../shared/off-canvas"; +import * as rvM from "./review.module" +import * as hp from "../shared/helpers"; + $(() => { - const SEL_DOC_CLASS = ".documentation"; - const SHOW_DOC_CHECK_COMPONENT = "#show-documentation-component"; const SHOW_DOC_CHECKBOX = ".show-documentation-checkbox"; const SHOW_DOC_HREF = ".show-documentation-switch"; const SHOW_DIFFONLY_CHECKBOX = ".show-diffonly-checkbox"; const SHOW_DIFFONLY_HREF = ".show-diffonly-switch"; const TOGGLE_DOCUMENTATION = ".line-toggle-documentation-button"; const SEL_HIDDEN_CLASS = ".hidden-api-toggleable"; - const SHOW_HIDDEN_CHECK_COMPONENT = "#show-hidden-api-component"; const SHOW_HIDDEN_CHECKBOX = "#show-hidden-api-checkbox"; const SHOW_HIDDEN_HREF = ".show-hidden-api"; - hideCheckboxesIfNotApplicable(); - - /* FUNCTIONS - --------------------------------------------------------------------------------------------------------------------------------------------------------*/ - function hideCheckboxesIfNotApplicable() { - if ($(SEL_DOC_CLASS).length == 0) { - $(SHOW_DOC_CHECK_COMPONENT).hide(); - } - if ($(SEL_HIDDEN_CLASS).length == 0) { - $(SHOW_HIDDEN_CHECK_COMPONENT).hide(); - } - } - - /* Split left and right review panes using split.js */ - function splitReviewPageContent() { - const rl = $('#review-left'); - const rr = $('#review-right'); - - if (rl.length && rr.length) { - Split(['#review-left', '#review-right'], { - direction: 'horizontal', - sizes: [17, 83], - elementStyle: (dimension, size, gutterSize) => { - return { - 'flex-basis': `calc(${size}% - ${gutterSize}px` - } - }, - gutterStyle: (dimension, gutterSize) => { - return { - 'flex-basis': `${gutterSize}px` - } - } - }); - } - } - - /* Update Icons that indicate if Section is Expanded or Collapsed */ - function updateSectionHeadingIcons(setTo: string, caretIcon, headingRow) { - if (setTo == "OPEN") { - caretIcon.removeClass("fa-angle-right"); - caretIcon.addClass("fa-angle-down"); - headingRow.find(".row-fold-elipsis").addClass("d-none"); - } - - if (setTo == "CLOSE") { - caretIcon.removeClass("fa-angle-down"); - caretIcon.addClass("fa-angle-right"); - headingRow.find(".row-fold-elipsis").removeClass("d-none"); - } - } - - /* Expand or Collapse CodeLine Top Level Sections */ - function toggleSectionContent(headingRow, sectionContent, caretDirection, caretIcon) { - if (caretDirection.endsWith("right")) { - // In case the section passed has already been replaced with more rows - if (sectionContent.length == 1) { - const sectionContentClass = sectionContent[0].className.replace(/\s/g, '.'); - const sectionCommentClass = sectionContentClass.replace("code-line.", "comment-row."); - sectionContent = $(`.${sectionContentClass}`); - sectionContent.push(...$(`.${sectionCommentClass}`)); - } - - $.each(sectionContent, function (index, value) { - let rowClasses = $(value).attr("class"); - if (rowClasses) { - if (rowClasses.match(/lvl_1_/)) { - if (rowClasses.match(/comment-row/) && !$("#show-comments-checkbox").prop("checked")) { - toggleCommentIcon($(value).attr("data-line-id"), true); - return; // Dont show comment row if show comments setting is unchecked - } - disableCommentsOnInRowTables($(value)); - $(value).removeClass("d-none"); - $(value).find("svg").attr("height", `${$(value).height()}`); - } - } - }); - - // Update section heading icons to open state - updateSectionHeadingIcons("OPEN", caretIcon, headingRow); - } - else { - $.each(sectionContent, function (index, value) { - let rowClasses = $(value).attr("class"); - if (rowClasses) { - if (rowClasses.match(/lvl_[0-9]+_parent_/)) { - // Update all heading/parent rows to closed state before hiding it - let caretIcon = $(value).find(".row-fold-caret").children("i"); - updateSectionHeadingIcons("CLOSE", caretIcon, $(value)); - } - } - $(value).addClass("d-none"); - }); - - // Update section heading icons to closed state - updateSectionHeadingIcons("CLOSE", caretIcon, headingRow); - } - } - - /* Expand or Collapse CodeLine SubSections */ - function toggleSubSectionContent(headingRow, subSectionLevel, subSectionHeadingPosition, subSectionContentClass, caretDirection, caretIcon, lineNumber) { - var subSectionDescendants = $(`.${subSectionContentClass}`); - - if (caretDirection.endsWith("right")) { - var startShowing = false; - - $.each(subSectionDescendants, function (index, value) { - var rowClasses = $(value).attr("class"); - var rowLineNumber = $(value).find(".line-number>span").text(); - if (rowClasses) { - if (rowClasses.match(new RegExp(`lvl_${subSectionLevel}_parent_${subSectionHeadingPosition}`)) && rowLineNumber == lineNumber) - startShowing = true; - - if (startShowing && (rowClasses.match(new RegExp(`lvl_${subSectionLevel}_parent_${Number(subSectionHeadingPosition) + 1}`)) - || rowClasses.match(new RegExp(`lvl_${subSectionLevel}_child_${Number(subSectionHeadingPosition) + 1}`)) - || rowClasses.match(new RegExp(`lvl_${Number(subSectionLevel) - 1}_`)))) - return false; - - // Show only immediate descendants - if (startShowing) { - if (rowClasses.match(new RegExp(`lvl_${Number(subSectionLevel) + 1}_`))) { - if (rowClasses.match(/comment-row/) && !$("#show-comments-checkbox").prop("checked")) { - toggleCommentIcon($(value).attr("data-line-id"), true); - return; // Dont show comment row if show comments setting is unchecked - } - - disableCommentsOnInRowTables($(value)); - $(value).removeClass("d-none"); - let rowHeight = $(value).height() ?? 0; - $(value).find("svg").attr("height", `${rowHeight}`); - } - } - } - }); - - // Update section heading icons to open state - updateSectionHeadingIcons("OPEN", caretIcon, headingRow); - } - else { - var startHiding = false; - - $.each(subSectionDescendants, function (index, value) { - var rowClasses = $(value).attr("class"); - var rowLineNumber = $(value).find(".line-number>span").text(); - if (rowClasses) { - if (rowClasses.match(new RegExp(`lvl_${subSectionLevel}_parent_${subSectionHeadingPosition}`)) && rowLineNumber == lineNumber) - startHiding = true; - - if (startHiding && (rowClasses.match(new RegExp(`lvl_${subSectionLevel}_parent_${Number(subSectionHeadingPosition) + 1}`)) - || rowClasses.match(new RegExp(`lvl_${subSectionLevel}_child_${Number(subSectionHeadingPosition) + 1}`)) - || rowClasses.match(new RegExp(`lvl_${Number(subSectionLevel) - 1}_`)))) - return false; - - if (startHiding) { - let descendantClasses = rowClasses.split(' ').filter(c => c.match(/lvl_[0-9]+_child_.*/))[0]; - if (descendantClasses) { - let descendantLevel = descendantClasses.split('_')[1]; - if (/^\d+$/.test(descendantLevel)) { - if (Number(descendantLevel) > Number(subSectionLevel)) { - $(value).addClass("d-none"); - if (rowClasses.match(/lvl_[0-9]+_parent_.*/)) { - // Update all heading/parent rows to closed state before hiding it - let caretIcon = $(value).find(".row-fold-caret").children("i"); - updateSectionHeadingIcons("CLOSE", caretIcon, $(value)); - - } - } - } - } - } - } - }); - - // Update section heading icons to closed state - updateSectionHeadingIcons("CLOSE", caretIcon, headingRow); - } - } - - /* On Click Handler for Expand/Collapse of CodeLine Sections and SubSections */ - function toggleCodeLines(headingRow) { - if (headingRow.attr('class')) { - const headingRowClasses = headingRow.attr('class').split(/\s+/); - const caretIcon = headingRow.find(".row-fold-caret").children("i"); - const caretDirection = caretIcon.attr("class").split(/\s+/).filter(c => c.startsWith('fa-angle-'))[0]; - const subSectionHeadingClass = headingRowClasses.filter(c => c.startsWith('code-line-section-heading-'))[0]; - const subSectionContentClass = headingRowClasses.filter(c => c.startsWith('code-line-section-content-'))[0]; - - if (subSectionHeadingClass) { - const sectionKey = subSectionHeadingClass.replace("code-line-section-heading-", "") - const sectionKeyA = headingRowClasses.filter(c => c.startsWith('rev-a-heading-'))[0]?.replace('rev-a-heading-', ''); - const sectionKeyB = headingRowClasses.filter(c => c.startsWith('rev-b-heading-'))[0]?.replace('rev-b-heading-', ''); - - if (/^\d+$/.test(sectionKey)) { - var sectionContent = $(`.code-line-section-content-${sectionKey}`); - if (sectionContent.hasClass("section-loaded")) { - toggleSectionContent(headingRow, sectionContent, caretDirection, caretIcon); - } - else { - let uri = '?handler=codelinesection'; - const uriPath = location.pathname.split('/'); - const reviewId = uriPath[uriPath.length - 1]; - const revisionId = new URLSearchParams(location.search).get("revisionId"); - const diffRevisionId = new URLSearchParams(location.search).get("diffRevisionId"); - const diffOnly = new URLSearchParams(location.search).get("diffOnly"); - uri = uri + '&id=' + reviewId + '§ionKey=' + sectionKey; - if (revisionId) - uri = uri + '&revisionId=' + revisionId; - if (diffRevisionId) - uri = uri + '&diffRevisionId=' + diffRevisionId; - if (diffOnly) - uri = uri + '&diffOnly=' + diffOnly; - if (sectionKeyA) - uri = uri + '§ionKeyA=' + sectionKeyA; - if (sectionKeyB) - uri = uri + '§ionKeyB=' + sectionKeyB; - - const loadingMarkUp = "Loading..."; - const failedToLoadMarkUp = ""; - if (sectionContent.children(".spinner-border").length == 0) { - sectionContent.children("td").after(loadingMarkUp); - } - sectionContent.removeClass("d-none"); - - const request = $.ajax({ url: uri }); - request.done(function (partialViewResult) { - sectionContent.replaceWith(partialViewResult); - toggleSectionContent(headingRow, sectionContent, caretDirection, caretIcon); - addToggleEventHandlers(); - }); - request.fail(function () { - if (sectionContent.children(".alert").length == 0) { - sectionContent.children(".spinner-border").replaceWith(failedToLoadMarkUp); - } - }); - return request; - } - } - } - - if (subSectionContentClass) { - const subSectionClass = headingRowClasses.filter(c => c.match(/.*lvl_[0-9]+_parent.*/))[0]; - const lineNumber = headingRow.find(".line-number>span").text(); - if (subSectionClass) { - const subSectionLevel = subSectionClass.split('_')[1]; - const subSectionHeadingPosition = subSectionClass.split('_')[3]; - if (/^\d+$/.test(subSectionLevel) && /^\d+$/.test(subSectionHeadingPosition)) { - toggleSubSectionContent(headingRow, subSectionLevel, subSectionHeadingPosition, subSectionContentClass, caretDirection, caretIcon, lineNumber); - } - } - } - } - } - - /* Add event handler for Expand/Collapse of CodeLine Sections and SubSections */ - function addToggleEventHandlers() { - $('.row-fold-elipsis, .row-fold-caret').on('click', function (event) { - event.preventDefault(); - event.stopImmediatePropagation(); - var headingRow = $(event.currentTarget).parents('.code-line').first(); - toggleCodeLines(headingRow); - - }); - } - - /* Disables Comments for tables within codeline rows. Used for code-removed lines in diff */ - function disableCommentsOnInRowTables(row: JQuery) { - if (row.hasClass("code-removed")) { - const innerTable = row.find(".code-inner>table"); - if (innerTable.length > 0) { - innerTable.find("tr").removeAttr("data-inline-id"); - innerTable.find(".line-comment-button").remove(); - } - } - } + rvM.hideCheckboxesIfNotApplicable(); // Enable SumoSelect $(document).ready(function () { @@ -314,7 +41,7 @@ $(() => { var caretClasses = caretIcon.attr("class"); var caretDirection = caretClasses ? caretClasses.split(' ').filter(c => c.startsWith('fa-angle-'))[0] : ""; if (caretDirection.endsWith("right")) { - $.when(toggleCodeLines(targetAnchorRow)).then(function () { + $.when(rvM.toggleCodeLines(targetAnchorRow)).then(function () { navItemRow.removeClass("nav-list-collapsed"); }); } @@ -335,7 +62,7 @@ $(() => { if (!$("#review-left").hasClass("d-none")) { // Only Add Split gutter if left navigation is not hidden - splitReviewPageContent(); + rvM.splitReviewPageContent(); } /* TOGGLE PAGE OPTIONS @@ -362,7 +89,7 @@ $(() => { }); $(SHOW_HIDDEN_CHECKBOX).on("click", e => { - updatePageSettings(function() { + hp.updatePageSettings(function() { $(SEL_HIDDEN_CLASS).toggleClass("d-none"); }); }); @@ -376,13 +103,13 @@ $(() => { }); $("#hide-line-numbers").on("click", e => { - updatePageSettings(function(){ + hp.updatePageSettings(function(){ $(".line-number").toggleClass("d-none"); }); }); $("#hide-left-navigation").on("click", e => { - updatePageSettings(function(){ + hp.updatePageSettings(function(){ var leftContainer = $("#review-left"); var rightContainer = $("#review-right"); var gutter = $(".gutter-horizontal"); @@ -391,7 +118,7 @@ $(() => { leftContainer.removeClass("d-none"); rightContainer.removeClass("col-12"); rightContainer.addClass("col-10"); - splitReviewPageContent(); + rvM.splitReviewPageContent(); } else { leftContainer.addClass("d-none"); @@ -474,13 +201,24 @@ $(() => { /* COLLAPSIBLE CODE LINES (EXPAND AND COLLAPSE FEATURE) --------------------------------------------------------------------------------------------------------------------------------------------------------*/ - addToggleEventHandlers(); + rvM.addCodeLineToggleEventHandlers(); + + // Ask to update codeLine Section state after page refresh + $(document).ready(function () { + // Get sessionStorage values holding section state + const shownSectionHeadingLineNumbers = sessionStorage.getItem("shownSectionHeadingLineNumbers"); + + if (shownSectionHeadingLineNumbers != null) + { + rvM.loadPreviouslyShownSections(); + } + }); /* RIGHT OFFCANVAS OPERATIONS --------------------------------------------------------------------------------------------------------------------------------------------------------*/ // Open / Close right Offcanvas Menu $("#review-right-offcanvas-toggle").on('click', function () { - updatePageSettings(function () { + hp.updatePageSettings(function () { rightOffCanvasNavToggle("review-main-container"); }); }); @@ -504,4 +242,7 @@ $(() => { document.cookie = `${id}=shown; max-age=${7 * 24 * 60 * 60}`; }); }); + + /* RIGHT OFFCANVAS OPERATIONS + --------------------------------------------------------------------------------------------------------------------------------------------------------*/ }); diff --git a/src/dotnet/APIView/APIViewWeb/Client/src/shared/comments.ts b/src/dotnet/APIView/APIViewWeb/Client/src/shared/comments.ts index 0b832b24875..8b86445557c 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/src/shared/comments.ts +++ b/src/dotnet/APIView/APIViewWeb/Client/src/shared/comments.ts @@ -329,12 +329,12 @@ $(() => { $(SEL_COMMENT_CELL).each(function () { const id = getElementId(this); const checked = $(SHOW_COMMENTS_CHECK).prop("checked"); - toggleCommentIcon(id, !checked); + toggleCommentIcon(id!, !checked); }); $(SEL_CODE_DIAG).each(function () { const id = getElementId(this); const checked = $(SHOW_SYS_COMMENTS_CHECK).prop("checked"); - toggleCommentIcon(id, !checked); + toggleCommentIcon(id!, !checked); }); }); diff --git a/src/dotnet/APIView/APIViewWeb/Client/src/shared/helpers.ts b/src/dotnet/APIView/APIViewWeb/Client/src/shared/helpers.ts index f998815c6f0..8adbaebd5c7 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/src/shared/helpers.ts +++ b/src/dotnet/APIView/APIViewWeb/Client/src/shared/helpers.ts @@ -1,4 +1,9 @@ -// Updated Page Setting by Updating UserPreference +/** +* Call APIView controller endpoint (/userprofile/updatereviewpagesettings) +* to update various page settings +* Takes a call back function that is run after ajax call succeeds +* @param { function } a callback function +*/ export function updatePageSettings(callBack) { var hideLineNumbers = $("#hide-line-numbers").prop("checked"); if (hideLineNumbers != undefined) { hideLineNumbers = !hideLineNumbers; } @@ -31,10 +36,18 @@ export function updatePageSettings(callBack) { }).done(callBack()); } +/** +* Retrieves a codeLineRow using the id +* @param { string } row id +*/ export function getCodeRow(id: string) { return $(`.code-line[data-line-id='${id}']`); } +/** +* Retrieves the classList for a codeLineRow using the id +* @param { string } row id +*/ export function getCodeRowSectionClasses(id: string) { var codeRow = getCodeRow(id); var rowSectionClasses = ""; @@ -44,6 +57,10 @@ export function getCodeRowSectionClasses(id: string) { return rowSectionClasses; } +/** +* Retrieves the classes that identifies the codeLine as a section +* @param { DOMTokenList } classlist +*/ export function getRowSectionClasses(classList: DOMTokenList) { const rowSectionClasses: string[] = []; for (const value of classList.values()) { @@ -54,6 +71,83 @@ export function getRowSectionClasses(classList: DOMTokenList) { return rowSectionClasses.join(' '); } -export function toggleCommentIcon(id, show: boolean) { +/** +* Updates the state of the comment icon (visible / invisible) +* @param { string } id +* @param { boolean } show +*/ +export function toggleCommentIcon(id: string, show: boolean) { getCodeRow(id).find(".icon-comments").toggleClass("invisible", !show); } + +/** +* Retrieve a Specific Cookie from Users Browser +* @param { String } cookies (pass document.cookies) +* @param { String } cookieName +* @return { String } cookieValue +*/ +export function getCookieValue (cookies: string, cookieName: string) +{ + const nameEQ = `${cookieName}=`; + const charArr = cookies.split(';'); + for (let i = 0; i < charArr.length; i++) + { + let ch = charArr[i]; + while(ch.charAt(0) === ' ') + { + ch = ch.substring(1, ch.length); + } + if (ch.indexOf(nameEQ) === 0) + return ch.substring(nameEQ.length, ch.length); + } + return null; +} + +/** +* Retrieve the list of classes on an element +* @param { JQuery | HTMLElement } element +* @return { string [] } classList - list of classes of the element +*/ +export function getElementClassList (element : JQuery | HTMLElement) { + let el : HTMLElement = (element instanceof HTMLElement) ? element : element[0]; + return Array.from(el.classList); +} + +// ToastNotification +export enum NotificationLevel { info, warning, error } +export interface Notification { + message : string; + level : NotificationLevel +} + +/** +* Contruct and add a toast notification to the page +* @param { ToastNotification } notification +* @param { number } duration - how long should the notification stay on the page +*/ +export function addToastNotification(notification : Notification, id : string = "", duration : number = 10000) { + const newtoast = $('#notification-toast').clone().removeAttr("id").attr("data-bs-delay", duration); + if (id != "") + { + newtoast.attr("id", id); + } + + switch (notification.level) { + case 0: + newtoast.find(".toast-header").prepend(``); + newtoast.find(".toast-header strong").html("Information"); + break; + case 1: + newtoast.find(".toast-header").prepend(``); + newtoast.find(".toast-header strong").html("Warning"); + break; + case 2: + newtoast.find(".toast-header").prepend(``); + newtoast.find(".toast-header strong").html("Error"); + break; + } + newtoast.find(".toast-body").html(notification.message); + const toastBootstrap = bootstrap.Toast.getOrCreateInstance(newtoast[0]); + $("#notification-container").append(newtoast); + toastBootstrap.show(); +} \ No newline at end of file diff --git a/src/dotnet/APIView/APIViewWeb/Client/src/shared/signalr.ts b/src/dotnet/APIView/APIViewWeb/Client/src/shared/signalr.ts index 773bbbbb8ef..73965b373a7 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/src/shared/signalr.ts +++ b/src/dotnet/APIView/APIViewWeb/Client/src/shared/signalr.ts @@ -1,15 +1,19 @@ -$(() => { - const notificationToast = $('#notification-toast'); +import * as hp from "./helpers"; - var signalR = require('@microsoft/signalr'); +$(() => { +//------------------------------------------------------------------------------------------------- +// Create SignalR Connection and Register various events +//------------------------------------------------------------------------------------------------- + const signalR = require('@microsoft/signalr'); const connection = new signalR.HubConnectionBuilder() - .withUrl(`${location.origin}/hubs/notification`) + .withUrl(`${location.origin}/hubs/notification`, { + skipNegotiation: true, + transport: signalR.HttpTransportType.WebSockets }) .configureLogging(signalR.LogLevel.Information) .withAutomaticReconnect() .build(); - async function start() { try { await connection.start(); @@ -25,25 +29,7 @@ $(() => { }); connection.on("RecieveNotification", (notification) => { - var newtoast = notificationToast.clone().removeAttr("id"); - switch (notification.level) { - case 0: - newtoast.find(".toast-header").prepend(``); - newtoast.find(".toast-header strong").html("Information"); - break; - case 1: - newtoast.find(".toast-header").prepend(``); - newtoast.find(".toast-header strong").html("Warning"); - break; - case 2: - newtoast.find(".toast-header").prepend(``); - newtoast.find(".toast-header strong").html("Error"); - break; - } - newtoast.find(".toast-body").html(notification.message); - const toastBootstrap = bootstrap.Toast.getOrCreateInstance(newtoast[0]); - $("#notification-container").append(newtoast); - toastBootstrap.show(); + hp.addToastNotification(notification); }); // Start the connection. diff --git a/src/dotnet/APIView/APIViewWeb/Client/tests/end-to-end/pages/review.spec.ts b/src/dotnet/APIView/APIViewWeb/Client/tests/end-to-end/pages/review.spec.ts new file mode 100644 index 00000000000..322266d0d60 --- /dev/null +++ b/src/dotnet/APIView/APIViewWeb/Client/tests/end-to-end/pages/review.spec.ts @@ -0,0 +1,261 @@ +import { test, expect, Locator } from "@playwright/test"; +import { FormData, File } from "formdata-node"; +import { FormDataEncoder } from "form-data-encoder" +import { Readable } from "node:stream" +import fetch from "node-fetch"; + +import * as path from "path"; +import * as fs from "fs"; + +const fixturesDir = process.env.FIXTURE_DIR as string; +const baseURL = process.env.BASE_URL as string; +const apiKey = process.env.APIVIEW_API_KEY as string; + +test.describe('CodeLine Section State Management', () => { + // Test Reviews Ids + const testReviewIds = {}; + + test.beforeAll(async ({}, testInfo) => { + // Create automatic Reviews using existing token files + await addAutoReview("webpubsub-data-plane-WebPubSub.Baseline.json", fixturesDir, "Swagger", testReviewIds); + await new Promise(resolve => setTimeout(resolve, 5000)); // Give the upload sometime to complete + }); + + test('codeLine section expands and collapses', async ({ page }) => { + test.slow(); + const swaggerTestReview = testReviewIds["Swagger"][0]; + await page.goto(swaggerTestReview); + + // Select one row-fold-caret btn to use for test + const sectionTriggerBtns = await page.locator(".row-fold-caret").all(); + const btnToTest = sectionTriggerBtns[2]; + + // Select the parent row heading class (the Section Heading) + const sectionHeadingRowClass = await btnToTest.evaluate((el) => { + let currEl = el; + while (currEl && !currEl.classList.contains("code-line")) { + currEl = currEl.parentElement!; + } + return currEl.classList[1]; + }); + + // Ensure that all section content does not exist + const sectionContentClass = sectionHeadingRowClass.replace("heading", "content"); + let sectionContentRows = await page.locator(`.${sectionContentClass}`).all(); + expect(sectionContentRows.length).toBe(1); + + // click on row caret (the test subject) + btnToTest.click(); + await page.waitForLoadState('networkidle', { timeout: 50000 }); // Give the network few seconds moment to load + + // Reselect section content rows and ensure they are present + sectionContentRows = await page.locator(`.${sectionContentClass}`).all(); + expect(sectionContentRows.length).toBe(10); + + // Click row caret again + btnToTest.click(); + + // Reselect section content rows and ensure they are hidden + sectionContentRows = await page.locator(`.${sectionContentClass}`).all(); + + expect(sectionContentRows.length).toBe(10); + for (const l of sectionContentRows) { + await l.waitFor({state: "hidden"}); + const classes = await l.evaluate((el) => Array.from(el.classList)); + await expect(classes).toContain("d-none"); + } + }); + + test('codeLine subSection expands and collapses and codeLine section collapses subSection', async ({ page }) => { + test.slow(); + const swaggerTestReview = testReviewIds["Swagger"][0]; + await page.goto(swaggerTestReview); + + // Select one row-fold-caret btn to use for test + const sectionTriggerBtns = await page.locator(".row-fold-caret").all(); + const btnToTest = sectionTriggerBtns[2]; + + // Select the parent row heading class (the Section Heading) + const sectionHeadingRowClass = await btnToTest.evaluate((el) => { + let currEl = el; + while (currEl && !currEl.classList.contains("code-line")) { + currEl = currEl.parentElement!; + } + return currEl.classList[1]; + }); + + // click on row caret (the test subject) + btnToTest.click(); + + // Select subSection row thats a heading + let subSectionParent; + const sectionContentClass = sectionHeadingRowClass.replace("heading", "content"); + await page.waitForLoadState('networkidle', { timeout: 50000 }); // Give the UI few seconds moment to load + + let sectionContentRows = await page.locator(`.${sectionContentClass}`).all(); + for (const l of sectionContentRows) + { + let parentClass = (await l.evaluate((el) => Array.from(el.classList))).filter(c => c.match(/_parent_/)); + if (parentClass.length > 0) + { + subSectionParent = l; + } + } + + // Ensure all sub section content is hidden + const subSectionContentRows : Locator [] = [] + for (const l of sectionContentRows) { + const sectionContentClasses = await l.evaluate((el) => Array.from(el.classList)); + if (sectionContentClasses.filter(c => c.match(/lvl_2_child_/)).length > 0) { + expect(Array.from(sectionContentClasses)).toContain("d-none"); + subSectionContentRows.push(l); + } + + } + expect(subSectionContentRows.length).toBe(7); + + // Find row caret thats child of subSectionHeading and click it + subSectionParent.locator(".row-fold-caret").click(); + await page.waitForLoadState('load'); // Give the network few seconds moment to load + await page.waitForTimeout(10000); + + // Ensure subsection content is now shown + sectionContentRows = await page.locator(`.${sectionContentClass}`).all(); + for (const l of sectionContentRows) { + const sectionContentClasses = await l.evaluate((el) => Array.from(el.classList)); + if (sectionContentClasses.filter(c => c.match(/lvl_2_child_/))) { + expect(Array.from(sectionContentClasses)).not.toContain("d-none"); + } + } + + // Click heading row caret to close section and subSection + btnToTest.click(); + + // Ensure section and subsection is hidden + sectionContentRows = await page.locator(`.${sectionContentClass}`).all(); + expect(sectionContentRows.length).toBe(10); + for (const l of sectionContentRows) { + await l.waitFor({state: "hidden"}); + const classes = await l.evaluate((el) => Array.from(el.classList)); + await expect(classes).toContain("d-none"); + } + }); + + test('codeLine secton and subSection stores heading line numbers in session storage', async ({ page }) => { + test.slow(); + const swaggerTestReview = testReviewIds["Swagger"][0]; + await page.goto(swaggerTestReview); + + // Select one row-fold-caret btn to use for test + const sectionTriggerBtns = await page.locator(".row-fold-caret").all(); + const btnToTest = sectionTriggerBtns[2]; + + // Select the parent row heading class (the Section Heading) + const sectionHeadingRowClass = await btnToTest.evaluate((el) => { + let currEl = el; + while (currEl && !currEl.classList.contains("code-line")) { + currEl = currEl.parentElement!; + } + return currEl.classList[1]; + }); + + // assert value in session storage + let shownSectionHeadingLineNumbers = await page.evaluate(() => { + return window.sessionStorage.getItem("shownSectionHeadingLineNumbers"); + }); + expect(shownSectionHeadingLineNumbers).toBeNull(); + + // click on row caret (the test subject) + btnToTest.click(); + await page.waitForLoadState('networkidle', { timeout: 50000 }); // Give the network few seconds moment to load + + // assert value in session storage + shownSectionHeadingLineNumbers = await page.evaluate(() => { + return window.sessionStorage.getItem("shownSectionHeadingLineNumbers"); + }); + expect(shownSectionHeadingLineNumbers).toBe("7"); + + // Select subSection row thats a heading + let subSectionParent; + const sectionContentClass = sectionHeadingRowClass.replace("heading", "content"); + await page.waitForLoadState('networkidle', { timeout: 50000 }); // Give the UI few seconds moment to load + + let sectionContentRows = await page.locator(`.${sectionContentClass}`).all(); + for (const l of sectionContentRows) + { + let parentClass = (await l.evaluate((el) => Array.from(el.classList))).filter(c => c.match(/_parent_/)); + if (parentClass.length > 0) + { + subSectionParent = l; + } + } + + // Find row caret thats child of subSectionHeading and click it + + subSectionParent.locator(".row-fold-caret").click(); + await page.waitForLoadState('networkidle', { timeout: 50000 }); // Give the network few seconds moment to load + await page.waitForFunction((sectionContentClass) => document.querySelectorAll(`.${sectionContentClass}`).length > 1, sectionContentClass);// Give the UI few seconds moment to load + await page.waitForTimeout(10000); + + // assert value in session storage + let shownSubSectionHeadingLineNumbers = await page.evaluate(() => { + return window.sessionStorage.getItem("shownSubSectionHeadingLineNumbers"); + }); + expect(shownSubSectionHeadingLineNumbers).toBe("10"); + + // click on row caret again (the test subject) + btnToTest.click(); + await page.waitForLoadState('networkidle', { timeout: 50000 }); // Give the network few seconds moment to load + await page.waitForSelector(sectionContentClass, { state: 'hidden' });// Give the UI few seconds moment to load + await page.waitForTimeout(10000); + + shownSectionHeadingLineNumbers = await page.evaluate(() => { + return window.sessionStorage.getItem("shownSectionHeadingLineNumbers"); + }); + shownSubSectionHeadingLineNumbers = await page.evaluate(() => { + return window.sessionStorage.getItem("shownSubSectionHeadingLineNumbers"); + }); + expect(shownSectionHeadingLineNumbers).toBe(""); + expect(shownSubSectionHeadingLineNumbers).toBe(""); + }); +}); + +/** +* Add an Auto Review to APIView using a Token File +* @param { String } fileName (full filename including extension) +* @param { String } fileDirectory +*/ +async function addAutoReview(fileName: string, fileDirectory: string, language: string, testReviewIds: {}) { + const swaggerTokenContent = fs.readFileSync(path.resolve(path.join(fileDirectory, fileName)), "utf8"); + const label = `${fileName} Review Label`; + const autoUplloadUrl = `${baseURL}/AutoReview/UploadAutoReview?label=${label}`; + + const formData = new FormData(); + const file = new File([swaggerTokenContent], fileName); + formData.set("file", file); + const encoder = new FormDataEncoder(formData); + + const requestOptions = { + method: "POST", + headers: { + "ApiKey": apiKey, + "Content-Type": encoder.headers["Content-Type"], + "Content-Length": encoder.headers["Content-Length"] + }, + body: Readable.from(encoder) + } + + await fetch(autoUplloadUrl, requestOptions) + .then(response => response.text()) + .then(result => { + if (Object.values(testReviewIds).includes(language)) { + testReviewIds[language].push(result); + } + else { + testReviewIds[language] = []; + testReviewIds[language].push(result); + } + }) + .catch(error => console.log("error uploading auto review", error)); +} + diff --git a/src/dotnet/APIView/APIViewWeb/Client/tests/unit/pages/review.spec.ts b/src/dotnet/APIView/APIViewWeb/Client/tests/unit/pages/review.spec.ts new file mode 100644 index 00000000000..3318464e281 --- /dev/null +++ b/src/dotnet/APIView/APIViewWeb/Client/tests/unit/pages/review.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; +import * as rvM from "../../../src/pages/review.module"; + +test.describe('CodeLine Section State Management', () => { + test('updateCodeLineSectionState should update cookie value', async ({ page }) => { + expect(rvM.updateCodeLineSectionState("", "24", rvM.CodeLineSectionState.hidden)).toBe(""); + expect(rvM.updateCodeLineSectionState("", "24", rvM.CodeLineSectionState.shown)).toBe("24"); + expect(rvM.updateCodeLineSectionState("24", "20", rvM.CodeLineSectionState.hidden)).toBe("24"); + expect(rvM.updateCodeLineSectionState("24", "20", rvM.CodeLineSectionState.shown)).toBe("24,20"); + expect(rvM.updateCodeLineSectionState("24,20", "5", rvM.CodeLineSectionState.shown)).toBe("24,20,5"); + expect(rvM.updateCodeLineSectionState("24,20,5", "19", rvM.CodeLineSectionState.hidden)).toBe("24,20,5"); + expect(rvM.updateCodeLineSectionState("24,20,5", "12", rvM.CodeLineSectionState.shown)).toBe("24,20,5,12"); + expect(rvM.updateCodeLineSectionState("24,20,5", "12", rvM.CodeLineSectionState.shown)).toBe("24,20,5,12"); + expect(rvM.updateCodeLineSectionState("24,20,5,12", "20", rvM.CodeLineSectionState.hidden)).toBe("24,5,12"); + expect(rvM.updateCodeLineSectionState("24,5,12", "24", rvM.CodeLineSectionState.hidden)).toBe("5,12"); + expect(rvM.updateCodeLineSectionState("5,12", "12", rvM.CodeLineSectionState.hidden)).toBe("5"); + expect(rvM.updateCodeLineSectionState("5", "5", rvM.CodeLineSectionState.hidden)).toBe(""); + }); +}); \ No newline at end of file diff --git a/src/dotnet/APIView/APIViewWeb/Client/tests/unit/shared/helpers.spec.ts b/src/dotnet/APIView/APIViewWeb/Client/tests/unit/shared/helpers.spec.ts new file mode 100644 index 00000000000..9f19c22e3cc --- /dev/null +++ b/src/dotnet/APIView/APIViewWeb/Client/tests/unit/shared/helpers.spec.ts @@ -0,0 +1,12 @@ +import { test, expect } from '@playwright/test'; +import * as hp from "../../../src/shared/helpers"; + +test('getCookies return valid single cookie', async ({ page }) => { + const testCookieString = 'preferred_color_mode=light; tz=America%2FLos_Angeles; _octo=GH1.1.1383888135.1683830525' + + // Verify that the cookies were retrieved correctly + expect(hp.getCookieValue(testCookieString, 'preferred_color_mode')).toBe('light'); + expect(hp.getCookieValue(testCookieString, 'tz')).toBe('America%2FLos_Angeles'); + expect(hp.getCookieValue(testCookieString, '_octo')).toBe('GH1.1.1383888135.1683830525'); + expect(hp.getCookieValue(testCookieString, 'invalidCookie')).toBe(null); +}); \ No newline at end of file diff --git a/src/dotnet/APIView/APIViewWeb/Client/tsconfig.json b/src/dotnet/APIView/APIViewWeb/Client/tsconfig.json index c6faa1bcede..f6d544d7aeb 100644 --- a/src/dotnet/APIView/APIViewWeb/Client/tsconfig.json +++ b/src/dotnet/APIView/APIViewWeb/Client/tsconfig.json @@ -12,9 +12,11 @@ "sourceMap": true, "baseUrl": ".", "noImplicitAny": false, + "typeRoots": ["./node_modules/@types"], "types": [ "webpack-env", - "jquery" + "jquery", + "node" ], "paths": { "@/*": [ diff --git a/src/dotnet/APIView/APIViewWeb/HostedServices/SwaggerReviewsBackgroundHostedService.cs b/src/dotnet/APIView/APIViewWeb/HostedServices/SwaggerReviewsBackgroundHostedService.cs deleted file mode 100644 index 0943ef4a719..00000000000 --- a/src/dotnet/APIView/APIViewWeb/HostedServices/SwaggerReviewsBackgroundHostedService.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using APIViewWeb.Managers; -using Microsoft.ApplicationInsights; -using Microsoft.ApplicationInsights.Extensibility; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; - -namespace APIViewWeb.HostedServices -{ - public class SwaggerReviewsBackgroundHostedService : BackgroundService - { - private readonly bool _isDisabled; - private readonly IReviewManager _reviewManager; - - static TelemetryClient _telemetryClient = new (TelemetryConfiguration.CreateDefault()); - - - public SwaggerReviewsBackgroundHostedService(IReviewManager reviewManager, IConfiguration configuration) - { - _reviewManager = reviewManager; - if (bool.TryParse(configuration["SwaggerMetaDataBackgroundTaskDisabled"], out bool taskDisabled)) - { - _isDisabled = taskDisabled; - } - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - if (!_isDisabled) - { - try - { - await _reviewManager.UpdateSwaggerReviewsMetaData(); - } - catch (Exception ex) - { - _telemetryClient.TrackException(ex); - } - - } - } - } -} diff --git a/src/dotnet/APIView/APIViewWeb/Managers/IReviewManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/IReviewManager.cs index 7698293fd9e..3d18e57a919 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/IReviewManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/IReviewManager.cs @@ -40,7 +40,6 @@ public Task CreateApiReview(ClaimsPrincipal user, string bu public Task UpdateReviewCodeFiles(string repoName, string buildId, string artifact, string project); public Task RequestApproversAsync(ClaimsPrincipal User, string ReviewId, HashSet reviewers); public Task GetLineNumbersOfHeadingsOfSectionsWithDiff(string reviewId, ReviewRevisionModel revision); - public Task UpdateSwaggerReviewsMetaData(); public TreeNode> ComputeSectionDiff(TreeNode before, TreeNode after, RenderedCodeFile beforeFile, RenderedCodeFile afterFile); public Task IsApprovedForFirstRelease(string language, string packageName); } diff --git a/src/dotnet/APIView/APIViewWeb/Managers/PullRequestManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/PullRequestManager.cs index 9f2fb2694a4..49f15e7d3a6 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/PullRequestManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/PullRequestManager.cs @@ -359,7 +359,10 @@ private async Task CreateRevisionIfRequired(CodeFile codeFile, pullRequestModel.ReviewId = review.ReviewId; review.FilterType = ReviewType.PullRequest; await _reviewsRepository.UpsertReviewAsync(review); - await _reviewManager.GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, newRevision); + if (!String.IsNullOrEmpty(review.Language) && review.Language == "Swagger") + { + await _reviewManager.GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, newRevision); + } await _pullRequestsRepository.UpsertPullRequestAsync(pullRequestModel); } diff --git a/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs b/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs index 3c089662a53..e2185776936 100644 --- a/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs +++ b/src/dotnet/APIView/APIViewWeb/Managers/ReviewManager.cs @@ -8,11 +8,8 @@ using System.Linq; using System.Security.Claims; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; -using System.Xml; using ApiView; -using APIView; using APIView.DIff; using APIView.Model; using APIViewWeb.Models; @@ -21,8 +18,6 @@ using Microsoft.ApplicationInsights.DataContracts; using Microsoft.ApplicationInsights.Extensibility; using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Azure; -using Microsoft.VisualStudio.Services.Common; namespace APIViewWeb.Managers { @@ -389,87 +384,6 @@ public async Task UpdateReviewBackground(HashSet updateDisabledLanguages } } - public async Task UpdateSwaggerReviewsMetaData() - { - IList reviews = (await _reviewsRepository.GetReviewsAsync(isClosed: false, language: "Swagger", fetchAllPages: true)).ToList(); - reviews.AddRange(await _reviewsRepository.GetReviewsAsync(isClosed: true, language: "Swagger", fetchAllPages: true)); - - int reviewsProcessed = 0; - - foreach (var review in reviews) - { - var comments = await _commentsRepository.GetCommentsAsync(review.ReviewId); - - foreach (var comment in comments) - { - if (comment.ElementId.EndsWith("table") && !String.IsNullOrEmpty(comment.GroupNo)) - { - comment.ElementId = comment.ElementId + $"-tr-{comment.GroupNo}"; - await _commentsRepository.UpsertCommentAsync(comment); - } - } - - foreach (var revision in review.Revisions) - { - // Update Number of Lines in LeafSections - var renderedCodeFile = await _codeFileRepository.GetCodeFileAsync(revision); - - for (int i = 0; i < renderedCodeFile.CodeFile.Tokens.Length; i++) - { - if (renderedCodeFile.CodeFile.Tokens[i].Kind == CodeFileTokenKind.LeafSectionPlaceholder) - { - var leafSection = renderedCodeFile.CodeFile.LeafSections[(Convert.ToInt16(renderedCodeFile.CodeFile.Tokens[i].Value))]; - CodeLine[] renderedLeafSection = CodeFileHtmlRenderer.Normal.Render(leafSection); - renderedCodeFile.CodeFile.Tokens[i].NumberOfLinesinLeafSection = renderedLeafSection.Length; - } - } - - await _codeFileRepository.UpsertCodeFileAsync(revision.RevisionId, revision.SingleFile.ReviewFileId, renderedCodeFile.CodeFile); - await GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, revision); - - bool entireFileRendered = false; - - foreach (var comment in comments) - { - if ((comment.RevisionId == revision.RevisionId) && comment.ElementId.EndsWith("table") && String.IsNullOrEmpty(comment.GroupNo)) - { - int rowCount = 0; - - if (!entireFileRendered) - renderedCodeFile.Render(false); - - foreach (var codeLine in renderedCodeFile.RenderResult.CodeLines) - { - if (codeLine.SectionKey != null) - { - var sectionLines = renderedCodeFile.GetCodeLineSection((int)codeLine.SectionKey); - - foreach (var sectionLine in sectionLines) - { - if (!String.IsNullOrEmpty(sectionLine.ElementId) && sectionLine.ElementId.StartsWith(comment.ElementId)) - rowCount++; - } - if (rowCount > 1) - { - comment.ElementId = comment.ElementId + $"-tr-{rowCount - 1}"; - await _commentsRepository.UpsertCommentAsync(comment); - break; - } - } - } - if (rowCount > 0) - { - break; - } - } - } - } - reviewsProcessed++; - _telemetryClient.TrackTrace("Swagger Reviews Updated: " + reviewsProcessed); - - } - } - // Languages that full ysupport sandboxing updates reviews using Azure devops pipeline // We should batch all eligible reviews to avoid a pipeline run storm private async Task UpdateReviewsUsingPipeline(string language, LanguageService languageService, int backgroundBatchProcessCount) @@ -668,8 +582,11 @@ public async Task UpdateReviewCodeFiles(string repoName, string buildId, string file.PackageName = codeFile.PackageName; await _reviewsRepository.UpsertReviewAsync(review); - // Trigger diff calculation using updated code file from sandboxing pipeline - await GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, revision); + if (!String.IsNullOrEmpty(review.Language) && review.Language == "Swagger") + { + // Trigger diff calculation using updated code file from sandboxing pipeline + await GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, revision); + } } } } @@ -856,7 +773,7 @@ private async Task AddRevisionAsync( review.ServiceName = p?.ServiceName ?? review.ServiceName; } - var languageService = language != null ? _languageServices.FirstOrDefault( l=> l.Name == language) : _languageServices.FirstOrDefault(s => s.IsSupportedFile(name)); + var languageService = language != null ? _languageServices.FirstOrDefault(l => l.Name == language) : _languageServices.FirstOrDefault(s => s.IsSupportedFile(name)); // Run pipeline to generate the review if sandbox is enabled if (languageService != null && languageService.IsReviewGenByPipeline) { @@ -868,13 +785,17 @@ private async Task AddRevisionAsync( await _notificationManager.SubscribeAsync(review, user); await _reviewsRepository.UpsertReviewAsync(review); await _notificationManager.NotifySubscribersOnNewRevisionAsync(revision, user); - if (awaitComputeDiff) - { - await GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, revision); - } - else + + if (!String.IsNullOrEmpty(review.Language) && review.Language == "Swagger") { - _ = Task.Run(async () => await GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, revision)); + if (awaitComputeDiff) + { + await GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, revision); + } + else + { + _ = Task.Run(async () => await GetLineNumbersOfHeadingsOfSectionsWithDiff(review.ReviewId, revision)); + } } } diff --git a/src/dotnet/APIView/APIViewWeb/Pages/Assemblies/Review.cshtml b/src/dotnet/APIView/APIViewWeb/Pages/Assemblies/Review.cshtml index 1f86a584928..0003b71ef52 100644 --- a/src/dotnet/APIView/APIViewWeb/Pages/Assemblies/Review.cshtml +++ b/src/dotnet/APIView/APIViewWeb/Pages/Assemblies/Review.cshtml @@ -8,6 +8,7 @@ var userPreference = PageModelHelpers.GetUserPreference(Model._preferenceCache, User); TempData["UserPreference"] = userPreference; TempData["LanguageCssSafeName"] = Model.Review.GetLanguageCssSafeName(); + TempData["Comments"] = Model.Comments; ViewBag.HasSections = (Model.CodeFile.LeafSections?.Count > 0) ? true : false; } @{ diff --git a/src/dotnet/APIView/APIViewWeb/Pages/Assemblies/_CodeLine.cshtml b/src/dotnet/APIView/APIViewWeb/Pages/Assemblies/_CodeLine.cshtml index 326ec0fa251..f35ef228123 100644 --- a/src/dotnet/APIView/APIViewWeb/Pages/Assemblies/_CodeLine.cshtml +++ b/src/dotnet/APIView/APIViewWeb/Pages/Assemblies/_CodeLine.cshtml @@ -1,251 +1,260 @@ -@using ApiView -@using APIView.Model -@using APIView.DIff -@using System.Text.RegularExpressions -@using APIViewWeb.Models; -@using System.Text -@using APIViewWeb.Helpers -@using Microsoft.AspNetCore.Mvc.TagHelpers -@model APIViewWeb.Models.CodeLineModel -@functions -{ - public static string RemoveMultipleSpaces(string str) - { - return String.Join(" ", str.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)); - } -} -@{ - bool isRemoved = Model.Kind == DiffLineKind.Removed; - string lineClass = Model.Kind switch - { - DiffLineKind.Removed => "code-removed", - DiffLineKind.Added => "code-added", - _ => "" - }; - - bool isSectionHeading = false; - bool isSubSectionHeading = false; - bool isHeadingWithDelta = false; - string headingClass = String.Empty; - string documentationRow = String.Empty; - string hiddenApiRow = String.Empty; - string codeLineDisplay = String.Empty; - string codeLineClass = (!String.IsNullOrWhiteSpace(Model.CodeLine.LineClass)) ? Model.CodeLine.LineClass.Trim() : String.Empty; - int? sectionKey = Model.DiffSectionId ?? Model.CodeLine.SectionKey; - - if (sectionKey != null) - { - isSectionHeading = true; - headingClass = $"code-line-section-heading-{sectionKey}"; - if (Model.IsDiffView) - { - switch (Model.Kind) - { - case DiffLineKind.Added: - headingClass += $" rev-a-heading-{Model.CodeLine.SectionKey}"; - break; - case DiffLineKind.Removed: - headingClass += $" rev-b-heading-{Model.CodeLine.SectionKey}"; - break; - case DiffLineKind.Unchanged: - headingClass += $" rev-a-heading-{Model.CodeLine.SectionKey}"; - headingClass += $" rev-b-heading-{Model.OtherLineSectionKey}"; - break; - } - } - } - - if(Model.CodeLine.IsDocumentation) - { - documentationRow = "code-line-documentation"; - codeLineDisplay = "hidden-row"; - } - - var userPreference = TempData["UserPreference"] as UserPreferenceModel ?? new UserPreferenceModel(); - - // Always show hidden APIs if we are in Diff mode and there are changes. - if (Model.CodeLine.IsHiddenApi && Model.Kind == DiffLineKind.Unchanged) - { - hiddenApiRow += PageModelHelpers.GetHiddenApiClass(userPreference); - } - - if (Regex.IsMatch(codeLineClass, @".*lvl_[0-9]+_parent.*")) - { - isSubSectionHeading = true; - } - - if (Model.Kind == DiffLineKind.Unchanged && - ((isSubSectionHeading && Model.IsSubHeadingWithDiffInSection) || (isSectionHeading && Model.HeadingsOfSectionsWithDiff.Contains(Model.LineNumber)))) - { - lineClass += " code-delta"; - isHeadingWithDelta = true; - } - var rowClass = RemoveMultipleSpaces($"code-line {headingClass} {codeLineClass} {lineClass} {codeLineDisplay} {documentationRow} {hiddenApiRow}"); - var cellContent = String.Empty; - for (int i = 0; i < Model.CodeLine.Indent; i++) - { - if ((isSectionHeading || isSubSectionHeading) && (i == Model.CodeLine.Indent - 1)) - { - cellContent += @" - - - "; - }else - { - cellContent += @" - - "; - } - } - cellContent += Model.CodeLine.DisplayString; -} - - - - - - @if(userPreference.HideLineNumbers == true) - { - var lineNumberClass = RemoveMultipleSpaces($"line-number d-none"); - - } - else - { - var lineNumberClass = RemoveMultipleSpaces($"line-number"); - if (Model.CodeLine.IsDocumentation) - { - +@using ApiView +@using APIView.Model +@using APIView.DIff +@using System.Text.RegularExpressions +@using APIViewWeb.Models; +@using System.Text +@using APIViewWeb.Helpers +@using Microsoft.AspNetCore.Mvc.TagHelpers +@model APIViewWeb.Models.CodeLineModel +@functions +{ + public static string RemoveMultipleSpaces(string str) + { + return String.Join(" ", str.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)); + } +} +@{ + bool isRemoved = Model.Kind == DiffLineKind.Removed; + string lineClass = Model.Kind switch + { + DiffLineKind.Removed => "code-removed", + DiffLineKind.Added => "code-added", + _ => "" + }; + + bool isSectionHeading = false; + bool isSubSectionHeading = false; + bool isHeadingWithDelta = false; + string headingClass = String.Empty; + string documentationRow = String.Empty; + string hiddenApiRow = String.Empty; + string codeLineDisplay = String.Empty; + string codeLineClass = (!String.IsNullOrWhiteSpace(Model.CodeLine.LineClass)) ? Model.CodeLine.LineClass.Trim() : String.Empty; + int? sectionKey = Model.DiffSectionId ?? Model.CodeLine.SectionKey; + + if (sectionKey != null) + { + isSectionHeading = true; + headingClass = $"code-line-section-heading-{sectionKey}"; + if (Model.IsDiffView) + { + switch (Model.Kind) + { + case DiffLineKind.Added: + headingClass += $" rev-a-heading-{Model.CodeLine.SectionKey}"; + break; + case DiffLineKind.Removed: + headingClass += $" rev-b-heading-{Model.CodeLine.SectionKey}"; + break; + case DiffLineKind.Unchanged: + headingClass += $" rev-a-heading-{Model.CodeLine.SectionKey}"; + headingClass += $" rev-b-heading-{Model.OtherLineSectionKey}"; + break; + } + } + } + + if(Model.CodeLine.IsDocumentation) + { + documentationRow = "code-line-documentation"; + codeLineDisplay = "hidden-row"; + } + + var userPreference = TempData["UserPreference"] as UserPreferenceModel ?? new UserPreferenceModel(); + + // Always show hidden APIs if we are in Diff mode and there are changes. + if (Model.CodeLine.IsHiddenApi && Model.Kind == DiffLineKind.Unchanged) + { + hiddenApiRow += PageModelHelpers.GetHiddenApiClass(userPreference); + } + + if (Regex.IsMatch(codeLineClass, @".*lvl_[0-9]+_parent.*")) + { + isSubSectionHeading = true; + } + + if (Model.Kind == DiffLineKind.Unchanged && + ((isSubSectionHeading && Model.IsSubHeadingWithDiffInSection) || (isSectionHeading && Model.HeadingsOfSectionsWithDiff.Contains(Model.LineNumber)))) + { + lineClass += " code-delta"; + isHeadingWithDelta = true; + } + var rowClass = RemoveMultipleSpaces($"code-line {headingClass} {codeLineClass} {lineClass} {codeLineDisplay} {documentationRow} {hiddenApiRow}"); + var cellContent = String.Empty; + for (int i = 0; i < Model.CodeLine.Indent; i++) + { + if ((isSectionHeading || isSubSectionHeading) && (i == Model.CodeLine.Indent - 1)) + { + cellContent += @" + + + "; + }else + { + cellContent += @" + + "; + } + } + cellContent += Model.CodeLine.DisplayString; +} + + + - - -@if (isSectionHeading) -{ - -} -@if (Model.Diagnostics.Any()) -{ - var errorDiags = Model.Diagnostics.Where(d => d.Level == APIView.CodeDiagnosticLevel.Default || d.Level == APIView.CodeDiagnosticLevel.Error); - var warningDiags = Model.Diagnostics.Where(d => d.Level == APIView.CodeDiagnosticLevel.Warning); - var infoDiags = Model.Diagnostics.Where(d => d.Level == APIView.CodeDiagnosticLevel.Info); - var diagnosticsClass = "code-diagnostics"; - if (userPreference.ShowSystemComments != true) - diagnosticsClass += " d-none"; - - if (Model.CodeLine.IsHiddenApi) - { - diagnosticsClass += PageModelHelpers.GetHiddenApiClass(userPreference); - } - - - - - - - - - -} - -@if (Model.CommentThread != null) -{ - + @{ + var iconCommentClass = "icon-comments invisible"; + var comments = TempData["Comments"] as ReviewCommentsModel; + if (sectionKey != null && comments.Threads.Count() > 0) + { + if (comments.Threads.Any(c => c.LineClass != null && c.LineClass.Contains($"code-line-section-content-{sectionKey}"))) + { + iconCommentClass = $"{iconCommentClass} comment-in-section"; } } + + } + + + + +
@Model.LineNumber
+ + + @if(userPreference.HideLineNumbers == true) + { + var lineNumberClass = RemoveMultipleSpaces($"line-number d-none"); + + } + else + { + var lineNumberClass = RemoveMultipleSpaces($"line-number"); + if (Model.CodeLine.IsDocumentation) + { + + } + else + { + if (Model.IsDiffView) + { + var paddValue = new string('0', Model.LineNumber.ToString().Length); + if (Model.Kind == DiffLineKind.Added) + { + + + } + else if (Model.Kind == DiffLineKind.Removed) + { + + + } + else + { + + + } + } + else + { + + } + } + } + - - } - else if (Model.Kind == DiffLineKind.Removed) - { - - - } - else - { - - - } - } - else - { - - } - } - } - - - - -
@Model.LineNumber@Model.LineNumber@Model.LineNumber@Model.LineNumber@Model.LineNumber@Model.LineNumber + @if (!isRemoved && Model.CodeLine.ElementId != null) + { + + + } + else + { + // Added for visual consistency } - else - { - if (Model.IsDiffView) - { - var paddValue = new string('0', Model.LineNumber.ToString().Length); - if (Model.Kind == DiffLineKind.Added) - { - @Model.LineNumber@Model.LineNumber@Model.LineNumber@Model.LineNumber@Model.LineNumber - @if (!isRemoved && Model.CodeLine.ElementId != null) - { - + - } - else - { - // Added for visual consistency - } - - - @if(Model.DocumentedByLines?.Length > 0) - { - - - - - } - @if (Model.CodeLine.IsDocumentation) - { - - - - - - } - - @if (isSectionHeading || isSubSectionHeading) - { - - } -
-
- @{ - string collapseMenu = (isSectionHeading || isSubSectionHeading) ? "" : ""; - } - @if (Model.Kind == DiffLineKind.Removed) - { - var codeRemovedSign = @" - - - "; -
@Html.Raw(codeRemovedSign)@Html.Raw(cellContent)@Html.Raw(collapseMenu)
- } - else if(Model.Kind == DiffLineKind.Added) - { - var codeAddedSign = @" - + - "; -
@Html.Raw(codeAddedSign)@Html.Raw(cellContent)@Html.Raw(collapseMenu)
- } - else - { - var indentOrDelta = String.Empty; - if (isHeadingWithDelta) - { - indentOrDelta = @" - ± - "; - } - else - { - indentOrDelta = ""; - } -
@Html.Raw(indentOrDelta)@Html.Raw(cellContent)@Html.Raw(collapseMenu)
- } -
+ @if(Model.DocumentedByLines?.Length > 0) + { + + + + + } + @if (Model.CodeLine.IsDocumentation) + { + + + + + + } + + @if (isSectionHeading || isSubSectionHeading) + { + + } +
+ + + @{ + string collapseMenu = (isSectionHeading || isSubSectionHeading) ? "" : ""; + } + @if (Model.Kind == DiffLineKind.Removed) + { + var codeRemovedSign = @" + - + "; +
@Html.Raw(codeRemovedSign)@Html.Raw(cellContent)@Html.Raw(collapseMenu)
+ } + else if(Model.Kind == DiffLineKind.Added) + { + var codeAddedSign = @" + + + "; +
@Html.Raw(codeAddedSign)@Html.Raw(cellContent)@Html.Raw(collapseMenu)
+ } + else + { + var indentOrDelta = String.Empty; + if (isHeadingWithDelta) + { + indentOrDelta = @" + ± + "; + } + else + { + indentOrDelta = ""; + } +
@Html.Raw(indentOrDelta)@Html.Raw(cellContent)@Html.Raw(collapseMenu)
+ } + + +@if (isSectionHeading) +{ + +} +@if (Model.Diagnostics.Any()) +{ + var errorDiags = Model.Diagnostics.Where(d => d.Level == APIView.CodeDiagnosticLevel.Default || d.Level == APIView.CodeDiagnosticLevel.Error); + var warningDiags = Model.Diagnostics.Where(d => d.Level == APIView.CodeDiagnosticLevel.Warning); + var infoDiags = Model.Diagnostics.Where(d => d.Level == APIView.CodeDiagnosticLevel.Info); + var diagnosticsClass = "code-diagnostics"; + if (userPreference.ShowSystemComments != true) + diagnosticsClass += " d-none"; + + if (Model.CodeLine.IsHiddenApi) + { + diagnosticsClass += PageModelHelpers.GetHiddenApiClass(userPreference); + } + + + + + + + + + +} + +@if (Model.CommentThread != null) +{ + } \ No newline at end of file diff --git a/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs b/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs index 612d2b251ac..b6e7024f955 100644 --- a/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs +++ b/src/dotnet/APIView/APIViewWeb/Repositories/DevopsArtifactRepository.cs @@ -52,7 +52,7 @@ public async Task DownloadPackageArtifact(string repoName, string buildI } downloadUrl = downloadUrl.Split("?")[0] + "?format=" + format + "&subPath=" + filePath; } - + var downloadResp = await GetFromDevopsAsync(downloadUrl); downloadResp.EnsureSuccessStatusCode(); return await downloadResp.Content.ReadAsStreamAsync(); @@ -65,9 +65,9 @@ private async Task GetFromDevopsAsync(string request) SetDevopsClientHeaders(); var downloadResp = await _devopsClient.GetAsync(request); int count = 0; - while (downloadResp.StatusCode == HttpStatusCode.TooManyRequests && count < 5) + while ((downloadResp.StatusCode == HttpStatusCode.TooManyRequests || downloadResp.StatusCode == HttpStatusCode.BadRequest) && count < 5) { - var retryAfter = downloadResp.Headers.RetryAfter.ToString(); + var retryAfter = (downloadResp.Headers.RetryAfter is null) ? "10" : downloadResp.Headers.RetryAfter.ToString(); _telemetryClient.TrackTrace($"Download request from devops artifact is throttled. Retry After: {retryAfter}, Retry count: {count}"); await Task.Delay(int.Parse(retryAfter) * 1000); downloadResp = await _devopsClient.GetAsync(request); diff --git a/src/dotnet/APIView/APIViewWeb/Startup.cs b/src/dotnet/APIView/APIViewWeb/Startup.cs index 9c5cb874a64..417d50c2009 100644 --- a/src/dotnet/APIView/APIViewWeb/Startup.cs +++ b/src/dotnet/APIView/APIViewWeb/Startup.cs @@ -26,6 +26,7 @@ using APIView.Identity; using APIViewWeb.Managers; using APIViewWeb.Hubs; +using System.Text.Json.Serialization; namespace APIViewWeb { @@ -221,8 +222,8 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddHostedService(); services.AddHostedService(); - services.AddHostedService(); services.AddAutoMapper(Assembly.GetExecutingAssembly()); + services.AddControllersWithViews().AddJsonOptions(options => options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve); services.AddSignalR(options => { options.EnableDetailedErrors = true; }); diff --git a/src/dotnet/APIView/apiview.yml b/src/dotnet/APIView/apiview.yml index 1ccfe1bca33..6b7f91ab963 100644 --- a/src/dotnet/APIView/apiview.yml +++ b/src/dotnet/APIView/apiview.yml @@ -221,6 +221,26 @@ stages: APIVIEW_ENDPOINT: "http://localhost:5000" APIVIEW_BLOB__CONNECTIONSTRING: $(AzuriteConnectionString) APIVIEW_COSMOS__CONNECTIONSTRING: $(CosmosEmulatorConnectionString) + + - script: | + npm install + workingDirectory: $(WebClientProjectDirectory) + displayName: "Install Client Dependencies" + + - script: | + npx playwright install --with-deps + workingDirectory: $(WebClientProjectDirectory) + displayName: "Install Playwright Browsers" + + - script: | + npx playwright test --project=unit-tests + workingDirectory: $(WebClientProjectDirectory) + displayName: "Run Client-Side Unit Tests" + + - task: PublishBuildArtifacts@1 + inputs: + pathtoPublish: '$(Build.SourcesDirectory)\src\dotnet\APIView\APIViewWeb\Client\playwright-report' + artifactName: 'Client-Side Unit Test Reports' - ${{ if and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['System.TeamProject'], 'internal')) }}: - template: /eng/pipelines/templates/steps/apiview-ui-tests.yml diff --git a/src/dotnet/Mgmt.CI.BuildTools/ci.yml b/src/dotnet/Mgmt.CI.BuildTools/ci.yml index 35a84642eb8..45fe79c9cc4 100644 --- a/src/dotnet/Mgmt.CI.BuildTools/ci.yml +++ b/src/dotnet/Mgmt.CI.BuildTools/ci.yml @@ -8,7 +8,7 @@ resources: - repository: azure-sdk-build-tools type: git name: internal/azure-sdk-build-tools - ref: refs/tags/azure-sdk-build-tools_20230425.1 + ref: refs/tags/azure-sdk-build-tools_20230530.2 variables: - template: /eng/pipelines/templates/variables/globals.yml diff --git a/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/CHANGELOG b/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/CHANGELOG index 6d99da258ef..b85b9a554be 100644 --- a/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/CHANGELOG +++ b/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/CHANGELOG @@ -1,5 +1,8 @@ # Release History +## 1.0.9 (5-22-2023) ++ Support schema for parameters referenced in relative files + ## 1.0.8 (3-1-2023) + Skip any invalid swagger file path when generating API review diff --git a/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerAPIViewGenerator.cs b/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerAPIViewGenerator.cs index 134037737b8..62c157ab6be 100644 --- a/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerAPIViewGenerator.cs +++ b/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerAPIViewGenerator.cs @@ -90,7 +90,7 @@ public static async Task GenerateSwaggerApiView(SwaggerSpec var referenceSwaggerSpec = await SwaggerDeserializer.Deserialize(referenceSwaggerFilePath); referenceSwaggerSpec.swaggerFilePath = Path.GetFullPath(referenceSwaggerFilePath); AddDefinitionsToCache(referenceSwaggerSpec, referenceSwaggerFilePath, schemaCache); - param = schemaCache.GetParameterFromCache(parameter.Ref, referenceSwaggerFilePath); + param = schemaCache.GetParameterFromCache(parameter.Ref, currentSwaggerFilePath); } else { diff --git a/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerApiParser.csproj b/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerApiParser.csproj index 78d291a3ec9..958dac04484 100644 --- a/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerApiParser.csproj +++ b/tools/apiview/parsers/swagger-api-parser/SwaggerApiParser/SwaggerApiParser.csproj @@ -6,7 +6,7 @@ swaggerAPIParser net6.0 swagger_api_parser - 1.0.8 + 1.0.9 Azure.Sdk.Tools.SwaggerApiParser True $(OfficialBuildId) diff --git a/tools/github-labels/common-labels.csv b/tools/github-labels/common-labels.csv index f77a0427468..bfc1bd4ccc3 100644 --- a/tools/github-labels/common-labels.csv +++ b/tools/github-labels/common-labels.csv @@ -234,7 +234,6 @@ Label,VideoAnalyzer,Azure Video Analyzer,e99695 Label,Visual Studio,,e99695 Label,Web Apps,,e99695 Label,WebPubSub,,e99695 -Label,auto-merge,Apply to PR's that we want to auto-merge when green,5df772 Label,blocking-release,Blocks release,d73a49 Label,breaking-change,,d73a49 Label,bug,This issue requires a change to an existing behavior in the product in order to be resolved.,eaa875 @@ -244,6 +243,7 @@ Label,duplicate,This issue is the duplicate of another issue,e4c288 Label,feature-request,This issue requires a new behavior in the product in order be resolved.,eaa875 Label,good first issue,This issue tracks work that may be a good starting point for a first-time contributor,64c170 Label,help wanted,This issue is tracking work for which community contributions would be welcomed and appreciated,64c170 +Label,late-blocker,Changes based on review feedback before imminent release.,d8f74c Label,issue-addressed,The Azure SDK team member assisting with this issue believes it to be addressed and ready to close.,5df772 Label,needs-author-feedback,More information is needed from author to address the issue.,f72598 Label,needs-team-attention,This issue needs attention from Azure service team or SDK team,3BA0F8 @@ -257,4 +257,4 @@ Label,pillar-reliability,"The issue is related to reliability, one of our core e Label,question,The issue doesn't require a change to the product in order to be resolved. Most issues start as that,eaa875 Label,test-enhancement,,7365c9 Label,test-manual-pass,,c0eaf9 -Label,test-reliability,Issue that causes tests to be unreliable,e04545 \ No newline at end of file +Label,test-reliability,Issue that causes tests to be unreliable,e04545 diff --git a/tools/github/scripts/Sync-AzsdkLabels.ps1 b/tools/github/scripts/Sync-AzsdkLabels.ps1 new file mode 100644 index 00000000000..784255616e5 --- /dev/null +++ b/tools/github/scripts/Sync-AzsdkLabels.ps1 @@ -0,0 +1,82 @@ +[CmdletBinding(DefaultParameterSetName = 'RepositoryFile', SupportsShouldProcess = $true)] +param ( + [Parameter(Position = 0, Mandatory = $true)] + [ValidatePattern('^\w+/[\w-]+$')] + [string] $SourceRepository, + + [Parameter(ParameterSetName = 'RepositoryFile')] + [ValidateScript({Test-Path $_ -PathType 'Leaf'})] + [string] $RepositoryFilePath = "$PSScriptRoot/../repositories.txt", + + [Parameter(ParameterSetName = 'Repositories')] + [ValidateNotNullOrEmpty()] + [string[]] $Repositories = @( + 'Azure/azure-sdk-for-cpp' + 'Azure/azure-sdk-for-go' + 'Azure/azure-sdk-for-java' + 'Azure/azure-sdk-for-js' + 'Azure/azure-sdk-for-net' + 'Azure/azure-sdk-for-python' + 'Azure/azure-sdk-tools' + ), + + [Parameter(ParameterSetName = 'Languages')] + [ValidateNotNullOrEmpty()] + [string[]] $Languages = @('cpp', 'go', 'java', 'js', 'net', 'python'), + + [Parameter()] + [switch] $Force +) + +if (!(Get-Command -Type Application gh -ErrorAction Ignore)) { + throw 'You must first install the GitHub CLI: https://github.com/cli/cli/tree/trunk#installation' +} + +if ($PSCmdlet.ParameterSetName -eq 'Languages') { + $Repositories = foreach ($lang in $Languages) { + "Azure/azure-sdk-for-$lang" + } +} + +if ($PSCmdlet.ParameterSetName -eq 'RepositoryFile') { + $Repositories = Get-Content $RepositoryFilePath +} + +# Filter out the source repository. +$Repositories = $Repositories.Where({$_ -ne $SourceRepository}) + +foreach ($repo in $Repositories) { + if ($Force -or $PSCmdlet.ShouldProcess( + "Cloning labels from $SourceRepository to $repo", + "Clone labels from $SourceRepository to $repo?", + "Clone labels")) { + $result = gh -R "$repo" label clone "$SourceRepository" --force 2>&1 + if ($LASTEXITCODE) { + Write-Error "Failed to clone labels from $SourceRepository to ${repo}: $result" + } + } +} + +<# +.SYNOPSIS +Clones labels from a source repository to all other repositories. + +.DESCRIPTION +Clones labels - without deleting any - from a source repository to all other listed repositories. + +.PARAMETER Repositories +The GitHub repositories to update. + +.PARAMETER Languages +The Azure SDK languages to query for milestones e.g., "net" for "Azure/azure-sdk-for-net". + +.PARAMETER RepositoryFilePath +The fully-qualified path (including filename) to a new line-delmited file of respositories to update. + +.PARAMETER Force +Create milestones for each repository without prompting. + +.EXAMPLE +Sync-AzsdkLabels.ps1 -WhatIf +See which repositories will receive cloned labels. +#> diff --git a/tools/pylint-extensions/azure-pylint-guidelines-checker/README.md b/tools/pylint-extensions/azure-pylint-guidelines-checker/README.md index 30efe7729b5..e71157db794 100644 --- a/tools/pylint-extensions/azure-pylint-guidelines-checker/README.md +++ b/tools/pylint-extensions/azure-pylint-guidelines-checker/README.md @@ -80,4 +80,10 @@ In the case of a false positive, use the disable command to remove the pylint er | name-too-long | Check that the length of class names, function names, and variable names are under 40 characters. | # pylint:disable=name-too-long |[link](https://github.com/Azure/azure-sdk-for-python/issues/26640) | delete-operation-wrong-return-type | Check that delete* or begin_delete* methods return None or LROPoller[None]. | # pylint:disable=delete-operation-wrong-return-type | [link](https://github.com/Azure/azure-sdk-for-python/issues/26662) client-method-missing-tracing-decorator |pylint:disable=client-method-missing-tracing-decorator | Check that sync client methods that make network calls have the sync distributed tracing decorator. | [link](https://guidelinescollab.github.io/azure-sdk/python_implementation.html#distributed-tracing) | -client-method-missing-tracing-decorator-async | pylint:disable=client-method-missing-tracing-decorator-async | Check that async client methods that make network calls have the async distributed tracing decorator. | [link](https://guidelinescollab.github.io/azure-sdk/python_implementation.html#distributed-tracing) | \ No newline at end of file +client-method-missing-tracing-decorator-async | pylint:disable=client-method-missing-tracing-decorator-async | Check that async client methods that make network calls have the async distributed tracing decorator. | [link](https://guidelinescollab.github.io/azure-sdk/python_implementation.html#distributed-tracing) | +client-list-methods-use-paging | pylint:disable=client-list-methods-use-paging | Client methods that return collections should use the Paging protocol. | [link](https://azure.github.io/azure-sdk/python_design.html#response-formats) | +docstring-missing-param | pylint:disable=docstring-missing-param | Docstring missing for param. | [link](https://azure.github.io/azure-sdk/python_documentation.html#docstrings) | +docstring-missing-type | pylint:disable=docstring-missing-type | Docstring missing for param type. | [link](https://azure.github.io/azure-sdk/python_documentation.html#docstrings) | +docstring-missing-return | pylint:disable=docstring-missing-return | Docstring missing return. | [link](https://azure.github.io/azure-sdk/python_documentation.html#docstrings) | +docstring-missing-rtype | pylint:disable=docstring-missing-rtype | Docstring missing return type. | [link](https://azure.github.io/azure-sdk/python_documentation.html#docstrings) | +docstring-should-be-keyword | pylint:disable=docstring-should-be-keyword | Docstring should use keywords. | [link](https://azure.github.io/azure-sdk/python_documentation.html#docstrings) | diff --git a/tools/pylint-extensions/azure-pylint-guidelines-checker/pylint_guidelines_checker.py b/tools/pylint-extensions/azure-pylint-guidelines-checker/pylint_guidelines_checker.py index 8b62ec98d7d..769d7719028 100644 --- a/tools/pylint-extensions/azure-pylint-guidelines-checker/pylint_guidelines_checker.py +++ b/tools/pylint-extensions/azure-pylint-guidelines-checker/pylint_guidelines_checker.py @@ -865,19 +865,19 @@ def visit_call(self, node): class ClientListMethodsUseCorePaging(BaseChecker): __implements__ = IAstroidChecker - name = "client-list-methods-use-paging" + name = "client-paging-methods-use-list" priority = -1 msgs = { "C4733": ( - "Operations that return collections should return a value that implements the Paging protocol. See details:" + "Operations that return collections should return a value that implements the Paging protocol and be prefixed with list_ or _list_. See details:" " https://azure.github.io/azure-sdk/python_design.html#response-formats", - "client-list-methods-use-paging", - "Client methods that return collections should use the Paging protocol.", + "client-paging-methods-use-list", + "Client methods that return collections should use the Paging protocol and be prefixed with list_ or _list_.", ), } options = ( ( - "ignore-client-list-methods-use-paging", + "ignore-client-paging-methods-use-list", { "default": False, "type": "yn", @@ -892,29 +892,33 @@ def __init__(self, linter=None): super(ClientListMethodsUseCorePaging, self).__init__(linter) def visit_return(self, node): - """Visits every method in the client and checks that any list_ methods return - an ItemPaged or AsyncItemPaged value. + """Visits every method in the client and checks that any list methods return + an ItemPaged or AsyncItemPaged value. Also, checks that if a method returns an iterable value + that the method name starts with list. :param node: function node :type node: ast.FunctionDef :return: None """ try: + iterable_return = False + paging_method = False if node.parent.parent.name.endswith("Client") and node.parent.parent.name not in self.ignore_clients and node.parent.is_method(): - if node.parent.name.startswith("list"): - paging_class = False + try: + if any(v for v in node.value.infer() if "def by_page" in v.as_string()): + iterable_return = True + except (astroid.exceptions.InferenceError, AttributeError, TypeError): # astroid can't always infer the return + logger.debug("Pylint custom checker failed to check if client list method uses core paging.") + return - try: - if any(v for v in node.value.infer() if "def by_page" in v.as_string()): - paging_class = True - except (astroid.exceptions.InferenceError, AttributeError, TypeError): # astroid can't always infer the return - logger.debug("Pylint custom checker failed to check if client list method uses core paging.") - return + if node.parent.name.startswith("list") or node.parent.name.startswith("_list"): + paging_method = True - if not paging_class: - self.add_message( - msgid="client-list-methods-use-paging", node=node.parent, confidence=None - ) + if (not paging_method and iterable_return) or (paging_method and not iterable_return): + self.add_message( + msgid="client-paging-methods-use-list", node=node.parent, confidence=None + ) + except (AttributeError, TypeError): logger.debug("Pylint custom checker failed to check if client list method uses core paging.") pass diff --git a/tools/pylint-extensions/azure-pylint-guidelines-checker/tests/test_pylint_custom_plugins.py b/tools/pylint-extensions/azure-pylint-guidelines-checker/tests/test_pylint_custom_plugins.py index 9b0a3fc3c41..19312a9415f 100644 --- a/tools/pylint-extensions/azure-pylint-guidelines-checker/tests/test_pylint_custom_plugins.py +++ b/tools/pylint-extensions/azure-pylint-guidelines-checker/tests/test_pylint_custom_plugins.py @@ -1932,10 +1932,10 @@ def list_thing2(self): #@ with self.assertAddsMessages( pylint.testutils.MessageTest( - msg_id="client-list-methods-use-paging", line=5, node=function_node_a, col_offset=4, end_line=5, end_col_offset=18 + msg_id="client-paging-methods-use-list", line=5, node=function_node_a, col_offset=4, end_line=5, end_col_offset=18 ), pylint.testutils.MessageTest( - msg_id="client-list-methods-use-paging", line=7, node=function_node_b, col_offset=4, end_line=7, end_col_offset=19 + msg_id="client-paging-methods-use-list", line=7, node=function_node_b, col_offset=4, end_line=7, end_col_offset=19 ), ): self.checker.visit_return(function_node_a.body[0]) @@ -1955,15 +1955,47 @@ async def list_thing2(self, **kwargs): #@ with self.assertAddsMessages( pylint.testutils.MessageTest( - msg_id="client-list-methods-use-paging", line=6, node=function_node_a, col_offset=4, end_line=6, end_col_offset=24 + msg_id="client-paging-methods-use-list", line=6, node=function_node_a, col_offset=4, end_line=6, end_col_offset=24 ), pylint.testutils.MessageTest( - msg_id="client-list-methods-use-paging", line=8, node=function_node_b, col_offset=4, end_line=8, end_col_offset=25 + msg_id="client-paging-methods-use-list", line=8, node=function_node_b, col_offset=4, end_line=8, end_col_offset=25 ), ): self.checker.visit_return(function_node_a.body[0]) self.checker.visit_return(function_node_b.body[0]) + def test_finds_return_ItemPaged_not_list(self): + class_node, function_node_a = astroid.extract_node(""" + from azure.core.paging import ItemPaged + + class SomeClient(): #@ + def some_thing(self): #@ + return ItemPaged() + """) + + with self.assertAddsMessages( + pylint.testutils.MessageTest( + msg_id="client-paging-methods-use-list", line=5, node=function_node_a, col_offset=4, end_line=5, end_col_offset=18 + ), + ): + self.checker.visit_return(function_node_a.body[0]) + + def test_finds_return_AsyncItemPaged_not_list(self): + class_node, function_node_a = astroid.extract_node(""" + from azure.core.async_paging import AsyncItemPaged + + class SomeClient(): #@ + async def some_thing(self): #@ + return AsyncItemPaged() + """) + + with self.assertAddsMessages( + pylint.testutils.MessageTest( + msg_id="client-paging-methods-use-list", line=5, node=function_node_a, col_offset=4, end_line=5, end_col_offset=18 + ), + ): + self.checker.visit_return(function_node_a.body[0]) + def test_core_paging_file_custom_class_acceptable_and_violation(self): file = open(os.path.join(TEST_FOLDER, "test_files", "core_paging_acceptable_and_violation.py")) node = astroid.parse(file.read()) @@ -1975,14 +2007,13 @@ def test_core_paging_file_custom_class_acceptable_and_violation(self): with self.assertAddsMessages( pylint.testutils.MessageTest( - msg_id="client-list-methods-use-paging", line=32, node=function_node_b, col_offset=4, end_line=32, end_col_offset=32 + msg_id="client-paging-methods-use-list", line=32, node=function_node_b, col_offset=4, end_line=32, end_col_offset=32 ) ): self.checker.visit_return(function_node.body[2]) self.checker.visit_return(function_node_a.body[0]) self.checker.visit_return(function_node_b.body[0]) - def test_core_paging_file_custom_class_violation(self): file = open(os.path.join(TEST_FOLDER, "test_files", "core_paging_violation.py")) node = astroid.parse(file.read()) @@ -1993,7 +2024,7 @@ def test_core_paging_file_custom_class_violation(self): with self.assertAddsMessages( pylint.testutils.MessageTest( - msg_id="client-list-methods-use-paging", line=8, node=function_node, col_offset=4, end_line=8, end_col_offset=18 + msg_id="client-paging-methods-use-list", line=8, node=function_node, col_offset=4, end_line=8, end_col_offset=18 ) ): self.checker.visit_return(function_node.body[0]) diff --git a/tools/stress-cluster/cluster/azure/monitoring/stress-test-workbook.bicep b/tools/stress-cluster/cluster/azure/monitoring/stress-test-workbook.bicep index b36b25ec0a3..05c16bd8fa6 100644 --- a/tools/stress-cluster/cluster/azure/monitoring/stress-test-workbook.bicep +++ b/tools/stress-cluster/cluster/azure/monitoring/stress-test-workbook.bicep @@ -25,46 +25,13 @@ var workbookContent = { ] parameters: [ { - id: '8f132ca2-e11d-4ec4-b2cf-e3d33a7cca0b' - version: 'KqlParameterItem/1.0' - name: 'PodUidParameter' - label: 'Pod' - type: 2 - description: 'Pod+Container name for metrics' - isRequired: true - multiSelect: true - quote: '\'' - delimiter: ',' - query: 'Perf \r\n| where ObjectName == "K8SContainer"\r\n| distinct InstanceName\r\n| extend ResourceId = split(InstanceName, \'/\')\r\n| extend PodUid = strcat(ResourceId[-2])\r\n| extend ContainerName = strcat(ResourceId[-1])\r\n| distinct ContainerName, PodUid\r\n// Exclude init containers\r\n| where ContainerName !startswith "init-"\r\n| join kind=inner (\r\n KubePodInventory\r\n | where Namespace != "kube-system" and Namespace != "stress-infra"\r\n | distinct Namespace, Name, PodUid\r\n) on PodUid\r\n| project value = PodUid, label = Name, group = Namespace\r\n' - crossComponentResources: [ - logAnalyticsResource - ] - typeSettings: { - additionalResourceOptions: [ - 'value::all' - ] - showDefault: false - } - timeContext: { - durationMs: 3600000 - } - queryType: 0 - resourceType: 'microsoft.operationalinsights/workspaces' - } - { - id: '7f1f155e-7592-404a-a809-10d06ba6eaf7' + id: 'df8aa152-61f4-47b9-a013-bdd4389be1da' version: 'KqlParameterItem/1.0' name: 'TimeRange' type: 4 isRequired: true - value: { - durationMs: 14400000 - } typeSettings: { selectableValues: [ - { - durationMs: 300000 - } { durationMs: 1800000 } @@ -86,19 +53,82 @@ var workbookContent = { { durationMs: 604800000 } + { + durationMs: 2592000000 + } ] allowCustom: true } timeContext: { - durationMs: 3600000 + durationMs: 604800000 } + value: { + durationMs: 86400000 + } + } + { + id: '1564be20-11df-4136-8075-032d684c1c37' + version: 'KqlParameterItem/1.0' + name: 'NamespaceParameter' + label: 'Namespace' + type: 2 + isRequired: true + multiSelect: true + quote: '\'' + delimiter: ',' + query: 'KubePodInventory\r\n| distinct Namespace\r\n| where Namespace !in ("kube-system", "kubernetes-dashboard", "gatekeeper-system")' + crossComponentResources: [ + logAnalyticsResource + ] + typeSettings: { + additionalResourceOptions: [ + 'value::all' + ] + showDefault: false + } + timeContext: { + durationMs: 0 + } + timeContextFromParameter: 'TimeRange' + queryType: 0 + resourceType: 'microsoft.operationalinsights/workspaces' + value: null + } + { + id: '2ce95e10-b3d6-4545-8b01-37bee8c5db89' + version: 'KqlParameterItem/1.0' + name: 'PodUidParameter' + label: 'Pod' + type: 2 + description: 'Pod+Container name for metrics' + isRequired: true + multiSelect: true + quote: '\'' + delimiter: ',' + query: 'Perf \r\n| where ObjectName == "K8SContainer"\r\n| extend DayBin = bin(TimeGenerated, 3d)\r\n| summarize arg_max(TimeGenerated, *) by InstanceName\r\n| extend ResourceId = split(InstanceName, "/")\r\n| extend PodUid = strcat(ResourceId[-2])\r\n| extend ContainerName = strcat(ResourceId[-1])\r\n// Exclude init containers\r\n| where ContainerName !startswith "init-"\r\n| join kind=inner (\r\n KubePodInventory\r\n | where Namespace in ({NamespaceParameter})\r\n | distinct Namespace, Name, PodUid\r\n) on PodUid\r\n| extend day = tostring(format_datetime(DayBin, "MM/dd"))\r\n| sort by day desc\r\n| project value = PodUid, label = Name, group = day\r\n' + crossComponentResources: [ + logAnalyticsResource + ] + typeSettings: { + additionalResourceOptions: [ + 'value::all' + ] + showDefault: false + } + timeContext: { + durationMs: 0 + } + timeContextFromParameter: 'TimeRange' + queryType: 0 + resourceType: 'microsoft.operationalinsights/workspaces' + value: null } ] style: 'pills' queryType: 0 resourceType: 'microsoft.operationalinsights/workspaces' } - name: 'parameters - 1' + name: 'parameters - 5' } { type: 12 diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/CommandOptions/OptionsGenerator.cs b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/CommandOptions/OptionsGenerator.cs index e5a119c4618..efede8671e0 100644 --- a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/CommandOptions/OptionsGenerator.cs +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/CommandOptions/OptionsGenerator.cs @@ -56,7 +56,16 @@ public static RootCommand GenerateCommandLineOptions(Func }; #endregion - var root = new RootCommand(); + + var Description = @"This tool is used by the Azure SDK team in two primary ways: + + - Run as a http record/playback server. (""start"" / default verb) + - Invoke a CLI Tool to interact with recordings in an external store. (""push"", ""restore"", ""reset"", ""config"")"; + + var root = new RootCommand() + { + Description = Description + }; root.AddGlobalOption(storageLocationOption); root.AddGlobalOption(storagePluginOption); diff --git a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md index eccece25e3a..5be66555b83 100644 --- a/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md +++ b/tools/test-proxy/Azure.Sdk.Tools.TestProxy/README.md @@ -149,32 +149,70 @@ For safety, the "official target" version that the azure-sdk team uses is presen ## Command line arguments -This is the help information for test-proxy. It uses the nuget package [`CommandLineParser`](https://www.nuget.org/packages/CommandLineParser) to parse arguments. +This is the help information for test-proxy. It uses the nuget package [`System.CommandLine`](https://www.nuget.org/packages/System.CommandLine) to parse arguments. The test-proxy executable fulfills one of two primary purposes: 1. The test-proxy server (the only option up to this point) -2. [`asset-sync`](#asset-sync-retrieve-external-test-recordings) push/restore/reset. +2. [`asset-sync`](#asset-sync-retrieve-external-test-recordings) verbs + - `push` + - `restore` + - `reset` + - `config` This is surfaced by only showing options for the default commands. Each individual command has its own argument set that can be detailed by invoking `test-proxy --help`. ```text -/>test-proxy --help -Azure.Sdk.Tools.TestProxy 1.0.0-dev.20220926.1 -c Microsoft Corporation. All rights reserved. - - start (Default Verb) Start the TestProxy. - - push Push the assets, referenced by assets.json, into git. - - reset Reset the assets, referenced by assets.json, from git to their original files referenced by the tag. Will prompt - if there are pending changes. - - restore Restore the assets, referenced by assets.json, from git. +/>Azure.Sdk.Tools.TestProxy --help +Description: + This tool is used by the Azure SDK team in two primary ways: + + - Run as a http record/playback server. ("start" / default verb) + - Invoke a CLI Tool to interact with recordings in an external store. ("push", "restore", "reset", "config") + +Usage: + Azure.Sdk.Tools.TestProxy [command] [options] + +Options: + -l, --storage-location The path to the target local git repo. If not provided as an argument, Environment + variable TEST_PROXY_FOLDER will be consumed. Lacking both, the current working + directory will be utilized. + -p, --storage-plugin The plugin for the selected storage, default is Git storage is GitStore. (Currently + the only option) [default: GitStore] + --version Show version information + -?, -h, --help Show help and usage information + +Commands: + start Start the TestProxy. + push Push the assets, referenced by assets.json, into git. + restore Restore the assets, referenced by assets.json, from git. + reset Reset the assets, referenced by assets.json, from git to their original files referenced by the tag. Will prompt if + there are pending changes unless indicated by -y/--yes. + config Interact with an assets.json. +``` - help Display more information on a specific command. +For the `config` verb, there are subverbs! - version Display version information. +```text +/>Azure.Sdk.Tools.TestProxy config --help +Description: + Interact with an assets.json. + +Usage: + Azure.Sdk.Tools.TestProxy config [command] [options] + +Options: + -l, --storage-location The path to the target local git repo. If not provided as an argument, Environment + variable TEST_PROXY_FOLDER will be consumed. Lacking both, the current working + directory will be utilized. + -p, --storage-plugin The plugin for the selected storage, default is Git storage is GitStore. (Currently + the only option) [default: GitStore] + -?, -h, --help Show help and usage information + +Commands: + create Enter a prompt and create an assets.json. + show Show the content of a given assets.json. + locate Get the assets repo root for a given assets.json path. ``` ### Storage Location diff --git a/tools/test-proxy/documentation/asset-sync/README.md b/tools/test-proxy/documentation/asset-sync/README.md index 13b12ade834..31aae84e7a8 100644 --- a/tools/test-proxy/documentation/asset-sync/README.md +++ b/tools/test-proxy/documentation/asset-sync/README.md @@ -69,10 +69,12 @@ Each of these CLI Commands takes an `assets.json` argument that provides the _co When using a Windows machine, it is technically possible to invoke tests from WSL against a windows clone. That path would appear under `/mnt/c/path/to/your/repo`. This is _not_ a supported scenario, as the `test-proxy` shells out to git for the push/restore actions. Running a `git push/pull` from _linux_ against a repo that was cloned down using a _windows_ git client can have unexpected results. Better to avoid the situation entirely and use an entirely separate clone for work on WSL. -## test-proxy CLI commands +## test-proxy CLI (asset) commands The test-proxy also offers interactions with the external assets repository as a CLI. Invoking `test-proxy --help` will show the available list of commands. `test-proxy --help` will show the help and options for an individual command. The options for a given command are all `--