diff --git a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 index d50820b8..117219d3 100644 --- a/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 +++ b/Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1 @@ -7,6 +7,7 @@ param( ) $ErrorActionPreference = "Stop" +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes Add-Type @' using System; using System.Runtime.InteropServices; @@ -105,9 +106,57 @@ function Wait-File([string] $path, [int] $seconds) { return $false } +function Invoke-ScrubbedCli([string] $name, [string] $root, [string] $support, [string[]] $arguments) { + $cliInfo = New-StartInfo $Cli $root + $cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support + foreach ($argument in $arguments) { [void]$cliInfo.ArgumentList.Add($argument) } + $cliInfo.RedirectStandardOutput = $true + $cliInfo.RedirectStandardError = $true + $cliProcess = [Diagnostics.Process]::new() + $cliProcess.StartInfo = $cliInfo + try { + if (-not $cliProcess.Start()) { throw "$name CLI '$($arguments[0])' could not start" } + $stdout = $cliProcess.StandardOutput.ReadToEndAsync() + $stderr = $cliProcess.StandardError.ReadToEndAsync() + if (-not $cliProcess.WaitForExit(10000)) { throw "$name CLI '$($arguments[0])' timed out" } + return [pscustomobject]@{ + ExitCode = $cliProcess.ExitCode + Output = $stdout.GetAwaiter().GetResult() + Error = $stderr.GetAwaiter().GetResult() + } + } finally { + if (-not $cliProcess.HasExited) { $cliProcess.Kill() } + $cliProcess.Dispose() + } +} + +function Wait-ProjectRow([int] $processId, [string] $projectName, [int] $seconds) { + $processCondition = [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::ProcessIdProperty, $processId) + $rowCondition = [Windows.Automation.AndCondition]::new( + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::ControlTypeProperty, + [Windows.Automation.ControlType]::ListItem), + [Windows.Automation.PropertyCondition]::new( + [Windows.Automation.AutomationElement]::NameProperty, $projectName)) + $deadline = [DateTime]::UtcNow.AddSeconds($seconds) + do { + $window = [Windows.Automation.AutomationElement]::RootElement.FindFirst( + [Windows.Automation.TreeScope]::Children, $processCondition) + if ($window) { + $row = $window.FindFirst([Windows.Automation.TreeScope]::Descendants, $rowCondition) + if ($row -and $row.Current.AutomationId.StartsWith("open-project-", [StringComparison]::Ordinal)) { + return $row.Current.AutomationId + } + } + Start-Sleep -Milliseconds 200 + } while ([DateTime]::UtcNow -lt $deadline) + return $null +} + function Invoke-Case( [string] $name, - [ValidateSet("escape", "skip", "complete", "marker")] + [ValidateSet("escape", "skip", "complete", "marker", "project")] [string] $action ) { $root = Join-Path $ScratchRoot $name @@ -116,9 +165,16 @@ function Invoke-Case( New-Item -ItemType Directory -Force -Path ` $support,(Join-Path $profile "AppData\Local"),(Join-Path $profile "AppData\Roaming"),(Join-Path $root "temp") | Out-Null - if ($action -eq "marker") { + if ($action -in @("marker", "project")) { Set-Content -LiteralPath (Join-Path $support "onboarding-seen") -Value "seen" -NoNewline } + # A plain folder, not a Git repository: startup must not need Git on PATH. + $projectPath = Join-Path $root "Core" + $projectRow = $null + if ($action -eq "project") { + New-Item -ItemType Directory -Force -Path $projectPath | Out-Null + Set-Content -LiteralPath (Join-Path $projectPath "README.md") -Value "fixture" -NoNewline + } $daemonProcess = [Diagnostics.Process]::new() $daemonInfo = New-StartInfo $Daemon $root @@ -127,9 +183,19 @@ function Invoke-Case( $shellProcess = [Diagnostics.Process]::new() $shellProcess.StartInfo = New-StartInfo $Shell $root try { - if (-not $daemonProcess.Start() -or -not $shellProcess.Start()) { - throw "$name could not start production processes" + if (-not $daemonProcess.Start()) { throw "$name could not start the production daemon" } + if ($action -eq "project") { + # Register through the production daemon before the shell starts, as the + # native folder picker does, so the shell restores a real daemon graph. + $deadline = [DateTime]::UtcNow.AddSeconds(15) + do { + $status = Invoke-ScrubbedCli $name $root $support @("status", $projectPath) + if ($status.ExitCode -eq 0) { break } + Start-Sleep -Milliseconds 250 + } while ([DateTime]::UtcNow -lt $deadline) + if ($status.ExitCode -ne 0) { throw "$name could not register the project: $($status.Error)" } } + if (-not $shellProcess.Start()) { throw "$name could not start the production shell" } [void]$daemonProcess.Handle [void]$shellProcess.Handle $keys = @($shellProcess.StartInfo.Environment.Keys | Sort-Object) @@ -144,7 +210,7 @@ function Invoke-Case( } $onboarding = Wait-NativeWindow $shellProcess.Id "GraphCodeWindowsOnboarding" 10 - if ($action -ne "marker") { + if ($action -notin @("marker", "project")) { if ($onboarding -eq [IntPtr]::Zero) { throw "$name onboarding was not shown" } switch ($action) { "escape" { @@ -179,6 +245,18 @@ function Invoke-Case( if ($main -eq [IntPtr]::Zero -or $shellProcess.HasExited -or $daemonProcess.HasExited) { throw "$name did not retain live shell/daemon after onboarding" } + if ($action -eq "project") { + $projectRow = Wait-ProjectRow $shellProcess.Id "Core" 20 + if (-not $projectRow) { throw "$name did not show the registered daemon project row" } + $survivalDeadline = [DateTime]::UtcNow.AddSeconds(5) + do { + $shellProcess.Refresh() + if ($shellProcess.HasExited) { + throw "$name shell exited with 0x$($shellProcess.ExitCode.ToString('X8')) after showing the project" + } + Start-Sleep -Milliseconds 100 + } while ([DateTime]::UtcNow -lt $survivalDeadline) + } $markerPath = Join-Path $support "onboarding-seen" if (-not (Wait-File $markerPath 10)) { throw "$name did not persist the onboarding marker" @@ -187,24 +265,13 @@ function Invoke-Case( $log = if (Test-Path $logPath) { Get-Content $logPath -Raw } else { "" } if ($log -match 'event=fatal') { throw "$name logged a fatal startup event: $log" } - $cliInfo = New-StartInfo $Cli $root - $cliInfo.Environment["GRAPHCODE_SUPPORT_DIR"] = $support - [void]$cliInfo.ArgumentList.Add("projects") - $cliInfo.RedirectStandardOutput = $true - $cliInfo.RedirectStandardError = $true - $cliProcess = [Diagnostics.Process]::new() - $cliProcess.StartInfo = $cliInfo - try { - if (-not $cliProcess.Start() -or -not $cliProcess.WaitForExit(10000)) { - throw "$name CLI endpoint check timed out" - } - $stdout = $cliProcess.StandardOutput.ReadToEnd() - $stderr = $cliProcess.StandardError.ReadToEnd() - if ($cliProcess.ExitCode -ne 0) { - throw "$name CLI endpoint rejected the request: $stderr" - } - } finally { - $cliProcess.Dispose() + $projects = Invoke-ScrubbedCli $name $root $support @("projects") + if ($projects.ExitCode -ne 0) { + throw "$name CLI endpoint rejected the request: $($projects.Error)" + } + $stdout = $projects.Output + if ($action -eq "project" -and $stdout -notmatch "(?m)^Core\s") { + throw "$name CLI does not list the registered project" } return [ordered]@{ name = $name @@ -214,6 +281,7 @@ function Invoke-Case( shellProcessId = $shellProcess.Id daemonProcessId = $daemonProcess.Id cliOutput = $stdout.Trim() + projectRow = $projectRow environmentKeys = $keys fatalLogAbsent = $true } @@ -235,7 +303,8 @@ $results = @( Invoke-Case "first-run-skip" "skip" Invoke-Case "first-run-complete" "complete" Invoke-Case "onboarding-marker" "marker" + Invoke-Case "registered-project" "project" ) -if ($results.Count -ne 4) { throw "Scrubbed startup executed $($results.Count)/4 cases" } +if ($results.Count -ne 5) { throw "Scrubbed startup executed $($results.Count)/5 cases" } Write-Output ("SCRUBBED_SHELL_STARTUP: PASS; executed=$($results.Count); " + "developerToolsExcluded=true; results=" + ($results | ConvertTo-Json -Compress -Depth 5)) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 4c9163fe..1cadb0c3 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -249,6 +249,14 @@ Assert-Contract ($actualAllowedKeys -notcontains "USERNAME" -and $scrubbedStartupSource -match 'onboarding-marker' -and $scrubbedStartupSource -match 'event=fatal') ` "scrubbed production startup gate must preserve the developer-free onboarding contract" +$daemonRoundTripSource = Get-Content (Join-Path $shellRoot "src\DaemonRoundTripTests.zig") -Raw +Assert-Contract ($scrubbedStartupSource -match 'Invoke-Case "registered-project" "project"' -and + $scrubbedStartupSource -match 'Wait-ProjectRow' -and + $scrubbedStartupSource -match '"status", \$projectPath' -and + $scrubbedStartupSource -match 'executed \$\(\$results\.Count\)/5 cases' -and + $daemonRoundTripSource -notmatch 'modelCompatibleFrame' -and + $daemonRoundTripSource -match 'self\.model\.updateFromFrame\(frame\)') ` + "production daemon graph frames must reach the shell model unmodified and render a registered project" $menuTimerBlock = [regex]::Match( $appSource, '(?s)else if \(wparam == MainWindow\.timer_id\) \{.*?const updated_connection_state' diff --git a/graphcode-windows/src/DaemonRoundTripTests.zig b/graphcode-windows/src/DaemonRoundTripTests.zig index 6d2d3596..3d328165 100644 --- a/graphcode-windows/src/DaemonRoundTripTests.zig +++ b/graphcode-windows/src/DaemonRoundTripTests.zig @@ -101,31 +101,12 @@ const Probe = struct { } self.last_success = true; } - const model_frame = modelCompatibleFrame(allocator, frame) catch { - self.model_error = true; - return; - }; - defer allocator.free(model_frame); - _ = self.model.updateFromFrame(model_frame) catch { + _ = self.model.updateFromFrame(frame) catch { self.model_error = true; }; } }; -fn modelCompatibleFrame(test_allocator: std.mem.Allocator, frame: []const u8) ![]u8 { - const parsed = try std.json.parseFromSlice(std.json.Value, test_allocator, frame, .{}); - defer parsed.deinit(); - if (parsed.value != .object) return test_allocator.dupe(u8, frame); - const event = parsed.value.object.get("event") orelse return test_allocator.dupe(u8, frame); - if (event != .object) return test_allocator.dupe(u8, frame); - const graph_changed = event.object.get("graphChanged") orelse return test_allocator.dupe(u8, frame); - if (graph_changed != .object) return test_allocator.dupe(u8, frame); - const graph = graph_changed.object.get("_0") orelse return test_allocator.dupe(u8, frame); - const graph_json = try std.json.Stringify.valueAlloc(test_allocator, graph, .{}); - defer test_allocator.free(graph_json); - return std.fmt.allocPrint(test_allocator, "{{\"graphChanged\":{s}}}", .{graph_json}); -} - fn envOwned(name: []const u8) ![]u8 { return std.process.getEnvVarOwned(allocator, name) catch error.MissingDaemonRoundTripEnvironment; } @@ -166,6 +147,9 @@ fn connectProject(client: *DaemonClient, probe: *Probe, path: []const u8) !void const canonical_path = try std.mem.replaceOwned(u8, allocator, path, "\\", "/"); defer allocator.free(canonical_path); try std.testing.expectEqualStrings(canonical_path, probe.model.currentGraph().?.project.path); + const expected_name = std.fs.path.basename(canonical_path); + try std.testing.expect(expected_name.len != 0); + try std.testing.expectEqualStrings(expected_name, probe.model.currentGraph().?.project.name); } fn waitForAcceptedResponse(client: *DaemonClient, probe: *Probe, before: usize) !void { diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 34fee883..79652f84 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -2192,6 +2192,7 @@ fn drawText( ) void { const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; defer allocator.free(wide); + if (wide.len == 0) return; const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); @@ -2211,6 +2212,7 @@ fn drawTextRect( ) void { const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text_value) catch return; defer allocator.free(wide); + if (wide.len == 0) return; const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); diff --git a/graphcode-windows/src/GraphModel.zig b/graphcode-windows/src/GraphModel.zig index 0f35c39a..c31c6519 100644 --- a/graphcode-windows/src/GraphModel.zig +++ b/graphcode-windows/src/GraphModel.zig @@ -2150,7 +2150,16 @@ fn decodeGraphFrame(allocator: std.mem.Allocator, frame: []const u8) !?Graph { defer event.deinit(); const value = if (root.has("event")) event.get("graphChanged") else root.get("graphChanged"); if (value.isNull()) return null; - return try decodeGraphObject(allocator, null, value.container('{') orelse return error.MalformedJson); + const payload = value.container('{') orelse return error.MalformedJson; + // The daemon's synthesized Swift coding wraps the unlabeled associated value as + // {"_0":}; the unwrapped form remains accepted for existing fixtures. + var wrapper = try JsonFields.init(allocator, payload); + defer wrapper.deinit(); + const graph_json = if (wrapper.has("_0")) + wrapper.get("_0").container('{') orelse return error.MalformedJson + else + payload; + return try decodeGraphObject(allocator, null, graph_json); } fn decodeGraphObject(allocator: std.mem.Allocator, parent_project: ?Project, json: []const u8) !Graph { @@ -3584,6 +3593,23 @@ test "field scope node and edge scalars do not inherit arbitrary nested fields" try std.testing.expectEqual(@as(u32, 0), edge.fire_count); } +test "production daemon graphChanged associated-value frame decodes project and nodes" { + // Exact shape emitted by the Swift daemon's synthesized DaemonEvent coding: + // `case graphChanged(LoopGraph)` travels as {"graphChanged":{"_0":}}. + const frame = + \\{"kind":"event","sequence":1,"event":{"graphChanged":{"_0":{"edges":[],"nodes":[{"id":"core-node","title":"Core loop"}],"revision":0,"id":"B566EEB2-0E4E-4162-A067-787F8D7A8306","project":{"name":"Core","lastOpenedAt":812912680.0330639,"path":"C:\/GraphCode-Fixtures\/Core"},"mailroomDigest":{"count":0,"fingerprint":14695981039346656037,"latestID":0}}}}} + ; + var model = Model.init(std.testing.allocator); + defer model.deinit(); + try std.testing.expectEqual(Wire.EventKind.graph_changed, try model.updateFromFrame(frame)); + const summary = model.graphFor("C:/GraphCode-Fixtures/Core") orelse return error.ProductionGraphProjectMissing; + try std.testing.expectEqualStrings("Core", summary.project.name); + try std.testing.expectEqual(@as(usize, 1), summary.nodes.items.len); + try std.testing.expectEqualStrings("Core loop", summary.nodes.items[0].title); + try std.testing.expectEqual(@as(usize, 1), model.graphs.items.len); + try std.testing.expectEqualStrings("C:/GraphCode-Fixtures/Core", model.graph.?.project.path); +} + test "field scope keys and delimiters inside scalar text cannot select graph fields" { const frame = \\{"metadata":{"graphChanged":{"project":{"path":"wrong"},"nodes":[],"edges":[]}},"event":{"graphChanged":{"note":"\"nodes\": [{\"id\":\"fake\"}], \"edges\": [{}] } \\ \u2603","nodes":[{"note":"\"id\":\"fake\" } [","id" : "real\u2603","title" : "Brace } quote \" slash \\ \uD83D\uDE80"}],"edges":[],"project":{"note":{"path":"wrong"},"path" : "right","name":"Root"}}}} diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 8f276a75..90947649 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -1392,6 +1392,7 @@ fn drawTile(state: *DialogState, tile_index: usize, draw_item: *c.DRAWITEMSTRUCT fn formDrawText(hdc: c.HDC, text: []const u8, bounds_value: c.RECT, size: i32, color: u32, bold: bool) void { const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.c_allocator, text) catch return; defer std.heap.c_allocator.free(wide); + if (wide.len == 0) return; const old_font = AppFont.selectForDpi(hdc, size, bold); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index 89960bb0..339d45f1 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -1703,6 +1703,30 @@ test "error footer exposes an inset wrapping rect" { try std.testing.expect(text.bottom < footer.bottom); } +test "sidebar paints an open project whose daemon name is empty" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + const frame = + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\unnamed","name":""},"nodes":[{"id":"n1","title":"","state":"idle"}],"edges":[]}}} + ; + _ = try model.updateFromFrame(frame); + try std.testing.expectEqualStrings("", model.graph.?.project.name); + var state = State.init(allocator); + defer state.deinit(); + const screen = c.GetDC(null) orelse return error.SkipZigTest; + defer _ = c.ReleaseDC(null, screen); + const hdc = c.CreateCompatibleDC(screen) orelse return error.TestUnexpectedResult; + defer _ = c.DeleteDC(hdc); + const bitmap = c.CreateCompatibleBitmap(screen, 400, 800) orelse return error.TestUnexpectedResult; + defer _ = c.DeleteObject(bitmap); + const previous = c.SelectObject(hdc, bitmap); + defer _ = c.SelectObject(hdc, previous); + var draw_allocator = std.heap.DebugAllocator(.{}){}; + defer std.debug.assert(draw_allocator.deinit() == .ok); + draw(hdc, &model, null, "", 0, "", 800, "", "", &state, -1, draw_allocator.allocator()); +} + fn rect(left: i32, top: i32, right: i32, bottom: i32) c.RECT { return .{ .left = left, .top = top, .right = right, .bottom = bottom }; } @@ -1726,6 +1750,7 @@ fn drawText( ) void { const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; defer allocator.free(wide); + if (wide.len == 0) return; const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); @@ -1745,6 +1770,7 @@ fn drawTextRect( ) void { const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; defer allocator.free(wide); + if (wide.len == 0) return; const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 843e521c..db185fe4 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -3806,6 +3806,7 @@ fn fillRect(hdc: c.HDC, bounds: c.RECT, color: u32) void { fn drawUtf8(hdc: c.HDC, text: []const u8, x: i32, y: i32, size: i32, color: u32) void { const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text) catch return; defer std.heap.page_allocator.free(wide); + if (wide.len == 0) return; const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); diff --git a/graphcode-windows/src/WindowsOnboarding.zig b/graphcode-windows/src/WindowsOnboarding.zig index df7c4e50..845c1a09 100644 --- a/graphcode-windows/src/WindowsOnboarding.zig +++ b/graphcode-windows/src/WindowsOnboarding.zig @@ -531,6 +531,7 @@ fn text( ) void { const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, value) catch return; defer allocator.free(wide); + if (wide.len == 0) return; const face = std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI"); const font = c.CreateFontW( -size, diff --git a/graphcode-windows/src/WindowsProductSettings.zig b/graphcode-windows/src/WindowsProductSettings.zig index 43a9e1b0..0323452c 100644 --- a/graphcode-windows/src/WindowsProductSettings.zig +++ b/graphcode-windows/src/WindowsProductSettings.zig @@ -601,6 +601,7 @@ fn deviceContext(address: usize) c.HDC { fn settingsText(hdc: c.HDC, value: []const u8, bounds_value: c.RECT, size: i32, color: u32, bold: bool) void { const wide = std.unicode.utf8ToUtf16LeAlloc(settings_state.allocator, value) catch return; defer settings_state.allocator.free(wide); + if (wide.len == 0) return; const face = std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI"); const font = c.CreateFontW(-size, 0, 0, 0, if (bold) c.FW_SEMIBOLD else c.FW_NORMAL, 0, 0, 0, c.DEFAULT_CHARSET, c.OUT_DEFAULT_PRECIS, c.CLIP_DEFAULT_PRECIS, c.CLEARTYPE_QUALITY, c.DEFAULT_PITCH | c.FF_DONTCARE, face.ptr); const old_font = if (font != null) c.SelectObject(hdc, font) else null;