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
6 changes: 6 additions & 0 deletions NuGet.config
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="uno-toolkit-theme-listener" value="tools/vendor/uno-toolkit-theme-listener" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
<packageSource key="uno-toolkit-theme-listener">
<package pattern="Uno.CommunityToolkit.Common" />
<package pattern="Uno.CommunityToolkit.WinUI" />
<package pattern="Uno.CommunityToolkit.WinUI.UI" />
</packageSource>
</packageSourceMapping>
</configuration>
3 changes: 3 additions & 0 deletions SalmonEgg/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@
<PackageVersion Include="Uno.Extensions.Reactive.WinUI" Version="7.3.6" />
<PackageVersion Include="Uno.Extensions.Reactive.Messaging" Version="7.3.6" />
<PackageVersion Include="Uno.CommunityToolkit.WinUI.UI.Controls.Markdown" Version="7.1.206" />
<!-- Upstream #242 supports Skia windows without CoreWindow. Remove this pin once a
published Toolkit release includes it; original CI packages and provenance are in tools/vendor. -->
<PackageVersion Include="Uno.CommunityToolkit.WinUI.UI" Version="7.1.207-dev.4.g15b1194ecb" />
<PackageVersion Include="Uno.Fonts.Fluent" Version="2.9.4" />
<PackageVersion Include="Uno.Fonts.OpenSans" Version="2.9.4" />
<!-- Uno 6.7 Skia X11 requires Tmds.DBus.Protocol >= 0.92.0; pin a current stable version centrally. -->
Expand Down
4 changes: 4 additions & 0 deletions SalmonEgg/SalmonEgg/MainPage.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,10 @@ private async void OnMainPageLoaded(object sender, RoutedEventArgs e)
// sampling remain owned by the independent probe; the page only exposes the live shell root.
NumberBoxThemeProbeDriver.TryStart(App.ServiceProvider, this);

// Diagnostics-only Markdown ThemeListener lifecycle probe. The seeded mixed conversation
// owns its content; the probe only exercises load, theme, reactivation, unload and reload.
MarkdownThemeProbeDriver.TryStart(App.ServiceProvider, this);

// The opt-in setup probe owns navigation and sampling; the shell only supplies its live root.
AcpSetupRuntimePathProbeDriver.TryStart(App.ServiceProvider, this);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using SalmonEgg.Controls;
using SalmonEgg.Presentation.ViewModels.Navigation;

namespace SalmonEgg.Presentation.Diagnostics;

/// <summary>
/// Diagnostics-only runtime probe for the Skia Markdown ThemeListener lifecycle regression.
/// </summary>
/// <remarks>
/// The Toolkit ThemeListener historically dereferenced <c>Window.Current.CoreWindow</c>, which does
/// not exist on Skia desktop windows; loading or disposing a Markdown control could then raise a
/// dispatcher exception. The driver exercises the real seeded Markdown conversation end to end —
/// materialized presenter, theme toggle, native window reactivation, authoritative selection switch
/// away (unload) and back (reload) — and reports only observable UI facts. It is compiled out of
/// Release behavior and inert unless <c>SALMONEGG_MARKDOWN_THEME_PROBE=1</c>.
/// </remarks>
internal static class MarkdownThemeProbeDriver
{
private const string EnableVariable = "SALMONEGG_MARKDOWN_THEME_PROBE";

// Mirrors SkiaDesktopGuiSeedWriter's constants; the app cannot reference the test-support
// assembly, and the shell gate hardcodes the same ids when seeding.
private const string MixedConversationId = "skia-mixed-session-01";
private const string PlainConversationId = "skia-plain-session-02";
private const string ProjectId = "project-1";

private const int StepTimeoutMilliseconds = 15000;
private const int PollDelayMilliseconds = 50;

#if DEBUG && __UNO_SKIA__
private static int _started;
#endif

public static void TryStart(IServiceProvider services, DependencyObject shellRoot)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(shellRoot);

#if DEBUG && __UNO_SKIA__
if (!string.Equals(Environment.GetEnvironmentVariable(EnableVariable), "1", StringComparison.Ordinal)
|| Interlocked.Exchange(ref _started, 1) != 0)
{
return;
}

var navigation = services.GetRequiredService<MainNavigationViewModel>();
_ = RunAsync(navigation, shellRoot);
#endif
}

#if DEBUG && __UNO_SKIA__
private static async Task RunAsync(MainNavigationViewModel navigation, DependencyObject shellRoot)
{
var loaded = false;
var themeToggled = false;
var reactivated = false;
var unloaded = false;
var reloaded = false;

try
{
App.BootLog("MarkdownThemeProbe: started");

// The NumberBox probe drives the content frame into settings and back; it owns the
// frame until it completes. Wait for it instead of racing it for navigation.
await NumberBoxThemeProbeDriver.Completion.WaitAsync(TimeSpan.FromSeconds(120)).ConfigureAwait(true);

// Re-assert the seeded conversation through the authoritative selection so the chat
// transcript is on screen regardless of where the frame was left.
if (!await navigation.ActivateSessionAsync(MixedConversationId, ProjectId).ConfigureAwait(true))
{
throw new InvalidOperationException(
$"The markdown conversation '{MixedConversationId}' did not activate.");
}

var firstPresenter = await WaitUntilAsync(
() => FindRenderedPresenter(shellRoot), StepTimeoutMilliseconds).ConfigureAwait(true);
loaded = firstPresenter is not null;
App.BootLog($"MarkdownThemeProbe: step=loaded rendered={loaded}");

if (loaded)
{
themeToggled = await ToggleThemeAsync().ConfigureAwait(true);
App.BootLog($"MarkdownThemeProbe: step=theme toggled={themeToggled}");

reactivated = ReactivateWindow();
App.BootLog($"MarkdownThemeProbe: step=reactivate activated={reactivated}");

// Switch the authoritative selection away: the plain sibling conversation has no
// markdown rows, so the presenter must unload and dispose with its ThemeListener.
if (!await navigation
.ActivateSessionAsync(PlainConversationId, ProjectId).ConfigureAwait(true))
{
throw new InvalidOperationException(
$"The plain sibling conversation '{PlainConversationId}' did not activate.");
}

var gone = await WaitUntilAsync(
() => FindRenderedPresenter(shellRoot) is null, StepTimeoutMilliseconds).ConfigureAwait(true);
unloaded = gone;
App.BootLog($"MarkdownThemeProbe: step=unload gone={unloaded}");

if (!await navigation
.ActivateSessionAsync(MixedConversationId, ProjectId).ConfigureAwait(true))
{
throw new InvalidOperationException(
$"The markdown conversation '{MixedConversationId}' did not re-activate.");
}

var secondPresenter = await WaitUntilAsync(
() =>
{
var candidate = FindRenderedPresenter(shellRoot);
return candidate is not null && !ReferenceEquals(candidate, firstPresenter)
? candidate
: null;
},
StepTimeoutMilliseconds).ConfigureAwait(true);
reloaded = secondPresenter is not null;
App.BootLog($"MarkdownThemeProbe: step=reload recreated={reloaded}");
}

var passed = loaded && themeToggled && reactivated && unloaded && reloaded;
App.BootLog(
$"MarkdownThemeProbe: complete loaded={loaded} themeToggled={themeToggled}"
+ $" reactivated={reactivated} unloaded={unloaded} reloaded={reloaded} passed={passed}");
}
catch (Exception ex)
{
// A dispatcher or layout fault anywhere in the lifecycle is the regression signature
// the gate exists to catch; the absence of the complete marker fails the gate.
App.BootLog($"MarkdownThemeProbe: faulted loaded={loaded} themeToggled={themeToggled}"
+ $" reactivated={reactivated} unloaded={unloaded} reloaded={reloaded} exception={ex}");
throw;
}
}

private static MarkdownTextPresenter? FindRenderedPresenter(DependencyObject root)
=> Find<MarkdownTextPresenter>(root, static presenter =>
presenter.ActualHeight > 0 && presenter.Visibility == Visibility.Visible);

private static async Task<bool> ToggleThemeAsync()
{
if (App.MainWindowInstance?.Content is not FrameworkElement root)
{
return false;
}

var original = root.RequestedTheme;
var opposite = original is ElementTheme.Light ? ElementTheme.Dark : ElementTheme.Light;
root.RequestedTheme = opposite;
await AwaitNextFrameAsync().ConfigureAwait(true);

// The ThemeListener reacts on its own dispatcher subscription; give the theme change a
// chance to propagate before restoring the preference.
await Task.Delay(150).ConfigureAwait(true);
var toggled = root.RequestedTheme == opposite;

if (root.RequestedTheme != original)
{
root.RequestedTheme = original;
await AwaitNextFrameAsync().ConfigureAwait(true);
}

return toggled;
}

private static bool ReactivateWindow()
{
if (App.MainWindowInstance is not { } window) return false;
window.Activate();
return true;
}

