Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions .github/skills/formatting-log/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
name: formatting-log
description: Import Razor formatting log zips that the user has already downloaded from Azure DevOps feedback tickets or GitHub issues into FormattingLogTest, validate whether the captured problem still reproduces, and if needed drive a minimal repro plus fix workflow.
---

# Razor Formatting Log Investigation

Use this skill when:
- investigating a Razor formatting issue from Visual Studio or VS Code feedback
- given an Azure DevOps work item, Developer Community ticket, or GitHub issue that includes a formatting log zip
- asked to import a `Full_*_razor.zip` or `Range_*_razor.zip` archive into the test suite

## Goal

1. Get a local path to the formatting log archive.
2. Import every file from that zip into `src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.Test\TestFiles\FormattingLog\<TestName>\`.
3. Add a matching `[Fact]` to `FormattingLogTest.cs`.
4. Run the imported formatting log test.
5. If it passes, report that the issue captured in the logs no longer reproduces.
6. If it fails, keep the imported repro, reduce it to a minimal `DocumentFormattingTest`, and then fix the product code.

## Finding the archive

### Azure DevOps / VS feedback ticket

1. Read the work item and comments with the Azure DevOps tools so you know which attachment to ask for.
2. Do **not** try to download Developer Community or Azure DevOps feedback attachments yourself. The available tooling cannot reliably fetch those private files.
3. Ask the user to download the `Full_*_razor.zip` or `Range_*_razor.zip` attachment locally and give you the absolute zip path.
4. Continue only after the user has provided that local path.

### GitHub issue

1. Read the issue body and comments.
2. Look for uploaded zip attachments or linked archives with the same naming pattern.
3. If the archive is directly downloadable, download it to a temporary folder. Otherwise ask the user to download it and provide a local path.

## Import the logs

Use the helper script in this skill:

```powershell
.\.github\skills\formatting-log\scripts\Import-FormattingLog.ps1 `
-ZipPath C:\Users\dawengie\Downloads\Range_Index_razor.zip `
-WorkItemUrl https://dev.azure.com/devdiv/DevDiv/_workitems/edit/2878103 `
-TestName RanOutOfOriginalLines
```

- `-ZipPath` must be a local file path. For Developer Community and Azure DevOps feedback tickets, ask the user to download the archive first instead of trying to fetch it yourself.
- `-TestName` defaults to a sanitized name derived from the zip filename. Override it when you want a shorter or more descriptive repro name.
- Keep the folder name and the test name identical.
- Use `-Expectation Null` only when the correct outcome is that all edits should be filtered out. Otherwise keep the default `NotNull`.
- The script copies every extracted file into the new test asset folder and appends the matching test method to `FormattingLogTest.cs`.

## Run the imported test

```powershell
dotnet test src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.Test\Microsoft.VisualStudio.LanguageServices.Razor.Test.csproj --filter "FullyQualifiedName~FormattingLogTest.<TestName>"
```

Do **not** run `dotnet test` at the repo root.

## Interpreting the result

- **Test passes**: report that the problem captured in the logs no longer reproduces.
- **Test fails**: continue with a minimal repro and product fix.

## When the imported log test fails

1. Commit the imported formatting log assets and `FormattingLogTest` change by themselves.
2. Reduce the failure to a focused repro in `src\Razor\test\Microsoft.CodeAnalysis.Razor.CohostingShared.Test\Formatting\DocumentFormattingTest.cs`.
3. Commit the minimal repro by itself.
4. Fix the product code.
5. Commit the fix by itself.
6. Re-run the imported `FormattingLogTest` plus the focused `DocumentFormattingTest` coverage.

The formatting-log test, the minimal repro, and the fix should stay as separate commits during the investigation.

## Notes

- If the user only gives you a Developer Community or Azure DevOps ticket URL, stop and ask them to download the zip before proceeding.
- `FormattingLogTest` accepts both real range payloads and literal `null` `Range.json` files from full-document logs.
- Imported log zips often contain more files than the test currently consumes. Keep them all in the test asset folder.
- Prefer adding `[WorkItem("...")]` with the originating Azure DevOps or GitHub URL on the imported test.
221 changes: 221 additions & 0 deletions .github/skills/formatting-log/scripts/Import-FormattingLog.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<#
.SYNOPSIS
Imports a locally downloaded Razor formatting log zip into FormattingLogTest.

.DESCRIPTION
Developer Community and Azure DevOps feedback attachments must be downloaded by the user first.
Pass the local archive path with -ZipPath.
#>

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$ZipPath,

[string]$TestName,

[string]$WorkItemUrl,

[ValidateSet('NotNull', 'Null')]
[string]$Expectation = 'NotNull',

[switch]$Force
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if ($ZipPath -match '^[A-Za-z][A-Za-z0-9+\-.]*://')
{
throw "ZipPath must be a local file path. Download the formatting log archive first, then pass the local zip path."
}

function Get-RepositoryRoot {
return (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path
}

function ConvertTo-TestName {
param(
[Parameter(Mandatory = $true)]
[string]$ZipBaseName
)

$candidate = $ZipBaseName
if ($candidate -match '^(Full|Range)_(?<Name>.+)_razor$')
{
$candidate = $Matches['Name']
}

$parts = $candidate -split '[^A-Za-z0-9]+' | Where-Object { $_ }
if (-not $parts)
{
throw "Couldn't derive a test name from '$ZipBaseName'. Pass -TestName explicitly."
}

$normalized = ($parts | ForEach-Object {
if ($_.Length -eq 1)
{
$_.ToUpperInvariant()
}
else
{
$_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1)
}
}) -join ''

if ($normalized -match '^[0-9]')
{
$normalized = "Case$normalized"
}

return $normalized
}

function Assert-ValidTestName {
param(
[Parameter(Mandatory = $true)]
[string]$Name
)

if ($Name -notmatch '^[A-Za-z_][A-Za-z0-9_]*$')
{
throw "Test name '$Name' is not a valid C# identifier. Pass -TestName with a valid identifier."
}
}

function Get-ContentRoot {
param(
[Parameter(Mandatory = $true)]
[string]$ExtractPath
)

$items = @(Get-ChildItem -LiteralPath $ExtractPath -Force)
if ($items.Count -eq 1 -and $items[0].PSIsContainer)
{
return $items[0].FullName
}

return $ExtractPath
}

function Copy-FormattingLogFiles {
param(
[Parameter(Mandatory = $true)]
[string]$SourcePath,

[Parameter(Mandatory = $true)]
[string]$DestinationPath
)

foreach ($item in Get-ChildItem -LiteralPath $SourcePath -Force)
{
Copy-Item -LiteralPath $item.FullName -Destination $DestinationPath -Recurse -Force
}
}

function Add-FormattingLogTest {
param(
[Parameter(Mandatory = $true)]
[string]$TestFilePath,

[Parameter(Mandatory = $true)]
[string]$Name,

[string]$IssueUrl,

[Parameter(Mandatory = $true)]
[ValidateSet('NotNull', 'Null')]
[string]$AssertionType,

[switch]$OverwriteExisting
)

$contents = [System.IO.File]::ReadAllText($TestFilePath)
$methodPattern = "(?m)^\s*public\s+async\s+Task\s+$([regex]::Escape($Name))\s*\("
if ([regex]::IsMatch($contents, $methodPattern))
{
if ($OverwriteExisting)
{
return
}

throw "FormattingLogTest already contains a test named '$Name'."
}

$newline = "`r`n"
$workItemAttribute = if ([string]::IsNullOrWhiteSpace($IssueUrl))
{
''
}
else
{
" [WorkItem(`"$IssueUrl`")]$newline"
}

