| 1 | <# |
| 2 | Usage: .\corpus.ps1 -Mode Create -Root C:\one-tests\runs\20260904-01 |
| 3 | .\corpus.ps1 -Mode Capture -Root C:\one-tests\runs\20260904-01 -Step 08-manual-ink |
| 4 | #> |
| 5 | [CmdletBinding()] |
| 6 | param( |
| 7 | [ValidateSet('Create', 'Capture')] |
| 8 | [string]$Mode = 'Create', |
| 9 | |
| 10 | [Parameter(Mandatory = $true)] |
| 11 | [string]$Root, |
| 12 | |
| 13 | [string]$Step |
| 14 | ) |
| 15 | |
| 16 | Set-StrictMode -Version Latest |
| 17 | $ErrorActionPreference = 'Stop' |
| 18 | |
| 19 | $schema = 1 |
| 20 | $namespace = 'http://schemas.microsoft.com/office/onenote/2010/onenote' |
| 21 | $runs = [IO.Path]::GetFullPath('C:\one-tests\runs').TrimEnd('\') |
| 22 | $root = [IO.Path]::GetFullPath($Root).TrimEnd('\') |
| 23 | $markerPath = Join-Path $root '.native-corpus.json' |
| 24 | $manifestPath = Join-Path $root 'manifest.json' |
| 25 | $logPath = Join-Path $root 'operation-log.jsonl' |
| 26 | $notebookPath = Join-Path $root 'notebook' |
| 27 | $sectionName = 'synthetic.one' |
| 28 | $sectionPath = Join-Path $notebookPath $sectionName |
| 29 | $script:app = $null |
| 30 | $script:openedNotebookId = $null |
| 31 | $script:sectionId = $null |
| 32 | $script:manifest = $null |
| 33 | |
| 34 | function Write-Json { |
| 35 | param( |
| 36 | $Value, |
| 37 | [string]$Path |
| 38 | ) |
| 39 | |
| 40 | $Value | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $Path -Encoding UTF8 |
| 41 | } |
| 42 | |
| 43 | function Save-Manifest { |
| 44 | if ($null -ne $script:manifest) { |
| 45 | Write-Json $script:manifest $manifestPath |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | function Add-Log { |
| 50 | param( |
| 51 | [string]$Action, |
| 52 | [string]$Fixture, |
| 53 | $Details |
| 54 | ) |
| 55 | |
| 56 | $entry = [pscustomobject][ordered]@{ |
| 57 | at_utc = [DateTime]::UtcNow.ToString('o') |
| 58 | fixture = $Fixture |
| 59 | action = $Action |
| 60 | details = $Details |
| 61 | } |
| 62 | $entry | ConvertTo-Json -Depth 12 -Compress | Add-Content -LiteralPath $logPath -Encoding UTF8 |
| 63 | } |
| 64 | |
| 65 | function Add-Fixture { |
| 66 | param( |
| 67 | [string]$Name, |
| 68 | [string]$Status, |
| 69 | [string]$Description, |
| 70 | $Details |
| 71 | ) |
| 72 | |
| 73 | $script:manifest.fixtures = @($script:manifest.fixtures) + [pscustomobject][ordered]@{ |
| 74 | name = $Name |
| 75 | status = $Status |
| 76 | description = $Description |
| 77 | at_utc = [DateTime]::UtcNow.ToString('o') |
| 78 | details = $Details |
| 79 | } |
| 80 | Save-Manifest |
| 81 | } |
| 82 | |
| 83 | function Assert-RunRoot { |
| 84 | $parent = [IO.Path]::GetDirectoryName($root) |
| 85 | if (-not [String]::Equals($parent, $runs, [StringComparison]::OrdinalIgnoreCase)) { |
| 86 | throw 'Choose a new direct child of C:\one-tests\runs.' |
| 87 | } |
| 88 | if ([String]::Equals($root, $runs, [StringComparison]::OrdinalIgnoreCase)) { |
| 89 | throw 'Choose a run directory below C:\one-tests\runs.' |
| 90 | } |
| 91 | |
| 92 | $oneTests = [IO.Path]::GetDirectoryName($runs) |
| 93 | if (-not (Test-Path -LiteralPath $oneTests -PathType Container)) { |
| 94 | throw 'Create C:\one-tests before running this harness.' |
| 95 | } |
| 96 | if (-not (Test-Path -LiteralPath $runs -PathType Container)) { |
| 97 | New-Item -ItemType Directory -Path $runs | Out-Null |
| 98 | } |
| 99 | if (((Get-Item -LiteralPath $runs -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { |
| 100 | throw 'C:\one-tests\runs cannot be a reparse point.' |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | function Assert-PathUnderRoot { |
| 105 | param([string]$Path) |
| 106 | |
| 107 | $full = [IO.Path]::GetFullPath($Path) |
| 108 | if (-not $full.StartsWith("$root\", [StringComparison]::OrdinalIgnoreCase)) { |
| 109 | throw 'The harness can write only below its run root.' |
| 110 | } |
| 111 | return $full |
| 112 | } |
| 113 | |
| 114 | function Get-TreeState { |
| 115 | param([string]$Path) |
| 116 | |
| 117 | $full = Assert-PathUnderRoot $Path |
| 118 | $files = @(Get-ChildItem -LiteralPath $full -Force -File -Recurse | Sort-Object FullName) |
| 119 | $entries = @( |
| 120 | foreach ($file in $files) { |
| 121 | $relative = $file.FullName.Substring($full.Length).TrimStart('\') |
| 122 | [pscustomobject][ordered]@{ |
| 123 | path = $relative |
| 124 | bytes = [int64]$file.Length |
| 125 | sha256 = (Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash.ToLowerInvariant() |
| 126 | } |
| 127 | } |
| 128 | ) |
| 129 | $lines = @($entries | ForEach-Object { "$($_.path)`t$($_.bytes)`t$($_.sha256)" }) |
| 130 | $bytes = [Text.Encoding]::UTF8.GetBytes([String]::Join("`n", $lines)) |
| 131 | $treeHash = [Security.Cryptography.SHA256]::Create().ComputeHash($bytes) |
| 132 | [pscustomobject][ordered]@{ |
| 133 | file_count = $entries.Count |
| 134 | byte_count = [int64](($entries | Measure-Object -Property bytes -Sum).Sum) |
| 135 | sha256 = ([BitConverter]::ToString($treeHash) -replace '-', '').ToLowerInvariant() |
| 136 | files = $entries |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | function Copy-Snapshot { |
| 141 | param([string]$Fixture) |
| 142 | |
| 143 | $snapshot = Join-Path (Join-Path $root 'snapshots') $Fixture |
| 144 | Assert-PathUnderRoot $snapshot | Out-Null |
| 145 | if (Test-Path -LiteralPath $snapshot) { |
| 146 | throw "Snapshot '$Fixture' already exists." |
| 147 | } |
| 148 | $before = Get-TreeState $notebookPath |
| 149 | New-Item -ItemType Directory -Path $snapshot | Out-Null |
| 150 | Copy-Item -LiteralPath $notebookPath -Destination $snapshot -Recurse -Force |
| 151 | $copied = Get-TreeState (Join-Path $snapshot 'notebook') |
| 152 | $after = Get-TreeState $notebookPath |
| 153 | if ($before.sha256 -ne $copied.sha256 -or $before.sha256 -ne $after.sha256) { |
| 154 | throw "Snapshot '$Fixture' changed while copying." |
| 155 | } |
| 156 | $state = [pscustomobject][ordered]@{ |
| 157 | source_before = [pscustomobject][ordered]@{ file_count = $before.file_count; byte_count = $before.byte_count; sha256 = $before.sha256 } |
| 158 | copied = [pscustomobject][ordered]@{ file_count = $copied.file_count; byte_count = $copied.byte_count; sha256 = $copied.sha256 } |
| 159 | source_after = [pscustomobject][ordered]@{ file_count = $after.file_count; byte_count = $after.byte_count; sha256 = $after.sha256 } |
| 160 | files = $copied.files |
| 161 | } |
| 162 | Write-Json $state (Join-Path $snapshot 'snapshot.json') |
| 163 | return $state |
| 164 | } |
| 165 | |
| 166 | function Assert-NotebookPaths { |
| 167 | param([string]$Hierarchy) |
| 168 | |
| 169 | [xml]$document = $Hierarchy |
| 170 | foreach ($node in @($document.SelectNodes('//*[@path]'))) { |
| 171 | $path = $node.GetAttribute('path') |
| 172 | if (-not [String]::IsNullOrWhiteSpace($path)) { |
| 173 | $full = [IO.Path]::GetFullPath($path) |
| 174 | if (-not $full.StartsWith("$notebookPath\", [StringComparison]::OrdinalIgnoreCase) -and |
| 175 | -not [String]::Equals($full.TrimEnd('\'), $notebookPath, [StringComparison]::OrdinalIgnoreCase)) { |
| 176 | throw 'OneNote returned a hierarchy path outside the generated notebook.' |
| 177 | } |
| 178 | } |
| 179 | } |
| 180 | return $document |
| 181 | } |
| 182 | |
| 183 | function Get-PageXml { |
| 184 | param([string]$PageId) |
| 185 | |
| 186 | $page = '' |
| 187 | $script:app.GetPageContent($PageId, [ref]$page, 3, $schema) |
| 188 | return $page |
| 189 | } |
| 190 | |
| 191 | function Get-PageIds { |
| 192 | param([xml]$Hierarchy) |
| 193 | |
| 194 | $manager = New-Object Xml.XmlNamespaceManager($Hierarchy.NameTable) |
| 195 | $manager.AddNamespace('one', $namespace) |
| 196 | return @($Hierarchy.SelectNodes('//one:Page', $manager) | ForEach-Object { $_.GetAttribute('ID') }) |
| 197 | } |
| 198 | |
| 199 | function Read-ReadyGeneratedNotebook { |
| 200 | param([int]$ExpectedPageCount = -1) |
| 201 | |
| 202 | if ([String]::IsNullOrWhiteSpace($script:openedNotebookId) -or [String]::IsNullOrWhiteSpace($script:sectionId)) { |
| 203 | throw 'The generated notebook and section must be open before readiness is checked.' |
| 204 | } |
| 205 | $deadline = (Get-Date).AddSeconds(15) |
| 206 | $lastError = $null |
| 207 | while ((Get-Date) -lt $deadline) { |
| 208 | try { |
| 209 | $hierarchy = '' |
| 210 | $script:app.GetHierarchy($script:openedNotebookId, 4, [ref]$hierarchy, $schema) |
| 211 | $document = Assert-NotebookPaths $hierarchy |
| 212 | $manager = New-Object Xml.XmlNamespaceManager($document.NameTable) |
| 213 | $manager.AddNamespace('one', $namespace) |
| 214 | $sections = @($document.SelectNodes('//one:Section', $manager) | Where-Object { |
| 215 | [String]::Equals($_.GetAttribute('ID'), $script:sectionId, [StringComparison]::OrdinalIgnoreCase) -and |
| 216 | [String]::Equals([IO.Path]::GetFullPath($_.GetAttribute('path')).TrimEnd('\'), $sectionPath, [StringComparison]::OrdinalIgnoreCase) |
| 217 | }) |
| 218 | if ($sections.Count -ne 1) { throw 'OneNote has not returned the generated section in the hierarchy.' } |
| 219 | $pageIds = @(Get-PageIds $document | Where-Object { -not [String]::IsNullOrWhiteSpace($_) }) |
| 220 | if ($ExpectedPageCount -ge 0 -and $pageIds.Count -ne $ExpectedPageCount) { |
| 221 | throw "Expected $ExpectedPageCount persisted pages, found $($pageIds.Count)." |
| 222 | } |
| 223 | $pageContents = @() |
| 224 | foreach ($pageId in $pageIds) { |
| 225 | $pageContents += Get-PageXml $pageId |
| 226 | } |
| 227 | return [pscustomobject][ordered]@{ |
| 228 | hierarchy = $hierarchy |
| 229 | page_ids = $pageIds |
| 230 | page_contents = $pageContents |
| 231 | } |
| 232 | } |
| 233 | catch { |
| 234 | $lastError = $_.Exception.Message |
| 235 | Start-Sleep -Milliseconds 250 |
| 236 | } |
| 237 | } |
| 238 | throw "OneNote did not return the generated section ready for capture: $lastError" |
| 239 | } |
| 240 | |
| 241 | function Get-SolePageId { |
| 242 | $state = Read-ReadyGeneratedNotebook |
| 243 | $pages = @($state.page_ids) |
| 244 | if ($pages.Count -ne 1 -or [String]::IsNullOrWhiteSpace($pages[0])) { |
| 245 | throw 'The generated notebook must contain exactly one page.' |
| 246 | } |
| 247 | return $pages[0] |
| 248 | } |
| 249 | |
| 250 | function Confirm-Closed { |
| 251 | param([string]$NotebookId) |
| 252 | |
| 253 | $probe = '' |
| 254 | $closed = $false |
| 255 | try { |
| 256 | $script:app.GetHierarchy($NotebookId, 0, [ref]$probe, $schema) |
| 257 | $closed = [String]::IsNullOrWhiteSpace($probe) |
| 258 | } |
| 259 | catch { |
| 260 | $closed = $true |
| 261 | } |
| 262 | if (-not $closed) { |
| 263 | throw 'OneNote still reports the generated notebook as open.' |
| 264 | } |
| 265 | } |
| 266 | |
| 267 | function Write-PageEvidence { |
| 268 | param( |
| 269 | [string]$Directory, |
| 270 | [string]$Hierarchy, |
| 271 | [string[]]$PageIds, |
| 272 | [string[]]$PageContents |
| 273 | ) |
| 274 | |
| 275 | if ($PageIds.Count -ne $PageContents.Count) { |
| 276 | throw 'Page evidence is incomplete.' |
| 277 | } |
| 278 | Assert-PathUnderRoot $Directory | Out-Null |
| 279 | $pagesDirectory = Join-Path $Directory 'pages' |
| 280 | New-Item -ItemType Directory -Path $pagesDirectory | Out-Null |
| 281 | [IO.File]::WriteAllText((Join-Path $Directory 'hierarchy.xml'), $Hierarchy, [Text.Encoding]::UTF8) |
| 282 | $pages = @() |
| 283 | for ($index = 0; $index -lt $PageIds.Count; $index++) { |
| 284 | $pageFile = 'page-{0:d2}.xml' -f $index |
| 285 | [IO.File]::WriteAllText((Join-Path $pagesDirectory $pageFile), $PageContents[$index], [Text.Encoding]::UTF8) |
| 286 | $pages += [pscustomobject][ordered]@{ id = $PageIds[$index]; file = "pages\\$pageFile" } |
| 287 | } |
| 288 | return $pages |
| 289 | } |
| 290 | |
| 291 | function Open-GeneratedNotebook { |
| 292 | param([int]$ExpectedPageCount = -1) |
| 293 | |
| 294 | $opened = '' |
| 295 | $script:app.OpenHierarchy($notebookPath, '', [ref]$opened, 0) |
| 296 | if ([String]::IsNullOrWhiteSpace($opened)) { |
| 297 | throw 'OneNote did not open the generated notebook.' |
| 298 | } |
| 299 | $script:openedNotebookId = $opened |
| 300 | $section = '' |
| 301 | $script:app.OpenHierarchy($sectionName, $opened, [ref]$section, 0) |
| 302 | if ([String]::IsNullOrWhiteSpace($section)) { |
| 303 | throw 'OneNote did not find the generated section.' |
| 304 | } |
| 305 | $script:sectionId = $section |
| 306 | $state = Read-ReadyGeneratedNotebook $ExpectedPageCount |
| 307 | Add-Log 'open_notebook' $null @{ notebook_id = $opened; path = $notebookPath; expected_page_count = if ($ExpectedPageCount -ge 0) { $ExpectedPageCount } else { $null }; actual_page_count = $state.page_ids.Count } |
| 308 | return $state |
| 309 | } |
| 310 | |
| 311 | function Sync-CloseGeneratedNotebook { |
| 312 | param( |
| 313 | [string]$Fixture, |
| 314 | [string]$Phase |
| 315 | ) |
| 316 | |
| 317 | $closingId = $script:openedNotebookId |
| 318 | if ([String]::IsNullOrWhiteSpace($closingId)) { throw 'The generated notebook must be open before it is synced.' } |
| 319 | $script:app.SyncHierarchy($closingId) |
| 320 | Add-Log 'sync_hierarchy' $Fixture @{ notebook_id = $closingId; phase = $Phase } |
| 321 | $script:app.CloseNotebook($closingId, $false) |
| 322 | $script:openedNotebookId = $null |
| 323 | Confirm-Closed $closingId |
| 324 | Add-Log 'close_notebook' $Fixture @{ notebook_id = $closingId; phase = $Phase; confirmed = $true } |
| 325 | } |
| 326 | |
| 327 | function Capture-Notebook { |
| 328 | param([string]$Fixture) |
| 329 | |
| 330 | $evidence = Join-Path (Join-Path $root 'evidence') $Fixture |
| 331 | Assert-PathUnderRoot $evidence | Out-Null |
| 332 | if (Test-Path -LiteralPath $evidence) { |
| 333 | throw "Evidence '$Fixture' already exists." |
| 334 | } |
| 335 | $baseline = Read-ReadyGeneratedNotebook |
| 336 | $expectedPageCount = $baseline.page_ids.Count |
| 337 | Sync-CloseGeneratedNotebook $Fixture 'stabilize' |
| 338 | $snapshot = Copy-Snapshot $Fixture |
| 339 | Add-Log 'copy_snapshot' $Fixture @{ sha256 = $snapshot.copied.sha256; file_count = $snapshot.copied.file_count; byte_count = $snapshot.copied.byte_count } |
| 340 | $state = Open-GeneratedNotebook $expectedPageCount |
| 341 | |
| 342 | New-Item -ItemType Directory -Path $evidence | Out-Null |
| 343 | $pages = @(Write-PageEvidence $evidence $state.hierarchy $state.page_ids $state.page_contents) |
| 344 | if ($Fixture -eq '06-attachment') { |
| 345 | $cachePaths = @( |
| 346 | foreach ($pageXml in @($state.page_contents)) { |
| 347 | [xml]$pageDocument = $pageXml |
| 348 | $manager = New-Object Xml.XmlNamespaceManager($pageDocument.NameTable) |
| 349 | $manager.AddNamespace('one', $namespace) |
| 350 | foreach ($file in @($pageDocument.SelectNodes('//one:InsertedFile', $manager))) { |
| 351 | if ($file.GetAttribute('preferredName') -eq 'fictitious-attachment.txt' -and -not [String]::IsNullOrWhiteSpace($file.GetAttribute('pathCache'))) { |
| 352 | $file.GetAttribute('pathCache') |
| 353 | } |
| 354 | } |
| 355 | } |
| 356 | ) |
| 357 | if ($cachePaths.Count -ne 1 -or -not (Test-Path -LiteralPath $cachePaths[0] -PathType Leaf)) { |
| 358 | throw 'OneNote did not return one readable cached generated attachment.' |
| 359 | } |
| 360 | $attachment = [IO.File]::ReadAllBytes($cachePaths[0]) |
| 361 | [IO.File]::WriteAllBytes((Join-Path $evidence 'attachment.txt'), $attachment) |
| 362 | Add-Log 'capture_attachment_cache' $Fixture @{ bytes = $attachment.Length } |
| 363 | } |
| 364 | $native = [pscustomobject][ordered]@{ |
| 365 | fixture = $Fixture |
| 366 | notebook_id = $script:openedNotebookId |
| 367 | section_id = $script:sectionId |
| 368 | hierarchy_file = 'hierarchy.xml' |
| 369 | pages = $pages |
| 370 | } |
| 371 | Write-Json $native (Join-Path $evidence 'native.json') |
| 372 | Add-Log 'capture_native_api' $Fixture @{ page_count = $pages.Count; schema = $schema } |
| 373 | |
| 374 | Sync-CloseGeneratedNotebook $Fixture 'persist' |
| 375 | $reopened = Open-GeneratedNotebook $expectedPageCount |
| 376 | $reopenedDirectory = Join-Path $evidence 'reopened' |
| 377 | if (Test-Path -LiteralPath $reopenedDirectory) { throw "Reopened evidence for '$Fixture' already exists." } |
| 378 | New-Item -ItemType Directory -Path $reopenedDirectory | Out-Null |
| 379 | $reopenedPages = @(Write-PageEvidence $reopenedDirectory $reopened.hierarchy $reopened.page_ids $reopened.page_contents) |
| 380 | Write-Json ([pscustomobject][ordered]@{ |
| 381 | expected_page_count = $expectedPageCount |
| 382 | actual_page_count = $reopenedPages.Count |
| 383 | hierarchy_file = 'hierarchy.xml' |
| 384 | pages = $reopenedPages |
| 385 | }) (Join-Path $reopenedDirectory 'native.json') |
| 386 | Add-Log 'reopen_ready' $Fixture @{ expected_page_count = $expectedPageCount; actual_page_count = $reopenedPages.Count } |
| 387 | return [pscustomobject][ordered]@{ |
| 388 | evidence = $evidence.Substring($root.Length).TrimStart('\') |
| 389 | reopened_evidence = (Join-Path (Join-Path 'evidence' $Fixture) 'reopened') |
| 390 | snapshot = (Join-Path 'snapshots' $Fixture) |
| 391 | } |
| 392 | } |
| 393 | |
| 394 | function Invoke-Step { |
| 395 | param( |
| 396 | [string]$Name, |
| 397 | [string]$Description, |
| 398 | [scriptblock]$Action |
| 399 | ) |
| 400 | |
| 401 | try { |
| 402 | Add-Log 'begin_fixture' $Name @{ description = $Description } |
| 403 | & $Action |
| 404 | $capture = Capture-Notebook $Name |
| 405 | Add-Fixture $Name 'captured' $Description $capture |
| 406 | } |
| 407 | catch { |
| 408 | Add-Log 'fixture_failed' $Name @{ error = $_.Exception.Message } |
| 409 | Add-Fixture $Name 'failed' $Description @{ error = $_.Exception.Message } |
| 410 | throw |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | function Invoke-PageUpdate { |
| 415 | param( |
| 416 | [string]$Fixture, |
| 417 | [string]$Xml, |
| 418 | $Details |
| 419 | ) |
| 420 | |
| 421 | $input = Join-Path (Join-Path $root 'inputs') "$Fixture.xml" |
| 422 | Assert-PathUnderRoot $input | Out-Null |
| 423 | [IO.File]::WriteAllText($input, $Xml, [Text.Encoding]::UTF8) |
| 424 | $script:app.UpdatePageContent($Xml, [DateTime]::MinValue, $schema, $false) |
| 425 | Add-Log 'update_page_content' $Fixture $Details |
| 426 | } |
| 427 | |
| 428 | function Get-OfficeMetadata { |
| 429 | $clsid = (Get-Item -LiteralPath 'Registry::HKEY_CLASSES_ROOT\OneNote.Application\CLSID').GetValue('') |
| 430 | $server = (Get-Item -LiteralPath "Registry::HKEY_CLASSES_ROOT\CLSID\$clsid\LocalServer32").GetValue('') |
| 431 | $match = [regex]::Match($server, '^"(?<path>[^"]+\.exe)"|^(?<path>[^\s]+\.exe)', [Text.RegularExpressions.RegexOptions]::IgnoreCase) |
| 432 | if (-not $match.Success) { |
| 433 | throw 'OneNote LocalServer32 does not resolve an executable.' |
| 434 | } |
| 435 | $path = $match.Groups['path'].Value |
| 436 | if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { |
| 437 | throw 'OneNote LocalServer32 executable is unavailable.' |
| 438 | } |
| 439 | $version = (Get-Item -LiteralPath $path).VersionInfo.FileVersion |
| 440 | if ([String]::IsNullOrWhiteSpace($version)) { |
| 441 | throw 'OneNote executable has no file version.' |
| 442 | } |
| 443 | return [pscustomobject][ordered]@{ |
| 444 | prog_id = 'OneNote.Application' |
| 445 | clsid = $clsid |
| 446 | local_server = $server |
| 447 | executable = $path |
| 448 | file_version = $version |
| 449 | xml_schema = 'xs2010' |
| 450 | xml_schema_value = $schema |
| 451 | xml_namespace = $namespace |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | function Wait-ForStableFile { |
| 456 | param([string]$Path) |
| 457 | |
| 458 | $full = Assert-PathUnderRoot $Path |
| 459 | $deadline = (Get-Date).AddSeconds(30) |
| 460 | while ((Get-Date) -lt $deadline) { |
| 461 | if (Test-Path -LiteralPath $full -PathType Leaf) { |
| 462 | $first = Get-Item -LiteralPath $full |
| 463 | $firstHash = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash.ToLowerInvariant() |
| 464 | Start-Sleep -Milliseconds 500 |
| 465 | $second = Get-Item -LiteralPath $full |
| 466 | $secondHash = (Get-FileHash -LiteralPath $full -Algorithm SHA256).Hash.ToLowerInvariant() |
| 467 | if ($first.Length -eq $second.Length -and $firstHash -eq $secondHash) { |
| 468 | return [pscustomobject][ordered]@{ bytes = [int64]$second.Length; sha256 = $secondHash } |
| 469 | } |
| 470 | } |
| 471 | Start-Sleep -Milliseconds 500 |
| 472 | } |
| 473 | throw 'OneNote did not finish writing the package within 30 seconds.' |
| 474 | } |
| 475 | |
| 476 | $profile = 'HKCU:\Software\Microsoft\Office\14.0\OneNote' |
| 477 | if ((Get-ItemProperty "$profile\Options\Paths").UnfiledNotesSection -ne 'C:\one-tests\Loose.one' -or |
| 478 | -not (Test-Path 'C:\one-tests\profile-original-cache')) { |
| 479 | throw 'Park the personal OneNote profile before running native tests.' |
| 480 | } |
| 481 | if (Get-Process ONENOTE -ErrorAction SilentlyContinue) { throw 'Close OneNote before creating fixtures.' } |
| 482 | Assert-RunRoot |
| 483 | |
| 484 | if ($Mode -eq 'Create') { |
| 485 | if (Test-Path -LiteralPath $root) { |
| 486 | throw 'Choose a root that does not exist.' |
| 487 | } |
| 488 | New-Item -ItemType Directory -Path $root | Out-Null |
| 489 | foreach ($directory in 'assets', 'evidence', 'inputs', 'snapshots') { |
| 490 | New-Item -ItemType Directory -Path (Join-Path $root $directory) | Out-Null |
| 491 | } |
| 492 | Write-Json ([pscustomobject][ordered]@{ |
| 493 | format = 'onenote-native-stage1-root-v1' |
| 494 | root = $root |
| 495 | generated = $true |
| 496 | }) $markerPath |
| 497 | $script:manifest = [pscustomobject][ordered]@{ |
| 498 | format = 'onenote-native-stage1-v1' |
| 499 | status = 'running' |
| 500 | root = $root |
| 501 | created_at_utc = [DateTime]::UtcNow.ToString('o') |
| 502 | office = Get-OfficeMetadata |
| 503 | harness = [pscustomobject][ordered]@{ |
| 504 | source_path = $PSCommandPath |
| 505 | source_sha256 = (Get-FileHash -LiteralPath $PSCommandPath -Algorithm SHA256).Hash.ToLowerInvariant() |
| 506 | } |
| 507 | notebook = [pscustomobject][ordered]@{ path = 'notebook'; section = $sectionName } |
| 508 | fixtures = @() |
| 509 | operation_log = 'operation-log.jsonl' |
| 510 | } |
| 511 | Save-Manifest |
| 512 | } |
| 513 | else { |
| 514 | if (-not (Test-Path -LiteralPath $root -PathType Container) -or -not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { |
| 515 | throw 'Capture requires a generated corpus root.' |
| 516 | } |
| 517 | if (((Get-Item -LiteralPath $root -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { |
| 518 | throw 'Capture root cannot be a reparse point.' |
| 519 | } |
| 520 | if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf) -or -not (Test-Path -LiteralPath $notebookPath -PathType Container)) { |
| 521 | throw 'Capture requires the generated manifest and notebook.' |
| 522 | } |
| 523 | try { |
| 524 | $marker = Get-Content -LiteralPath $markerPath -Raw | ConvertFrom-Json |
| 525 | } |
| 526 | catch { |
| 527 | throw 'Capture requires a readable generated corpus marker.' |
| 528 | } |
| 529 | if ($marker.format -ne 'onenote-native-stage1-root-v1' -or $marker.generated -ne $true -or |
| 530 | -not [String]::Equals([IO.Path]::GetFullPath($marker.root).TrimEnd('\'), $root, [StringComparison]::OrdinalIgnoreCase)) { |
| 531 | throw 'Capture root marker does not match this generated corpus.' |
| 532 | } |
| 533 | if ([String]::IsNullOrWhiteSpace($Step) -or $Step -notmatch '^[0-9]{2}-[a-z0-9-]+$') { |
| 534 | throw 'Choose a lowercase numbered fixture name for -Step.' |
| 535 | } |
| 536 | $script:manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json |
| 537 | } |
| 538 | |
| 539 | try { |
| 540 | $script:app = New-Object -ComObject OneNote.Application |
| 541 | Add-Log 'connect_com' $null @{ prog_id = 'OneNote.Application'; schema = $schema } |
| 542 | |
| 543 | if ($Mode -eq 'Capture') { |
| 544 | Open-GeneratedNotebook |
| 545 | $capture = Capture-Notebook $Step |
| 546 | Add-Fixture $Step 'captured' 'Manual native fixture.' $capture |
| 547 | } |
| 548 | else { |
| 549 | $attachmentPath = Join-Path $root 'assets\fictitious-attachment.txt' |
| 550 | $imagePath = Join-Path $root 'assets\fictitious-image.png' |
| 551 | [IO.File]::WriteAllText($attachmentPath, 'Fictitious attachment for native corpus.', [Text.Encoding]::UTF8) |
| 552 | [IO.File]::WriteAllBytes($imagePath, [Convert]::FromBase64String('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9J1uoAAAAASUVORK5CYII=')) |
| 553 | Add-Log 'generate_assets' $null @{ attachment = 'assets\fictitious-attachment.txt'; image = 'assets\fictitious-image.png' } |
| 554 | |
| 555 | Invoke-Step '00-empty' 'New notebook and section.' { |
| 556 | $notebook = '' |
| 557 | $script:app.OpenHierarchy($notebookPath, '', [ref]$notebook, 1) |
| 558 | if ([String]::IsNullOrWhiteSpace($notebook)) { throw 'OneNote did not create the generated notebook.' } |
| 559 | $script:openedNotebookId = $notebook |
| 560 | $section = '' |
| 561 | $script:app.OpenHierarchy($sectionName, $notebook, [ref]$section, 3) |
| 562 | if ([String]::IsNullOrWhiteSpace($section)) { throw 'OneNote did not create the generated section.' } |
| 563 | $script:sectionId = $section |
| 564 | Add-Log 'create_notebook_section' '00-empty' @{ notebook_id = $notebook; section_id = $section; path = $notebookPath } |
| 565 | } |
| 566 | |
| 567 | Invoke-Step '01-blank-page' 'Blank page with no title.' { |
| 568 | $page = '' |
| 569 | $script:app.CreateNewPage($script:sectionId, [ref]$page, 2) |
| 570 | if ([String]::IsNullOrWhiteSpace($page)) { throw 'OneNote did not create the blank page.' } |
| 571 | Add-Log 'create_new_page' '01-blank-page' @{ page_id = $page; style = 'npsBlankPageNoTitle'; value = 2 } |
| 572 | } |
| 573 | |
| 574 | Invoke-Step '02-text' 'Plain text outline.' { |
| 575 | $pageId = Get-SolePageId |
| 576 | $body = '<one:Outline><one:OEChildren><one:OE><one:T><![CDATA[Fictitious plain text.]]></one:T></one:OE></one:OEChildren></one:Outline>' |
| 577 | $xml = "<one:Page xmlns:one=`"$namespace`" ID=`"$pageId`">$body</one:Page>" |
| 578 | Invoke-PageUpdate '02-text' $xml @{ page_id = $pageId; kind = 'plain_text_outline'; input = 'inputs\\02-text.xml' } |
| 579 | } |
| 580 | |
| 581 | Invoke-Step '03-format-unicode' 'Formatted Unicode text replacing the fetched outline.' { |
| 582 | $pageId = Get-SolePageId |
| 583 | $before = Get-PageXml $pageId |
| 584 | [IO.File]::WriteAllText((Join-Path $root 'evidence\03-format-unicode-before.xml'), $before, [Text.Encoding]::UTF8) |
| 585 | [xml]$source = $before |
| 586 | $manager = New-Object Xml.XmlNamespaceManager($source.NameTable) |
| 587 | $manager.AddNamespace('one', $namespace) |
| 588 | $outline = $source.SelectSingleNode('//one:Outline[@objectID]', $manager) |
| 589 | if ($null -eq $outline) { throw 'OneNote did not assign an objectID to the text outline.' } |
| 590 | $text = $outline.SelectSingleNode('.//one:T', $manager) |
| 591 | if ($null -eq $text) { throw 'The fetched text outline has no text node.' } |
| 592 | foreach ($child in @($text.ChildNodes)) { [void]$text.RemoveChild($child) } |
| 593 | $unicode = 'Fictitious: caf' + [char]0x00E9 + ', ' + [char]0x6771 + [char]0x4EAC + ', ' + [char]0x0645 + [char]0x0631 + [char]0x062D + [char]0x0628 + [char]0x0627 |
| 594 | $html = '<span style="font-family:Calibri;font-size:11.0pt;font-weight:bold;color:#1F4E79">' + $unicode + '</span>' |
| 595 | [void]$text.AppendChild($source.CreateCDataSection($html)) |
| 596 | $change = New-Object Xml.XmlDocument |
| 597 | $page = $change.CreateElement('one', 'Page', $namespace) |
| 598 | $page.SetAttribute('ID', $pageId) |
| 599 | [void]$page.AppendChild($change.ImportNode($outline, $true)) |
| 600 | [void]$change.AppendChild($page) |
| 601 | Invoke-PageUpdate '03-format-unicode' $change.OuterXml @{ page_id = $pageId; object_id = $outline.GetAttribute('objectID'); kind = 'full_outline_replace'; input = 'inputs\\03-format-unicode.xml' } |
| 602 | } |
| 603 | |
| 604 | Invoke-Step '04-positioned-outline' 'Positioned text outline.' { |
| 605 | $pageId = Get-SolePageId |
| 606 | $body = '<one:Outline><one:Position x="144" y="96"/><one:Size width="260" height="72"/><one:OEChildren><one:OE><one:T><![CDATA[Fictitious positioned outline.]]></one:T></one:OE></one:OEChildren></one:Outline>' |
| 607 | $xml = "<one:Page xmlns:one=`"$namespace`" ID=`"$pageId`">$body</one:Page>" |
| 608 | Invoke-PageUpdate '04-positioned-outline' $xml @{ page_id = $pageId; x = 144; y = 96; input = 'inputs\\04-positioned-outline.xml' } |
| 609 | } |
| 610 | |
| 611 | Invoke-Step '05-image' 'Generated PNG in a positioned outline.' { |
| 612 | $pageId = Get-SolePageId |
| 613 | $data = [Convert]::ToBase64String([IO.File]::ReadAllBytes($imagePath)) |
| 614 | $body = '<one:Outline><one:Position x="144" y="192"/><one:Size width="96" height="96"/><one:OEChildren><one:OE><one:Image format="png"><one:Data>' + $data + '</one:Data></one:Image></one:OE></one:OEChildren></one:Outline>' |
| 615 | $xml = "<one:Page xmlns:one=`"$namespace`" ID=`"$pageId`">$body</one:Page>" |
| 616 | Invoke-PageUpdate '05-image' $xml @{ page_id = $pageId; asset = 'assets\fictitious-image.png'; x = 144; y = 192; input = 'inputs\\05-image.xml' } |
| 617 | } |
| 618 | |
| 619 | Invoke-Step '06-attachment' 'Generated attachment in a positioned outline.' { |
| 620 | $pageId = Get-SolePageId |
| 621 | $body = '<one:Outline><one:Position x="264" y="192"/><one:OEChildren><one:OE><one:InsertedFile pathSource="' + [Security.SecurityElement]::Escape($attachmentPath) + '" preferredName="fictitious-attachment.txt"/></one:OE></one:OEChildren></one:Outline>' |
| 622 | $xml = "<one:Page xmlns:one=`"$namespace`" ID=`"$pageId`">$body</one:Page>" |
| 623 | Invoke-PageUpdate '06-attachment' $xml @{ page_id = $pageId; asset = 'assets\fictitious-attachment.txt'; x = 264; y = 192; input = 'inputs\\06-attachment.xml' } |
| 624 | } |
| 625 | |
| 626 | Invoke-Step '07-table' 'Two-cell native table.' { |
| 627 | $pageId = Get-SolePageId |
| 628 | $body = '<one:Outline><one:Position x="144" y="312"/><one:OEChildren><one:OE><one:Table bordersVisible="true"><one:Columns><one:Column index="0" width="37.11"/><one:Column index="1" width="37.11"/></one:Columns><one:Row><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Left cell]]></one:T></one:OE></one:OEChildren></one:Cell><one:Cell><one:OEChildren><one:OE><one:T><![CDATA[Right cell]]></one:T></one:OE></one:OEChildren></one:Cell></one:Row></one:Table></one:OE></one:OEChildren></one:Outline>' |
| 629 | $xml = "<one:Page xmlns:one=`"$namespace`" ID=`"$pageId`">$body</one:Page>" |
| 630 | Invoke-PageUpdate '07-table' $xml @{ page_id = $pageId; x = 144; y = 312; input = 'inputs\\07-table.xml'; kind = 'two_cell_table' } |
| 631 | } |
| 632 | |
| 633 | try { |
| 634 | $packagePath = Join-Path $root 'notebook.onepkg' |
| 635 | $script:app.Publish($script:openedNotebookId, $packagePath, 1, '') |
| 636 | $package = Wait-ForStableFile $packagePath |
| 637 | $capture = Capture-Notebook '08-onepkg' |
| 638 | Add-Fixture '08-onepkg' 'captured' 'Published OneNote package.' @{ package = 'notebook.onepkg'; bytes = $package.bytes; sha256 = $package.sha256; capture = $capture } |
| 639 | Add-Log 'publish_package' '08-onepkg' @{ package = 'notebook.onepkg'; bytes = $package.bytes; sha256 = $package.sha256 } |
| 640 | } |
| 641 | catch { |
| 642 | Add-Log 'fixture_failed' '08-onepkg' @{ error = $_.Exception.Message } |
| 643 | Add-Fixture '08-onepkg' 'failed' 'Published OneNote package.' @{ error = $_.Exception.Message } |
| 644 | } |
| 645 | } |
| 646 | |
| 647 | $script:manifest.status = if (@($script:manifest.fixtures | Where-Object { $_.status -eq 'failed' }).Count -gt 0) { 'completed-with-failures' } else { 'completed' } |
| 648 | $script:manifest | Add-Member -NotePropertyName completed_at_utc -NotePropertyValue ([DateTime]::UtcNow.ToString('o')) -Force |
| 649 | Save-Manifest |
| 650 | } |
| 651 | catch { |
| 652 | if ($null -ne $script:manifest) { |
| 653 | $script:manifest.status = 'failed' |
| 654 | $script:manifest | Add-Member -NotePropertyName failure -NotePropertyValue $_.Exception.Message -Force |
| 655 | Save-Manifest |
| 656 | } |
| 657 | throw |
| 658 | } |
| 659 | finally { |
| 660 | if ($null -ne $script:app -and -not [String]::IsNullOrWhiteSpace($script:openedNotebookId)) { |
| 661 | try { |
| 662 | $script:app.CloseNotebook($script:openedNotebookId, $false) |
| 663 | } |
| 664 | catch { |
| 665 | } |
| 666 | } |
| 667 | } |