private static async Task AwaitNextFrameAsync()
{
var rendered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnRendered(object? sender, object args) => rendered.TrySetResult();
CompositionTarget.Rendering += OnRendered;
try
{
await rendered.Task.ConfigureAwait(true);
}
finally
{
CompositionTarget.Rendering -= OnRendered;
}
}

private static async Task<T?> WaitUntilAsync<T>(Func<T?> probe, int timeoutMilliseconds)
where T : class
{
using var cancellation = new CancellationTokenSource(timeoutMilliseconds);
while (true)
{
if (probe() is { } result) return result;
await Task.Delay(PollDelayMilliseconds, cancellation.Token).ConfigureAwait(true);
}
}

private static async Task<bool> WaitUntilAsync(Func<bool> condition, int timeoutMilliseconds)
{
using var cancellation = new CancellationTokenSource(timeoutMilliseconds);
while (!condition())
{
await Task.Delay(PollDelayMilliseconds, cancellation.Token).ConfigureAwait(true);
}

return true;
}

private static T? Find<T>(DependencyObject root, Func<T, bool>? predicate = null)
where T : class, DependencyObject
{
predicate ??= static _ => true;
if (root is T match && predicate(match)) return match;
for (var index = 0; index < VisualTreeHelper.GetChildrenCount(root); index++)
{
if (Find(VisualTreeHelper.GetChild(root, index), predicate) is { } descendant) return descendant;
}

return null;
}
#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ internal static class NumberBoxThemeProbeDriver
private const double MinimumContrastRatio = 4.5;
#if DEBUG && __UNO_SKIA__
private static int _started;
private static readonly TaskCompletionSource s_completion = new(TaskCreationOptions.RunContinuationsAsynchronously);

/// <summary>Completes when the probe run finishes, succeeded or faulted; lets sibling probes
/// that drive navigation wait instead of racing this one for the content frame.</summary>
internal static Task Completion => s_completion.Task;
#endif

public static void TryStart(IServiceProvider services, DependencyObject shellRoot)
Expand Down Expand Up @@ -90,6 +95,7 @@ private static async Task RunAsync(MainNavigationViewModel navigation, Dependenc
$"NumberBoxThemeProbe: complete samples={result.CompletedSamples}"
+ $" valueUnchanged={result.ValueUnchanged} passed={result.Passed}"
+ $" reason={result.FailureReason}");
s_completion.TrySetResult();
}
}

Expand Down Expand Up @@ -369,6 +375,11 @@ private static async Task<RenderedColors> TryCaptureRenderedColorsAsync(
/// reason. A bare <c>null</c> cannot distinguish an empty Skia frame from an off-viewport rect,
/// a translucent offscreen composition or an ambiguous cluster, so every exit reports its cause.
/// </summary>
// Uno0001: the analyzer's not-implemented listing marks the whole RenderTargetBitmap surface,
// yet the Skia runtime demonstrably renders and reports real pixels here (the probe's inliers
// capture and passing contrast samples are real pixels), so the listing is stale for this
// target. Silence it for this capture method rather than weakening the sampler.
#pragma warning disable Uno0001
private static async Task<BackgroundCapture> TryCaptureRenderedBackgroundAsync(
Border borderElement,
ScrollViewer? scrollHost)
Expand Down Expand Up @@ -441,6 +452,7 @@ private static async Task<BackgroundCapture> TryCaptureRenderedBackgroundAsync(
bounds.Y * scaleY,
bounds.Width * scaleX,
bounds.Height * scaleY));
#pragma warning restore Uno0001
}

/// <summary>
Expand Down
1 change: 1 addition & 0 deletions SalmonEgg/SalmonEgg/SalmonEgg.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@

<ItemGroup Condition="'$(TargetFramework)' != 'net10.0-windows10.0.26100.0'">
<PackageReference Include="Uno.CommunityToolkit.WinUI.UI.Controls.Markdown" />
<PackageReference Include="Uno.CommunityToolkit.WinUI.UI" />
<PackageReference Include="Uno.Extensions.Reactive.WinUI" />
<PackageReference Include="Uno.WinUI.Lottie" />
</ItemGroup>
Expand Down
31 changes: 28 additions & 3 deletions scripts/gates/run-skia-desktop-gui-smoke-gates.sh
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ WINDOW_CREATED_TIMEOUT_SECONDS=120
TRANSCRIPT_SEED_CONVERSATION_ID="skia-mixed-session-01"
TRANSCRIPT_SEED_MARKER="SKIA_MD_MARKER_7f3a"
NUMBERBOX_PROBE_COMPLETE_MARKER="NumberBoxThemeProbe: complete"
MARKDOWN_PROBE_COMPLETE_MARKER="MarkdownThemeProbe: complete"