$assertion = if ($AssertionType -eq 'Null') { 'Assert.Null' } else { 'Assert.NotNull' }
$methodBlock = " [Fact]$newline$workItemAttribute public async Task $Name()$newline => $assertion(await GetFormattingEditsAsync());$newline$newline"

$marker = ' private async Task<TextEdit[]?> GetFormattingEditsAsync'
$markerIndex = $contents.IndexOf($marker)
if ($markerIndex -lt 0)
{
throw "Couldn't find the insertion point in '$TestFilePath'."
}

$updatedContents = $contents.Insert($markerIndex, $methodBlock)
$encoding = [System.Text.UTF8Encoding]::new($true)
[System.IO.File]::WriteAllText($TestFilePath, $updatedContents, $encoding)
}

$resolvedZipPath = (Resolve-Path -LiteralPath $ZipPath).Path
$repoRoot = Get-RepositoryRoot
$testFilePath = Join-Path $repoRoot 'src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.Test\Cohost\Formatting\FormattingLogTest.cs'
$assetRoot = Join-Path $repoRoot 'src\Razor\test\Microsoft.VisualStudio.LanguageServices.Razor.Test\TestFiles\FormattingLog'

if (-not $TestName)
{
$TestName = ConvertTo-TestName -ZipBaseName ([System.IO.Path]::GetFileNameWithoutExtension($resolvedZipPath))
}

