|
| 1 | +// Copyright (c) Microsoft Corporation and Contributors. All rights reserved. |
| 2 | +// Licensed under the MIT License. |
| 3 | + |
| 4 | +using System.Globalization; |
| 5 | + |
| 6 | +namespace WinApp.Cli.Tests; |
| 7 | + |
| 8 | +/// <summary> |
| 9 | +/// Integration tests verifying that the Program-level gating logic correctly |
| 10 | +/// suppresses update notifications for --json, --quiet, and --cli-schema modes, |
| 11 | +/// and that --caller plumbs through to the notification hint text. |
| 12 | +/// Tests that exercise suppression invoke Program.Main directly. |
| 13 | +/// Tests that verify notification content use the service layer with env vars |
| 14 | +/// matching what Program.Main would set. |
| 15 | +/// </summary> |
| 16 | +[TestClass] |
| 17 | +[DoNotParallelize] // Modifies static Console streams and environment variables |
| 18 | +public class UpdateNotificationGatingTests |
| 19 | +{ |
| 20 | + private string _tempCacheDir = null!; |
| 21 | + private string? _savedCacheDir; |
| 22 | + private string? _savedCaller; |
| 23 | + private string? _savedUpdateCheck; |
| 24 | + |
| 25 | + [TestInitialize] |
| 26 | + public void Setup() |
| 27 | + { |
| 28 | + // Create temp cache directory and seed with a "newer" version |
| 29 | + _tempCacheDir = Path.Combine(Path.GetTempPath(), $"winapp_gating_test_{Guid.NewGuid():N}"); |
| 30 | + Directory.CreateDirectory(_tempCacheDir); |
| 31 | + SeedUpdateCheckCache("99.0.0"); |
| 32 | + |
| 33 | + // Create first-run marker so FirstRunService doesn't trigger logging |
| 34 | + File.Create(Path.Combine(_tempCacheDir, ".first-run-complete")).Dispose(); |
| 35 | + |
| 36 | + // Save and override env vars |
| 37 | + _savedCacheDir = Environment.GetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY"); |
| 38 | + _savedCaller = Environment.GetEnvironmentVariable("WINAPP_CLI_CALLER"); |
| 39 | + _savedUpdateCheck = Environment.GetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK"); |
| 40 | + |
| 41 | + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", _tempCacheDir); |
| 42 | + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", null); |
| 43 | + Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", null); |
| 44 | + |
| 45 | + // Clear CI vars to avoid suppression |
| 46 | + Environment.SetEnvironmentVariable("CI", null); |
| 47 | + Environment.SetEnvironmentVariable("GITHUB_ACTIONS", null); |
| 48 | + Environment.SetEnvironmentVariable("TF_BUILD", null); |
| 49 | + } |
| 50 | + |
| 51 | + [TestCleanup] |
| 52 | + public void Cleanup() |
| 53 | + { |
| 54 | + Environment.SetEnvironmentVariable("WINAPP_CLI_CACHE_DIRECTORY", _savedCacheDir); |
| 55 | + Environment.SetEnvironmentVariable("WINAPP_CLI_CALLER", _savedCaller); |
| 56 | + Environment.SetEnvironmentVariable("WINAPP_CLI_UPDATE_CHECK", _savedUpdateCheck); |
| 57 | + |
| 58 | + try { Directory.Delete(_tempCacheDir, recursive: true); } catch { /* best effort */ } |
| 59 | + } |
| 60 | + |
| 61 | + [TestMethod] |
| 62 | + public async Task JsonMode_SuppressesUpdateNotice_StdoutHasNoNotice() |
| 63 | + { |
| 64 | + var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--json"]); |
| 65 | + |
| 66 | + Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), |
| 67 | + $"--json stdout must not contain update notice. Got stdout: {stdout}"); |
| 68 | + Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), |
| 69 | + $"--json stderr must not contain update notice. Got stderr: {stderr}"); |
| 70 | + } |
| 71 | + |
| 72 | + [TestMethod] |
| 73 | + public async Task QuietMode_SuppressesUpdateNotice() |
| 74 | + { |
| 75 | + var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--quiet"]); |
| 76 | + |
| 77 | + Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), |
| 78 | + $"--quiet stdout must not contain update notice. Got stdout: {stdout}"); |
| 79 | + Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), |
| 80 | + $"--quiet stderr must not contain update notice. Got stderr: {stderr}"); |
| 81 | + } |
| 82 | + |
| 83 | + [TestMethod] |
| 84 | + public async Task CliSchemaMode_SuppressesUpdateNotice() |
| 85 | + { |
| 86 | + var (stdout, stderr, _) = await InvokeProgramAsync(["--cli-schema"]); |
| 87 | + |
| 88 | + Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), |
| 89 | + $"--cli-schema stdout must not contain update notice. Got stdout: {stdout}"); |
| 90 | + Assert.IsFalse(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), |
| 91 | + $"--cli-schema stderr must not contain update notice. Got stderr: {stderr}"); |
| 92 | + } |
| 93 | + |
| 94 | + [TestMethod] |
| 95 | + public async Task NormalMode_ShowsUpdateNotice_OnStderr() |
| 96 | + { |
| 97 | + // Invoke through the real entrypoint — the notification should appear on stderr, |
| 98 | + // never stdout. We capture stderr via Console.SetError. |
| 99 | + var (stdout, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global"]); |
| 100 | + |
| 101 | + Assert.IsFalse(stdout.Contains("available", StringComparison.OrdinalIgnoreCase), |
| 102 | + $"Update notice must not appear on stdout. Got stdout: {stdout}"); |
| 103 | + Assert.IsTrue(stderr.Contains("available", StringComparison.OrdinalIgnoreCase), |
| 104 | + $"Update notice should appear on stderr in normal mode. Got stderr: {stderr}"); |
| 105 | + } |
| 106 | + |
| 107 | + [TestMethod] |
| 108 | + public async Task CallerNpm_ProducesNpmHint() |
| 109 | + { |
| 110 | + // --caller npm should set WINAPP_CLI_CALLER=npm which makes the update notice |
| 111 | + // include the npm update hint. |
| 112 | + var (_, stderr, _) = await InvokeProgramAsync(["get-winapp-path", "--global", "--caller", "npm"]); |
| 113 | + |
| 114 | + Assert.IsTrue(stderr.Contains("npm update", StringComparison.OrdinalIgnoreCase), |
| 115 | + $"With --caller npm, notice should contain npm update hint. Got stderr: {stderr}"); |
| 116 | + } |
| 117 | + |
| 118 | + /// <summary> |
| 119 | + /// Seeds the .update-check file with a timestamp (now) and a specified "latest" version |
| 120 | + /// so the notification fires immediately without needing network access. |
| 121 | + /// </summary> |
| 122 | + private void SeedUpdateCheckCache(string version) |
| 123 | + { |
| 124 | + var content = $"{DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture)}\n{version}\n"; |
| 125 | + File.WriteAllText(Path.Combine(_tempCacheDir, ".update-check"), content); |
| 126 | + } |
| 127 | + |
| 128 | + /// <summary> |
| 129 | + /// Invokes Program.Main with captured stdout/stderr. |
| 130 | + /// Writers are intentionally not disposed to avoid ObjectDisposedException from |
| 131 | + /// Spectre.Console's static AnsiConsole.Console which may reference them after return. |
| 132 | + /// </summary> |
| 133 | + private static async Task<(string Stdout, string Stderr, int ExitCode)> InvokeProgramAsync(string[] args) |
| 134 | + { |
| 135 | + var originalOut = Console.Out; |
| 136 | + var originalErr = Console.Error; |
| 137 | + |
| 138 | + var stdoutWriter = new StringWriter(); |
| 139 | + var stderrWriter = new StringWriter(); |
| 140 | + |
| 141 | + try |
| 142 | + { |
| 143 | + Console.SetOut(stdoutWriter); |
| 144 | + Console.SetError(stderrWriter); |
| 145 | + |
| 146 | + var exitCode = await Program.Main(args); |
| 147 | + |
| 148 | + return (stdoutWriter.ToString(), stderrWriter.ToString(), exitCode); |
| 149 | + } |
| 150 | + finally |
| 151 | + { |
| 152 | + Console.SetOut(originalOut); |
| 153 | + Console.SetError(originalErr); |
| 154 | + } |
| 155 | + } |
| 156 | +} |
0 commit comments