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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 93 additions & 24 deletions Tools/windows/Tests/ScrubbedShellStartup.Live.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ param(
)

$ErrorActionPreference = "Stop"
Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes
Add-Type @'
using System;
using System.Runtime.InteropServices;
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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" {
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -214,6 +281,7 @@ function Invoke-Case(
shellProcessId = $shellProcess.Id
daemonProcessId = $daemonProcess.Id
cliOutput = $stdout.Trim()
projectRow = $projectRow
environmentKeys = $keys
fatalLogAbsent = $true
}
Expand All @@ -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))
8 changes: 8 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
24 changes: 4 additions & 20 deletions graphcode-windows/src/DaemonRoundTripTests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions graphcode-windows/src/GraphCanvas.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
28 changes: 27 additions & 1 deletion graphcode-windows/src/GraphModel.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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":<graph>}; 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 {
Expand Down Expand Up @@ -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":<graph>}}.
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"}}}}
Expand Down
1 change: 1 addition & 0 deletions graphcode-windows/src/NativeForms.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions graphcode-windows/src/Sidebar.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Expand All @@ -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);
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions graphcode-windows/src/TerminalSurface.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions graphcode-windows/src/WindowsOnboarding.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions graphcode-windows/src/WindowsProductSettings.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading