diff --git a/.github/workflows/update-integration-data.yml b/.github/workflows/update-integration-data.yml index fad27f629..14b6b7f40 100644 --- a/.github/workflows/update-integration-data.yml +++ b/.github/workflows/update-integration-data.yml @@ -112,6 +112,7 @@ jobs: git add -- \ src/frontend/src/data/aspire-integrations.json \ src/frontend/src/data/integration-docs.json \ + src/frontend/src/data/api-generation-failures.json \ src/frontend/src/data/github-stats.json \ src/frontend/src/data/github-contributors.json \ src/frontend/src/data/samples.json \ @@ -206,42 +207,13 @@ jobs: fi - name: Track API generation failures - if: ${{ always() && steps.update.outcome == 'success' && steps.update.outputs.regen_ran == 'true' }} + if: ${{ always() && steps.update.outputs.api_failures_checked == 'true' }} env: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} FAILURES: ${{ steps.update.outputs.api_failures }} - shell: bash - run: | - set -euo pipefail - - # One long-lived issue. Its body always lists the current failing - # packages; a comment is added only when that list changes, and the issue - # is closed once nothing fails, so the same failures never create noise. - title="Integration Data Updater: API generation failures" - existing=$(gh issue list \ - --state open \ - --search "\"$title\" in:title" \ - --json number,title \ - --jq "map(select(.title == \"$title\")) | .[0].number // empty") - - if [ -z "$FAILURES" ]; then - if [ -n "$existing" ]; then - gh issue close "$existing" --comment "No packages failed API generation in $RUN_URL." - fi - exit 0 - fi - - marker="" - body=$(printf '%s\n\nThese packages failed API generation in the latest [Integration Data Updater run](%s). Their previous API data is kept and the rest of the data still updates. This issue updates itself and closes when nothing fails.\n\n```\n%s\n```\n' "$marker" "$RUN_URL" "$FAILURES") - - if [ -z "$existing" ]; then - gh issue create --title "$title" --body "$body" - elif ! gh issue view "$existing" --json body --jq .body | grep -qF "$marker"; then - gh issue edit "$existing" --body "$body" - gh issue comment "$existing" --body "The set of failing packages changed: $RUN_URL" - fi + run: node src/frontend/scripts/report-api-generation-failures.mjs - name: Summary if: ${{ always() }} diff --git a/src/frontend/scripts/report-api-generation-failures.mjs b/src/frontend/scripts/report-api-generation-failures.mjs new file mode 100644 index 000000000..38d121491 --- /dev/null +++ b/src/frontend/scripts/report-api-generation-failures.mjs @@ -0,0 +1,68 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const title = 'Integration Data Updater: API generation failures'; + +export function reportApiGenerationFailures(failures, runUrl, gh = runGh) { + const issues = JSON.parse( + gh([ + 'issue', + 'list', + '--state', + 'all', + '--search', + `"${title}" in:title`, + '--json', + 'number,title,state,body', + '--limit', + '100', + ]) + ).filter((issue) => issue.title === title); + const existing = issues.find((issue) => issue.state === 'OPEN') ?? issues[0]; + + if (!failures.trim()) { + if (existing?.state === 'OPEN') { + gh([ + 'issue', + 'close', + String(existing.number), + '--comment', + `All packages passed API generation in ${runUrl}.`, + ]); + } + return; + } + + const normalized = failures.trim().split(/\r?\n/).sort().join('\n'); + const marker = ``; + const body = [ + marker, + `API generation failures observed in [this updater run](${runUrl}).`, + 'Successful packages can still update. Failed packages retain their last-good API data when available. A later validation or infrastructure failure can still block publication.', + `\`\`\`text\n${normalized}\n\`\`\``, + 'This issue is reused when failures recur and closes only after a complete generation pass succeeds.', + ].join('\n\n'); + + if (!existing) { + gh(['issue', 'create', '--title', title, '--body', body]); + return; + } + if (existing.state !== 'OPEN') { + gh(['issue', 'reopen', String(existing.number)]); + } else if (existing.body.includes(marker)) { + return; + } + gh(['issue', 'edit', String(existing.number), '--body', body]); + // Keep the actual diagnostics in the history, not just a link to expiring logs. + gh(['issue', 'comment', String(existing.number), '--body', body]); +} + +function runGh(args) { + return execFileSync('gh', args, { encoding: 'utf8' }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + reportApiGenerationFailures(process.env.FAILURES ?? '', process.env.RUN_URL); +} diff --git a/src/frontend/scripts/update-integration-data.ps1 b/src/frontend/scripts/update-integration-data.ps1 index b4500bbff..feae0332a 100644 --- a/src/frontend/scripts/update-integration-data.ps1 +++ b/src/frontend/scripts/update-integration-data.ps1 @@ -17,7 +17,7 @@ aspire-integrations.json against the freshly written one by package title -> version. Metadata-only changes (icons, descriptions, download counts) do NOT trigger regeneration. - 3. Conditional API-reference regeneration (only when a version changed): + 3. API-reference regeneration (version changes or pending package failures): a. generate-package-json.ps1 -> src/data/pkgs/*.json (C# API) b. bun run update:ts-api -> src/data/ts-modules/*.json (TS API) + chains twoslash aspire.d.ts bundle @@ -32,8 +32,9 @@ 1 a required phase failed (data update/validation, TS API regen, out-of-scope diff). The caller must NOT open a PR on a non-zero exit. - Packages without a public API surface are reported as explicit skips. Any - generation failure or semantic validation error fails the run. + Package failures retain last-good API data and are recorded for retry and + issue reporting. Infrastructure failures and unexpected semantic errors + still fail the run without publishing. .PARAMETER SkipRegen Skip the API-reference regeneration phase even when versions changed. Useful @@ -67,6 +68,7 @@ $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..' '..' '..')).Path $FrontendDir = Join-Path $RepoRoot 'src' 'frontend' $DataDir = Join-Path $FrontendDir 'src' 'data' $IntegrationsJson = Join-Path $DataDir 'aspire-integrations.json' +$ApiFailuresJson = Join-Path $DataDir 'api-generation-failures.json' $PkgGenScript = Join-Path $RepoRoot 'src' 'tools' 'PackageJsonGenerator' 'generate-package-json.ps1' # Working-tree paths that this automation is allowed to change. Anything outside @@ -74,6 +76,7 @@ $PkgGenScript = Join-Path $RepoRoot 'src' 'tools' 'PackageJsonGenerator' 'genera $AllowedPaths = @( 'src/frontend/src/data/aspire-integrations.json', 'src/frontend/src/data/integration-docs.json', + 'src/frontend/src/data/api-generation-failures.json', 'src/frontend/src/data/github-stats.json', 'src/frontend/src/data/github-contributors.json', 'src/frontend/src/data/samples.json', @@ -122,6 +125,68 @@ function Set-Output { } } +function Read-ApiFailures { + param([string]$Log, [int]$ExpectedCount, [string]$Stage) + + $failures = @{} + foreach ($line in $Log -split "`r?`n") { + if ($line -match '^\s*FAILURE (?\S+): (?.+)$') { + $name = $Matches.Name + $reason = $Matches.Reason.Trim() + if (-not $newVersions.ContainsKey($name)) { + throw "Failure reported for unknown catalog package '$name'." + } + $failures[$name] = [PSCustomObject]@{ + name = $name + version = $newVersions[$name] + stage = $Stage + reason = $reason + } + } + } + if ($failures.Count -ne $ExpectedCount) { + throw "$Stage reported $ExpectedCount failures but supplied $($failures.Count) package diagnostics." + } + $failures.Values | Sort-Object name +} + +function Save-ApiFailures { + # Persist exact versions for validation in the generated PR, and emit the + # diagnostics before any subsequent normalization, bundling or validation. + ConvertTo-Json -InputObject @($apiFailures.ToArray()) -Depth 5 | + Set-Content -LiteralPath $ApiFailuresJson -Encoding utf8 + $apiFailureDetails.Clear() + foreach ($failure in $apiFailures) { + $apiFailureDetails.Add("$($failure.stage) $($failure.name)@$($failure.version): $($failure.reason)") + } + Set-Output 'api_failures' ($apiFailureDetails -join "`n") + if ($apiFailures.Count -gt 0) { + Set-Output 'api_failures_checked' 'true' + } +} + +function Restore-FailedApiPackage { + param([string]$Name) + + # Keep C# and TypeScript as a matching pair when TS generation fails after + # C# has already advanced. A new package may have no last-good output. + foreach ($directory in @('pkgs', 'ts-modules')) { + $destination = Join-Path $script:ApiStageRoot $directory + foreach ($file in Get-ChildItem -LiteralPath $destination -Filter '*.json' -File) { + $metadata = (Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json).package + if ($metadata.name -eq $Name) { + Remove-Item -LiteralPath $file.FullName -Force + } + } + foreach ($file in Get-ChildItem -LiteralPath (Join-Path $DataDir $directory) -Filter '*.json' -File) { + $metadata = (Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json).package + if ($metadata.name -eq $Name) { + Copy-Item -LiteralPath $file.FullName -Destination $destination + } + } + } +} + function Test-PathAllowed { param([Parameter(Mandatory)][string]$RelPath) $normalized = $RelPath -replace '\\', '/' @@ -138,7 +203,8 @@ function Restore-ApiEnvironment { foreach ($name in @( 'ASPIRE_API_PKGS_DIR', 'ASPIRE_API_TS_MODULES_DIR', - 'ASPIRE_API_TWOSLASH_FILE' + 'ASPIRE_API_TWOSLASH_FILE', + 'ASPIRE_TS_API_CARRY_FORWARD' )) { $previousValue = $script:PreviousApiEnvironment[$name] if ($null -eq $previousValue) { @@ -296,7 +362,8 @@ if ($iconLine) { # instead of silently exiting 0 here. $statusLines = @(Invoke-Git @('status', '--porcelain') | Where-Object { $_ -and $_.Trim().Length -gt 0 }) -$anyChanges = $statusLines.Count -gt 0 +$pendingApiFailures = @(Get-Content -LiteralPath $ApiFailuresJson -Raw | ConvertFrom-Json) +$anyChanges = $statusLines.Count -gt 0 -or $pendingApiFailures.Count -gt 0 if (-not $anyChanges) { Write-Section 'Result — no changes' @@ -375,6 +442,7 @@ $pkgSkippedPackages = '' $pkgFailedPackages = '' $tsFailedPackages = '' $apiFailureDetails = [System.Collections.Generic.List[string]]::new() +$apiFailures = [System.Collections.Generic.List[object]]::new() $tsApiSummary = '' $tsSkippedPackages = '' $tsToleratedPackages = '' @@ -382,7 +450,7 @@ $tsToleratedDiagnostics = [System.Collections.Generic.List[string]]::new() $twoslashSummary = '' $semanticSummary = '' -if ($versionsChanged -and -not $SkipRegen) { +if (($versionsChanged -or $pendingApiFailures.Count -gt 0) -and -not $SkipRegen) { Write-Section 'Phase 3 — API reference regeneration' $script:ApiStageRoot = Join-Path $DataDir ".api-generation-$([Guid]::NewGuid().ToString('N'))" @@ -390,16 +458,24 @@ if ($versionsChanged -and -not $SkipRegen) { $pkgStageDir = Join-Path $script:ApiStageRoot 'pkgs' $tsStageDir = Join-Path $script:ApiStageRoot 'ts-modules' $twoslashStageFile = Join-Path $script:ApiStageRoot 'twoslash' 'aspire.d.ts' + foreach ($directory in @('pkgs', 'ts-modules')) { + $destination = Join-Path $script:ApiStageRoot $directory + New-Item -ItemType Directory -Path $destination -Force | Out-Null + Get-ChildItem -LiteralPath (Join-Path $DataDir $directory) -Filter '*.json' -File | + Copy-Item -Destination $destination + } foreach ($name in @( 'ASPIRE_API_PKGS_DIR', 'ASPIRE_API_TS_MODULES_DIR', - 'ASPIRE_API_TWOSLASH_FILE' + 'ASPIRE_API_TWOSLASH_FILE', + 'ASPIRE_TS_API_CARRY_FORWARD' )) { $script:PreviousApiEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') } $env:ASPIRE_API_PKGS_DIR = $pkgStageDir $env:ASPIRE_API_TS_MODULES_DIR = $tsStageDir $env:ASPIRE_API_TWOSLASH_FILE = $twoslashStageFile + $env:ASPIRE_TS_API_CARRY_FORWARD = '1' # 3a. C# API JSON. Write-Host "→ generate-package-json.ps1 (C# API → pkgs/)" -ForegroundColor Cyan @@ -419,14 +495,12 @@ if ($versionsChanged -and -not $SkipRegen) { Stop-ApiRegeneration "C# API generation did not emit a valid completion summary. Aborting; no PR will be opened." } $pkgSummary = "Success: $($Matches.Succeeded) | Failed: $($Matches.Failed) | Skipped: $($Matches.Skipped)" - if ([int]$Matches.Failed -gt 0) { - $pkgFailedLine = ($pkgLog -split "`n" | Where-Object { $_ -match '^\s*Failed packages:' } | Select-Object -First 1) - $pkgFailedPackages = if ($pkgFailedLine) { ($pkgFailedLine -replace '^\s*Failed packages:\s*', '').Trim() } else { "$($Matches.Failed) unnamed package(s)" } - $pkgReasonLines = @($pkgLog -split "`r?`n" | Where-Object { $_ -match '^\s*FAILURE \S+: ' }) - foreach ($reasonLine in $pkgReasonLines) { - $apiFailureDetails.Add("C# " + ($reasonLine -replace '^\s*FAILURE ', '').Trim()) - } - if ($pkgReasonLines.Count -eq 0) { $apiFailureDetails.Add("C# ${pkgFailedPackages}: reason not captured; see the run log") } + $pkgFailureCount = [int]$Matches.Failed + $csharpFailures = @(Read-ApiFailures -Log $pkgLog -ExpectedCount $pkgFailureCount -Stage 'C#') + foreach ($failure in $csharpFailures) { $apiFailures.Add($failure) } + Save-ApiFailures + if ($pkgFailureCount -gt 0) { + $pkgFailedPackages = ($csharpFailures.name -join ', ') Write-Warning "C# API generation failed for: $pkgFailedPackages. Continuing; existing data for these packages is kept." } $pkgSkippedLine = ($pkgLog -split "`n" | @@ -456,8 +530,8 @@ if ($versionsChanged -and -not $SkipRegen) { } # 3b. TS API JSON (+ chained twoslash bundle). Requires the Aspire CLI; the - # script honours ASPIRE_CLI_PATH. A non-zero exit here IS fatal — we must not - # ship a PR with C#-only pkgs updates. + # script honours ASPIRE_CLI_PATH and carries failed packages forward. + # A non-zero exit still indicates an infrastructure or bundling failure. Write-Host "→ bun run update:ts-api (TS API → ts-modules/ + twoslash aspire.d.ts)" -ForegroundColor Cyan Push-Location $FrontendDir try { @@ -467,6 +541,21 @@ if ($versionsChanged -and -not $SkipRegen) { finally { Pop-Location } + # Capture package failures even if the wrapper failed later while bundling. + $tsApiDone = ($tsLog -split "`n" | + Where-Object { $_ -match 'Complete:\s+\d+\s+succeeded,\s+\d+\s+failed,\s+\d+\s+skipped' } | + Select-Object -First 1) + if ($tsApiDone -and + $tsApiDone -match 'Complete:\s+(?\d+)\s+succeeded,\s+(?\d+)\s+failed,\s+(?\d+)\s+skipped') { + $tsApiSummary = "$($Matches.Succeeded) succeeded, $($Matches.Failed) failed, $($Matches.Skipped) skipped" + $tsFailureCount = [int]$Matches.Failed + $typescriptFailures = @(Read-ApiFailures -Log $tsLog -ExpectedCount $tsFailureCount -Stage 'TypeScript') + foreach ($failure in $typescriptFailures) { $apiFailures.Add($failure) } + Save-ApiFailures + } + else { + Stop-ApiRegeneration "TypeScript API generation did not emit a valid completion summary.`n$tsLog" + } if ($tsExit -ne 0) { # Distinguish phase-2 vs phase-3 failure using the script's log markers. if ($tsLog -match 'Twoslash type generation failed') { @@ -477,23 +566,20 @@ if ($versionsChanged -and -not $SkipRegen) { } } - $tsApiDone = ($tsLog -split "`n" | - Where-Object { $_ -match 'Complete:\s+\d+\s+succeeded,\s+\d+\s+failed,\s+\d+\s+skipped' } | - Select-Object -First 1) - if (-not $tsApiDone -or - $tsApiDone -notmatch 'Complete:\s+(?\d+)\s+succeeded,\s+(?\d+)\s+failed,\s+(?\d+)\s+skipped') { - Stop-ApiRegeneration "TypeScript API generation did not emit a valid completion summary. Aborting; no PR will be opened." - } - $tsApiSummary = "$($Matches.Succeeded) succeeded, $($Matches.Failed) failed, $($Matches.Skipped) skipped" - if ([int]$Matches.Failed -gt 0) { - $tsFailedLine = ($tsLog -split "`n" | Where-Object { $_ -match '^\s*Failed packages:' } | Select-Object -First 1) - $tsFailedPackages = if ($tsFailedLine) { ($tsFailedLine -replace '^\s*Failed packages:\s*', '').Trim() } else { "$($Matches.Failed) unnamed package(s)" } - $tsReasonLines = @($tsLog -split "`r?`n" | Where-Object { $_ -match '^\s*(FAILED|FAILURE)\b' }) - foreach ($reasonLine in $tsReasonLines) { - $apiFailureDetails.Add("TypeScript " + ($reasonLine -replace '^\s*(FAILED|FAILURE)\s*', '').Trim()) + if ($tsFailureCount -gt 0) { + $tsFailedPackages = $typescriptFailures.name -join ', ' + foreach ($failure in $typescriptFailures) { + Restore-FailedApiPackage -Name $failure.name } - if ($tsReasonLines.Count -eq 0) { $apiFailureDetails.Add("TypeScript ${tsFailedPackages}: reason not captured; see the run log") } Write-Warning "TypeScript API generation failed for: $tsFailedPackages. Continuing." + Push-Location $FrontendDir + try { + & bun run twoslash-types + if ($LASTEXITCODE -ne 0) { + Stop-ApiRegeneration "Twoslash regeneration after restoring failed packages failed." + } + } + finally { Pop-Location } } $tsSkippedLine = ($tsLog -split "`n" | Where-Object { $_ -match '^\s*Skipped packages:' } | @@ -533,6 +619,10 @@ if ($versionsChanged -and -not $SkipRegen) { $twoslashSummary = 'succeeded' + # Only a complete generation pass may clear a previously reported failure. + Set-Output 'regen_ran' 'true' + Set-Output 'api_failures_checked' 'true' + # 3c. Cross-artifact semantic validation. This runs only after every # generator has completed and must pass before the workflow can publish. Write-Host "→ bun run validate:api-data (semantic regression gate)" -ForegroundColor Cyan @@ -649,7 +739,7 @@ $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine("### API reference regeneration") [void]$sb.AppendLine("") if ($regenRan) { - [void]$sb.AppendLine("Versions changed for the following packages, so the C# and TypeScript API reference data and the twoslash bundle were regenerated:") + [void]$sb.AppendLine("The C# and TypeScript API reference data and twoslash bundle were regenerated for version changes or to retry previously failed packages:") [void]$sb.AppendLine("") $shown = 0 foreach ($change in $versionChanges) { @@ -683,7 +773,7 @@ if ($regenRan) { [void]$sb.AppendLine("") [void]$sb.AppendLine("### :warning: API generation failures") [void]$sb.AppendLine("") - [void]$sb.AppendLine("These packages failed to generate and kept their previous API data. They are tracked in the *Integration Data Updater: API generation failures* issue.") + [void]$sb.AppendLine("These packages failed to generate. Their last-good API data is retained when available; new packages have no API reference until generation succeeds. Exact versions and reasons are saved in ``api-generation-failures.json`` and the *Integration Data Updater: API generation failures* issue.") [void]$sb.AppendLine("") foreach ($detail in $apiFailureDetails) { [void]$sb.AppendLine("- ``$detail``") } } diff --git a/src/frontend/scripts/validate-generated-api-data.ts b/src/frontend/scripts/validate-generated-api-data.ts index 29faa7ec2..939039142 100644 --- a/src/frontend/scripts/validate-generated-api-data.ts +++ b/src/frontend/scripts/validate-generated-api-data.ts @@ -92,6 +92,14 @@ export interface ValidationInput { packages: GeneratedFile[]; modules: GeneratedFile[]; declarations: string; + failures?: ApiGenerationFailure[]; +} + +export interface ApiGenerationFailure { + name: string; + version: string; + stage: 'C#' | 'TypeScript'; + reason: string; } export interface ValidationResult { @@ -377,8 +385,23 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu errors ); + const failedPackages = new Set(); + for (const failure of input.failures ?? []) { + if ( + !failure.reason?.trim() || + !['C#', 'TypeScript'].includes(failure.stage) || + catalogByName.get(failure.name)?.version !== failure.version + ) { + errors.push( + `Invalid or outdated API generation failure for ${failure.name}@${failure.version}.` + ); + } else { + failedPackages.add(failure.name); + } + } for (const entry of input.catalog) { if (!isPackageOutputExpected(entry.title) || entry.apiReference === false) continue; + if (failedPackages.has(entry.title)) continue; const key = `${entry.title}@${entry.version}`; if (!packageByIdentity.has(key)) { errors.push(`Missing C# API output for catalog package ${key}.`); @@ -390,7 +413,7 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu const catalogEntry = catalogByName.get(metadata.name); if (!catalogEntry) { errors.push(`C# API output ${identity(metadata)} is not present in the integration catalog.`); - } else if (catalogEntry.version !== metadata.version) { + } else if (catalogEntry.version !== metadata.version && !failedPackages.has(metadata.name)) { errors.push( `Stale C# API output ${identity(metadata)}; catalog version is ${catalogEntry.version}.` ); @@ -428,7 +451,7 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu errors.push( `TypeScript API output ${identity(metadata)} is not present in the integration catalog.` ); - } else if (catalogEntry.version !== metadata.version) { + } else if (catalogEntry.version !== metadata.version && !failedPackages.has(metadata.name)) { errors.push( `Stale TypeScript API output ${identity(metadata)}; catalog version is ${catalogEntry.version}.` ); @@ -574,6 +597,7 @@ export function validateGeneratedApiData(input: ValidationInput): ValidationResu `${selectedDtos.size} DTO shapes checked`, `${selectedHandles.size} handle inheritance chains checked`, 'attribute payload regressions checked against HEAD', + `${failedPackages.size} failed package updates deferred (see api-generation-failures.json)`, ], }; } @@ -667,6 +691,9 @@ function main(): void { packages: loadJsonFiles(packageDir, canonicalPackageDir), modules: loadJsonFiles(moduleDir), declarations: fs.readFileSync(declarationsFile, 'utf8'), + failures: JSON.parse( + fs.readFileSync(path.join(dataDir, 'api-generation-failures.json'), 'utf8') + ) as ApiGenerationFailure[], }); if (result.errors.length > 0) { diff --git a/src/frontend/src/data/api-generation-failures.json b/src/frontend/src/data/api-generation-failures.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/src/frontend/src/data/api-generation-failures.json @@ -0,0 +1 @@ +[] diff --git a/src/frontend/tests/unit/integration-data-updater.vitest.test.ts b/src/frontend/tests/unit/integration-data-updater.vitest.test.ts new file mode 100644 index 000000000..06c554afd --- /dev/null +++ b/src/frontend/tests/unit/integration-data-updater.vitest.test.ts @@ -0,0 +1,340 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, test } from 'vitest'; + +import { reportApiGenerationFailures } from '../../scripts/report-api-generation-failures.mjs'; +import type { ApiGenerationFailure } from '../../scripts/validate-generated-api-data'; + +const frontend = fileURLToPath(new URL('../..', import.meta.url)); +const temporaryDirectories: string[] = []; +const title = 'Integration Data Updater: API generation failures'; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('API failure issue lifecycle', () => { + function client(issues: { number: number; title: string; state: string; body: string }[] = []) { + const calls: string[][] = []; + return { + calls, + gh: (args: string[]) => { + calls.push(args); + return args[1] === 'list' ? JSON.stringify(issues) : ''; + }, + }; + } + + test('creates an issue with exact diagnostics and does not comment on unchanged failures', () => { + const first = client(); + reportApiGenerationFailures( + 'C# Foo@2: NU1102 dependency missing', + 'https://example.com/run/1', + first.gh + ); + const create = first.calls.find((args) => args[1] === 'create')!; + const body = create[create.indexOf('--body') + 1]; + expect(body).toContain('NU1102 dependency missing'); + const second = client([{ number: 42, title, state: 'OPEN', body }]); + reportApiGenerationFailures( + 'C# Foo@2: NU1102 dependency missing', + 'https://example.com/run/2', + second.gh + ); + expect(second.calls.map((args) => args[1])).toEqual(['list']); + }); + + test('records changed diagnostics in history without creating another issue', () => { + const api = client([{ number: 42, title, state: 'OPEN', body: 'old failure' }]); + reportApiGenerationFailures( + 'TypeScript Foo@2: SDK dump timed out', + 'https://example.com/run/2', + api.gh + ); + expect(api.calls.map((args) => args[1])).toEqual(['list', 'edit', 'comment']); + expect(api.calls[2].at(-1)).toContain('SDK dump timed out'); + }); + + test('closes on recovery and reopens the same issue on recurrence', () => { + const api = client([{ number: 42, title, state: 'OPEN', body: '' }]); + reportApiGenerationFailures('', 'https://example.com/run/2', api.gh); + expect(api.calls.map((args) => args[1])).toEqual(['list', 'close']); + const recurring = client([{ number: 42, title, state: 'CLOSED', body: '' }]); + reportApiGenerationFailures('C# Foo@2: NU1102', 'https://example.com/run/3', recurring.gh); + expect(recurring.calls.map((args) => args[1])).toEqual(['list', 'reopen', 'edit', 'comment']); + }); + + test('does not hide GitHub API errors', () => { + expect(() => + reportApiGenerationFailures('failure', 'run', () => { + throw new Error('GitHub API unavailable'); + }) + ).toThrow('GitHub API unavailable'); + }); +}); + +// Run the real orchestrator and semantic validator in an isolated git repository. +// Only external data collection and package generation are replaced with fixtures. +function fixture(mode: string) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'aspire-updater-test-')); + temporaryDirectories.push(root); + const data = path.join(root, 'src', 'frontend', 'src', 'data'); + const scripts = path.join(root, 'src', 'frontend', 'scripts'); + const tool = path.join(root, 'src', 'tools', 'PackageJsonGenerator'); + for (const directory of [ + scripts, + tool, + ...['pkgs', 'ts-modules', 'twoslash'].map((name) => path.join(data, name)), + ]) { + fs.mkdirSync(directory, { recursive: true }); + } + for (const name of [ + 'update-integration-data.ps1', + 'validate-generated-api-data.ts', + 'supplement-ats-enums.ts', + ]) { + fs.copyFileSync(path.join(frontend, 'scripts', name), path.join(scripts, name)); + } + fs.symlinkSync( + path.join(frontend, 'node_modules'), + path.join(root, 'src', 'frontend', 'node_modules'), + 'junction' + ); + fs.writeFileSync( + path.join(root, '.gitignore'), + 'node_modules/\noutputs.txt\nintegration-pr-body.md\n' + ); + fs.writeFileSync(path.join(root, 'src', 'frontend', 'package.json'), '{"type":"module"}'); + const names = ['Aspire.Hosting.Good', 'Aspire.Hosting.Broken']; + const catalog = names.map((name) => ({ + title: name, + version: mode === 'retry' ? '2.0.0' : '1.0.0', + })); + fs.writeFileSync(path.join(data, 'aspire-integrations.json'), JSON.stringify(catalog)); + fs.writeFileSync( + path.join(data, 'api-generation-failures.json'), + mode === 'retry' + ? JSON.stringify([{ name: names[1], version: '2.0.0', stage: 'C#', reason: 'NU1102' }]) + : '[]' + ); + for (const name of names) { + const metadata = { + name, + version: '1.0.0', + sourceRepository: 'https://example.com/source', + sourceCommit: 'abc', + }; + fs.writeFileSync( + path.join(data, 'pkgs', `${name}.1.0.0.json`), + JSON.stringify({ package: { ...metadata, hasGeneratedExports: true }, types: [] }) + ); + fs.writeFileSync( + path.join(data, 'ts-modules', `${name}.1.0.0.json`), + JSON.stringify({ package: metadata, functions: [{}] }) + ); + } + fs.writeFileSync(path.join(data, 'twoslash', 'aspire.d.ts'), ''); + const git = (...args: string[]) => { + const result = spawnSync('git', args, { cwd: root, encoding: 'utf8' }); + if (result.status !== 0) throw new Error(result.stderr); + }; + git('init', '--quiet'); + git('add', '.'); + git( + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.invalid', + 'commit', + '--quiet', + '-m', + 'Fixture' + ); + + fs.writeFileSync( + path.join(tool, 'generate-package-json.ps1'), + ` +param([string]$OutputDir) +$catalog = Get-Content "$PSScriptRoot/../../frontend/src/data/aspire-integrations.json" -Raw | ConvertFrom-Json +$failed = @() +foreach ($entry in $catalog) { + if ($env:FIXTURE_MODE -eq 'all-fail' -or + ($env:FIXTURE_MODE -notin @('retry', 'ts-only-failure', 'clean-bundle-failure') -and $entry.title -ne 'Aspire.Hosting.Good')) { + $failed += $entry.title + continue + } + Get-ChildItem $OutputDir -Filter "$($entry.title).*json" | Remove-Item -Force + @{ package = @{ name=$entry.title; version=$entry.version; sourceRepository='https://example.com/source'; sourceCommit='abc'; hasGeneratedExports=$true }; types=@() } | + ConvertTo-Json -Depth 5 | Set-Content (Join-Path $OutputDir "$($entry.title).$($entry.version).json") +} +Write-Host "Done! Success: $($catalog.Count - $failed.Count) | Failed: $($failed.Count) | Skipped: 0" +foreach ($name in $failed) { Write-Host "FAILURE \${name}: error NU1102: Aspire.Hosting >= 17.0.0-preview is unavailable" } +` + ); + const wrapper = path.join(root, 'run.ps1'); + fs.writeFileSync( + wrapper, + ` +$ErrorActionPreference = 'Stop' +function global:bun { + $global:LASTEXITCODE = 0 + $data = Join-Path $PSScriptRoot 'src/frontend/src/data' + switch ($args[1]) { + 'update:all' { + if ($env:FIXTURE_MODE -ne 'retry') { + @( + @{ title='Aspire.Hosting.Good'; version='2.0.0' }, + @{ title='Aspire.Hosting.Broken'; version='2.0.0' }, + @{ title='Aspire.Hosting.New'; version='2.0.0' } + ) | ConvertTo-Json | Set-Content (Join-Path $data 'aspire-integrations.json') + } + } + 'update:ts-api' { + if ($env:ASPIRE_TS_API_CARRY_FORWARD -ne '1') { throw 'Carry-forward was not enabled' } + $failed = 0 + foreach ($file in Get-ChildItem $env:ASPIRE_API_PKGS_DIR -Filter '*.json') { + $pkg = (Get-Content $file.FullName -Raw | ConvertFrom-Json).package + if ($env:FIXTURE_MODE -in @('ts-failure', 'ts-only-failure') -and $pkg.name -eq 'Aspire.Hosting.Good') { + $failed++ + continue + } + Get-ChildItem $env:ASPIRE_API_TS_MODULES_DIR -Filter "$($pkg.name).*json" | Remove-Item -Force + @{ package=$pkg; functions=@(@{}) } | ConvertTo-Json -Depth 5 | + Set-Content (Join-Path $env:ASPIRE_API_TS_MODULES_DIR "$($pkg.name).$($pkg.version).json") + } + Write-Output "Complete: 1 succeeded, $failed failed, 0 skipped" + if ($failed) { Write-Output 'FAILURE Aspire.Hosting.Good: aspire sdk dump timed out after 3 attempts' } + New-Item (Split-Path $env:ASPIRE_API_TWOSLASH_FILE) -ItemType Directory -Force | Out-Null + Set-Content $env:ASPIRE_API_TWOSLASH_FILE '' + if ($env:FIXTURE_MODE -in @('bundle-failure', 'clean-bundle-failure')) { $global:LASTEXITCODE = 1 } + } + 'twoslash-types' { Set-Content $env:ASPIRE_API_TWOSLASH_FILE '' } + 'normalize:api-data' { + if ($env:FIXTURE_MODE -eq 'normalize-failure') { $global:LASTEXITCODE = 1 } + } + 'validate:api-data' { + if ($env:FIXTURE_MODE -eq 'validation-failure') { + Remove-Item (Join-Path $env:ASPIRE_API_TS_MODULES_DIR 'Aspire.Hosting.Good.2.0.0.json') + } + & node $env:FIXTURE_TSX (Join-Path $PSScriptRoot 'src/frontend/scripts/validate-generated-api-data.ts') + $global:LASTEXITCODE = $LASTEXITCODE + } + } +} +& "$PSScriptRoot/src/frontend/scripts/update-integration-data.ps1" +exit $LASTEXITCODE +` + ); + // Keep the mock launcher and generator out of the scope gate's working-tree diff. + git('add', '.'); + git( + '-c', + 'user.name=Fixture', + '-c', + 'user.email=fixture@example.invalid', + 'commit', + '--quiet', + '-m', + 'Mocks' + ); + const output = path.join(root, 'outputs.txt'); + const result = spawnSync('pwsh', ['-NoProfile', '-File', wrapper], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + FIXTURE_MODE: mode, + GITHUB_ACTIONS: 'true', + GITHUB_OUTPUT: output, + RUNNER_TEMP: root, + FIXTURE_TSX: path.join(frontend, 'node_modules', 'tsx', 'dist', 'cli.mjs'), + }, + }); + return { + ...result, + data, + outputs: fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : '', + failures: JSON.parse( + fs.readFileSync(path.join(data, 'api-generation-failures.json'), 'utf8') + ) as ApiGenerationFailure[], + }; +} + +describe('integration data orchestration', () => { + test('publishes healthy updates, preserves last-good files, and records missing new packages', () => { + const run = fixture('partial'); + expect(run.status, run.stdout + run.stderr).toBe(0); + expect(fs.existsSync(path.join(run.data, 'pkgs', 'Aspire.Hosting.Good.2.0.0.json'))).toBe(true); + expect(fs.existsSync(path.join(run.data, 'pkgs', 'Aspire.Hosting.Broken.1.0.0.json'))).toBe( + true + ); + expect(run.failures).toHaveLength(2); + expect(run.failures[0].reason).toContain('NU1102'); + expect(run.outputs).toContain('api_failures_checked<<'); + }); + + test('restores a matching C#/TypeScript pair when only the TS generation fails', () => { + const run = fixture('ts-only-failure'); + expect(run.status, run.stdout + run.stderr).toBe(0); + for (const directory of ['pkgs', 'ts-modules']) { + expect(fs.existsSync(path.join(run.data, directory, 'Aspire.Hosting.Good.1.0.0.json'))).toBe( + true + ); + expect(fs.existsSync(path.join(run.data, directory, 'Aspire.Hosting.Good.2.0.0.json'))).toBe( + false + ); + } + expect(run.outputs).toContain('aspire sdk dump timed out'); + expect(run.failures).toEqual([ + { + name: 'Aspire.Hosting.Good', + version: '2.0.0', + stage: 'TypeScript', + reason: 'aspire sdk dump timed out after 3 attempts', + }, + ]); + }); + + test.each(['normalize-failure', 'bundle-failure', 'validation-failure'])( + 'reports package reasons even when %s blocks publication', + (mode) => { + const run = fixture(mode); + expect(run.status, run.stdout + run.stderr).toBe(1); + expect(run.outputs).toContain('api_failures_checked<<'); + expect(run.outputs).toContain('NU1102'); + expect(run.outputs).not.toContain('changed<<'); + expect(fs.existsSync(path.join(run.data, 'pkgs', 'Aspire.Hosting.Good.1.0.0.json'))).toBe( + true + ); + } + ); + + test('retries persisted failures with no catalog changes and clears them after recovery', () => { + const run = fixture('retry'); + expect(run.status, run.stdout + run.stderr).toBe(0); + expect(run.failures).toEqual([]); + expect(run.outputs).toContain('api_failures_checked<<'); + expect(fs.existsSync(path.join(run.data, 'pkgs', 'Aspire.Hosting.Broken.2.0.0.json'))).toBe( + true + ); + }); + + test('does not close the tracking issue when bundling fails before a complete pass', () => { + const run = fixture('clean-bundle-failure'); + expect(run.status, run.stdout + run.stderr).toBe(1); + expect(run.outputs).not.toContain('api_failures_checked<<'); + }); + + test('still publishes metadata and reports reasons when every package restore fails', () => { + const run = fixture('all-fail'); + expect(run.status, run.stdout + run.stderr).toBe(0); + expect(run.failures).toHaveLength(3); + expect(fs.existsSync(path.join(run.data, 'pkgs', 'Aspire.Hosting.Good.1.0.0.json'))).toBe(true); + expect(run.outputs).toContain('api_failures_checked<<'); + }); +}); diff --git a/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts b/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts index d8e7f6d78..03d3bf374 100644 --- a/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts +++ b/src/frontend/tests/unit/validate-generated-api-data.vitest.test.ts @@ -318,6 +318,75 @@ describe('validateGeneratedApiData', () => { ); expect(errors).toContain('Missing C# API output for catalog package Aspire.Hosting.Foo@2.0.0.'); }); + + test('tolerates missing and stale output for packages whose generation failed', () => { + const input = createValidInput(); + input.catalog[0].version = '2.0.0'; + input.failures = [ + { + name: 'Aspire.Hosting.Foo', + version: '2.0.0', + stage: 'C#', + reason: 'NU1102: required dependency is unavailable', + }, + ]; + + expect(validateGeneratedApiData(input).errors).toEqual([]); + }); + + test('allows a new failed package to have no previous API data', () => { + const input = createValidInput(); + input.catalog.push({ title: 'Aspire.Hosting.New', version: '2.0.0' }); + input.failures = [ + { + name: 'Aspire.Hosting.New', + version: '2.0.0', + stage: 'C#', + reason: 'NU1102', + }, + ]; + expect(validateGeneratedApiData(input).errors).toEqual([]); + }); + + test('does not exempt unrelated packages or another version of a failed package', () => { + const input = createValidInput(); + input.catalog[0].version = '2.0.0'; + input.failures = [ + { + name: 'Aspire.Hosting.Foo', + version: '1.0.0', + stage: 'C#', + reason: 'NU1102', + }, + ]; + const errors = validateGeneratedApiData(input).errors; + expect(errors).toContain( + 'Invalid or outdated API generation failure for Aspire.Hosting.Foo@1.0.0.' + ); + expect(errors).toContain('Missing C# API output for catalog package Aspire.Hosting.Foo@2.0.0.'); + }); + + test('still rejects broken provenance and missing exported modules for a deferred package', () => { + const input = createValidInput(); + input.failures = [ + { + name: 'Aspire.Hosting.Foo', + version: '1.0.0', + stage: 'TypeScript', + reason: 'SDK dump failed', + }, + ]; + input.modules[0].data.package.sourceCommit = 'wrong'; + expect( + validateGeneratedApiData(input).errors.some((error) => + error.startsWith('Source commit mismatch') + ) + ).toBe(true); + input.modules = []; + expect(validateGeneratedApiData(input).errors).toContain( + 'Missing TypeScript API output for exported package Aspire.Hosting.Foo@1.0.0.' + ); + }); }); describe('loadJsonFromHead', () => { diff --git a/src/frontend/vitest.structured-data.config.ts b/src/frontend/vitest.structured-data.config.ts index a4186f9ee..45ff8554d 100644 --- a/src/frontend/vitest.structured-data.config.ts +++ b/src/frontend/vitest.structured-data.config.ts @@ -16,6 +16,7 @@ export default defineConfig({ 'tests/unit/structured-data.vitest.test.ts', 'tests/unit/update-integrations.vitest.test.ts', 'tests/unit/validate-generated-api-data.vitest.test.ts', + 'tests/unit/integration-data-updater.vitest.test.ts', 'tests/unit/supplement-ats-enums.vitest.test.ts', 'tests/unit/cli-config-schema.vitest.test.ts', ], diff --git a/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 b/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 index 45c9ec417..7cd09f410 100644 --- a/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 +++ b/src/tools/AtsJsonGenerator/generate-ts-api-json.ps1 @@ -1037,6 +1037,7 @@ $failedPackageNames = [System.Collections.Generic.HashSet[string]]::new( $skippedPackageNames = [System.Collections.Generic.HashSet[string]]::new( [StringComparer]::OrdinalIgnoreCase) $toleratedDiagnosticsByPackage = @{} +$failureReasons = @{} $coreOutputFile = $null $slowestResult = $null $generationStopwatch = [System.Diagnostics.Stopwatch]::StartNew() @@ -1069,6 +1070,9 @@ function Complete-TsModuleResult { if ($Result.Status -ne "Succeeded") { $script:failed++ [void]$failedPackageNames.Add($Result.Name) + $failureReasons[$Result.Name] = ($Result.Log | + Where-Object Kind -eq 'Warning' | + Select-Object -Last 1).Text return } @@ -1096,6 +1100,7 @@ function Complete-TsModuleResult { Remove-Item $Result.OutputFile -Force -ErrorAction SilentlyContinue $script:failed++ [void]$failedPackageNames.Add($Result.Name) + $failureReasons[$Result.Name] = $_.Exception.Message } } } @@ -1121,6 +1126,10 @@ Write-Host "══════════════════════ Write-Host "Complete: $success succeeded, $failed failed, $skipped skipped" -ForegroundColor $(if ($failed -gt 0) { "Yellow" } else { "Green" }) if ($failedPackageNames.Count -gt 0) { Write-Host "Failed packages: $(($failedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Red + foreach ($name in ($failedPackageNames | Sort-Object)) { + $reason = $failureReasons[$name] -replace '\r?\n', ' ' + Write-Host "FAILURE ${name}: $reason" -ForegroundColor Red + } } if ($skippedPackageNames.Count -gt 0) { Write-Host "Skipped packages: $(($skippedPackageNames | Sort-Object) -join ', ')" -ForegroundColor Yellow diff --git a/src/tools/PackageJsonGenerator/generate-package-json.ps1 b/src/tools/PackageJsonGenerator/generate-package-json.ps1 index 7646e2af9..5744ea060 100644 --- a/src/tools/PackageJsonGenerator/generate-package-json.ps1 +++ b/src/tools/PackageJsonGenerator/generate-package-json.ps1 @@ -1075,11 +1075,6 @@ Write-Host "Prepared $($manifestEntries.Count) packages for generation." if ($manifestEntries.Count -eq 0) { Write-Host "`nNo packages to process." -ForegroundColor Yellow - if ($failCount -gt 0) { - Remove-Item -Path $OutputDir -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item (Join-Path $ScriptDir ".package-json-generator-work") -Recurse -Force -ErrorAction SilentlyContinue - exit 1 - } } # Phase 3: Generate JSON files @@ -1133,8 +1128,13 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { } } - if ($batchExitCode -ne 0) { - Write-Warning "Batch tool exited with code $batchExitCode; no reported result will be published." + $batchSummary = $batchOutput | Where-Object { + $_ -match '^Batch complete in .+: \d+ succeeded, \d+ failed, \d+ skipped$' + } | Select-Object -Last 1 + if (-not $batchSummary -or + $batchExitCode -ne [int]($failedPackages.Count -gt 0) -or + ($succeededPackages.Count + $failedPackages.Count + $skippedPackages.Count) -ne $manifestEntries.Count) { + throw "Batch generation did not complete reliably (exit $batchExitCode). No API data will be published." } foreach ($entry in $manifestEntries) { @@ -1143,14 +1143,8 @@ if (-not $Sequential -and $manifestEntries.Count -gt 0) { [int]($skippedPackages.Contains($entry.packageName)) + [int]($failedPackages.Contains($entry.packageName)) - if ($batchExitCode -ne 0) { - $failCount++ - Add-PackageFailure $entry.packageName "batch tool exited with code $batchExitCode" - } - elseif ($reportedStatusCount -ne 1) { - Write-Warning "Batch tool reported $reportedStatusCount terminal statuses for $($entry.packageName); expected exactly one." - $failCount++ - Add-PackageFailure $entry.packageName "batch tool reported $reportedStatusCount terminal statuses; expected exactly one" + if ($reportedStatusCount -ne 1) { + throw "Batch tool reported $reportedStatusCount terminal statuses for $($entry.packageName); expected exactly one." } elseif ($succeededPackages.Contains($entry.packageName)) { if (Test-Path -LiteralPath $entry.output -PathType Leaf) { diff --git a/tests/PackageJsonGenerator.Tests/generate-package-json.Tests.ps1 b/tests/PackageJsonGenerator.Tests/generate-package-json.Tests.ps1 index 98705e3d1..b47ccdef3 100644 --- a/tests/PackageJsonGenerator.Tests/generate-package-json.Tests.ps1 +++ b/tests/PackageJsonGenerator.Tests/generate-package-json.Tests.ps1 @@ -72,3 +72,66 @@ if ($catalogResult.title -contains "Particular.Aspire.Hosting.ServicePlatform") } Write-Host "apiReference filter passed fixture and catalog checks ($($catalogResult.Count) included, $($excluded.Count) excluded)." + +# Execute the real batch reconciliation without restoring packages or building. +$batchGuardAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.IfStatementAst] -and + $node.Extent.Text.StartsWith('if (-not $batchSummary -or') +}, $true) +$batchReconciliationAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.ForEachStatementAst] -and + $node.Extent.Text.Contains('$reportedStatusCount =') +}, $true) +$failureFunctionAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Add-PackageFailure' +}, $true) +if ($null -eq $batchGuardAst -or $null -eq $batchReconciliationAst -or $null -eq $failureFunctionAst) { + throw 'Could not locate batch reconciliation and failure reporting.' +} +. ([scriptblock]::Create($failureFunctionAst.Extent.Text)) +$guard = [scriptblock]::Create($batchGuardAst.Extent.Text) +$reconcile = [scriptblock]::Create($batchReconciliationAst.Extent.Text) +$successCount = 0 +$skipCount = 0 +$failCount = 0 +$failureReasons = @{} +$failedPackageNames = [System.Collections.Generic.HashSet[string]]::new() +$skippedPackageNames = [System.Collections.Generic.HashSet[string]]::new() +$succeededPackages = [System.Collections.Generic.HashSet[string]]::new() +$failedPackages = [System.Collections.Generic.HashSet[string]]::new() +$skippedPackages = [System.Collections.Generic.HashSet[string]]::new() +[void]$succeededPackages.Add('Example.Good') +[void]$failedPackages.Add('Example.Broken') +$batchFailureReasons = @{ 'Example.Broken' = 'cannot read assembly' } +$batchExitCode = 1 +$batchSummary = 'Batch complete in 1.0s: 1 succeeded, 1 failed, 0 skipped' +$removedFor = [System.Collections.Generic.List[string]]::new() +function Remove-StalePackageJsonFiles { + param($PackageName, $CurrentOutputFile, $OutputDirectory) + $removedFor.Add($PackageName) +} +$OutputDir = [System.IO.Path]::GetTempPath() +$manifestEntries = @( + @{ packageName = 'Example.Good'; output = $scriptPath }, + @{ packageName = 'Example.Broken'; output = 'unused' } +) +. $guard +. $reconcile +if ($successCount -ne 1 -or $failCount -ne 1 -or $removedFor.Count -ne 1 -or + $removedFor[0] -ne 'Example.Good' -or + $failureReasons['Example.Broken'] -notmatch 'cannot read assembly') { + throw 'A completed partial batch must publish successes and report only actual failures.' +} +foreach ($scenario in @('crash', 'incomplete')) { + $batchExitCode = if ($scenario -eq 'crash') { 2 } else { 1 } + $batchSummary = if ($scenario -eq 'incomplete') { $null } else { 'complete' } + $rejected = $false + try { . $guard } + catch { $rejected = $true } + if (-not $rejected) { throw "$scenario batch was not rejected." } +} +Write-Host 'Batch reconciliation passed partial-success, crash, and incomplete-output checks.'