Assert-ValidTestName -Name $TestName

$destinationPath = Join-Path $assetRoot $TestName
if (Test-Path -LiteralPath $destinationPath)
{
if (-not $Force)
{
throw "Destination folder '$destinationPath' already exists. Pass -Force to overwrite its contents."
}

Remove-Item -LiteralPath $destinationPath -Recurse -Force
}

$extractPath = Join-Path ([System.IO.Path]::GetTempPath()) ("formatting-log-" + [Guid]::NewGuid().ToString('N'))

try
{
New-Item -ItemType Directory -Path $extractPath | Out-Null
Expand-Archive -LiteralPath $resolvedZipPath -DestinationPath $extractPath -Force

$contentRoot = Get-ContentRoot -ExtractPath $extractPath
if (-not (Get-ChildItem -LiteralPath $contentRoot -Force))
{
throw "The archive '$resolvedZipPath' did not contain any files to import."
}

New-Item -ItemType Directory -Path $destinationPath | Out-Null
Copy-FormattingLogFiles -SourcePath $contentRoot -DestinationPath $destinationPath

Add-FormattingLogTest -TestFilePath $testFilePath -Name $TestName -IssueUrl $WorkItemUrl -AssertionType $Expectation -OverwriteExisting:$Force
}
finally
{
if (Test-Path -LiteralPath $extractPath)
{
Remove-Item -LiteralPath $extractPath -Recurse -Force
}
}

Write-Host "Imported formatting log archive '$resolvedZipPath' as test '$TestName'."
Write-Host "Assets: $destinationPath"
Write-Host "Test file: $testFilePath"
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ public async Task CSSWrappedToMultipleLines()
public async Task MultiLineLambda()
=> Assert.NotNull(await GetFormattingEditsAsync());

[Fact]
[WorkItem("https://developercommunity.visualstudio.com/t/Razor-Formatting-Feature---Internal-Erro/11068847")]
public async Task GameTracAdmin()
=> Assert.NotNull(await GetFormattingEditsAsync());

private async Task<TextEdit[]?> GetFormattingEditsAsync([CallerMemberName] string? testName = null)
{
var contents = GetResource(testName.AssumeNotNull(), "InitialDocument.txt").AssumeNotNull();
Expand All @@ -81,7 +86,7 @@ public async Task MultiLineLambda()
}

TextSpan span = default;
if (GetResource(testName, "Range.json") is { } rangeFile)
if (GetResource(testName, "Range.json") is { } rangeFile && rangeFile != "null")
{
var linePositionSpan = (LinePositionSpan)JsonSerializer.Deserialize(rangeFile, typeof(LinePositionSpan), JsonHelpers.JsonSerializerOptions).AssumeNotNull();
span = sourceText.GetTextSpan(linePositionSpan);
Expand All @@ -90,7 +95,7 @@ public async Task MultiLineLambda()
var formattingService = (RazorFormattingService)OOPExportProvider.GetExportedValue<IRazorFormattingService>();
formattingService.GetTestAccessor().SetFormattingLoggerFactory(new TestFormattingLoggerFactory(TestOutputHelper));

return await GetFormattingEditsAsync(document, htmlEdits, span: default, options.CodeBlockBraceOnNextLine, options.AttributeIndentStyle, options.InsertSpaces, options.TabSize, options.CSharpSyntaxFormattingOptions.AssumeNotNull());
return await GetFormattingEditsAsync(document, htmlEdits, span, options.CodeBlockBraceOnNextLine, options.AttributeIndentStyle, options.InsertSpaces, options.TabSize, options.CSharpSyntaxFormattingOptions.AssumeNotNull());
}

private string? GetResource(string testName, string name)
Expand All @@ -103,6 +108,7 @@ public async Task MultiLineLambda()
return null;
}

return testFile.ReadAllText();
// Formatting logs capture absolute spans against the original file contents, so we must not normalize line endings.
return testFile.ReadAllText(normalizeLineEndings: false);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Formatting-log text artifacts come directly from customer machines and must be preserved byte-for-byte.
*.txt -text
Loading