diff --git a/.github/skills/ghpmv-e2e-validation/SKILL.md b/.github/skills/ghpmv-e2e-validation/SKILL.md index 9279fb0c..12aaa9a8 100644 --- a/.github/skills/ghpmv-e2e-validation/SKILL.md +++ b/.github/skills/ghpmv-e2e-validation/SKILL.md @@ -87,7 +87,7 @@ browser login command も同様に agent が終了まで監視する。ユーザ | PAT permission preflight | HTTP status と endpoint ごとの response | | fixture 作成 | exit code、作成された repository / Project、Project number | | export | exit code、`snapshot.json`、mapping CSV、warning | -| browser-e2e field defaults / sums | typed defaults と4 Viewのsnapshot contract、target `Field: Match` / `View: Match`、new-draft functional check、rendered-header DOM check、drift report、repair report | +| browser-e2e field defaults / sums | typed defaults と5 Viewのsnapshot contract、target `Field: Match` / `View: Match`、new-draft functional check、rendered-header DOM check、drift report、repair report | | GEI | migration status、target repository、Issue / PR number | | import | `result`、target Project number、`import-log.json` | | verify | overall / category result、`verify-report.json` | @@ -190,6 +190,8 @@ agent が terminal に command を直接入力できず、ユーザー自身が | browser-e2e field-sum status | `fixture-pending`, `snapshot-match`, `target-view-match`, `target-render-observed`, `drift-detected`, `repair-match` | | browser-e2e field-default contract | `Fixture Text=既定値 🌏`, `Fixture Number=-7`, `Fixture Number 2=0`, `Fixture Select=Beta` | | browser-e2e field-default status | `fixture-pending`, `snapshot-match`, `target-field-match`, `new-draft-observed`, `drift-detected`, `repair-match` | +| browser-e2e Roadmap display contract | Project-shared state: both Roadmaps=`(truncateTitles=true, showDateFields=false)` | +| browser-e2e Roadmap display status | `fixture-pending`, `snapshot-match`, `target-view-match`, `target-render-observed`, `drift-detected`, `repair-match` | | resource inventory | この run が作成した Project / repository の side、name、URL / number、作成 Step、cleanup 状態 | `browser-e2e` の既存 round-trip は次の field-sum contract も常に検証する。別 scenario には分岐させず、settings に重複保存しない。 @@ -200,11 +202,16 @@ agent が terminal に command を直接入力できず、ユーザー自身が | `Fixture Roadmap` | `ROADMAP_LAYOUT` / `Status` | `Fixture Number 2` | | `Fixture Board` | `BOARD_LAYOUT` / `Status` | `Fixture Number` | | `Fixture Empty Sums` | `TABLE_LAYOUT` / `Status` | empty | +| `Fixture Roadmap Dates Hidden` | `ROADMAP_LAYOUT` / `Status` | `Fixture Number 2` | required Number fields は `Fixture Number` と `Fixture Number 2`。source / target の実 resource 名を E2E settings schema に追加する必要はない。browser state、PAT、cookie は引き続き settings に保存しない。 同じ round-trip で field defaults も常に検証する。`setup --fixture --fixture-ui` は source items 作成後に defaults を設定するため既存 item values を変更しない。Step 6 は typed defaults を snapshot から検査し、Step 10 は `Field: Match` 後に `--fixture-field-default-check` で disposable target draft への自動入力を機械確認する。drift phase は `--fixture-field-default-drift` で Text / zero Number / Single-select を変更し、negative Number default を clear した後、既存の一回の repair import で Field sum と同時に戻す。 +同じ round-trip でProject-shared Roadmap state `(truncateTitles=true, showDateFields=false)` を両Roadmapからcaptureし、DOM checkpointでtitle truncationとdate非表示を検証する。negative-test phaseはtitle-only `(false,false)` とdate-only `(true,true)` をそれぞれ別checkpointで適用し、exact mismatch、DOM rendering、baseline repairを各段階で確認する。 + +> **Undocumented UI warning:** `Truncate titles` / `Show date fields` と対応するbrowser storageはGitHubの公開API・互換性契約ではない。GitHubのUI、selector、storage key、保存動作が予告なく変更・削除されるとcapture/import/DOM checkpointが失敗し得る。`browser-e2e`でfixtureを作成する前に、この依存と失敗時はwarningを成功扱いしないことをoperatorへ明示する。 + ## Feature checkpoint の実行時間最小化 Issue ごとの機能検証を追加するときも、user-facing scenario selector や独立した full round trip を増やさない。次へ統合する。 @@ -539,7 +546,7 @@ settings の `execution.fixturePreparation`、`execution.repositoryPreparationMo `api-only` または `browser-e2e` では、settings の `execution.fixturePreparation` を `fixture preparation` として記録し、設定済みなら質問しない。設定がない場合だけ、既存 source Project を使うか fixture を作るかを一問で確認する。`api-only` の `existing` は Step 5 を実行せず、fixture 作成用権限を要求しない。`browser-e2e` の `existing` は resource を作成しない確認 Step として Step 5 を通り、現行標準 fixture contract を記録する。 -`browser-e2e` の fixture preparation 質問では、既存 round-trip が Text / Number / Single-select defaults と grouped Table / Roadmap の Field sum も検証することを質問文に含める。`create` は現行の標準 fixture が4 typed defaults、required Number fields、4 Viewsを決定的に作るため推奨する。`existing` は arbitrary Project ではなく、下記 contract を満たす現行標準 fixture または同等構成に限る。Step 6 の snapshot gate が不一致なら手編集で続行せず、新しい標準 fixture を作るか明示的に選び直す。 +`browser-e2e` の fixture preparation 質問では、既存 round-trip が Text / Number / Single-select defaults と grouped Table / Roadmap の Field sum も検証することを質問文に含める。`create` は現行の標準 fixture が4 typed defaults、required Number fields、5 Viewsを決定的に作るため推奨する。`existing` は arbitrary Project ではなく、下記 contract を満たす現行標準 fixture または同等構成に限る。Step 6 の snapshot gate が不一致なら手編集で続行せず、新しい標準 fixture を作るか明示的に選び直す。 同じ mode では、settings の `execution.repositoryPreparationMode` を `repository preparation mode` として記録し、設定済みなら質問しない。設定がない場合だけ、target repository を GEI で移行するか fixture seed で作るかを Step 4 より前に一問で確認する。token の用途が決まるまで PAT の入力を求めない。 @@ -894,7 +901,7 @@ fine-grained PAT の **Administration** または **All repositories** を付与 `browser-e2e` では fixture preparation にかかわらず、Step 5 の開始時に次を state へ記録する。 -- View names: `View 1`, `Fixture Board`, `Fixture Roadmap`, `Fixture Empty Sums` +- View names: `View 1`, `Fixture Board`, `Fixture Roadmap`, `Fixture Empty Sums`, `Fixture Roadmap Dates Hidden` - Number field names: `Fixture Number`, `Fixture Number 2` - Grouping field: `Status` - expected FieldSum: session state の fixture contract 表 @@ -970,6 +977,8 @@ source が data residency の場合は選択した source command に `--api-bas `fixture preparation=create` の成功後、出力された source Project title / number / URL を resource inventory に `created` として追加する。`source empty-repository fallback=selected` なら `/` はこの run より前に作成されたため `pre-existing` として追加し、通常経路だけ repository を `created` とする。`browser-e2e` では作成された source fixture が上記 contract を持つことを前提にせず、Step 6 の gate で必ず確認する。 +`browser-e2e` の実resource作成前説明には、作成するresource名に加え、Roadmap title/date controlsがundocumented browser UI/browser storage依存であり、GitHub側の変更時は後続automationがfail-closedで停止することを必ず含める。 + `browser-e2e` の再試行も同じ combined command と同じ title / repository を使う。CLI は owned fixture の `fixture-ui-complete` marker を確認し、完了済みなら UI setup を自動で skipし、未完了なら再開する。marker-aware retry を迂回するため、通常の再試行で `--fixture-ui --fixture-project ` を実行しない。 ### Fixture UI 再実行 @@ -977,7 +986,7 @@ source が data residency の場合は選択した source command に `--api-bas 同じ Project に明示的に再実行すると non-default Views が重複する。次のどちらかを選んでもらう。 1. 新しい fixture Project を作る(推奨) -2. `View 1` を残し、既存の `Fixture Board` / `Fixture Roadmap` / `Fixture Empty Sums` を手動削除して再実行する +2. `View 1` を残し、既存の `Fixture Board` / `Fixture Roadmap` / `Fixture Empty Sums` / `Fixture Roadmap Dates Hidden` を手動削除して再実行する Workflow は再設定できる。warning が出た場合は、目視だけで終了せず、後続 export が UI settings を警告なしで取得できるか確認する。 @@ -1072,7 +1081,8 @@ $expectedViews = @( [pscustomobject]@{ Name = 'View 1'; Layout = 'TABLE_LAYOUT'; GroupBy = @('Status'); FieldSum = @('Count', 'Fixture Number', 'Fixture Number 2') }, [pscustomobject]@{ Name = 'Fixture Board'; Layout = 'BOARD_LAYOUT'; GroupBy = @('Status'); FieldSum = @('Fixture Number') }, [pscustomobject]@{ Name = 'Fixture Roadmap'; Layout = 'ROADMAP_LAYOUT'; GroupBy = @('Status'); FieldSum = @('Fixture Number 2') }, - [pscustomobject]@{ Name = 'Fixture Empty Sums'; Layout = 'TABLE_LAYOUT'; GroupBy = @('Status'); FieldSum = @() } + [pscustomobject]@{ Name = 'Fixture Empty Sums'; Layout = 'TABLE_LAYOUT'; GroupBy = @('Status'); FieldSum = @() }, + [pscustomobject]@{ Name = 'Fixture Roadmap Dates Hidden'; Layout = 'ROADMAP_LAYOUT'; GroupBy = @('Status'); FieldSum = @('Fixture Number 2') } ) foreach ($requiredField in @('Fixture Number', 'Fixture Number 2')) { $numberFields = @($snapshot.fields | Where-Object { $_.name -eq $requiredField -and $_.dataType -eq 'NUMBER' }) @@ -1100,12 +1110,54 @@ foreach ($expected in $expectedViews) { } Write-Output ("GHPMV_FIELD_SUM_VIEW:{0}:{1}" -f $expected.Name, ($actualFieldSum -join ', ')) } +$roadmap = @($snapshot.views | Where-Object name -eq 'Fixture Roadmap') +if ($roadmap.Count -ne 1 -or + $roadmap[0].ui.roadmap.truncateTitles -ne $true -or + $roadmap[0].ui.roadmap.showDateFields -ne $false) { + Stop-FieldSumSnapshotCheck 'Fixture Roadmap must capture truncateTitles=true and showDateFields=false.' + return +} +$roadmapDatesHidden = @($snapshot.views | Where-Object name -eq 'Fixture Roadmap Dates Hidden') +if ($roadmapDatesHidden.Count -ne 1 -or + $roadmapDatesHidden[0].ui.roadmap.truncateTitles -ne $true -or + $roadmapDatesHidden[0].ui.roadmap.showDateFields -ne $false) { + Stop-FieldSumSnapshotCheck 'Fixture Roadmap Dates Hidden must capture truncateTitles=true and showDateFields=false.' + return +} +$roadmapLongTitle = 'Fixture roadmap item with a deliberately long title for truncation rendering verification' +$roadmapItems = @($snapshot.items | Where-Object { + $_.type -eq 'DRAFT_ISSUE' -and $_.draft.title -eq $roadmapLongTitle +}) +if ($roadmapItems.Count -ne 1) { + Stop-FieldSumSnapshotCheck "Expected exactly one Roadmap rendering draft '$roadmapLongTitle', found $($roadmapItems.Count)." + return +} +$roadmapItem = $roadmapItems[0] +if ($roadmapItem.isArchived -ne $false) { + Stop-FieldSumSnapshotCheck "Roadmap rendering draft '$roadmapLongTitle' must be unarchived." + return +} +$roadmapDates = @($roadmapItem.fieldValues | Where-Object { + $_.fieldName -eq 'Fixture Date' -and -not [string]::IsNullOrWhiteSpace($_.date) +}) +if ($roadmapDates.Count -ne 1) { + Stop-FieldSumSnapshotCheck "Roadmap rendering draft '$roadmapLongTitle' must have exactly one non-empty Fixture Date value." + return +} +$roadmapIterations = @($roadmapItem.fieldValues | Where-Object { + $_.fieldName -eq 'Fixture Sprint' -and -not [string]::IsNullOrWhiteSpace($_.iterationTitle) +}) +if ($roadmapIterations.Count -ne 1) { + Stop-FieldSumSnapshotCheck "Roadmap rendering draft '$roadmapLongTitle' must have exactly one non-empty Fixture Sprint value." + return +} +Write-Output 'GHPMV_ROADMAP_DISPLAY_SNAPSHOT_MATCH' Write-Output 'GHPMV_FIELD_SUM_SNAPSHOT_MATCH' Write-Output 'GHPMV_FIELD_DEFAULT_SNAPSHOT_MATCH' $global:LASTEXITCODE = 0 ``` -`GHPMV_FIELD_DEFAULT_SNAPSHOT_MATCH`、`GHPMV_FIELD_SUM_SNAPSHOT_MATCH`、command exit code 0 をすべて確認した場合だけ両 feature status を `snapshot-match` とし、先へ進む。zero は null と区別し、Single-select は source option ID でなく `singleSelectOptionName=Beta` を要求する。Table / Roadmap のいずれかだけ一致、warning、missing UI、`1 more` のような summary text、`null` と空集合以外の不一致を成功扱いしない。失敗時は source fixture contract の実値を示し、新しい標準 fixture を作るかどうかを一問で確認して停止する。 +`GHPMV_FIELD_DEFAULT_SNAPSHOT_MATCH`、`GHPMV_FIELD_SUM_SNAPSHOT_MATCH`、`GHPMV_ROADMAP_DISPLAY_SNAPSHOT_MATCH`、command exit code 0 をすべて確認した場合だけ各 feature status を `snapshot-match` とし、先へ進む。zero は null と区別し、Single-select は source option ID でなく `singleSelectOptionName=Beta` を要求する。Table / Roadmap のいずれかだけ一致、warning、missing UI、`1 more` のような summary text、`null` と空集合以外の不一致を成功扱いしない。失敗時は source fixture contract の実値を示し、新しい標準 fixture を作るかどうかを一問で確認して停止する。 `api-only` / `browser-e2e`では、target PAT入力またはtarget resource準備より前に同じterminalでsnapshot-driven capabilityを算出する。 @@ -1511,6 +1563,7 @@ target が data residency の場合は `--api-base-url ` と `-- - `Fixture Roadmap`: layout、Group by=`Status`、Field sum=`Fixture Number 2` - `Fixture Board`: layout、Swimlanes=`Status`、Field sum=`Fixture Number` - `Fixture Empty Sums`: layout、Group by=`Status`、Field sum=empty +- `Fixture Roadmap Dates Hidden`: layout、Group by=`Status`、Field sum=`Fixture Number 2`、Show date fields=false このため Group by、Field sum menu の選択状態、空集合について対話用質問や目視確認を重ねない。Issue #62 の派生描画 checkpoint も、初回 `View: Match` 後に次の Playwright command で自動検証する。ユーザーへ browser reload や自己申告を求めない。 @@ -1532,11 +1585,185 @@ finally { } ``` -target が data residency の場合は `--api-base-url ` と `--browser-base-url ` を追加する。command は `View 1` と `Fixture Roadmap` を reload し、visible group header、`Count` rendering、各 Number field の numeric aggregate label を DOM で検査する。両 View の `Rendered Field sums verified`、`Fixture field-sum rendering verified: ... views=2`、command exit code 0 を確認した場合だけ `browser-e2e field-sum status=target-render-observed` として deliberate drift へ進む。欠落は非ゼロ終了にし、対話用質問で補完しない。 +target が data residency の場合は `--api-base-url ` と `--browser-base-url ` を追加する。command は `View 1` と2つのRoadmapをreloadし、aggregate rendering、長いtitleのclip/ellipsis、同じitem内のdate field表示/非表示をDOMで検査する。3 Viewの`Rendered Field sums verified`、`Fixture field-sum rendering verified: ... views=3`、command exit code 0を確認した場合だけ Field sum と Roadmap display のstatusを`target-render-observed`としてdeliberate driftへ進む。 ### Deliberate drift と repair -初回の機械的な `Field: Match` / `View: Match` と new-draft functional check 後、同じ terminal で Field default と Field sum の deliberate drift command を順に送る。ユーザーへ手動変更を依頼しない。 +初回の機械的な `Field: Match` / `View: Match` と new-draft functional check 後、まずtitle-only Roadmap driftを同じtargetへ適用する。 + +```powershell +$previousGhpmvToken = $env:GHPMV_TOKEN +$previousGitHubToken = $env:GITHUB_TOKEN +try { + $env:GHPMV_TOKEN = $env:TARGET_TOKEN + Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue + dotnet run --project src\Ghpmv.Cli -c Release --no-build -- setup ` + --fixture-roadmap-display-drift ` + --fixture-org ` + --fixture-project ` + --browser-profile target +} +finally { + if ($null -eq $previousGhpmvToken) { Remove-Item Env:GHPMV_TOKEN -ErrorAction SilentlyContinue } else { $env:GHPMV_TOKEN = $previousGhpmvToken } + if ($null -eq $previousGitHubToken) { Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue } else { $env:GITHUB_TOKEN = $previousGitHubToken } +} +``` + +同じterminalでbrowser-assisted verifyを実行する。 + +```powershell +$titleDriftReport = Join-Path $env:GHPMV_DEMO_SNAPSHOT 'roadmap-title-drift-report.json' +Remove-Item -LiteralPath $titleDriftReport -ErrorAction SilentlyContinue +$previousGhpmvToken = $env:GHPMV_TOKEN +$previousGitHubToken = $env:GITHUB_TOKEN +try { + $env:GHPMV_TOKEN = $env:TARGET_TOKEN + Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue + dotnet run --project src\Ghpmv.Cli -c Release --no-build -- verify ` + --org ` + --project ` + --in $env:GHPMV_DEMO_SNAPSHOT ` + --repo-mapping "$env:GHPMV_DEMO_SNAPSHOT\repository-mappings.csv" ` + --user-mapping "$env:GHPMV_DEMO_SNAPSHOT\user-mappings.csv" ` + --org-mapping "$env:GHPMV_DEMO_SNAPSHOT\organization-mappings.csv" ` + --categories View ` + --enable-browser-automation ` + --browser-profile target ` + --fail-on-warning ` + --report-json $titleDriftReport + $titleDriftExitCode = $LASTEXITCODE +} +finally { + if ($null -eq $previousGhpmvToken) { Remove-Item Env:GHPMV_TOKEN -ErrorAction SilentlyContinue } else { $env:GHPMV_TOKEN = $previousGhpmvToken } + if ($null -eq $previousGitHubToken) { Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue } else { $env:GITHUB_TOKEN = $previousGitHubToken } +} +if ($titleDriftExitCode -eq 0) { throw 'Title-only Roadmap drift was not detected.' } +$titleReport = Get-Content -LiteralPath $titleDriftReport -Raw | ConvertFrom-Json +$titleViewCategory = @($titleReport.categories | Where-Object category -eq 'View') +$titleDifferences = @($titleReport.differences | Where-Object { $_.category -eq 'View' -and $_.message -match 'truncate titles mismatch' }) +$dateDifferences = @($titleReport.differences | Where-Object { $_.category -eq 'View' -and $_.message -match 'show date fields mismatch' }) +$titleNonInfoDifferences = @($titleReport.differences | Where-Object severity -ne 'Info') +$expectedTitleViews = @("view 'Fixture Roadmap': truncate titles mismatch", "view 'Fixture Roadmap Dates Hidden': truncate titles mismatch") +if ($titleViewCategory.Count -ne 1 -or + $titleViewCategory[0].status -ne 'Mismatch' -or + $titleDifferences.Count -ne 2 -or + $dateDifferences.Count -ne 0 -or + $titleNonInfoDifferences.Count -ne 2 -or + @($expectedTitleViews | Where-Object { $expected = $_; -not ($titleDifferences.message | Where-Object { $_ -like "$expected*" }) }).Count -ne 0) { + throw 'Title-only Roadmap drift did not produce exactly the two expected title mismatches.' +} +Write-Output 'GHPMV_ROADMAP_TITLE_ONLY_DRIFT_DETECTED' +$global:LASTEXITCODE = 0 +``` + +targetがdata residencyの場合は初回verifyと同じendpoint optionを追加する。markerとwrapper exit code 0を確認後、DOMを確認する。 + +```powershell +$previousGhpmvToken = $env:GHPMV_TOKEN +$previousGitHubToken = $env:GITHUB_TOKEN +try { + $env:GHPMV_TOKEN = $env:TARGET_TOKEN + Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue + dotnet run --project src\Ghpmv.Cli -c Release --no-build -- setup ` + --fixture-roadmap-title-display-render-check ` + --fixture-org ` + --fixture-project ` + --browser-profile target +} +finally { + if ($null -eq $previousGhpmvToken) { Remove-Item Env:GHPMV_TOKEN -ErrorAction SilentlyContinue } else { $env:GHPMV_TOKEN = $previousGhpmvToken } + if ($null -eq $previousGitHubToken) { Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue } else { $env:GITHUB_TOKEN = $previousGitHubToken } +} +``` + +title-only checkpoint後はrepair importを挟まない。次のdate-only drift `(true,true)` がtitle truncationだけをbaselineへ戻し、date displayだけをdriftさせる。 + +```powershell +$previousGhpmvToken = $env:GHPMV_TOKEN +$previousGitHubToken = $env:GITHUB_TOKEN +try { + $env:GHPMV_TOKEN = $env:TARGET_TOKEN + Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue + dotnet run --project src\Ghpmv.Cli -c Release --no-build -- setup ` + --fixture-roadmap-date-display-drift ` + --fixture-org ` + --fixture-project ` + --browser-profile target +} +finally { + if ($null -eq $previousGhpmvToken) { Remove-Item Env:GHPMV_TOKEN -ErrorAction SilentlyContinue } else { $env:GHPMV_TOKEN = $previousGhpmvToken } + if ($null -eq $previousGitHubToken) { Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue } else { $env:GITHUB_TOKEN = $previousGitHubToken } +} +``` + +同じterminalでbrowser-assisted View verifyを`roadmap-date-drift-report.json`へ出力する。 + +```powershell +$dateDriftReport = Join-Path $env:GHPMV_DEMO_SNAPSHOT 'roadmap-date-drift-report.json' +Remove-Item -LiteralPath $dateDriftReport -ErrorAction SilentlyContinue +$previousGhpmvToken = $env:GHPMV_TOKEN +$previousGitHubToken = $env:GITHUB_TOKEN +try { + $env:GHPMV_TOKEN = $env:TARGET_TOKEN + Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue + dotnet run --project src\Ghpmv.Cli -c Release --no-build -- verify ` + --org ` + --project ` + --in $env:GHPMV_DEMO_SNAPSHOT ` + --repo-mapping "$env:GHPMV_DEMO_SNAPSHOT\repository-mappings.csv" ` + --user-mapping "$env:GHPMV_DEMO_SNAPSHOT\user-mappings.csv" ` + --org-mapping "$env:GHPMV_DEMO_SNAPSHOT\organization-mappings.csv" ` + --categories View ` + --enable-browser-automation ` + --browser-profile target ` + --fail-on-warning ` + --report-json $dateDriftReport + $dateDriftExitCode = $LASTEXITCODE +} +finally { + if ($null -eq $previousGhpmvToken) { Remove-Item Env:GHPMV_TOKEN -ErrorAction SilentlyContinue } else { $env:GHPMV_TOKEN = $previousGhpmvToken } + if ($null -eq $previousGitHubToken) { Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue } else { $env:GITHUB_TOKEN = $previousGitHubToken } +} +if ($dateDriftExitCode -eq 0) { throw 'Date-only Roadmap drift was not detected.' } +$dateReport = Get-Content -LiteralPath $dateDriftReport -Raw | ConvertFrom-Json +$dateViewCategory = @($dateReport.categories | Where-Object category -eq 'View') +$dateOnlyDifferences = @($dateReport.differences | Where-Object { $_.category -eq 'View' -and $_.message -match 'show date fields mismatch' }) +$unexpectedTitleDifferences = @($dateReport.differences | Where-Object { $_.category -eq 'View' -and $_.message -match 'truncate titles mismatch' }) +$dateNonInfoDifferences = @($dateReport.differences | Where-Object severity -ne 'Info') +if ($dateViewCategory.Count -ne 1 -or + $dateViewCategory[0].status -ne 'Mismatch' -or + $dateOnlyDifferences.Count -ne 2 -or + $unexpectedTitleDifferences.Count -ne 0 -or + $dateNonInfoDifferences.Count -ne 2) { + throw 'Date-only Roadmap drift did not produce exactly the two expected date mismatches.' +} +Write-Output 'GHPMV_ROADMAP_DATE_ONLY_DRIFT_DETECTED' +$global:LASTEXITCODE = 0 +``` + +targetがdata residencyの場合は初回verifyと同じendpoint optionを追加する。markerとwrapper exit code 0を確認後、次を実行する。 + +```powershell +$previousGhpmvToken = $env:GHPMV_TOKEN +$previousGitHubToken = $env:GITHUB_TOKEN +try { + $env:GHPMV_TOKEN = $env:TARGET_TOKEN + Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue + dotnet run --project src\Ghpmv.Cli -c Release --no-build -- setup ` + --fixture-roadmap-date-display-render-check ` + --fixture-org ` + --fixture-project ` + --browser-profile target +} +finally { + if ($null -eq $previousGhpmvToken) { Remove-Item Env:GHPMV_TOKEN -ErrorAction SilentlyContinue } else { $env:GHPMV_TOKEN = $previousGhpmvToken } + if ($null -eq $previousGitHubToken) { Remove-Item Env:GITHUB_TOKEN -ErrorAction SilentlyContinue } else { $env:GITHUB_TOKEN = $previousGitHubToken } +} +``` + +truncated title / visible datesのDOM確認後、date-only driftを維持したままField default / Field sum driftへ進む。すべてのdriftは既存の最終re-import 1回で同時にrepairする。 + +次に同じ terminal で Field default と Field sum の deliberate drift command を順に送る。ユーザーへ手動変更を依頼しない。 ```powershell $previousGhpmvToken = $env:GHPMV_TOKEN @@ -1577,7 +1804,7 @@ finally { } ``` -target が data residency の場合は `--api-base-url ` と `--browser-base-url ` を追加する。`Fixture field-sum drift applied`、`viewWarnings=0`、command exit code 0 を確認した後、同じ terminal で次の drift verify command を送る。placeholder、optional mapping、profile、endpoint は初回 verify と同じ実値へ置き換える。この command は native exit code 0 を失敗とし、非ゼロ終了かつ report の View category が `Mismatch`、`field sum mismatch` が存在する場合だけ semantic success とする。 +target が data residency の場合は `--api-base-url ` と `--browser-base-url ` を追加する。`Fixture field-sum drift applied`、`viewWarnings=0`、command exit code 0 を確認した後、保持中のRoadmap date-display driftを含め、Field default / Field sum / Roadmap date-displayの3 driftを同時検証するverifyを送る。 ```powershell function Stop-FieldSumDriftCheck([string]$Message) { @@ -1617,6 +1844,12 @@ $driftFieldCategories = @($driftReport.categories | Where-Object category -eq 'F $driftViewCategories = @($driftReport.categories | Where-Object category -eq 'View') $fieldDefaultDifferences = @($driftReport.differences | Where-Object { $_.category -eq 'Field' -and $_.message -match 'default value mismatch' }) $fieldSumDifferences = @($driftReport.differences | Where-Object { $_.category -eq 'View' -and $_.message -match "view 'View 1': field sum mismatch" }) +$roadmapTitleDifferences = @($driftReport.differences | Where-Object { + $_.category -eq 'View' -and $_.message -match "view 'Fixture Roadmap(?: Dates Hidden)?': truncate titles mismatch" +}) +$roadmapDateDifferences = @($driftReport.differences | Where-Object { + $_.category -eq 'View' -and $_.message -match "view 'Fixture Roadmap(?: Dates Hidden)?': show date fields mismatch" +}) $nonInfoDifferences = @($driftReport.differences | Where-Object severity -ne 'Info') $unexpectedCategoryStatuses = @($driftReport.categories | Where-Object { $_.category -notin @('Field', 'View') -and $_.status -notin @('Match', 'NotApplicable') @@ -1627,19 +1860,22 @@ if ($driftFieldCategories.Count -ne 1 -or $driftViewCategories.Count -ne 1 -or $driftViewCategories[0].status -ne 'Mismatch' -or $fieldSumDifferences.Count -ne 1 -or - $nonInfoDifferences.Count -ne 5 -or + $roadmapTitleDifferences.Count -ne 0 -or + $roadmapDateDifferences.Count -ne 2 -or + $nonInfoDifferences.Count -ne 7 -or $unexpectedCategoryStatuses.Count -ne 0) { - Stop-FieldSumDriftCheck 'Verify did not contain exactly four Field default mismatches and the expected View 1 field-sum mismatch.' + Stop-FieldSumDriftCheck 'Verify did not contain exactly four Field default mismatches, the View 1 field-sum mismatch, and both Roadmap date-display mismatches.' return } Write-Output $fieldDefaultDifferences.message Write-Output $fieldSumDifferences.message Write-Output 'GHPMV_FIELD_DEFAULT_DRIFT_DETECTED' Write-Output 'GHPMV_FIELD_SUM_DRIFT_DETECTED' +Write-Output 'GHPMV_ROADMAP_DISPLAY_DRIFT_DETECTED' $global:LASTEXITCODE = 0 ``` -target が data residency の場合は、この drift verify にも初回 verify と同じ `--target-base-url ` と `--browser-base-url ` を追加する。`GHPMV_FIELD_DEFAULT_DRIFT_DETECTED`、`GHPMV_FIELD_SUM_DRIFT_DETECTED`、wrapper exit code 0 を確認した場合だけ両 feature status=`drift-detected` とする。 +target が data residency の場合は、この drift verify にも初回 verify と同じ `--target-base-url ` と `--browser-base-url ` を追加する。3つの drift marker と wrapper exit code 0 を確認した場合だけ各feature status=`drift-detected`としてrepairへ進む。 続けて同じ snapshot と target Project へ browser-assisted import を再実行する。`--project-number` は既存 Project を常に更新するため、`--on-conflict` や `--project-title` を追加しない。 @@ -1747,10 +1983,11 @@ if ($global:GHPMV_REPAIR_VERIFY_EXIT_CODE -eq 0 -or Write-Output 'GHPMV_ITEM_VALUES_REPAIR_MATCH' Write-Output 'GHPMV_FIELD_DEFAULT_REPAIR_MATCH' Write-Output 'GHPMV_FIELD_SUM_REPAIR_MATCH' +Write-Output 'GHPMV_ROADMAP_DISPLAY_REPAIR_MATCH' $global:LASTEXITCODE = 0 ``` -target が data residency の場合は repair import / verify にも初回と同じ endpoint option を追加する。`GHPMV_FIELD_DEFAULT_REPAIR_MATCH`、`GHPMV_FIELD_SUM_REPAIR_MATCH`、command exit code 0 を確認後、次のfunctional checkを同じterminalへ送る。 +target が data residency の場合は repair import / verify にも初回と同じ endpoint option を追加する。Field default / Field sum / Roadmap display の3つの repair marker と command exit code 0 を確認後、次のfunctional checkを同じterminalへ送る。 ```powershell $previousGhpmvToken = $env:GHPMV_TOKEN @@ -1770,9 +2007,9 @@ finally { } ``` -target が data residency の場合は `--api-base-url ` と `--browser-base-url ` を追加する。`Fixture field defaults functionally verified: ... fields=4 draft= title='' cleanup=pending` と exit code 0 を確認し、修復後draftをnested resource inventoryへ`created`として追加する。修復後の new draftにも4 defaultsが入り、`GHPMV_ITEM_VALUES_REPAIR_MATCH`が既知のinventory draft以外にItem差分がないことを証明した場合、追加の対話用質問を行わず両 feature status=`repair-match` とする。 +target が data residency の場合は `--api-base-url <target-api-url>` と `--browser-base-url <target-web-url>` を追加する。`Fixture field defaults functionally verified: ... fields=4 draft=<id> title='<title>' cleanup=pending` と exit code 0 を確認し、修復後draftをnested resource inventoryへ`created`として追加する。修復後の new draftにも4 defaultsが入り、`GHPMV_ITEM_VALUES_REPAIR_MATCH`が既知のinventory draft以外にItem差分がないことを証明した場合、追加の対話用質問を行わず3 feature status=`repair-match` とする。 -`browser-e2e` は field-defaultの`new-draft-observed` / `repair-match` と field-sumの`target-render-observed` / `repair-match`へ到達してから Resource inventory の cleanup 同意へ進む。`api-only` は通常の Step 10 完了後に cleanup 同意へ進む。 +`browser-e2e` は field-defaultの`new-draft-observed` / `repair-match`、field-sumとRoadmap displayの`target-render-observed` / `repair-match`へ到達してから Resource inventory の cleanup 同意へ進む。`api-only` は通常の Step 10 完了後に cleanup 同意へ進む。 ## Troubleshooting diff --git a/.github/workflows/live-api-report.yml b/.github/workflows/live-api-report.yml new file mode 100644 index 00000000..74a07bca --- /dev/null +++ b/.github/workflows/live-api-report.yml @@ -0,0 +1,226 @@ +name: Report Live GitHub API + +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +concurrency: + group: live-api-report-${{ github.event.workflow_run.pull_requests[0].number || github.event.workflow_run.id }} + cancel-in-progress: false + +defaults: + run: + shell: pwsh + +jobs: + report: + name: Report integration result + if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0] != null }} + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + SERVER_URL: ${{ github.server_url }} + steps: + - name: Find Live GitHub API result + id: result + env: + GH_TOKEN: ${{ github.token }} + run: | + $currentHeadSha = gh api "repos/$env:REPOSITORY/pulls/$env:PR_NUMBER" --jq '.head.sha' + if ($LASTEXITCODE -ne 0) { + throw 'Could not read the pull request current head SHA.' + } + if (-not [string]::Equals($currentHeadSha, $env:RUN_HEAD_SHA, [StringComparison]::OrdinalIgnoreCase)) { + 'result=stale' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + Write-Output "Ignoring completed run for stale head '$env:RUN_HEAD_SHA'; current head is '$currentHeadSha'." + exit 0 + } + + $jobsJson = gh api --paginate --slurp "repos/$env:REPOSITORY/actions/runs/$env:RUN_ID/jobs?per_page=100" + if ($LASTEXITCODE -ne 0) { + throw 'Could not read jobs for the completed CI run.' + } + $jobs = @($jobsJson | ConvertFrom-Json | ForEach-Object { $_.jobs }) + $integration = @($jobs | Where-Object name -eq 'Live GitHub API / Integration' | Select-Object -Last 1) + if ($integration.Count -eq 0) { + 'result=skipped' | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + exit 0 + } + $summary = @($jobs | Where-Object name -eq 'Live GitHub API / Summarize integration result' | Select-Object -Last 1) + $result = if ($integration[0].conclusion -eq 'success' -and + ($summary.Count -eq 0 -or $summary[0].conclusion -ne 'success')) { + 'skipped' + } + else { + $integration[0].conclusion + } + "result=$result" | Out-File -FilePath $env:GITHUB_OUTPUT -Encoding utf8 -Append + - name: Download integration test results + if: ${{ steps.result.outputs.result == 'failure' }} + continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} + run: gh run download "$env:RUN_ID" --repo "$env:REPOSITORY" --name live-api-test-results --dir tests/Ghpmv.Integration.Tests/TestResults + - name: Report integration failure + if: ${{ steps.result.outputs.result == 'failure' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + $marker = '<!-- ghpmv-live-api-result -->' + $shortSha = $env:RUN_HEAD_SHA.Substring(0, [Math]::Min(7, $env:RUN_HEAD_SHA.Length)) + $reportPath = Get-ChildItem -LiteralPath 'tests/Ghpmv.Integration.Tests/TestResults' -Filter 'integration.trx' -File -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + $failedTests = @() + $messages = @() + if ($null -ne $reportPath) { + $settings = [System.Xml.XmlReaderSettings]::new() + $settings.DtdProcessing = [System.Xml.DtdProcessing]::Prohibit + $settings.XmlResolver = $null + $reader = [System.Xml.XmlReader]::Create($reportPath, $settings) + $results = [System.Xml.XmlDocument]::new() + $results.XmlResolver = $null + try { + $results.Load($reader) + } + finally { + $reader.Dispose() + } + $failures = @($results.SelectNodes("//*[local-name()='UnitTestResult' and @outcome='Failed']")) + foreach ($failure in $failures) { + $failedTests += $failure.testName + $message = $failure.SelectSingleNode("./*[local-name()='Output']/*[local-name()='ErrorInfo']/*[local-name()='Message']") + if ($null -ne $message) { + $messages += $message.InnerText + } + } + } + + $failureText = $messages -join "`n" + $diagnosis = if ($failureText -match 'Can only add 1000 curated projects to a repo') { + "The target fixture repository reached GitHub's 1,000 linked-Projects limit. Rotate the target fixture repository; rerunning without replacing it will fail again." + } + elseif ($failureText -match 'could not link repository') { + "Repository linking failed. Confirm the target fixture exists, the test token can administer it, and the repository has not reached GitHub's linked-Projects limit." + } + elseif ($failureText -match '(?i)rate limit') { + 'GitHub API rate limiting interrupted the live test. Review the rate-limit entries in the run log before rerunning.' + } + elseif ($failureText -match 'Resource not accessible by personal access token') { + 'The test token lacks access required by the failing GitHub operation. Review its scopes, repository access, approval, and SSO authorization.' + } + else { + 'The live GitHub API test failed for an unclassified reason. Inspect the run log and uploaded TRX artifact.' + } + + $testLines = if ($failedTests.Count -gt 0) { + @($failedTests | Sort-Object -Unique | ForEach-Object { + $singleLineName = [regex]::Replace([string]$_, '[\r\n\u2028\u2029]+', ' ') + $safeName = [regex]::Replace( + $singleLineName, + '[^\p{L}\p{N}\p{Zs}]', + { param($match) "&#$([int][char]$match.Value);" }) + "- <code>$safeName</code>" + }) + } + else { + @('- Failed test names were unavailable; inspect the run log.') + } + $body = (@( + $marker + "### Live GitHub API check failed for ``$shortSha``" + '' + "**Diagnosis:** $diagnosis" + '' + '**Failed tests:**' + ) + $testLines + @( + '' + "Commit: [$shortSha]($env:SERVER_URL/$env:REPOSITORY/commit/$env:RUN_HEAD_SHA)" + '' + "[Open the Actions run]($env:RUN_URL)" + )) -join "`n" + + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value ($body.Replace($marker, '').Trim()) + Write-Output "::error title=Live GitHub API check failed::$diagnosis" + $commentsJson = gh api --paginate --slurp "repos/$env:REPOSITORY/issues/$env:PR_NUMBER/comments?per_page=100" + if ($LASTEXITCODE -ne 0) { + Write-Output '::warning title=PR failure comment was not posted::Could not list existing pull request comments.' + exit 0 + } + $comments = @($commentsJson | ConvertFrom-Json | ForEach-Object { $_ }) + $comment = @($comments | Where-Object { + $_.user.login -eq 'github-actions[bot]' -and $_.body.Contains($marker, [StringComparison]::Ordinal) + } | Select-Object -Last 1) + $currentHeadSha = gh api "repos/$env:REPOSITORY/pulls/$env:PR_NUMBER" --jq '.head.sha' + if ($LASTEXITCODE -ne 0) { + Write-Output '::warning title=PR failure comment was not posted::Could not revalidate the pull request head.' + exit 0 + } + if (-not [string]::Equals($currentHeadSha, $env:RUN_HEAD_SHA, [StringComparison]::OrdinalIgnoreCase)) { + Write-Output "PR head changed to '$currentHeadSha'; skipping stale failure comment for '$env:RUN_HEAD_SHA'." + exit 0 + } + $payload = @{ body = $body } | ConvertTo-Json -Compress + if ($comment.Count -eq 1) { + $payload | gh api --method PATCH "repos/$env:REPOSITORY/issues/comments/$($comment[0].id)" --input - + } + else { + $payload | gh api --method POST "repos/$env:REPOSITORY/issues/$env:PR_NUMBER/comments" --input - + } + if ($LASTEXITCODE -ne 0) { + Write-Output '::warning title=PR failure comment was not posted::The pull request comment API request failed.' + exit 0 + } + - name: Resolve integration failure comment + if: ${{ steps.result.outputs.result == 'success' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + $marker = '<!-- ghpmv-live-api-result -->' + $shortSha = $env:RUN_HEAD_SHA.Substring(0, [Math]::Min(7, $env:RUN_HEAD_SHA.Length)) + $commentsJson = gh api --paginate --slurp "repos/$env:REPOSITORY/issues/$env:PR_NUMBER/comments?per_page=100" + if ($LASTEXITCODE -ne 0) { + Write-Output '::warning title=PR failure comment was not resolved::Could not list existing pull request comments.' + exit 0 + } + $comments = @($commentsJson | ConvertFrom-Json | ForEach-Object { $_ }) + $comment = @($comments | Where-Object { + $_.user.login -eq 'github-actions[bot]' -and $_.body.Contains($marker, [StringComparison]::Ordinal) + } | Select-Object -Last 1) + if ($comment.Count -eq 0) { + Write-Output 'No prior Live GitHub API failure comment exists.' + exit 0 + } + + $currentHeadSha = gh api "repos/$env:REPOSITORY/pulls/$env:PR_NUMBER" --jq '.head.sha' + if ($LASTEXITCODE -ne 0) { + Write-Output '::warning title=PR failure comment was not resolved::Could not revalidate the pull request head.' + exit 0 + } + if (-not [string]::Equals($currentHeadSha, $env:RUN_HEAD_SHA, [StringComparison]::OrdinalIgnoreCase)) { + Write-Output "PR head changed to '$currentHeadSha'; skipping stale recovery comment for '$env:RUN_HEAD_SHA'." + exit 0 + } + + $body = @" + $marker + ### Live GitHub API check recovered for ``$shortSha`` + + The Live GitHub API check passed for commit [$shortSha]($env:SERVER_URL/$env:REPOSITORY/commit/$env:RUN_HEAD_SHA) on the [Actions run]($env:RUN_URL). + "@ + $payload = @{ body = $body.Trim() } | ConvertTo-Json -Compress + $payload | gh api --method PATCH "repos/$env:REPOSITORY/issues/comments/$($comment[0].id)" --input - + if ($LASTEXITCODE -ne 0) { + Write-Output '::warning title=PR failure comment was not resolved::The pull request comment API request failed.' + exit 0 + } diff --git a/.github/workflows/live-api.yml b/.github/workflows/live-api.yml index 91342f91..56c34fd5 100644 --- a/.github/workflows/live-api.yml +++ b/.github/workflows/live-api.yml @@ -30,6 +30,8 @@ jobs: timeout-minutes: 20 permissions: contents: read + outputs: + credentials_available: ${{ steps.credentials.outputs.available }} env: GHPMV_TEST_ORG: ${{ vars.GHPMV_TEST_ORG || 'gpm-source' }} GHPMV_TEST_PROJECT_NUMBER: ${{ vars.GHPMV_TEST_PROJECT_NUMBER || '89' }} @@ -80,6 +82,7 @@ jobs: if: ${{ steps.credentials.outputs.available == 'true' }} run: dotnet build Ghpmv.slnx -c Release --no-restore -warnaserror - name: Synchronize fixture and test + id: integration if: ${{ steps.credentials.outputs.available == 'true' }} env: GHPMV_TEST_TOKEN: ${{ secrets.GHPMV_TEST_TOKEN }} @@ -111,3 +114,79 @@ jobs: path: tests/Ghpmv.Integration.Tests/TestResults/** if-no-files-found: warn retention-days: 7 + + report: + name: Summarize integration result + needs: integration + if: ${{ always() && needs.integration.outputs.credentials_available == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - name: Download integration test results + if: ${{ needs.integration.result == 'failure' }} + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: live-api-test-results + path: tests/Ghpmv.Integration.Tests/TestResults + - name: Report integration failure + if: ${{ needs.integration.result == 'failure' }} + env: + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + $marker = '<!-- ghpmv-live-api-failure -->' + $reportPath = Get-ChildItem -LiteralPath 'tests/Ghpmv.Integration.Tests/TestResults' -Filter 'integration.trx' -File -Recurse -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + $failedTests = @() + $messages = @() + if ($null -ne $reportPath) { + [xml]$results = Get-Content -LiteralPath $reportPath + $failures = @($results.SelectNodes("//*[local-name()='UnitTestResult' and @outcome='Failed']")) + foreach ($failure in $failures) { + $failedTests += $failure.testName + $message = $failure.SelectSingleNode("./*[local-name()='Output']/*[local-name()='ErrorInfo']/*[local-name()='Message']") + if ($null -ne $message) { + $messages += $message.InnerText + } + } + } + + $failureText = $messages -join "`n" + $diagnosis = if ($failureText -match 'Can only add 1000 curated projects to a repo') { + "The target fixture repository reached GitHub's 1,000 linked-Projects limit. Rotate the target fixture repository; rerunning without replacing it will fail again." + } + elseif ($failureText -match 'could not link repository') { + "Repository linking failed. Confirm the target fixture exists, the test token can administer it, and the repository has not reached GitHub's linked-Projects limit." + } + elseif ($failureText -match '(?i)rate limit') { + 'GitHub API rate limiting interrupted the live test. Review the rate-limit entries in the run log before rerunning.' + } + elseif ($failureText -match 'Resource not accessible by personal access token') { + 'The test token lacks access required by the failing GitHub operation. Review its scopes, repository access, approval, and SSO authorization.' + } + else { + 'The live GitHub API test failed for an unclassified reason. Inspect the run log and uploaded TRX artifact.' + } + + $testLines = if ($failedTests.Count -gt 0) { + @($failedTests | Sort-Object -Unique | ForEach-Object { "- ``$_``" }) + } + else { + @('- Failed test names were unavailable; inspect the run log.') + } + $body = (@( + $marker + '### Live GitHub API check failed' + '' + "**Diagnosis:** $diagnosis" + '' + '**Failed tests:**' + ) + $testLines + @( + '' + "[Open the Actions run]($env:RUN_URL)" + )) -join "`n" + + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value ($body.Replace($marker, '').Trim()) + Write-Output "::error title=Live GitHub API check failed::$diagnosis" diff --git a/README.md b/README.md index 6cffe0ac..9cb608ad 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ Tokens are resolved from `--token`, then the `GITHUB_TOKEN` / `GHPMV_TOKEN` envi | Category | Verification coverage | |---|---| -| Project | Description, README, visibility, closed state, and organization template state. A changed title is informational because import supports title overrides. Legacy snapshots without `project.template` preserve and do not compare the target template state. | +| Project | Description, README, visibility, closed state, and organization template state. A changed title is informational because import supports title overrides. | | Field | Field presence/type, select option order/name/color/description, Issue Field description/visibility/linkage, and iteration dates/durations. | | Item | Counts/types, issue and pull request identity, draft body, field values (including Project and Issue Field multi-select values), active-item order, and archived state. Archived-item order is excluded because GitHub cannot restore it. | | StatusUpdate | History order, body (including the imported attribution note), status, start date, and target date. | diff --git a/docs/BROWSER_AUTOMATION_PLAN.md b/docs/BROWSER_AUTOMATION_PLAN.md index 7204fec9..5232dd89 100644 --- a/docs/BROWSER_AUTOMATION_PLAN.md +++ b/docs/BROWSER_AUTOMATION_PLAN.md @@ -26,8 +26,8 @@ GraphQL と Playwright を組み合わせた View・Workflow 移行の詳細設 | sort(複数キー+方向) | GraphQL `sortByFields`(`ProjectV2SortByField.direction`) | **UI** | | | **Slice by** | ❌ API に無い → **UI で読む** | **UI** | | | **Field sum** | ❌ API に無い → **UI で読む** | **UI** | Board と grouped Table / Roadmap。Count、複数 Number field、空集合を complete-set 同期 | -| **Roadmap 設定(Dates / Zoom / Markers)** | ❌ API に無い → **UI で読む** | **UI** | | -| タブの並び順 | **UI**(`navigation "Select view"` 内のsaved tab `href`順) | **UI**(タブの drag & drop) | GraphQL `POSITION`は現行UIのsaved-tab順と乖離する場合がある。`ViewSnapshot.tabPosition`はschema v1のnullable additive field | +| **Roadmap 設定(Dates / Zoom / Markers / Truncate titles / Show date fields)** | ❌ API に無い → **UI で読む** | **UI** | 表示optionは全Roadmap Viewで共有され、browser storageに保持されるため、各Viewから同一stateをcapture/replayし、書き込み後にprofileを保存 | +| タブの並び順 | **UI**(`navigation "Select view"` 内のsaved tab `href`順) | **UI**(タブの drag & drop) | GraphQL `POSITION`は現行UIのsaved-tab順と乖離する場合がある | ### Workflow のプロパティ別ソースマップ @@ -336,6 +336,7 @@ browser importer 自体は各 view / workflow の適用直後に完全な read-b 2. "Fixture Board" — Board, Column by, swimlane, Field sum=[Fixture Number] 3. "Fixture Roadmap" — grouped Roadmap, Field sum=[Fixture Number 2], Dates, Zoom, Markers 4. "Fixture Empty Sums" — grouped Table, Field sum=[] + 5. "Fixture Roadmap Dates Hidden" — grouped Roadmap, Truncate titles=on, Show date fields=off - Workflows: W-1〜W-8 を非デフォルト Status 値で有効化、W-9 を 2 本(別リポ + 別フィルター)。1 つは disabled のまま設定を持たせる(§4.3 の D0 論点の検証用) - Items: issue 10 / PR 3 / draft 3(archived 2 を含む) - Field defaults: Fixture Text=`既定値 🌏`、Fixture Number=`-7`、Fixture Number 2=`0`、Fixture Select=`Beta` diff --git a/docs/MANUAL_TEST_PLAN.md b/docs/MANUAL_TEST_PLAN.md index 33e8f072..ef954f77 100644 --- a/docs/MANUAL_TEST_PLAN.md +++ b/docs/MANUAL_TEST_PLAN.md @@ -126,7 +126,7 @@ EMU / SAML / OIDC backed organization の場合は、PAT と browser session の Team link の手動 E2E では共有 Team を変更せず、source/target の各 organization にこのテスト専用 Team を作成してください。source fixture には `--fixture-team <source-team-slug>` を渡します。target Team は同じ slug、または renamed mapping を確認する別 slug にします。 -Views の作成と name / layout / filter / visible fields は GraphQL API で設定します。標準 fixture には API 未対応の View 設定、非自明な `Fixture Roadmap → View 1 → Fixture Board → Fixture Empty Sums` の tab order、Workflows も含まれるため、`ghpmv setup --fixture-ui` は API View import の後に C# の Playwright layer で補完します。手動で UI をぽちぽち濃くする必要はありません。 +Views の作成と name / layout / filter / visible fields は GraphQL API で設定します。標準 fixture には API 未対応の View 設定、非自明な `Fixture Roadmap → View 1 → Fixture Board → Fixture Empty Sums → Fixture Roadmap Dates Hidden` の tab order、Workflows も含まれるため、`ghpmv setup --fixture-ui` は API View import の後に C# の Playwright layer で補完します。手動で UI をぽちぽち濃くする必要はありません。 --- @@ -287,18 +287,23 @@ dotnet run --project src/Ghpmv.Cli -c Release --no-build -- login --profile targ ## 5. Source fixture の作成 -### 5.1 API で作れる部分を C# で作成 +### 5.1 API / browser fixture を combined setup で作成 ```powershell dotnet run --project src/Ghpmv.Cli -- setup ` --fixture ` + --fixture-ui ` --fixture-org $env:GHPMV_SOURCE_ORG ` --fixture-title gpm-fixture ` --fixture-repo $env:GHPMV_FIXTURE_REPO ` --fixture-team <dedicated-source-team-slug> ` - --token $env:GHPMV_SOURCE_TOKEN + --fixture-require-new ` + --token $env:GHPMV_SOURCE_TOKEN ` + --browser-profile source ``` +この combined command は repository / fields / items / Status Updates に加え、Roadmap rendering 検証に必要な長い dated item、Views、Workflows、browser-only settings を同じ owned operation で作成します。`--fixture-require-new` により同名 Project / repository が存在する場合は書き込み前に停止します。 + 出力された Project URL と project number を控えます。 ```text @@ -306,45 +311,23 @@ Source project URL: https://github.com/orgs/<source-org>/projects/<source-projec Source project number: <source-project-number> ``` -### 5.2 View / Workflow fixture を GraphQL API + C# / Playwright で作成する +### 5.2 View / Workflow fixture の適用と再開 -`ghpmv setup --fixture` は repository / fields / items / Status Updates までを作ります。`--fixture-require-new` を指定した新規 E2E fixture では、Project の Date / Iteration 値を実行週の月曜日を基準に配置し、Roadmap の初期表示範囲内で確認できるようにします。基準日は operation log に保存されるため、日をまたいだ再実行でも変わりません。続けて `ghpmv setup --fixture-ui` を実行すると、Views の基本設定を GraphQL API で作成・更新し、group/sort/slice/roadmap、非自明な tab order、Workflows を C# の `ViewUiImporter` / `WorkflowUiImporter` が Playwright で補完します。 +5.1 の combined command は Views の基本設定を GraphQL API で作成・更新し、group/sort/slice/roadmap、非自明な tab order、Workflows を C# の `ViewUiImporter` / `WorkflowUiImporter` が Playwright で補完します。Project の Date / Iteration 値は実行週の月曜日を基準に配置され、基準日は operation log に保存されるため、日をまたいだ再実行でも変わりません。Roadmap の shared display option などbrowser storageに保持される設定は、書き込み後に同じbrowser profileへ保存され、後続のfresh contextでも再利用されます。 -```powershell -dotnet run --project src/Ghpmv.Cli -- setup ` - --fixture-ui ` - --fixture-org $env:GHPMV_SOURCE_ORG ` - --fixture-project <source-project-number> ` - --fixture-repo $env:GHPMV_FIXTURE_REPO ` - --token $env:GHPMV_SOURCE_TOKEN ` - --browser-profile source -``` +5.1 の command が途中で失敗した場合は、同じ title / repository / browser profileを指定した同じ combined commandを再実行します。この操作が所有する Project は、前回の UI 適用が未完了なら再開し、完了済みならskipします。marker-aware retryを迂回して`--fixture-ui --fixture-project`へ切り替えないでください。 -API-backed fixture 作成と UI-only fixture 作成を 1 回で実行する場合は、`--fixture` と `--fixture-ui` を併用できます。この場合、`--fixture-project` は不要です。 - -```powershell -dotnet run --project src/Ghpmv.Cli -- setup ` - --fixture ` - --fixture-ui ` - --fixture-org $env:GHPMV_SOURCE_ORG ` - --fixture-title gpm-fixture ` - --fixture-repo $env:GHPMV_FIXTURE_REPO ` - --token $env:GHPMV_SOURCE_TOKEN ` - --browser-profile source -``` - -`--fixture --fixture-ui` の再実行では、別の操作で作成された同名 Project は Workflows の重複作成を避けるため UI 適用を自動で skip します。この操作が所有する Project は、前回の UI 適用が未完了なら再開し、完了済みなら skip します。既存 Project に fixture を強制的に再適用する場合だけ、`--fixture` を外して `--fixture-ui --fixture-project <source-project-number>` を明示してください。 - -> **再実行時の注意:** `setup --fixture-ui` の View import は既存 View を名前で再利用するため、`Fixture Board` / `Fixture Roadmap` は重複しません。Workflows は built-in entries を再設定できますが、複製した Auto-add workflow は重複し得るため、完全にクリーンな検証には新しい fixture Project を使用してください。 +> **既存 Project への明示適用:** `--fixture-ui --fixture-project` を単独で使えるのは、Roadmap rendering 検証用の長い dated itemとDate / Iteration値を既に含むProjectだけです。通常のE2Eでは使用せず、新しいcombined fixtureを作成してください。 このコマンドは、既存 Project に対して標準テスト用の以下を作成します。 - Views - `View 1`: grouped Table、filter、sort、Slice by、Field sum=`Count` + `Fixture Number` + `Fixture Number 2`、visible fields - `Fixture Board`: Board、Column by、Swimlanes、Field sum - - `Fixture Roadmap`: grouped Roadmap、Field sum=`Fixture Number 2`、date fields、Quarter zoom、markers + - `Fixture Roadmap`: grouped Roadmap、Field sum=`Fixture Number 2`、date fields、Quarter zoom、markers、shared `Truncate titles`=on、`Show date fields`=off。fixture は truncation 確認用の長い draft title を含む - `Fixture Empty Sums`: grouped Table、Field sum の空選択 - - tab order: `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` + - `Fixture Roadmap Dates Hidden`: grouped Roadmap、`Truncate titles`=on、`Show date fields`=off + - tab order: `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` - Workflows - item state 系 built-in workflows - `Auto-add to project` @@ -361,7 +344,7 @@ dotnet run --project src/Ghpmv.Cli -- setup ` Views: -- タブを `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` の順に並べる +- タブを `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` の順に並べる - `View 1` (Table) - filter=`status:Todo` - visible fields を標準 fixture に合わせる @@ -379,9 +362,16 @@ Views: - Dates=`Fixture Date` → `Fixture Sprint end` - Zoom=`Quarter` - Markers=`Fixture Date` + - Truncate titles=on + - Show date fields=off - `Fixture Empty Sums` (Table) を作成 - group by Status - Field sum は空(`Count` を含めてすべて解除) +- `Fixture Roadmap Dates Hidden` (Roadmap) + - group by Status + - Dates=`Fixture Date` → `Fixture Sprint end` + - Truncate titles=on + - Show date fields=off Workflows: @@ -502,14 +492,18 @@ dotnet run --project src/Ghpmv.Cli -- export ` - `Fixture Board`: `fieldSum=["Fixture Number"]` - `Fixture Roadmap`: `fieldSum=["Fixture Number 2"]` - `Fixture Empty Sums`: `fieldSum=[]`(submenu を取得できた空選択。control/submenu を取得できない場合は View UI 未取得 warning) + - `Fixture Roadmap Dates Hidden`: `fieldSum=["Fixture Number 2"]`, `truncateTitles=true`, `showDateFields=false` 3 件以上の Field sum は GitHub UI で `1 more` と省略されますが、snapshot には実フィールド名が全件必要です。既存の snapshot 確認に次を追加し、別 export は実行しません。 ```powershell $snapshot = Get-Content "$env:GHPMV_SNAPSHOT_DIR/snapshot.json" -Raw | ConvertFrom-Json $snapshot.views | - Where-Object name -in @('View 1', 'Fixture Board', 'Fixture Roadmap', 'Fixture Empty Sums') | - Select-Object name, groupByFields, @{ Name = 'fieldSum'; Expression = { @($_.ui.fieldSum) -join ', ' } } + Where-Object name -in @('View 1', 'Fixture Board', 'Fixture Roadmap', 'Fixture Empty Sums', 'Fixture Roadmap Dates Hidden') | + Select-Object name, groupByFields, + @{ Name = 'fieldSum'; Expression = { @($_.ui.fieldSum) -join ', ' } }, + @{ Name = 'truncateTitles'; Expression = { $_.ui.roadmap.truncateTitles } }, + @{ Name = 'showDateFields'; Expression = { $_.ui.roadmap.showDateFields } } ``` ### 7.2 Mapping CSV を補完 @@ -627,7 +621,7 @@ human-readable category table と `verify-report.json` の両方に `StatusUpdat Field defaults と Field sum はこの既存 round trip の中で確認し、別の export/import シナリオは実行しません。 1. 初回 browser-assisted verify で `View: Match` を確認します。Group by、Field sum menu の完全な選択集合、空集合は Playwright capture と verifier が機械比較するため、同じ内容を目視しません。 -2. Issue #62 の派生描画 checkpoint は次の command で自動検証します。Playwright が target の `View 1` と `Fixture Roadmap` を reload し、visible group header、`Count` rendering、各 Number field の numeric aggregate label を DOM で確認します。ユーザーによる reload / 目視確認は不要です。 +2. Issue #62/#65 の派生描画 checkpoint は次の command で自動検証します。Playwright が target の `View 1`、`Fixture Roadmap`、`Fixture Roadmap Dates Hidden` を reload し、visible group header、aggregate label、長いtitleのtruncation、date fieldの表示/非表示をDOMで確認します。ユーザーによる reload / 目視確認は不要です。 ```powershell dotnet run --project src/Ghpmv.Cli -- setup ` @@ -637,14 +631,18 @@ dotnet run --project src/Ghpmv.Cli -- setup ` --browser-profile target ``` -`Rendered Field sums verified` が両 View に出力され、最後に `Fixture field-sum rendering verified: project=#<target-project-number> views=2` と exit code 0 になることを確認します。 +`Rendered Field sums verified` が3 Viewに出力され、最後に `Fixture field-sum rendering verified: project=#<target-project-number> views=3` と exit code 0 になることを確認します。 3. `ghpmv setup --fixture-field-default-check --fixture-org <target-org> --fixture-project <target-project-number> --browser-profile target` を実行し、Projects UIから作成されたdisposable draftにText / negative Number / zero / Single-select defaultsが自動入力されることを確認します。出力されたdraft item ID / titleをresource inventoryに追加し、cleanup同意前には削除しません。 -4. `ghpmv setup --fixture-field-default-drift --fixture-org <target-org> --fixture-project <target-project-number> --browser-profile target` と既存の `--fixture-field-sum-drift` を同じ target に実行します。前者は Text / zero Number / Single-select を変更し、negative Number default を clear します。 -5. browser-assisted verify を `--categories Field,View` で再実行し、4 件の `default value mismatch` と `view 'View 1': field sum mismatch` を確認します。 -6. 7.3 の再 import を `--project-number <target-project-number>` で一度だけ実行し、Status Updates の idempotence、field defaults、Field sum の復元を同時に確認します。 -7. browser-assisted verify を `--categories Field,Item,View` で実行します。`Field: Match` / `View: Match`に加え、`Item`差分がcleanup同意待ちのinventory済みcheck draft 1件だけで、source由来itemの値差分がないことを確認します。その後`--fixture-field-default-check`を再実行して修復後の新規draftにdefaultsが適用されることを機械確認し、このdraftもinventoryへ追加します。 +4. `ghpmv setup --fixture-roadmap-display-drift --fixture-org <target-org> --fixture-project <target-project-number> --browser-profile target` を実行し、baseline `(true,false)` から titleだけを `(false,false)` へ変更します。 +5. browser-assisted verify を `--categories View` で実行し、2 Roadmapの `truncate titles mismatch` だけを確認します。続けて `--fixture-roadmap-title-display-render-check` を実行し、full title / hidden datesをDOMで確認します。 +6. repairを挟まず `ghpmv setup --fixture-roadmap-date-display-drift ...` を実行します。この`(true,true)` transitionはtitleだけをbaselineへ戻し、dateだけをdriftします。 +7. browser-assisted verify を `--categories View` で実行し、2 Roadmapの `show date fields mismatch` だけを確認します。続けて `--fixture-roadmap-date-display-render-check` を実行し、truncated title / visible datesをDOMで確認します。 +8. `ghpmv setup --fixture-field-default-drift ...` と `--fixture-field-sum-drift ...` を同じtargetに実行します。 +9. browser-assisted verify `--categories Field,View` で4件のdefault mismatch、View 1 field-sum mismatch、2件のdate mismatchだけを確認します。 +10. 7.3 の再 import を同じtargetへ一度だけ実行し、Status Updates、field defaults、Field sum、Roadmap stateを同時に復元します。 +11. browser-assisted verifyを`--categories Field,Item,View`で実行し、`Field: Match` / `View: Match`とinventory済みdraft以外のItem差分がないことを確認します。最後に`--fixture-field-default-check`を再実行して修復後defaultsを確認します。 -この統合で追加実行するのは、初回functional check、field-default drift、field-sum drift、drift verify、修復用re-import、repair verify、修復後functional checkの7 commandです。同じsnapshot / target / mappingsを再利用し、証跡は11、draftとProjectの削除は10の既存cleanup同意へまとめます。 +同じsnapshot / target / mappingsを再利用し、Roadmapの各controlはexactly-one-property driftと直後のDOM observationで独立に検証します。draftとProjectの削除は既存cleanup同意へまとめます。 warning / error が出た場合は、次の観点で切り分けます。 @@ -698,9 +696,12 @@ warning / error が出た場合は、次の観点で切り分けます。 - [ ] Table view の filter / visible fields / sort / group by / field sum は browser-assisted `verify` で `View: Match`。 - [ ] Board view の Column by / Swimlanes / Slice by / field sum は browser-assisted `verify` で `View: Match`。 -- [ ] Roadmap view の group by / field sum / date fields / zoom / markers は browser-assisted `verify` で `View: Match`。 +- [ ] Roadmap view の group by / field sum / date fields / zoom / markers / Truncate titles / Show date fields は browser-assisted `verify` で `View: Match`。 +- [ ] Roadmap の長い title と date-field 表示が source/target で一致し、reload 後も維持される。 +- [ ] `setup --fixture-roadmap-display-drift` がtitleだけを `(false,false)` へ変更し、title mismatchとfull-title/hidden-date DOMを確認後、再 importで復元される。 +- [ ] `setup --fixture-roadmap-date-display-drift` がdateだけを `(true,true)` へ変更し、date mismatchとtruncated-title/visible-date DOMを確認後、再 importで復元される。 - [ ] View 名が一致。 -- [ ] View tab order が `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` で一致。 +- [ ] View tab order が `Fixture Roadmap` → `View 1` → `Fixture Board` → `Fixture Empty Sums` → `Fixture Roadmap Dates Hidden` で一致。 - [ ] 通常幅とタブが画面幅を超える狭い幅の両方で source/target 順が一致。 - [ ] import を再実行しても既に正しい tab order は変化しない。 @@ -737,7 +738,6 @@ warning / error が出た場合は、次の観点で切り分けます。 - [ ] source と target の `project.template` が一致し、Organization の Templates UI と Create project ダイアログに表示される。 - [ ] Status Updates を含む import では、target が一時的に通常 Project となり、全 writer 完了後に template へ戻る。 - [ ] snapshot の `project.template` を `false` にして既存 template target へ再 import すると、最終段で通常 Projectへ戻る。 -- [ ] `project.template` 行を削除した schema v1 snapshot では、既存 target の template 状態が変わらない。 --- diff --git a/docs/MIGRATION_SCOPE.md b/docs/MIGRATION_SCOPE.md index 23b2a9eb..a92040bd 100644 --- a/docs/MIGRATION_SCOPE.md +++ b/docs/MIGRATION_SCOPE.md @@ -19,7 +19,7 @@ Issue and pull request content and metadata, including labels, milestones, assig | Project collaborators | ✅ with browser automation / API import-only | GitHub exposes a write API but no read API for project collaborators. With `--enable-browser-automation`, `ghpmv` exports explicitly listed project collaborators from Settings → Manage access and imports them through the API. Inherited/base-role access is outside `ghpmv`'s scope and is expected to come from GEI, organization/team/repository settings, or enterprise policy. | | Project status updates | ✅ | Body, status, start/target dates, and history order are migrated. Because GitHub cannot preserve the original author or creation time, `ghpmv` prepends them to the recreated body. | | Project-to-Team links | ✅ for organization-owned Projects | Exported through `ProjectV2.teams`, stored as `organization/slug`, resolved before any write, and recreated with `linkProjectV2ToTeam`. Explicit Team collaborators remain separate. Target-only links are reported but not removed. User-owned Projects use empty/no-op/not-applicable behavior. | -| Project templates | ✅ for organization-owned Projects | `ProjectV2.template` is exported and applied only after Items, Status Updates, Views, Workflows, and other writers finish. Existing template targets are temporarily unmarked when a blocked write is required. A legacy snapshot without `project.template` preserves the target state; user-owned Projects reject `template: true` before any write. | +| Project templates | ✅ for organization-owned Projects | `ProjectV2.template` is exported and applied only after Items, Status Updates, Views, Workflows, and other writers finish. Existing template targets are temporarily unmarked when a blocked write is required; user-owned Projects reject `template: true` before any write. | ## Fields and field values @@ -63,9 +63,9 @@ View names, layouts, filters, and ordered visible fields are imported through th |---|---:|---| | Table views | ✅ / best effort | Name, layout, filter, and visible-field order use GraphQL. Group by, the first sort key, Slice by, and grouped-view Field sum use browser enrichment. Field sums preserve Count, multiple Number fields, and an empty selection. Additional sort keys are exported but only the first is applied. | | Board views | ✅ | Column by, Swimlanes and Field sum are tested. | -| Roadmap views | ✅ | Date fields, zoom level, markers, and grouped-view Field sum are tested. Field sums preserve Count, multiple Number fields, and an empty selection. | +| Roadmap views | ✅ | Date fields, zoom level, markers, grouped-view Field sum, and the project-shared title truncation/date-field display state are tested. | | View API settings | ✅ | Name, layout, filter, and ordered visible fields are migrated without browser automation. | -| View UI-only settings | ✅ | Grouping, sorting, slicing, field sums, and Roadmap settings are exported/imported by browser automation where the UI exposes them. | +| View UI-only settings | ✅ | Grouping, sorting, slicing, field sums, and Roadmap settings are exported/imported by browser automation where the UI exposes them. Roadmap checkbox failures identify the property and View. | | View tab order | ✅ with browser automation | The public GraphQL `POSITION` order can differ from the saved-tab order shown by GitHub. Browser-assisted export/verify read tab `href` values in DOM order, and browser-assisted import applies the minimum drag-and-drop moves after all View settings. API-only export leaves tab order uncaptured, API-only import warns when a snapshot contains it, and API-only verify marks it not verified. | | Insights charts | ❌ | Out of scope for v1. They require a separate UI automation design. | diff --git a/docs/ui-maps/insights-ui-discovery.md b/docs/ui-maps/insights-ui-discovery.md index 1dbba561..4a4f656c 100644 --- a/docs/ui-maps/insights-ui-discovery.md +++ b/docs/ui-maps/insights-ui-discovery.md @@ -460,8 +460,8 @@ test flow: Discovery blockers を解消後、次の順で分ける。 -1. **Contract / pure logic**: nullable custom chart collection (`CurrentSchemaVersion` は上げない)、 - normalization、field identity、comparison、backward compatibility。historical points は model に入れない。 +1. **Contract / pure logic**: custom chart collection、normalization、field identity、comparison。 + historical points は model に入れない。 2. **Selectors + read-only exporter**: `Sel.cs`、custom list/order、filter/config reader、default exclusion。 3. **Importer lifecycle**: mandatory host/login preflight → pending create + baseline persist → create/reconcile → target chart ID persist → rename → configure → filter → read-back。 diff --git a/docs/ui-maps/projects-ui-discovery.md b/docs/ui-maps/projects-ui-discovery.md index cda4ffb2..66f7988c 100644 --- a/docs/ui-maps/projects-ui-discovery.md +++ b/docs/ui-maps/projects-ui-discovery.md @@ -38,6 +38,19 @@ GitHub.com の一時 user-owned Project で Table / Board / Roadmap を作り、 5. Table / Roadmap とも変更後は `button "Save view"` が表示され、`alertdialog "Save display options for <view>?"` の `button "Save"` で確定する。既存の 2 段階保存フローと同じ 6. existing Project の再 import では GraphQL の View update が grouping / UI-only state を一旦 clear する。save 後の reload は未保存でも dirty 表示を消すため、`Save view` が消えたことだけでは永続化を証明できない。grouping、Slice by、Field sum を reload 後に意味的に再読し、不一致なら bounded retry する 7. grouped Table / Roadmap の visible header content は `[class*='group-header-module__groupHeaderContent']`、Number sum label は `[class*='aggregate-labels-module__Label']`。標準 fixture の Table では `Todo 2 (2) Fixture Number: 3.14 Fixture Number 2: 0` のように描画される。`setup --fixture-field-sum-render-check` は reload 後にこの DOM を読み、Count の `N (N)` と各 `Field: numeric-value` を機械検証する +8. `Truncate titles` / `Show date fields` は親 View menu の direct `menuitemcheckbox` で、状態は `aria-checked` に保持される。2026-08-25 の Project #74 live診断ではclickでmenuが即閉じ、`Unsaved changes`/`Save view`は表示されず、menu再openとreload後にも値がpersistした。さらに片方のRoadmapで変更すると未操作のRoadmapにも同じ値が反映され、2 controlはProject内の全Roadmapで共有されることを確認した。menu textにはcurrent valueを表示しないため、ghpmvは各Roadmapから`aria-checked`を読み、全Roadmapへ同じshared stateを適用する + +## Roadmap title/date display discovery (2026-08-25) + +GitHub.com の一時 organization-owned Project #74 で `Truncate titles` / `Show date fields` を診断した。 + +1. 両 control は親 View menu の direct `menuitemcheckbox` で、state は `aria-checked`、disabled=false。menu text に current value は表示されない +2. click 直後に menu は閉じ、`Unsaved changes` / `Save view` は表示されない。menu 再openと同じ BrowserSession 内の reload では変更値を読める +3. 片方の Roadmap で変更すると未操作の Roadmap にも同じ値が反映され、View単位のstateではない +4. 値はProject APIではなくBrowserContextのbrowser storageへ保存される。contextだけを変更してprofileのstorage-state fileへflushしない場合、fresh BrowserSessionでは両値が`false`へ戻る +5. `BrowserSession.SaveStateAsync`でprofileへflush後、別process/fresh BrowserSessionがProject #74のshared state `(false,true)` を取得できた。さらにfresh contextから`(true,false)`へ修復・flushし、別のfresh BrowserSessionで同値を確認した +6. このため両controlはbrowser profile間で移行可能だが、write後のprofile flushとfresh-session read-backをdurability gateとして必須にする +7. 2026-08-26 の target Project #4746 では同じitem titleが固定左列とRoadmap pillの両方に描画された。truncation renderingの判定は`[class*='roadmap-pill-module__SanitizedHtml']`へ限定し、固定左列の常設ellipsisをRoadmap stateとして誤認しない ## Field default UI contract (2026-08-25 live discovery) @@ -60,11 +73,12 @@ GitHub Docs と public schema introspection で Text / Number / Single-select de ## フィクスチャー最終状態(gpm-source/projects/3) - Views: - - tab order=Fixture Roadmap → View 1 → Fixture Board → Fixture Empty Sums + - tab order=Fixture Roadmap → View 1 → Fixture Board → Fixture Empty Sums → Fixture Roadmap Dates Hidden - 1=View 1 (TABLE): filter=`status:Todo`, Group by=Status, Sort by=Fixture Number (asc), Slice by=Fixture Select, Field sum=[Count, Fixture Number, Fixture Number 2], visibleFields=既定 5 + Fixture Text + Fixture Date(Fixture Number はソート由来の仮想列のため visibleFields に入らない — 下記 E2E 知見 8) - 2=Fixture Board (BOARD): Column by=Fixture Select, Swimlanes=Status(GraphQL groupByFields に反映), Field sum=`Fixture Number` (Count は uncheck 済み) - 3=Fixture Roadmap (ROADMAP): Group by=Status, Field sum=Fixture Number 2, Dates=Fixture Date → Fixture Sprint end, Zoom=Quarter, Markers=[Fixture Date] - 4=Fixture Empty Sums (TABLE): Group by=Status, Field sum=[] + - 5=Fixture Roadmap Dates Hidden (ROADMAP): Group by=Status, Field sum=Fixture Number 2, Truncate titles=on, Show date fields=off - Workflows 9(GraphQL 可視分): 既定 6 enabled + Auto-add to project (#7: repo=fixture-repo, filter=`is:issue is:open`) + **Auto-add secondary**(repo=fixture-repo, filter=`is:issue label:bug`, enabled)+ **Code changes requested**(保存済み disabled, Set value=In Progress) - Field defaults: Fixture Text=`既定値 🌏`、Fixture Number=`-7`、Fixture Number 2=`0`、Fixture Select=`Beta` - fixture-repo: private, Issue #1/#2(gpm-target 側にも同名 repo あり — workflow E2E 用) diff --git a/src/Ghpmv.Cli/Program.cs b/src/Ghpmv.Cli/Program.cs index bfdb2dec..855229dc 100644 --- a/src/Ghpmv.Cli/Program.cs +++ b/src/Ghpmv.Cli/Program.cs @@ -444,6 +444,11 @@ async Task ValidateImportBeforeWriteAsync(CancellationToken ct) return; } + if (enableBrowserAutomation) + { + ViewUiImporter.ValidateSharedRoadmapDisplaySettings(snapshot.Views); + } + await ImportCapabilityPreflight.ValidateAsync( ownerType == ProjectOwnerType.Organization ? capabilityPlan @@ -733,12 +738,12 @@ await PersistUnresolvedWarningsAsync( { await templateWriteSession.CompleteAsync(snapshot.Project.Template, cancellationToken); } - else if (snapshot.Project.Template is { } desiredTemplate) + else { await ProjectTemplateWriteSession.SetFinalStateAsync( client, result.ProjectId, - desiredTemplate, + snapshot.Project.Template, Console.Error.WriteLine, cancellationToken); } @@ -1140,10 +1145,26 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( { Description = "Apply the standard View 1 field-sum drift to an existing fixture Project using browser automation.", }; +var fixtureRoadmapDisplayDriftOption = new Option<bool>("--fixture-roadmap-display-drift") +{ + Description = "Disable project-shared Roadmap title truncation while preserving hidden dates.", +}; +var fixtureRoadmapDateDisplayDriftOption = new Option<bool>("--fixture-roadmap-date-display-drift") +{ + Description = "Enable project-shared Roadmap date display while preserving title truncation.", +}; var fixtureFieldSumRenderCheckOption = new Option<bool>("--fixture-field-sum-render-check") { Description = "Verify visible grouped-header Field sum rendering on an existing standard fixture Project.", }; +var fixtureRoadmapDateDisplayRenderCheckOption = new Option<bool>("--fixture-roadmap-date-display-render-check") +{ + Description = "Verify the date-only Roadmap display drift renders truncated titles with visible dates.", +}; +var fixtureRoadmapTitleDisplayRenderCheckOption = new Option<bool>("--fixture-roadmap-title-display-render-check") +{ + Description = "Verify the title-only Roadmap display drift renders full titles with hidden dates.", +}; var fixtureFieldDefaultCheckOption = new Option<bool>("--fixture-field-default-check") { Description = "Create a disposable draft and verify that the standard fixture field defaults are applied.", @@ -1207,7 +1228,11 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( setupCommand.Options.Add(fixtureOption); setupCommand.Options.Add(fixtureUiOption); setupCommand.Options.Add(fixtureFieldSumDriftOption); +setupCommand.Options.Add(fixtureRoadmapDisplayDriftOption); +setupCommand.Options.Add(fixtureRoadmapDateDisplayDriftOption); setupCommand.Options.Add(fixtureFieldSumRenderCheckOption); +setupCommand.Options.Add(fixtureRoadmapDateDisplayRenderCheckOption); +setupCommand.Options.Add(fixtureRoadmapTitleDisplayRenderCheckOption); setupCommand.Options.Add(fixtureFieldDefaultCheckOption); setupCommand.Options.Add(fixtureFieldDefaultDriftOption); setupCommand.Options.Add(fixtureFieldDefaultCleanupItemOption); @@ -1220,7 +1245,6 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( setupCommand.Options.Add(fixtureAllowExistingEmptyRepoOption); setupCommand.Options.Add(fixtureTeamOption); setupCommand.Options.Add(setupBrowserProfileOption); -setupCommand.Options.Add(baseUrlOption); setupCommand.Options.Add(browserBaseUrlOption); setupCommand.Options.Add(tokenOption); setupCommand.Options.Add(setupApiBaseUrlOption); @@ -1244,7 +1268,11 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( if (!result.GetValue(fixtureUiOption) && !result.GetValue(fixtureFieldSumDriftOption) + && !result.GetValue(fixtureRoadmapDisplayDriftOption) + && !result.GetValue(fixtureRoadmapDateDisplayDriftOption) && !result.GetValue(fixtureFieldSumRenderCheckOption) + && !result.GetValue(fixtureRoadmapDateDisplayRenderCheckOption) + && !result.GetValue(fixtureRoadmapTitleDisplayRenderCheckOption) && !result.GetValue(fixtureFieldDefaultCheckOption) && !result.GetValue(fixtureFieldDefaultDriftOption) && result.GetValue(fixtureFieldDefaultCleanupItemOption) is null @@ -1254,7 +1282,11 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( } if ((result.GetValue(fixtureFieldSumDriftOption) + || result.GetValue(fixtureRoadmapDisplayDriftOption) + || result.GetValue(fixtureRoadmapDateDisplayDriftOption) || result.GetValue(fixtureFieldSumRenderCheckOption) + || result.GetValue(fixtureRoadmapDateDisplayRenderCheckOption) + || result.GetValue(fixtureRoadmapTitleDisplayRenderCheckOption) || result.GetValue(fixtureFieldDefaultCheckOption) || result.GetValue(fixtureFieldDefaultDriftOption) || result.GetValue(fixtureFieldDefaultCleanupItemOption) is not null) @@ -1266,7 +1298,11 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( var fixtureBrowserOperationCount = new[] { result.GetValue(fixtureFieldSumDriftOption), + result.GetValue(fixtureRoadmapDisplayDriftOption), + result.GetValue(fixtureRoadmapDateDisplayDriftOption), result.GetValue(fixtureFieldSumRenderCheckOption), + result.GetValue(fixtureRoadmapDateDisplayRenderCheckOption), + result.GetValue(fixtureRoadmapTitleDisplayRenderCheckOption), result.GetValue(fixtureFieldDefaultCheckOption), result.GetValue(fixtureFieldDefaultDriftOption), result.GetValue(fixtureFieldDefaultCleanupItemOption) is not null, @@ -1282,12 +1318,6 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( result.AddError("--fixture-field-default-cleanup-item and --fixture-field-default-cleanup-title must be provided together."); } - if (result.GetResult(baseUrlOption) is { Implicit: false } - && result.GetResult(browserBaseUrlOption) is { Implicit: false }) - { - result.AddError("Browser fixture operations accept either --browser-base-url or the legacy --base-url, not both."); - } - if (string.IsNullOrWhiteSpace(result.GetValue(fixtureOrgOption))) { result.AddError("Browser fixture operations require --fixture-org."); @@ -1305,7 +1335,11 @@ await ProjectTemplateWriteSession.SetFinalStateAsync( && !parseResult.GetValue(fixtureOption) && !parseResult.GetValue(fixtureUiOption) && !parseResult.GetValue(fixtureFieldSumDriftOption) + && !parseResult.GetValue(fixtureRoadmapDisplayDriftOption) + && !parseResult.GetValue(fixtureRoadmapDateDisplayDriftOption) && !parseResult.GetValue(fixtureFieldSumRenderCheckOption) + && !parseResult.GetValue(fixtureRoadmapDateDisplayRenderCheckOption) + && !parseResult.GetValue(fixtureRoadmapTitleDisplayRenderCheckOption) && !parseResult.GetValue(fixtureFieldDefaultCheckOption) && !parseResult.GetValue(fixtureFieldDefaultDriftOption) && parseResult.GetValue(fixtureFieldDefaultCleanupItemOption) is null @@ -1369,7 +1403,9 @@ await observer.DeleteDraftAsync( } } - if (parseResult.GetValue(fixtureFieldSumRenderCheckOption)) + if (parseResult.GetValue(fixtureFieldSumRenderCheckOption) + || parseResult.GetValue(fixtureRoadmapDateDisplayRenderCheckOption) + || parseResult.GetValue(fixtureRoadmapTitleDisplayRenderCheckOption)) { try { @@ -1387,14 +1423,11 @@ await observer.DeleteDraftAsync( var apiBaseUrl = parseResult.GetValue(setupApiBaseUrlOption); var graphQlBaseUri = apiBaseUrl is null ? null : GitHubGraphQLClient.NormalizeBaseUrl(apiBaseUrl); - var legacyBrowserBaseUrl = parseResult.GetResult(baseUrlOption) is { Implicit: false } - ? parseResult.GetValue(baseUrlOption) - : null; await using var browserSession = new BrowserSession(new BrowserSessionOptions { BaseUrl = BrowserBaseUrl.Resolve( graphQlBaseUri, - parseResult.GetValue(browserBaseUrlOption) ?? legacyBrowserBaseUrl), + parseResult.GetValue(browserBaseUrlOption)), Profile = parseResult.GetValue(setupBrowserProfileOption), }); using var client = new GitHubGraphQLClient(token, graphQlBaseUri); @@ -1414,7 +1447,9 @@ await observer.DeleteDraftAsync( """, new { login = org, number = projectNumber }, cancellationToken); - var expectedNames = new HashSet<string>(["View 1", "Fixture Roadmap"], StringComparer.Ordinal); + var expectedNames = new HashSet<string>( + ["View 1", "Fixture Roadmap", "Fixture Roadmap Dates Hidden"], + StringComparer.Ordinal); var viewNumbers = projectData .GetProperty("organization") .GetProperty("projectV2") @@ -1447,7 +1482,13 @@ await observer.DeleteDraftAsync( { OnProgress = Console.Error.WriteLine, }; - await observer.ValidateStandardFixtureAsync( + var expected = parseResult.GetValue(fixtureRoadmapDateDisplayRenderCheckOption) + ? FixtureUiSnapshotFactory.CreateRoadmapDateDisplayDrift() + : parseResult.GetValue(fixtureRoadmapTitleDisplayRenderCheckOption) + ? FixtureUiSnapshotFactory.CreateRoadmapDisplayDrift() + : FixtureUiSnapshotFactory.Create(); + await observer.ValidateFixtureAsync( + expected, org, ProjectOwnerType.Organization, projectNumber, @@ -1482,14 +1523,11 @@ await observer.ValidateStandardFixtureAsync( var apiBaseUrl = parseResult.GetValue(setupApiBaseUrlOption); var graphQlBaseUri = apiBaseUrl is null ? null : GitHubGraphQLClient.NormalizeBaseUrl(apiBaseUrl); - var legacyBrowserBaseUrl = parseResult.GetResult(baseUrlOption) is { Implicit: false } - ? parseResult.GetValue(baseUrlOption) - : null; await using var browserSession = new BrowserSession(new BrowserSessionOptions { BaseUrl = BrowserBaseUrl.Resolve( graphQlBaseUri, - parseResult.GetValue(browserBaseUrlOption) ?? legacyBrowserBaseUrl), + parseResult.GetValue(browserBaseUrlOption)), Profile = parseResult.GetValue(setupBrowserProfileOption), }); using var client = new GitHubGraphQLClient(token, graphQlBaseUri); @@ -1534,14 +1572,11 @@ await observer.ValidateStandardFixtureAsync( var apiBaseUrl = parseResult.GetValue(setupApiBaseUrlOption); var graphQlBaseUri = apiBaseUrl is null ? null : GitHubGraphQLClient.NormalizeBaseUrl(apiBaseUrl); - var legacyBrowserBaseUrl = parseResult.GetResult(baseUrlOption) is { Implicit: false } - ? parseResult.GetValue(baseUrlOption) - : null; await using var browserSession = new BrowserSession(new BrowserSessionOptions { BaseUrl = BrowserBaseUrl.Resolve( graphQlBaseUri, - parseResult.GetValue(browserBaseUrlOption) ?? legacyBrowserBaseUrl), + parseResult.GetValue(browserBaseUrlOption)), Profile = parseResult.GetValue(setupBrowserProfileOption), }); using var client = new GitHubGraphQLClient(token, graphQlBaseUri); @@ -1595,14 +1630,11 @@ await importer.ImportAsync( var apiBaseUrl = parseResult.GetValue(setupApiBaseUrlOption); var graphQlBaseUri = apiBaseUrl is null ? null : GitHubGraphQLClient.NormalizeBaseUrl(apiBaseUrl); - var legacyBrowserBaseUrl = parseResult.GetResult(baseUrlOption) is { Implicit: false } - ? parseResult.GetValue(baseUrlOption) - : null; await using var browserSession = new BrowserSession(new BrowserSessionOptions { BaseUrl = BrowserBaseUrl.Resolve( graphQlBaseUri, - parseResult.GetValue(browserBaseUrlOption) ?? legacyBrowserBaseUrl), + parseResult.GetValue(browserBaseUrlOption)), Profile = parseResult.GetValue(setupBrowserProfileOption), }); using var client = new GitHubGraphQLClient(token, graphQlBaseUri); @@ -1671,6 +1703,102 @@ await viewImporter.ApplyFieldSumAsync( } } + if (parseResult.GetValue(fixtureRoadmapDisplayDriftOption) + || parseResult.GetValue(fixtureRoadmapDateDisplayDriftOption)) + { + try + { + var org = parseResult.GetValue(fixtureOrgOption)!; + var projectNumber = parseResult.GetValue(fixtureProjectOption)!.Value; + var token = parseResult.GetValue(tokenOption) + ?? Environment.GetEnvironmentVariable("GITHUB_TOKEN") + ?? Environment.GetEnvironmentVariable("GHPMV_TOKEN") + ?? Environment.GetEnvironmentVariable("GHPMV_TEST_TOKEN"); + if (string.IsNullOrWhiteSpace(token)) + { + Console.Error.WriteLine("error: no token provided. Use --token or set GITHUB_TOKEN / GHPMV_TOKEN / GHPMV_TEST_TOKEN."); + return 1; + } + + var apiBaseUrl = parseResult.GetValue(setupApiBaseUrlOption); + var graphQlBaseUri = apiBaseUrl is null ? null : GitHubGraphQLClient.NormalizeBaseUrl(apiBaseUrl); + await using var browserSession = new BrowserSession(new BrowserSessionOptions + { + BaseUrl = BrowserBaseUrl.Resolve( + graphQlBaseUri, + parseResult.GetValue(browserBaseUrlOption)), + Profile = parseResult.GetValue(setupBrowserProfileOption), + }); + using var client = new GitHubGraphQLClient(token, graphQlBaseUri); + client.OnRetry = Console.Error.WriteLine; + var apiLogin = await client.GetViewerLoginAsync(cancellationToken); + await browserSession.ValidateAuthenticationAsync(apiLogin, cancellationToken); + + var snapshot = parseResult.GetValue(fixtureRoadmapDateDisplayDriftOption) + ? FixtureUiSnapshotFactory.CreateRoadmapDateDisplayDrift( + parseResult.GetValue(fixtureRepoOption) ?? "fixture-repo") + : FixtureUiSnapshotFactory.CreateRoadmapDisplayDrift( + parseResult.GetValue(fixtureRepoOption) ?? "fixture-repo"); + var view = snapshot.Views.Single(candidate => + string.Equals(candidate.Name, "Fixture Roadmap", StringComparison.Ordinal)); + var projectData = await client.QueryAsync( + """ + query($login: String!, $number: Int!) { + organization(login: $login) { + projectV2(number: $number) { + views(first: 100) { nodes { number name } } + } + } + } + """, + new { login = org, number = projectNumber }, + cancellationToken); + var targetViews = projectData + .GetProperty("organization") + .GetProperty("projectV2") + .GetProperty("views") + .GetProperty("nodes") + .EnumerateArray() + .Where(node => string.Equals( + node.GetProperty("name").GetString(), + view.Name, + StringComparison.Ordinal)) + .ToArray(); + if (targetViews.Length != 1) + { + Console.Error.WriteLine( + $"error: expected exactly one target View named '{view.Name}', found {targetViews.Length}."); + return 1; + } + + var roadmap = view.Ui!.Roadmap!; + var viewImporter = new ViewUiImporter(browserSession) { OnProgress = Console.Error.WriteLine }; + await viewImporter.ApplyRoadmapDisplayOptionsAsync( + org, + ProjectOwnerType.Organization, + projectNumber, + targetViews[0].GetProperty("number").GetInt32(), + view.Name, + roadmap.TruncateTitles!.Value, + roadmap.ShowDateFields!.Value, + cancellationToken); + foreach (var warning in viewImporter.Warnings) + { + Console.Error.WriteLine($"warning: {warning}"); + } + + Console.Error.WriteLine(string.Create( + CultureInfo.InvariantCulture, + $"Fixture Roadmap {(parseResult.GetValue(fixtureRoadmapDateDisplayDriftOption) ? "date-display" : "title-truncation")} drift applied: project=#{projectNumber} viewWarnings={viewImporter.Warnings.Count}")); + return viewImporter.Warnings.Count == 0 ? 0 : 1; + } + catch (Exception exception) when (exception is PlaywrightException or InvalidOperationException or IOException or TimeoutException or GitHubGraphQLException or ArgumentException or FormatException) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; + } + } + BrowserSession? authenticatedFixtureUiSession = null; int? createdFixtureProjectNumber = null; FixtureProjectSetupResult? fixtureResult = null; @@ -1712,14 +1840,11 @@ await ImportCapabilityPreflight.ValidateAsync( ct); if (parseResult.GetValue(fixtureUiOption) && authenticatedFixtureUiSession is null) { - var legacyBrowserBaseUrl = parseResult.GetResult(baseUrlOption) is { Implicit: false } - ? parseResult.GetValue(baseUrlOption) - : null; var browserSession = new BrowserSession(new BrowserSessionOptions { BaseUrl = BrowserBaseUrl.Resolve( graphQlBaseUri, - parseResult.GetValue(browserBaseUrlOption) ?? legacyBrowserBaseUrl), + parseResult.GetValue(browserBaseUrlOption)), Profile = parseResult.GetValue(setupBrowserProfileOption), }); try @@ -1748,6 +1873,7 @@ await ImportCapabilityPreflight.ValidateAsync( OperationLogDirectory = fixtureOperationDirectory, RequireNewResources = parseResult.GetValue(fixtureRequireNewOption), AllowExistingEmptyRepository = parseResult.GetValue(fixtureAllowExistingEmptyRepoOption), + IncludeRoadmapRenderingItem = parseResult.GetValue(fixtureUiOption), BeforeWriteAsync = ValidateFixtureBeforeWriteAsync, }; try @@ -1841,16 +1967,13 @@ await ImportCapabilityPreflight.ValidateAsync( return 1; } - var legacyBrowserBaseUrl = parseResult.GetResult(baseUrlOption) is { Implicit: false } - ? parseResult.GetValue(baseUrlOption) - : null; var graphQlBaseUri = apiBaseUrl is null ? null : GitHubGraphQLClient.NormalizeBaseUrl(apiBaseUrl); var fixtureUiSession = authenticatedFixtureUiSession ?? new BrowserSession(new BrowserSessionOptions { BaseUrl = BrowserBaseUrl.Resolve( graphQlBaseUri, - parseResult.GetValue(browserBaseUrlOption) ?? legacyBrowserBaseUrl), + parseResult.GetValue(browserBaseUrlOption)), Profile = parseResult.GetValue(setupBrowserProfileOption), }); await using var fixtureUiSessionScope = fixtureUiSession; @@ -1862,6 +1985,20 @@ await ImportCapabilityPreflight.ValidateAsync( await fixtureUiSession.ValidateAuthenticationAsync(apiLogin, cancellationToken); } + if (!parseResult.GetValue(fixtureOption)) + { + var existingFixture = await new ProjectExporter(fixtureUiClient) + { + Sections = ProjectExportSections.Items, + }.ExportAsync(org, projectNumber.Value, cancellationToken); + if (!existingFixture.Items.Any(FixtureProjectBuilder.IsRoadmapRenderingItem)) + { + throw new InvalidOperationException( + $"Project #{projectNumber.Value} does not contain the unarchived, dated Roadmap rendering item. " + + "Create the browser fixture with setup --fixture --fixture-ui."); + } + } + File.Delete(uiCompletionPath); var apiViewImporter = new ProjectImporter(fixtureUiClient) diff --git a/src/Ghpmv.Core/Browser/FieldDefaultUiImporter.cs b/src/Ghpmv.Core/Browser/FieldDefaultUiImporter.cs index 60856da7..51134668 100644 --- a/src/Ghpmv.Core/Browser/FieldDefaultUiImporter.cs +++ b/src/Ghpmv.Core/Browser/FieldDefaultUiImporter.cs @@ -8,6 +8,7 @@ namespace Ghpmv.Core.Browser; /// <summary>Applies browser-only defaults after Project fields, options, and existing items exist.</summary> public sealed class FieldDefaultUiImporter { + private const int PersistenceAttempts = 3; private readonly BrowserSession _session; private readonly List<string> _warnings = []; @@ -236,60 +237,72 @@ private async Task ApplyAsync( int projectNumber, CancellationToken cancellationToken) { - await FieldDefaultUiExporter.OpenFieldSettingsAsync( - page, - _session, - ownerLogin, - ownerType, - projectNumber, - field.Name, - cancellationToken).ConfigureAwait(false); - - var current = await FieldDefaultUiExporter.ReadDefaultValueAsync(page, field) - .ConfigureAwait(false); - if (ValuesEqual(field.DataType, field.DefaultValue!, current)) + FieldDefaultValueSnapshot? actual = null; + for (var attempt = 1; attempt <= PersistenceAttempts; attempt++) { - return; - } + await FieldDefaultUiExporter.OpenFieldSettingsAsync( + page, + _session, + ownerLogin, + ownerType, + projectNumber, + field.Name, + cancellationToken).ConfigureAwait(false); - var control = Sel.FieldDefaultControl(page); - switch (field.DataType) - { - case "TEXT": - await control.FillAsync(field.DefaultValue!.Text ?? string.Empty).ConfigureAwait(false); - break; - case "NUMBER": - await control.FillAsync(field.DefaultValue!.Number?.ToString("R", CultureInfo.InvariantCulture) ?? string.Empty) - .ConfigureAwait(false); - break; - case "SINGLE_SELECT": - await ApplySingleSelectAsync( - page, - current.SingleSelectOptionName, - field.DefaultValue!.SingleSelectOptionName) - .ConfigureAwait(false); - break; - } + var current = await FieldDefaultUiExporter.ReadDefaultValueAsync(page, field) + .ConfigureAwait(false); + if (ValuesEqual(field.DataType, field.DefaultValue!, current)) + { + return; + } - // Field settings auto-save. As with View persistence, leave enough time for the - // request to become durable before navigation can cancel it, then verify by reload. - await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); - - await FieldDefaultUiExporter.OpenFieldSettingsAsync( - page, - _session, - ownerLogin, - ownerType, - projectNumber, - field.Name, - cancellationToken).ConfigureAwait(false); - var actual = await FieldDefaultUiExporter.ReadDefaultValueAsync(page, field) - .ConfigureAwait(false); - if (!ValuesEqual(field.DataType, field.DefaultValue!, actual)) - { - throw new InvalidOperationException( - $"saved value did not persist (expected {Display(field)}, actual {Display(field with { DefaultValue = actual })})"); + var control = Sel.FieldDefaultControl(page); + switch (field.DataType) + { + case "TEXT": + await control.FillAsync(field.DefaultValue!.Text ?? string.Empty).ConfigureAwait(false); + break; + case "NUMBER": + await control.FillAsync(field.DefaultValue!.Number?.ToString("R", CultureInfo.InvariantCulture) ?? string.Empty) + .ConfigureAwait(false); + break; + case "SINGLE_SELECT": + await ApplySingleSelectAsync( + page, + current.SingleSelectOptionName, + field.DefaultValue!.SingleSelectOptionName) + .ConfigureAwait(false); + break; + } + + // Field settings auto-save. Leave enough time for the request to become durable + // before navigation can cancel it, then verify by reload. + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + await FieldDefaultUiExporter.OpenFieldSettingsAsync( + page, + _session, + ownerLogin, + ownerType, + projectNumber, + field.Name, + cancellationToken).ConfigureAwait(false); + actual = await FieldDefaultUiExporter.ReadDefaultValueAsync(page, field) + .ConfigureAwait(false); + if (ValuesEqual(field.DataType, field.DefaultValue!, actual)) + { + return; + } + + if (attempt < PersistenceAttempts) + { + OnProgress?.Invoke( + $"Field '{field.Name}' default did not persist; retrying ({attempt + 1}/{PersistenceAttempts})..."); + } } + + throw new InvalidOperationException( + $"saved value did not persist after {PersistenceAttempts} attempts " + + $"(expected {Display(field)}, actual {Display(field with { DefaultValue = actual })})"); } private static async Task ApplySingleSelectAsync( diff --git a/src/Ghpmv.Core/Browser/FieldSumRenderingObserver.cs b/src/Ghpmv.Core/Browser/FieldSumRenderingObserver.cs index 2a167abd..23f9b214 100644 --- a/src/Ghpmv.Core/Browser/FieldSumRenderingObserver.cs +++ b/src/Ghpmv.Core/Browser/FieldSumRenderingObserver.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text.RegularExpressions; using Ghpmv.Core.GitHub; +using Ghpmv.Core.Fixtures; using Ghpmv.Core.Snapshot; using Microsoft.Playwright; @@ -25,12 +26,28 @@ public async Task ValidateStandardFixtureAsync( int projectNumber, IReadOnlyDictionary<string, int> viewNumbers, CancellationToken cancellationToken = default) + => await ValidateFixtureAsync( + FixtureUiSnapshotFactory.Create(), + ownerLogin, + ownerType, + projectNumber, + viewNumbers, + cancellationToken).ConfigureAwait(false); + + public async Task ValidateFixtureAsync( + ProjectSnapshot expected, + string ownerLogin, + ProjectOwnerType ownerType, + int projectNumber, + IReadOnlyDictionary<string, int> viewNumbers, + CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(expected); ArgumentException.ThrowIfNullOrWhiteSpace(ownerLogin); ArgumentNullException.ThrowIfNull(viewNumbers); - var expectedViews = FixtureUiSnapshotFactory.Create().Views - .Where(view => view.Name is "View 1" or "Fixture Roadmap") + var expectedViews = expected.Views + .Where(view => view.Name is "View 1" or "Fixture Roadmap" or "Fixture Roadmap Dates Hidden") .ToArray(); var page = await _session.GetPageAsync(cancellationToken).ConfigureAwait(false); foreach (var view in expectedViews) @@ -58,6 +75,10 @@ await headers.First.WaitForAsync(new() var headerTexts = await ReadNormalizedTextsAsync(headers).ConfigureAwait(false); var labelTexts = await ReadNormalizedTextsAsync(Sel.GroupHeaderAggregateLabels(page)).ConfigureAwait(false); ValidateObservation(view, headerTexts, labelTexts); + if (string.Equals(view.Layout, "ROADMAP_LAYOUT", StringComparison.Ordinal)) + { + await ValidateRoadmapDisplayAsync(page, view).ConfigureAwait(false); + } OnProgress?.Invoke( $"Rendered Field sums verified for view '{view.Name}': headers=[{string.Join(" | ", headerTexts)}]"); } @@ -98,6 +119,78 @@ internal static void ValidateObservation( } } + internal static void ValidateRoadmapDisplayObservation( + ViewSnapshot view, + bool titleTruncated, + bool datesRendered) + { + var roadmap = view.Ui?.Roadmap + ?? throw new InvalidOperationException($"view '{view.Name}': expected Roadmap display state is unavailable"); + if (roadmap.TruncateTitles is true && !titleTruncated) + { + throw new InvalidOperationException($"view '{view.Name}': long item title was not visibly truncated"); + } + + if (roadmap.TruncateTitles is false && titleTruncated) + { + throw new InvalidOperationException($"view '{view.Name}': long item title was truncated despite being disabled"); + } + + if (roadmap.ShowDateFields is true && !datesRendered) + { + throw new InvalidOperationException($"view '{view.Name}': item date fields were not visibly rendered"); + } + + if (roadmap.ShowDateFields is false && datesRendered) + { + throw new InvalidOperationException($"view '{view.Name}': item date fields were rendered despite being disabled"); + } + } + + private static async Task ValidateRoadmapDisplayAsync(IPage page, ViewSnapshot view) + { + var title = Sel.RoadmapPillTitle(page, FixtureProjectBuilder.RoadmapLongTitle); + await title.WaitForAsync(new() + { + State = WaitForSelectorState.Visible, + Timeout = 15_000, + }).ConfigureAwait(false); + var item = Sel.RoadmapItem(title); + if (await item.CountAsync().ConfigureAwait(false) == 0) + { + throw new InvalidOperationException( + $"view '{view.Name}': containing Roadmap item for the long fixture title was not found"); + } + + var titleTruncated = await title.EvaluateAsync<bool>( + """ + (element, titleText) => { + for (let node = element; + node && node instanceof HTMLElement && + node.textContent?.trim() === titleText; + node = node.parentElement) { + const style = getComputedStyle(node); + const horizontallyEllipsized = + style.whiteSpace === 'nowrap' && + style.textOverflow === 'ellipsis' && + node.scrollWidth > node.clientWidth; + const lineClamp = Number.parseInt(style.webkitLineClamp, 10); + const verticallyClamped = + Number.isFinite(lineClamp) && lineClamp > 0 && node.scrollHeight > node.clientHeight; + if (horizontallyEllipsized || verticallyClamped) { + return true; + } + } + return false; + } + """, + FixtureProjectBuilder.RoadmapLongTitle).ConfigureAwait(false); + var itemText = await item.InnerTextAsync().ConfigureAwait(false); + var datesRendered = await Sel.RoadmapItemDateElements(item).CountAsync().ConfigureAwait(false) > 0 + || RenderedDate().IsMatch(itemText); + ValidateRoadmapDisplayObservation(view, titleTruncated, datesRendered); + } + private static async Task<IReadOnlyList<string>> ReadNormalizedTextsAsync(ILocator locator) { var result = new List<string>(); @@ -118,4 +211,7 @@ private static async Task<IReadOnlyList<string>> ReadNormalizedTextsAsync(ILocat [GeneratedRegex(@"^\s*[-+]?(?:\d+(?:[.,]\d+)?|[.,]\d+)\b", RegexOptions.CultureInvariant)] private static partial Regex NumericRendering(); + + [GeneratedRegex(@"\b(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\b|\d{1,4}[/-]\d{1,2}|\d{1,2}月", RegexOptions.CultureInvariant)] + private static partial Regex RenderedDate(); } diff --git a/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs b/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs index 1ec62bd7..a3ca9efc 100644 --- a/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs +++ b/src/Ghpmv.Core/Browser/FixtureUiSnapshotFactory.cs @@ -25,11 +25,15 @@ public static ProjectSnapshot Create(string repositoryName = "fixture-repo") Readme = null, Public = false, Closed = false, + Template = false, }, Fields = CreateFields(), Views = CreateViews(), Workflows = CreateWorkflows(repositoryName), Items = [], + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], }; } @@ -49,6 +53,39 @@ public static ProjectSnapshot CreateFieldSumDrift(string repositoryName = "fixtu }; } + /// <summary>Creates title-only Roadmap display drift for every Roadmap View.</summary> + public static ProjectSnapshot CreateRoadmapDisplayDrift(string repositoryName = "fixture-repo") + => CreateRoadmapDisplayDrift(repositoryName, truncateTitles: false, showDateFields: false); + + /// <summary>Creates date-only Roadmap display drift for every Roadmap View.</summary> + public static ProjectSnapshot CreateRoadmapDateDisplayDrift(string repositoryName = "fixture-repo") + => CreateRoadmapDisplayDrift(repositoryName, truncateTitles: true, showDateFields: true); + + private static ProjectSnapshot CreateRoadmapDisplayDrift( + string repositoryName, + bool truncateTitles, + bool showDateFields) + { + var snapshot = Create(repositoryName); + return snapshot with + { + Views = snapshot.Views.Select(view => + string.Equals(view.Layout, "ROADMAP_LAYOUT", StringComparison.Ordinal) + ? view with + { + Ui = view.Ui! with + { + Roadmap = view.Ui.Roadmap! with + { + TruncateTitles = truncateTitles, + ShowDateFields = showDateFields, + }, + }, + } + : view).ToList(), + }; + } + /// <summary> /// Creates deliberate target drift for every supported field-default type. The negative /// Number default is cleared so the same operation also exercises explicit removal. @@ -185,6 +222,8 @@ private static IReadOnlyList<ViewSnapshot> CreateViews() => TargetField = "Fixture Sprint end", Zoom = "Quarter", Markers = ["Fixture Date"], + TruncateTitles = true, + ShowDateFields = false, }, }, }, @@ -204,6 +243,31 @@ private static IReadOnlyList<ViewSnapshot> CreateViews() => FieldSum = [], }, }, + new ViewSnapshot + { + Number = 5, + TabPosition = 4, + Name = "Fixture Roadmap Dates Hidden", + Layout = "ROADMAP_LAYOUT", + Filter = null, + GroupByFields = ["Status"], + SortByFields = [], + VerticalGroupByFields = [], + VisibleFields = [], + Ui = new ViewUiSnapshot + { + FieldSum = ["Fixture Number 2"], + Roadmap = new RoadmapSettingsSnapshot + { + StartField = "Fixture Date", + TargetField = "Fixture Sprint end", + Zoom = "Quarter", + Markers = ["Fixture Date"], + TruncateTitles = true, + ShowDateFields = false, + }, + }, + }, ]; private static IReadOnlyList<WorkflowSnapshot> CreateWorkflows(string repositoryName) => diff --git a/src/Ghpmv.Core/Browser/Sel.cs b/src/Ghpmv.Core/Browser/Sel.cs index 4efc85bd..8098377c 100644 --- a/src/Ghpmv.Core/Browser/Sel.cs +++ b/src/Ghpmv.Core/Browser/Sel.cs @@ -39,6 +39,10 @@ public static ILocator ConfigurationMenuItem(ILocator menu, string label) public static ILocator CheckboxOptions(ILocator menu) => menu.GetByRole(AriaRole.Menuitemcheckbox).Or(menu.GetByRole(AriaRole.Option)); + /// <summary>A direct checkbox in the parent View configuration menu.</summary> + public static ILocator ViewOptionCheckbox(ILocator menu, string name) + => menu.GetByRole(AriaRole.Menuitemcheckbox, new() { Name = name, Exact = true }); + /// <summary>View tab by name (prefix match — an unsaved-changes dot can alter the suffix).</summary> public static ILocator ViewTab(IPage page, string name) => page.GetByRole(AriaRole.Tab, new() { NameRegex = new Regex($"^{Regex.Escape(name)}") }); @@ -75,6 +79,21 @@ public static ILocator GroupHeaderContents(IPage page) public static ILocator GroupHeaderAggregateLabels(IPage page) => page.Locator("[class*='aggregate-labels-module__Label']:visible"); + /// <summary>The title rendered inside a Roadmap pill rather than the fixed left-hand table.</summary> + public static ILocator RoadmapPillTitle(IPage page, string title) + => page.Locator("[class*='roadmap-pill-module__SanitizedHtml']") + .Filter(new() { HasText = title }) + .First; + + /// <summary>The Roadmap item/card containing an item-title locator.</summary> + public static ILocator RoadmapItem(ILocator title) + => title.Locator( + "xpath=ancestor::*[@role='row' or @role='listitem' or contains(@data-testid,'roadmap-item') or contains(@class,'roadmap-item') or contains(@class,'RoadmapItem')][1]"); + + /// <summary>Semantic date/time elements rendered within one Roadmap item.</summary> + public static ILocator RoadmapItemDateElements(ILocator item) + => item.Locator("time:visible, relative-time:visible"); + /// <summary>"Select date fields" dialog opened from the "Dates" configuration item (Roadmap).</summary> public static ILocator DateFieldsDialog(IPage page) => page.GetByRole(AriaRole.Dialog, new() { Name = "Select date fields" }); diff --git a/src/Ghpmv.Core/Browser/ViewUiExporter.cs b/src/Ghpmv.Core/Browser/ViewUiExporter.cs index f22696af..ea6434e8 100644 --- a/src/Ghpmv.Core/Browser/ViewUiExporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiExporter.cs @@ -8,7 +8,7 @@ namespace Ghpmv.Core.Browser; /// <summary> /// UI export of view settings that GraphQL does not expose (B2). For each view the /// "View" configuration menu is opened and the current values of Markers / Dates / -/// Zoom level / Slice by / Field sum are read. Results are stored in <see cref="ViewSnapshot.Ui"/>; +/// Zoom level / Slice by / Field sum / Roadmap display options are read. Results are stored in <see cref="ViewSnapshot.Ui"/>; /// views whose UI settings cannot be read keep <c>Ui = null</c> and add a warning. /// </summary> public sealed class ViewUiExporter @@ -121,6 +121,14 @@ private async Task<ViewUiSnapshot> ReadViewUiAsync( await menu.WaitForAsync().ConfigureAwait(false); await Task.Delay(300, cancellationToken).ConfigureAwait(false); + bool? truncateTitles = null; + bool? showDateFields = null; + if (string.Equals(view.Layout, "ROADMAP_LAYOUT", StringComparison.Ordinal)) + { + truncateTitles = await ReadRoadmapDisplayOptionAsync(menu, view.Name, "Truncate titles").ConfigureAwait(false); + showDateFields = await ReadRoadmapDisplayOptionAsync(menu, view.Name, "Show date fields").ConfigureAwait(false); + } + var sliceBy = ParseMenuValue(await ReadMenuItemTextAsync(menu, "Slice by").ConfigureAwait(false)); var fieldSum = await ReadCheckedMenuValuesAsync( page, @@ -141,6 +149,8 @@ private async Task<ViewUiSnapshot> ReadViewUiAsync( TargetField = targetField, Zoom = zoom, Markers = markers, + TruncateTitles = truncateTitles, + ShowDateFields = showDateFields, }; } @@ -155,6 +165,29 @@ private async Task<ViewUiSnapshot> ReadViewUiAsync( }; } + private async Task<bool?> ReadRoadmapDisplayOptionAsync(ILocator menu, string viewName, string label) + { + var option = Sel.ViewOptionCheckbox(menu, label); + if (await option.CountAsync().ConfigureAwait(false) == 0) + { + _warnings.Add($"view '{viewName}': roadmap display option '{label}' could not be read — control is unavailable"); + return null; + } + + return await option.First.GetAttributeAsync("aria-checked").ConfigureAwait(false) switch + { + "true" => true, + "false" => false, + _ => WarnUnreadableRoadmapDisplayOption(viewName, label), + }; + } + + private bool? WarnUnreadableRoadmapDisplayOption(string viewName, string label) + { + _warnings.Add($"view '{viewName}': roadmap display option '{label}' could not be read — aria-checked is unavailable"); + return null; + } + private static async Task<string?> ReadMenuItemTextAsync(ILocator menu, string label) { var item = Sel.ConfigurationMenuItem(menu, label); diff --git a/src/Ghpmv.Core/Browser/ViewUiImporter.cs b/src/Ghpmv.Core/Browser/ViewUiImporter.cs index 2c7a94bd..de5d1002 100644 --- a/src/Ghpmv.Core/Browser/ViewUiImporter.cs +++ b/src/Ghpmv.Core/Browser/ViewUiImporter.cs @@ -117,6 +117,7 @@ public async Task EnrichAsync( ArgumentNullException.ThrowIfNull(snapshot); ArgumentException.ThrowIfNullOrWhiteSpace(ownerLogin); ArgumentNullException.ThrowIfNull(viewNumbers); + ValidateSharedRoadmapDisplaySettings(snapshot.Views); if (snapshot.Views.Count == 0) { @@ -159,6 +160,30 @@ await ApplyTabOrderRecoverablyAsync( viewNumbers, cancellationToken), _warnings).ConfigureAwait(false); + // GitHub keeps some View preferences in browser storage rather than the Project API. + await _session.SaveStateAsync(cancellationToken).ConfigureAwait(false); + } + + public static void ValidateSharedRoadmapDisplaySettings(IReadOnlyList<ViewSnapshot> views) + { + ArgumentNullException.ThrowIfNull(views); + var roadmaps = views + .Where(view => string.Equals(view.Layout, "ROADMAP_LAYOUT", StringComparison.Ordinal)) + .Select(view => view.Ui?.Roadmap) + .Where(settings => settings is not null) + .ToArray(); + if (roadmaps.Where(settings => settings!.TruncateTitles is not null) + .Select(settings => settings!.TruncateTitles) + .Distinct() + .Count() > 1 + || roadmaps.Where(settings => settings!.ShowDateFields is not null) + .Select(settings => settings!.ShowDateFields) + .Distinct() + .Count() > 1) + { + throw new InvalidOperationException( + "Roadmap Truncate titles and Show date fields are project-shared and must have one consistent value across all Roadmap Views."); + } } /// <summary>Applies and saves only the complete Field sum selection for one target View.</summary> @@ -191,6 +216,7 @@ await ApplyAndVerifyFieldSumAsync( viewName, fieldSum, cancellationToken).ConfigureAwait(false); + await _session.SaveStateAsync(cancellationToken).ConfigureAwait(false); } catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) { @@ -198,6 +224,122 @@ await ApplyAndVerifyFieldSumAsync( } } + /// <summary>Applies and verifies both persisted Roadmap display checkboxes for one target View.</summary> + public async Task ApplyRoadmapDisplayOptionsAsync( + string ownerLogin, + ProjectOwnerType ownerType, + int projectNumber, + int viewNumber, + string viewName, + bool truncateTitles, + bool showDateFields, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerLogin); + ArgumentException.ThrowIfNullOrWhiteSpace(viewName); + + OnProgress?.Invoke($"Applying Roadmap display-option drift for view '{viewName}'..."); + IPage page; + try + { + page = await _session.GetPageAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) + { + _warnings.Add($"view '{viewName}': Roadmap display-option drift could not be applied — {exception.Message}"); + return; + } + + await ApplyRoadmapDisplayWriteRecoverablyAsync( + async () => + { + var url = BrowserProjectUrl.Build( + _session.BaseUrl, + ownerLogin, + ownerType, + projectNumber, + string.Create(CultureInfo.InvariantCulture, $"views/{viewNumber}")); + await _session.GotoAsync(url, cancellationToken).ConfigureAwait(false); + + (bool? TruncateTitles, bool? ShowDateFields) persisted = default; + for (var attempt = 1; attempt <= ViewPersistenceAttempts; attempt++) + { + var warningStart = _warnings.Count; + await TrySetMenuCheckboxAsync( + page, + "Truncate titles", + truncateTitles, + viewName, + cancellationToken).ConfigureAwait(false); + await TrySetMenuCheckboxAsync( + page, + "Show date fields", + showDateFields, + viewName, + cancellationToken).ConfigureAwait(false); + await SaveViewAsync(page, cancellationToken).ConfigureAwait(false); + persisted = await ReadPersistedRoadmapDisplayOptionsAsync(page, cancellationToken).ConfigureAwait(false); + if (persisted.TruncateTitles == truncateTitles + && persisted.ShowDateFields == showDateFields) + { + return; + } + + if (attempt < ViewPersistenceAttempts) + { + _warnings.RemoveRange(warningStart, _warnings.Count - warningStart); + OnProgress?.Invoke( + $"View '{viewName}' did not persist the Roadmap display options; retrying ({attempt + 1}/{ViewPersistenceAttempts})..."); + await page.ReloadAsync(new() { WaitUntil = WaitUntilState.DOMContentLoaded }).ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + } + } + + if (persisted.TruncateTitles != truncateTitles) + { + _warnings.Add( + $"view '{viewName}': Truncate titles expected '{FormatBoolean(truncateTitles)}', " + + $"actual '{FormatBoolean(persisted.TruncateTitles)}' did not persist after {ViewPersistenceAttempts} attempts"); + } + + if (persisted.ShowDateFields != showDateFields) + { + _warnings.Add( + $"view '{viewName}': Show date fields expected '{FormatBoolean(showDateFields)}', " + + $"actual '{FormatBoolean(persisted.ShowDateFields)}' did not persist after {ViewPersistenceAttempts} attempts"); + } + }, + () => _session.SaveStateAsync(CancellationToken.None), + _warnings, + viewName).ConfigureAwait(false); + } + + internal static async Task ApplyRoadmapDisplayWriteRecoverablyAsync( + Func<Task> writeAsync, + Func<Task> saveStateAsync, + List<string> warnings, + string viewName) + { + ArgumentNullException.ThrowIfNull(writeAsync); + ArgumentNullException.ThrowIfNull(saveStateAsync); + ArgumentNullException.ThrowIfNull(warnings); + ArgumentException.ThrowIfNullOrWhiteSpace(viewName); + + try + { + await writeAsync().ConfigureAwait(false); + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException or InvalidOperationException) + { + warnings.Add($"view '{viewName}': Roadmap display-option drift could not be applied — {exception.Message}"); + } + finally + { + // Preserve partial browser-storage writes even when read-back reports a recoverable mismatch. + await saveStateAsync().ConfigureAwait(false); + } + } + internal static async Task ApplyTabOrderRecoverablyAsync( Func<Task> reorderAsync, List<string> warnings) @@ -578,6 +720,26 @@ await TrySetSingleAsync( if (view.Ui?.Roadmap is { } roadmap) { + if (roadmap.TruncateTitles is { } truncateTitles) + { + await TrySetMenuCheckboxAsync( + page, + "Truncate titles", + truncateTitles, + view.Name, + cancellationToken).ConfigureAwait(false); + } + + if (roadmap.ShowDateFields is { } showDateFields) + { + await TrySetMenuCheckboxAsync( + page, + "Show date fields", + showDateFields, + view.Name, + cancellationToken).ConfigureAwait(false); + } + if (roadmap.StartField is not null || roadmap.TargetField is not null) { await TrySetDateFieldsAsync(page, roadmap, view.Name, cancellationToken).ConfigureAwait(false); @@ -742,27 +904,41 @@ private static async Task<PersistedViewSettings> ReadPersistedSettingsAsync( var sliceBy = view.Ui is null ? null : await ReadMenuValueAsync(menu, "Slice by").ConfigureAwait(false); + var truncateTitles = view.Ui?.Roadmap?.TruncateTitles is null + ? null + : await ReadMenuCheckboxAsync(menu, "Truncate titles").ConfigureAwait(false); + var showDateFields = view.Ui?.Roadmap?.ShowDateFields is null + ? null + : await ReadMenuCheckboxAsync(menu, "Show date fields").ConfigureAwait(false); var expectedFieldSum = FieldSumValuesToApply(view); - if (expectedFieldSum is null) + IReadOnlyList<string> fieldSum = []; + var fieldSumAvailable = false; + if (expectedFieldSum is not null) { - return new PersistedViewSettings(groupBy, columnBy, sliceBy, FieldSumAvailable: false, []); - } - - var fieldSumItem = Sel.ConfigurationMenuItem(menu, "Field sum"); - if (await fieldSumItem.CountAsync().ConfigureAwait(false) == 0) - { - return new PersistedViewSettings(groupBy, columnBy, sliceBy, FieldSumAvailable: false, []); + var fieldSumItem = Sel.ConfigurationMenuItem(menu, "Field sum"); + if (await fieldSumItem.CountAsync().ConfigureAwait(false) > 0) + { + await fieldSumItem.First.ClickAsync().ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + var overlay = Sel.OpenMenu(page); + await overlay.WaitForAsync().ConfigureAwait(false); + if (await ReadCheckedValuesAsync(overlay).ConfigureAwait(false) is { } checkedValues) + { + fieldSum = checkedValues; + fieldSumAvailable = true; + } + } } - await fieldSumItem.First.ClickAsync().ConfigureAwait(false); - await PauseAsync(cancellationToken).ConfigureAwait(false); - var overlay = Sel.OpenMenu(page); - await overlay.WaitForAsync().ConfigureAwait(false); - var checkedValues = await ReadCheckedValuesAsync(overlay).ConfigureAwait(false); - return checkedValues is null - ? new PersistedViewSettings(groupBy, columnBy, sliceBy, FieldSumAvailable: false, []) - : new PersistedViewSettings(groupBy, columnBy, sliceBy, FieldSumAvailable: true, checkedValues); + return new PersistedViewSettings( + groupBy, + columnBy, + sliceBy, + fieldSumAvailable, + fieldSum, + truncateTitles, + showDateFields); } finally { @@ -801,6 +977,23 @@ private static async Task<PersistedViewSettings> ReadPersistedSettingsAsync( } } + private static async Task<(bool? TruncateTitles, bool? ShowDateFields)> ReadPersistedRoadmapDisplayOptionsAsync( + IPage page, + CancellationToken cancellationToken) + { + var menu = await OpenViewMenuAsync(page, cancellationToken).ConfigureAwait(false); + try + { + return ( + await ReadMenuCheckboxAsync(menu, "Truncate titles").ConfigureAwait(false), + await ReadMenuCheckboxAsync(menu, "Show date fields").ConfigureAwait(false)); + } + finally + { + await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); + } + } + private static async Task<string?> ReadMenuValueAsync(ILocator menu, string label) { var item = Sel.ConfigurationMenuItem(menu, label); @@ -809,6 +1002,22 @@ private static async Task<PersistedViewSettings> ReadPersistedSettingsAsync( : ViewUiExporter.ParseMenuValue(await item.First.InnerTextAsync().ConfigureAwait(false)); } + private static async Task<bool?> ReadMenuCheckboxAsync(ILocator menu, string label) + { + var option = Sel.ViewOptionCheckbox(menu, label); + if (await option.CountAsync().ConfigureAwait(false) == 0) + { + return null; + } + + return await option.First.GetAttributeAsync("aria-checked").ConfigureAwait(false) switch + { + "true" => true, + "false" => false, + _ => null, + }; + } + private static async Task<IReadOnlyList<string>?> ReadCheckedValuesAsync(ILocator overlay) { var values = new List<string>(); @@ -885,6 +1094,23 @@ internal static IReadOnlyList<string> CollectPersistenceDifferences( } } + if (expected.Ui?.Roadmap is { } roadmap) + { + if (roadmap.TruncateTitles is { } expectedTruncateTitles + && actual.TruncateTitles != expectedTruncateTitles) + { + differences.Add( + $"truncate-titles expected '{FormatBoolean(expectedTruncateTitles)}', actual '{FormatBoolean(actual.TruncateTitles)}'"); + } + + if (roadmap.ShowDateFields is { } expectedShowDateFields + && actual.ShowDateFields != expectedShowDateFields) + { + differences.Add( + $"show-date-fields expected '{FormatBoolean(expectedShowDateFields)}', actual '{FormatBoolean(actual.ShowDateFields)}'"); + } + } + return differences; } @@ -904,12 +1130,62 @@ internal static bool CheckboxSelectionMatches( private static string FormatValue(string? value) => value ?? "none"; + private static string FormatBoolean(bool? value) => value?.ToString().ToLowerInvariant() ?? "unavailable"; + internal sealed record PersistedViewSettings( string? GroupBy, string? ColumnBy, string? SliceBy, bool FieldSumAvailable, - IReadOnlyList<string> FieldSum); + IReadOnlyList<string> FieldSum, + bool? TruncateTitles = null, + bool? ShowDateFields = null); + + private async Task TrySetMenuCheckboxAsync( + IPage page, + string label, + bool desired, + string viewName, + CancellationToken cancellationToken) + { + try + { + var menu = await OpenViewMenuAsync(page, cancellationToken).ConfigureAwait(false); + var option = Sel.ViewOptionCheckbox(menu, label); + if (await option.CountAsync().ConfigureAwait(false) == 0) + { + _warnings.Add($"view '{viewName}': roadmap display option '{label}' is not available on the target"); + await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); + return; + } + + var current = await ReadMenuCheckboxAsync(menu, label).ConfigureAwait(false); + var disabled = string.Equals( + await option.First.GetAttributeAsync("aria-disabled").ConfigureAwait(false), + "true", + StringComparison.Ordinal); + if (current is null) + { + _warnings.Add($"view '{viewName}': roadmap display option '{label}' state could not be read on the target"); + } + else if (current != desired && disabled) + { + _warnings.Add($"view '{viewName}': roadmap display option '{label}' is disabled and could not be set to {FormatBoolean(desired)}"); + } + else if (current != desired) + { + await option.First.ClickAsync().ConfigureAwait(false); + await PauseAsync(cancellationToken).ConfigureAwait(false); + } + + await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is PlaywrightException or TimeoutException) + { + _warnings.Add($"view '{viewName}': roadmap display option '{label}' could not be applied — {exception.Message}"); + await CloseMenusAsync(page, cancellationToken).ConfigureAwait(false); + } + } private async Task<bool> TrySetSingleAsync(IPage page, string label, string value, string viewName, CancellationToken cancellationToken) => await TrySetSingleAsync(page, label, [value], value, viewName, cancellationToken).ConfigureAwait(false); diff --git a/src/Ghpmv.Core/Export/MappingTemplates.cs b/src/Ghpmv.Core/Export/MappingTemplates.cs index ab23a414..96e92ce7 100644 --- a/src/Ghpmv.Core/Export/MappingTemplates.cs +++ b/src/Ghpmv.Core/Export/MappingTemplates.cs @@ -33,7 +33,7 @@ public static IReadOnlyList<string> ExtractSourceRepositories(IEnumerable<Projec var repositories = new List<string>(); foreach (var repository in snapshotList.SelectMany(s => s.Items) .Select(item => item.Repository) - .Concat(snapshotList.SelectMany(s => s.LinkedRepositories ?? [])) + .Concat(snapshotList.SelectMany(s => s.LinkedRepositories)) .Concat(snapshotList.SelectMany(s => s.Workflows) .Select(workflow => workflow.Ui?.Repository)) .Concat(FilterIdentifiers(snapshotList, "repo").Select(identifier => identifier.Value))) @@ -115,7 +115,7 @@ public static IReadOnlyList<string> ExtractLinkedTeams(IEnumerable<ProjectSnapsh ArgumentNullException.ThrowIfNull(snapshots); var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase); return snapshots - .SelectMany(snapshot => snapshot.LinkedTeams ?? []) + .SelectMany(snapshot => snapshot.LinkedTeams) .Select(team => team.Identity) .Where(seen.Add) .ToList(); diff --git a/src/Ghpmv.Core/Export/ProjectExporter.cs b/src/Ghpmv.Core/Export/ProjectExporter.cs index 5d385e3c..13028868 100644 --- a/src/Ghpmv.Core/Export/ProjectExporter.cs +++ b/src/Ghpmv.Core/Export/ProjectExporter.cs @@ -90,7 +90,7 @@ public async Task<ProjectSnapshot> ExportAsync(string ownerLogin, int projectNum : []; var statusUpdates = Sections.HasFlag(ProjectExportSections.StatusUpdates) ? await FetchStatusUpdatesAsync(ownerLogin, projectNumber, cancellationToken).ConfigureAwait(false) - : null; + : []; var fields = Sections.HasFlag(ProjectExportSections.Fields) ? await FetchApiFieldsAsync(ownerLogin, projectNumber, cancellationToken).ConfigureAwait(false) : []; @@ -101,7 +101,7 @@ public async Task<ProjectSnapshot> ExportAsync(string ownerLogin, int projectNum OnProgress?.Invoke(string.Create( CultureInfo.InvariantCulture, - $"Fetched {fields.Count} fields, {items.Count} items, and {statusUpdates?.Count ?? 0} status updates.")); + $"Fetched {fields.Count} fields, {items.Count} items, and {statusUpdates.Count} status updates.")); var snapshot = new ProjectSnapshot { @@ -488,17 +488,7 @@ private static bool TryGetNextPageCursor(JsonElement connection, out string afte } private static List<string> ParseVisibleFields(JsonElement view) - { - if (view.TryGetProperty("configuration", out var configuration) - && configuration.ValueKind == JsonValueKind.Object) - { - return ParseFieldNameConnection(configuration, "visibleFields"); - } - - // Backward compatibility for snapshots produced from responses captured - // before ProjectV2View.configuration was added on 2026-07-30. - return ParseFieldNameConnection(view, "fields"); - } + => ParseFieldNameConnection(view.GetProperty("configuration"), "visibleFields"); private static List<string> ParseFieldNameConnection(JsonElement view, string propertyName) { diff --git a/src/Ghpmv.Core/Fixtures/FixtureProjectBuilder.cs b/src/Ghpmv.Core/Fixtures/FixtureProjectBuilder.cs index a8595285..b18bcb2a 100644 --- a/src/Ghpmv.Core/Fixtures/FixtureProjectBuilder.cs +++ b/src/Ghpmv.Core/Fixtures/FixtureProjectBuilder.cs @@ -12,12 +12,14 @@ namespace Ghpmv.Core.Fixtures; /// <summary>Creates the standard API-backed integration-test fixture without PowerShell or gh CLI.</summary> public sealed class FixtureProjectBuilder { + public const string RoadmapLongTitle = "Fixture roadmap item with a deliberately long title for truncation rendering verification"; + private const string RepositoryClaimFileName = "fixture-repository.txt"; private const string ReferenceDateFileName = "fixture-reference-date"; private const string PendingRepositoryStatus = "pending"; private const string FallbackPendingRepositoryStatus = "fallback-pending"; private const string ClaimedRepositoryStatus = "claimed"; - private static readonly DateOnly LegacyFixtureReferenceDate = new(2026, 1, 1); + private static readonly DateOnly StableFixtureReferenceDate = new(2026, 1, 1); private readonly GitHubGraphQLClient _graphQl; private readonly GitHubRestClient _rest; @@ -40,6 +42,8 @@ public FixtureProjectBuilder(GitHubGraphQLClient graphQl, GitHubRestClient rest) public bool AllowExistingEmptyRepository { get; init; } + public bool IncludeRoadmapRenderingItem { get; init; } + public Func<CancellationToken, Task>? BeforeWriteAsync { get; init; } public async Task<FixtureProjectSetupResult> CreateAsync( @@ -80,15 +84,11 @@ public async Task<FixtureProjectSetupResult> CreateAsync( OperationLogDirectory, apiHost, repositoryFullName); - var hasPriorOperationState = File.Exists(Path.Combine(operationDirectory, ProjectImportLog.FileName)) - || File.Exists(Path.Combine(operationDirectory, ImportLog.FileName)) - || File.Exists(Path.Combine(operationDirectory, RepositoryClaimFileName)); var projectLog = await ProjectImportLog.LoadAsync(operationDirectory, cancellationToken).ConfigureAwait(false); var itemLog = await ImportLog.LoadAsync(operationDirectory, cancellationToken).ConfigureAwait(false); var referenceDate = await ResolveFixtureReferenceDateAsync( operationDirectory, useCurrentWeek: RequireNewResources, - hasPriorOperationState, DateOnly.FromDateTime(TimeProvider.GetLocalNow().DateTime), cancellationToken).ConfigureAwait(false); var projectMatches = await FindProjectsByTitleAsync(organization, title, cancellationToken).ConfigureAwait(false); @@ -226,6 +226,10 @@ async Task PersistTemplateRestorationAsync(bool required, CancellationToken ct) pullRequestNumber, teamSlug, referenceDate); + if (IncludeRoadmapRenderingItem) + { + snapshot = AddRoadmapRenderingItem(snapshot); + } var importStatusUpdates = true; IReadOnlyDictionary<int, string> matchedFixtureStatusUpdates = new Dictionary<int, string>(); @@ -234,31 +238,18 @@ async Task PersistTemplateRestorationAsync(bool required, CancellationToken ct) var snapshotFingerprint = ImportLog.ComputeSnapshotFingerprint(snapshot); if (!string.Equals(itemLog.SourceSnapshotFingerprint, snapshotFingerprint, StringComparison.Ordinal)) { - itemLog = UpgradeLegacyFixtureLog(itemLog, snapshot); - if (itemLog is null) - { - throw new InvalidOperationException( - $"{ImportLog.FileName} in '{operationDirectory}' belongs to a different fixture snapshot. Recreate the preview fixture instead of reusing incompatible artifacts."); - } - - await itemLog.SaveAsync(operationDirectory, cancellationToken).ConfigureAwait(false); - templateLog = itemLog; + throw new InvalidOperationException( + $"{ImportLog.FileName} in '{operationDirectory}' belongs to a different fixture snapshot. Recreate the preview fixture instead of reusing incompatible artifacts."); } } - await PersistLegacyProjectIdAsync( - projectLog, - itemLog, - operationDirectory, - cancellationToken).ConfigureAwait(false); - - if (existing is not null && snapshot.StatusUpdates is { Count: > 0 } expectedStatusUpdates) + if (existing is not null && snapshot.StatusUpdates.Count > 0) { var existingStatusUpdates = await FetchStatusUpdatesAsync( existing.Id, cancellationToken).ConfigureAwait(false); var reconciliation = ReconcileFixtureStatusUpdates( - expectedStatusUpdates, + snapshot.StatusUpdates, existingStatusUpdates, itemLog); matchedFixtureStatusUpdates = reconciliation.CanonicalMatches; @@ -278,7 +269,7 @@ await PersistLegacyProjectIdAsync( { OnProgress?.Invoke(string.Create( CultureInfo.InvariantCulture, - $"Fixture project contains {matchedFixtureStatusUpdates.Count}/{expectedStatusUpdates.Count} expected status updates; seeding only the missing fixture history and leaving unrelated history unchanged.")); + $"Fixture project contains {matchedFixtureStatusUpdates.Count}/{snapshot.StatusUpdates.Count} expected status updates; seeding only the missing fixture history and leaving unrelated history unchanged.")); } } @@ -292,7 +283,7 @@ await PersistLegacyProjectIdAsync( await ProjectTemplateWriteSession.SetFinalStateAsync( _graphQl, existing.Id, - snapshot.Project.Template!.Value, + snapshot.Project.Template, OnProgress, cancellationToken).ConfigureAwait(false); OnProgress?.Invoke($"Fixture project already completed; no API fixture writes are required: {existing.Url}"); @@ -418,7 +409,7 @@ await templateWriteSession.CompleteAsync( await ProjectTemplateWriteSession.SetFinalStateAsync( _graphQl, project.ProjectId, - snapshot.Project.Template!.Value, + snapshot.Project.Template, OnProgress, cancellationToken).ConfigureAwait(false); } @@ -455,40 +446,6 @@ internal static bool HasItemWork(ImportLog? log) or { PendingDrafts.Count: > 0 } or { PendingContents.Count: > 0 }; - internal static ImportLog? UpgradeLegacyFixtureLog(ImportLog log, ProjectSnapshot snapshot) - { - ArgumentNullException.ThrowIfNull(log); - ArgumentNullException.ThrowIfNull(snapshot); - - var withoutTemplate = snapshot with - { - Project = snapshot.Project with { Template = null }, - }; - var matchesPreTemplateSnapshot = string.Equals( - log.SourceSnapshotFingerprint, - ImportLog.ComputeSnapshotFingerprint(withoutTemplate), - StringComparison.Ordinal); - var matchesPreStatusSnapshot = log.StatusUpdates.Count == 0 - && log.PendingStatusUpdates.Count == 0 - && (string.Equals( - log.SourceSnapshotFingerprint, - ImportLog.ComputeSnapshotFingerprint(snapshot with { StatusUpdates = null }), - StringComparison.Ordinal) - || string.Equals( - log.SourceSnapshotFingerprint, - ImportLog.ComputeSnapshotFingerprint(withoutTemplate with { StatusUpdates = null }), - StringComparison.Ordinal)); - if (!matchesPreTemplateSnapshot && !matchesPreStatusSnapshot) - { - return null; - } - - return log with - { - SourceSnapshotFingerprint = ImportLog.ComputeSnapshotFingerprint(snapshot), - }; - } - internal static IReadOnlyDictionary<int, string> MatchFixtureStatusUpdates( IReadOnlyList<StatusUpdateSnapshot> expected, IReadOnlyList<FixtureStatusUpdate> actual) @@ -696,22 +653,6 @@ internal static async Task<bool> MarkOperationCompletedAsync( return projectLog.ImportCompleted is true; } - internal static async Task<bool> PersistLegacyProjectIdAsync( - ProjectImportLog projectLog, - ImportLog? itemLog, - string operationDirectory, - CancellationToken cancellationToken) - { - if (projectLog.CreatedProjectId is not null || itemLog is null) - { - return false; - } - - projectLog.CreatedProjectId = itemLog.ProjectId; - await projectLog.SaveAsync(operationDirectory, cancellationToken).ConfigureAwait(false); - return true; - } - internal static void ValidateNewProjectRequirement( string organization, string title, @@ -1185,37 +1126,9 @@ private static string ResolveOperationDirectory( string? teamSlug) { var teamIdentity = teamSlug is null ? string.Empty : $"\n{teamSlug.ToLowerInvariant()}"; - var current = GetOperationDirectory( + return GetOperationDirectory( root, $"{apiHost}\n{organization.ToLowerInvariant()}\n{title}\n{repositoryName.ToLowerInvariant()}{teamIdentity}"); - if (HasDurableOperationState(current)) - { - return current; - } - - var legacyInputs = new List<string> - { - $"{apiHost}\n{organization}\n{title}\n{repositoryName}{(teamSlug is null ? string.Empty : $"\n{teamSlug}")}", - }; - if (string.Equals(apiHost, "https://api.github.com", StringComparison.Ordinal)) - { - legacyInputs.Add($"{organization.ToLowerInvariant()}\n{title}\n{repositoryName.ToLowerInvariant()}{teamIdentity}"); - legacyInputs.Add($"{organization}\n{title}\n{repositoryName}{(teamSlug is null ? string.Empty : $"\n{teamSlug}")}"); - } - - var legacyDirectories = legacyInputs - .Select(input => GetOperationDirectory(root, input)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Where(path => !string.Equals(path, current, StringComparison.OrdinalIgnoreCase)) - .Where(HasDurableOperationState) - .ToArray(); - return legacyDirectories.Length switch - { - 0 => current, - 1 => legacyDirectories[0], - _ => throw new InvalidOperationException( - "Multiple legacy fixture operation logs match this fixture; refusing to choose an ownership record."), - }; } private static string GetOperationDirectory(string root, string operationIdentity) @@ -1226,23 +1139,16 @@ private static string GetOperationDirectory(string root, string operationIdentit return Path.Combine(root, operationKey); } - private static bool HasDurableOperationState(string directory) - => File.Exists(Path.Combine(directory, ProjectImportLog.FileName)) - || File.Exists(Path.Combine(directory, ImportLog.FileName)) - || File.Exists(Path.Combine(directory, RepositoryClaimFileName)) - || File.Exists(Path.Combine(directory, ReferenceDateFileName)); - internal static async Task<DateOnly> ResolveFixtureReferenceDateAsync( string operationDirectory, bool useCurrentWeek, - bool hasPriorOperationState, DateOnly currentDate, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(operationDirectory); if (!useCurrentWeek) { - return LegacyFixtureReferenceDate; + return StableFixtureReferenceDate; } Directory.CreateDirectory(operationDirectory); @@ -1264,9 +1170,7 @@ internal static async Task<DateOnly> ResolveFixtureReferenceDateAsync( return persistedDate; } - var referenceDate = hasPriorOperationState - ? LegacyFixtureReferenceDate - : StartOfWeek(currentDate); + var referenceDate = StartOfWeek(currentDate); var temporaryPath = path + "." + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture) + ".tmp"; try { @@ -1472,7 +1376,7 @@ public static ProjectSnapshot CreateSnapshot( DateOnly? referenceDate = null) { repositoryFullName = repositoryFullName.ToLowerInvariant(); - var today = (referenceDate ?? LegacyFixtureReferenceDate).ToDateTime(TimeOnly.MinValue); + var today = (referenceDate ?? StableFixtureReferenceDate).ToDateTime(TimeOnly.MinValue); var sprint0Start = today.AddDays(-28).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); var sprint1Start = today.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); var sprint2Start = today.AddDays(14).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); @@ -1672,6 +1576,29 @@ public static ProjectSnapshot CreateSnapshot( static FieldValueSnapshot Status(string value) => new() { FieldName = "Status", SingleSelectOptionName = value }; } + internal static ProjectSnapshot AddRoadmapRenderingItem(ProjectSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + var source = snapshot.Items.First(item => item.Draft?.Title == "Fixture draft 1"); + var item = source with + { + Position = snapshot.Items.Count, + Draft = source.Draft! with { Title = RoadmapLongTitle }, + }; + return snapshot with { Items = [.. snapshot.Items, item] }; + } + + public static bool IsRoadmapRenderingItem(ItemSnapshot item) + { + ArgumentNullException.ThrowIfNull(item); + return !item.IsArchived + && string.Equals(item.Draft?.Title, RoadmapLongTitle, StringComparison.Ordinal) + && item.FieldValues.Any(value => + value.FieldName == "Fixture Date" && value.Date is not null) + && item.FieldValues.Any(value => + value.FieldName == "Fixture Sprint" && value.IterationTitle is not null); + } + private async Task EnsureMultiSelectIssueFieldValueAsync( string repositoryFullName, ImportResult project, diff --git a/src/Ghpmv.Core/Import/ImportCapabilityAnalyzer.cs b/src/Ghpmv.Core/Import/ImportCapabilityAnalyzer.cs index c4419fcd..0a5474c9 100644 --- a/src/Ghpmv.Core/Import/ImportCapabilityAnalyzer.cs +++ b/src/Ghpmv.Core/Import/ImportCapabilityAnalyzer.cs @@ -77,7 +77,7 @@ public static ImportCapabilityPlan Analyze( Add(requirements, repository, capability); } - foreach (var repository in snapshot.LinkedRepositories ?? []) + foreach (var repository in snapshot.LinkedRepositories) { Add( requirements, diff --git a/src/Ghpmv.Core/Import/ProjectTemplateWriteSession.cs b/src/Ghpmv.Core/Import/ProjectTemplateWriteSession.cs index 038c8e90..446b5ba0 100644 --- a/src/Ghpmv.Core/Import/ProjectTemplateWriteSession.cs +++ b/src/Ghpmv.Core/Import/ProjectTemplateWriteSession.cs @@ -157,25 +157,15 @@ public async Task RestoreAsync(CancellationToken cancellationToken = default) _restored = true; } - /// <summary> - /// Applies a captured template state as the final successful import stage. A null - /// snapshot value restores a temporarily unmarked legacy target without otherwise - /// changing its state. - /// </summary> - public async Task CompleteAsync(bool? desiredTemplate, CancellationToken cancellationToken = default) + /// <summary>Applies the template state as the final successful import stage.</summary> + public async Task CompleteAsync(bool desiredTemplate, CancellationToken cancellationToken = default) { - if (desiredTemplate is null) + if (_currentTemplate != desiredTemplate) { - await RestoreAsync(cancellationToken).ConfigureAwait(false); - return; - } - - if (_currentTemplate != desiredTemplate.Value) - { - OnProgress?.Invoke(desiredTemplate.Value + OnProgress?.Invoke(desiredTemplate ? "Marking the target project as a template as the final import stage..." : "Unmarking the target project as a template as the final import stage..."); - await SetTemplateAsync(desiredTemplate.Value, cancellationToken).ConfigureAwait(false); + await SetTemplateAsync(desiredTemplate, cancellationToken).ConfigureAwait(false); } if (RestorationRequired && _persistRestorationStateAsync is not null) diff --git a/src/Ghpmv.Core/Import/StatusUpdateImporter.cs b/src/Ghpmv.Core/Import/StatusUpdateImporter.cs index 95a5a974..d83f0fca 100644 --- a/src/Ghpmv.Core/Import/StatusUpdateImporter.cs +++ b/src/Ghpmv.Core/Import/StatusUpdateImporter.cs @@ -39,12 +39,6 @@ public async Task<StatusUpdateImportResult> ImportAsync( ArgumentNullException.ThrowIfNull(target); ArgumentException.ThrowIfNullOrWhiteSpace(logDirectory); - if (snapshot.StatusUpdates is null) - { - OnProgress?.Invoke("Status updates were not captured by this schema-v1 snapshot; leaving the target history unchanged."); - return EmptyResult(); - } - ValidateStatusUpdates(snapshot.StatusUpdates); var log = await LoadLogAsync(snapshot, target.ProjectId, logDirectory, cancellationToken).ConfigureAwait(false); ValidateLogAgainstSnapshot(log, snapshot.StatusUpdates.Count, target.ProjectId); diff --git a/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs b/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs index e26142a7..2e72cddd 100644 --- a/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs +++ b/src/Ghpmv.Core/Snapshot/ProjectSnapshot.cs @@ -7,7 +7,7 @@ namespace Ghpmv.Core.Snapshot; public sealed record ProjectSnapshot { /// <summary>The schema version written by the current tool.</summary> - public const int CurrentSchemaVersion = 1; + public const int CurrentSchemaVersion = 2; public required int SchemaVersion { get; init; } @@ -21,11 +21,8 @@ public sealed record ProjectSnapshot public required IReadOnlyList<ItemSnapshot> Items { get; init; } - /// <summary> - /// Project status update history in reverse chronological order. Null when the - /// snapshot predates status update support (a backward-compatible schema-v1 addition). - /// </summary> - public IReadOnlyList<StatusUpdateSnapshot>? StatusUpdates { get; init; } + /// <summary>Project status update history in reverse chronological order.</summary> + public required IReadOnlyList<StatusUpdateSnapshot> StatusUpdates { get; init; } /// <summary> /// Project collaborators (users/teams with an explicit project role). Null when not @@ -39,16 +36,14 @@ public sealed record ProjectSnapshot public IReadOnlyList<CollaboratorSnapshot>? Collaborators { get; init; } /// <summary> - /// Repositories linked to the project, in "owner/name" form. Null when the snapshot - /// predates this field (schema additions are backward compatible within version 1). + /// Repositories linked to the project, in "owner/name" form. /// </summary> - public IReadOnlyList<string>? LinkedRepositories { get; init; } + public required IReadOnlyList<string> LinkedRepositories { get; init; } /// <summary> - /// Teams linked to an organization-owned Project. Null when the snapshot predates - /// this schema-v1 addition. Current user-owned Project exports always use an empty list. + /// Teams linked to an organization-owned Project. User-owned Project exports use an empty list. /// </summary> - public IReadOnlyList<LinkedTeamSnapshot>? LinkedTeams { get; init; } + public required IReadOnlyList<LinkedTeamSnapshot> LinkedTeams { get; init; } } /// <summary>A historical Project status update.</summary> @@ -57,8 +52,7 @@ public sealed record StatusUpdateSnapshot public required string Body { get; init; } /// <summary> - /// GraphQL <c>ProjectV2StatusUpdateStatus</c>. Null when the source update has no - /// status or when a schema-v1 snapshot predates this optional property. + /// GraphQL <c>ProjectV2StatusUpdateStatus</c>. Null when the source update has no status. /// </summary> public string? Status { get; init; } @@ -117,11 +111,8 @@ public sealed record ProjectInfoSnapshot public required bool Closed { get; init; } - /// <summary> - /// Whether an organization-owned Project is a template. Null when the snapshot - /// predates template support and import must preserve the target's current state. - /// </summary> - public bool? Template { get; init; } + /// <summary>Whether an organization-owned Project is a template.</summary> + public required bool Template { get; init; } } /// <summary> @@ -216,7 +207,7 @@ public sealed record IterationSnapshot /// <summary> /// A project view. GraphQL-readable settings are captured here; -/// UI-only settings (Slice by, Field sum, Roadmap dates/zoom/markers) are +/// UI-only settings (Slice by, Field sum, Roadmap dates/zoom/markers/display options) are /// reserved in <see cref="Ui"/> and populated by the browser module (M6). /// </summary> public sealed record ViewSnapshot @@ -283,6 +274,12 @@ public sealed record RoadmapSettingsSnapshot public string? Zoom { get; init; } public IReadOnlyList<string>? Markers { get; init; } + + /// <summary>Whether long item titles are truncated. Null when the UI control could not be read.</summary> + public bool? TruncateTitles { get; init; } + + /// <summary>Whether item date fields are rendered. Null when the UI control could not be read.</summary> + public bool? ShowDateFields { get; init; } } /// <summary> diff --git a/src/Ghpmv.Core/Snapshot/SnapshotFile.cs b/src/Ghpmv.Core/Snapshot/SnapshotFile.cs index cefc4220..6d8fabf4 100644 --- a/src/Ghpmv.Core/Snapshot/SnapshotFile.cs +++ b/src/Ghpmv.Core/Snapshot/SnapshotFile.cs @@ -34,8 +34,58 @@ public static async Task<ProjectSnapshot> LoadAsync(string directory, Cancellati var stream = File.OpenRead(path); await using (stream.ConfigureAwait(false)) { - return await JsonSerializer.DeserializeAsync(stream, SnapshotJsonContext.Default.ProjectSnapshot, cancellationToken).ConfigureAwait(false) + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException($"'{path}' must contain a JSON object."); + } + + if (!root.TryGetProperty("schemaVersion", out var schemaVersionElement) + || schemaVersionElement.ValueKind != JsonValueKind.Number + || !schemaVersionElement.TryGetInt32(out var schemaVersion)) + { + throw new InvalidDataException($"'{path}' is missing required integer 'schemaVersion'."); + } + + if (schemaVersion != ProjectSnapshot.CurrentSchemaVersion) + { + throw new InvalidDataException( + $"'{path}' uses unsupported schema version {schemaVersion}; expected {ProjectSnapshot.CurrentSchemaVersion}."); + } + + if (!root.TryGetProperty("project", out var project) + || project.ValueKind != JsonValueKind.Object) + { + throw new InvalidDataException($"'{path}' is missing required object 'project'."); + } + + if (!project.TryGetProperty("template", out var template) + || template.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + { + throw new InvalidDataException($"'{path}' is missing required boolean 'project.template'."); + } + + RequireArray(root, "fields", path); + RequireArray(root, "views", path); + RequireArray(root, "workflows", path); + RequireArray(root, "items", path); + RequireArray(root, "statusUpdates", path); + RequireArray(root, "linkedRepositories", path); + RequireArray(root, "linkedTeams", path); + + return root.Deserialize(SnapshotJsonContext.Default.ProjectSnapshot) ?? throw new InvalidDataException($"'{path}' contained a null snapshot."); } } + + private static void RequireArray(JsonElement root, string propertyName, string path) + { + if (!root.TryGetProperty(propertyName, out var property) + || property.ValueKind != JsonValueKind.Array) + { + throw new InvalidDataException( + $"'{path}' is missing required array '{propertyName}'."); + } + } } diff --git a/src/Ghpmv.Core/Verify/ProjectVerifier.cs b/src/Ghpmv.Core/Verify/ProjectVerifier.cs index 3038c375..7516a0c6 100644 --- a/src/Ghpmv.Core/Verify/ProjectVerifier.cs +++ b/src/Ghpmv.Core/Verify/ProjectVerifier.cs @@ -277,17 +277,9 @@ private static VerifyReport CompareCore( target.Fields.Where(field => field.IssueField is not null).Select(field => field.Name).ToHashSet(StringComparer.Ordinal), differences); } - var statusUpdatesExplicitlyRequested = includedCategories?.Contains(VerifyCategories.StatusUpdate) == true; if (Includes(includedCategories, VerifyCategories.StatusUpdate)) { - if (source.StatusUpdates is null && statusUpdatesExplicitlyRequested) - { - notVerified.Add(VerifyCategories.StatusUpdate); - } - else - { - CompareStatusUpdates(source.StatusUpdates, target.StatusUpdates, differences); - } + CompareStatusUpdates(source.StatusUpdates, target.StatusUpdates, differences); } if (Includes(includedCategories, VerifyCategories.Collaborator)) { @@ -295,11 +287,11 @@ private static VerifyReport CompareCore( } if (Includes(includedCategories, VerifyCategories.LinkedRepository)) { - CompareLinkedRepositories(source.LinkedRepositories, target.LinkedRepositories, differences, notVerified); + CompareLinkedRepositories(source.LinkedRepositories, target.LinkedRepositories, differences); } if (teamLinksApplicable && Includes(includedCategories, VerifyCategories.TeamLink)) { - CompareLinkedTeams(source.LinkedTeams, target.LinkedTeams, differences, notVerified); + CompareLinkedTeams(source.LinkedTeams, target.LinkedTeams, differences); } var categories = new List<VerifyCategoryResult>(); @@ -310,8 +302,7 @@ private static VerifyReport CompareCore( AddCategoryIfIncluded(categories, VerifyCategories.Workflow, includedCategories, differences, notVerified); AddCategoryIfIncluded(categories, VerifyCategories.Collaborator, includedCategories, differences, notVerified); AddCategoryIfIncluded(categories, VerifyCategories.LinkedRepository, includedCategories, differences, notVerified); - if ((source.StatusUpdates is not null || statusUpdatesExplicitlyRequested) - && Includes(includedCategories, VerifyCategories.StatusUpdate)) + if (Includes(includedCategories, VerifyCategories.StatusUpdate)) { categories.Add(CategoryResult(VerifyCategories.StatusUpdate, differences, notVerified)); } @@ -415,7 +406,7 @@ private static ProjectSnapshot ApplyRepositoryMapping( : item).ToList() : source.Items, LinkedRepositories = Includes(includedCategories, VerifyCategories.LinkedRepository) - ? source.LinkedRepositories?.Select(repository => repositoryMapping.TryGetValue(repository, out var mappedRepository) + ? source.LinkedRepositories.Select(repository => repositoryMapping.TryGetValue(repository, out var mappedRepository) ? mappedRepository : repository).ToList() : source.LinkedRepositories, @@ -460,7 +451,7 @@ private static ProjectSnapshot ApplyTeamMapping( ProjectSnapshot source, IReadOnlyDictionary<string, string> teamMapping) { - var collaboratorMapping = (source.LinkedTeams ?? []) + var collaboratorMapping = source.LinkedTeams .Select(team => { if (!teamMapping.TryGetValue(team.Identity, out var mapped) @@ -480,7 +471,7 @@ private static ProjectSnapshot ApplyTeamMapping( return source with { - LinkedTeams = source.LinkedTeams?.Select(team => + LinkedTeams = source.LinkedTeams.Select(team => { if (!teamMapping.TryGetValue(team.Identity, out var mapped) || !TeamLinkMapping.TryParseIdentity(mapped, out var organization, out var slug)) @@ -834,23 +825,10 @@ private static string Describe(CollaboratorSnapshot collaborator) => $"{collaborator.Type.ToUpperInvariant()} '{collaborator.Login}'"; private static void CompareLinkedRepositories( - IReadOnlyList<string>? source, - IReadOnlyList<string>? target, - List<VerifyDifference> differences, - HashSet<string> notVerified) + IReadOnlyList<string> source, + IReadOnlyList<string> target, + List<VerifyDifference> differences) { - if (source is null || target is null) - { - notVerified.Add(LinkedRepositoryCategory); - if (source is not null) - { - Add(differences, VerifySeverity.Warning, LinkedRepositoryCategory, - "linked repositories were captured in the source but could not be read from the target"); - } - - return; - } - var targetSet = target.ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var repository in source.Where(r => !targetSet.Contains(r))) { @@ -867,23 +845,10 @@ private static void CompareLinkedRepositories( } private static void CompareLinkedTeams( - IReadOnlyList<LinkedTeamSnapshot>? source, - IReadOnlyList<LinkedTeamSnapshot>? target, - List<VerifyDifference> differences, - HashSet<string> notVerified) + IReadOnlyList<LinkedTeamSnapshot> source, + IReadOnlyList<LinkedTeamSnapshot> target, + List<VerifyDifference> differences) { - if (source is null || target is null) - { - notVerified.Add(TeamLinkCategory); - if (source is not null) - { - Add(differences, VerifySeverity.Warning, TeamLinkCategory, - "linked Teams were captured in the source but could not be read from the target"); - } - - return; - } - var targetSet = target.Select(team => team.Identity).ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var team in source.Where(team => !targetSet.Contains(team.Identity))) { @@ -1067,7 +1032,9 @@ private static bool RoadmapEquals(RoadmapSettingsSnapshot? source, RoadmapSettin && string.Equals(source.StartField, target.StartField, StringComparison.Ordinal) && string.Equals(source.TargetField, target.TargetField, StringComparison.Ordinal) && string.Equals(source.Zoom, target.Zoom, StringComparison.Ordinal) - && UiListEquals(source.Markers, target.Markers); + && UiListEquals(source.Markers, target.Markers) + && (source.TruncateTitles is null || source.TruncateTitles == target.TruncateTitles) + && (source.ShowDateFields is null || source.ShowDateFields == target.ShowDateFields); private static bool MultisetEquals<T>( IReadOnlyList<T> source, @@ -1158,6 +1125,35 @@ private static void CompareViewUi(string name, ViewUiSnapshot source, ViewUiSnap AddError(differences, ViewCategory, $"view '{name}': markers mismatch (source [{JoinUi(sourceRoadmap.Markers)}], target [{JoinUi(targetRoadmap.Markers)}])"); } + + CompareUiBoolean( + differences, + name, + "truncate titles", + sourceRoadmap.TruncateTitles, + targetRoadmap.TruncateTitles); + CompareUiBoolean( + differences, + name, + "show date fields", + sourceRoadmap.ShowDateFields, + targetRoadmap.ShowDateFields); + } + } + + private static void CompareUiBoolean( + List<VerifyDifference> differences, + string viewName, + string setting, + bool? source, + bool? target) + { + if (source is not null && source != target) + { + AddError( + differences, + ViewCategory, + $"view '{viewName}': {setting} mismatch (source '{source.Value.ToString().ToLowerInvariant()}', target '{target?.ToString().ToLowerInvariant() ?? "not captured"}')"); } } @@ -1361,18 +1357,10 @@ private static void CompareItems( } private static void CompareStatusUpdates( - IReadOnlyList<StatusUpdateSnapshot>? source, - IReadOnlyList<StatusUpdateSnapshot>? target, + IReadOnlyList<StatusUpdateSnapshot> source, + IReadOnlyList<StatusUpdateSnapshot> target, List<VerifyDifference> differences) { - // Null is the schema-v1 compatibility sentinel: older snapshots did not capture - // this collection and retain the pre-status-update verification behavior. - if (source is null) - { - return; - } - - target ??= []; if (source.Count != target.Count) { AddError(differences, StatusUpdateCategory, string.Create( diff --git a/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs b/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs index 1dfddc26..9207e682 100644 --- a/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs +++ b/tests/Ghpmv.Browser.Tests/BrowserRoundTripTests.cs @@ -5,6 +5,7 @@ using Ghpmv.Core.Snapshot; using Ghpmv.Core.Verify; using Ghpmv.TestSupport; +using System.Runtime.ExceptionServices; using System.Text.Json; namespace Ghpmv.Browser.Tests; @@ -136,6 +137,13 @@ public async Task Browser_features_round_trip_in_one_shared_scenario() }; var operationLogDirectory = CreateOperationLogDirectory(); + var expectedRoadmapDisplay = Assert.Single( + snapshot.Views + .Where(view => view.Layout == "ROADMAP_LAYOUT") + .Select(view => ( + view.Ui!.Roadmap!.TruncateTitles, + view.Ui.Roadmap.ShowDateFields)) + .Distinct()); var importer = new ProjectImporter(targetClient) { OperationLogDirectory = operationLogDirectory, @@ -147,6 +155,8 @@ public async Task Browser_features_round_trip_in_one_shared_scenario() UserMapping = userMapping, }; var result = await importer.ImportAsync(apiImportSnapshot, TargetOrg, cancellationToken); + Exception? testFailure = null; + Exception? cleanupFailure = null; try { var initialItemResult = await new ItemImporter(targetClient) @@ -177,6 +187,18 @@ await fieldDefaultImporter.ImportAsync( var targetPage = await targetSession.GetPageAsync(cancellationToken); await targetPage.SetViewportSizeAsync(480, 1000); + await targetPage.EvaluateAsync( + """ + values => { + localStorage.setItem("projects.roadmapTruncateTitles", values.truncateTitles); + localStorage.setItem("projects.roadmapShowDateFields", values.showDateFields); + } + """, + new + { + truncateTitles = (!expectedRoadmapDisplay.TruncateTitles!.Value).ToString().ToLowerInvariant(), + showDateFields = (!expectedRoadmapDisplay.ShowDateFields!.Value).ToString().ToLowerInvariant(), + }); var viewImporter = new ViewUiImporter(targetSession); await viewImporter.EnrichAsync( snapshot, @@ -187,6 +209,16 @@ await viewImporter.EnrichAsync( cancellationToken); Assert.Empty(viewImporter.Warnings); + var sourceRoadmap = Assert.Single(snapshot.Views, view => view.Name == "Fixture Roadmap"); + await AssertRoadmapDisplayFreshSessionAsync( + targetStatePath!, + E2eTestEnvironment.Current.Target, + result.ProjectNumber, + result.ViewNumbers[sourceRoadmap.Number], + expectedRoadmapDisplay.TruncateTitles.Value, + expectedRoadmapDisplay.ShowDateFields.Value, + cancellationToken); + var workflowImporter = new WorkflowUiImporter(targetSession) { OrganizationMapping = OrganizationMapping, @@ -250,6 +282,92 @@ await viewImporter.EnrichAsync( TargetOrg, result.ProjectNumber, cancellationToken); + await new FieldSumRenderingObserver(targetSession).ValidateStandardFixtureAsync( + TargetOrg, + ProjectOwnerType.Organization, + result.ProjectNumber, + snapshot.Views.ToDictionary( + view => view.Name, + view => result.ViewNumbers[view.Number], + StringComparer.Ordinal), + cancellationToken); + + await viewImporter.ApplyRoadmapDisplayOptionsAsync( + TargetOrg, + ProjectOwnerType.Organization, + result.ProjectNumber, + result.ViewNumbers[sourceRoadmap.Number], + sourceRoadmap.Name, + truncateTitles: false, + showDateFields: false, + cancellationToken); + Assert.Empty(viewImporter.Warnings); + + var titleDriftReport = await verifier.VerifyAsync( + snapshot, + TargetOrg, + result.ProjectNumber, + cancellationToken); + Assert.Equal(2, titleDriftReport.Differences.Count(difference => + difference.Category == VerifyCategories.View + && difference.Message.Contains("truncate titles mismatch", StringComparison.Ordinal))); + Assert.Equal(2, titleDriftReport.Differences.Count(difference => + difference.Severity != VerifySeverity.Info)); + Assert.Equal( + VerifyStatus.Mismatch, + Assert.Single(titleDriftReport.Categories, category => + category.Category == VerifyCategories.View).Status); + Assert.DoesNotContain(titleDriftReport.Differences, difference => + difference.Category == VerifyCategories.View + && difference.Message.Contains("show date fields mismatch", StringComparison.Ordinal)); + await new FieldSumRenderingObserver(targetSession).ValidateFixtureAsync( + FixtureUiSnapshotFactory.CreateRoadmapDisplayDrift(), + TargetOrg, + ProjectOwnerType.Organization, + result.ProjectNumber, + snapshot.Views.ToDictionary( + view => view.Name, + view => result.ViewNumbers[view.Number], + StringComparer.Ordinal), + cancellationToken); + + await viewImporter.ApplyRoadmapDisplayOptionsAsync( + TargetOrg, + ProjectOwnerType.Organization, + result.ProjectNumber, + result.ViewNumbers[sourceRoadmap.Number], + sourceRoadmap.Name, + truncateTitles: true, + showDateFields: true, + cancellationToken); + Assert.Empty(viewImporter.Warnings); + var dateDriftReport = await verifier.VerifyAsync( + snapshot, + TargetOrg, + result.ProjectNumber, + cancellationToken); + Assert.DoesNotContain(dateDriftReport.Differences, difference => + difference.Category == VerifyCategories.View + && difference.Message.Contains("truncate titles mismatch", StringComparison.Ordinal)); + Assert.Equal(2, dateDriftReport.Differences.Count(difference => + difference.Category == VerifyCategories.View + && difference.Message.Contains("show date fields mismatch", StringComparison.Ordinal))); + Assert.Equal(2, dateDriftReport.Differences.Count(difference => + difference.Severity != VerifySeverity.Info)); + Assert.Equal( + VerifyStatus.Mismatch, + Assert.Single(dateDriftReport.Categories, category => + category.Category == VerifyCategories.View).Status); + await new FieldSumRenderingObserver(targetSession).ValidateFixtureAsync( + FixtureUiSnapshotFactory.CreateRoadmapDateDisplayDrift(), + TargetOrg, + ProjectOwnerType.Organization, + result.ProjectNumber, + snapshot.Views.ToDictionary( + view => view.Name, + view => result.ViewNumbers[view.Number], + StringComparer.Ordinal), + cancellationToken); var sourceTable = Assert.Single(snapshot.Views, view => view.Name == "View 1"); await viewImporter.ApplyFieldSumAsync( @@ -289,6 +407,12 @@ await fieldDefaultImporter.ImportAsync( difference.Severity == VerifySeverity.Error && difference.Category == VerifyCategories.View && difference.Message.Contains("field sum mismatch", StringComparison.Ordinal)); + Assert.DoesNotContain(driftReport.Differences, difference => + difference.Category == VerifyCategories.View + && difference.Message.Contains("truncate titles mismatch", StringComparison.Ordinal)); + Assert.Equal(2, driftReport.Differences.Count(difference => + difference.Category == VerifyCategories.View + && difference.Message.Contains("show date fields mismatch", StringComparison.Ordinal))); Assert.Contains(driftReport.Differences, difference => difference.Severity == VerifySeverity.Error && difference.Category == VerifyCategories.Workflow @@ -360,11 +484,63 @@ await workflowImporter.ImportAsync( result.ProjectNumber, cancellationToken); - Assert.Equal(3, browserVerificationCount); + Assert.Equal(5, browserVerificationCount); + } + catch (Exception exception) + { + testFailure = exception; } finally { - await DeleteProjectAsync(targetClient, result.ProjectId); + try + { + var targetPage = await targetSession.GetPageAsync(CancellationToken.None); + await targetPage.EvaluateAsync( + """ + values => { + localStorage.setItem("projects.roadmapTruncateTitles", values.truncateTitles); + localStorage.setItem("projects.roadmapShowDateFields", values.showDateFields); + } + """, + new + { + truncateTitles = expectedRoadmapDisplay.TruncateTitles!.Value.ToString().ToLowerInvariant(), + showDateFields = expectedRoadmapDisplay.ShowDateFields!.Value.ToString().ToLowerInvariant(), + }); + await targetSession.SaveStateAsync(CancellationToken.None); + } + catch (Exception exception) + { + cleanupFailure = exception; + } + finally + { + try + { + await DeleteProjectAsync(targetClient, result.ProjectId); + } + catch (Exception exception) + { + cleanupFailure = cleanupFailure is null + ? exception + : new AggregateException(cleanupFailure, exception); + } + } + } + + if (testFailure is not null) + { + if (cleanupFailure is not null) + { + testFailure.Data["BrowserRoundTrip cleanup failure"] = cleanupFailure.ToString(); + } + + ExceptionDispatchInfo.Capture(testFailure).Throw(); + } + + if (cleanupFailure is not null) + { + ExceptionDispatchInfo.Capture(cleanupFailure).Throw(); } } finally @@ -395,6 +571,11 @@ private static void AssertSourceViews(ProjectSnapshot source) Assert.Equal(["Fixture Number 2"], sourceRoadmap.Ui!.FieldSum); Assert.Equal("Quarter", sourceRoadmap.Ui.Roadmap?.Zoom); Assert.Contains("Fixture Date", sourceRoadmap.Ui.Roadmap?.Markers ?? []); + Assert.True(sourceRoadmap.Ui.Roadmap?.TruncateTitles); + Assert.False(sourceRoadmap.Ui.Roadmap?.ShowDateFields); + var sourceDatesHidden = Assert.Single(source.Views, view => view.Name == "Fixture Roadmap Dates Hidden"); + Assert.True(sourceDatesHidden.Ui!.Roadmap?.TruncateTitles); + Assert.False(sourceDatesHidden.Ui.Roadmap?.ShowDateFields); var sourceEmptySums = Assert.Single(source.Views, view => view.Name == "Fixture Empty Sums"); Assert.Equal(["Status"], sourceEmptySums.GroupByFields); @@ -496,6 +677,8 @@ private static void AssertRoundTrippedViews(ProjectSnapshot snapshot, ProjectSna Assert.Equal(roadmap.TargetField, actual.Ui.Roadmap.TargetField); Assert.Equal(roadmap.Zoom, actual.Ui.Roadmap.Zoom); Assert.Equal(roadmap.Markers ?? [], actual.Ui.Roadmap.Markers ?? []); + Assert.Equal(roadmap.TruncateTitles, actual.Ui.Roadmap.TruncateTitles); + Assert.Equal(roadmap.ShowDateFields, actual.Ui.Roadmap.ShowDateFields); } } } @@ -538,6 +721,40 @@ private static void AssertRoundTrippedFieldDefaults(ProjectSnapshot snapshot, Pr } } + private static async Task AssertRoadmapDisplayFreshSessionAsync( + string statePath, + E2eEndpointSettings endpoint, + int projectNumber, + int viewNumber, + bool truncateTitles, + bool showDateFields, + CancellationToken cancellationToken) + { + await using var session = CreateSession(statePath, endpoint); + var page = await session.GotoAsync( + BrowserProjectUrl.Build( + session.BaseUrl, + TargetOrg, + ProjectOwnerType.Organization, + projectNumber, + $"views/{viewNumber}"), + cancellationToken); + var storage = await page.EvaluateAsync<Dictionary<string, string?>>( + """ + () => ({ + "projects.roadmapTruncateTitles": localStorage.getItem("projects.roadmapTruncateTitles"), + "projects.roadmapShowDateFields": localStorage.getItem("projects.roadmapShowDateFields") + }) + """); + + Assert.Equal( + truncateTitles.ToString().ToLowerInvariant(), + storage["projects.roadmapTruncateTitles"]); + Assert.Equal( + showDateFields.ToString().ToLowerInvariant(), + storage["projects.roadmapShowDateFields"]); + } + private static async Task DeleteProjectAsync(GitHubGraphQLClient client, string projectId) { _ = await client.QueryAsync( diff --git a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs index faedf3ad..ad462380 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiLogicTests.cs @@ -285,6 +285,44 @@ public void Rendered_field_sum_observation_rejects_missing_numeric_label() Assert.Contains("Fixture Number 2", exception.Message, StringComparison.Ordinal); } + [Theory] + [InlineData(true, false, true)] + [InlineData(false, false, false)] + [InlineData(true, true, false)] + public void Rendered_roadmap_observation_requires_configured_truncation_and_date_visibility( + bool titleTruncated, + bool datesRendered, + bool expected) + { + var view = FixtureUiSnapshotFactory.Create().Views.Single(candidate => candidate.Name == "Fixture Roadmap"); + + if (expected) + { + FieldSumRenderingObserver.ValidateRoadmapDisplayObservation(view, titleTruncated, datesRendered); + return; + } + + Assert.Throws<InvalidOperationException>( + () => FieldSumRenderingObserver.ValidateRoadmapDisplayObservation(view, titleTruncated, datesRendered)); + } + + [Fact] + public void Rendered_roadmap_observation_rejects_dates_when_the_view_hides_them() + { + var view = FixtureUiSnapshotFactory.Create().Views.Single( + candidate => candidate.Name == "Fixture Roadmap Dates Hidden"); + + FieldSumRenderingObserver.ValidateRoadmapDisplayObservation( + view, + titleTruncated: true, + datesRendered: false); + Assert.Throws<InvalidOperationException>( + () => FieldSumRenderingObserver.ValidateRoadmapDisplayObservation( + view, + titleTruncated: true, + datesRendered: true)); + } + [Fact] public void Persistence_check_accepts_saved_grouping_slice_and_unordered_field_sums() { @@ -352,6 +390,62 @@ public void Persistence_check_reports_board_column_loss() Assert.StartsWith("column-by expected", difference, StringComparison.Ordinal); } + [Fact] + public void Persistence_check_reports_roadmap_display_option_loss_independently() + { + var view = View("Roadmap", "ROADMAP_LAYOUT") with + { + Ui = new ViewUiSnapshot + { + Roadmap = new RoadmapSettingsSnapshot + { + TruncateTitles = true, + ShowDateFields = false, + }, + }, + }; + var persisted = new ViewUiImporter.PersistedViewSettings( + GroupBy: null, + ColumnBy: null, + SliceBy: null, + FieldSumAvailable: false, + FieldSum: [], + TruncateTitles: false, + ShowDateFields: null); + + var differences = ViewUiImporter.CollectPersistenceDifferences(view, persisted); + + Assert.Contains("truncate-titles expected 'true', actual 'false'", differences); + Assert.Contains("show-date-fields expected 'false', actual 'unavailable'", differences); + Assert.Equal(2, differences.Count); + } + + [Fact] + public void Persistence_check_skips_only_uncaptured_roadmap_display_options() + { + var view = View("Roadmap", "ROADMAP_LAYOUT") with + { + Ui = new ViewUiSnapshot + { + Roadmap = new RoadmapSettingsSnapshot + { + TruncateTitles = null, + ShowDateFields = null, + }, + }, + }; + var persisted = new ViewUiImporter.PersistedViewSettings( + GroupBy: null, + ColumnBy: null, + SliceBy: null, + FieldSumAvailable: false, + FieldSum: [], + TruncateTitles: false, + ShowDateFields: true); + + Assert.Empty(ViewUiImporter.CollectPersistenceDifferences(view, persisted)); + } + [Theory] [InlineData(true, false, true, true)] [InlineData(false, true, true, true)] @@ -373,11 +467,17 @@ public void FixtureUiSnapshotFactory_creates_importable_standard_views_and_workf var snapshot = FixtureUiSnapshotFactory.Create("fixture-repo"); Assert.Equal( - ["View 1", "Fixture Board", "Fixture Roadmap", "Fixture Empty Sums"], + ["View 1", "Fixture Board", "Fixture Roadmap", "Fixture Empty Sums", "Fixture Roadmap Dates Hidden"], snapshot.Views.Select(v => v.Name)); Assert.Equal( - ["Fixture Roadmap", "View 1", "Fixture Board", "Fixture Empty Sums"], + ["Fixture Roadmap", "View 1", "Fixture Board", "Fixture Empty Sums", "Fixture Roadmap Dates Hidden"], snapshot.Views.OrderBy(view => view.TabPosition).Select(view => view.Name)); + var roadmap = Assert.Single(snapshot.Views, view => view.Name == "Fixture Roadmap").Ui!.Roadmap!; + Assert.True(roadmap.TruncateTitles); + Assert.False(roadmap.ShowDateFields); + var datesHidden = Assert.Single(snapshot.Views, view => view.Name == "Fixture Roadmap Dates Hidden").Ui!.Roadmap!; + Assert.True(datesHidden.TruncateTitles); + Assert.False(datesHidden.ShowDateFields); Assert.Equal( ["Count", "Fixture Number", "Fixture Number 2"], snapshot.Views.Single(view => view.Name == "View 1").Ui!.FieldSum); @@ -395,6 +495,47 @@ public void FixtureUiSnapshotFactory_creates_importable_standard_views_and_workf Assert.Empty(WorkflowUiImporter.CollectPreflightWarnings(snapshot, WorkflowUiImporter.DefaultMaxAutoAddWorkflows)); } + [Fact] + public void FixtureUiSnapshotFactory_creates_project_shared_roadmap_display_drift() + { + var standard = Assert.Single( + FixtureUiSnapshotFactory.Create().Views, + view => view.Name == "Fixture Roadmap").Ui!.Roadmap!; + var drift = Assert.Single( + FixtureUiSnapshotFactory.CreateRoadmapDisplayDrift().Views, + view => view.Name == "Fixture Roadmap").Ui!.Roadmap!; + var dateDrift = Assert.Single( + FixtureUiSnapshotFactory.CreateRoadmapDateDisplayDrift().Views, + view => view.Name == "Fixture Roadmap").Ui!.Roadmap!; + + Assert.True(standard.TruncateTitles); + Assert.False(standard.ShowDateFields); + Assert.False(drift.TruncateTitles); + Assert.False(drift.ShowDateFields); + Assert.True(dateDrift.TruncateTitles); + Assert.True(dateDrift.ShowDateFields); + } + + [Fact] + public void Shared_roadmap_display_settings_reject_conflicting_view_values() + { + var snapshot = FixtureUiSnapshotFactory.Create(); + ViewUiImporter.ValidateSharedRoadmapDisplaySettings(snapshot.Views); + var conflicting = snapshot.Views.Select(view => + view.Name == "Fixture Roadmap Dates Hidden" + ? view with + { + Ui = view.Ui! with + { + Roadmap = view.Ui.Roadmap! with { ShowDateFields = true }, + }, + } + : view).ToList(); + + Assert.Throws<InvalidOperationException>( + () => ViewUiImporter.ValidateSharedRoadmapDisplaySettings(conflicting)); + } + [Fact] public void FixtureUiSnapshotFactory_field_sum_drift_only_changes_View_1_field_sum() { @@ -557,6 +698,33 @@ await ViewUiImporter.ApplyTabOrderRecoverablyAsync( Assert.Contains("forced DOM read failure", warning, StringComparison.Ordinal); } + [Fact] + public async Task Roadmap_recoverable_failure_saves_partial_browser_storage_state() + { + var warnings = new List<string>(); + var partialWriteApplied = false; + var savedPartialWrite = false; + + await ViewUiImporter.ApplyRoadmapDisplayWriteRecoverablyAsync( + () => + { + partialWriteApplied = true; + throw new InvalidOperationException("forced read-back failure"); + }, + () => + { + savedPartialWrite = partialWriteApplied; + return Task.CompletedTask; + }, + warnings, + "Fixture Roadmap"); + + Assert.True(savedPartialWrite); + var warning = Assert.Single(warnings); + Assert.Contains("Fixture Roadmap", warning, StringComparison.Ordinal); + Assert.Contains("forced read-back failure", warning, StringComparison.Ordinal); + } + [Fact] public async Task Tab_order_poll_retries_an_incomplete_connection_until_all_mapped_views_are_visible() { @@ -673,6 +841,70 @@ public void Verifier_reports_field_sum_differences_for_every_layout(string layou Assert.Equal(VerifyStatus.Mismatch, report.Status); } + [Theory] + [InlineData(true, true, false, true, "truncate titles mismatch")] + [InlineData(false, true, false, false, "show date fields mismatch")] + public void Verifier_reports_each_roadmap_display_option_difference( + bool sourceTruncateTitles, + bool sourceShowDateFields, + bool targetTruncateTitles, + bool targetShowDateFields, + string expectedMessage) + { + var source = View("Roadmap", "ROADMAP_LAYOUT") with + { + Ui = new ViewUiSnapshot + { + Roadmap = new RoadmapSettingsSnapshot + { + TruncateTitles = sourceTruncateTitles, + ShowDateFields = sourceShowDateFields, + }, + }, + }; + var target = source with + { + Ui = new ViewUiSnapshot + { + Roadmap = new RoadmapSettingsSnapshot + { + TruncateTitles = targetTruncateTitles, + ShowDateFields = targetShowDateFields, + }, + }, + }; + + var difference = Assert.Single( + ProjectVerifier.Compare(Snapshot([], source), Snapshot([], target)).Differences, + difference => difference.Category == "View"); + + Assert.Contains(expectedMessage, difference.Message, StringComparison.Ordinal); + } + + [Fact] + public void Verifier_skips_only_uncaptured_roadmap_display_options() + { + var source = View("Roadmap", "ROADMAP_LAYOUT") with + { + Ui = new ViewUiSnapshot { Roadmap = new RoadmapSettingsSnapshot() }, + }; + var target = source with + { + Ui = new ViewUiSnapshot + { + Roadmap = new RoadmapSettingsSnapshot + { + TruncateTitles = true, + ShowDateFields = false, + }, + }, + }; + + var report = ProjectVerifier.Compare(Snapshot([], source), Snapshot([], target)); + + Assert.DoesNotContain(report.Differences, difference => difference.Category == "View"); + } + [Fact] public void Verifier_marks_ui_not_verified_when_one_side_has_no_ui() { @@ -697,6 +929,8 @@ public void Verifier_marks_ui_not_verified_when_one_side_has_no_ui() TargetField = "Fixture Sprint end", Zoom = "Month", Markers = ["Fixture Sprint"], + TruncateTitles = true, + ShowDateFields = false, }, ScrapedAt = new DateTimeOffset(2026, 7, 5, 0, 0, 0, TimeSpan.Zero), }; @@ -721,7 +955,10 @@ private static ViewSnapshot View( private static ProjectSnapshot Snapshot(IReadOnlyList<string> fields, params ViewSnapshot[] views) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false, Template = false }, Fields = [.. fields.Select(f => new FieldSnapshot { Name = f, DataType = "TEXT" })], Views = views, Workflows = [], diff --git a/tests/Ghpmv.Browser.Tests/ViewUiSnapshotSerializationTests.cs b/tests/Ghpmv.Browser.Tests/ViewUiSnapshotSerializationTests.cs index 9244d434..17441e18 100644 --- a/tests/Ghpmv.Browser.Tests/ViewUiSnapshotSerializationTests.cs +++ b/tests/Ghpmv.Browser.Tests/ViewUiSnapshotSerializationTests.cs @@ -16,7 +16,10 @@ public async Task Ui_settings_round_trip_through_snapshot_file() var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false, Template = false }, Fields = [], Views = [ @@ -40,6 +43,8 @@ public async Task Ui_settings_round_trip_through_snapshot_file() TargetField = "Fixture Sprint end", Zoom = "Month", Markers = ["Fixture Sprint"], + TruncateTitles = true, + ShowDateFields = false, }, ScrapedAt = scrapedAt, }, @@ -92,10 +97,70 @@ public async Task Ui_settings_round_trip_through_snapshot_file() Assert.Equal("Fixture Sprint end", roadmap.Ui.Roadmap.TargetField); Assert.Equal("Month", roadmap.Ui.Roadmap.Zoom); Assert.Equal(["Fixture Sprint"], roadmap.Ui.Roadmap.Markers); + Assert.True(roadmap.Ui.Roadmap.TruncateTitles); + Assert.False(roadmap.Ui.Roadmap.ShowDateFields); Assert.Null(loaded.Views[1].Ui); Assert.Empty(loaded.Views[2].Ui!.FieldSum!); } + + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Roadmap_display_options_round_trip_all_boolean_combinations( + bool truncateTitles, + bool showDateFields) + { + var snapshot = new ProjectSnapshot + { + SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false, Template = false }, + Fields = [], + Views = + [ + new ViewSnapshot + { + Number = 1, + Name = "Roadmap", + Layout = "ROADMAP_LAYOUT", + GroupByFields = [], + SortByFields = [], + VerticalGroupByFields = [], + VisibleFields = [], + Ui = new ViewUiSnapshot + { + Roadmap = new RoadmapSettingsSnapshot + { + TruncateTitles = truncateTitles, + ShowDateFields = showDateFields, + }, + }, + }, + ], + Workflows = [], + Items = [], + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + }; + var directory = Path.Combine(Path.GetTempPath(), "ghpmv-browser-tests-" + Guid.NewGuid().ToString("N")); + try + { + await SnapshotFile.SaveAsync(snapshot, directory, TestContext.Current.CancellationToken); + var loaded = await SnapshotFile.LoadAsync(directory, TestContext.Current.CancellationToken); + var roadmap = Assert.Single(loaded.Views).Ui!.Roadmap!; + + Assert.Equal(truncateTitles, roadmap.TruncateTitles); + Assert.Equal(showDateFields, roadmap.ShowDateFields); + } finally { Directory.Delete(directory, recursive: true); @@ -109,7 +174,10 @@ public async Task Workflow_ui_settings_round_trip_through_snapshot_file() var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = diff --git a/tests/Ghpmv.Browser.Tests/WorkflowUiLogicTests.cs b/tests/Ghpmv.Browser.Tests/WorkflowUiLogicTests.cs index 213aaccb..251777ac 100644 --- a/tests/Ghpmv.Browser.Tests/WorkflowUiLogicTests.cs +++ b/tests/Ghpmv.Browser.Tests/WorkflowUiLogicTests.cs @@ -202,7 +202,10 @@ private static WorkflowSnapshot AutoAdd(string name, string repository) private static ProjectSnapshot Snapshot(params WorkflowSnapshot[] workflows) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = workflows.Select((w, i) => w with { Number = i + 1 }).ToList(), diff --git a/tests/Ghpmv.Core.Tests/CliImportTests.cs b/tests/Ghpmv.Core.Tests/CliImportTests.cs index e92d1561..95909ceb 100644 --- a/tests/Ghpmv.Core.Tests/CliImportTests.cs +++ b/tests/Ghpmv.Core.Tests/CliImportTests.cs @@ -109,31 +109,6 @@ public async Task Verify_categories_limits_comparison_and_api_sections() } } - [Fact] - public async Task Verify_explicit_status_update_category_fails_for_legacy_snapshot() - { - var cancellationToken = TestContext.Current.CancellationToken; - var directory = Path.Combine(Path.GetTempPath(), "ghpmv-cli-verify-legacy-status-" + Guid.NewGuid().ToString("N")); - await SnapshotFile.SaveAsync(VerifySnapshot(), directory, cancellationToken); - - using var server = new GraphQlStubServer( - VerifyProjectResponse, - VerifyStatusUpdatesResponse); - try - { - var result = await RunVerifyCliAsync(directory, server, "--categories", "StatusUpdate"); - - Assert.Equal(1, result.ExitCode); - Assert.Equal(2, server.RequestBodies.Count); - Assert.Contains("StatusUpdate: NotVerified", result.Output, StringComparison.Ordinal); - Assert.Contains("1 not verified", result.Output, StringComparison.Ordinal); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } - [Fact] public async Task Conflict_skip_with_browser_automation_does_not_run_downstream_importers() { @@ -250,7 +225,8 @@ public async Task Conflict_update_emits_stable_result_and_applies_project_mutati using var server = new GraphQlStubServer( ExistingProjectResponse, UpdateProjectResponse, - EmptyFieldsResponse); + EmptyFieldsResponse, + NonTemplateProjectResponse); try { var result = await RunCliAsync(directory, server, "--on-conflict", "update"); @@ -266,7 +242,7 @@ public async Task Conflict_update_emits_stable_result_and_applies_project_mutati result.Output, StringComparison.Ordinal); Assert.Contains("views: imported=0 warnings=0", result.Output, StringComparison.Ordinal); - Assert.Equal(3, server.RequestBodies.Count); + Assert.Equal(4, server.RequestBodies.Count); Assert.Single(server.RequestBodies, request => request.Contains("mutation", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(server.RequestBodies, request => @@ -314,7 +290,8 @@ public async Task Successful_created_project_import_restores_requested_conflict_ OwnerResponse, CreateProjectResponse, UpdateCreatedProjectResponse, - EmptyFieldsResponse)) + EmptyFieldsResponse, + NonTemplateProjectResponse)) { var created = await RunCliAsync(directory, createServer); @@ -438,7 +415,8 @@ public async Task Incomplete_item_log_forces_update_on_default_retry() ExistingProjectResponse, UpdateProjectResponse, EmptyFieldsResponse, - PositionResponse); + PositionResponse, + NonTemplateProjectResponse); try { var result = await RunCliAsync(directory, server); @@ -548,14 +526,19 @@ public async Task Import_marks_and_restores_the_template_only_when_the_snapshot_ var withoutDirectory = Path.Combine(Path.GetTempPath(), "ghpmv-cli-template-none-" + Guid.NewGuid().ToString("N")); var withDirectory = Path.Combine(Path.GetTempPath(), "ghpmv-cli-template-some-" + Guid.NewGuid().ToString("N")); await SnapshotFile.SaveAsync(MinimalSnapshot() with { StatusUpdates = [] }, withoutDirectory, cancellationToken); - await SnapshotFile.SaveAsync(SnapshotWithStatusUpdates(), withDirectory, cancellationToken); + var templateSnapshot = SnapshotWithStatusUpdates(); + await SnapshotFile.SaveAsync( + templateSnapshot with { Project = templateSnapshot.Project with { Template = true } }, + withDirectory, + cancellationToken); try { using (var withoutServer = new GraphQlStubServer( ExistingProjectResponse, UpdateProjectResponse, - EmptyFieldsResponse)) + EmptyFieldsResponse, + NonTemplateProjectResponse)) { var withoutResult = await RunCliAsync(withoutDirectory, withoutServer, "--on-conflict", "update"); @@ -565,8 +548,7 @@ public async Task Import_marks_and_restores_the_template_only_when_the_snapshot_ withoutResult.Output, StringComparison.Ordinal); - // An empty status-update list must not even probe the template state. - Assert.Equal(3, withoutServer.RequestBodies.Count); + Assert.Equal(4, withoutServer.RequestBodies.Count); Assert.DoesNotContain(withoutServer.RequestBodies, request => request.Contains("ProjectV2AsTemplate", StringComparison.Ordinal)); Assert.DoesNotContain(withoutServer.RequestBodies, request => @@ -602,11 +584,15 @@ public async Task Import_marks_and_restores_the_template_only_when_the_snapshot_ } [Fact] - public async Task Import_restores_the_template_after_downstream_importers() + public async Task Import_applies_the_requested_template_state_after_downstream_importers() { var cancellationToken = TestContext.Current.CancellationToken; var directory = Path.Combine(Path.GetTempPath(), "ghpmv-cli-template-order-" + Guid.NewGuid().ToString("N")); - await SnapshotFile.SaveAsync(SnapshotWithStatusUpdates(), directory, cancellationToken); + var snapshot = SnapshotWithStatusUpdates(); + await SnapshotFile.SaveAsync( + snapshot with { Project = snapshot.Project with { Template = true } }, + directory, + cancellationToken); using var server = new GraphQlStubServer( ExistingProjectResponse, @@ -634,14 +620,14 @@ public async Task Import_restores_the_template_after_downstream_importers() Assert.True(unmarkIndex >= 0 && unmarkIndex < createIndex); Assert.True(createIndex < markIndex); - // Restoration is the final orchestration stage: nothing is sent afterwards. + // The requested final template state is the last orchestration stage. Assert.Equal(server.RequestBodies.Count - 1, markIndex); Assert.Contains( "Temporarily unmarking the target project as a template before status update writes...", result.Error, StringComparison.Ordinal); Assert.Contains( - "Restoring the target project's template state as the final import stage...", + "Marking the target project as a template as the final import stage...", result.Error, StringComparison.Ordinal); Assert.Contains( @@ -738,7 +724,11 @@ public async Task Import_reports_a_template_restore_failure_on_stderr_and_fails_ { var cancellationToken = TestContext.Current.CancellationToken; var directory = Path.Combine(Path.GetTempPath(), "ghpmv-cli-template-restore-fail-" + Guid.NewGuid().ToString("N")); - await SnapshotFile.SaveAsync(SnapshotWithStatusUpdates(), directory, cancellationToken); + var snapshot = SnapshotWithStatusUpdates(); + await SnapshotFile.SaveAsync( + snapshot with { Project = snapshot.Project with { Template = true } }, + directory, + cancellationToken); using var server = new GraphQlStubServer( ExistingProjectResponse, @@ -908,11 +898,15 @@ private static bool IsUnmarkTemplateMutation(string requestBody) private static ProjectSnapshot MinimalSnapshot() => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], @@ -923,11 +917,15 @@ private static bool IsUnmarkTemplateMutation(string requestBody) private static ProjectSnapshot SnapshotWithDownstreamContent() => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false, + Template = false, }, Fields = [], Views = diff --git a/tests/Ghpmv.Core.Tests/FixtureProjectBuilderTests.cs b/tests/Ghpmv.Core.Tests/FixtureProjectBuilderTests.cs index 348e9b2c..3f41eb24 100644 --- a/tests/Ghpmv.Core.Tests/FixtureProjectBuilderTests.cs +++ b/tests/Ghpmv.Core.Tests/FixtureProjectBuilderTests.cs @@ -374,39 +374,6 @@ public async Task Fixture_item_warnings_keep_the_operation_incomplete() } } - [Fact] - public async Task Legacy_item_log_project_id_is_persisted_for_completion() - { - var directory = Directory.CreateTempSubdirectory("ghpmv-fixture-legacy-project-id-").FullName; - try - { - var projectLog = new ProjectImportLog { ImportCompleted = false }; - await projectLog.SaveAsync(directory, TestContext.Current.CancellationToken); - var itemLog = new ImportLog - { - ProjectId = "PVT_legacy", - SourceSnapshotFingerprint = "fingerprint", - }; - - var changed = await FixtureProjectBuilder.PersistLegacyProjectIdAsync( - projectLog, - itemLog, - directory, - TestContext.Current.CancellationToken); - - Assert.True(changed); - Assert.Equal( - "PVT_legacy", - (await ProjectImportLog.LoadAsync( - directory, - TestContext.Current.CancellationToken)).CreatedProjectId); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } - [Fact] public void Demo_fixture_exercises_every_snapshot_field_pattern() { @@ -467,13 +434,11 @@ public async Task Dynamic_fixture_reference_date_is_persisted_across_retries() var first = await FixtureProjectBuilder.ResolveFixtureReferenceDateAsync( operationDirectory, useCurrentWeek: true, - hasPriorOperationState: false, currentDate: new DateOnly(2026, 8, 20), TestContext.Current.CancellationToken); var retry = await FixtureProjectBuilder.ResolveFixtureReferenceDateAsync( operationDirectory, useCurrentWeek: true, - hasPriorOperationState: true, currentDate: new DateOnly(2026, 9, 7), TestContext.Current.CancellationToken); @@ -492,24 +457,32 @@ await File.ReadAllTextAsync( } [Fact] - public async Task Existing_fixture_operation_without_reference_date_keeps_legacy_dates() + public void New_e2e_fixture_adds_a_long_dated_roadmap_item_without_changing_the_shared_fixture() { - var operationDirectory = Directory.CreateTempSubdirectory("ghpmv-fixture-legacy-reference-date-").FullName; - try - { - var referenceDate = await FixtureProjectBuilder.ResolveFixtureReferenceDateAsync( - operationDirectory, - useCurrentWeek: true, - hasPriorOperationState: true, - currentDate: new DateOnly(2026, 8, 17), - TestContext.Current.CancellationToken); + var snapshot = FixtureProjectBuilder.CreateSnapshot( + "Fixture", + "example/fixture", + "octocat", + pullRequestNumber: 2, + referenceDate: new DateOnly(2026, 8, 17)); - Assert.Equal(new DateOnly(2026, 1, 1), referenceDate); - } - finally + var extended = FixtureProjectBuilder.AddRoadmapRenderingItem(snapshot); + var item = Assert.Single( + extended.Items, + candidate => candidate.Draft?.Title == FixtureProjectBuilder.RoadmapLongTitle); + + Assert.Equal(snapshot.Items.Count + 1, extended.Items.Count); + Assert.DoesNotContain( + snapshot.Items, + candidate => candidate.Draft?.Title == FixtureProjectBuilder.RoadmapLongTitle); + Assert.Contains(item.FieldValues, value => value.FieldName == "Fixture Date" && value.Date is not null); + Assert.Contains(item.FieldValues, value => value.FieldName == "Fixture Sprint" && value.IterationTitle is not null); + Assert.True(FixtureProjectBuilder.IsRoadmapRenderingItem(item)); + Assert.False(FixtureProjectBuilder.IsRoadmapRenderingItem(item with { IsArchived = true })); + Assert.False(FixtureProjectBuilder.IsRoadmapRenderingItem(item with { - Directory.Delete(operationDirectory, recursive: true); - } + FieldValues = item.FieldValues.Where(value => value.FieldName != "Fixture Date").ToList(), + })); } [Fact] @@ -624,63 +597,6 @@ public void Status_only_import_log_does_not_resume_the_fixture_item_stage() projectImportWasPending: false)); } - [Fact] - public void Legacy_fixture_log_rebinds_to_the_status_update_snapshot_without_losing_item_state() - { - var snapshot = FixtureProjectBuilder.CreateSnapshot( - "Fixture", - "example/fixture", - "octocat", - pullRequestNumber: 2); - var legacySnapshot = snapshot with { StatusUpdates = null }; - var legacyLog = new ImportLog - { - ProjectId = "PVT_fixture", - SourceSnapshotFingerprint = ImportLog.ComputeSnapshotFingerprint(legacySnapshot), - }; - legacyLog.Items["0"] = "PVTI_existing"; - legacyLog.ItemStates["draft:0"] = new ImportItemState - { - TargetItemId = "PVTI_existing", - TargetContentIdentity = "draft", - }; - - var upgraded = FixtureProjectBuilder.UpgradeLegacyFixtureLog(legacyLog, snapshot); - - Assert.NotNull(upgraded); - Assert.Equal(ImportLog.ComputeSnapshotFingerprint(snapshot), upgraded.SourceSnapshotFingerprint); - Assert.Equal("PVTI_existing", upgraded.Items["0"]); - Assert.Equal("PVTI_existing", upgraded.ItemStates["draft:0"].TargetItemId); - Assert.Empty(upgraded.StatusUpdates); - Assert.Empty(upgraded.PendingStatusUpdates); - } - - [Fact] - public void Legacy_fixture_log_rebinds_to_the_template_snapshot_with_completed_status_updates() - { - var snapshot = FixtureProjectBuilder.CreateSnapshot( - "Fixture", - "example/fixture", - "octocat", - pullRequestNumber: 2); - var legacySnapshot = snapshot with - { - Project = snapshot.Project with { Template = null }, - }; - var legacyLog = new ImportLog - { - ProjectId = "PVT_fixture", - SourceSnapshotFingerprint = ImportLog.ComputeSnapshotFingerprint(legacySnapshot), - }; - legacyLog.StatusUpdates["0"] = "PVTSU_existing"; - - var upgraded = FixtureProjectBuilder.UpgradeLegacyFixtureLog(legacyLog, snapshot); - - Assert.NotNull(upgraded); - Assert.Equal(ImportLog.ComputeSnapshotFingerprint(snapshot), upgraded.SourceSnapshotFingerprint); - Assert.Equal("PVTSU_existing", upgraded.StatusUpdates["0"]); - } - [Fact] public void Fixture_status_history_matching_finds_complete_ordered_subsequence_among_unrelated_entries() { @@ -1060,39 +976,6 @@ public async Task Require_new_resources_resumes_project_owned_by_operation() } } - [Fact] - public async Task Require_new_resources_resumes_project_from_legacy_operation_key() - { - var logRoot = Directory.CreateTempSubdirectory("ghpmv-fixture-legacy-project-resume-").FullName; - try - { - var operationDirectory = GetLegacyOperationDirectory(logRoot, "example", "Fixture", "fixture"); - await new ProjectImportLog { CreatedProjectId = "PVT_1" } - .SaveAsync(operationDirectory, TestContext.Current.CancellationToken); - using var graphQlHandler = new RecordingHandler( - JsonResponse( - """ - {"data":{"organization":{"projectsV2":{"nodes":[{"id":"PVT_1","number":1,"title":"Fixture","url":"https://github.com/orgs/example/projects/1"}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}} - """), - JsonResponse("""{"data":{"viewer":{"login":"octocat"}}}""")); - using var restHandler = new RecordingHandler(JsonResponse("""{"id":1,"name":"fixture"}""")); - using var graphQl = new GitHubGraphQLClient("token", baseUrl: null, graphQlHandler, (_, _) => Task.CompletedTask); - using var rest = new GitHubRestClient("token", baseUri: null, restHandler); - var builder = CreateRequireNewBuilder(graphQl, rest, operationLogDirectory: logRoot); - - var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => - builder.CreateAsync("example", "Fixture", "fixture", TestContext.Current.CancellationToken)); - - Assert.Equal("Fixture repository 'example/fixture' already exists.", exception.Message); - Assert.DoesNotContain(graphQlHandler.RequestBodies, body => body.Contains("mutation", StringComparison.Ordinal)); - Assert.Equal([HttpMethod.Get], restHandler.RequestMethods); - } - finally - { - Directory.Delete(logRoot, recursive: true); - } - } - [Fact] public async Task Require_new_resources_rejects_unrelated_duplicate_of_owned_project() { @@ -1959,19 +1842,6 @@ private static string GetOperationDirectory( return Path.Combine(logRoot, operationKey); } - private static string GetLegacyOperationDirectory( - string logRoot, - string organization, - string title, - string repositoryName) - { - var operationKey = Convert.ToHexString( - SHA256.HashData(Encoding.UTF8.GetBytes( - $"{organization}\n{title}\n{repositoryName}")))[..16] - .ToLowerInvariant(); - return Path.Combine(logRoot, operationKey); - } - private static string GetRepositoryLockPath( string logRoot, string repositoryFullName, diff --git a/tests/Ghpmv.Core.Tests/ImportCapabilityTests.cs b/tests/Ghpmv.Core.Tests/ImportCapabilityTests.cs index ce872fc2..870de79c 100644 --- a/tests/Ghpmv.Core.Tests/ImportCapabilityTests.cs +++ b/tests/Ghpmv.Core.Tests/ImportCapabilityTests.cs @@ -455,11 +455,15 @@ public void Project_preflight_rejects_missing_admin_for_privileged_changes( private static ProjectSnapshot MinimalSnapshot() => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = "Project", Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], diff --git a/tests/Ghpmv.Core.Tests/ItemImporterLogicTests.cs b/tests/Ghpmv.Core.Tests/ItemImporterLogicTests.cs index 0db6b250..84ea5a1b 100644 --- a/tests/Ghpmv.Core.Tests/ItemImporterLogicTests.cs +++ b/tests/Ghpmv.Core.Tests/ItemImporterLogicTests.cs @@ -80,7 +80,10 @@ public async Task Import_synchronizes_present_and_absent_issue_field_values() var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false, Template = false }, Fields = [ new FieldSnapshot @@ -217,7 +220,10 @@ public async Task Import_applies_ordinary_multi_select_value_with_target_option_ var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false, Template = false }, Fields = [ new FieldSnapshot @@ -388,11 +394,15 @@ public async Task Import_rejects_target_switch_when_log_has_pending_operations() var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = "Snapshot", Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], @@ -450,7 +460,10 @@ public async Task Import_rejects_changed_snapshot_without_mutating_log_or_target var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Snapshot", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Snapshot", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Core.Tests/ItemImporterResumeTests.cs b/tests/Ghpmv.Core.Tests/ItemImporterResumeTests.cs index 01d09f21..bbeec904 100644 --- a/tests/Ghpmv.Core.Tests/ItemImporterResumeTests.cs +++ b/tests/Ghpmv.Core.Tests/ItemImporterResumeTests.cs @@ -684,7 +684,10 @@ private static ProjectSnapshot CreateSnapshot(bool draft, bool assignedDraft) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Project", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Project", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], @@ -720,7 +723,10 @@ private static ProjectSnapshot CreateStageSnapshot(bool archived, bool withField => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Project", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Project", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Core.Tests/MappingTemplatesTests.cs b/tests/Ghpmv.Core.Tests/MappingTemplatesTests.cs index b2c25bf9..64625bb6 100644 --- a/tests/Ghpmv.Core.Tests/MappingTemplatesTests.cs +++ b/tests/Ghpmv.Core.Tests/MappingTemplatesTests.cs @@ -247,7 +247,10 @@ private static ProjectSnapshot SnapshotWithItems(params ItemSnapshot[] items) private static ProjectSnapshot SnapshotWithItems(IReadOnlyList<CollaboratorSnapshot>? collaborators, params ItemSnapshot[] items) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Core.Tests/ProjectFilterTransformerTests.cs b/tests/Ghpmv.Core.Tests/ProjectFilterTransformerTests.cs index 2780b1bc..378dc0bb 100644 --- a/tests/Ghpmv.Core.Tests/ProjectFilterTransformerTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectFilterTransformerTests.cs @@ -197,7 +197,10 @@ public void TransformSnapshot_maps_view_and_workflow_filters() private static ProjectSnapshot Snapshot(string viewFilter, string workflowFilter) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "t", Public = false, Closed = false, Template = false }, Fields = [], Views = [ diff --git a/tests/Ghpmv.Core.Tests/ProjectImporterLogicTests.cs b/tests/Ghpmv.Core.Tests/ProjectImporterLogicTests.cs index aa12864e..da162baa 100644 --- a/tests/Ghpmv.Core.Tests/ProjectImporterLogicTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectImporterLogicTests.cs @@ -1157,6 +1157,9 @@ await importer.ImportIntoAsync( private static ProjectSnapshot MinimalSnapshot(string title) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = title, @@ -1164,6 +1167,7 @@ await importer.ImportIntoAsync( Readme = "must not be applied", Public = true, Closed = true, + Template = false, }, Fields = [], Views = [], diff --git a/tests/Ghpmv.Core.Tests/ProjectImporterResumeTests.cs b/tests/Ghpmv.Core.Tests/ProjectImporterResumeTests.cs index a52061ee..266d05f1 100644 --- a/tests/Ghpmv.Core.Tests/ProjectImporterResumeTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectImporterResumeTests.cs @@ -814,7 +814,10 @@ private static GitHubGraphQLClient CreateClient(HttpMessageHandler handler) private static ProjectSnapshot Snapshot(bool withField = false) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Project", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Project", Public = false, Closed = false, Template = false }, Fields = withField ? [new FieldSnapshot { Name = "Custom", DataType = "TEXT" }] : [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Core.Tests/ProjectTemplateWriteSessionTests.cs b/tests/Ghpmv.Core.Tests/ProjectTemplateWriteSessionTests.cs index c885adba..06948302 100644 --- a/tests/Ghpmv.Core.Tests/ProjectTemplateWriteSessionTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectTemplateWriteSessionTests.cs @@ -359,7 +359,9 @@ public async Task Apply_snapshot_does_not_touch_template_state() var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false }, + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs index 99d3e292..37a7d4fc 100644 --- a/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs +++ b/tests/Ghpmv.Core.Tests/ProjectVerifierTests.cs @@ -20,6 +20,7 @@ public class ProjectVerifierTests private static ProjectSnapshot BuildSnapshot() => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], Project = new ProjectInfoSnapshot { Title = "Fixture", @@ -27,6 +28,7 @@ public class ProjectVerifierTests Readme = "# Readme", Public = false, Closed = false, + Template = false, }, Fields = [ @@ -463,19 +465,6 @@ difference is } && difference.Message.Contains("template state mismatch", StringComparison.Ordinal)); } - [Fact] - public void Legacy_null_template_state_is_not_compared() - { - var source = BuildSnapshot(); - var target = source with { Project = source.Project with { Template = true } }; - - var report = ProjectVerifier.Compare(source, target); - - Assert.DoesNotContain(report.Differences, difference => - difference.Category == "Project" - && difference.Message.Contains("template", StringComparison.Ordinal)); - } - // ----- fields ----- [Fact] @@ -1500,17 +1489,6 @@ public void Team_mapping_normalizes_matching_explicit_team_collaborator() Assert.True(report.IsMatch); } - [Fact] - public void Team_links_are_not_verified_for_legacy_null_capture() - { - var report = ProjectVerifier.Compare( - BuildSnapshot() with { LinkedTeams = null }, - BuildSnapshot()); - - Assert.Contains(report.Categories, category => - category.Category == "TeamLink" && category.Status == VerifyStatus.NotVerified); - } - [Fact] public void Repository_mapping_normalizes_items_linked_repositories_and_workflow_ui_repositories() { @@ -1594,35 +1572,6 @@ source.Workflows[0] with Assert.True(report.IsMatch); } - [Fact] - public void Linked_repositories_are_not_verified_when_the_source_predates_capture() - { - var target = BuildSnapshot() with { LinkedRepositories = ["org/repo-a"] }; - - var source = BuildSnapshot() with { LinkedRepositories = null }; - var report = ProjectVerifier.Compare(source, target); - - Assert.Equal(VerifyStatus.NotVerified, report.Status); - Assert.Contains(report.Categories, category => - category.Category == "LinkedRepository" && category.Status == VerifyStatus.NotVerified); - } - - [Fact] - public void Linked_repositories_are_not_verified_when_target_capture_is_missing() - { - var source = BuildSnapshot() with { LinkedRepositories = ["org/repo-a"] }; - var target = BuildSnapshot() with { LinkedRepositories = null }; - - var report = ProjectVerifier.Compare(source, target); - - Assert.Equal(VerifyStatus.NotVerified, report.Status); - Assert.DoesNotContain(report.Differences, difference => difference.Severity == VerifySeverity.Error); - Assert.Contains(report.Differences, difference => - difference.Severity == VerifySeverity.Warning - && difference.Category == "LinkedRepository" - && difference.Message.Contains("could not be read", StringComparison.Ordinal)); - } - [Fact] public void Exit_policy_fails_errors_unconditionally_and_optional_incomplete_results() { @@ -1715,46 +1664,6 @@ private static IReadOnlyList<StatusUpdateSnapshot> SourceStatusUpdates() => private static IReadOnlyList<StatusUpdateSnapshot> ImportedStatusUpdates(IReadOnlyList<StatusUpdateSnapshot> source) => [.. source.Select(update => update with { Body = StatusUpdateImporter.BuildImportedBody(update) })]; - [Fact] - public void Status_updates_are_not_compared_when_the_source_predates_capture() - { - // BuildSnapshot() predates status update capture (StatusUpdates is null), so the - // category is omitted entirely rather than reported as NotVerified. - var source = BuildSnapshot(); - Assert.Null(source.StatusUpdates); - var target = BuildSnapshot() with { StatusUpdates = SourceStatusUpdates() }; - - var report = ProjectVerifier.Compare(source, target); - - Assert.DoesNotContain(report.Categories, category => category.Category == "StatusUpdate"); - Assert.DoesNotContain(report.Differences, difference => difference.Category == "StatusUpdate"); - Assert.Equal(0, report.NotVerifiedCount); - Assert.True(report.IsMatch); - - var baseline = ProjectVerifier.Compare(BuildSnapshot(), BuildSnapshot()); - Assert.Equal( - baseline.Categories.Select(category => category.Category), - report.Categories.Select(category => category.Category)); - } - - [Fact] - public void Explicit_status_update_scope_marks_legacy_snapshot_not_verified() - { - var source = BuildSnapshot(); - Assert.Null(source.StatusUpdates); - - var report = ProjectVerifier.Compare( - source, - BuildSnapshot() with { StatusUpdates = SourceStatusUpdates() }, - new HashSet<string>(StringComparer.Ordinal) { VerifyCategories.StatusUpdate }); - - var category = Assert.Single(report.Categories); - Assert.Equal(VerifyCategories.StatusUpdate, category.Category); - Assert.Equal(VerifyStatus.NotVerified, category.Status); - Assert.Equal(VerifyStatus.NotVerified, report.Status); - Assert.True(report.ShouldFail(failOnWarning: false)); - } - [Fact] public void Status_update_category_is_present_and_matches_when_sequences_align() { @@ -1926,26 +1835,6 @@ public void Status_update_creator_and_created_at_are_not_compared() Assert.True(report.IsMatch); } - [Fact] - public void Status_updates_with_null_target_collection_are_treated_as_empty() - { - var source = BuildSnapshot() with { StatusUpdates = [.. SourceStatusUpdates().Take(2)] }; - var target = BuildSnapshot() with { StatusUpdates = null }; - - var report = ProjectVerifier.Compare(source, target); - - var difference = Assert.Single( - report.Differences, - difference => difference.Category == "StatusUpdate"); - Assert.Equal(VerifySeverity.Error, difference.Severity); - Assert.Equal("status update count mismatch (source 2, target 0)", difference.Message); - - // A null target is an empty history, not an unverifiable one. - var category = Assert.Single(report.Categories, category => category.Category == "StatusUpdate"); - Assert.Equal(VerifyStatus.Mismatch, category.Status); - Assert.NotEqual(VerifyStatus.NotVerified, category.Status); - } - [Fact] public void Status_update_category_rolls_up_to_mismatch_on_any_error() { diff --git a/tests/Ghpmv.Core.Tests/SnapshotTests.cs b/tests/Ghpmv.Core.Tests/SnapshotTests.cs index 4cc3f01d..9d4aebf7 100644 --- a/tests/Ghpmv.Core.Tests/SnapshotTests.cs +++ b/tests/Ghpmv.Core.Tests/SnapshotTests.cs @@ -290,8 +290,8 @@ public void Fields_without_default_value_deserialize_as_uncaptured() const string Json = """ { - "schemaVersion": 1, - "project": { "title": "T", "public": false, "closed": false }, + "schemaVersion": 2, + "project": { "title": "T", "public": false, "closed": false, "template": false }, "fields": [ { "name": "Text", "dataType": "TEXT" }, { "name": "Number", "dataType": "NUMBER" }, @@ -299,7 +299,10 @@ public void Fields_without_default_value_deserialize_as_uncaptured() ], "views": [], "workflows": [], - "items": [] + "items": [], + "statusUpdates": [], + "linkedRepositories": [], + "linkedTeams": [] } """; @@ -309,64 +312,13 @@ public void Fields_without_default_value_deserialize_as_uncaptured() Assert.All(restored.Fields, field => Assert.Null(field.DefaultValue)); } - [Fact] - public void Deserialize_snapshot_without_collaborators_and_linked_repositories_yields_null() - { - // Snapshots written before the collaborator/linked-repository fields stay loadable - // within schema version 1; the new fields deserialize as null ("not captured"). - const string Json = - """ - { - "schemaVersion": 1, - "project": { "title": "T", "public": false, "closed": false }, - "fields": [], "views": [], "workflows": [], "items": [] - } - """; - - var restored = JsonSerializer.Deserialize(Json, SnapshotJsonContext.Default.ProjectSnapshot); - - Assert.NotNull(restored); - Assert.Null(restored.Collaborators); - Assert.Null(restored.LinkedRepositories); - Assert.Null(restored.LinkedTeams); - } - - [Fact] - public void View_without_tab_position_remains_backward_compatible() - { - const string Json = - """ - { - "schemaVersion": 1, - "project": { "title": "T", "public": false, "closed": false }, - "fields": [], - "views": [{ - "number": 7, - "name": "Legacy", - "layout": "TABLE_LAYOUT", - "groupByFields": [], - "sortByFields": [], - "verticalGroupByFields": [], - "visibleFields": [] - }], - "workflows": [], - "items": [] - } - """; - - var restored = JsonSerializer.Deserialize(Json, SnapshotJsonContext.Default.ProjectSnapshot); - - Assert.Null(Assert.Single(restored!.Views).TabPosition); - Assert.Equal(1, restored.SchemaVersion); - } - [Fact] public void Serialized_json_contains_schema_version() { var json = JsonSerializer.Serialize(CreateFullSnapshot(), SnapshotJsonContext.Default.ProjectSnapshot); using var document = JsonDocument.Parse(json); - Assert.Equal(1, document.RootElement.GetProperty("schemaVersion").GetInt32()); + Assert.Equal(ProjectSnapshot.CurrentSchemaVersion, document.RootElement.GetProperty("schemaVersion").GetInt32()); } [Fact] @@ -417,6 +369,156 @@ public async Task SnapshotFile_saves_and_loads_snapshot_json() } } + [Fact] + public async Task SnapshotFile_rejects_an_unsupported_schema_version() + { + var directory = Directory.CreateTempSubdirectory("ghpmv-snapshot-version-").FullName; + try + { + await File.WriteAllTextAsync( + Path.Combine(directory, SnapshotFile.FileName), + """ + { + "schemaVersion": 1, + "project": { "title": "Old", "public": false, "closed": false }, + "fields": [], "views": [], "workflows": [], "items": [] + } + """, + TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync<InvalidDataException>( + () => SnapshotFile.LoadAsync(directory, TestContext.Current.CancellationToken)); + + Assert.Contains("unsupported schema version 1; expected 2", exception.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task SnapshotFile_rejects_schema_two_without_template_state() + { + var directory = Directory.CreateTempSubdirectory("ghpmv-snapshot-template-").FullName; + try + { + await File.WriteAllTextAsync( + Path.Combine(directory, SnapshotFile.FileName), + """ + { + "schemaVersion": 2, + "project": { "title": "Malformed", "public": false, "closed": false }, + "fields": [], "views": [], "workflows": [], "items": [] + } + """, + TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync<InvalidDataException>( + () => SnapshotFile.LoadAsync(directory, TestContext.Current.CancellationToken)); + + Assert.Contains("missing required boolean 'project.template'", exception.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData("statusUpdates", "null")] + [InlineData("linkedRepositories", "{}")] + [InlineData("linkedTeams", "null")] + public async Task SnapshotFile_rejects_missing_or_non_array_current_collections( + string propertyName, + string invalidValue) + { + var directory = Directory.CreateTempSubdirectory("ghpmv-snapshot-collection-").FullName; + try + { + var json = $$""" + { + "schemaVersion": 2, + "project": { "title": "Malformed", "public": false, "closed": false, "template": false }, + "fields": [], "views": [], "workflows": [], "items": [], + "statusUpdates": [], + "linkedRepositories": [], + "linkedTeams": [], + "{{propertyName}}": {{invalidValue}} + } + """; + await File.WriteAllTextAsync( + Path.Combine(directory, SnapshotFile.FileName), + json, + TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync<InvalidDataException>( + () => SnapshotFile.LoadAsync(directory, TestContext.Current.CancellationToken)); + + Assert.Contains($"missing required array '{propertyName}'", exception.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public async Task SnapshotFile_rejects_an_omitted_current_collection() + { + var directory = Directory.CreateTempSubdirectory("ghpmv-snapshot-omitted-").FullName; + try + { + await File.WriteAllTextAsync( + Path.Combine(directory, SnapshotFile.FileName), + """ + { + "schemaVersion": 2, + "project": { "title": "Malformed", "public": false, "closed": false, "template": false }, + "fields": [], "views": [], "workflows": [], "items": [], + "linkedRepositories": [], "linkedTeams": [] + } + """, + TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync<InvalidDataException>( + () => SnapshotFile.LoadAsync(directory, TestContext.Current.CancellationToken)); + + Assert.Contains("missing required array 'statusUpdates'", exception.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Theory] + [InlineData("[]", "must contain a JSON object")] + [InlineData("null", "must contain a JSON object")] + [InlineData("""{"schemaVersion":"2"}""", "missing required integer 'schemaVersion'")] + public async Task SnapshotFile_normalizes_malformed_root_and_schema_version_errors( + string json, + string expectedMessage) + { + var directory = Directory.CreateTempSubdirectory("ghpmv-snapshot-malformed-").FullName; + try + { + await File.WriteAllTextAsync( + Path.Combine(directory, SnapshotFile.FileName), + json, + TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync<InvalidDataException>( + () => SnapshotFile.LoadAsync(directory, TestContext.Current.CancellationToken)); + + Assert.Contains(expectedMessage, exception.Message, StringComparison.Ordinal); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + [Fact] public void Roundtrip_preserves_status_updates() { @@ -478,44 +580,20 @@ public void Statusless_update_round_trips_when_status_property_is_omitted() var restored = JsonSerializer.Deserialize(json, SnapshotJsonContext.Default.ProjectSnapshot); - var restoredUpdate = Assert.Single(Assert.IsType<ProjectSnapshot>(restored).StatusUpdates!); + var restoredUpdate = Assert.Single(Assert.IsType<ProjectSnapshot>(restored).StatusUpdates); Assert.Null(restoredUpdate.Status); Assert.Equal("Update without a status.", restoredUpdate.Body); } [Fact] - public void Deserialize_snapshot_without_status_updates_yields_null() - { - // Snapshots written before status update support stay loadable within schema - // version 1; the new collection deserializes as null ("not captured"). - const string Json = - """ - { - "schemaVersion": 1, - "project": { "title": "T", "public": false, "closed": false }, - "fields": [], "views": [], "workflows": [], "items": [] - } - """; - - var restored = JsonSerializer.Deserialize(Json, SnapshotJsonContext.Default.ProjectSnapshot); - - Assert.NotNull(restored); - Assert.Null(restored.Project.Template); - Assert.Null(restored.StatusUpdates); - Assert.Empty(restored.Items); - } - - [Fact] - public void Serialized_json_keeps_schema_version_one_when_status_updates_are_present() + public void Serialized_json_uses_current_schema_when_status_updates_are_present() { - // Status updates are an additive schema-v1 field: capturing them must not bump - // the version, otherwise every previously written snapshot becomes unreadable. - Assert.Equal(1, ProjectSnapshot.CurrentSchemaVersion); + Assert.Equal(2, ProjectSnapshot.CurrentSchemaVersion); var json = JsonSerializer.Serialize(CreateFullSnapshot(), SnapshotJsonContext.Default.ProjectSnapshot); using var document = JsonDocument.Parse(json); - Assert.Equal(1, document.RootElement.GetProperty("schemaVersion").GetInt32()); + Assert.Equal(ProjectSnapshot.CurrentSchemaVersion, document.RootElement.GetProperty("schemaVersion").GetInt32()); Assert.Equal(2, document.RootElement.GetProperty("statusUpdates").GetArrayLength()); Assert.True(document.RootElement.GetProperty("project").GetProperty("template").GetBoolean()); } diff --git a/tests/Ghpmv.Core.Tests/StatusUpdateImporterLogicTests.cs b/tests/Ghpmv.Core.Tests/StatusUpdateImporterLogicTests.cs index fe3d333f..08965e0d 100644 --- a/tests/Ghpmv.Core.Tests/StatusUpdateImporterLogicTests.cs +++ b/tests/Ghpmv.Core.Tests/StatusUpdateImporterLogicTests.cs @@ -187,38 +187,6 @@ public async Task Import_sends_the_attributed_body_and_optional_dates() } } - [Fact] - public async Task Import_does_nothing_when_the_snapshot_predates_status_updates() - { - var directory = Directory.CreateTempSubdirectory("ghpmv-status-").FullName; - try - { - using var handler = new StatusUpdateHandler(); - using var client = CreateClient(handler); - var progress = new List<string>(); - var importer = new StatusUpdateImporter(client) { OnProgress = progress.Add }; - - var result = await importer.ImportAsync( - CreateSnapshot((IReadOnlyList<StatusUpdateSnapshot>?)null), - Target, - directory, - TestContext.Current.CancellationToken); - - Assert.Empty(handler.RequestBodies); - Assert.Equal(0, result.Created); - Assert.Equal(0, result.Resumed); - Assert.Equal(0, result.AlreadyComplete); - Assert.Equal( - ["Status updates were not captured by this schema-v1 snapshot; leaving the target history unchanged."], - progress); - Assert.False(File.Exists(Path.Combine(directory, ImportLog.FileName))); - } - finally - { - Directory.Delete(directory, recursive: true); - } - } - [Fact] public async Task Import_does_nothing_when_the_snapshot_has_an_empty_status_update_list() { @@ -241,8 +209,7 @@ public async Task Import_does_nothing_when_the_snapshot_has_an_empty_status_upda Assert.Equal(0, result.Resumed); Assert.Equal(0, result.AlreadyComplete); - // An empty list is "captured, but there is nothing to replay" — a different - // state from a schema-v1 snapshot, so the summary is still emitted. + // An empty captured list still emits the stable summary line. Assert.Equal( ["Status update import finished: 0 created, 0 resumed, 0 already complete."], progress); @@ -574,13 +541,12 @@ private static StatusUpdateSnapshot SnapshotUpdate( UpdatedAt = createdAt, }; - private static ProjectSnapshot CreateSnapshot(params StatusUpdateSnapshot[] updates) - => CreateSnapshot((IReadOnlyList<StatusUpdateSnapshot>?)updates); - - private static ProjectSnapshot CreateSnapshot(IReadOnlyList<StatusUpdateSnapshot>? updates) => new() + private static ProjectSnapshot CreateSnapshot(params StatusUpdateSnapshot[] updates) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false }, + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Core.Tests/StatusUpdateImporterResumeTests.cs b/tests/Ghpmv.Core.Tests/StatusUpdateImporterResumeTests.cs index 398b9882..7e1a5cba 100644 --- a/tests/Ghpmv.Core.Tests/StatusUpdateImporterResumeTests.cs +++ b/tests/Ghpmv.Core.Tests/StatusUpdateImporterResumeTests.cs @@ -366,7 +366,9 @@ private static StatusUpdateSnapshot Update( private static ProjectSnapshot CreateSnapshot(params StatusUpdateSnapshot[] updates) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false }, + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Core.Tests/TeamLinkImportTests.cs b/tests/Ghpmv.Core.Tests/TeamLinkImportTests.cs index c671bf3d..c21608d7 100644 --- a/tests/Ghpmv.Core.Tests/TeamLinkImportTests.cs +++ b/tests/Ghpmv.Core.Tests/TeamLinkImportTests.cs @@ -272,7 +272,9 @@ await importer.ImportIntoAsync( private static ProjectSnapshot Snapshot(params LinkedTeamSnapshot[] teams) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + Project = new ProjectInfoSnapshot { Title = "Roadmap", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Integration.Tests/CollaboratorImportTests.cs b/tests/Ghpmv.Integration.Tests/CollaboratorImportTests.cs index c8e1d627..9c535dcb 100644 --- a/tests/Ghpmv.Integration.Tests/CollaboratorImportTests.cs +++ b/tests/Ghpmv.Integration.Tests/CollaboratorImportTests.cs @@ -49,11 +49,14 @@ public async Task Import_applies_collaborators_and_linked_repositories_and_warns var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = "ghpmv-collab-test-" + Guid.NewGuid().ToString("N"), Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], diff --git a/tests/Ghpmv.Integration.Tests/ImportCapabilityPreflightIntegrationTests.cs b/tests/Ghpmv.Integration.Tests/ImportCapabilityPreflightIntegrationTests.cs index 024f41b3..bfe31db9 100644 --- a/tests/Ghpmv.Integration.Tests/ImportCapabilityPreflightIntegrationTests.cs +++ b/tests/Ghpmv.Integration.Tests/ImportCapabilityPreflightIntegrationTests.cs @@ -189,11 +189,15 @@ await TemporaryProjectFixture.DeleteAllByTitleAsync( private static ProjectSnapshot MinimalSnapshot(string title) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = title, Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], diff --git a/tests/Ghpmv.Integration.Tests/IntegrationFixtureSnapshotTests.cs b/tests/Ghpmv.Integration.Tests/IntegrationFixtureSnapshotTests.cs index b6242745..511c0a3f 100644 --- a/tests/Ghpmv.Integration.Tests/IntegrationFixtureSnapshotTests.cs +++ b/tests/Ghpmv.Integration.Tests/IntegrationFixtureSnapshotTests.cs @@ -11,7 +11,10 @@ public void SelectCanonicalItems_excludes_unrelated_shared_fixture_items() var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, - Project = new ProjectInfoSnapshot { Title = "fixture", Public = false, Closed = false }, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], + Project = new ProjectInfoSnapshot { Title = "fixture", Public = false, Closed = false, Template = false }, Fields = [], Views = [], Workflows = [], diff --git a/tests/Ghpmv.Integration.Tests/IssueFieldLifecycleIntegrationTests.cs b/tests/Ghpmv.Integration.Tests/IssueFieldLifecycleIntegrationTests.cs index c9488ee4..4644bdb1 100644 --- a/tests/Ghpmv.Integration.Tests/IssueFieldLifecycleIntegrationTests.cs +++ b/tests/Ghpmv.Integration.Tests/IssueFieldLifecycleIntegrationTests.cs @@ -133,11 +133,15 @@ await TemporaryProjectFixture.DeleteAllByTitleAsync( private static ProjectSnapshot Snapshot(string title, FieldSnapshot issueField) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = title, Public = false, Closed = false, + Template = false, }, Fields = [issueField], Views = [], diff --git a/tests/Ghpmv.Integration.Tests/ItemImporterTests.cs b/tests/Ghpmv.Integration.Tests/ItemImporterTests.cs index 3ea7ddcd..a82fdd68 100644 --- a/tests/Ghpmv.Integration.Tests/ItemImporterTests.cs +++ b/tests/Ghpmv.Integration.Tests/ItemImporterTests.cs @@ -367,11 +367,15 @@ public async Task Pull_request_item_is_imported_from_identity_mapped_repository_ var snapshot = new ProjectSnapshot { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = targetProjectTitle, Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], diff --git a/tests/Ghpmv.Integration.Tests/ProjectImporterTests.cs b/tests/Ghpmv.Integration.Tests/ProjectImporterTests.cs index 79cc66bf..2c9330ca 100644 --- a/tests/Ghpmv.Integration.Tests/ProjectImporterTests.cs +++ b/tests/Ghpmv.Integration.Tests/ProjectImporterTests.cs @@ -305,11 +305,15 @@ public async Task Import_with_overridden_title_creates_project_with_new_title() private static ProjectSnapshot MinimalSnapshot(string title) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = title, Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], @@ -343,6 +347,7 @@ title shortDescription readme public closed Readme = project.GetProperty("readme").GetString(), Public = project.GetProperty("public").GetBoolean(), Closed = project.GetProperty("closed").GetBoolean(), + Template = false, }; } @@ -592,11 +597,14 @@ await TemporaryProjectFixture.DeleteAllByTitleAsync( private static ProjectSnapshot StatusUpdateSnapshot(string title) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = title, Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], diff --git a/tests/Ghpmv.Integration.Tests/ProjectTemplateIntegrationTests.cs b/tests/Ghpmv.Integration.Tests/ProjectTemplateIntegrationTests.cs index c22264ab..2cc89452 100644 --- a/tests/Ghpmv.Integration.Tests/ProjectTemplateIntegrationTests.cs +++ b/tests/Ghpmv.Integration.Tests/ProjectTemplateIntegrationTests.cs @@ -110,68 +110,6 @@ public async Task Template_state_round_trips_through_export_import_and_verify(bo } } - [Fact] - public async Task Existing_template_can_be_unmarked_and_legacy_null_preserves_state() - { - var cancellationToken = TestContext.Current.CancellationToken; - using var client = IntegrationTestSettings.CreateClient(Token); - var initialDirectory = IntegrationTestSettings.CreateOperationLogDirectory(); - var ordinaryDirectory = IntegrationTestSettings.CreateOperationLogDirectory(); - var legacyDirectory = IntegrationTestSettings.CreateOperationLogDirectory(); - ImportResult? target = null; - - try - { - var initial = Snapshot(NewTitle("existing"), template: true, includeStatusUpdate: false); - target = await ImportCompleteSnapshotAsync( - client, - TargetOrg, - initial, - initialDirectory, - addStatusAttribution: true, - registerProject: result => target = result, - cancellationToken); - - var ordinary = initial with { Project = initial.Project with { Template = false } }; - await new ProjectImporter(client) - { - OnConflict = ConflictAction.Update, - OperationLogDirectory = ordinaryDirectory, - }.ImportIntoAsync(ordinary, TargetOrg, target.ProjectNumber, cancellationToken); - await ProjectTemplateWriteSession.SetFinalStateAsync( - client, - target.ProjectId, - desiredTemplate: false, - cancellationToken: cancellationToken); - Assert.False((await ExportAsync(client, target.ProjectNumber, cancellationToken)).Project.Template); - - await ProjectTemplateWriteSession.SetFinalStateAsync( - client, - target.ProjectId, - desiredTemplate: true, - cancellationToken: cancellationToken); - var legacy = ordinary with { Project = ordinary.Project with { Template = null } }; - await new ProjectImporter(client) - { - OnConflict = ConflictAction.Update, - OperationLogDirectory = legacyDirectory, - }.ImportIntoAsync(legacy, TargetOrg, target.ProjectNumber, cancellationToken); - - Assert.True((await ExportAsync(client, target.ProjectNumber, cancellationToken)).Project.Template); - } - finally - { - if (target is not null) - { - await DeleteProjectAsync(client, target.ProjectId); - } - - TryDeleteDirectory(initialDirectory); - TryDeleteDirectory(ordinaryDirectory); - TryDeleteDirectory(legacyDirectory); - } - } - private static async Task<ImportResult> ImportCompleteSnapshotAsync( GitHubGraphQLClient client, string organization, @@ -224,7 +162,7 @@ async Task PersistAsync(bool required, CancellationToken token) await ProjectTemplateWriteSession.SetFinalStateAsync( client, result.ProjectId, - snapshot.Project.Template!.Value, + snapshot.Project.Template, cancellationToken: cancellationToken); } @@ -240,6 +178,8 @@ private static Task<ProjectSnapshot> ExportAsync( private static ProjectSnapshot Snapshot(string title, bool template, bool includeStatusUpdate) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = title, diff --git a/tests/Ghpmv.Integration.Tests/ProjectTemplateWriteSessionTests.cs b/tests/Ghpmv.Integration.Tests/ProjectTemplateWriteSessionTests.cs index 1a0f6112..cdf0fdd6 100644 --- a/tests/Ghpmv.Integration.Tests/ProjectTemplateWriteSessionTests.cs +++ b/tests/Ghpmv.Integration.Tests/ProjectTemplateWriteSessionTests.cs @@ -278,11 +278,14 @@ await client.MutationAsync( private static ProjectSnapshot SnapshotWithStatusUpdates(string title) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = title, Public = false, Closed = false, + Template = false, }, Fields = [], Views = [], diff --git a/tests/Ghpmv.Integration.Tests/ProjectViewImporterIntegrationTests.cs b/tests/Ghpmv.Integration.Tests/ProjectViewImporterIntegrationTests.cs index 921eb20b..0c47ee06 100644 --- a/tests/Ghpmv.Integration.Tests/ProjectViewImporterIntegrationTests.cs +++ b/tests/Ghpmv.Integration.Tests/ProjectViewImporterIntegrationTests.cs @@ -126,11 +126,15 @@ await TemporaryProjectFixture.DeleteAllByTitleAsync( private static ProjectSnapshot Snapshot(string title) => new() { SchemaVersion = ProjectSnapshot.CurrentSchemaVersion, + StatusUpdates = [], + LinkedRepositories = [], + LinkedTeams = [], Project = new ProjectInfoSnapshot { Title = title, Public = false, Closed = false, + Template = false, }, Fields = [ diff --git a/tests/Ghpmv.Integration.Tests/VerifyTests.cs b/tests/Ghpmv.Integration.Tests/VerifyTests.cs index d4ca4c50..c14ca54d 100644 --- a/tests/Ghpmv.Integration.Tests/VerifyTests.cs +++ b/tests/Ghpmv.Integration.Tests/VerifyTests.cs @@ -112,7 +112,7 @@ public async Task Verify_matches_after_import_then_detects_deleted_field_and_cha Assert.Equal(source.StatusUpdates.Count, importLog.StatusUpdates.Count); var verificationSnapshot = snapshot with { - LinkedRepositories = snapshot.LinkedRepositories?.Select(repository => + LinkedRepositories = snapshot.LinkedRepositories.Select(repository => string.Equals(repository, FixtureRepo, StringComparison.OrdinalIgnoreCase) ? targetFixtureRepo : repository).ToList(), @@ -126,7 +126,7 @@ await IntegrationFixtureSnapshot.RemoveUnexpectedItemsAsync( await ProjectTemplateWriteSession.SetFinalStateAsync( client, result.ProjectId, - snapshot.Project.Template!.Value, + snapshot.Project.Template, cancellationToken: cancellationToken); var postExportCalled = false;