DOTNET_BIN="${DOTNET_BIN:-$(command -v dotnet || true)}"
GIT_BIN="${GIT_BIN:-$(command -v git || true)}"
Expand Down Expand Up @@ -185,10 +186,10 @@ case "${OS_NAME}" in
if [ -z "${SMOKE_DISPLAY}" ]; then
start_xvfb "${XVFB_BIN}"
fi
LAUNCH_COMMAND=(env DISPLAY="${SMOKE_DISPLAY}" SALMONEGG_GUI=1 SALMONEGG_NUMBERBOX_THEME_PROBE=1 SALMONEGG_APPDATA_ROOT="${APPDATA_ROOT}" "${APP_PATH}")
LAUNCH_COMMAND=(env DISPLAY="${SMOKE_DISPLAY}" SALMONEGG_GUI=1 SALMONEGG_NUMBERBOX_THEME_PROBE=1 SALMONEGG_MARKDOWN_THEME_PROBE=1 SALMONEGG_APPDATA_ROOT="${APPDATA_ROOT}" "${APP_PATH}")
;;
Darwin)
LAUNCH_COMMAND=(env SALMONEGG_GUI=1 SALMONEGG_NUMBERBOX_THEME_PROBE=1 SALMONEGG_APPDATA_ROOT="${APPDATA_ROOT}" "${APP_PATH}")
LAUNCH_COMMAND=(env SALMONEGG_GUI=1 SALMONEGG_NUMBERBOX_THEME_PROBE=1 SALMONEGG_MARKDOWN_THEME_PROBE=1 SALMONEGG_APPDATA_ROOT="${APPDATA_ROOT}" "${APP_PATH}")
;;
*)
echo "Skia Desktop GUI smoke supports Linux and macOS. Use scripts/gates/run-gui-smoke-gates.ps1 for Windows WinUI/FlaUI." >&2
Expand Down Expand Up @@ -263,6 +264,7 @@ deadline=$((SECONDS + 60))
shell_ready=0
transcript_ready=0
numberbox_probe_complete=0
markdown_probe_complete=0
while [ "${SECONDS}" -lt "${deadline}" ]; do
if ! kill -0 "${APP_PID}" 2>/dev/null; then
cat "${STDOUT_LOG}" >&2
Expand All @@ -287,11 +289,17 @@ while [ "${SECONDS}" -lt "${deadline}" ]; do
&& grep -Fq "${NUMBERBOX_PROBE_COMPLETE_MARKER}" "${BOOT_LOG}"; then
numberbox_probe_complete=1
fi

if [ "${markdown_probe_complete}" -eq 0 ] \
&& grep -Fq "${MARKDOWN_PROBE_COMPLETE_MARKER}" "${BOOT_LOG}"; then
markdown_probe_complete=1
fi
fi

if [ "${shell_ready}" -eq 1 ] \
&& [ "${transcript_ready}" -eq 1 ] \
&& [ "${numberbox_probe_complete}" -eq 1 ]; then
&& [ "${numberbox_probe_complete}" -eq 1 ] \
&& [ "${markdown_probe_complete}" -eq 1 ]; then
break
fi

Expand Down Expand Up @@ -325,6 +333,23 @@ if [ "${numberbox_probe_complete}" -ne 1 ]; then
exit 1
fi

if [ "${markdown_probe_complete}" -ne 1 ]; then
cat "${STDOUT_LOG}" >&2
if [ -f "${BOOT_LOG}" ]; then
cat "${BOOT_LOG}" >&2
fi
echo "Skia Desktop GUI smoke did not complete the Markdown ThemeListener lifecycle probe." >&2
exit 1
fi

# The probe logs its complete line even when a lifecycle step failed; a bare marker grep would
# pass on "passed=False". Require the passing verdict on the complete line itself.
if ! grep -F "MarkdownThemeProbe: complete" "${BOOT_LOG}" | grep -Fq "passed=True"; then
cat "${BOOT_LOG}" >&2
echo "Skia Desktop GUI smoke markdown lifecycle probe completed without a passing verdict." >&2
exit 1
fi

numberbox_sample_count="$(grep -acE 'NumberBoxThemeProbe: sample=[0-9]+' "${BOOT_LOG}" || true)"
if [ "${numberbox_sample_count}" -lt 3 ]; then
cat "${BOOT_LOG}" >&2
Expand Down
Loading
Loading